diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e0e067..695a091 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,30 @@ jobs: --deselect tests/test_gradle.py \ --deselect tests/test_haskell.py + ts-dsl: + name: harmont-ts (vitest + tsc) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: dsls/harmont-ts/package-lock.json + + - name: Install + working-directory: dsls/harmont-ts + run: npm ci + + - name: Type check + working-directory: dsls/harmont-ts + run: npx tsc --noEmit + + - name: Test + working-directory: dsls/harmont-ts + run: npm test + integration: name: docker-gated integration test runs-on: ubuntu-latest diff --git a/crates/hm-pipeline-ir/tests/e2e_fixtures.rs b/crates/hm-pipeline-ir/tests/e2e_fixtures.rs new file mode 100644 index 0000000..3258f78 --- /dev/null +++ b/crates/hm-pipeline-ir/tests/e2e_fixtures.rs @@ -0,0 +1,250 @@ +#![allow( + clippy::cargo_common_metadata, + clippy::multiple_crate_versions, + clippy::unwrap_used, + clippy::expect_used, + clippy::panic +)] + +use std::collections::BTreeSet; +use std::fs; +use std::path::PathBuf; + +use daggy::petgraph::visit::{EdgeRef, IntoNodeReferences}; +use hm_pipeline_ir::{EdgeKind, PipelineGraph}; + +const SCENARIOS: &[&str] = &[ + "monorepo-ci", + "rust-release", + "zig-node-polyglot", + "kitchen-sink", +]; + +fn fixtures_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/e2e/fixtures") +} + +fn load_fixture(dsl: &str, scenario: &str) -> PipelineGraph { + let path = fixtures_dir().join(dsl).join(format!("{scenario}.json")); + let bytes = + fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + serde_json::from_slice(&bytes) + .unwrap_or_else(|e| panic!("parse {dsl}/{scenario}: {e}")) +} + +fn step_labels(g: &PipelineGraph) -> BTreeSet { + g.dag() + .graph() + .node_references() + .filter_map(|(_, t)| t.step.label.clone()) + .collect() +} + +fn edge_kinds(g: &PipelineGraph) -> (usize, usize) { + let mut builds_in = 0usize; + let mut depends_on = 0usize; + for e in g.dag().graph().edge_references() { + match e.weight() { + EdgeKind::BuildsIn => builds_in += 1, + EdgeKind::DependsOn => depends_on += 1, + } + } + (builds_in, depends_on) +} + +// ---- Python fixtures ---- + +#[test] +fn python_monorepo_ci() { + let g = load_fixture("python", "monorepo-ci"); + assert_eq!(g.default_image(), Some("ubuntu:24.04")); + assert!(g.node_count() >= 15, "nodes: {}", g.node_count()); + let labels = step_labels(&g); + assert!(labels.iter().any(|l| l.contains("go"))); + assert!(labels.iter().any(|l| l.contains("python") || l.contains("uv"))); + assert!(labels.iter().any(|l| l.contains("node") || l.contains("npm"))); +} + +#[test] +fn python_rust_release() { + let g = load_fixture("python", "rust-release"); + assert_eq!(g.default_image(), Some("ubuntu:24.04")); + assert!(g.node_count() >= 5, "nodes: {}", g.node_count()); + let labels = step_labels(&g); + assert!(labels.iter().any(|l| l.contains("rust"))); +} + +#[test] +fn python_zig_node_polyglot() { + let g = load_fixture("python", "zig-node-polyglot"); + assert_eq!(g.default_image(), Some("ubuntu:24.04")); + assert!(g.node_count() >= 10, "nodes: {}", g.node_count()); + let labels = step_labels(&g); + assert!(labels.iter().any(|l| l.contains("zig"))); + assert!(labels.iter().any(|l| l.contains("node") || l.contains("npm"))); +} + +#[test] +fn python_kitchen_sink() { + let g = load_fixture("python", "kitchen-sink"); + assert_eq!(g.default_image(), Some("ubuntu:24.04")); + assert!(g.node_count() >= 12, "nodes: {}", g.node_count()); + let labels = step_labels(&g); + assert!(labels.iter().any(|l| l.contains("haskell"))); + assert!(labels.iter().any(|l| l.contains("cmake") || l.contains(":c:"))); + for (_, t) in g.dag().graph().node_references() { + assert!(t.env.contains_key("CI"), "node {} missing CI env", t.step.key); + } +} + +// ---- TypeScript fixtures ---- + +#[test] +fn ts_monorepo_ci() { + let g = load_fixture("ts", "monorepo-ci"); + assert_eq!(g.default_image(), Some("ubuntu:24.04")); + assert!(g.node_count() >= 15); +} + +#[test] +fn ts_rust_release() { + let g = load_fixture("ts", "rust-release"); + assert_eq!(g.default_image(), Some("ubuntu:24.04")); + assert!(g.node_count() >= 5); +} + +#[test] +fn ts_zig_node_polyglot() { + let g = load_fixture("ts", "zig-node-polyglot"); + assert_eq!(g.default_image(), Some("ubuntu:24.04")); + assert!(g.node_count() >= 10); +} + +#[test] +fn ts_kitchen_sink() { + let g = load_fixture("ts", "kitchen-sink"); + assert_eq!(g.default_image(), Some("ubuntu:24.04")); + assert!(g.node_count() >= 12); +} + +// ---- Structural invariants on all fixtures ---- + +#[test] +fn all_fixtures_have_valid_structure() { + for dsl in ["python", "ts"] { + for scenario in SCENARIOS { + let g = load_fixture(dsl, scenario); + + for (_, t) in g.dag().graph().node_references() { + assert!(!t.step.key.is_empty(), "{dsl}/{scenario}: empty key"); + assert!( + !t.step.cmd.is_empty(), + "{dsl}/{scenario}: empty cmd for {}", + t.step.key, + ); + } + + let (bi, dep) = edge_kinds(&g); + assert!(bi + dep > 0, "{dsl}/{scenario}: no edges"); + + for e in g.dag().graph().edge_references() { + assert_ne!( + e.source(), + e.target(), + "{dsl}/{scenario}: self-loop", + ); + } + } + } +} + +// ---- Cross-DSL parity ---- + +#[test] +fn parity_node_count() { + for scenario in SCENARIOS { + let py = load_fixture("python", scenario); + let ts = load_fixture("ts", scenario); + assert_eq!( + py.node_count(), + ts.node_count(), + "parity/{scenario}: node count (py={}, ts={})", + py.node_count(), + ts.node_count(), + ); + } +} + +#[test] +fn parity_edge_kinds() { + for scenario in SCENARIOS { + let py = load_fixture("python", scenario); + let ts = load_fixture("ts", scenario); + let py_ek = edge_kinds(&py); + let ts_ek = edge_kinds(&ts); + assert_eq!( + py_ek, ts_ek, + "parity/{scenario}: edge kinds (py={py_ek:?}, ts={ts_ek:?})", + ); + } +} + +#[test] +fn parity_step_labels() { + for scenario in SCENARIOS { + let py = load_fixture("python", scenario); + let ts = load_fixture("ts", scenario); + let py_labels = step_labels(&py); + let ts_labels = step_labels(&ts); + assert_eq!( + py_labels, ts_labels, + "parity/{scenario}: labels\npy-only: {:?}\nts-only: {:?}", + py_labels.difference(&ts_labels).collect::>(), + ts_labels.difference(&py_labels).collect::>(), + ); + } +} + +#[test] +fn parity_default_image() { + for scenario in SCENARIOS { + let py = load_fixture("python", scenario); + let ts = load_fixture("ts", scenario); + assert_eq!( + py.default_image(), + ts.default_image(), + "parity/{scenario}: default_image", + ); + } +} + +#[test] +fn parity_env_keys() { + for scenario in SCENARIOS { + let py = load_fixture("python", scenario); + let ts = load_fixture("ts", scenario); + let py_labels = step_labels(&py); + let ts_labels = step_labels(&ts); + + for label in py_labels.intersection(&ts_labels) { + let py_env: BTreeSet<_> = py + .dag() + .graph() + .node_references() + .find(|(_, t)| t.step.label.as_deref() == Some(label)) + .map(|(_, t)| t.env.keys().cloned().collect()) + .unwrap(); + let ts_env: BTreeSet<_> = ts + .dag() + .graph() + .node_references() + .find(|(_, t)| t.step.label.as_deref() == Some(label)) + .map(|(_, t)| t.env.keys().cloned().collect()) + .unwrap(); + assert_eq!( + py_env, ts_env, + "parity/{scenario}/{label}: env keys", + ); + } + } +} diff --git a/crates/hm-pipeline-ir/tests/snapshots/schema_snapshot__command_step_schema_is_stable.snap b/crates/hm-pipeline-ir/tests/snapshots/schema_snapshot__command_step_schema_is_stable.snap index 7198ebe..f3d4d5c 100644 --- a/crates/hm-pipeline-ir/tests/snapshots/schema_snapshot__command_step_schema_is_stable.snap +++ b/crates/hm-pipeline-ir/tests/snapshots/schema_snapshot__command_step_schema_is_stable.snap @@ -1,11 +1,11 @@ --- source: crates/hm-pipeline-ir/tests/schema_snapshot.rs -assertion_line: 6 expression: schema --- { "$schema": "http://json-schema.org/draft-07/schema#", "title": "CommandStep", + "description": "A single build command within a pipeline.\n\nSerialized as a JSON object inside each graph node's `step` field. The `key` is the unique identifier used to reference this step in edges and log output.", "type": "object", "required": [ "cmd", @@ -13,9 +13,11 @@ expression: schema ], "properties": { "key": { + "description": "Unique identifier for this step within the pipeline.", "type": "string" }, "label": { + "description": "Human-readable label shown in build output.", "default": null, "type": [ "string", @@ -23,9 +25,11 @@ expression: schema ] }, "cmd": { + "description": "Shell command to execute inside the container.", "type": "string" }, "image": { + "description": "Docker image to boot from. Root steps without an image inherit `PipelineGraph::default_image`; child steps boot from their parent's committed snapshot.", "default": null, "type": [ "string", @@ -33,6 +37,7 @@ expression: schema ] }, "env": { + "description": "Per-step environment variables merged on top of the pipeline env.", "default": null, "type": [ "object", @@ -43,6 +48,7 @@ expression: schema } }, "timeout_seconds": { + "description": "Maximum wall-clock seconds before the step is killed.", "default": null, "type": [ "integer", @@ -52,6 +58,7 @@ expression: schema "minimum": 0.0 }, "cache": { + "description": "Cache configuration for this step's committed snapshot.", "default": null, "anyOf": [ { @@ -63,24 +70,30 @@ expression: schema ] }, "runner": { + "description": "Step-executor plugin name. `None` falls back to the default runner (Docker in the shipped configuration).", "type": [ "string", "null" ] }, - "runner_args": true + "runner_args": { + "description": "Plugin-specific extra fields passed verbatim to the runner." + } }, "definitions": { "Cache": { + "description": "Snapshot cache configuration for a step.", "type": "object", "required": [ "policy" ], "properties": { "policy": { + "description": "Cache policy name (e.g. `\"content-hash\"`).", "type": "string" }, "key": { + "description": "Explicit cache key override; derived from the step if absent.", "default": null, "type": [ "string", diff --git a/dsls/harmont-py/harmont/haskell.py b/dsls/harmont-py/harmont/haskell.py index 810133c..9e51a83 100644 --- a/dsls/harmont-py/harmont/haskell.py +++ b/dsls/harmont-py/harmont/haskell.py @@ -19,7 +19,6 @@ import re from dataclasses import dataclass -from pathlib import Path from typing import TYPE_CHECKING, Any, overload from ._toolchain import make_install_chain @@ -130,10 +129,7 @@ def package( if cache_paths is not None: paths = cache_paths else: - paths = ( - tuple(sorted(p.as_posix() for p in Path(path).glob("*.cabal"))) - + ((f"{path}/cabal.project",) if Path(path, "cabal.project").exists() else ()) - ) + paths = (f"{path}/*.cabal", f"{path}/cabal.project") deps = self.installed.sh( f"cabal update && cd {path} && cabal build all --only-dependencies", label=f":haskell: {path} deps", diff --git a/dsls/harmont-py/harmont/keygen.py b/dsls/harmont-py/harmont/keygen.py index 5fba539..f7285c3 100644 --- a/dsls/harmont-py/harmont/keygen.py +++ b/dsls/harmont-py/harmont/keygen.py @@ -113,8 +113,15 @@ def _resolve_policy( env_keys = policy.get("env_keys", []) return "ttl-" + str(bucket) + "-" + _sha256_hex(cmd + NUL + _env_subset(env_keys, env)) if kind == "on_change": - paths = sorted(policy["paths"]) - pre = "".join(_path_hash(base_path / p) + NUL for p in paths) + resolved: list[Path] = [] + for p in sorted(policy["paths"]): + if any(c in p for c in ("*", "?", "[")): + resolved.extend(sorted(base_path.glob(p))) + else: + full = base_path / p + if full.exists(): + resolved.append(full) + pre = "".join(_path_hash(r) + NUL for r in resolved) return "sha-" + _sha256_hex(pre) if kind == "compose": subs = policy["sub_policies"] diff --git a/dsls/harmont-py/tests/test_e2e_fixtures.py b/dsls/harmont-py/tests/test_e2e_fixtures.py new file mode 100644 index 0000000..eb48195 --- /dev/null +++ b/dsls/harmont-py/tests/test_e2e_fixtures.py @@ -0,0 +1,161 @@ +"""E2E fixture generation + validation. + +Renders 4 complex pipeline scenarios to v0 IR JSON and writes +committed fixtures for Rust deserialization tests. + +Regenerate: UPDATE_E2E_FIXTURES=1 pytest tests/test_e2e_fixtures.py -v +""" +from __future__ import annotations + +import json +import os +from datetime import timedelta +from pathlib import Path + +import pytest + +import harmont as hm +from harmont.cmake import cmake +from harmont.go import go +from harmont.haskell import haskell +from harmont.npm import npm +from harmont.python import python as python_tc +from harmont.rust import rust +from harmont.zig import zig + +REPO_ROOT = Path(__file__).resolve().parents[3] +FIXTURES_DIR = REPO_ROOT / "tests" / "e2e" / "fixtures" / "python" + + +def _render(ir: dict) -> str: + return json.dumps(ir, indent=2, sort_keys=True, ensure_ascii=False) + + +def _assert_fixture(name: str, ir: dict) -> None: + rendered = _render(ir) + fixture_path = FIXTURES_DIR / f"{name}.json" + + if os.environ.get("UPDATE_E2E_FIXTURES"): + fixture_path.write_text(rendered + "\n") + return + + assert fixture_path.exists(), ( + f"Fixture {fixture_path} missing — run with UPDATE_E2E_FIXTURES=1" + ) + expected = json.loads(fixture_path.read_text()) + actual = json.loads(rendered) + assert actual == expected, ( + f"Fixture drift for {name}. Regenerate with UPDATE_E2E_FIXTURES=1" + ) + + +def _build_monorepo_ci() -> dict: + go_project = go(path="services/api") + py_project = python_tc(path="services/ml") + web_project = npm(path="web") + + return hm.pipeline( + go_project.build(), + go_project.test(), + go_project.vet(), + py_project.test(), + py_project.lint(), + py_project.typecheck(), + web_project.run("build"), + web_project.run("test"), + web_project.run("lint"), + env={"CI": "true"}, + default_image="ubuntu:24.04", + ) + + +def _build_rust_release() -> dict: + project = rust(path=".") + + return hm.pipeline( + project.build(), + project.test(), + project.clippy(), + project.fmt(), + project.doc(), + env={"CI": "true"}, + default_image="ubuntu:24.04", + ) + + +def _build_zig_node_polyglot() -> dict: + base = hm.sh( + "apt-get update && apt-get install -y --no-install-recommends " + "curl ca-certificates xz-utils", + label=":apt: base", + cache=hm.ttl(timedelta(days=1)), + image="ubuntu:24.04", + ) + zig_tc = zig(base=base) + proj_a = zig_tc.project(path="zig-a") + proj_b = zig_tc.project(path="zig-b") + web = npm(path="web", base=base) + + return hm.pipeline( + proj_a.build(), + proj_a.test(), + proj_b.build(), + proj_b.test(), + web.run("build"), + web.run("test"), + web.run("lint"), + env={"CI": "true"}, + default_image="ubuntu:24.04", + ) + + +def _build_kitchen_sink() -> dict: + hs_tc = haskell(ghc="9.6.7") + pkg_a = hs_tc.cabal(path="pkg-a") + pkg_b = hs_tc.cabal(path="pkg-b") + + c_project = cmake(path="infra/agent", lang="c") + + return hm.pipeline( + pkg_a.build(), + pkg_a.test(), + pkg_b.build(), + pkg_b.test(), + pkg_b.hlint(), + pkg_b.fmt(), + c_project.build(), + c_project.test(), + c_project.fmt(), + env={"CI": "true", "STACK_ROOT": "/tmp/.stack"}, # noqa: S108 + default_image="ubuntu:24.04", + ) + + +SCENARIOS = { + "monorepo-ci": _build_monorepo_ci, + "rust-release": _build_rust_release, + "zig-node-polyglot": _build_zig_node_polyglot, + "kitchen-sink": _build_kitchen_sink, +} + + +@pytest.mark.parametrize("name", SCENARIOS.keys()) +def test_e2e_fixture(name: str) -> None: + ir = SCENARIOS[name]() + + assert ir["version"] == "0" + assert ir["default_image"] == "ubuntu:24.04" + assert len(ir["graph"]["nodes"]) > 0 + assert ir["graph"]["edge_property"] == "directed" + + for node in ir["graph"]["nodes"]: + assert "key" in node["step"] + assert "cmd" in node["step"] + assert isinstance(node["env"], dict) + + for src, dst, kind in ir["graph"]["edges"]: + assert kind in ("builds_in", "depends_on") + assert src < len(ir["graph"]["nodes"]) + assert dst < len(ir["graph"]["nodes"]) + + _assert_fixture(name, ir) diff --git a/dsls/harmont-py/tests/test_haskell.py b/dsls/harmont-py/tests/test_haskell.py index 7833b1c..f832f15 100644 --- a/dsls/harmont-py/tests/test_haskell.py +++ b/dsls/harmont-py/tests/test_haskell.py @@ -80,7 +80,7 @@ def test_haskell_package_deps_cache_default(): p = hm.pipeline(api.test()) deps = _step_by_substring(p, "cabal build all --only-dependencies") assert deps["cache"]["policy"] == "on_change" - assert deps["cache"]["paths"] == ["api/harmont-api.cabal", "api/cabal.project"] + assert deps["cache"]["paths"] == ["api/*.cabal", "api/cabal.project"] def test_haskell_package_deps_cache_default_no_cabal_project(): @@ -89,7 +89,7 @@ def test_haskell_package_deps_cache_default_no_cabal_project(): p = hm.pipeline(fs.test()) deps = _step_by_substring(p, "cabal build all --only-dependencies") assert deps["cache"]["policy"] == "on_change" - assert deps["cache"]["paths"] == ["freestyle/freestyle.cabal"] + assert deps["cache"]["paths"] == ["freestyle/*.cabal", "freestyle/cabal.project"] def test_haskell_package_deps_cache_explicit_paths(): diff --git a/dsls/harmont-py/tests/test_keygen.py b/dsls/harmont-py/tests/test_keygen.py index 4b56693..c889dde 100644 --- a/dsls/harmont-py/tests/test_keygen.py +++ b/dsls/harmont-py/tests/test_keygen.py @@ -209,7 +209,7 @@ def test_on_change_handles_directory_paths(): assert out2["nodes"][0]["step"]["cache"]["key"] != key1 -def test_on_change_missing_path_raises(): +def test_on_change_missing_path_skipped(): with tempfile.TemporaryDirectory() as d: graph = _make_graph([ { @@ -221,15 +221,15 @@ def test_on_change_missing_path_raises(): "env": {}, }, ]) - with pytest.raises(FileNotFoundError, match="on_change path does not exist"): - resolve_pipeline_keys( - graph, - pipeline_org="default", - pipeline_slug="default", - now=0, - base_path=Path(d), - env={}, - ) + resolve_pipeline_keys( + graph, + pipeline_org="default", + pipeline_slug="default", + now=0, + base_path=Path(d), + env={}, + ) + assert graph["nodes"][0]["step"]["cache"]["key"] is not None def test_env_keys_are_sorted_and_picked_up(): diff --git a/dsls/harmont-ts/CLAUDE.md b/dsls/harmont-ts/CLAUDE.md new file mode 100644 index 0000000..06eb7d8 --- /dev/null +++ b/dsls/harmont-ts/CLAUDE.md @@ -0,0 +1,28 @@ +# harmont (TypeScript DSL) + +TypeScript pipeline DSL — equivalent of `dsls/harmont-py/`. + +## Commands + +- `npm test` — run Vitest test suite +- `npm run build` — compile TypeScript to `dist/` + +## Architecture + +- `src/step.ts` — Step class (immutable chain primitive) +- `src/cache.ts` — Cache policy discriminated unions +- `src/triggers.ts` — Trigger factory functions +- `src/keys.ts` — Step key resolution (slug/hash) +- `src/pipeline.ts` — Lowering pass (step chains → petgraph IR) +- `src/target.ts` — Memoized reusable targets +- `src/envelope.ts` — Envelope rendering (schema_version:1) +- `src/toolchains/` — Language toolchain abstractions +- `src/index.ts` — Public API barrel export + +## IR Compatibility + +Output must match the v0 IR that `crates/hm-pipeline-ir/` deserializes. +The Rust `CommandStep` accepts: key, cmd, label?, image?, env?, timeout_seconds?, cache?, runner?, runner_args?. +The Rust `Cache` accepts: policy, key?. +Edge kinds: `builds_in`, `depends_on`. +Envelope: `{ schema_version: "1", pipelines: [...] }`. diff --git a/dsls/harmont-ts/package-lock.json b/dsls/harmont-ts/package-lock.json new file mode 100644 index 0000000..e3ce34f --- /dev/null +++ b/dsls/harmont-ts/package-lock.json @@ -0,0 +1,1628 @@ +{ + "name": "harmont", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "harmont", + "version": "0.1.0", + "devDependencies": { + "@types/node": "^25.9.1", + "typescript": "^5.8.0", + "vitest": "^3.2.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/dsls/harmont-ts/package.json b/dsls/harmont-ts/package.json new file mode 100644 index 0000000..100c747 --- /dev/null +++ b/dsls/harmont-ts/package.json @@ -0,0 +1,31 @@ +{ + "name": "harmont", + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./toolchains": { + "import": "./dist/toolchains/index.js", + "types": "./dist/toolchains/index.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "typescript": "^5.8.0", + "vitest": "^3.2.0" + }, + "engines": { + "node": ">=18" + } +} diff --git a/dsls/harmont-ts/src/cache.ts b/dsls/harmont-ts/src/cache.ts new file mode 100644 index 0000000..fc423c6 --- /dev/null +++ b/dsls/harmont-ts/src/cache.ts @@ -0,0 +1,41 @@ +export interface CacheForever { + readonly kind: "forever"; + readonly envKeys: readonly string[]; +} + +export interface CacheTTL { + readonly kind: "ttl"; + readonly durationSeconds: number; + readonly envKeys: readonly string[]; +} + +export interface CacheOnChange { + readonly kind: "on_change"; + readonly paths: readonly string[]; +} + +export interface CacheCompose { + readonly kind: "compose"; + readonly policies: readonly CachePolicy[]; +} + +export type CachePolicy = CacheForever | CacheTTL | CacheOnChange | CacheCompose; + +export function forever(opts?: { envKeys?: string[] }): CacheForever { + return { kind: "forever", envKeys: opts?.envKeys ?? [] }; +} + +export function ttl( + durationSeconds: number, + opts?: { envKeys?: string[] }, +): CacheTTL { + return { kind: "ttl", durationSeconds, envKeys: opts?.envKeys ?? [] }; +} + +export function onChange(...paths: string[]): CacheOnChange { + return { kind: "on_change", paths }; +} + +export function compose(...policies: CachePolicy[]): CacheCompose { + return { kind: "compose", policies }; +} diff --git a/dsls/harmont-ts/src/envelope.ts b/dsls/harmont-ts/src/envelope.ts new file mode 100644 index 0000000..eb43f2b --- /dev/null +++ b/dsls/harmont-ts/src/envelope.ts @@ -0,0 +1,37 @@ +import type { PipelineIR } from "./pipeline.js"; +import type { Trigger } from "./triggers.js"; + +export interface PipelineDefinition { + readonly slug: string; + readonly name?: string; + readonly allowManual?: boolean; + readonly triggers?: readonly Trigger[]; + readonly pipeline: PipelineIR; +} + +interface EnvelopeJSON { + schema_version: string; + pipelines: EnvelopePipelineJSON[]; +} + +interface EnvelopePipelineJSON { + slug: string; + name: string; + allow_manual: boolean; + triggers: Record[]; + definition: PipelineIR; +} + +export function renderEnvelope(definitions: readonly PipelineDefinition[]): string { + const envelope: EnvelopeJSON = { + schema_version: "1", + pipelines: definitions.map((def) => ({ + slug: def.slug, + name: def.name ?? def.slug, + allow_manual: def.allowManual ?? true, + triggers: (def.triggers ?? []).map((t) => t.toJSON()), + definition: def.pipeline, + })), + }; + return JSON.stringify(envelope); +} diff --git a/dsls/harmont-ts/src/index.ts b/dsls/harmont-ts/src/index.ts new file mode 100644 index 0000000..26987f1 --- /dev/null +++ b/dsls/harmont-ts/src/index.ts @@ -0,0 +1,24 @@ +export { Step, scratch, sh, wait, type StepOptions } from "./step.js"; +export { + type CachePolicy, + type CacheForever, + type CacheTTL, + type CacheOnChange, + type CacheCompose, + forever, + ttl, + onChange, + compose, +} from "./cache.js"; +export { + type Trigger, + PushTrigger, + PullRequestTrigger, + ScheduleTrigger, + push, + pullRequest, + schedule, +} from "./triggers.js"; +export { pipeline, type PipelineIR, type PipelineOptions } from "./pipeline.js"; +export { target, clearTargetCache } from "./target.js"; +export { renderEnvelope, type PipelineDefinition } from "./envelope.js"; diff --git a/dsls/harmont-ts/src/keys.ts b/dsls/harmont-ts/src/keys.ts new file mode 100644 index 0000000..75408b3 --- /dev/null +++ b/dsls/harmont-ts/src/keys.ts @@ -0,0 +1,84 @@ +import { createHash } from "node:crypto"; +import type { Step } from "./step.js"; + +const EMOJI_SHORTCODE_RE = /:[a-z0-9_+-]+:/g; +const NON_ALNUM_RE = /[^a-z0-9]+/g; + +export function slugifyLabel(label: string): string { + let s = label.toLowerCase(); + s = s.replace(EMOJI_SHORTCODE_RE, " "); + s = s.replace(NON_ALNUM_RE, "-"); + s = s.replace(/^-+|-+$/g, ""); + return s; +} + +export function hashKey(parentKey: string, cmd: string, position: number): string { + const h = createHash("sha256"); + h.update(parentKey, "utf8"); + h.update("\0"); + h.update(cmd, "utf8"); + h.update("\0"); + h.update(String(position), "utf8"); + return h.digest("hex").slice(0, 12); +} + +export function resolveKeys(steps: readonly Step[]): Map { + const overrides = new Map(); + const naturalSlugs = new Map(); + + for (const s of steps) { + if (s._keyOverride != null) { + overrides.set(s._id, s._keyOverride); + } + if (s._label != null) { + const slug = slugifyLabel(s._label); + if (slug) { + naturalSlugs.set(s._id, slug); + } + } + } + + const reserved = new Set(overrides.values()); + + const slugCounts = new Map(); + for (const slug of naturalSlugs.values()) { + slugCounts.set(slug, (slugCounts.get(slug) ?? 0) + 1); + } + + const labelSlugs = new Map(); + for (const [id, slug] of naturalSlugs) { + if (!overrides.has(id)) { + labelSlugs.set(id, slug); + } + } + + const keys = new Map(); + for (let position = 0; position < steps.length; position++) { + const s = steps[position]; + const sid = s._id; + + if (overrides.has(sid)) { + keys.set(sid, overrides.get(sid)!); + continue; + } + + const candidateSlug = labelSlugs.get(sid); + if ( + candidateSlug != null && + !reserved.has(candidateSlug) && + slugCounts.get(candidateSlug) === 1 + ) { + keys.set(sid, candidateSlug); + reserved.add(candidateSlug); + continue; + } + + let parentKey = ""; + if (s._parent != null && keys.has(s._parent._id)) { + parentKey = keys.get(s._parent._id)!; + } + keys.set(sid, hashKey(parentKey, s._cmd ?? "", position)); + } + + return keys; +} diff --git a/dsls/harmont-ts/src/pipeline.ts b/dsls/harmont-ts/src/pipeline.ts new file mode 100644 index 0000000..29674f0 --- /dev/null +++ b/dsls/harmont-ts/src/pipeline.ts @@ -0,0 +1,209 @@ +import type { CachePolicy } from "./cache.js"; +import { resolveKeys } from "./keys.js"; +import type { Step } from "./step.js"; + +export interface PipelineOptions { + readonly env?: Readonly>; + readonly defaultImage?: string; +} + +export interface PipelineIR { + version: string; + default_image?: string; + graph: { + nodes: GraphNode[]; + node_holes: never[]; + edge_property: "directed"; + edges: [number, number, string][]; + }; +} + +interface GraphNode { + step: Record; + env: Record; +} + +export function pipeline(...args: (Step | PipelineOptions)[]): PipelineIR { + if (args.length === 0) { + throw new Error( + "pipeline must have at least one leaf — pass the terminal step(s) of each branch as positional args", + ); + } + + let leaves: Step[]; + let opts: PipelineOptions | undefined; + + const last = args[args.length - 1]; + if (last && typeof last === "object" && !("_id" in last)) { + opts = last as PipelineOptions; + leaves = args.slice(0, -1) as Step[]; + } else { + leaves = args as Step[]; + } + + if (leaves.length === 0) { + throw new Error( + "pipeline must have at least one leaf — pass the terminal step(s) of each branch as positional args", + ); + } + + const ir: PipelineIR = { version: "0", graph: lowerToGraph(leaves, opts) }; + if (opts?.defaultImage != null) { + ir.default_image = opts.defaultImage; + } + return ir; +} + +function lowerToGraph( + leaves: Step[], + opts?: PipelineOptions, +): PipelineIR["graph"] { + const ordered = topoCollect(leaves); + const commandSteps = ordered.filter((s) => s._cmd !== null && !s._isWait); + const keys = resolveKeys(commandSteps); + + const idxById = new Map(); + for (let i = 0; i < commandSteps.length; i++) { + idxById.set(commandSteps[i]._id, i); + } + + const hasBuildsInParent = new Set(); + const nodes: GraphNode[] = []; + const edges: [number, number, string][] = []; + + let preWaitIndices: number[] = []; + let pendingDependsOn: number[] = []; + + for (const s of ordered) { + if (s._isWait) { + pendingDependsOn = [...preWaitIndices]; + preWaitIndices = []; + continue; + } + + if (s._cmd === null) continue; + + const nodeIdx = idxById.get(s._id)!; + const stepKey = keys.get(s._id)!; + + const stepDict: Record = { + key: stepKey, + cmd: s._cmd, + }; + if (s._label != null) stepDict.label = s._label; + if (s._cache != null) stepDict.cache = cachePolicyToDict(s._cache); + if (s._timeoutSeconds != null) stepDict.timeout_seconds = s._timeoutSeconds; + if (s._image != null) stepDict.image = s._image; + if (s._runner != null) stepDict.runner = s._runner; + if (s._runnerArgs != null) stepDict.runner_args = s._runnerArgs; + + const mergedEnv: Record = {}; + if (opts?.env) Object.assign(mergedEnv, opts.env); + if (s._env) Object.assign(mergedEnv, s._env); + + nodes.push({ step: stepDict, env: mergedEnv }); + + const parentKey = resolvedParentKey(s, keys); + if (parentKey !== null) { + const parentIdx = findIdxByKey(parentKey, commandSteps, keys, idxById); + edges.push([parentIdx, nodeIdx, "builds_in"]); + hasBuildsInParent.add(nodeIdx); + } + + for (const depIdx of pendingDependsOn) { + edges.push([depIdx, nodeIdx, "depends_on"]); + } + + preWaitIndices.push(nodeIdx); + } + + if (opts?.defaultImage != null) { + for (let i = 0; i < nodes.length; i++) { + if (!hasBuildsInParent.has(i) && !("image" in nodes[i].step)) { + nodes[i].step.image = opts.defaultImage; + } + } + } + + return { + nodes, + node_holes: [], + edge_property: "directed", + edges, + }; +} + +function topoCollect(leaves: Step[]): Step[] { + const seen = new Set(); + const ordered: Step[] = []; + + for (const leaf of leaves) { + if (leaf._isWait) { + ordered.push(leaf); + continue; + } + const chain: Step[] = []; + let node: Step | null = leaf; + while (node !== null) { + if (seen.has(node._id)) break; + chain.push(node); + node = node._parent; + } + for (let i = chain.length - 1; i >= 0; i--) { + const s = chain[i]; + if (seen.has(s._id)) continue; + seen.add(s._id); + ordered.push(s); + } + } + + return ordered; +} + +function resolvedParentKey( + s: Step, + keys: Map, +): string | null { + let node = s._parent; + while (node !== null) { + if (node._cmd !== null && !node._isWait) { + return keys.get(node._id) ?? null; + } + node = node._parent; + } + return null; +} + +function findIdxByKey( + key: string, + commandSteps: Step[], + keys: Map, + idxById: Map, +): number { + for (const s of commandSteps) { + if (keys.get(s._id) === key) { + return idxById.get(s._id)!; + } + } + throw new Error(`BUG: no step with key "${key}"`); +} + +function cachePolicyToDict(policy: CachePolicy): Record { + switch (policy.kind) { + case "forever": + return { policy: "forever", env_keys: [...policy.envKeys] }; + case "ttl": + return { + policy: "ttl", + duration_seconds: policy.durationSeconds, + env_keys: [...policy.envKeys], + }; + case "on_change": + return { policy: "on_change", paths: [...policy.paths] }; + case "compose": + return { + policy: "compose", + sub_policies: policy.policies.map(cachePolicyToDict), + }; + } +} diff --git a/dsls/harmont-ts/src/step.ts b/dsls/harmont-ts/src/step.ts new file mode 100644 index 0000000..2b95ce6 --- /dev/null +++ b/dsls/harmont-ts/src/step.ts @@ -0,0 +1,113 @@ +import type { CachePolicy } from "./cache.js"; + +export interface StepOptions { + readonly label?: string; + readonly cache?: CachePolicy; + readonly env?: Readonly>; + readonly timeoutSeconds?: number; + readonly image?: string; + readonly runner?: string; + readonly runnerArgs?: Readonly>; + readonly key?: string; + readonly cwd?: string; +} + +let nextId = 0; + +export class Step { + readonly _id: number; + readonly _cmd: string | null; + readonly _parent: Step | null; + readonly _isWait: boolean; + readonly _continueOnFailure: boolean; + readonly _label: string | undefined; + readonly _cache: CachePolicy | undefined; + readonly _env: Readonly> | undefined; + readonly _timeoutSeconds: number | undefined; + readonly _image: string | undefined; + readonly _runner: string | undefined; + readonly _runnerArgs: Readonly> | undefined; + readonly _keyOverride: string | undefined; + + /** @internal */ + constructor(init: { + cmd: string | null; + parent: Step | null; + isWait?: boolean; + continueOnFailure?: boolean; + label?: string; + cache?: CachePolicy; + env?: Record; + timeoutSeconds?: number; + image?: string; + runner?: string; + runnerArgs?: Record; + keyOverride?: string; + }) { + this._id = nextId++; + this._cmd = init.cmd; + this._parent = init.parent; + this._isWait = init.isWait ?? false; + this._continueOnFailure = init.continueOnFailure ?? false; + this._label = init.label; + this._cache = init.cache; + this._env = init.env; + this._timeoutSeconds = init.timeoutSeconds; + this._image = init.image; + this._runner = init.runner; + this._runnerArgs = init.runnerArgs; + this._keyOverride = init.keyOverride; + } + + sh(cmd: string, opts?: StepOptions): Step { + if (opts?.cwd === "") { + throw new Error( + 'hm: cwd must be a non-empty path\n → omit cwd to run in the workspace root, or pass cwd="some/dir"', + ); + } + const effectiveCmd = opts?.cwd != null ? `cd ${opts.cwd} && ${cmd}` : cmd; + const effectiveImage = + opts?.image != null + ? opts.image + : this._cmd === null + ? this._image + : undefined; + return new Step({ + cmd: effectiveCmd, + parent: this, + label: opts?.label, + cache: opts?.cache, + env: opts?.env, + timeoutSeconds: opts?.timeoutSeconds, + image: effectiveImage, + runner: opts?.runner, + runnerArgs: opts?.runnerArgs, + keyOverride: opts?.key, + }); + } + + fork(opts?: { label?: string }): Step { + return new Step({ + cmd: null, + parent: this, + label: opts?.label, + }); + } +} + +export function scratch(opts?: { image?: string }): Step { + return new Step({ cmd: null, parent: null, image: opts?.image }); +} + +export function sh(cmd: string, opts?: StepOptions): Step { + return scratch().sh(cmd, opts); +} + +export function wait(opts?: { continueOnFailure?: boolean }): Step { + return new Step({ + cmd: null, + parent: null, + isWait: true, + continueOnFailure: opts?.continueOnFailure ?? false, + }); +} diff --git a/dsls/harmont-ts/src/target.ts b/dsls/harmont-ts/src/target.ts new file mode 100644 index 0000000..0aa289d --- /dev/null +++ b/dsls/harmont-ts/src/target.ts @@ -0,0 +1,15 @@ +const cache = new Map(); + +export function target(_name: string, fn: () => T): () => T { + const key = Symbol(_name); + return () => { + if (!cache.has(key)) { + cache.set(key, fn()); + } + return cache.get(key) as T; + }; +} + +export function clearTargetCache(): void { + cache.clear(); +} diff --git a/dsls/harmont-ts/src/toolchains/cmake.ts b/dsls/harmont-ts/src/toolchains/cmake.ts new file mode 100644 index 0000000..47ca9d1 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/cmake.ts @@ -0,0 +1,86 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = [ + "build-essential", + "cmake", + "ninja-build", + "clang-format", +] as const; + +export interface CMakeOptions { + readonly path?: string; + readonly lang?: "c" | "cpp"; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class CMakeProject { + readonly path: string; + private readonly _installed: Step; + private readonly _tag: string; + + constructor(path: string, installed: Step, tag: string) { + this.path = path; + this._installed = installed; + this._tag = tag; + } + + install(): Step { + return this._installed; + } + + configure(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && cmake -S . -B build`, { + label: `:${this._tag}: configure`, + ...opts, + }); + } + + build(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && cmake -S . -B build && cmake --build build`, + { label: `:${this._tag}: build`, ...opts }, + ); + } + + test(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && cmake -S . -B build && cmake --build build && ctest --test-dir build --output-on-failure`, + { label: `:${this._tag}: test`, ...opts }, + ); + } + + fmt(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && find src tests -name '*.[ch]' -o -name '*.cpp' -o -name '*.hpp' | xargs clang-format --dry-run --Werror`, + { label: `:${this._tag}: fmt`, ...opts }, + ); + } +} + +export function cmake(opts?: CMakeOptions): CMakeProject { + const path = opts?.path ?? "."; + const lang = opts?.lang ?? "c"; + + if (lang !== "c" && lang !== "cpp") { + throw new Error( + `hm.cmake: invalid lang "${lang}"\n → use "c" or "cpp"`, + ); + } + + const installed = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd: "cmake --version && clang-format --version", + installCache: forever(), + langTag: lang, + installTag: "cmake-verify", + image: opts?.image, + base: opts?.base, + }); + + return new CMakeProject(path, installed, lang); +} diff --git a/dsls/harmont-ts/src/toolchains/composer.ts b/dsls/harmont-ts/src/toolchains/composer.ts new file mode 100644 index 0000000..2f0092e --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/composer.ts @@ -0,0 +1,89 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever, onChange } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = [ + "php-cli", + "php-mbstring", + "php-xml", + "php-curl", + "php-sqlite3", + "composer", + "git", + "unzip", +] as const; + +export interface ComposerOptions { + readonly path?: string; + readonly laravel?: boolean; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class ComposerProject { + readonly path: string; + private readonly _installed: Step; + private readonly _tag: string; + private readonly _laravel: boolean; + + constructor( + path: string, + installed: Step, + tag: string, + laravel: boolean, + ) { + this.path = path; + this._installed = installed; + this._tag = tag; + this._laravel = laravel; + } + + install(): Step { + return this._installed; + } + + test(opts?: ActionOptions): Step { + const cmd = this._laravel + ? `cd ${this.path} && php artisan test` + : `cd ${this.path} && vendor/bin/phpunit`; + return this._installed.sh(cmd, { + label: `:${this._tag}: test`, + ...opts, + }); + } + + lint(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && vendor/bin/phpstan analyse`, + { label: `:${this._tag}: lint`, ...opts }, + ); + } +} + +export function composer(opts?: ComposerOptions): ComposerProject { + const path = opts?.path ?? "."; + const laravel = opts?.laravel ?? false; + const tag = laravel ? "laravel" : "php"; + + const composerVerified = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd: "composer --version && php --version", + installCache: forever(), + langTag: tag, + installTag: "composer", + image: opts?.image, + base: opts?.base, + }); + + const deps = composerVerified.sh( + `cd ${path} && composer install --no-interaction --prefer-dist`, + { + label: `:${tag}: deps`, + cache: onChange(`${path}/composer.lock`), + }, + ); + + return new ComposerProject(path, deps, tag, laravel); +} diff --git a/dsls/harmont-ts/src/toolchains/dotnet.ts b/dsls/harmont-ts/src/toolchains/dotnet.ts new file mode 100644 index 0000000..3512f1b --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/dotnet.ts @@ -0,0 +1,81 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = ["curl", "ca-certificates", "libicu-dev"] as const; +const CHANNEL_RE = /^([0-9]+\.[0-9]+|LTS|STS)$/; + +export interface DotnetOptions { + readonly path?: string; + readonly channel?: string; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class DotnetProject { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + build(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && dotnet build`, { + label: ":dotnet: build", + ...opts, + }); + } + + test(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && dotnet test`, { + label: ":dotnet: test", + ...opts, + }); + } + + fmt(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && dotnet format --verify-no-changes`, + { label: ":dotnet: fmt", ...opts }, + ); + } +} + +export function dotnet(opts?: DotnetOptions): DotnetProject { + const path = opts?.path ?? "."; + const channel = opts?.channel ?? "8.0"; + + if (!CHANNEL_RE.test(channel)) { + throw new Error( + `hm.dotnet: invalid channel "${channel}"\n → use "8.0", "LTS", or "STS"`, + ); + } + + const installCmd = [ + "curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh", + "chmod +x /tmp/dotnet-install.sh", + `/tmp/dotnet-install.sh --channel ${channel} --install-dir /usr/local/dotnet`, + "ln -sf /usr/local/dotnet/dotnet /usr/local/bin/dotnet", + "dotnet --info", + ].join(" && "); + + const installed = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd, + installCache: forever(), + langTag: "dotnet", + installTag: "install", + image: opts?.image, + base: opts?.base, + }); + + return new DotnetProject(path, installed); +} diff --git a/dsls/harmont-ts/src/toolchains/elm.ts b/dsls/harmont-ts/src/toolchains/elm.ts new file mode 100644 index 0000000..dbfc954 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/elm.ts @@ -0,0 +1,102 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever } from "../cache.js"; +import { makeInstallChain, nodeInstallCmd } from "./shared.js"; + +const APT_PACKAGES = ["curl", "ca-certificates"] as const; +const ELM_VERSION_RE = /^[0-9]+(\.[0-9]+)+$/; +const NODE_VERSION_RE = /^[0-9]+(\.x)?$/; + +export interface ElmOptions { + readonly path?: string; + readonly elmVersion?: string; + readonly nodeVersion?: string; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class ElmProject { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + make(target: string, opts?: ActionOptions & { output?: string }): Step { + const outputFlag = opts?.output != null ? ` --output=${opts.output}` : ""; + return this._installed.sh( + `cd ${this.path} && elm make ${target}${outputFlag}`, + { label: `:elm: make ${target}`, ...opts }, + ); + } + + test(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && npx --yes elm-test`, { + label: ":elm: test", + ...opts, + }); + } + + review(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && npx --yes elm-review`, { + label: ":elm: review", + ...opts, + }); + } + + fmt(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && npx --yes elm-format --validate .`, + { label: ":elm: fmt", ...opts }, + ); + } +} + +export function elm(opts?: ElmOptions): ElmProject { + const path = opts?.path ?? "."; + const elmVersion = opts?.elmVersion ?? "0.19.1"; + const nodeVersion = opts?.nodeVersion ?? "20"; + + if (!ELM_VERSION_RE.test(elmVersion)) { + throw new Error( + `hm.elm: invalid elm version "${elmVersion}"\n → use a semver like "0.19.1"`, + ); + } + + if (!NODE_VERSION_RE.test(nodeVersion)) { + throw new Error( + `hm.elm: invalid node version "${nodeVersion}"\n → use a major version like "20"`, + ); + } + + const nodeInstalled = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd: nodeInstallCmd(nodeVersion), + installCache: forever(), + langTag: "elm", + installTag: "node", + image: opts?.image, + base: opts?.base, + }); + + const elmInstallCmd = [ + `curl -fsSL https://github.com/elm/compiler/releases/download/${elmVersion}/binary-for-linux-64-bit.gz -o /tmp/elm.gz`, + "gunzip /tmp/elm.gz", + "chmod +x /tmp/elm", + "mv /tmp/elm /usr/local/bin/elm", + ].join(" && "); + + const elmInstalled = nodeInstalled.sh(elmInstallCmd, { + label: ":elm: install", + cache: forever(), + }); + + return new ElmProject(path, elmInstalled); +} diff --git a/dsls/harmont-ts/src/toolchains/go.ts b/dsls/harmont-ts/src/toolchains/go.ts new file mode 100644 index 0000000..f04364a --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/go.ts @@ -0,0 +1,88 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = ["curl", "ca-certificates", "git"] as const; +const VERSION_RE = /^[0-9]+\.[0-9]+(\.[0-9]+)?$/; + +export interface GoOptions { + readonly path?: string; + readonly version?: string; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class GoToolchain { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + build(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && go build ./...`, { + label: ":go: build", + ...opts, + }); + } + + test(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && go test ./...`, { + label: ":go: test", + ...opts, + }); + } + + vet(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && go vet ./...`, { + label: ":go: vet", + ...opts, + }); + } + + fmt(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && test -z "$(gofmt -l .)"`, + { label: ":go: fmt", ...opts }, + ); + } +} + +export function go(opts?: GoOptions): GoToolchain { + const path = opts?.path ?? "."; + const version = opts?.version ?? "1.23.2"; + + if (!VERSION_RE.test(version)) { + throw new Error( + `hm.go: invalid version "${version}"\n → use a semver like "1.23" or "1.23.2"`, + ); + } + + const installCmd = [ + `curl -fsSL https://go.dev/dl/go${version}.linux-amd64.tar.gz -o /tmp/go.tgz`, + "rm -rf /usr/local/go && tar -C /usr/local -xzf /tmp/go.tgz", + "ln -sf /usr/local/go/bin/go /usr/local/bin/go", + "ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt", + "go version", + ].join(" && "); + + const installed = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd, + installCache: forever(), + langTag: "go", + installTag: "install", + image: opts?.image, + base: opts?.base, + }); + + return new GoToolchain(path, installed); +} diff --git a/dsls/harmont-ts/src/toolchains/gradle.ts b/dsls/harmont-ts/src/toolchains/gradle.ts new file mode 100644 index 0000000..b5bdb04 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/gradle.ts @@ -0,0 +1,93 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const GRADLE_VERSION = "8.10"; +const JDK_RE = /^(11|17|21)$/; + +export interface GradleOptions { + readonly path?: string; + readonly jdk?: string; + readonly kotlin?: boolean; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class GradleProject { + readonly path: string; + private readonly _installed: Step; + private readonly _tag: string; + + constructor(path: string, installed: Step, tag: string) { + this.path = path; + this._installed = installed; + this._tag = tag; + } + + install(): Step { + return this._installed; + } + + build(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && gradle build`, { + label: `:${this._tag}: build`, + ...opts, + }); + } + + test(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && gradle test`, { + label: `:${this._tag}: test`, + ...opts, + }); + } + + lint(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && gradle check`, { + label: `:${this._tag}: lint`, + ...opts, + }); + } +} + +export function gradle(opts?: GradleOptions): GradleProject { + const path = opts?.path ?? "."; + const jdk = opts?.jdk ?? "21"; + const kotlin = opts?.kotlin ?? false; + const tag = kotlin ? "kotlin" : "java"; + + if (!JDK_RE.test(jdk)) { + throw new Error( + `hm.gradle: invalid jdk "${jdk}"\n → use "11", "17", or "21"`, + ); + } + + const aptPackages = [ + "curl", + "ca-certificates", + "unzip", + `openjdk-${jdk}-jdk-headless`, + ]; + + const installCmd = [ + `curl -fsSL https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip -o /tmp/gradle.zip`, + "unzip -q /tmp/gradle.zip -d /opt", + `ln -sf /opt/gradle-${GRADLE_VERSION}/bin/gradle /usr/local/bin/gradle`, + "rm /tmp/gradle.zip", + "java -version && gradle --version", + ].join(" && "); + + const installed = makeInstallChain({ + aptPackages, + installCmd, + installCache: forever(), + langTag: tag, + installTag: "jdk", + image: opts?.image, + base: opts?.base, + }); + + return new GradleProject(path, installed, tag); +} diff --git a/dsls/harmont-ts/src/toolchains/haskell.ts b/dsls/harmont-ts/src/toolchains/haskell.ts new file mode 100644 index 0000000..a0775d3 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/haskell.ts @@ -0,0 +1,133 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever, onChange } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = [ + "curl", + "ca-certificates", + "build-essential", + "libgmp-dev", + "libffi-dev", + "libncurses-dev", + "zlib1g-dev", +] as const; +const GHC_RE = /^[a-zA-Z0-9.-]+$/; + +export interface HaskellOptions { + readonly ghc: string; + readonly cabal?: string; + readonly path?: string; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class HaskellToolchain { + private readonly _installed: Step; + + constructor(installed: Step) { + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + cabal(path: string = ".", opts?: { cachePaths?: readonly string[] }): HaskellPackage { + const cachePaths = opts?.cachePaths ?? [`${path}/*.cabal`]; + const depsStep = this._installed.sh( + `cabal update && cd ${path} && cabal build all --only-dependencies`, + { + label: `:haskell: ${path} deps`, + cache: onChange(...cachePaths), + }, + ); + return new HaskellPackage(path, depsStep); + } +} + +export class HaskellPackage { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + build(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && cabal build all`, { + label: `:haskell: ${this.path} build`, + ...opts, + }); + } + + test(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && cabal test all`, { + label: `:haskell: ${this.path} test`, + ...opts, + }); + } + + lint(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && cabal build all --flag werror`, + { label: `:haskell: ${this.path} lint`, ...opts }, + ); + } + + hlint(opts?: ActionOptions): Step { + return this._installed.sh(`hlint ${this.path}`, { + label: `:haskell: ${this.path} hlint`, + ...opts, + }); + } + + fmt(opts?: ActionOptions): Step { + return this._installed.sh(`fourmolu --mode check ${this.path}`, { + label: `:haskell: ${this.path} fmt`, + ...opts, + }); + } +} + +export function haskell(opts: HaskellOptions & { path: string }): HaskellPackage; +export function haskell(opts: HaskellOptions): HaskellToolchain; +export function haskell(opts: HaskellOptions): HaskellToolchain | HaskellPackage { + const ghc = opts.ghc; + const cabalVersion = opts.cabal ?? "latest"; + + if (!GHC_RE.test(ghc)) { + throw new Error( + `hm.haskell: invalid ghc version "${ghc}"\n → use a version like "9.6.7"`, + ); + } + + const installCmd = [ + "curl -fsSL https://downloads.haskell.org/~ghcup/x86_64-linux-ghcup -o /usr/local/bin/ghcup", + "chmod +x /usr/local/bin/ghcup", + `ghcup install ghc ${ghc} && ghcup install cabal ${cabalVersion}`, + `ghcup set ghc ${ghc} && ghcup set cabal ${cabalVersion}`, + "ln -sf /root/.ghcup/bin/* /usr/local/bin/", + "curl -fsSL https://github.com/fourmolu/fourmolu/releases/download/v0.18.0.0/fourmolu-0.18.0.0-linux-x86_64 -o /usr/local/bin/fourmolu", + "chmod +x /usr/local/bin/fourmolu", + ].join(" && "); + + const installed = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd, + installCache: forever(), + langTag: "haskell", + installTag: "ghcup", + image: opts.image, + base: opts.base, + }); + + const toolchain = new HaskellToolchain(installed); + return opts.path != null ? toolchain.cabal(opts.path) : toolchain; +} diff --git a/dsls/harmont-ts/src/toolchains/index.ts b/dsls/harmont-ts/src/toolchains/index.ts new file mode 100644 index 0000000..2de2c28 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/index.ts @@ -0,0 +1,28 @@ +export { npm, NpmProject, type NpmOptions } from "./npm.js"; +export { go, GoToolchain, type GoOptions } from "./go.js"; +export { rust, RustToolchain, type RustOptions } from "./rust.js"; +export { python, PythonToolchain, type PythonOptions } from "./python.js"; +export { cmake, CMakeProject, type CMakeOptions } from "./cmake.js"; +export { gradle, GradleProject, type GradleOptions } from "./gradle.js"; +export { dotnet, DotnetProject, type DotnetOptions } from "./dotnet.js"; +export { ruby, RubyProject, type RubyOptions } from "./ruby.js"; +export { perl, PerlProject, type PerlOptions } from "./perl.js"; +export { + composer, + ComposerProject, + type ComposerOptions, +} from "./composer.js"; +export { elm, ElmProject, type ElmOptions } from "./elm.js"; +export { + zig, + ZigToolchain, + ZigProject, + type ZigOptions, +} from "./zig.js"; +export { ocaml, OCamlProject, type OCamlOptions } from "./ocaml.js"; +export { + haskell, + HaskellToolchain, + HaskellPackage, + type HaskellOptions, +} from "./haskell.js"; diff --git a/dsls/harmont-ts/src/toolchains/npm.ts b/dsls/harmont-ts/src/toolchains/npm.ts new file mode 100644 index 0000000..c7e8407 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/npm.ts @@ -0,0 +1,79 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever, onChange } from "../cache.js"; +import { makeInstallChain, nodeInstallCmd } from "./shared.js"; + +const APT_PACKAGES = ["curl", "ca-certificates"] as const; +const VERSION_RE = /^[0-9]+(\.x)?$/; + +export interface NpmOptions { + readonly path?: string; + readonly version?: string; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class NpmProject { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + run(script: string, opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && npm run ${script}`, { + label: `:node: ${script}`, + ...opts, + }); + } + + test(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && npm test`, { + label: ":node: test", + ...opts, + }); + } + + lint(opts?: ActionOptions): Step { + return this.run("lint", opts); + } + + fmt(opts?: ActionOptions): Step { + return this.run("fmt", opts); + } +} + +export function npm(opts?: NpmOptions): NpmProject { + const path = opts?.path ?? "."; + const version = opts?.version ?? "20"; + + if (!VERSION_RE.test(version)) { + throw new Error( + `hm.npm: invalid version "${version}"\n → use a Node major version like "20" or "20.x"`, + ); + } + + const nodeInstalled = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd: nodeInstallCmd(version), + installCache: forever(), + langTag: "node", + installTag: "install", + image: opts?.image, + base: opts?.base, + }); + + const npmCi = nodeInstalled.sh(`cd ${path} && npm ci`, { + label: ":node: deps", + cache: onChange(`${path}/package-lock.json`), + }); + + return new NpmProject(path, npmCi); +} diff --git a/dsls/harmont-ts/src/toolchains/ocaml.ts b/dsls/harmont-ts/src/toolchains/ocaml.ts new file mode 100644 index 0000000..8bb1ae7 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/ocaml.ts @@ -0,0 +1,97 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = [ + "opam", + "build-essential", + "git", + "m4", + "unzip", + "bubblewrap", +] as const; +const COMPILER_RE = /^[0-9]+\.[0-9]+\.[0-9]+$/; + +export interface OCamlOptions { + readonly path?: string; + readonly compiler?: string; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class OCamlProject { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + build(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && opam exec -- dune build`, + { label: ":ocaml: build", ...opts }, + ); + } + + test(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && opam exec -- dune runtest`, + { label: ":ocaml: test", ...opts }, + ); + } + + fmt(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && opam exec -- dune build @fmt`, + { label: ":ocaml: fmt", ...opts }, + ); + } +} + +export function ocaml(opts?: OCamlOptions): OCamlProject { + const path = opts?.path ?? "."; + const compiler = opts?.compiler ?? "5.1.1"; + + if (!COMPILER_RE.test(compiler)) { + throw new Error( + `hm.ocaml: invalid compiler "${compiler}"\n → use a semver like "5.1.1"`, + ); + } + + const opamInitCmd = [ + "opam init -y --disable-sandboxing --bare", + `opam switch create ${compiler} ${compiler}`, + "eval $(opam env)", + "opam install -y dune ocamlformat", + ].join(" && "); + + const opamInstalled = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd: opamInitCmd, + installCache: forever(), + langTag: "ocaml", + installTag: "opam", + image: opts?.image, + base: opts?.base, + }); + + const depsCmd = [ + `cd ${path}`, + 'if ls *.opam >/dev/null 2>&1; then opam install -y . --deps-only --with-test; else echo "no .opam files; skipping deps"; fi', + ].join(" && "); + + const deps = opamInstalled.sh(depsCmd, { + label: ":ocaml: deps", + cache: forever(), + }); + + return new OCamlProject(path, deps); +} diff --git a/dsls/harmont-ts/src/toolchains/perl.ts b/dsls/harmont-ts/src/toolchains/perl.ts new file mode 100644 index 0000000..57100ce --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/perl.ts @@ -0,0 +1,65 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever, onChange } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = ["perl", "cpanminus", "build-essential"] as const; + +export interface PerlOptions { + readonly path?: string; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class PerlProject { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + test(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && prove -lv t/`, { + label: ":perl: test", + ...opts, + }); + } + + lint(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && perlcritic lib/`, { + label: ":perl: lint", + ...opts, + }); + } +} + +export function perl(opts?: PerlOptions): PerlProject { + const path = opts?.path ?? "."; + + const cpanmInstalled = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd: "cpanm --notest --quiet Perl::Critic && perl --version", + installCache: forever(), + langTag: "perl", + installTag: "cpanm", + image: opts?.image, + base: opts?.base, + }); + + const deps = cpanmInstalled.sh( + `cd ${path} && cpanm --installdeps --notest .`, + { + label: ":perl: deps", + cache: onChange(`${path}/cpanfile`), + }, + ); + + return new PerlProject(path, deps); +} diff --git a/dsls/harmont-ts/src/toolchains/python.ts b/dsls/harmont-ts/src/toolchains/python.ts new file mode 100644 index 0000000..78fc144 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/python.ts @@ -0,0 +1,98 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever, onChange } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = [ + "curl", + "ca-certificates", + "python3", + "python3-venv", +] as const; +const VERSION_RE = /^([0-9]+\.[0-9]+\.[0-9]+|latest)$/; + +export interface PythonOptions { + readonly path?: string; + readonly uvVersion?: string; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class PythonToolchain { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + test(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && uv run pytest`, { + label: ":python: test", + ...opts, + }); + } + + lint(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && uv run ruff check .`, { + label: ":python: lint", + ...opts, + }); + } + + fmt(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && uv run ruff format --check .`, + { label: ":python: fmt", ...opts }, + ); + } + + typecheck(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && uv run mypy .`, { + label: ":python: typecheck", + ...opts, + }); + } +} + +export function python(opts?: PythonOptions): PythonToolchain { + const path = opts?.path ?? "."; + const uvVersion = opts?.uvVersion ?? "latest"; + + if (!VERSION_RE.test(uvVersion)) { + throw new Error( + `hm.python: invalid uv version "${uvVersion}"\n → use "latest" or a semver like "0.2.0"`, + ); + } + + const uvEnvPrefix = + uvVersion === "latest" ? "" : `UV_VERSION=${uvVersion} `; + const uvInstallCmd = [ + `${uvEnvPrefix}curl -LsSf https://astral.sh/uv/install.sh | sh`, + "ln -sf /root/.local/bin/uv /usr/local/bin/uv", + "uv --version", + ].join(" && "); + + const uvInstalled = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd: uvInstallCmd, + installCache: forever(), + langTag: "python", + installTag: "uv-install", + image: opts?.image, + base: opts?.base, + }); + + const synced = uvInstalled.sh(`cd ${path} && uv sync --all-extras`, { + label: ":python: uv-sync", + cache: onChange(`${path}/uv.lock`, `${path}/pyproject.toml`), + }); + + return new PythonToolchain(path, synced); +} diff --git a/dsls/harmont-ts/src/toolchains/ruby.ts b/dsls/harmont-ts/src/toolchains/ruby.ts new file mode 100644 index 0000000..0086832 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/ruby.ts @@ -0,0 +1,77 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever, onChange } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = ["ruby-full", "build-essential", "git"] as const; +const VERSION_RE = /^(default|[0-9]+\.[0-9]+(\.[0-9]+)?)$/; + +export interface RubyOptions { + readonly path?: string; + readonly version?: string; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class RubyProject { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + test(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && bundle exec rspec`, { + label: ":ruby: test", + ...opts, + }); + } + + lint(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && bundle exec rubocop`, { + label: ":ruby: lint", + ...opts, + }); + } +} + +export function ruby(opts?: RubyOptions): RubyProject { + const path = opts?.path ?? "."; + const version = opts?.version ?? "default"; + + if (!VERSION_RE.test(version)) { + throw new Error( + `hm.ruby: invalid version "${version}"\n → use "default" or a semver like "3.3"`, + ); + } + + if (version !== "default") { + throw new Error( + `hm.ruby: pinned Ruby versions are not yet implemented\n → use version="default" (system apt package)`, + ); + } + + const bundlerInstalled = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd: "gem install bundler && bundle --version", + installCache: forever(), + langTag: "ruby", + installTag: "bundler", + image: opts?.image, + base: opts?.base, + }); + + const deps = bundlerInstalled.sh(`cd ${path} && bundle install`, { + label: ":ruby: deps", + cache: onChange(`${path}/Gemfile.lock`), + }); + + return new RubyProject(path, deps); +} diff --git a/dsls/harmont-ts/src/toolchains/rust.ts b/dsls/harmont-ts/src/toolchains/rust.ts new file mode 100644 index 0000000..2ee5b59 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/rust.ts @@ -0,0 +1,100 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = [ + "curl", + "ca-certificates", + "build-essential", + "pkg-config", + "libssl-dev", +] as const; +const VERSION_RE = /^[a-z0-9.-]+$/; + +export interface RustOptions { + readonly path?: string; + readonly version?: string; + readonly image?: string; + readonly components?: readonly string[]; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class RustToolchain { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + private _cargo(cmd: string, label: string, opts?: ActionOptions): Step { + return this._installed.sh( + `. $HOME/.cargo/env && cd ${this.path} && ${cmd}`, + { label, ...opts }, + ); + } + + build(opts?: ActionOptions & { release?: boolean }): Step { + const cmd = opts?.release ? "cargo build --release" : "cargo build"; + return this._cargo(cmd, ":rust: build", opts); + } + + test(opts?: ActionOptions & { release?: boolean }): Step { + const cmd = opts?.release ? "cargo test --release" : "cargo test"; + return this._cargo(cmd, ":rust: test", opts); + } + + clippy(opts?: ActionOptions): Step { + return this._cargo( + "cargo clippy --all-targets -- -D warnings", + ":rust: clippy", + opts, + ); + } + + fmt(opts?: ActionOptions): Step { + return this._cargo("cargo fmt --check", ":rust: fmt", opts); + } + + doc(opts?: ActionOptions): Step { + return this._cargo("cargo doc --no-deps", ":rust: doc", opts); + } +} + +export function rust(opts?: RustOptions): RustToolchain { + const path = opts?.path ?? "."; + const version = opts?.version ?? "stable"; + const components = opts?.components ?? ["clippy", "rustfmt"]; + + if (!VERSION_RE.test(version)) { + throw new Error( + `hm.rust: invalid version "${version}"\n → use "stable", "nightly", or a semver like "1.81.0"`, + ); + } + + const componentFlag = + components.length > 0 ? ` --component ${components.join(",")}` : ""; + const installCmd = [ + `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain ${version} --profile minimal${componentFlag}`, + `. $HOME/.cargo/env && rustc --version && cargo --version`, + ].join(" && "); + + const installed = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd, + installCache: forever(), + langTag: "rust", + installTag: "rustup", + image: opts?.image, + base: opts?.base, + }); + + return new RustToolchain(path, installed); +} diff --git a/dsls/harmont-ts/src/toolchains/shared.ts b/dsls/harmont-ts/src/toolchains/shared.ts new file mode 100644 index 0000000..f7a3da3 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/shared.ts @@ -0,0 +1,37 @@ +import { scratch, type Step, type StepOptions } from "../step.js"; +import { ttl, type CachePolicy } from "../cache.js"; + +const APT_TTL_SECONDS = 86400; // 1 day + +export function aptInstallCmd(packages: readonly string[]): string { + return `apt-get update && apt-get install -y ${packages.join(" ")}`; +} + +export function nodeInstallCmd(version: string): string { + const major = version.replace(/\.x$/, ""); + return `curl -fsSL https://deb.nodesource.com/setup_${major}.x | bash - && apt-get install -y nodejs`; +} + +export function makeInstallChain(opts: { + aptPackages: readonly string[]; + installCmd: string; + installCache: CachePolicy; + langTag: string; + installTag: string; + image?: string; + base?: Step; +}): Step { + let parent: Step; + if (opts.base == null) { + parent = scratch({ image: opts.image }).sh(aptInstallCmd(opts.aptPackages), { + label: `:${opts.langTag}: apt-base`, + cache: ttl(APT_TTL_SECONDS), + }); + } else { + parent = opts.base; + } + return parent.sh(opts.installCmd, { + label: `:${opts.langTag}: ${opts.installTag}`, + cache: opts.installCache, + }); +} diff --git a/dsls/harmont-ts/src/toolchains/zig.ts b/dsls/harmont-ts/src/toolchains/zig.ts new file mode 100644 index 0000000..478f8ef --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/zig.ts @@ -0,0 +1,99 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = ["curl", "ca-certificates", "xz-utils"] as const; +const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+$/; + +export interface ZigOptions { + readonly path?: string; + readonly version?: string; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class ZigToolchain { + private readonly _installed: Step; + + constructor(installed: Step) { + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + project(path: string = "."): ZigProject { + return new ZigProject(path, this._installed); + } +} + +export class ZigProject { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + build(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && zig build`, { + label: `:zig: ${this.path} build`, + ...opts, + }); + } + + test(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && zig build test`, { + label: `:zig: ${this.path} test`, + ...opts, + }); + } + + fmt(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && zig fmt --check .`, { + label: `:zig: ${this.path} fmt`, + ...opts, + }); + } +} + +export function zig(opts: ZigOptions & { path: string }): ZigProject; +export function zig(opts?: ZigOptions): ZigToolchain; +export function zig(opts?: ZigOptions): ZigToolchain | ZigProject { + const version = opts?.version ?? "0.13.0"; + + if (!VERSION_RE.test(version)) { + throw new Error( + `hm.zig: invalid version "${version}"\n → use a semver like "0.13.0"`, + ); + } + + const installCmd = [ + `curl -fsSL https://ziglang.org/download/${version}/zig-linux-x86_64-${version}.tar.xz -o /tmp/zig.tar.xz`, + "rm -rf /usr/local/zig && mkdir -p /usr/local/zig", + "tar -xJf /tmp/zig.tar.xz -C /usr/local/zig --strip-components=1", + "ln -sf /usr/local/zig/zig /usr/local/bin/zig", + "zig version", + ].join(" && "); + + const installed = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd, + installCache: forever(), + langTag: "zig", + installTag: "install", + image: opts?.image, + base: opts?.base, + }); + + const toolchain = new ZigToolchain(installed); + return opts?.path != null ? toolchain.project(opts.path) : toolchain; +} diff --git a/dsls/harmont-ts/src/triggers.ts b/dsls/harmont-ts/src/triggers.ts new file mode 100644 index 0000000..212059c --- /dev/null +++ b/dsls/harmont-ts/src/triggers.ts @@ -0,0 +1,118 @@ +export type Trigger = PushTrigger | PullRequestTrigger | ScheduleTrigger; + +function normalizeGlobs( + value: string | readonly string[] | undefined, +): string[] | undefined { + if (value === undefined) return undefined; + if (typeof value === "string") return [value]; + return [...value]; +} + +export class PushTrigger { + readonly branches: string[] | undefined; + readonly tags: string[] | undefined; + + constructor(branches: string[] | undefined, tags: string[] | undefined) { + this.branches = branches; + this.tags = tags; + } + + toJSON(): Record { + const out: Record = { event: "push" }; + if (this.branches !== undefined) out.branches = this.branches; + if (this.tags !== undefined) out.tags = this.tags; + return out; + } +} + +export function push( + opts: { branch: string | string[]; tag?: undefined } | { tag: string | string[]; branch?: undefined }, +): PushTrigger { + const branch = "branch" in opts ? opts.branch : undefined; + const tag = "tag" in opts ? opts.tag : undefined; + const branches = normalizeGlobs(branch); + const tags = normalizeGlobs(tag); + if ((branches === undefined) === (tags === undefined)) { + throw new Error( + 'hm.push: pass exactly one of branch or tag\n → e.g. push({ branch: "main" }) or push({ tag: "v*" })', + ); + } + return new PushTrigger(branches, tags); +} + +const PR_TYPES = new Set([ + "opened", + "synchronize", + "reopened", + "closed", + "ready_for_review", +] as const); + +type PrEventType = "opened" | "synchronize" | "reopened" | "closed" | "ready_for_review"; + +const DEFAULT_PR_TYPES: PrEventType[] = ["opened", "synchronize", "reopened"]; + +export class PullRequestTrigger { + readonly branches: string[] | undefined; + readonly types: string[]; + + constructor(branches: string[] | undefined, types: string[]) { + this.branches = branches; + this.types = types; + } + + toJSON(): Record { + const out: Record = { event: "pull_request" }; + if (this.branches !== undefined) out.branches = this.branches; + out.types = this.types; + return out; + } +} + +export function pullRequest(opts?: { + branches?: string | string[]; + types?: PrEventType[]; +}): PullRequestTrigger { + const types = opts?.types ?? DEFAULT_PR_TYPES; + if (types.length === 0) { + throw new Error("hm.pullRequest: types must be non-empty"); + } + for (const t of types) { + if (!PR_TYPES.has(t as any)) { + const valid = [...PR_TYPES].sort().join(", "); + throw new Error(`unknown pull_request type "${t}"\n → valid: ${valid}`); + } + } + return new PullRequestTrigger(normalizeGlobs(opts?.branches), [...types]); +} + +export class ScheduleTrigger { + readonly cron: string; + + constructor(cron: string) { + this.cron = cron; + } + + toJSON(): Record { + return { event: "schedule", cron: this.cron }; + } +} + +const CRON_FIELD_RE = /^(\*|[0-9]+(-[0-9]+)?(\/[0-9]+)?|(\*\/[0-9]+))$/; + +function isValidCron(expr: string): boolean { + const fields = expr.trim().split(/\s+/); + if (fields.length !== 5) return false; + return fields.every((f) => { + return f.split(",").every((part) => CRON_FIELD_RE.test(part)); + }); +} + +export function schedule(cron: string): ScheduleTrigger { + if (!isValidCron(cron)) { + throw new Error( + `hm.schedule: invalid cron expression "${cron}"\n → five-field crontab, UTC, e.g. '0 4 * * *'`, + ); + } + return new ScheduleTrigger(cron); +} diff --git a/dsls/harmont-ts/tests/cache.test.ts b/dsls/harmont-ts/tests/cache.test.ts new file mode 100644 index 0000000..cee168d --- /dev/null +++ b/dsls/harmont-ts/tests/cache.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { forever, ttl, onChange, compose, type CachePolicy } from "../src/cache.js"; + +describe("forever", () => { + it("creates a forever policy with no env keys", () => { + const p = forever(); + expect(p).toEqual({ kind: "forever", envKeys: [] }); + }); + + it("accepts env keys", () => { + const p = forever({ envKeys: ["NODE_ENV"] }); + expect(p.envKeys).toEqual(["NODE_ENV"]); + }); +}); + +describe("ttl", () => { + it("creates a ttl policy with duration in seconds", () => { + const p = ttl(3600); + expect(p).toEqual({ kind: "ttl", durationSeconds: 3600, envKeys: [] }); + }); + + it("accepts env keys", () => { + const p = ttl(86400, { envKeys: ["CI"] }); + expect(p.envKeys).toEqual(["CI"]); + }); +}); + +describe("onChange", () => { + it("creates an on_change policy with paths", () => { + const p = onChange("src/", "package.json"); + expect(p).toEqual({ kind: "on_change", paths: ["src/", "package.json"] }); + }); +}); + +describe("compose", () => { + it("composes multiple policies", () => { + const p = compose(ttl(86400), onChange("src/")); + expect(p.kind).toBe("compose"); + expect(p.policies).toHaveLength(2); + expect(p.policies[0].kind).toBe("ttl"); + expect(p.policies[1].kind).toBe("on_change"); + }); +}); + +describe("type discrimination", () => { + it("kind field enables type narrowing", () => { + const p: CachePolicy = forever(); + switch (p.kind) { + case "forever": + expect(p.envKeys).toEqual([]); + break; + default: + throw new Error("unexpected kind"); + } + }); +}); diff --git a/dsls/harmont-ts/tests/e2e-fixtures.test.ts b/dsls/harmont-ts/tests/e2e-fixtures.test.ts new file mode 100644 index 0000000..d2ca635 --- /dev/null +++ b/dsls/harmont-ts/tests/e2e-fixtures.test.ts @@ -0,0 +1,145 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { beforeEach, describe, expect, it } from "vitest"; +import { clearTargetCache } from "../src/target.js"; +import { pipeline } from "../src/pipeline.js"; +import { sh } from "../src/step.js"; +import { ttl } from "../src/cache.js"; +import { go } from "../src/toolchains/go.js"; +import { python } from "../src/toolchains/python.js"; +import { npm } from "../src/toolchains/npm.js"; +import { rust } from "../src/toolchains/rust.js"; +import { zig } from "../src/toolchains/zig.js"; +import { haskell } from "../src/toolchains/haskell.js"; +import { cmake } from "../src/toolchains/cmake.js"; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const FIXTURES_DIR = resolve(__dir, "../../../tests/e2e/fixtures/ts"); + +function deepSortKeys(obj: unknown): unknown { + if (Array.isArray(obj)) return obj.map(deepSortKeys); + if (obj !== null && typeof obj === "object") { + const sorted: Record = {}; + for (const key of Object.keys(obj as Record).sort()) { + sorted[key] = deepSortKeys((obj as Record)[key]); + } + return sorted; + } + return obj; +} + +function assertFixture(name: string, ir: Record): void { + const rendered = JSON.stringify(deepSortKeys(ir), null, 2) + "\n"; + const fixturePath = resolve(FIXTURES_DIR, `${name}.json`); + + if (process.env.UPDATE_E2E_FIXTURES) { + mkdirSync(dirname(fixturePath), { recursive: true }); + writeFileSync(fixturePath, rendered); + return; + } + + if (!existsSync(fixturePath)) { + throw new Error( + `Fixture ${fixturePath} missing — run with UPDATE_E2E_FIXTURES=1`, + ); + } + const expected = JSON.parse(readFileSync(fixturePath, "utf-8")); + const actual = JSON.parse(rendered); + expect(actual).toEqual(expected); +} + +describe("E2E pipeline fixtures", () => { + beforeEach(() => { + clearTargetCache(); + }); + + it("monorepo-ci", () => { + const goProject = go({ path: "services/api" }); + const pyProject = python({ path: "services/ml" }); + const webProject = npm({ path: "web" }); + + const ir = pipeline( + goProject.build(), + goProject.test(), + goProject.vet(), + pyProject.test(), + pyProject.lint(), + pyProject.typecheck(), + webProject.run("build"), + webProject.run("test"), + webProject.run("lint"), + { env: { CI: "true" }, defaultImage: "ubuntu:24.04" }, + ); + + expect(ir.version).toBe("0"); + expect(ir.default_image).toBe("ubuntu:24.04"); + expect(ir.graph.nodes.length).toBeGreaterThan(0); + assertFixture("monorepo-ci", ir); + }); + + it("rust-release", () => { + const project = rust({ path: "." }); + + const ir = pipeline( + project.build(), + project.test(), + project.clippy(), + project.fmt(), + project.doc(), + { env: { CI: "true" }, defaultImage: "ubuntu:24.04" }, + ); + + expect(ir.version).toBe("0"); + assertFixture("rust-release", ir); + }); + + it("zig-node-polyglot", () => { + const base = sh( + "apt-get update && apt-get install -y --no-install-recommends " + + "curl ca-certificates xz-utils", + { label: ":apt: base", cache: ttl(86400), image: "ubuntu:24.04" }, + ); + const zigTc = zig({ base }); + const projA = zigTc.project("zig-a"); + const projB = zigTc.project("zig-b"); + const web = npm({ path: "web", base }); + + const ir = pipeline( + projA.build(), + projA.test(), + projB.build(), + projB.test(), + web.run("build"), + web.run("test"), + web.run("lint"), + { env: { CI: "true" }, defaultImage: "ubuntu:24.04" }, + ); + + expect(ir.version).toBe("0"); + assertFixture("zig-node-polyglot", ir); + }); + + it("kitchen-sink", () => { + const hsTc = haskell({ ghc: "9.6.7" }); + const pkgA = hsTc.cabal("pkg-a"); + const pkgB = hsTc.cabal("pkg-b"); + const cProject = cmake({ path: "infra/agent", lang: "c" }); + + const ir = pipeline( + pkgA.build(), + pkgA.test(), + pkgB.build(), + pkgB.test(), + pkgB.hlint(), + pkgB.fmt(), + cProject.build(), + cProject.test(), + cProject.fmt(), + { env: { CI: "true", STACK_ROOT: "/tmp/.stack" }, defaultImage: "ubuntu:24.04" }, + ); + + expect(ir.version).toBe("0"); + assertFixture("kitchen-sink", ir); + }); +}); diff --git a/dsls/harmont-ts/tests/envelope.test.ts b/dsls/harmont-ts/tests/envelope.test.ts new file mode 100644 index 0000000..a0a626a --- /dev/null +++ b/dsls/harmont-ts/tests/envelope.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { renderEnvelope, type PipelineDefinition } from "../src/envelope.js"; +import { pipeline } from "../src/pipeline.js"; +import { sh } from "../src/step.js"; +import { push, pullRequest } from "../src/triggers.js"; + +function makeDef(overrides?: Partial): PipelineDefinition { + return { + slug: "ci", + pipeline: pipeline(sh("echo", { label: "test" })), + ...overrides, + }; +} + +describe("renderEnvelope", () => { + it("produces schema_version 1 envelope", () => { + const json = renderEnvelope([makeDef()]); + const parsed = JSON.parse(json); + expect(parsed.schema_version).toBe("1"); + expect(parsed.pipelines).toHaveLength(1); + }); + + it("includes slug, name, allow_manual, triggers, definition", () => { + const json = renderEnvelope([ + makeDef({ + slug: "my-pipeline", + name: "My Pipeline", + allowManual: false, + triggers: [push({ branch: "main" })], + }), + ]); + const parsed = JSON.parse(json); + const p = parsed.pipelines[0]; + expect(p.slug).toBe("my-pipeline"); + expect(p.name).toBe("My Pipeline"); + expect(p.allow_manual).toBe(false); + expect(p.triggers).toEqual([{ event: "push", branches: ["main"] }]); + expect(p.definition.version).toBe("0"); + }); + + it("defaults name to slug, allowManual to true, triggers to empty", () => { + const json = renderEnvelope([makeDef({ slug: "ci" })]); + const parsed = JSON.parse(json); + const p = parsed.pipelines[0]; + expect(p.name).toBe("ci"); + expect(p.allow_manual).toBe(true); + expect(p.triggers).toEqual([]); + }); + + it("handles multiple pipelines", () => { + const json = renderEnvelope([ + makeDef({ slug: "ci" }), + makeDef({ slug: "deploy" }), + ]); + const parsed = JSON.parse(json); + expect(parsed.pipelines).toHaveLength(2); + expect(parsed.pipelines[0].slug).toBe("ci"); + expect(parsed.pipelines[1].slug).toBe("deploy"); + }); +}); diff --git a/dsls/harmont-ts/tests/examples.test.ts b/dsls/harmont-ts/tests/examples.test.ts new file mode 100644 index 0000000..9cc8a6c --- /dev/null +++ b/dsls/harmont-ts/tests/examples.test.ts @@ -0,0 +1,68 @@ +import { readdirSync, existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { beforeEach, describe, expect, it } from "vitest"; +import { clearTargetCache } from "../src/target.js"; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const EXAMPLES_ROOT = resolve(__dir, "../../../examples"); + +function exampleDirs(): string[] { + if (!existsSync(EXAMPLES_ROOT)) return []; + return readdirSync(EXAMPLES_ROOT, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .filter((d) => + existsSync(join(EXAMPLES_ROOT, d.name, ".harmont", "pipeline.ts")), + ) + .map((d) => d.name) + .sort(); +} + +const examples = exampleDirs(); + +describe("examples render to v0 IR", () => { + beforeEach(() => { + clearTargetCache(); + }); + + for (const name of examples) { + it(`${name}: produces valid CI pipeline IR`, async () => { + const pipelinePath = join( + EXAMPLES_ROOT, + name, + ".harmont", + "pipeline.ts", + ); + const mod = await import(pipelinePath); + const definitions = mod.default; + + expect(Array.isArray(definitions)).toBe(true); + expect(definitions.length).toBeGreaterThan(0); + + const ci = definitions.find((d: any) => d.slug === "ci"); + expect(ci).toBeDefined(); + expect(ci.pipeline.version).toBe("0"); + expect(ci.pipeline.graph.nodes.length).toBeGreaterThan(0); + expect(ci.pipeline.graph.edge_property).toBe("directed"); + expect(ci.pipeline.default_image).toBeTruthy(); + + // Verify all nodes have required fields + for (const node of ci.pipeline.graph.nodes) { + expect(node.step.key).toBeDefined(); + expect(node.step.cmd).toBeDefined(); + expect(typeof node.env).toBe("object"); + } + + // Verify edges reference valid node indices + for (const [src, dst, kind] of ci.pipeline.graph.edges) { + expect(src).toBeLessThan(ci.pipeline.graph.nodes.length); + expect(dst).toBeLessThan(ci.pipeline.graph.nodes.length); + expect(["builds_in", "depends_on"]).toContain(kind); + } + }); + } + + it("discovered at least 18 example pipeline.ts files", () => { + expect(examples.length).toBeGreaterThanOrEqual(18); + }); +}); diff --git a/dsls/harmont-ts/tests/integration.test.ts b/dsls/harmont-ts/tests/integration.test.ts new file mode 100644 index 0000000..da40ed2 --- /dev/null +++ b/dsls/harmont-ts/tests/integration.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { + Step, + scratch, + sh, + wait, + forever, + ttl, + onChange, + compose, + pipeline, + target, + clearTargetCache, + renderEnvelope, + push, + pullRequest, + schedule, + PushTrigger, + PullRequestTrigger, + ScheduleTrigger, + type PipelineDefinition, +} from "../src/index.js"; + +beforeEach(() => { + clearTargetCache(); +}); + +describe("full pipeline build", () => { + it("creates install -> build -> test chain with cache, env, defaultImage", () => { + const install = scratch() + .sh("npm ci", { label: "install", cache: forever() }); + const build = install + .sh("npm run build", { label: "build", env: { NODE_ENV: "production" } }); + const test = build + .sh("npm test", { label: "test", timeoutSeconds: 300 }); + + const ir = pipeline(test, { + env: { CI: "true" }, + defaultImage: "node:22-alpine", + }); + + // version + expect(ir.version).toBe("0"); + + // node count + expect(ir.graph.nodes).toHaveLength(3); + + // edge count — two builds_in edges (install->build, build->test) + expect(ir.graph.edges).toHaveLength(2); + expect(ir.graph.edges.every((e) => e[2] === "builds_in")).toBe(true); + + // env merge: pipeline env CI=true merges with step env + const installNode = ir.graph.nodes[0]; + const buildNode = ir.graph.nodes[1]; + const testNode = ir.graph.nodes[2]; + + expect(installNode.env).toEqual({ CI: "true" }); + expect(buildNode.env).toEqual({ CI: "true", NODE_ENV: "production" }); + expect(testNode.env).toEqual({ CI: "true" }); + + // default_image applies to root node only (install), not children + expect(ir.default_image).toBe("node:22-alpine"); + expect(installNode.step.image).toBe("node:22-alpine"); + expect("image" in buildNode.step).toBe(false); + expect("image" in testNode.step).toBe(false); + + // cache on install step + expect(installNode.step.cache).toEqual({ policy: "forever", env_keys: [] }); + + // timeout on test step + expect(testNode.step.timeout_seconds).toBe(300); + }); +}); + +describe("wait barrier", () => { + it("creates depends_on edges from pre-wait steps to post-wait steps", () => { + const a = scratch().sh("step a", { label: "a" }); + const b = scratch().sh("step b", { label: "b" }); + const c = scratch().sh("step c", { label: "c" }); + const ir = pipeline(a, b, wait(), c); + + const keys = ir.graph.nodes.map((n) => n.step.key); + const idxA = keys.indexOf("a"); + const idxB = keys.indexOf("b"); + const idxC = keys.indexOf("c"); + + const dependsOnEdges = ir.graph.edges.filter((e) => e[2] === "depends_on"); + + // c depends_on both a and b + expect(dependsOnEdges).toContainEqual([idxA, idxC, "depends_on"]); + expect(dependsOnEdges).toContainEqual([idxB, idxC, "depends_on"]); + expect(dependsOnEdges).toHaveLength(2); + }); +}); + +describe("target memoization", () => { + it("shared target appears once in graph when used in two branches", () => { + const nodeBase = target("node-base", () => + sh("apt-get install -y nodejs", { + label: "node-base", + cache: forever(), + }), + ); + + const branchA = nodeBase().sh("npm run lint", { label: "lint" }); + const branchB = nodeBase().sh("npm test", { label: "test" }); + + const ir = pipeline(branchA, branchB); + + // node-base should appear exactly once (memoized) + const keys = ir.graph.nodes.map((n) => n.step.key); + expect(keys.filter((k) => k === "node-base")).toHaveLength(1); + + // total nodes: node-base, lint, test + expect(ir.graph.nodes).toHaveLength(3); + + // both branches build from node-base + const nodeBaseIdx = keys.indexOf("node-base"); + const lintIdx = keys.indexOf("lint"); + const testIdx = keys.indexOf("test"); + + const buildsInEdges = ir.graph.edges.filter((e) => e[2] === "builds_in"); + expect(buildsInEdges).toContainEqual([nodeBaseIdx, lintIdx, "builds_in"]); + expect(buildsInEdges).toContainEqual([nodeBaseIdx, testIdx, "builds_in"]); + }); +}); + +describe("envelope", () => { + it("renders a complete envelope with triggers", () => { + const def: PipelineDefinition = { + slug: "my-ci", + name: "My CI Pipeline", + allowManual: false, + triggers: [ + push({ branch: "main" }), + pullRequest({ branches: "develop" }), + schedule("0 4 * * *"), + ], + pipeline: pipeline(sh("echo hello", { label: "hello" })), + }; + + const json = renderEnvelope([def]); + const parsed = JSON.parse(json); + + // schema_version + expect(parsed.schema_version).toBe("1"); + + // pipeline metadata + expect(parsed.pipelines).toHaveLength(1); + const p = parsed.pipelines[0]; + expect(p.slug).toBe("my-ci"); + expect(p.name).toBe("My CI Pipeline"); + expect(p.allow_manual).toBe(false); + + // triggers + expect(p.triggers).toHaveLength(3); + expect(p.triggers[0]).toEqual({ event: "push", branches: ["main"] }); + expect(p.triggers[1]).toEqual({ + event: "pull_request", + branches: ["develop"], + types: ["opened", "synchronize", "reopened"], + }); + expect(p.triggers[2]).toEqual({ event: "schedule", cron: "0 4 * * *" }); + + // definition is the IR + expect(p.definition.version).toBe("0"); + expect(p.definition.graph.nodes).toHaveLength(1); + }); +}); + +describe("JSON snake_case output", () => { + it("uses snake_case keys in IR, not camelCase", () => { + const s = scratch().sh("make", { + label: "build", + timeoutSeconds: 600, + cache: onChange("src/", "lib/"), + }); + const ir = pipeline(s, { defaultImage: "ubuntu:24.04" }); + const json = JSON.stringify(ir); + + // Must contain snake_case keys + expect(json).toContain('"default_image"'); + expect(json).toContain('"timeout_seconds"'); + expect(json).toContain('"edge_property"'); + expect(json).toContain('"node_holes"'); + expect(json).toContain('"on_change"'); + + // Must NOT contain camelCase equivalents + expect(json).not.toContain('"defaultImage"'); + expect(json).not.toContain('"timeoutSeconds"'); + expect(json).not.toContain('"edgeProperty"'); + expect(json).not.toContain('"nodeHoles"'); + expect(json).not.toContain('"onChange"'); + }); + + it("envelope uses snake_case keys", () => { + const def: PipelineDefinition = { + slug: "ci", + allowManual: true, + pipeline: pipeline(sh("echo")), + }; + const json = renderEnvelope([def]); + + expect(json).toContain('"schema_version"'); + expect(json).toContain('"allow_manual"'); + expect(json).not.toContain('"schemaVersion"'); + expect(json).not.toContain('"allowManual"'); + }); +}); + +describe("public API completeness", () => { + it("exports all expected symbols", () => { + // Classes and functions are values + expect(Step).toBeDefined(); + expect(typeof scratch).toBe("function"); + expect(typeof sh).toBe("function"); + expect(typeof wait).toBe("function"); + expect(typeof forever).toBe("function"); + expect(typeof ttl).toBe("function"); + expect(typeof onChange).toBe("function"); + expect(typeof compose).toBe("function"); + expect(typeof pipeline).toBe("function"); + expect(typeof target).toBe("function"); + expect(typeof clearTargetCache).toBe("function"); + expect(typeof renderEnvelope).toBe("function"); + expect(typeof push).toBe("function"); + expect(typeof pullRequest).toBe("function"); + expect(typeof schedule).toBe("function"); + + // Trigger classes are exported as values for instanceof checks + expect(PushTrigger).toBeDefined(); + expect(PullRequestTrigger).toBeDefined(); + expect(ScheduleTrigger).toBeDefined(); + + const t = push({ branch: "main" }); + expect(t instanceof PushTrigger).toBe(true); + }); +}); diff --git a/dsls/harmont-ts/tests/keys.test.ts b/dsls/harmont-ts/tests/keys.test.ts new file mode 100644 index 0000000..593468f --- /dev/null +++ b/dsls/harmont-ts/tests/keys.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { slugifyLabel, hashKey, resolveKeys } from "../src/keys.js"; +import { scratch, sh } from "../src/step.js"; + +describe("slugifyLabel", () => { + it("lowercases and replaces non-alnum with dashes", () => { + expect(slugifyLabel("Hello World")).toBe("hello-world"); + }); + + it("strips emoji shortcodes", () => { + expect(slugifyLabel(":rust: build")).toBe("build"); + }); + + it("trims leading/trailing dashes", () => { + expect(slugifyLabel("--hello--")).toBe("hello"); + }); + + it("returns empty string for non-ASCII-only labels", () => { + expect(slugifyLabel("构建")).toBe(""); + }); + + it("handles mixed emoji and text", () => { + expect(slugifyLabel(":node: deps install")).toBe("deps-install"); + }); +}); + +describe("hashKey", () => { + it("returns a 12-char hex string", () => { + const key = hashKey("parent", "echo hello", 0); + expect(key).toMatch(/^[0-9a-f]{12}$/); + }); + + it("is deterministic", () => { + expect(hashKey("p", "cmd", 1)).toBe(hashKey("p", "cmd", 1)); + }); + + it("differs for different inputs", () => { + expect(hashKey("p", "cmd1", 0)).not.toBe(hashKey("p", "cmd2", 0)); + }); +}); + +describe("resolveKeys", () => { + it("uses slugified label when unique", () => { + const a = scratch().sh("install", { label: "install" }); + const b = a.sh("build", { label: "build" }); + const keys = resolveKeys([a, b]); + expect(keys.get(a._id)).toBe("install"); + expect(keys.get(b._id)).toBe("build"); + }); + + it("falls back to hash when label slugs collide", () => { + const a = scratch().sh("cmd a", { label: "test" }); + const b = scratch().sh("cmd b", { label: "test" }); + const keys = resolveKeys([a, b]); + expect(keys.get(a._id)).toMatch(/^[0-9a-f]{12}$/); + expect(keys.get(b._id)).toMatch(/^[0-9a-f]{12}$/); + expect(keys.get(a._id)).not.toBe(keys.get(b._id)); + }); + + it("explicit key override wins over label", () => { + const a = scratch().sh("echo", { label: "hello", key: "my-key" }); + const keys = resolveKeys([a]); + expect(keys.get(a._id)).toBe("my-key"); + }); + + it("explicit override reserves slug, colliding label falls back to hash", () => { + const a = scratch().sh("cmd a", { label: "build", key: "build" }); + const b = scratch().sh("cmd b", { label: "build" }); + const keys = resolveKeys([a, b]); + expect(keys.get(a._id)).toBe("build"); + expect(keys.get(b._id)).toMatch(/^[0-9a-f]{12}$/); + }); + + it("falls back to hash when label is empty after slugify", () => { + const a = scratch().sh("echo", { label: "构建" }); + const keys = resolveKeys([a]); + expect(keys.get(a._id)).toMatch(/^[0-9a-f]{12}$/); + }); + + it("uses parent key for hash computation", () => { + const parent = scratch().sh("install", { label: "install" }); + const child = parent.sh("build"); + const keys = resolveKeys([parent, child]); + expect(keys.get(parent._id)).toBe("install"); + const expected = hashKey("install", "build", 1); + expect(keys.get(child._id)).toBe(expected); + }); +}); diff --git a/dsls/harmont-ts/tests/pipeline.test.ts b/dsls/harmont-ts/tests/pipeline.test.ts new file mode 100644 index 0000000..c008167 --- /dev/null +++ b/dsls/harmont-ts/tests/pipeline.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "vitest"; +import { pipeline } from "../src/pipeline.js"; +import { scratch, sh, wait } from "../src/step.js"; +import { forever, onChange } from "../src/cache.js"; + +function stepKeys(ir: any): string[] { + return ir.graph.nodes.map((n: any) => n.step.key); +} + +function buildsInEdges(ir: any): [number, number][] { + return ir.graph.edges + .filter((e: any) => e[2] === "builds_in") + .map((e: any) => [e[0], e[1]]); +} + +function dependsOnEdges(ir: any): [number, number][] { + return ir.graph.edges + .filter((e: any) => e[2] === "depends_on") + .map((e: any) => [e[0], e[1]]); +} + +function parentKeyMap(ir: any): Record { + const keyByIdx: Record = {}; + for (let i = 0; i < ir.graph.nodes.length; i++) { + keyByIdx[i] = ir.graph.nodes[i].step.key; + } + const result: Record = {}; + for (const n of ir.graph.nodes) { + result[n.step.key] = null; + } + for (const [src, dst, kind] of ir.graph.edges) { + if (kind === "builds_in") { + result[keyByIdx[dst]] = keyByIdx[src]; + } + } + return result; +} + +describe("pipeline", () => { + it("returns v0 IR dict", () => { + const p = pipeline(scratch().sh("echo", { label: "echo" })); + expect(p.version).toBe("0"); + expect(p.graph).toBeDefined(); + expect(p.graph.nodes).toHaveLength(1); + }); + + it("rejects no leaves", () => { + expect(() => pipeline()).toThrow("at least one leaf"); + }); + + it("sets default_image on IR when provided", () => { + const p = pipeline(sh("echo", { label: "a", image: "ubuntu:24.04" }), { + defaultImage: "alpine:3.20", + }); + expect(p.default_image).toBe("alpine:3.20"); + expect(p.graph.nodes[0].step.image).toBe("ubuntu:24.04"); + }); +}); + +describe("lowering: single chain", () => { + it("emits nodes in parent-first order with builds_in edges", () => { + const a = scratch().sh("step a", { label: "a" }); + const b = a.sh("step b", { label: "b" }); + const c = b.sh("step c", { label: "c" }); + const ir = pipeline(c); + expect(stepKeys(ir)).toEqual(["a", "b", "c"]); + const parents = parentKeyMap(ir); + expect(parents.a).toBeNull(); + expect(parents.b).toBe("a"); + expect(parents.c).toBe("b"); + }); +}); + +describe("lowering: fork", () => { + it("fork nodes are not emitted, children inherit grandparent", () => { + const base = scratch().sh("install", { label: "install" }); + const branch = base.fork({ label: "branch-a" }); + const leaf = branch.sh("test", { label: "test" }); + const ir = pipeline(leaf); + expect(stepKeys(ir)).toEqual(["install", "test"]); + const parents = parentKeyMap(ir); + expect(parents.install).toBeNull(); + expect(parents.test).toBe("install"); + }); + + it("two branches share parent", () => { + const base = scratch().sh("install", { label: "install" }); + const a = base.fork().sh("test-a", { label: "test-a" }); + const b = base.fork().sh("test-b", { label: "test-b" }); + const ir = pipeline(a, b); + const parents = parentKeyMap(ir); + expect(parents["test-a"]).toBe("install"); + expect(parents["test-b"]).toBe("install"); + }); +}); + +describe("lowering: wait", () => { + it("emits depends_on edges from pre-wait to post-wait steps", () => { + const a = scratch().sh("a", { label: "a" }); + const b = scratch().sh("b", { label: "b" }); + const c = scratch().sh("c", { label: "c" }); + const ir = pipeline(a, b, wait(), c); + const keys = stepKeys(ir); + const idxA = keys.indexOf("a"); + const idxB = keys.indexOf("b"); + const idxC = keys.indexOf("c"); + const deps = dependsOnEdges(ir); + expect(deps).toContainEqual([idxA, idxC]); + expect(deps).toContainEqual([idxB, idxC]); + }); +}); + +describe("lowering: env merge", () => { + it("merges pipeline env with per-step env", () => { + const s = scratch().sh("make", { env: { STEP: "1" } }); + const ir = pipeline(s, { env: { PIPE: "true" } }); + expect(ir.graph.nodes[0].env).toEqual({ PIPE: "true", STEP: "1" }); + }); + + it("step env overrides pipeline env", () => { + const s = scratch().sh("make", { env: { X: "step" } }); + const ir = pipeline(s, { env: { X: "pipe" } }); + expect(ir.graph.nodes[0].env.X).toBe("step"); + }); +}); + +describe("lowering: optional fields", () => { + it("omits label/timeout/cache when unset", () => { + const s = scratch().sh("make"); + const ir = pipeline(s); + const step = ir.graph.nodes[0].step; + expect(step.key).toBeDefined(); + expect(step.cmd).toBe("make"); + expect("label" in step).toBe(false); + expect("timeout_seconds" in step).toBe(false); + expect("cache" in step).toBe(false); + }); + + it("includes label/timeout/cache when set", () => { + const s = scratch().sh("make", { + label: "build", + timeoutSeconds: 600, + cache: forever(), + }); + const ir = pipeline(s); + const step = ir.graph.nodes[0].step; + expect(step.label).toBe("build"); + expect(step.timeout_seconds).toBe(600); + expect(step.cache).toEqual({ policy: "forever", env_keys: [] }); + }); +}); + +describe("lowering: cache serialization", () => { + it("serializes forever cache", () => { + const s = sh("echo", { cache: forever({ envKeys: ["CI"] }) }); + const ir = pipeline(s); + expect(ir.graph.nodes[0].step.cache).toEqual({ + policy: "forever", + env_keys: ["CI"], + }); + }); + + it("serializes onChange cache", () => { + const s = sh("echo", { cache: onChange("src/", "lib/") }); + const ir = pipeline(s); + expect(ir.graph.nodes[0].step.cache).toEqual({ + policy: "on_change", + paths: ["src/", "lib/"], + }); + }); +}); + +describe("lowering: dedup", () => { + it("shared ancestor appears once when reachable from multiple leaves", () => { + const base = scratch().sh("install", { label: "install" }); + const a = base.sh("a", { label: "a" }); + const b = base.sh("b", { label: "b" }); + const ir = pipeline(a, b); + const keys = stepKeys(ir); + expect(keys.filter((k) => k === "install")).toHaveLength(1); + }); +}); + +describe("lowering: default_image", () => { + it("applies default_image to root nodes without explicit image", () => { + const s = scratch().sh("echo"); + const ir = pipeline(s, { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes[0].step.image).toBe("ubuntu:24.04"); + }); + + it("does not override explicit image", () => { + const s = scratch().sh("echo", { image: "alpine:3.20" }); + const ir = pipeline(s, { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes[0].step.image).toBe("alpine:3.20"); + }); + + it("does not apply to child nodes with builds_in parent", () => { + const parent = scratch().sh("a", { label: "a" }); + const child = parent.sh("b", { label: "b" }); + const ir = pipeline(child, { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes[0].step.image).toBe("ubuntu:24.04"); + expect("image" in ir.graph.nodes[1].step).toBe(false); + }); +}); + +describe("lowering: graph structure", () => { + it("emits petgraph-serde structure", () => { + const s = scratch().sh("echo", { label: "hello" }); + const ir = pipeline(s); + expect(ir.graph.node_holes).toEqual([]); + expect(ir.graph.edge_property).toBe("directed"); + }); +}); diff --git a/dsls/harmont-ts/tests/step.test.ts b/dsls/harmont-ts/tests/step.test.ts new file mode 100644 index 0000000..d3b2ed8 --- /dev/null +++ b/dsls/harmont-ts/tests/step.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { scratch, sh, wait, Step } from "../src/step.js"; + +describe("scratch", () => { + it("creates a root step with no cmd or parent", () => { + const s = scratch(); + expect(s).toBeInstanceOf(Step); + expect(s._cmd).toBeNull(); + expect(s._parent).toBeNull(); + expect(s._isWait).toBe(false); + }); +}); + +describe("sh", () => { + it("creates a step with cmd and implicit scratch parent", () => { + const s = sh("echo hello"); + expect(s._cmd).toBe("echo hello"); + expect(s._parent).not.toBeNull(); + expect(s._parent!._cmd).toBeNull(); + }); + + it("passes options through", () => { + const s = sh("make", { + label: "build", + timeoutSeconds: 600, + env: { CI: "true" }, + image: "ubuntu:24.04", + key: "my-key", + }); + expect(s._label).toBe("build"); + expect(s._timeoutSeconds).toBe(600); + expect(s._env).toEqual({ CI: "true" }); + expect(s._image).toBe("ubuntu:24.04"); + expect(s._keyOverride).toBe("my-key"); + }); + + it("prepends cd when cwd is set", () => { + const s = sh("npm test", { cwd: "packages/app" }); + expect(s._cmd).toBe("cd packages/app && npm test"); + }); + + it("rejects empty cwd", () => { + expect(() => sh("echo", { cwd: "" })).toThrow("cwd must be a non-empty path"); + }); +}); + +describe("Step.sh", () => { + it("chains a child step with parent pointer", () => { + const parent = sh("install"); + const child = parent.sh("build"); + expect(child._cmd).toBe("build"); + expect(child._parent).toBe(parent); + }); + + it("inherits image from scratch parent", () => { + const base = scratch({ image: "alpine:3.20" }); + const child = base.sh("echo"); + expect(child._image).toBe("alpine:3.20"); + }); + + it("does not inherit image from command parent", () => { + const parent = sh("install", { image: "ubuntu:24.04" }); + const child = parent.sh("build"); + expect(child._image).toBeUndefined(); + }); + + it("explicit image overrides inherited image", () => { + const base = scratch({ image: "alpine:3.20" }); + const child = base.sh("echo", { image: "ubuntu:24.04" }); + expect(child._image).toBe("ubuntu:24.04"); + }); +}); + +describe("Step.fork", () => { + it("creates a cmd-less step with parent pointer", () => { + const parent = sh("install"); + const branch = parent.fork({ label: "branch-a" }); + expect(branch._cmd).toBeNull(); + expect(branch._parent).toBe(parent); + expect(branch._label).toBe("branch-a"); + }); +}); + +describe("wait", () => { + it("creates a wait step", () => { + const w = wait(); + expect(w._isWait).toBe(true); + expect(w._continueOnFailure).toBe(false); + }); + + it("accepts continueOnFailure", () => { + const w = wait({ continueOnFailure: true }); + expect(w._continueOnFailure).toBe(true); + }); +}); + +describe("step identity", () => { + it("each step gets a unique id", () => { + const a = sh("a"); + const b = sh("b"); + expect(a._id).not.toBe(b._id); + }); +}); diff --git a/dsls/harmont-ts/tests/target.test.ts b/dsls/harmont-ts/tests/target.test.ts new file mode 100644 index 0000000..5460ee6 --- /dev/null +++ b/dsls/harmont-ts/tests/target.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { target, clearTargetCache } from "../src/target.js"; +import { sh } from "../src/step.js"; +import { forever } from "../src/cache.js"; + +beforeEach(() => { + clearTargetCache(); +}); + +describe("target", () => { + it("returns a factory function", () => { + const nodeBase = target("node-base", () => { + return sh("apt-get install -y nodejs", { cache: forever() }); + }); + expect(typeof nodeBase).toBe("function"); + }); + + it("factory returns the step", () => { + const nodeBase = target("node-base", () => { + return sh("apt-get install -y nodejs"); + }); + const step = nodeBase(); + expect(step._cmd).toBe("apt-get install -y nodejs"); + }); + + it("memoizes return value", () => { + let callCount = 0; + const nodeBase = target("node-base", () => { + callCount++; + return sh("install"); + }); + const a = nodeBase(); + const b = nodeBase(); + expect(a).toBe(b); + expect(callCount).toBe(1); + }); + + it("clearTargetCache resets memoization", () => { + let callCount = 0; + const nodeBase = target("node-base", () => { + callCount++; + return sh("install"); + }); + nodeBase(); + clearTargetCache(); + nodeBase(); + expect(callCount).toBe(2); + }); + + it("different targets are independent", () => { + const a = target("a", () => sh("cmd-a")); + const b = target("b", () => sh("cmd-b")); + expect(a()._cmd).toBe("cmd-a"); + expect(b()._cmd).toBe("cmd-b"); + }); + + it("target can build on another target", () => { + const base = target("base", () => sh("install base")); + const app = target("app", () => base().sh("install app")); + const step = app(); + expect(step._cmd).toBe("install app"); + expect(step._parent!._cmd).toBe("install base"); + }); + + it("memoizes non-Step values (generic)", () => { + const factory = target("my-obj", () => ({ value: Math.random() })); + const a = factory(); + const b = factory(); + expect(a).toBe(b); + expect(a.value).toBe(b.value); + }); +}); diff --git a/dsls/harmont-ts/tests/toolchains/cmake.test.ts b/dsls/harmont-ts/tests/toolchains/cmake.test.ts new file mode 100644 index 0000000..db162b1 --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/cmake.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { cmake } from "../../src/toolchains/cmake.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("cmake factory", () => { + it("returns a CMakeProject with defaults", () => { + const c = cmake(); + expect(c.path).toBe("."); + expect(c.install()._cmd).toContain("cmake --version"); + }); + + it("accepts lang cpp", () => { + const c = cmake({ lang: "cpp" }); + expect(c.build()._label).toBe(":cpp: build"); + }); + + it("rejects invalid lang", () => { + expect(() => cmake({ lang: "java" as any })).toThrow("invalid lang"); + }); +}); + +describe("cmake actions", () => { + it("configure runs cmake -S . -B build", () => { + expect(cmake().configure()._cmd).toContain("cmake -S . -B build"); + }); + + it("build runs cmake --build", () => { + expect(cmake().build()._cmd).toContain("cmake --build build"); + }); + + it("test runs ctest", () => { + expect(cmake().test()._cmd).toContain("ctest --test-dir build"); + }); + + it("fmt runs clang-format", () => { + expect(cmake().fmt()._cmd).toContain("clang-format --dry-run --Werror"); + }); + + it("labels use lang tag", () => { + const c = cmake({ lang: "c" }); + expect(c.build()._label).toBe(":c: build"); + + const cpp = cmake({ lang: "cpp" }); + expect(cpp.build()._label).toBe(":cpp: build"); + }); +}); + +describe("cmake in pipeline", () => { + it("produces valid IR", () => { + const c = cmake(); + const ir = pipeline(c.build(), c.test(), { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/dsls/harmont-ts/tests/toolchains/composer.test.ts b/dsls/harmont-ts/tests/toolchains/composer.test.ts new file mode 100644 index 0000000..23a6825 --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/composer.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { composer } from "../../src/toolchains/composer.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("composer factory", () => { + it("returns a ComposerProject with defaults (php mode)", () => { + const c = composer(); + expect(c.path).toBe("."); + expect(c.install()._cmd).toContain("composer install"); + }); + + it("accepts laravel flag", () => { + const c = composer({ laravel: true }); + expect(c.test()._label).toBe(":laravel: test"); + }); +}); + +describe("composer actions", () => { + it("test runs phpunit by default", () => { + expect(composer().test()._cmd).toContain("vendor/bin/phpunit"); + }); + + it("test runs artisan test in laravel mode", () => { + expect(composer({ laravel: true }).test()._cmd).toContain( + "php artisan test", + ); + }); + + it("lint runs phpstan", () => { + expect(composer().lint()._cmd).toContain("vendor/bin/phpstan analyse"); + }); + + it("php labels use :php: prefix", () => { + const c = composer(); + expect(c.test()._label).toBe(":php: test"); + expect(c.lint()._label).toBe(":php: lint"); + }); + + it("laravel labels use :laravel: prefix", () => { + const c = composer({ laravel: true }); + expect(c.test()._label).toBe(":laravel: test"); + expect(c.lint()._label).toBe(":laravel: lint"); + }); +}); + +describe("composer install chain", () => { + it("chain is: scratch → apt-base → composer-verify → deps", () => { + const c = composer(); + const deps = c.install(); + expect(deps._label).toBe(":php: deps"); + + const composerVerify = deps._parent!; + expect(composerVerify._label).toBe(":php: composer"); + }); +}); + +describe("composer in pipeline", () => { + it("produces valid IR", () => { + const c = composer(); + const ir = pipeline(c.test(), c.lint(), { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/dsls/harmont-ts/tests/toolchains/dotnet.test.ts b/dsls/harmont-ts/tests/toolchains/dotnet.test.ts new file mode 100644 index 0000000..c882a93 --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/dotnet.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { dotnet } from "../../src/toolchains/dotnet.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("dotnet factory", () => { + it("returns a DotnetProject with defaults", () => { + const d = dotnet(); + expect(d.path).toBe("."); + expect(d.install()._cmd).toContain("dotnet --info"); + }); + + it("accepts channel", () => { + const d = dotnet({ channel: "LTS" }); + expect(d.install()._cmd).toContain("--channel LTS"); + }); + + it("rejects invalid channel", () => { + expect(() => dotnet({ channel: "bad" })).toThrow("invalid channel"); + }); +}); + +describe("dotnet actions", () => { + it("build runs dotnet build", () => { + expect(dotnet().build()._cmd).toContain("dotnet build"); + }); + + it("test runs dotnet test", () => { + expect(dotnet().test()._cmd).toContain("dotnet test"); + }); + + it("fmt runs dotnet format --verify-no-changes", () => { + expect(dotnet().fmt()._cmd).toContain("dotnet format --verify-no-changes"); + }); + + it("default labels use :dotnet: prefix", () => { + const d = dotnet(); + expect(d.build()._label).toBe(":dotnet: build"); + expect(d.test()._label).toBe(":dotnet: test"); + expect(d.fmt()._label).toBe(":dotnet: fmt"); + }); +}); + +describe("dotnet in pipeline", () => { + it("produces valid IR", () => { + const d = dotnet(); + const ir = pipeline(d.build(), d.test(), { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/dsls/harmont-ts/tests/toolchains/elm.test.ts b/dsls/harmont-ts/tests/toolchains/elm.test.ts new file mode 100644 index 0000000..e74e2da --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/elm.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { elm } from "../../src/toolchains/elm.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("elm factory", () => { + it("returns an ElmProject with defaults", () => { + const e = elm(); + expect(e.path).toBe("."); + expect(e.install()._cmd).toContain("/usr/local/bin/elm"); + }); + + it("accepts elm and node versions", () => { + const e = elm({ elmVersion: "0.19.1", nodeVersion: "22" }); + expect(e.install()._cmd).toContain("0.19.1"); + expect(e.install()._parent!._cmd).toContain("setup_22"); + }); + + it("rejects invalid elm version", () => { + expect(() => elm({ elmVersion: "abc" })).toThrow("invalid elm version"); + }); + + it("rejects invalid node version", () => { + expect(() => elm({ nodeVersion: "abc" })).toThrow("invalid node version"); + }); +}); + +describe("elm actions", () => { + it("make compiles target", () => { + expect(elm().make("src/Main.elm")._cmd).toContain("elm make src/Main.elm"); + }); + + it("make accepts output flag", () => { + expect(elm().make("src/Main.elm", { output: "app.js" })._cmd).toContain( + "--output=app.js", + ); + }); + + it("test runs elm-test via npx", () => { + expect(elm().test()._cmd).toContain("npx --yes elm-test"); + }); + + it("review runs elm-review via npx", () => { + expect(elm().review()._cmd).toContain("npx --yes elm-review"); + }); + + it("fmt runs elm-format via npx", () => { + expect(elm().fmt()._cmd).toContain("npx --yes elm-format --validate"); + }); + + it("default labels use :elm: prefix", () => { + const e = elm(); + expect(e.test()._label).toBe(":elm: test"); + expect(e.review()._label).toBe(":elm: review"); + expect(e.fmt()._label).toBe(":elm: fmt"); + }); +}); + +describe("elm install chain", () => { + it("chain is: scratch → apt-base → node → elm-binary", () => { + const e = elm(); + const elmInstall = e.install(); + expect(elmInstall._label).toBe(":elm: install"); + + const nodeInstall = elmInstall._parent!; + expect(nodeInstall._label).toBe(":elm: node"); + + const aptBase = nodeInstall._parent!; + expect(aptBase._cmd).toContain("apt-get"); + }); +}); + +describe("elm in pipeline", () => { + it("produces valid IR", () => { + const e = elm(); + const ir = pipeline(e.test(), e.fmt(), { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/dsls/harmont-ts/tests/toolchains/go.test.ts b/dsls/harmont-ts/tests/toolchains/go.test.ts new file mode 100644 index 0000000..da97b93 --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/go.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { go } from "../../src/toolchains/go.js"; +import { sh } from "../../src/step.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("go factory", () => { + it("returns a GoToolchain with defaults", () => { + const g = go(); + expect(g.path).toBe("."); + expect(g.install()._cmd).toContain("go version"); + }); + + it("accepts path and version", () => { + const g = go({ path: "cmd/server", version: "1.22" }); + expect(g.path).toBe("cmd/server"); + expect(g.install()._cmd).toContain("go1.22"); + }); + + it("rejects invalid version", () => { + expect(() => go({ version: "abc" })).toThrow("invalid version"); + }); + + it("accepts two-part version", () => { + expect(() => go({ version: "1.23" })).not.toThrow(); + }); +}); + +describe("go actions", () => { + it("build runs go build", () => { + const g = go(); + expect(g.build()._cmd).toContain("go build ./..."); + }); + + it("test runs go test", () => { + const g = go(); + expect(g.test()._cmd).toContain("go test ./..."); + }); + + it("vet runs go vet", () => { + const g = go(); + expect(g.vet()._cmd).toContain("go vet ./..."); + }); + + it("fmt runs gofmt check", () => { + const g = go(); + expect(g.fmt()._cmd).toContain("gofmt -l"); + }); + + it("actions chain from install step", () => { + const g = go(); + expect(g.build()._parent).toBe(g.install()); + }); + + it("accepts step options", () => { + const g = go(); + const t = g.test({ label: "my test", timeoutSeconds: 300 }); + expect(t._label).toBe("my test"); + expect(t._timeoutSeconds).toBe(300); + }); + + it("default labels use :go: prefix", () => { + const g = go(); + expect(g.build()._label).toBe(":go: build"); + expect(g.test()._label).toBe(":go: test"); + expect(g.vet()._label).toBe(":go: vet"); + expect(g.fmt()._label).toBe(":go: fmt"); + }); +}); + +describe("go install chain", () => { + it("chain is: scratch → apt-base → go-install", () => { + const g = go(); + const install = g.install(); + expect(install._cmd).toContain("go version"); + + const aptBase = install._parent!; + expect(aptBase._cmd).toContain("apt-get"); + + const root = aptBase._parent!; + expect(root._cmd).toBeNull(); + }); + + it("accepts base step", () => { + const base = sh("custom base"); + const g = go({ base }); + expect(g.install()._parent).toBe(base); + }); + + it("accepts custom image", () => { + const g = go({ image: "debian:12" }); + const install = g.install(); + const aptBase = install._parent!; + const root = aptBase._parent!; + expect(root._image).toBe("debian:12"); + }); +}); + +describe("go in pipeline", () => { + it("produces valid IR", () => { + const g = go(); + const ir = pipeline(g.build(), g.test(), { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(3); + expect(ir.version).toBe("0"); + }); +}); diff --git a/dsls/harmont-ts/tests/toolchains/gradle.test.ts b/dsls/harmont-ts/tests/toolchains/gradle.test.ts new file mode 100644 index 0000000..877d262 --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/gradle.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { gradle } from "../../src/toolchains/gradle.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("gradle factory", () => { + it("returns a GradleProject with defaults", () => { + const g = gradle(); + expect(g.path).toBe("."); + expect(g.install()._cmd).toContain("gradle --version"); + }); + + it("accepts jdk and kotlin flag", () => { + const g = gradle({ jdk: "17", kotlin: true }); + expect(g.install()._parent!._cmd).toContain("openjdk-17"); + expect(g.build()._label).toBe(":kotlin: build"); + }); + + it("rejects invalid jdk", () => { + expect(() => gradle({ jdk: "8" })).toThrow("invalid jdk"); + }); +}); + +describe("gradle actions", () => { + it("build runs gradle build", () => { + expect(gradle().build()._cmd).toContain("gradle build"); + }); + + it("test runs gradle test", () => { + expect(gradle().test()._cmd).toContain("gradle test"); + }); + + it("lint runs gradle check", () => { + expect(gradle().lint()._cmd).toContain("gradle check"); + }); + + it("java labels use :java: prefix", () => { + const g = gradle(); + expect(g.build()._label).toBe(":java: build"); + }); + + it("kotlin labels use :kotlin: prefix", () => { + const g = gradle({ kotlin: true }); + expect(g.build()._label).toBe(":kotlin: build"); + }); +}); + +describe("gradle in pipeline", () => { + it("produces valid IR", () => { + const g = gradle(); + const ir = pipeline(g.build(), g.test(), { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/dsls/harmont-ts/tests/toolchains/haskell.test.ts b/dsls/harmont-ts/tests/toolchains/haskell.test.ts new file mode 100644 index 0000000..d1ad374 --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/haskell.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + haskell, + HaskellToolchain, + HaskellPackage, +} from "../../src/toolchains/haskell.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("haskell factory", () => { + it("returns HaskellToolchain without path", () => { + const tc = haskell({ ghc: "9.6.7" }); + expect(tc).toBeInstanceOf(HaskellToolchain); + }); + + it("returns HaskellPackage with path", () => { + const pkg = haskell({ ghc: "9.6.7", path: "." }); + expect(pkg).toBeInstanceOf(HaskellPackage); + expect(pkg.path).toBe("."); + }); + + it("rejects invalid ghc version", () => { + expect(() => haskell({ ghc: "not valid!" })).toThrow("invalid ghc"); + }); +}); + +describe("haskell toolchain", () => { + it("cabal creates HaskellPackage with deps step", () => { + const tc = haskell({ ghc: "9.6.7" }); + const pkg = tc.cabal("."); + expect(pkg).toBeInstanceOf(HaskellPackage); + expect(pkg.install()._cmd).toContain("cabal build all --only-dependencies"); + expect(pkg.install()._label).toBe(":haskell: . deps"); + }); + + it("multiple packages share ghcup install", () => { + const tc = haskell({ ghc: "9.6.7" }); + const a = tc.cabal("pkg-a"); + const b = tc.cabal("pkg-b"); + expect(a.install()._parent).toBe(b.install()._parent); + }); +}); + +describe("haskell package actions", () => { + it("build runs cabal build all", () => { + const pkg = haskell({ ghc: "9.6.7", path: "." }); + expect(pkg.build()._cmd).toContain("cabal build all"); + }); + + it("test runs cabal test all", () => { + const pkg = haskell({ ghc: "9.6.7", path: "." }); + expect(pkg.test()._cmd).toContain("cabal test all"); + }); + + it("lint runs cabal build all --flag werror", () => { + const pkg = haskell({ ghc: "9.6.7", path: "." }); + expect(pkg.lint()._cmd).toContain("--flag werror"); + }); + + it("hlint runs hlint on path", () => { + const pkg = haskell({ ghc: "9.6.7", path: "src" }); + expect(pkg.hlint()._cmd).toContain("hlint src"); + }); + + it("fmt runs fourmolu on path", () => { + const pkg = haskell({ ghc: "9.6.7", path: "." }); + expect(pkg.fmt()._cmd).toContain("fourmolu --mode check ."); + }); + + it("labels include path", () => { + const pkg = haskell({ ghc: "9.6.7", path: "my-pkg" }); + expect(pkg.build()._label).toBe(":haskell: my-pkg build"); + expect(pkg.test()._label).toBe(":haskell: my-pkg test"); + }); +}); + +describe("haskell install chain", () => { + it("chain is: scratch → apt-base → ghcup", () => { + const tc = haskell({ ghc: "9.6.7" }); + const install = tc.install(); + expect(install._label).toBe(":haskell: ghcup"); + expect(install._cmd).toContain("ghcup install ghc 9.6.7"); + }); +}); + +describe("haskell in pipeline", () => { + it("produces valid IR", () => { + const pkg = haskell({ ghc: "9.6.7", path: "." }); + const ir = pipeline(pkg.build(), pkg.test(), pkg.fmt(), { + defaultImage: "ubuntu:24.04", + }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(5); + expect(ir.version).toBe("0"); + }); +}); diff --git a/dsls/harmont-ts/tests/toolchains/npm.test.ts b/dsls/harmont-ts/tests/toolchains/npm.test.ts new file mode 100644 index 0000000..c3a5d7b --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/npm.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { npm } from "../../src/toolchains/index.js"; +import { sh } from "../../src/step.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("npm factory", () => { + it("returns an NpmProject with install chain", () => { + const n = npm(); + expect(n.path).toBe("."); + const installed = n.install(); + expect(installed._cmd).toContain("npm ci"); + }); + + it("accepts path and version", () => { + const n = npm({ path: "packages/app", version: "22" }); + expect(n.path).toBe("packages/app"); + expect(n.install()._cmd).toContain("packages/app"); + }); + + it("rejects invalid version", () => { + expect(() => npm({ version: "abc" })).toThrow("invalid version"); + }); + + it("accepts version with .x suffix", () => { + expect(() => npm({ version: "20.x" })).not.toThrow(); + }); +}); + +describe("npm actions", () => { + it("test returns a step chained from install", () => { + const n = npm(); + const t = n.test(); + expect(t._cmd).toContain("npm test"); + expect(t._parent).toBe(n.install()); + }); + + it("lint runs npm run lint", () => { + const n = npm(); + const l = n.lint(); + expect(l._cmd).toContain("npm run lint"); + }); + + it("run executes arbitrary script", () => { + const n = npm(); + const r = n.run("typecheck"); + expect(r._cmd).toContain("npm run typecheck"); + }); + + it("actions accept step options", () => { + const n = npm(); + const t = n.test({ label: "my test", timeoutSeconds: 300 }); + expect(t._label).toBe("my test"); + expect(t._timeoutSeconds).toBe(300); + }); + + it("default labels use :node: prefix", () => { + const n = npm(); + expect(n.test()._label).toBe(":node: test"); + expect(n.lint()._label).toBe(":node: lint"); + }); +}); + +describe("npm install chain structure", () => { + it("chain is: scratch → apt-base → node-install → npm-ci", () => { + const n = npm(); + const npmCi = n.install(); + expect(npmCi._cmd).toContain("npm ci"); + + const nodeInstall = npmCi._parent!; + expect(nodeInstall._cmd).toContain("nodejs"); + expect(nodeInstall._cache).toBeDefined(); + + const aptBase = nodeInstall._parent!; + expect(aptBase._cmd).toContain("apt-get"); + + const root = aptBase._parent!; + expect(root._cmd).toBeNull(); // scratch + }); + + it("accepts base step to skip apt chain", () => { + const customBase = sh("custom base"); + const n = npm({ base: customBase }); + const npmCi = n.install(); + const nodeInstall = npmCi._parent!; + // When base is provided, it's used directly + expect(nodeInstall._parent).toBe(customBase); + }); + + it("accepts custom image", () => { + const n = npm({ image: "debian:12" }); + const npmCi = n.install(); + const nodeInstall = npmCi._parent!; + const aptBase = nodeInstall._parent!; + const root = aptBase._parent!; + expect(root._image).toBe("debian:12"); + }); +}); + +describe("npm in pipeline", () => { + it("produces valid IR when used as pipeline leaves", () => { + const n = npm(); + const ir = pipeline(n.test(), n.lint(), { defaultImage: "ubuntu:24.04" }); + // Should have at least: apt-base, node-install, npm-ci, test, lint + // (test and lint share npm-ci as parent) + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(4); + expect(ir.version).toBe("0"); + }); +}); diff --git a/dsls/harmont-ts/tests/toolchains/ocaml.test.ts b/dsls/harmont-ts/tests/toolchains/ocaml.test.ts new file mode 100644 index 0000000..d771c10 --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/ocaml.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { ocaml } from "../../src/toolchains/ocaml.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("ocaml factory", () => { + it("returns an OCamlProject with defaults", () => { + const o = ocaml(); + expect(o.path).toBe("."); + expect(o.install()._cmd).toContain("opam install"); + }); + + it("accepts compiler version", () => { + const o = ocaml({ compiler: "5.2.0" }); + expect(o.install()._parent!._cmd).toContain("5.2.0"); + }); + + it("rejects invalid compiler", () => { + expect(() => ocaml({ compiler: "bad" })).toThrow("invalid compiler"); + }); +}); + +describe("ocaml actions", () => { + it("build runs opam exec -- dune build", () => { + expect(ocaml().build()._cmd).toContain("opam exec -- dune build"); + }); + + it("test runs opam exec -- dune runtest", () => { + expect(ocaml().test()._cmd).toContain("opam exec -- dune runtest"); + }); + + it("fmt runs opam exec -- dune build @fmt", () => { + expect(ocaml().fmt()._cmd).toContain("opam exec -- dune build @fmt"); + }); + + it("default labels use :ocaml: prefix", () => { + const o = ocaml(); + expect(o.build()._label).toBe(":ocaml: build"); + expect(o.test()._label).toBe(":ocaml: test"); + expect(o.fmt()._label).toBe(":ocaml: fmt"); + }); +}); + +describe("ocaml install chain", () => { + it("chain is: scratch → apt-base → opam → deps", () => { + const o = ocaml(); + const deps = o.install(); + expect(deps._label).toBe(":ocaml: deps"); + + const opam = deps._parent!; + expect(opam._label).toBe(":ocaml: opam"); + }); +}); + +describe("ocaml in pipeline", () => { + it("produces valid IR", () => { + const o = ocaml(); + const ir = pipeline(o.build(), o.test(), { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/dsls/harmont-ts/tests/toolchains/perl.test.ts b/dsls/harmont-ts/tests/toolchains/perl.test.ts new file mode 100644 index 0000000..d04ef13 --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/perl.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { perl } from "../../src/toolchains/perl.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("perl factory", () => { + it("returns a PerlProject with defaults", () => { + const p = perl(); + expect(p.path).toBe("."); + expect(p.install()._cmd).toContain("cpanm --installdeps"); + }); + + it("accepts path", () => { + const p = perl({ path: "lib" }); + expect(p.install()._cmd).toContain("lib"); + }); +}); + +describe("perl actions", () => { + it("test runs prove", () => { + expect(perl().test()._cmd).toContain("prove -lv t/"); + }); + + it("lint runs perlcritic", () => { + expect(perl().lint()._cmd).toContain("perlcritic lib/"); + }); + + it("default labels use :perl: prefix", () => { + const p = perl(); + expect(p.test()._label).toBe(":perl: test"); + expect(p.lint()._label).toBe(":perl: lint"); + }); +}); + +describe("perl install chain", () => { + it("chain is: scratch → apt-base → cpanm → deps", () => { + const p = perl(); + const deps = p.install(); + expect(deps._label).toBe(":perl: deps"); + + const cpanm = deps._parent!; + expect(cpanm._label).toBe(":perl: cpanm"); + }); +}); + +describe("perl in pipeline", () => { + it("produces valid IR", () => { + const p = perl(); + const ir = pipeline(p.test(), p.lint(), { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/dsls/harmont-ts/tests/toolchains/python.test.ts b/dsls/harmont-ts/tests/toolchains/python.test.ts new file mode 100644 index 0000000..7dfbfd4 --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/python.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { python } from "../../src/toolchains/python.js"; +import { sh } from "../../src/step.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("python factory", () => { + it("returns a PythonToolchain with defaults", () => { + const p = python(); + expect(p.path).toBe("."); + expect(p.install()._cmd).toContain("uv sync"); + }); + + it("accepts path and uvVersion", () => { + const p = python({ path: "backend", uvVersion: "0.2.0" }); + expect(p.path).toBe("backend"); + expect(p.install()._parent!._cmd).toContain("UV_VERSION=0.2.0"); + }); + + it("rejects invalid uvVersion", () => { + expect(() => python({ uvVersion: "abc" })).toThrow("invalid uv version"); + }); + + it("latest uvVersion omits UV_VERSION env prefix", () => { + const p = python({ uvVersion: "latest" }); + expect(p.install()._parent!._cmd).not.toContain("UV_VERSION"); + }); +}); + +describe("python actions", () => { + it("test runs uv run pytest", () => { + const p = python(); + expect(p.test()._cmd).toContain("uv run pytest"); + }); + + it("lint runs uv run ruff check", () => { + const p = python(); + expect(p.lint()._cmd).toContain("uv run ruff check ."); + }); + + it("fmt runs uv run ruff format --check", () => { + const p = python(); + expect(p.fmt()._cmd).toContain("uv run ruff format --check ."); + }); + + it("typecheck runs uv run mypy", () => { + const p = python(); + expect(p.typecheck()._cmd).toContain("uv run mypy ."); + }); + + it("actions chain from install (sync step)", () => { + const p = python(); + expect(p.test()._parent).toBe(p.install()); + }); + + it("default labels use :python: prefix", () => { + const p = python(); + expect(p.test()._label).toBe(":python: test"); + expect(p.lint()._label).toBe(":python: lint"); + expect(p.fmt()._label).toBe(":python: fmt"); + expect(p.typecheck()._label).toBe(":python: typecheck"); + }); +}); + +describe("python install chain", () => { + it("chain is: scratch → apt-base → uv-install → uv-sync", () => { + const p = python(); + const sync = p.install(); + expect(sync._label).toBe(":python: uv-sync"); + + const uvInstall = sync._parent!; + expect(uvInstall._label).toBe(":python: uv-install"); + + const aptBase = uvInstall._parent!; + expect(aptBase._cmd).toContain("apt-get"); + }); + + it("accepts base step", () => { + const base = sh("custom"); + const p = python({ base }); + const sync = p.install(); + const uvInstall = sync._parent!; + expect(uvInstall._parent).toBe(base); + }); +}); + +describe("python in pipeline", () => { + it("produces valid IR", () => { + const p = python(); + const ir = pipeline(p.test(), p.lint(), { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(4); + expect(ir.version).toBe("0"); + }); +}); diff --git a/dsls/harmont-ts/tests/toolchains/ruby.test.ts b/dsls/harmont-ts/tests/toolchains/ruby.test.ts new file mode 100644 index 0000000..fd4654c --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/ruby.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { ruby } from "../../src/toolchains/ruby.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("ruby factory", () => { + it("returns a RubyProject with defaults", () => { + const r = ruby(); + expect(r.path).toBe("."); + expect(r.install()._cmd).toContain("bundle install"); + }); + + it("accepts path", () => { + const r = ruby({ path: "apps/web" }); + expect(r.install()._cmd).toContain("apps/web"); + }); + + it("rejects invalid version", () => { + expect(() => ruby({ version: "abc" })).toThrow("invalid version"); + }); + + it("rejects pinned version (not implemented)", () => { + expect(() => ruby({ version: "3.3" })).toThrow("not yet implemented"); + }); +}); + +describe("ruby actions", () => { + it("test runs bundle exec rspec", () => { + expect(ruby().test()._cmd).toContain("bundle exec rspec"); + }); + + it("lint runs bundle exec rubocop", () => { + expect(ruby().lint()._cmd).toContain("bundle exec rubocop"); + }); + + it("default labels use :ruby: prefix", () => { + const r = ruby(); + expect(r.test()._label).toBe(":ruby: test"); + expect(r.lint()._label).toBe(":ruby: lint"); + }); +}); + +describe("ruby install chain", () => { + it("chain is: scratch → apt-base → bundler → deps", () => { + const r = ruby(); + const deps = r.install(); + expect(deps._label).toBe(":ruby: deps"); + + const bundler = deps._parent!; + expect(bundler._label).toBe(":ruby: bundler"); + }); +}); + +describe("ruby in pipeline", () => { + it("produces valid IR", () => { + const r = ruby(); + const ir = pipeline(r.test(), r.lint(), { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/dsls/harmont-ts/tests/toolchains/rust.test.ts b/dsls/harmont-ts/tests/toolchains/rust.test.ts new file mode 100644 index 0000000..6f3546b --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/rust.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { rust } from "../../src/toolchains/rust.js"; +import { sh } from "../../src/step.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("rust factory", () => { + it("returns a RustToolchain with defaults", () => { + const r = rust(); + expect(r.path).toBe("."); + expect(r.install()._cmd).toContain("rustc --version"); + }); + + it("accepts path and version", () => { + const r = rust({ path: "crates/core", version: "nightly" }); + expect(r.path).toBe("crates/core"); + expect(r.install()._cmd).toContain("nightly"); + }); + + it("accepts custom components", () => { + const r = rust({ components: ["clippy", "rustfmt", "miri"] }); + expect(r.install()._cmd).toContain("clippy,rustfmt,miri"); + }); + + it("rejects invalid version", () => { + expect(() => rust({ version: "not valid!" })).toThrow("invalid version"); + }); +}); + +describe("rust actions", () => { + it("build runs cargo build", () => { + const r = rust(); + expect(r.build()._cmd).toContain("cargo build"); + expect(r.build()._cmd).not.toContain("--release"); + }); + + it("build --release", () => { + const r = rust(); + expect(r.build({ release: true })._cmd).toContain("cargo build --release"); + }); + + it("test runs cargo test", () => { + const r = rust(); + expect(r.test()._cmd).toContain("cargo test"); + }); + + it("clippy runs with -D warnings", () => { + const r = rust(); + expect(r.clippy()._cmd).toContain("cargo clippy --all-targets -- -D warnings"); + }); + + it("fmt runs cargo fmt --check", () => { + const r = rust(); + expect(r.fmt()._cmd).toContain("cargo fmt --check"); + }); + + it("doc runs cargo doc --no-deps", () => { + const r = rust(); + expect(r.doc()._cmd).toContain("cargo doc --no-deps"); + }); + + it("actions source cargo env", () => { + const r = rust(); + expect(r.build()._cmd).toContain(". $HOME/.cargo/env"); + }); + + it("actions chain from install", () => { + const r = rust(); + expect(r.build()._parent).toBe(r.install()); + }); + + it("accepts step options", () => { + const r = rust(); + const t = r.test({ label: "my test", timeoutSeconds: 600 }); + expect(t._label).toBe("my test"); + expect(t._timeoutSeconds).toBe(600); + }); + + it("default labels use :rust: prefix", () => { + const r = rust(); + expect(r.build()._label).toBe(":rust: build"); + expect(r.test()._label).toBe(":rust: test"); + expect(r.clippy()._label).toBe(":rust: clippy"); + expect(r.fmt()._label).toBe(":rust: fmt"); + expect(r.doc()._label).toBe(":rust: doc"); + }); +}); + +describe("rust install chain", () => { + it("chain is: scratch → apt-base → rustup", () => { + const r = rust(); + const install = r.install(); + expect(install._label).toBe(":rust: rustup"); + + const aptBase = install._parent!; + expect(aptBase._cmd).toContain("apt-get"); + + const root = aptBase._parent!; + expect(root._cmd).toBeNull(); + }); + + it("accepts base step", () => { + const base = sh("custom base"); + const r = rust({ base }); + expect(r.install()._parent).toBe(base); + }); +}); + +describe("rust in pipeline", () => { + it("produces valid IR", () => { + const r = rust(); + const ir = pipeline(r.build(), r.test(), r.clippy(), r.fmt(), { + defaultImage: "ubuntu:24.04", + }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(4); + expect(ir.version).toBe("0"); + }); +}); diff --git a/dsls/harmont-ts/tests/toolchains/zig.test.ts b/dsls/harmont-ts/tests/toolchains/zig.test.ts new file mode 100644 index 0000000..4d3285f --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/zig.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { zig, ZigToolchain, ZigProject } from "../../src/toolchains/zig.js"; +import { sh } from "../../src/step.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("zig factory", () => { + it("returns ZigToolchain without path", () => { + const tc = zig(); + expect(tc).toBeInstanceOf(ZigToolchain); + }); + + it("returns ZigProject with path", () => { + const proj = zig({ path: "." }); + expect(proj).toBeInstanceOf(ZigProject); + expect(proj.path).toBe("."); + }); + + it("rejects invalid version", () => { + expect(() => zig({ version: "abc" })).toThrow("invalid version"); + }); +}); + +describe("zig toolchain", () => { + it("project creates ZigProject sharing install step", () => { + const tc = zig(); + const a = tc.project("lib-a"); + const b = tc.project("lib-b"); + expect(a.install()).toBe(b.install()); + expect(a.path).toBe("lib-a"); + expect(b.path).toBe("lib-b"); + }); +}); + +describe("zig project actions", () => { + it("build runs zig build", () => { + const p = zig({ path: "." }); + expect(p.build()._cmd).toContain("zig build"); + }); + + it("test runs zig build test", () => { + const p = zig({ path: "." }); + expect(p.test()._cmd).toContain("zig build test"); + }); + + it("fmt runs zig fmt --check", () => { + const p = zig({ path: "." }); + expect(p.fmt()._cmd).toContain("zig fmt --check"); + }); + + it("labels include project path", () => { + const p = zig({ path: "lib-a" }); + expect(p.build()._label).toBe(":zig: lib-a build"); + expect(p.test()._label).toBe(":zig: lib-a test"); + }); +}); + +describe("zig install chain", () => { + it("chain is: scratch → apt-base → zig-install", () => { + const tc = zig(); + const install = tc.install(); + expect(install._cmd).toContain("zig version"); + + const aptBase = install._parent!; + expect(aptBase._cmd).toContain("apt-get"); + }); + + it("accepts base step", () => { + const base = sh("custom"); + const tc = zig({ base }); + expect(tc.install()._parent).toBe(base); + }); +}); + +describe("zig multi-project pipeline", () => { + it("two projects share one install step in IR", () => { + const tc = zig(); + const a = tc.project("lib-a"); + const b = tc.project("lib-b"); + const ir = pipeline(a.build(), a.test(), b.build(), b.test(), { + defaultImage: "ubuntu:24.04", + }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(5); + expect(ir.version).toBe("0"); + }); +}); diff --git a/dsls/harmont-ts/tests/triggers.test.ts b/dsls/harmont-ts/tests/triggers.test.ts new file mode 100644 index 0000000..1d8c50b --- /dev/null +++ b/dsls/harmont-ts/tests/triggers.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { push, pullRequest, schedule } from "../src/triggers.js"; + +describe("push", () => { + it("creates a branch trigger from string", () => { + const t = push({ branch: "main" }); + expect(t.toJSON()).toEqual({ event: "push", branches: ["main"] }); + }); + + it("creates a branch trigger from array", () => { + const t = push({ branch: ["main", "develop"] }); + expect(t.toJSON()).toEqual({ event: "push", branches: ["main", "develop"] }); + }); + + it("creates a tag trigger", () => { + const t = push({ tag: "v*" }); + expect(t.toJSON()).toEqual({ event: "push", tags: ["v*"] }); + }); + + it("rejects when neither branch nor tag", () => { + expect(() => push({} as any)).toThrow("exactly one of branch or tag"); + }); + + it("rejects when both branch and tag", () => { + expect(() => push({ branch: "main", tag: "v*" } as any)).toThrow( + "exactly one of branch or tag", + ); + }); +}); + +describe("pullRequest", () => { + it("uses default types when none specified", () => { + const t = pullRequest(); + expect(t.toJSON()).toEqual({ + event: "pull_request", + types: ["opened", "synchronize", "reopened"], + }); + }); + + it("accepts branch filter", () => { + const t = pullRequest({ branches: ["main"] }); + const json = t.toJSON(); + expect(json.branches).toEqual(["main"]); + }); + + it("accepts custom types", () => { + const t = pullRequest({ types: ["opened", "closed"] }); + expect(t.toJSON().types).toEqual(["opened", "closed"]); + }); + + it("rejects invalid types", () => { + expect(() => pullRequest({ types: ["invalid" as any] })).toThrow("unknown pull_request type"); + }); + + it("rejects empty types", () => { + expect(() => pullRequest({ types: [] })).toThrow("types must be non-empty"); + }); +}); + +describe("schedule", () => { + it("creates a cron trigger", () => { + const t = schedule("0 4 * * *"); + expect(t.toJSON()).toEqual({ event: "schedule", cron: "0 4 * * *" }); + }); + + it("rejects invalid cron", () => { + expect(() => schedule("not a cron")).toThrow("invalid cron expression"); + }); +}); diff --git a/dsls/harmont-ts/tsconfig.json b/dsls/harmont-ts/tsconfig.json new file mode 100644 index 0000000..bc951f4 --- /dev/null +++ b/dsls/harmont-ts/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/dsls/harmont-ts/vitest.config.ts b/dsls/harmont-ts/vitest.config.ts new file mode 100644 index 0000000..eb1dac9 --- /dev/null +++ b/dsls/harmont-ts/vitest.config.ts @@ -0,0 +1,14 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + }, + resolve: { + alias: { + "harmont/toolchains": path.resolve(__dirname, "src/toolchains/index.ts"), + harmont: path.resolve(__dirname, "src/index.ts"), + }, + }, +}); diff --git a/examples/c/.harmont/pipeline.ts b/examples/c/.harmont/pipeline.ts new file mode 100644 index 0000000..adf6ece --- /dev/null +++ b/examples/c/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { cmake } from "harmont/toolchains"; + +const project = cmake({ path: ".", lang: "c" }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + pipeline: pipeline(project.build(), project.test(), project.fmt(), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/cpp/.harmont/pipeline.ts b/examples/cpp/.harmont/pipeline.ts new file mode 100644 index 0000000..1daa43a --- /dev/null +++ b/examples/cpp/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { cmake } from "harmont/toolchains"; + +const project = cmake({ path: ".", lang: "cpp" }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + pipeline: pipeline(project.build(), project.test(), project.fmt(), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/csharp/.harmont/pipeline.ts b/examples/csharp/.harmont/pipeline.ts new file mode 100644 index 0000000..c67e4ac --- /dev/null +++ b/examples/csharp/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { dotnet } from "harmont/toolchains"; + +const project = dotnet({ path: ".", channel: "8.0" }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + pipeline: pipeline(project.build(), project.test(), project.fmt(), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/go/.harmont/pipeline.ts b/examples/go/.harmont/pipeline.ts new file mode 100644 index 0000000..082a55e --- /dev/null +++ b/examples/go/.harmont/pipeline.ts @@ -0,0 +1,20 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { go } from "harmont/toolchains"; + +const project = go({ path: "." }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + pipeline: pipeline( + project.build(), + project.test(), + project.vet(), + project.fmt(), + { env: { CI: "true" }, defaultImage: "ubuntu:24.04" }, + ), + }, +]; + +export default pipelines; diff --git a/examples/haskell/.harmont/pipeline.ts b/examples/haskell/.harmont/pipeline.ts new file mode 100644 index 0000000..ed64b3a --- /dev/null +++ b/examples/haskell/.harmont/pipeline.ts @@ -0,0 +1,21 @@ +import { pipeline, push, target, type PipelineDefinition } from "harmont"; +import { haskell } from "harmont/toolchains"; + +const ghc = target("ghc", () => haskell({ ghc: "9.6.7" })); +const project = target("project", () => ghc().cabal(".")); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + pipeline: pipeline( + project().build(), + project().test(), + project().lint(), + project().fmt(), + { env: { CI: "true" }, defaultImage: "ubuntu:24.04" }, + ), + }, +]; + +export default pipelines; diff --git a/examples/java/.harmont/pipeline.ts b/examples/java/.harmont/pipeline.ts new file mode 100644 index 0000000..41ae406 --- /dev/null +++ b/examples/java/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { gradle } from "harmont/toolchains"; + +const project = gradle({ path: ".", jdk: "21" }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + pipeline: pipeline(project.build(), project.test(), project.lint(), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/kotlin/.harmont/pipeline.ts b/examples/kotlin/.harmont/pipeline.ts new file mode 100644 index 0000000..fe4f4b8 --- /dev/null +++ b/examples/kotlin/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { gradle } from "harmont/toolchains"; + +const project = gradle({ path: ".", jdk: "21", kotlin: true }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + pipeline: pipeline(project.build(), project.test(), project.lint(), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/nextjs/.harmont/pipeline.ts b/examples/nextjs/.harmont/pipeline.ts new file mode 100644 index 0000000..87f6389 --- /dev/null +++ b/examples/nextjs/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { npm } from "harmont/toolchains"; + +const project = npm({ path: "." }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + pipeline: pipeline(project.run("build"), project.run("test"), project.run("lint"), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/ocaml/.harmont/pipeline.ts b/examples/ocaml/.harmont/pipeline.ts new file mode 100644 index 0000000..0f3a38d --- /dev/null +++ b/examples/ocaml/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { ocaml } from "harmont/toolchains"; + +const project = ocaml({ path: "." }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + pipeline: pipeline(project.build(), project.test(), project.fmt(), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/perl/.harmont/pipeline.ts b/examples/perl/.harmont/pipeline.ts new file mode 100644 index 0000000..c0c8921 --- /dev/null +++ b/examples/perl/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { perl } from "harmont/toolchains"; + +const project = perl({ path: "." }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + pipeline: pipeline(project.test(), project.lint(), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/php-laravel/.harmont/pipeline.ts b/examples/php-laravel/.harmont/pipeline.ts new file mode 100644 index 0000000..e0959b9 --- /dev/null +++ b/examples/php-laravel/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { composer } from "harmont/toolchains"; + +const project = composer({ path: ".", laravel: true }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + pipeline: pipeline(project.test(), project.lint(), { + env: { CI: "true", APP_ENV: "testing" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/python-uv/.harmont/pipeline.ts b/examples/python-uv/.harmont/pipeline.ts new file mode 100644 index 0000000..94ee5ab --- /dev/null +++ b/examples/python-uv/.harmont/pipeline.ts @@ -0,0 +1,20 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { python } from "harmont/toolchains"; + +const project = python({ path: "." }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + pipeline: pipeline( + project.test(), + project.lint(), + project.fmt(), + project.typecheck(), + { env: { CI: "true" }, defaultImage: "ubuntu:24.04" }, + ), + }, +]; + +export default pipelines; diff --git a/examples/react/.harmont/pipeline.ts b/examples/react/.harmont/pipeline.ts new file mode 100644 index 0000000..87f6389 --- /dev/null +++ b/examples/react/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { npm } from "harmont/toolchains"; + +const project = npm({ path: "." }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + pipeline: pipeline(project.run("build"), project.run("test"), project.run("lint"), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/ruby/.harmont/pipeline.ts b/examples/ruby/.harmont/pipeline.ts new file mode 100644 index 0000000..34b78fb --- /dev/null +++ b/examples/ruby/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { ruby } from "harmont/toolchains"; + +const project = ruby({ path: "." }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + pipeline: pipeline(project.test(), project.lint(), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/rust/.harmont/pipeline.ts b/examples/rust/.harmont/pipeline.ts new file mode 100644 index 0000000..71c3ef1 --- /dev/null +++ b/examples/rust/.harmont/pipeline.ts @@ -0,0 +1,20 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { rust } from "harmont/toolchains"; + +const project = rust({ path: "." }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + pipeline: pipeline( + project.build(), + project.test(), + project.clippy(), + project.fmt(), + { env: { CI: "true" }, defaultImage: "ubuntu:24.04" }, + ), + }, +]; + +export default pipelines; diff --git a/examples/typescript/.harmont/pipeline.ts b/examples/typescript/.harmont/pipeline.ts new file mode 100644 index 0000000..87f6389 --- /dev/null +++ b/examples/typescript/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { npm } from "harmont/toolchains"; + +const project = npm({ path: "." }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + pipeline: pipeline(project.run("build"), project.run("test"), project.run("lint"), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/zig-js/.harmont/pipeline.ts b/examples/zig-js/.harmont/pipeline.ts new file mode 100644 index 0000000..6a1337b --- /dev/null +++ b/examples/zig-js/.harmont/pipeline.ts @@ -0,0 +1,33 @@ +import { pipeline, push, scratch, target, ttl, type PipelineDefinition } from "harmont"; +import { npm, zig } from "harmont/toolchains"; + +const aptBase = target("apt-base", () => + scratch({ image: "ubuntu:24.04" }).sh( + "apt-get update && apt-get install -y --no-install-recommends curl ca-certificates xz-utils", + { label: ":apt: base", cache: ttl(86400) }, + ), +); + +const zigTc = target("zig", () => zig({ base: aptBase() })); +const zigLibA = target("zig-lib-a", () => zigTc().project("zig-a")); +const zigLibB = target("zig-lib-b", () => zigTc().project("zig-b")); +const webProject = target("web-project", () => npm({ path: "web", base: aptBase() })); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + pipeline: pipeline( + zigLibA().build(), + zigLibA().test(), + zigLibB().build(), + zigLibB().test(), + webProject().run("build"), + webProject().run("test"), + webProject().run("lint"), + { env: { CI: "true" }, defaultImage: "ubuntu:24.04" }, + ), + }, +]; + +export default pipelines; diff --git a/examples/zig/.harmont/pipeline.ts b/examples/zig/.harmont/pipeline.ts new file mode 100644 index 0000000..26df58f --- /dev/null +++ b/examples/zig/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { zig } from "harmont/toolchains"; + +const project = zig({ path: "." }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + pipeline: pipeline(project.build(), project.test(), project.fmt(), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/tests/e2e/fixtures/python/kitchen-sink.json b/tests/e2e/fixtures/python/kitchen-sink.json new file mode 100644 index 0000000..49cb00f --- /dev/null +++ b/tests/e2e/fixtures/python/kitchen-sink.json @@ -0,0 +1,276 @@ +{ + "default_image": "ubuntu:24.04", + "graph": { + "edge_property": "directed", + "edges": [ + [ + 0, + 1, + "builds_in" + ], + [ + 1, + 2, + "builds_in" + ], + [ + 2, + 3, + "builds_in" + ], + [ + 2, + 4, + "builds_in" + ], + [ + 1, + 5, + "builds_in" + ], + [ + 5, + 6, + "builds_in" + ], + [ + 5, + 7, + "builds_in" + ], + [ + 5, + 8, + "builds_in" + ], + [ + 5, + 9, + "builds_in" + ], + [ + 10, + 11, + "builds_in" + ], + [ + 11, + 12, + "builds_in" + ], + [ + 11, + 13, + "builds_in" + ], + [ + 11, + 14, + "builds_in" + ] + ], + "node_holes": [], + "nodes": [ + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y curl ca-certificates build-essential libgmp-dev libffi-dev libncurses-dev zlib1g-dev", + "image": "ubuntu:24.04", + "key": "d9d2f7730236", + "label": ":haskell: apt-base" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -fsSL https://downloads.haskell.org/~ghcup/x86_64-linux-ghcup -o /usr/local/bin/ghcup && chmod +x /usr/local/bin/ghcup && ghcup install ghc 9.6.7 && ghcup install cabal latest && ghcup set ghc 9.6.7 && ghcup set cabal latest && ln -sf /root/.ghcup/bin/* /usr/local/bin/ && curl -fsSL https://github.com/fourmolu/fourmolu/releases/download/v0.18.0.0/fourmolu-0.18.0.0-linux-x86_64 -o /usr/local/bin/fourmolu && chmod +x /usr/local/bin/fourmolu", + "key": "ghcup", + "label": ":haskell: ghcup" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "paths": [ + "pkg-a/*.cabal", + "pkg-a/cabal.project" + ], + "policy": "on_change" + }, + "cmd": "cabal update && cd pkg-a && cabal build all --only-dependencies", + "key": "pkg-a-deps", + "label": ":haskell: pkg-a deps" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd pkg-a && cabal build all", + "key": "pkg-a-build", + "label": ":haskell: pkg-a build" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd pkg-a && cabal test all", + "key": "pkg-a-test", + "label": ":haskell: pkg-a test" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "paths": [ + "pkg-b/*.cabal", + "pkg-b/cabal.project" + ], + "policy": "on_change" + }, + "cmd": "cabal update && cd pkg-b && cabal build all --only-dependencies", + "key": "pkg-b-deps", + "label": ":haskell: pkg-b deps" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd pkg-b && cabal build all", + "key": "pkg-b-build", + "label": ":haskell: pkg-b build" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd pkg-b && cabal test all", + "key": "pkg-b-test", + "label": ":haskell: pkg-b test" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "hlint pkg-b", + "key": "pkg-b-hlint", + "label": ":haskell: pkg-b hlint" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "fourmolu --mode check pkg-b", + "key": "pkg-b-fmt", + "label": ":haskell: pkg-b fmt" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y build-essential cmake ninja-build clang-format", + "image": "ubuntu:24.04", + "key": "6dc3a82f7a67", + "label": ":c: apt-base" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "cmake --version && clang-format --version", + "key": "cmake-verify", + "label": ":c: cmake-verify" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd infra/agent && cmake -S . -B build && cmake --build build", + "key": "build", + "label": ":c: build" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd infra/agent && cmake -S . -B build && cmake --build build && ctest --test-dir build --output-on-failure", + "key": "test", + "label": ":c: test" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd infra/agent && find src tests -name '*.[ch]' -o -name '*.cpp' -o -name '*.hpp' | xargs clang-format --dry-run --Werror", + "key": "fmt", + "label": ":c: fmt" + } + } + ] + }, + "version": "0" +} diff --git a/tests/e2e/fixtures/python/monorepo-ci.json b/tests/e2e/fixtures/python/monorepo-ci.json new file mode 100644 index 0000000..f2d128e --- /dev/null +++ b/tests/e2e/fixtures/python/monorepo-ci.json @@ -0,0 +1,295 @@ +{ + "default_image": "ubuntu:24.04", + "graph": { + "edge_property": "directed", + "edges": [ + [ + 0, + 1, + "builds_in" + ], + [ + 1, + 2, + "builds_in" + ], + [ + 1, + 3, + "builds_in" + ], + [ + 1, + 4, + "builds_in" + ], + [ + 5, + 6, + "builds_in" + ], + [ + 6, + 7, + "builds_in" + ], + [ + 7, + 8, + "builds_in" + ], + [ + 7, + 9, + "builds_in" + ], + [ + 7, + 10, + "builds_in" + ], + [ + 11, + 12, + "builds_in" + ], + [ + 12, + 13, + "builds_in" + ], + [ + 13, + 14, + "builds_in" + ], + [ + 13, + 15, + "builds_in" + ], + [ + 13, + 16, + "builds_in" + ] + ], + "node_holes": [], + "nodes": [ + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y curl ca-certificates git", + "image": "ubuntu:24.04", + "key": "334b29e96b76", + "label": ":go: apt-base" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -fsSL https://go.dev/dl/go1.23.2.linux-amd64.tar.gz -o /tmp/go.tgz && rm -rf /usr/local/go && tar -C /usr/local -xzf /tmp/go.tgz && ln -sf /usr/local/go/bin/go /usr/local/bin/go && ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt && go version", + "key": "e0b494124562", + "label": ":go: install" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/api && go build ./...", + "key": "6f9493b7219f", + "label": ":go: build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/api && go test ./...", + "key": "1ad6d86b2c0a", + "label": ":go: test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/api && go vet ./...", + "key": "vet", + "label": ":go: vet" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y curl ca-certificates python3 python3-venv", + "image": "ubuntu:24.04", + "key": "c8d9fda86ff3", + "label": ":python: apt-base" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -LsSf https://astral.sh/uv/install.sh | sh && ln -sf /root/.local/bin/uv /usr/local/bin/uv && uv --version", + "key": "uv-install", + "label": ":python: uv-install" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "paths": [ + "services/ml/uv.lock", + "services/ml/pyproject.toml" + ], + "policy": "on_change" + }, + "cmd": "cd services/ml && uv sync --all-extras", + "key": "uv-sync", + "label": ":python: uv-sync" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/ml && uv run pytest", + "key": "847020e744bc", + "label": ":python: test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/ml && uv run ruff check .", + "key": "6c48498afb84", + "label": ":python: lint" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/ml && uv run mypy .", + "key": "typecheck", + "label": ":python: typecheck" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y curl ca-certificates", + "image": "ubuntu:24.04", + "key": "3c2cfedcad46", + "label": ":node: apt-base" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs", + "key": "6c25ac8f0830", + "label": ":node: install" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "paths": [ + "web/package-lock.json" + ], + "policy": "on_change" + }, + "cmd": "cd web && npm ci", + "key": "deps", + "label": ":node: deps" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run build", + "key": "a94a0f84e711", + "label": ":node: build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run test", + "key": "d2438adde70d", + "label": ":node: test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run lint", + "key": "74c52c9e5ef6", + "label": ":node: lint" + } + } + ] + }, + "version": "0" +} diff --git a/tests/e2e/fixtures/python/rust-release.json b/tests/e2e/fixtures/python/rust-release.json new file mode 100644 index 0000000..dd35019 --- /dev/null +++ b/tests/e2e/fixtures/python/rust-release.json @@ -0,0 +1,122 @@ +{ + "default_image": "ubuntu:24.04", + "graph": { + "edge_property": "directed", + "edges": [ + [ + 0, + 1, + "builds_in" + ], + [ + 1, + 2, + "builds_in" + ], + [ + 1, + 3, + "builds_in" + ], + [ + 1, + 4, + "builds_in" + ], + [ + 1, + 5, + "builds_in" + ], + [ + 1, + 6, + "builds_in" + ] + ], + "node_holes": [], + "nodes": [ + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y curl ca-certificates build-essential pkg-config libssl-dev", + "image": "ubuntu:24.04", + "key": "apt-base", + "label": ":rust: apt-base" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal --component clippy,rustfmt && . $HOME/.cargo/env && rustc --version && cargo --version", + "key": "rustup", + "label": ":rust: rustup" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": ". $HOME/.cargo/env && cd . && cargo build", + "key": "build", + "label": ":rust: build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": ". $HOME/.cargo/env && cd . && cargo test", + "key": "test", + "label": ":rust: test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": ". $HOME/.cargo/env && cd . && cargo clippy --all-targets -- -D warnings", + "key": "clippy", + "label": ":rust: clippy" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": ". $HOME/.cargo/env && cd . && cargo fmt --check", + "key": "fmt", + "label": ":rust: fmt" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": ". $HOME/.cargo/env && cd . && cargo doc --no-deps", + "key": "doc", + "label": ":rust: doc" + } + } + ] + }, + "version": "0" +} diff --git a/tests/e2e/fixtures/python/zig-node-polyglot.json b/tests/e2e/fixtures/python/zig-node-polyglot.json new file mode 100644 index 0000000..2743f1d --- /dev/null +++ b/tests/e2e/fixtures/python/zig-node-polyglot.json @@ -0,0 +1,192 @@ +{ + "default_image": "ubuntu:24.04", + "graph": { + "edge_property": "directed", + "edges": [ + [ + 0, + 1, + "builds_in" + ], + [ + 1, + 2, + "builds_in" + ], + [ + 1, + 3, + "builds_in" + ], + [ + 1, + 4, + "builds_in" + ], + [ + 1, + 5, + "builds_in" + ], + [ + 0, + 6, + "builds_in" + ], + [ + 6, + 7, + "builds_in" + ], + [ + 7, + 8, + "builds_in" + ], + [ + 7, + 9, + "builds_in" + ], + [ + 7, + 10, + "builds_in" + ] + ], + "node_holes": [], + "nodes": [ + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y --no-install-recommends curl ca-certificates xz-utils", + "image": "ubuntu:24.04", + "key": "base", + "label": ":apt: base" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -fsSL https://ziglang.org/download/0.13.0/zig-linux-x86_64-0.13.0.tar.xz -o /tmp/zig.tar.xz && rm -rf /usr/local/zig && mkdir -p /usr/local/zig && tar -xJf /tmp/zig.tar.xz -C /usr/local/zig --strip-components=1 && ln -sf /usr/local/zig/zig /usr/local/bin/zig && zig version", + "key": "b589fbff75ec", + "label": ":zig: install" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd zig-a && zig build", + "key": "zig-a-build", + "label": ":zig: zig-a build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd zig-a && zig build test", + "key": "zig-a-test", + "label": ":zig: zig-a test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd zig-b && zig build", + "key": "zig-b-build", + "label": ":zig: zig-b build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd zig-b && zig build test", + "key": "zig-b-test", + "label": ":zig: zig-b test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs", + "key": "e1242026cf31", + "label": ":node: install" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "paths": [ + "web/package-lock.json" + ], + "policy": "on_change" + }, + "cmd": "cd web && npm ci", + "key": "deps", + "label": ":node: deps" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run build", + "key": "build", + "label": ":node: build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run test", + "key": "test", + "label": ":node: test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run lint", + "key": "lint", + "label": ":node: lint" + } + } + ] + }, + "version": "0" +} diff --git a/tests/e2e/fixtures/ts/kitchen-sink.json b/tests/e2e/fixtures/ts/kitchen-sink.json new file mode 100644 index 0000000..4fcbe39 --- /dev/null +++ b/tests/e2e/fixtures/ts/kitchen-sink.json @@ -0,0 +1,274 @@ +{ + "default_image": "ubuntu:24.04", + "graph": { + "edge_property": "directed", + "edges": [ + [ + 0, + 1, + "builds_in" + ], + [ + 1, + 2, + "builds_in" + ], + [ + 2, + 3, + "builds_in" + ], + [ + 2, + 4, + "builds_in" + ], + [ + 1, + 5, + "builds_in" + ], + [ + 5, + 6, + "builds_in" + ], + [ + 5, + 7, + "builds_in" + ], + [ + 5, + 8, + "builds_in" + ], + [ + 5, + 9, + "builds_in" + ], + [ + 10, + 11, + "builds_in" + ], + [ + 11, + 12, + "builds_in" + ], + [ + 11, + 13, + "builds_in" + ], + [ + 11, + 14, + "builds_in" + ] + ], + "node_holes": [], + "nodes": [ + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y curl ca-certificates build-essential libgmp-dev libffi-dev libncurses-dev zlib1g-dev", + "image": "ubuntu:24.04", + "key": "d9d2f7730236", + "label": ":haskell: apt-base" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -fsSL https://downloads.haskell.org/~ghcup/x86_64-linux-ghcup -o /usr/local/bin/ghcup && chmod +x /usr/local/bin/ghcup && ghcup install ghc 9.6.7 && ghcup install cabal latest && ghcup set ghc 9.6.7 && ghcup set cabal latest && ln -sf /root/.ghcup/bin/* /usr/local/bin/ && curl -fsSL https://github.com/fourmolu/fourmolu/releases/download/v0.18.0.0/fourmolu-0.18.0.0-linux-x86_64 -o /usr/local/bin/fourmolu && chmod +x /usr/local/bin/fourmolu", + "key": "ghcup", + "label": ":haskell: ghcup" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "paths": [ + "pkg-a/*.cabal" + ], + "policy": "on_change" + }, + "cmd": "cabal update && cd pkg-a && cabal build all --only-dependencies", + "key": "pkg-a-deps", + "label": ":haskell: pkg-a deps" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd pkg-a && cabal build all", + "key": "pkg-a-build", + "label": ":haskell: pkg-a build" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd pkg-a && cabal test all", + "key": "pkg-a-test", + "label": ":haskell: pkg-a test" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "paths": [ + "pkg-b/*.cabal" + ], + "policy": "on_change" + }, + "cmd": "cabal update && cd pkg-b && cabal build all --only-dependencies", + "key": "pkg-b-deps", + "label": ":haskell: pkg-b deps" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd pkg-b && cabal build all", + "key": "pkg-b-build", + "label": ":haskell: pkg-b build" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd pkg-b && cabal test all", + "key": "pkg-b-test", + "label": ":haskell: pkg-b test" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "hlint pkg-b", + "key": "pkg-b-hlint", + "label": ":haskell: pkg-b hlint" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "fourmolu --mode check pkg-b", + "key": "pkg-b-fmt", + "label": ":haskell: pkg-b fmt" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y build-essential cmake ninja-build clang-format", + "image": "ubuntu:24.04", + "key": "6dc3a82f7a67", + "label": ":c: apt-base" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "cmake --version && clang-format --version", + "key": "cmake-verify", + "label": ":c: cmake-verify" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd infra/agent && cmake -S . -B build && cmake --build build", + "key": "build", + "label": ":c: build" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd infra/agent && cmake -S . -B build && cmake --build build && ctest --test-dir build --output-on-failure", + "key": "test", + "label": ":c: test" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd infra/agent && find src tests -name '*.[ch]' -o -name '*.cpp' -o -name '*.hpp' | xargs clang-format --dry-run --Werror", + "key": "fmt", + "label": ":c: fmt" + } + } + ] + }, + "version": "0" +} diff --git a/tests/e2e/fixtures/ts/monorepo-ci.json b/tests/e2e/fixtures/ts/monorepo-ci.json new file mode 100644 index 0000000..f2d128e --- /dev/null +++ b/tests/e2e/fixtures/ts/monorepo-ci.json @@ -0,0 +1,295 @@ +{ + "default_image": "ubuntu:24.04", + "graph": { + "edge_property": "directed", + "edges": [ + [ + 0, + 1, + "builds_in" + ], + [ + 1, + 2, + "builds_in" + ], + [ + 1, + 3, + "builds_in" + ], + [ + 1, + 4, + "builds_in" + ], + [ + 5, + 6, + "builds_in" + ], + [ + 6, + 7, + "builds_in" + ], + [ + 7, + 8, + "builds_in" + ], + [ + 7, + 9, + "builds_in" + ], + [ + 7, + 10, + "builds_in" + ], + [ + 11, + 12, + "builds_in" + ], + [ + 12, + 13, + "builds_in" + ], + [ + 13, + 14, + "builds_in" + ], + [ + 13, + 15, + "builds_in" + ], + [ + 13, + 16, + "builds_in" + ] + ], + "node_holes": [], + "nodes": [ + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y curl ca-certificates git", + "image": "ubuntu:24.04", + "key": "334b29e96b76", + "label": ":go: apt-base" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -fsSL https://go.dev/dl/go1.23.2.linux-amd64.tar.gz -o /tmp/go.tgz && rm -rf /usr/local/go && tar -C /usr/local -xzf /tmp/go.tgz && ln -sf /usr/local/go/bin/go /usr/local/bin/go && ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt && go version", + "key": "e0b494124562", + "label": ":go: install" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/api && go build ./...", + "key": "6f9493b7219f", + "label": ":go: build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/api && go test ./...", + "key": "1ad6d86b2c0a", + "label": ":go: test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/api && go vet ./...", + "key": "vet", + "label": ":go: vet" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y curl ca-certificates python3 python3-venv", + "image": "ubuntu:24.04", + "key": "c8d9fda86ff3", + "label": ":python: apt-base" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -LsSf https://astral.sh/uv/install.sh | sh && ln -sf /root/.local/bin/uv /usr/local/bin/uv && uv --version", + "key": "uv-install", + "label": ":python: uv-install" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "paths": [ + "services/ml/uv.lock", + "services/ml/pyproject.toml" + ], + "policy": "on_change" + }, + "cmd": "cd services/ml && uv sync --all-extras", + "key": "uv-sync", + "label": ":python: uv-sync" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/ml && uv run pytest", + "key": "847020e744bc", + "label": ":python: test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/ml && uv run ruff check .", + "key": "6c48498afb84", + "label": ":python: lint" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/ml && uv run mypy .", + "key": "typecheck", + "label": ":python: typecheck" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y curl ca-certificates", + "image": "ubuntu:24.04", + "key": "3c2cfedcad46", + "label": ":node: apt-base" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs", + "key": "6c25ac8f0830", + "label": ":node: install" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "paths": [ + "web/package-lock.json" + ], + "policy": "on_change" + }, + "cmd": "cd web && npm ci", + "key": "deps", + "label": ":node: deps" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run build", + "key": "a94a0f84e711", + "label": ":node: build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run test", + "key": "d2438adde70d", + "label": ":node: test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run lint", + "key": "74c52c9e5ef6", + "label": ":node: lint" + } + } + ] + }, + "version": "0" +} diff --git a/tests/e2e/fixtures/ts/rust-release.json b/tests/e2e/fixtures/ts/rust-release.json new file mode 100644 index 0000000..dd35019 --- /dev/null +++ b/tests/e2e/fixtures/ts/rust-release.json @@ -0,0 +1,122 @@ +{ + "default_image": "ubuntu:24.04", + "graph": { + "edge_property": "directed", + "edges": [ + [ + 0, + 1, + "builds_in" + ], + [ + 1, + 2, + "builds_in" + ], + [ + 1, + 3, + "builds_in" + ], + [ + 1, + 4, + "builds_in" + ], + [ + 1, + 5, + "builds_in" + ], + [ + 1, + 6, + "builds_in" + ] + ], + "node_holes": [], + "nodes": [ + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y curl ca-certificates build-essential pkg-config libssl-dev", + "image": "ubuntu:24.04", + "key": "apt-base", + "label": ":rust: apt-base" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal --component clippy,rustfmt && . $HOME/.cargo/env && rustc --version && cargo --version", + "key": "rustup", + "label": ":rust: rustup" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": ". $HOME/.cargo/env && cd . && cargo build", + "key": "build", + "label": ":rust: build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": ". $HOME/.cargo/env && cd . && cargo test", + "key": "test", + "label": ":rust: test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": ". $HOME/.cargo/env && cd . && cargo clippy --all-targets -- -D warnings", + "key": "clippy", + "label": ":rust: clippy" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": ". $HOME/.cargo/env && cd . && cargo fmt --check", + "key": "fmt", + "label": ":rust: fmt" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": ". $HOME/.cargo/env && cd . && cargo doc --no-deps", + "key": "doc", + "label": ":rust: doc" + } + } + ] + }, + "version": "0" +} diff --git a/tests/e2e/fixtures/ts/zig-node-polyglot.json b/tests/e2e/fixtures/ts/zig-node-polyglot.json new file mode 100644 index 0000000..2743f1d --- /dev/null +++ b/tests/e2e/fixtures/ts/zig-node-polyglot.json @@ -0,0 +1,192 @@ +{ + "default_image": "ubuntu:24.04", + "graph": { + "edge_property": "directed", + "edges": [ + [ + 0, + 1, + "builds_in" + ], + [ + 1, + 2, + "builds_in" + ], + [ + 1, + 3, + "builds_in" + ], + [ + 1, + 4, + "builds_in" + ], + [ + 1, + 5, + "builds_in" + ], + [ + 0, + 6, + "builds_in" + ], + [ + 6, + 7, + "builds_in" + ], + [ + 7, + 8, + "builds_in" + ], + [ + 7, + 9, + "builds_in" + ], + [ + 7, + 10, + "builds_in" + ] + ], + "node_holes": [], + "nodes": [ + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y --no-install-recommends curl ca-certificates xz-utils", + "image": "ubuntu:24.04", + "key": "base", + "label": ":apt: base" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -fsSL https://ziglang.org/download/0.13.0/zig-linux-x86_64-0.13.0.tar.xz -o /tmp/zig.tar.xz && rm -rf /usr/local/zig && mkdir -p /usr/local/zig && tar -xJf /tmp/zig.tar.xz -C /usr/local/zig --strip-components=1 && ln -sf /usr/local/zig/zig /usr/local/bin/zig && zig version", + "key": "b589fbff75ec", + "label": ":zig: install" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd zig-a && zig build", + "key": "zig-a-build", + "label": ":zig: zig-a build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd zig-a && zig build test", + "key": "zig-a-test", + "label": ":zig: zig-a test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd zig-b && zig build", + "key": "zig-b-build", + "label": ":zig: zig-b build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd zig-b && zig build test", + "key": "zig-b-test", + "label": ":zig: zig-b test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs", + "key": "e1242026cf31", + "label": ":node: install" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "paths": [ + "web/package-lock.json" + ], + "policy": "on_change" + }, + "cmd": "cd web && npm ci", + "key": "deps", + "label": ":node: deps" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run build", + "key": "build", + "label": ":node: build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run test", + "key": "test", + "label": ":node: test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run lint", + "key": "lint", + "label": ":node: lint" + } + } + ] + }, + "version": "0" +}