diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index c8f1dad6..55bf3a65 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -54,10 +54,6 @@ jobs: with: shared-key: examples-hm - - name: Install esbuild (for harmont-ts bundle) - working-directory: crates/hm-dsl-engine/harmont-ts - run: npm ci - - name: Build hm run: cargo build -p harmont-cli diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aaac3470..e3c42d07 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,10 +25,6 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - name: Install esbuild (for harmont-ts bundle) - working-directory: crates/hm-dsl-engine/harmont-ts - run: npm ci - - name: Set version from tag run: | VERSION="${GITHUB_REF_NAME#v}" @@ -181,52 +177,6 @@ jobs: cargo publish -p harmont-cli --token ${{ secrets.CRATES_IO_TOKEN }} --allow-dirty --no-verify fi - npm: - name: Publish to npm - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: read - id-token: write - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: "20" - registry-url: "https://registry.npmjs.org" - cache: npm - cache-dependency-path: crates/hm-dsl-engine/harmont-ts/package-lock.json - - # node 20 ships npm 10, which can sign provenance but cannot AUTHENTICATE - # via OIDC trusted publishing — that needs npm >= 11.5.1. Without it the - # publish PUT goes out unauthenticated and npm masks it as a 404. Upgrade - # so the configured trusted publisher actually authenticates the publish. - - name: Upgrade npm for OIDC trusted publishing - run: npm install -g npm@latest - - - name: Install dependencies - working-directory: crates/hm-dsl-engine/harmont-ts - run: npm ci - - - name: Build - working-directory: crates/hm-dsl-engine/harmont-ts - run: npm run build - - - name: Set version from tag - working-directory: crates/hm-dsl-engine/harmont-ts - run: npm version "${GITHUB_REF_NAME#v}" --no-git-tag-version - - - name: Publish - working-directory: crates/hm-dsl-engine/harmont-ts - run: | - VERSION="${GITHUB_REF_NAME#v}" - if npm view @harmont/hm@"$VERSION" version 2>/dev/null; then - echo "@harmont/hm@$VERSION already published, skipping" - else - npm publish --access public --provenance - fi - pypi: name: Publish to PyPI runs-on: ubuntu-latest @@ -303,16 +253,6 @@ jobs: if: matrix.musl run: sudo apt-get update && sudo apt-get install -y musl-tools - - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: npm - cache-dependency-path: crates/hm-dsl-engine/harmont-ts/package-lock.json - - - name: Install esbuild - working-directory: crates/hm-dsl-engine/harmont-ts - run: npm ci - - name: Set version from tag run: | VERSION="${GITHUB_REF_NAME#v}" diff --git a/.hm/ci.py b/.hm/ci.py index 56629d69..8d3b8d3d 100644 --- a/.hm/ci.py +++ b/.hm/ci.py @@ -25,11 +25,7 @@ def rust_project(shared_base: hm.Target[hm.Step]) -> tuple[hm.Step, ...]: # the build emits an 18-byte stub and the bundled_sources tests fail (CLI-37). # Installing Node here also lets the JS-runtime-gated render tests actually # run instead of self-skipping. - ts_deps = hm.js.project( - path="crates/hm-dsl-engine/harmont-ts", - base=shared_base, - ).install() - project = hm.rust.project(path=".", base=ts_deps) + project = hm.rust.project(path=".") return hm.group([ project.test(), # cargo test --workspace --locked — every package project.clippy(), @@ -51,18 +47,6 @@ def py_project(shared_base: hm.Target[hm.Step]) -> tuple[hm.Step, ...]: ]) -@hm.target() -def ts_project(shared_base: hm.Target[hm.Step]) -> tuple[hm.Step, ...]: - project = hm.js.project( - path="crates/hm-dsl-engine/harmont-ts", - base=shared_base, - ) - return hm.group([ - project.run("typecheck", label=":typescript: tsc"), - project.run("test", label=":test_tube: vitest"), - ]) - - @hm.pipeline( "ci", env={"CI": "true"}, @@ -74,6 +58,5 @@ def ts_project(shared_base: hm.Target[hm.Step]) -> tuple[hm.Step, ...]: def ci( rust_project: hm.Target[tuple[hm.Step, ...]], py_project: hm.Target[tuple[hm.Step, ...]], - ts_project: hm.Target[tuple[hm.Step, ...]], ) -> list: - return [rust_project, py_project, ts_project] + return [rust_project, py_project] diff --git a/CLAUDE.md b/CLAUDE.md index d4eb60e6..4d8d5c1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,22 +7,18 @@ The `cli/` directory is a Cargo workspace. - `crates/hm-util/` — shared OS and filesystem utilities. - `crates/hm-plugin-protocol/` — wire types (serde structs only). - `crates/hm-plugin-sdk/` — authoring SDK for plugin writers. -Run `cargo build` from the workspace root. Build requires esbuild -(`npm ci` in `crates/hm-dsl-engine/harmont-ts/`). +Run `cargo build` from the workspace root. For cross-cutting doctrine see [PRINCIPLES.md](../PRINCIPLES.md). -## DSLs +## DSL -Both DSLs live inside `crates/hm-dsl-engine/` so they ship with the crate: - -- `crates/hm-dsl-engine/harmont-py/` — the `harmont` Python package (pipeline DSL). -- `crates/hm-dsl-engine/harmont-ts/` — the `harmont` TypeScript package (pipeline DSL). +The `harmont` Python package (pipeline DSL) lives inside `crates/hm-dsl-engine/harmont-py/` so it ships with the crate. ## Keep the SDK, `hm init` templates, and docs in sync The toolchain helpers in `crates/hm-dsl-engine/` (e.g. -`harmont-py/harmont/_rust.py`, `harmont-ts/src/toolchains/rust.ts`) are the +`harmont-py/harmont/_rust.py`) are the **public authoring SDK**. They have two downstream surfaces that drift silently unless you update them in the same change. **A toolchain change is not done until all three agree:** diff --git a/README.md b/README.md index ebc4862e..a797c840 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,12 @@

- CI/CD as real code. Write your pipelines in Python or TypeScript, then run the exact same pipeline locally in Docker or on managed runners in Harmont Cloud — with layer caching and DAG parallelism built in. + CI/CD as real code. Write your pipelines in Python, then run the exact same pipeline locally in Docker or on managed runners in Harmont Cloud — with layer caching and DAG parallelism built in.

## What is Harmont? -Harmont lets you define CI/CD pipelines in **TypeScript or Python** and run them +Harmont lets you define CI/CD pipelines in **Python** and run them two ways from a single definition: instantly on your own machine in Docker, or on managed runners in [Harmont Cloud](https://app.harmont.dev). It's the same pipeline either way — the run you debug locally is byte-for-byte the run that @@ -76,7 +76,7 @@ cargo install harmont-cli hm init ``` -`hm init` scaffolds a working `.hm/pipeline.{py,ts}` from a template and offers +`hm init` scaffolds a working `.hm/pipeline.{py}` from a template and offers to install Claude Code skills that write and maintain your pipeline. Run it and pick your stack from the menu, or name a template up front with `-t`: @@ -99,10 +99,7 @@ same pipeline, on managed runners. See [Cloud](#cloud) below. ### Or write it by hand -A pipeline is just code. Save this as `.hm/pipeline.py` (or `.hm/pipeline.ts`): - -
-Python +A pipeline is just code. Save this as `.hm/pipeline.py`: ```python import harmont as hm @@ -125,37 +122,6 @@ def ci(project: hm.Target[PythonToolchain]) -> tuple[hm.Step, ...]: ) ``` -
- -
-TypeScript - -```typescript -import { pipeline, push, type PipelineDefinition } from "@harmont/hm"; -import { python } from "@harmont/hm/toolchains"; - -const project = python({ path: "." }); - -const pipelines: PipelineDefinition[] = [ - { - slug: "ci", - triggers: [push({ branch: "main" })], - pipeline: pipeline( - [ - project.test(), - project.lint(), - project.fmt(), - project.typecheck(), - ], - ), - }, -]; - -export default pipelines; -``` - -
- ```sh hm run ci ``` @@ -372,7 +338,7 @@ input reference, sub-actions, and caching details. ## Examples The [`examples/`](./examples) directory has a complete, runnable pipeline for -each stack — every one shipped in **both** Python and TypeScript: +each stack: | | | | |---|---|---| diff --git a/crates/hm-dsl-engine/Cargo.toml b/crates/hm-dsl-engine/Cargo.toml index 4c489a2a..4d0eb80c 100644 --- a/crates/hm-dsl-engine/Cargo.toml +++ b/crates/hm-dsl-engine/Cargo.toml @@ -4,7 +4,7 @@ version = "0.0.0-dev" edition.workspace = true license.workspace = true repository.workspace = true -description = "DSL engine: evaluate Python/TypeScript pipeline definitions via system runtimes." +description = "DSL engine: evaluate Python pipeline definitions via system runtimes." keywords = ["ci", "harmont", "dsl"] categories = ["command-line-utilities"] diff --git a/crates/hm-dsl-engine/build.rs b/crates/hm-dsl-engine/build.rs deleted file mode 100644 index c66e0f31..00000000 --- a/crates/hm-dsl-engine/build.rs +++ /dev/null @@ -1,68 +0,0 @@ -// Build scripts legitimately panic on errors — no runtime to propagate to. -#![allow( - clippy::expect_used, - clippy::unwrap_used, - clippy::panic, - clippy::print_stderr -)] - -use std::env; -use std::path::{Path, PathBuf}; -use std::process::Command; - -fn main() { - let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); - let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); - let ts_src = manifest_dir.join("harmont-ts/src"); - - println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rerun-if-changed={}", ts_src.display()); - - let esbuild = find_esbuild(&manifest_dir); - - let Some(esbuild) = esbuild else { - panic!( - "esbuild not found.\ - Install it or run `npm ci` in crates/hm-dsl-engine/harmont-ts/ for generating js bundles before building the crate." - ); - }; - - bundle( - &esbuild, - &ts_src.join("index.ts"), - &out_dir.join("harmont-index.mjs"), - ); - bundle( - &esbuild, - &ts_src.join("toolchains/index.ts"), - &out_dir.join("harmont-toolchains.mjs"), - ); -} - -fn bundle(esbuild: &Path, entry: &Path, outfile: &Path) { - let status = Command::new(esbuild) - .arg(entry) - .arg("--bundle") - .arg("--format=esm") - .arg("--platform=node") - .arg(format!("--outfile={}", outfile.display())) - .status() - .expect("failed to run esbuild"); - - assert!(status.success(), "esbuild failed for {}", entry.display()); -} - -fn find_esbuild(manifest_dir: &Path) -> Option { - let local = manifest_dir.join("harmont-ts/node_modules/.bin/esbuild"); - if local.exists() { - return Some(local); - } - let which = Command::new("which").arg("esbuild").output().ok()?; - if which.status.success() { - let path = String::from_utf8_lossy(&which.stdout).trim().to_string(); - if !path.is_empty() { - return Some(PathBuf::from(path)); - } - } - None -} diff --git a/crates/hm-dsl-engine/harmont-ts/CLAUDE.md b/crates/hm-dsl-engine/harmont-ts/CLAUDE.md deleted file mode 100644 index 51730277..00000000 --- a/crates/hm-dsl-engine/harmont-ts/CLAUDE.md +++ /dev/null @@ -1,28 +0,0 @@ -# harmont (TypeScript DSL) - -TypeScript pipeline DSL — equivalent of `harmont-py/` (sibling directory). - -## 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/crates/hm-dsl-engine/harmont-ts/package-lock.json b/crates/hm-dsl-engine/harmont-ts/package-lock.json deleted file mode 100644 index 64eb979b..00000000 --- a/crates/hm-dsl-engine/harmont-ts/package-lock.json +++ /dev/null @@ -1,1628 +0,0 @@ -{ - "name": "@harmont/hm", - "version": "0.0.0-dev", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@harmont/hm", - "version": "0.0.0-dev", - "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/crates/hm-dsl-engine/harmont-ts/package.json b/crates/hm-dsl-engine/harmont-ts/package.json deleted file mode 100644 index 77500f1a..00000000 --- a/crates/hm-dsl-engine/harmont-ts/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@harmont/hm", - "version": "0.0.0-dev", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/harmont-dev/harmont-cli.git", - "directory": "crates/hm-dsl-engine/harmont-ts" - }, - "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", - "typecheck": "tsc --noEmit", - "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/crates/hm-dsl-engine/harmont-ts/src/cache.ts b/crates/hm-dsl-engine/harmont-ts/src/cache.ts deleted file mode 100644 index fc423c6d..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/cache.ts +++ /dev/null @@ -1,41 +0,0 @@ -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/crates/hm-dsl-engine/harmont-ts/src/duration.ts b/crates/hm-dsl-engine/harmont-ts/src/duration.ts deleted file mode 100644 index 37eb142e..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/duration.ts +++ /dev/null @@ -1,51 +0,0 @@ -// src/duration.ts -/** - * Parse a human duration to a positive integer number of seconds. - * - * Accepts a Go-style string ("30s", "5m", "1h30m"; units h, m, s) or a - * number of seconds. Used by `timeout()` and `pipeline({ timeout })`. - */ -const DURATION_RE = /^(?:\d+[hms])+$/; -const SEGMENT_RE = /(\d+)([hms])/g; -const UNIT_SECONDS: Record = { h: 3600, m: 60, s: 1 }; - -export function parseDuration(value: string | number): number { - let seconds: number; - if (typeof value === "number") { - if (!Number.isInteger(value)) { - throw new Error( - `hm: timeout duration must be a whole number of seconds — got ${value}`, - ); - } - seconds = value; - } else if (typeof value === "string") { - seconds = parseStr(value); - } else { - throw new Error( - `hm: timeout duration must be a string or number — got ${typeof value}`, - ); - } - - if (seconds <= 0) { - throw new Error( - `hm: timeout duration must be positive — got ${JSON.stringify(value)}\n` + - ` → use a value like "30s" or "5m"`, - ); - } - return seconds; -} - -function parseStr(text: string): number { - const stripped = text.trim(); - if (!DURATION_RE.test(stripped)) { - throw new Error( - `hm: invalid timeout duration ${JSON.stringify(text)}\n` + - ` → use a Go-style duration like "30s", "5m", or "1h30m" (units: h, m, s)`, - ); - } - let total = 0; - for (const m of stripped.matchAll(SEGMENT_RE)) { - total += Number(m[1]) * UNIT_SECONDS[m[2]]; - } - return total; -} diff --git a/crates/hm-dsl-engine/harmont-ts/src/envelope.ts b/crates/hm-dsl-engine/harmont-ts/src/envelope.ts deleted file mode 100644 index 7fc9f18b..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/envelope.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { resolvePipelineCacheKeys } from "./keygen.js"; -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; -} - -export interface RenderOptions { - readonly basePath?: string; - readonly pipelineOrg?: string; - readonly now?: number; - readonly env?: Readonly>; -} - -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[], - opts?: RenderOptions, -): string { - const pipelineOrg = - opts?.pipelineOrg ?? - (typeof process !== "undefined" - ? process.env.HM_PIPELINE_ORG - : undefined) ?? - "default"; - const now = opts?.now ?? Math.floor(Date.now() / 1000); - const basePath = opts?.basePath; - const env: Readonly> = - opts?.env ?? - (typeof process !== "undefined" - ? (process.env as Record) - : {}); - - const envelope: EnvelopeJSON = { - schema_version: "1", - pipelines: definitions.map((def) => { - const entry: EnvelopePipelineJSON = { - slug: def.slug, - name: def.name ?? def.slug, - allow_manual: def.allowManual ?? true, - triggers: (def.triggers ?? []).map((t) => t.toJSON()), - definition: def.pipeline, - }; - - const hasCachedSteps = entry.definition.graph.nodes.some( - (node) => { - const policy = node.step.cache as - | { policy: string } - | undefined; - return policy != null && policy.policy !== "none"; - }, - ); - - if (hasCachedSteps && basePath == null) { - throw new Error( - `Pipeline "${def.slug}" contains cached steps but no basePath was provided. ` + - `Pass { basePath: process.cwd() } as the second argument to renderEnvelope().`, - ); - } - - if (basePath != null) { - resolvePipelineCacheKeys(entry.definition.graph, { - pipelineOrg, - pipelineSlug: def.slug, - now, - basePath, - env, - }); - } - - return entry; - }), - }; - return JSON.stringify(envelope); -} diff --git a/crates/hm-dsl-engine/harmont-ts/src/index.ts b/crates/hm-dsl-engine/harmont-ts/src/index.ts deleted file mode 100644 index c950c850..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -export { Step, scratch, sh, timeout, 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, - push, - pullRequest, -} from "./triggers.js"; -export { pipeline, type PipelineIR, type PipelineOptions } from "./pipeline.js"; -export { target, clearTargetCache } from "./target.js"; -export { aptBase } from "./toolchains/shared.js"; -export { - renderEnvelope, - type PipelineDefinition, - type RenderOptions, -} from "./envelope.js"; -export { - resolvePipelineCacheKeys, - type CacheKeyOptions, -} from "./keygen.js"; diff --git a/crates/hm-dsl-engine/harmont-ts/src/keygen.ts b/crates/hm-dsl-engine/harmont-ts/src/keygen.ts deleted file mode 100644 index 2867b3c5..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/keygen.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { createHash } from "node:crypto"; -import { - readFileSync, - readdirSync, - statSync, - existsSync, -} from "node:fs"; -import { join, resolve as resolvePath, relative } from "node:path"; -import type { PipelineIR } from "./pipeline.js"; - -const NUL = "\0"; - -export interface CacheKeyOptions { - readonly pipelineOrg: string; - readonly pipelineSlug: string; - readonly now: number; - readonly basePath: string; - readonly env: Readonly>; -} - -export function resolvePipelineCacheKeys( - graph: PipelineIR["graph"], - opts: CacheKeyOptions, -): void { - const nodes = graph.nodes; - const edges = graph.edges; - - const keyByIdx = new Map(); - for (let i = 0; i < nodes.length; i++) { - keyByIdx.set(i, nodes[i].step.key as string); - } - - const parentKeyMap = new Map(); - for (const [src, dst, kind] of edges) { - if (kind === "builds_in") { - parentKeyMap.set(keyByIdx.get(dst)!, keyByIdx.get(src)!); - } - } - - const resolved = new Map(); - - for (const node of nodes) { - const step = node.step; - const cache = step.cache as Record | undefined; - if (!cache || cache.policy === "none") continue; - - const cmd = (step.cmd as string) ?? ""; - const stepKey = step.key as string; - const parentStepKey = parentKeyMap.get(stepKey); - const parentResolved = lookupParent(parentStepKey, resolved); - const policyRes = resolvePolicy(cache, cmd, opts); - - const key = sha256hex( - opts.pipelineOrg + - NUL + - opts.pipelineSlug + - NUL + - stepKey + - NUL + - parentResolved + - NUL + - policyRes, - ); - - cache.key = key; - resolved.set(stepKey, key); - } -} - -function lookupParent( - parentStepKey: string | undefined, - resolved: Map, -): string { - if (parentStepKey == null) return "scratch"; - const key = resolved.get(parentStepKey); - if (key == null) { - throw new Error( - `step references builds_in "${parentStepKey}" which has no cached key (parent must be defined upstream and cached)`, - ); - } - return key; -} - -function resolvePolicy( - cache: Record, - cmd: string, - opts: CacheKeyOptions, -): string { - const policy = cache.policy as string; - - if (policy === "none") return "none"; - - if (policy === "forever") { - const envKeys = (cache.env_keys as string[]) ?? []; - return "forever-" + sha256hex(cmd + NUL + envSubset(envKeys, opts.env)); - } - - if (policy === "ttl") { - const duration = cache.duration_seconds as number; - const bucket = Math.floor(opts.now / duration); - const envKeys = (cache.env_keys as string[]) ?? []; - return ( - "ttl-" + - bucket + - "-" + - sha256hex(cmd + NUL + envSubset(envKeys, opts.env)) - ); - } - - if (policy === "on_change") { - const paths = (cache.paths as string[]) ?? []; - const resolvedPaths: string[] = []; - for (const p of [...paths].sort()) { - if (/[*?[]/.test(p)) { - resolvedPaths.push(...globPaths(opts.basePath, p)); - } else { - const full = resolvePath(opts.basePath, p); - if (existsSync(full)) resolvedPaths.push(full); - } - } - const pre = resolvedPaths.map((r) => pathHash(r) + NUL).join(""); - return "sha-" + sha256hex(pre); - } - - if (policy === "compose") { - const subs = cache.sub_policies as Record[]; - const parts = subs.map((sub) => - sub.policy !== "none" ? resolvePolicy(sub, cmd, opts) : "none", - ); - return "compose-" + sha256hex(parts.join("")); - } - - throw new Error(`resolve-policy-key: unknown policy "${policy}"`); -} - -function envSubset( - envKeys: readonly string[], - env: Readonly>, -): string { - const sorted = [...envKeys].sort(); - return sorted.map((k) => k + "=" + (env[k] ?? "") + NUL).join(""); -} - -function pathHash(fullPath: string): string { - const stat = statSync(fullPath); - if (stat.isFile()) { - return sha256hex(readFileSync(fullPath)); - } - if (stat.isDirectory()) { - const h = createHash("sha256"); - const files = walkDir(fullPath).sort(); - for (const child of files) { - const rel = relative(fullPath, child).split("\\").join("/"); - h.update(rel, "utf8"); - h.update(NUL); - h.update(readFileSync(child)); - h.update(NUL); - } - return h.digest("hex"); - } - throw new Error(`on_change path does not exist: ${fullPath}`); -} - -function walkDir(dir: string): string[] { - const results: string[] = []; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const full = join(dir, entry.name); - if (entry.isDirectory()) { - results.push(...walkDir(full)); - } else if (entry.isFile()) { - results.push(full); - } - } - return results; -} - -function globPaths(basePath: string, pattern: string): string[] { - const parts = pattern.split("/"); - let candidates = [basePath]; - - for (const part of parts) { - const next: string[] = []; - for (const dir of candidates) { - if (!existsSync(dir) || !statSync(dir).isDirectory()) continue; - if (part === "**") { - next.push(dir); - next.push(...walkDir(dir).map((f) => join(f, ".."))); - } else if (/[*?[]/.test(part)) { - const re = globPartToRegex(part); - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (re.test(entry.name)) { - next.push(join(dir, entry.name)); - } - } - } else { - const full = join(dir, part); - if (existsSync(full)) next.push(full); - } - } - candidates = next; - } - - return candidates - .filter((p) => existsSync(p) && statSync(p).isFile()) - .sort(); -} - -function globPartToRegex(part: string): RegExp { - let re = "^"; - for (const ch of part) { - if (ch === "*") re += ".*"; - else if (ch === "?") re += "."; - else re += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - } - re += "$"; - return new RegExp(re); -} - -function sha256hex(data: string | Buffer): string { - return createHash("sha256").update(data).digest("hex"); -} diff --git a/crates/hm-dsl-engine/harmont-ts/src/keys.ts b/crates/hm-dsl-engine/harmont-ts/src/keys.ts deleted file mode 100644 index 75408b3c..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/keys.ts +++ /dev/null @@ -1,84 +0,0 @@ -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/crates/hm-dsl-engine/harmont-ts/src/pipeline.ts b/crates/hm-dsl-engine/harmont-ts/src/pipeline.ts deleted file mode 100644 index bf745598..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/pipeline.ts +++ /dev/null @@ -1,206 +0,0 @@ -import type { CachePolicy } from "./cache.js"; -import { parseDuration } from "./duration.js"; -import { resolveKeys } from "./keys.js"; -import type { Step } from "./step.js"; - -// Across-the-board default image for imageless root steps. The SDK's -// toolchains assume an apt-capable base (apt-get), so ubuntu:24.04 is the -// universal default; child steps boot from their parent's snapshot. -const DEFAULT_IMAGE = "ubuntu:24.04"; - -export interface PipelineOptions { - readonly env?: Readonly>; - readonly timeout?: string | number; -} - -export interface PipelineIR { - version: string; - timeout_seconds?: number; - graph: { - nodes: GraphNode[]; - node_holes: never[]; - edge_property: "directed"; - edges: [number, number, string][]; - }; -} - -interface GraphNode { - step: Record; - env: Record; -} - -export function pipeline( - leaves: Step[], - opts?: PipelineOptions, -): PipelineIR { - if (!Array.isArray(leaves)) { - throw new Error("pipeline() expects an array of steps as its first argument"); - } - - if (leaves.length === 0) { - throw new Error( - "pipeline must have at least one leaf — pass the terminal step(s) of each branch as the first argument", - ); - } - - const ir: PipelineIR = { version: "0", graph: lowerToGraph(leaves, opts) }; - if (opts?.timeout != null) { - ir.timeout_seconds = parseDuration(opts.timeout); - } - 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 = { - DEBIAN_FRONTEND: "noninteractive", - TERM: "dumb", - }; - 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); - } - - for (let i = 0; i < nodes.length; i++) { - if (!hasBuildsInParent.has(i) && !("image" in nodes[i].step)) { - nodes[i].step.image = DEFAULT_IMAGE; - } - } - - 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/crates/hm-dsl-engine/harmont-ts/src/step.ts b/crates/hm-dsl-engine/harmont-ts/src/step.ts deleted file mode 100644 index 5803c0cf..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/step.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { CachePolicy } from "./cache.js"; -import { parseDuration } from "./duration.js"; - -export interface StepOptions { - readonly label?: string; - readonly cache?: CachePolicy; - readonly env?: Readonly>; - 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, - 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, - }); - } - - /** @internal — returns a copy with the timeout set; preserves the chain. */ - withTimeoutSeconds(seconds: number): Step { - return new Step({ - cmd: this._cmd, - parent: this._parent, - isWait: this._isWait, - continueOnFailure: this._continueOnFailure, - label: this._label, - cache: this._cache, - env: this._env as Record | undefined, - timeoutSeconds: seconds, - image: this._image, - runner: this._runner, - runnerArgs: this._runnerArgs as Record | undefined, - keyOverride: this._keyOverride, - }); - } -} - -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, - }); -} - -/** - * Apply a wall-clock timeout to a single step. The executor (and `hm run` - * locally) kills the step once `duration` elapses; the step then fails as - * timed out. Wrapping a step that already has a timeout replaces it. - * - * @param duration "30s" / "5m" / "1h30m" (units h, m, s) or a number of seconds. - * @param step A command step (not a `wait` barrier). - */ -export function timeout(duration: string | number, step: Step): Step { - if (step._isWait) { - throw new Error( - 'hm: timeout() cannot wrap a wait() barrier\n' + - ' → apply timeout() to a command step, e.g. timeout("30s", sh("make test"))', - ); - } - return step.withTimeoutSeconds(parseDuration(duration)); -} diff --git a/crates/hm-dsl-engine/harmont-ts/src/target.ts b/crates/hm-dsl-engine/harmont-ts/src/target.ts deleted file mode 100644 index 0aa289d4..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/target.ts +++ /dev/null @@ -1,15 +0,0 @@ -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/crates/hm-dsl-engine/harmont-ts/src/toolchains/cargo.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/cargo.ts deleted file mode 100644 index 7fedf4e2..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/cargo.ts +++ /dev/null @@ -1,96 +0,0 @@ -// Shared cargo-argument assembly for the Rust toolchain helper. -// -// Mirrors harmont-py/harmont/_cargo.py exactly so both DSLs emit identical -// cargo command strings. User-supplied *values* are shell-quoted; raw `flags` -// pass through verbatim. `exclude` pairs with `--workspace` (cargo requires it). - -export interface CargoOpts { - readonly workspace?: boolean; - readonly packages?: readonly string[]; - readonly exclude?: readonly string[]; - readonly allFeatures?: boolean; - readonly noDefaultFeatures?: boolean; - readonly features?: readonly string[]; - readonly target?: string; - readonly allTargets?: boolean; - readonly release?: boolean; - readonly profile?: string; - readonly locked?: boolean; - readonly flags?: readonly string[]; -} - -// POSIX single-quote escaping, byte-for-byte identical to Python's shlex.quote: -// safe characters are left bare; everything else is wrapped in single quotes -// with embedded single quotes rendered as '"'"'. The empty string becomes ''. -const SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/; -export function shQuote(s: string): string { - if (s.length === 0) return "''"; - if (SAFE.test(s)) return s; - return "'" + s.replace(/'/g, "'\"'\"'") + "'"; -} - -function validate(o: CargoOpts): void { - if (o.allFeatures && ((o.features?.length ?? 0) > 0 || o.noDefaultFeatures)) { - throw new Error( - "rust: --all-features conflicts with features/noDefaultFeatures\n" + - ` observed: allFeatures=true, features=${JSON.stringify(o.features ?? [])}, ` + - `noDefaultFeatures=${o.noDefaultFeatures ?? false}\n` + - " → pass allFeatures alone, or list explicit features without allFeatures", - ); - } - if (o.release && o.profile !== undefined) { - throw new Error( - "rust: release conflicts with profile\n" + - ` observed: release=true, profile=${JSON.stringify(o.profile)}\n` + - ' → use profile: "release" (identical effect) or drop one', - ); - } - if (o.exclude?.length) { - if (o.packages?.length) { - throw new Error( - "rust: exclude cannot combine with packages\n" + - ` observed: packages=${JSON.stringify(o.packages)}, exclude=${JSON.stringify(o.exclude)}\n` + - " → --exclude pairs with --workspace; packages already selects explicitly, so drop one", - ); - } - if (!o.workspace) { - throw new Error( - "rust: exclude requires workspace\n" + - ` observed: exclude=${JSON.stringify(o.exclude)} without workspace=true\n` + - " → cargo --exclude only applies to --workspace; pass workspace: true", - ); - } - } -} - -export function cargoFlags(o: CargoOpts): string { - validate(o); - const toks: string[] = []; - - if (o.packages?.length) { - for (const p of o.packages) toks.push(`-p ${shQuote(p)}`); - } else if (o.workspace) { - toks.push("--workspace"); - for (const e of o.exclude ?? []) toks.push(`--exclude ${shQuote(e)}`); - } - - if (o.allTargets) toks.push("--all-targets"); - - if (o.allFeatures) { - toks.push("--all-features"); - } else { - if (o.noDefaultFeatures) toks.push("--no-default-features"); - if (o.features?.length) toks.push(`--features ${shQuote(o.features.join(","))}`); - } - - if (o.target !== undefined) toks.push(`--target ${shQuote(o.target)}`); - - if (o.profile !== undefined) toks.push(`--profile ${shQuote(o.profile)}`); - else if (o.release) toks.push("--release"); - - if (o.locked ?? true) toks.push("--locked"); - - if (o.flags?.length) toks.push(...o.flags); - - return toks.length ? " " + toks.join(" ") : ""; -} diff --git a/crates/hm-dsl-engine/harmont-ts/src/toolchains/cmake.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/cmake.ts deleted file mode 100644 index ca30e36f..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/cmake.ts +++ /dev/null @@ -1,350 +0,0 @@ -import type { Step, StepOptions } from "../step.js"; -import { forever, onChange, type CachePolicy } from "../cache.js"; -import { makeInstallChain } from "./shared.js"; - -const COMPILER_RE = /^(gcc|clang)(-\d+)?$/; - -type ActionOptions = Omit; - -export interface CMakeToolchainOptions { - readonly compiler?: string; - readonly generator?: "ninja" | "make"; - readonly ccache?: boolean; - readonly image?: string; - readonly base?: Step; -} - -export interface CMakeProjectOptions { - readonly path?: string; - readonly preset?: string; - readonly defines?: Record; - readonly deps?: "vcpkg" | null; - readonly target?: string; - readonly cache?: CachePolicy; -} - -export type CMakeOptions = CMakeToolchainOptions & CMakeProjectOptions; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function aptPackages( - compiler: string | undefined, - ccache: boolean, - generator: string, -): string[] { - const pkgs: string[] = ["cmake", "build-essential", "pkg-config"]; - if (generator === "ninja") pkgs.push("ninja-build"); - if (ccache) pkgs.push("ccache"); - pkgs.push("clang-format"); - pkgs.push("clang-tidy"); - if (compiler != null) { - const m = COMPILER_RE.exec(compiler); - if (m == null) { - throw new Error( - `hm.cmake: invalid compiler "${compiler}"\n → use "gcc", "gcc-14", "clang", or "clang-18"`, - ); - } - const family = m[1]; - const suffix = m[2] ?? ""; - if (family === "gcc") { - pkgs.push(`gcc${suffix}`, `g++${suffix}`); - } else { - pkgs.push(`clang${suffix}`, `lld${suffix}`); - } - } - return pkgs; -} - -function verifyCmd( - compiler: string | undefined, - ccache: boolean, - generator: string, -): string { - const parts: string[] = ["cmake --version"]; - if (generator === "ninja") parts.push("ninja --version"); - if (ccache) parts.push("ccache --version"); - if (compiler != null) { - const m = COMPILER_RE.exec(compiler); - if (m) { - const family = m[1]; - const suffix = m[2] ?? ""; - if (family === "gcc") { - parts.push(`gcc${suffix} --version`); - } else { - parts.push(`clang${suffix} --version`); - } - } - } - return parts.join(" && "); -} - -function configureCmd(opts: { - path: string; - preset: string | undefined; - defines: Record | undefined; - compiler: string | undefined; - ccache: boolean; - generator: string; - buildDir?: string; -}): string { - if (opts.preset != null) { - return `cd ${opts.path} && cmake --preset ${opts.preset}`; - } - - const buildDir = opts.buildDir ?? "build"; - const genFlag = opts.generator === "ninja" ? "Ninja" : "Unix Makefiles"; - const parts: string[] = [ - `cd ${opts.path} && cmake -S . -B ${buildDir}`, - `-G ${genFlag}`, - "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", - ]; - - if (opts.ccache) { - parts.push("-DCMAKE_C_COMPILER_LAUNCHER=ccache"); - parts.push("-DCMAKE_CXX_COMPILER_LAUNCHER=ccache"); - } - - if (opts.compiler != null) { - const m = COMPILER_RE.exec(opts.compiler); - if (m) { - const family = m[1]; - const suffix = m[2] ?? ""; - if (family === "gcc") { - parts.push(`-DCMAKE_C_COMPILER=gcc${suffix}`); - parts.push(`-DCMAKE_CXX_COMPILER=g++${suffix}`); - } else { - parts.push(`-DCMAKE_C_COMPILER=clang${suffix}`); - parts.push(`-DCMAKE_CXX_COMPILER=clang++${suffix}`); - } - } - } - - if (opts.defines) { - for (const [k, v] of Object.entries(opts.defines)) { - parts.push(`-D${k}=${v}`); - } - } - - return parts.join(" "); -} - -function buildCmd( - path: string, - target: string | undefined, - buildDir: string = "build", - relative: boolean = false, -): string { - const prefix = relative ? buildDir : `${path}/${buildDir}`; - let cmd = `cmake --build ${prefix} --parallel $(nproc)`; - if (target != null) { - cmd += ` --target ${target}`; - } - return cmd; -} - -// --------------------------------------------------------------------------- -// CMakeToolchain -// --------------------------------------------------------------------------- - -export class CMakeToolchain { - private readonly _installed: Step; - readonly compiler: string | undefined; - readonly ccache: boolean; - readonly generator: string; - - constructor( - installed: Step, - compiler: string | undefined, - ccache: boolean, - generator: string, - ) { - this._installed = installed; - this.compiler = compiler; - this.ccache = ccache; - this.generator = generator; - } - - install(): Step { - return this._installed; - } - - /** Append a post-install command and return an advanced toolchain; chainable. - * For prep steps the toolchain's actions must depend on but the SDK does not - * model natively (codegen, fixtures, extra tooling). Project actions forked - * off this toolchain see its results. (On built-based projects, splice prep - * here, pre-configure: hm.cmake().setup("…").project({ path: "." }).) - * @example hm.cmake().setup("conan install .").project({ path: "." }) */ - setup(cmd: string, opts?: StepOptions): CMakeToolchain { - return new CMakeToolchain(this._installed.sh(cmd, opts), this.compiler, this.ccache, this.generator); - } - - project(opts?: CMakeProjectOptions): CMakeProject { - const path = opts?.path ?? "."; - const preset = opts?.preset; - const defines = opts?.defines; - const deps = opts?.deps; - const target = opts?.target; - const cache = opts?.cache; - - const configure = configureCmd({ - path, - preset, - defines, - compiler: this.compiler, - ccache: this.ccache, - generator: this.generator, - }); - const warmupCmd = `${configure} && ${buildCmd(path, target, "build", true)}`; - - // Determine warmup cache policy - let warmupCache: CachePolicy; - if (cache != null) { - warmupCache = cache; - } else if (deps === "vcpkg") { - warmupCache = onChange("vcpkg.json"); - } else { - const cmakelists = - path !== "." ? `${path}/CMakeLists.txt` : "CMakeLists.txt"; - warmupCache = onChange(cmakelists); - } - - // Determine the parent for the warmup step - let warmupParent: Step; - if (deps === "vcpkg") { - const vcpkgCmd = [ - "git clone https://github.com/microsoft/vcpkg.git /opt/vcpkg", - "/opt/vcpkg/bootstrap-vcpkg.sh", - `cd ${path} && /opt/vcpkg/vcpkg install`, - ].join(" && "); - warmupParent = this._installed.sh(vcpkgCmd, { - label: ":cmake: vcpkg", - cache: onChange("vcpkg.json"), - }); - } else { - warmupParent = this._installed; - } - - const built = warmupParent.sh(warmupCmd, { - label: ":cmake: build", - cache: warmupCache, - }); - - return new CMakeProject(this, built, path); - } -} - -// --------------------------------------------------------------------------- -// CMakeProject -// --------------------------------------------------------------------------- - -export class CMakeProject { - readonly toolchain: CMakeToolchain; - private readonly _built: Step; - readonly path: string; - - constructor(toolchain: CMakeToolchain, built: Step, path: string) { - this.toolchain = toolchain; - this._built = built; - this.path = path; - } - - build(): Step { - return this._built; - } - - test(opts?: ActionOptions & { parallel?: boolean }): Step { - const parallel = opts?.parallel ?? true; - const { parallel: _, ...rest } = opts ?? {}; - const parallelFlag = parallel ? " --parallel $(nproc)" : ""; - const cmd = [ - buildCmd(this.path, undefined), - `ctest --test-dir ${this.path}/build --output-on-failure${parallelFlag}`, - ].join(" && "); - return this._built.sh(cmd, { label: ":cmake: test", ...rest }); - } - - install(opts?: ActionOptions & { prefix?: string }): Step { - const prefixFlag = opts?.prefix ? ` --prefix ${opts.prefix}` : ""; - const { prefix: _, ...rest } = opts ?? {}; - const cmd = `cmake --install ${this.path}/build${prefixFlag}`; - return this._built.sh(cmd, { label: ":cmake: install", ...rest }); - } - - fmt(opts?: ActionOptions & { fix?: boolean }): Step { - const mode = opts?.fix ? "-i" : "--dry-run --Werror"; - const { fix: _, ...rest } = opts ?? {}; - const cmd = [ - `cd ${this.path} && find . -not -path './build/*'`, - `\\( -name '*.c' -o -name '*.h'`, - `-o -name '*.cpp' -o -name '*.hpp' -o -name '*.cc' -o -name '*.cxx' \\) |`, - `xargs clang-format ${mode}`, - ].join(" "); - return this.toolchain.install().sh(cmd, { label: ":cmake: fmt", ...rest }); - } - - lint(opts?: ActionOptions): Step { - const cmd = `cd ${this.path} && run-clang-tidy -p build`; - return this._built.sh(cmd, { label: ":cmake: lint", ...opts }); - } - - package(opts?: ActionOptions & { generator?: string }): Step { - const { generator, ...rest } = opts ?? {}; - const genFlag = generator ? ` -G ${generator}` : ""; - const cmd = `cd ${this.path}/build && cpack${genFlag}`; - return this._built.sh(cmd, { label: ":cmake: package", ...rest }); - } -} - -// --------------------------------------------------------------------------- -// Factory function (overloaded) -// --------------------------------------------------------------------------- - -export function cmake(opts: CMakeOptions & { path: string }): CMakeProject; -export function cmake(opts?: CMakeToolchainOptions): CMakeToolchain; -export function cmake(opts?: CMakeOptions): CMakeToolchain | CMakeProject { - const compiler = opts?.compiler; - const generator = opts?.generator ?? "ninja"; - const ccache = opts?.ccache ?? true; - - if (generator !== "ninja" && generator !== "make") { - throw new Error( - `hm.cmake: invalid generator "${generator}"\n → use "ninja" or "make"`, - ); - } - - if (compiler != null && !COMPILER_RE.test(compiler)) { - throw new Error( - `hm.cmake: invalid compiler "${compiler}"\n → use "gcc", "gcc-14", "clang", or "clang-18"`, - ); - } - - const pkgs = aptPackages(compiler, ccache, generator); - const verify = verifyCmd(compiler, ccache, generator); - - const installed = makeInstallChain({ - aptPackages: pkgs, - installCmd: verify, - installCache: forever(), - langTag: "cmake", - installTag: "verify", - image: opts?.image, - base: opts?.base, - }); - - const toolchain = new CMakeToolchain(installed, compiler, ccache, generator); - - if (opts?.path != null) { - return toolchain.project({ - path: opts.path, - preset: (opts as CMakeProjectOptions).preset, - defines: (opts as CMakeProjectOptions).defines, - deps: (opts as CMakeProjectOptions).deps, - target: (opts as CMakeProjectOptions).target, - cache: (opts as CMakeProjectOptions).cache, - }); - } - - return toolchain; -} diff --git a/crates/hm-dsl-engine/harmont-ts/src/toolchains/detect.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/detect.ts deleted file mode 100644 index d037b926..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/detect.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { readFileSync, readdirSync } from "node:fs"; -import { join } from "node:path"; - -type Runtime = "node" | "bun" | "deno"; -type Pm = "npm" | "pnpm" | "yarn-classic" | "yarn-berry" | "bun"; - -export interface DetectedToolchain { - runtime?: Runtime; - pm?: Pm; - pmVersion?: string; -} - -export function detectFromPackageJson( - packageJson: Record, -): DetectedToolchain { - const result: DetectedToolchain = {}; - - const engines = packageJson.engines; - if (engines != null && typeof engines === "object") { - const eng = engines as Record; - if ("bun" in eng) { - result.runtime = "bun"; - result.pm = "bun"; - } else if ("deno" in eng) { - result.runtime = "deno"; - } else if ("node" in eng) { - result.runtime = "node"; - } - } - - if (result.pm == null) { - const pmField = packageJson.packageManager; - if (typeof pmField === "string") { - const name = pmField.split("@")[0]; - const version = pmField.split("@")[1]; - if (name === "pnpm") result.pm = "pnpm"; - else if (name === "bun") result.pm = "bun"; - else if (name === "npm") result.pm = "npm"; - else if (name === "yarn") { - result.pm = version && parseInt(version, 10) >= 2 ? "yarn-berry" : "yarn-classic"; - } - if (version) result.pmVersion = version; - } - } - - return result; -} - -export function detectFromLockfiles( - files: readonly string[], -): DetectedToolchain { - const set = new Set(files); - - if (set.has("bun.lock") || set.has("bun.lockb")) { - return { pm: "bun", runtime: "bun" }; - } - if (set.has("pnpm-lock.yaml")) { - return { pm: "pnpm" }; - } - if (set.has("deno.lock")) { - return { runtime: "deno" }; - } - if (set.has("package-lock.json")) { - return { pm: "npm" }; - } - if (set.has("yarn.lock")) { - return { pm: "yarn-classic" }; - } - - return {}; -} - -export function detect(path: string): DetectedToolchain { - let fromPkg: DetectedToolchain = {}; - try { - const raw = readFileSync(join(path, "package.json"), "utf8"); - fromPkg = detectFromPackageJson(JSON.parse(raw)); - } catch { - // no package.json or invalid JSON - } - - let fromLock: DetectedToolchain = {}; - try { - fromLock = detectFromLockfiles(readdirSync(path)); - } catch { - // directory unreadable - } - - const result: DetectedToolchain = {}; - const runtime = fromPkg.runtime ?? fromLock.runtime; - const pm = fromPkg.pm ?? fromLock.pm; - if (runtime != null) result.runtime = runtime; - if (pm != null) result.pm = pm; - const pmVersion = fromPkg.pmVersion; - if (pmVersion != null) result.pmVersion = pmVersion; - return result; -} diff --git a/crates/hm-dsl-engine/harmont-ts/src/toolchains/elixir.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/elixir.ts deleted file mode 100644 index 38f46527..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/elixir.ts +++ /dev/null @@ -1,209 +0,0 @@ -import type { Step, StepOptions } from "../step.js"; -import { forever, onChange } from "../cache.js"; -import { makeInstallChain } from "./shared.js"; - -const APT_PACKAGES = [ - "curl", - "ca-certificates", - "git", - "unzip", - "build-essential", - "autoconf", - "libncurses-dev", - "libssl-dev", -] as const; - -const ELIXIR_ENV = { ELIXIR_ERL_OPTIONS: "+fnu" } as const; - -const ELIXIR_VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+$/; -const OTP_VERSION_RE = /^[0-9]+(\.[0-9]+(\.[0-9]+)?)?$/; - -export interface ElixirOptions { - readonly path?: string; - readonly elixirVersion?: string; - readonly otpVersion?: string; - readonly image?: string; - readonly base?: Step; -} - -type ActionOptions = Omit; - -export class ElixirProject { - readonly path: string; - private readonly _installed: Step; - private _plt: Step | null = null; - - constructor(path: string, installed: Step) { - this.path = path; - this._installed = installed; - } - - install(): Step { - return this._installed; - } - - /** Append a post-install command and return an advanced project; chainable. - * For prep steps the toolchain's actions must depend on but the SDK does not - * model natively (codegen, fixtures, extra tooling). Action methods on the - * returned object fork from this step. - * @example hm.elixir({ path: "elixir" }).setup("mix proto.gen").compile() */ - setup(cmd: string, opts?: StepOptions): ElixirProject { - return new ElixirProject(this.path, this._installed.sh(cmd, opts)); - } - - private _sh(parent: Step, cmd: string, opts?: ActionOptions): Step { - const { env: userEnv, ...rest } = opts ?? {}; - return parent.sh(cmd, { env: { ...ELIXIR_ENV, ...userEnv }, ...rest }); - } - - compile(opts?: ActionOptions): Step { - return this._sh(this._installed, - `cd ${this.path} && mix compile --warnings-as-errors`, - { label: ":ex: compile", ...opts }, - ); - } - - test(opts?: ActionOptions & { cover?: boolean; partitions?: number }): Step { - const flags: string[] = []; - if (opts?.cover) flags.push("--cover"); - if (opts?.partitions != null) flags.push(`--partitions ${opts.partitions}`); - const { cover: _, partitions: __, ...rest } = opts ?? {}; - const flagStr = flags.length > 0 ? ` ${flags.join(" ")}` : ""; - return this._sh(this._installed, `cd ${this.path} && mix test${flagStr}`, { - label: ":ex: test", - ...rest, - }); - } - - format(opts?: ActionOptions): Step { - return this._sh(this._installed, - `cd ${this.path} && mix format --check-formatted`, - { label: ":ex: format", ...opts }, - ); - } - - credo(opts?: ActionOptions & { strict?: boolean }): Step { - const strict = opts?.strict !== false ? " --strict" : ""; - const { strict: _, ...rest } = opts ?? {}; - return this._sh(this._installed, `cd ${this.path} && mix credo${strict}`, { - label: ":ex: credo", - ...rest, - }); - } - - plt(): Step { - if (this._plt == null) { - this._plt = this._sh(this._installed, `cd ${this.path} && mix dialyzer --plt`, { - label: ":ex: plt", - cache: onChange(`${this.path}/mix.lock`), - }); - } - return this._plt; - } - - dialyzer(opts?: ActionOptions): Step { - return this._sh(this.plt(), `cd ${this.path} && mix dialyzer`, { - label: ":ex: dialyzer", - ...opts, - }); - } - - sobelow(opts?: ActionOptions): Step { - return this._sh(this._installed, `cd ${this.path} && mix sobelow --exit`, { - label: ":ex: sobelow", - ...opts, - }); - } - - depsAudit(opts?: ActionOptions): Step { - return this._sh(this._installed, `cd ${this.path} && mix deps.audit`, { - label: ":ex: deps-audit", - ...opts, - }); - } - - hexAudit(opts?: ActionOptions): Step { - return this._sh(this._installed, `cd ${this.path} && mix hex.audit`, { - label: ":ex: hex-audit", - ...opts, - }); - } - - mix(task: string, opts?: ActionOptions): Step { - return this._sh(this._installed, `cd ${this.path} && mix ${task}`, { - label: `:ex: ${task}`, - ...opts, - }); - } - - release(opts?: ActionOptions & { mixEnv?: string }): Step { - const env = opts?.mixEnv ?? "prod"; - const { mixEnv: _, ...rest } = opts ?? {}; - return this._sh(this._installed, - `cd ${this.path} && MIX_ENV=${env} mix release`, - { label: ":ex: release", ...rest }, - ); - } - -} - -export function elixir(opts?: ElixirOptions): ElixirProject { - const path = opts?.path ?? "."; - const elixirVersion = opts?.elixirVersion ?? "1.18.3"; - const otpVersion = opts?.otpVersion ?? "27.3.3"; - - if (!ELIXIR_VERSION_RE.test(elixirVersion)) { - throw new Error( - `hm.elixir: invalid elixir version "${elixirVersion}"\n → use a semver like "1.18.3"`, - ); - } - - if (!OTP_VERSION_RE.test(otpVersion)) { - throw new Error( - `hm.elixir: invalid otp version "${otpVersion}"\n → use a semver like "27" or "27.3.3"`, - ); - } - - const otpMajor = otpVersion.split(".")[0]; - - const erlangInstallCmd = [ - `curl -fsSL https://binaries2.erlang-solutions.com/debian/pool/contrib/e/esl-erlang/esl-erlang_${otpVersion}-1~debian~bookworm_amd64.deb -o /tmp/erlang.deb`, - "(dpkg -i /tmp/erlang.deb || apt-get install -fy)", - "erl -eval 'erlang:display(erlang:system_info(otp_release)), halt().' -noshell", - ].join(" && "); - - const erlangInstalled = makeInstallChain({ - aptPackages: [...APT_PACKAGES], - installCmd: erlangInstallCmd, - installCache: forever(), - langTag: "ex", - installTag: "erlang-install", - image: opts?.image, - base: opts?.base, - }); - - const elixirInstalled = erlangInstalled.sh( - [ - `curl -fsSL https://github.com/elixir-lang/elixir/releases/download/v${elixirVersion}/elixir-otp-${otpMajor}.zip -o /tmp/elixir.zip`, - "unzip -q /tmp/elixir.zip -d /usr/local/elixir", - "ln -sf /usr/local/elixir/bin/elixir /usr/local/bin/elixir", - "ln -sf /usr/local/elixir/bin/mix /usr/local/bin/mix", - "ln -sf /usr/local/elixir/bin/iex /usr/local/bin/iex", - "mix local.hex --force", - "mix local.rebar --force", - "elixir --version", - ].join(" && "), - { label: ":ex: elixir-install", cache: forever(), env: ELIXIR_ENV }, - ); - - const depsInstalled = elixirInstalled.sh( - `cd ${path} && mix deps.get && mix deps.compile`, - { - label: ":ex: mix-deps", - cache: onChange(`${path}/mix.lock`), - env: ELIXIR_ENV, - }, - ); - - return new ElixirProject(path, depsInstalled); -} diff --git a/crates/hm-dsl-engine/harmont-ts/src/toolchains/go.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/go.ts deleted file mode 100644 index d925ddad..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/go.ts +++ /dev/null @@ -1,97 +0,0 @@ -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; - } - - /** Append a post-install command and return an advanced toolchain; chainable. - * For prep steps the toolchain's actions must depend on but the SDK does not - * model natively (codegen, fixtures, extra tooling). Action methods on the - * returned object fork from this step. - * @example hm.go({ path: "." }).setup("go generate ./...").build() */ - setup(cmd: string, opts?: StepOptions): GoToolchain { - return new GoToolchain(this.path, this._installed.sh(cmd, opts)); - } - - 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/crates/hm-dsl-engine/harmont-ts/src/toolchains/index.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/index.ts deleted file mode 100644 index c65a8d49..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -export { js, ts, JsProject, type JsOptions } from "./js.js"; -export { go, GoToolchain, type GoOptions } from "./go.js"; -export { rust, RustToolchain, RustProject, type RustToolchainOptions, type RustProjectOptions, type FeaturePowersetOptions } from "./rust.js"; -export { python, PythonToolchain, type PythonOptions } from "./python.js"; -export { - cmake, - CMakeToolchain, - CMakeProject, - type CMakeToolchainOptions, - type CMakeProjectOptions, - type CMakeOptions, -} from "./cmake.js"; -export { - zig, - ZigToolchain, - ZigProject, - type ZigOptions, -} from "./zig.js"; -export * as py from "./py/index.js"; -export { elixir, ElixirProject, type ElixirOptions } from "./elixir.js"; diff --git a/crates/hm-dsl-engine/harmont-ts/src/toolchains/js.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/js.ts deleted file mode 100644 index db196327..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/js.ts +++ /dev/null @@ -1,228 +0,0 @@ -import type { Step, StepOptions } from "../step.js"; -import { forever, onChange } from "../cache.js"; -import { - makeInstallChain, - nodeInstallCmd, - bunInstallCmd, - denoInstallCmd, -} from "./shared.js"; -import { detect } from "./detect.js"; - -// Runtimes execute JS/TS; package managers install dependencies. `deno` is a -// runtime only — its dependency management is intrinsic, so it is not a `pm` -// value (see makeProject). yarn's classic/berry split is two pm values rather -// than a version axis because the lockfile install flag differs between them. -type Runtime = "node" | "bun" | "deno"; -type PackageManager = "npm" | "pnpm" | "yarn-classic" | "yarn-berry" | "bun" | "deno"; -type ActionOptions = Omit; - -export interface JsOptions { - readonly path?: string; - readonly pm?: PackageManager; - readonly runtime?: Runtime; - /** Runtime version — Node major ("22"/"22.x") or Bun/Deno semver ("1.2.3"). - * PM versions are pinned by the project's `packageManager` field. */ - readonly version?: string; - readonly image?: string; - readonly base?: Step; -} - -const NODE_VERSION_RE = /^[0-9]+(\.x)?$/; -const SEMVER_RE = /^[0-9]+\.[0-9]+(\.[0-9]+)?$/; - -const LOCKFILES: Record = { - npm: "package-lock.json", - pnpm: "pnpm-lock.yaml", - "yarn-classic": "yarn.lock", - "yarn-berry": "yarn.lock", - bun: "bun.lock", - deno: "deno.lock", -}; - -const DEPS_CMD: Record = { - npm: "npm ci", - pnpm: "pnpm install --frozen-lockfile", - "yarn-classic": "yarn install --frozen-lockfile", - "yarn-berry": "yarn install --immutable", - bun: "bun install --frozen-lockfile", - deno: "deno install", -}; - -const RUN_PREFIX: Record = { - npm: "npm run", - pnpm: "pnpm run", - "yarn-classic": "yarn run", - "yarn-berry": "yarn run", - bun: "bun run", - deno: "deno task", -}; - -/** Command to bring `pm` onto the runtime image, or null when the PM already - * ships with the runtime (npm with node, bun with the bun runtime). */ -function pmBootstrap(pm: PackageManager, runtime: Runtime, version?: string): string | null { - switch (pm) { - case "npm": - return null; // bundled with node - case "bun": - return runtime === "bun" ? null : bunInstallCmd(); - case "deno": - return null; // bundled with deno - case "pnpm": - return version != null - ? `corepack enable pnpm && corepack install -g pnpm@${version}` - : "corepack enable pnpm"; - case "yarn-classic": - case "yarn-berry": - return version != null - ? `corepack enable yarn && corepack install -g yarn@${version}` - : "corepack enable"; - } -} - -export class JsProject { - readonly path: string; - private readonly _installed: Step; - private readonly _runPrefix: string; - private readonly _tag: string; - private readonly _pm: PackageManager; - - constructor(path: string, installed: Step, pm: PackageManager, tag: string) { - this.path = path; - this._installed = installed; - this._runPrefix = RUN_PREFIX[pm]; - this._tag = tag; - this._pm = pm; - } - - /** The dependency-install step (`npm ci`, `bun install`, `deno install`, …). - * Every action attaches to it so installation is shared across CI jobs. */ - install(): Step { - return this._installed; - } - - /** Append a post-install command and return an advanced project; chainable. - * For prep steps the toolchain's actions must depend on but the SDK does not - * model natively (codegen, fixtures, extra tooling). Action methods on the - * returned object fork from this step. - * @example hm.js.project({ path: "web" }).setup("npm run codegen").run("build") */ - setup(cmd: string, opts?: StepOptions): JsProject { - return new JsProject(this.path, this._installed.sh(cmd, opts), this._pm, this._tag); - } - - /** Run a package.json script / deno.json task by name. - * This is the uniform action across all PMs — for native tooling - * (`deno test`, `bun test`) define a script or drop to `.sh()`. */ - run(script: string, opts?: ActionOptions): Step { - return this._installed.sh( - `cd ${this.path} && ${this._runPrefix} ${script}`, - { - label: `:${this._tag}: ${script}`, - ...opts, - }, - ); - } -} - -function validateVersion(runtime: Runtime, version: string): void { - if (runtime === "node") { - if (!NODE_VERSION_RE.test(version)) { - throw new Error( - `js.project: invalid version "${version}"\n → use a Node major version like "22" or "22.x"`, - ); - } - } else if (!SEMVER_RE.test(version)) { - throw new Error( - `js.project: invalid version "${version}"\n → use a semver version like "1.2" or "1.2.0"`, - ); - } -} - -function makeProject(opts?: JsOptions): JsProject { - const path = opts?.path ?? "."; - const detected = - opts?.runtime == null && opts?.pm == null ? detect(path) : {}; - const runtime = opts?.runtime ?? detected.runtime ?? "node"; - - if (opts?.version != null) { - validateVersion(runtime, opts.version); - } - - // --- Deno: built-in PM, no pm option --- - if (runtime === "deno") { - if (opts?.pm != null) { - throw new Error( - `js.project: runtime="deno" manages its own dependencies — do not set pm`, - ); - } - const runtimeInstalled = makeInstallChain({ - aptPackages: ["curl", "ca-certificates", "unzip"], - installCmd: denoInstallCmd(opts?.version), - installCache: forever(), - langTag: "deno", - installTag: "install", - image: opts?.image, - base: opts?.base, - }); - const depsInstalled = runtimeInstalled.sh(`cd ${path} && ${DEPS_CMD.deno}`, { - label: ":deno: deps", - cache: onChange(`${path}/${LOCKFILES.deno}`), - }); - return new JsProject(path, depsInstalled, "deno", "deno"); - } - - // --- Node / Bun runtime --- - const pm: PackageManager = opts?.pm ?? detected.pm ?? (runtime === "bun" ? "bun" : "npm"); - const pmVersion = detected.pmVersion; - - if (pm === "deno") { - throw new Error( - 'js.project: pm="deno" is not valid — use runtime="deno" instead', - ); - } - - if (runtime === "bun" && pm !== "bun") { - throw new Error(`js.project: runtime="bun" only supports pm="bun"`); - } - - const aptPkgs: string[] = ["curl", "ca-certificates"]; - if (runtime === "bun" || pm === "bun") { - aptPkgs.push("unzip"); // bun's installer needs unzip - } - - const langTag = runtime === "bun" ? "bun" : "node"; - const runtimeCmd = - runtime === "bun" - ? bunInstallCmd(opts?.version) - : nodeInstallCmd(opts?.version ?? "22"); - - const runtimeInstalled = makeInstallChain({ - aptPackages: aptPkgs, - installCmd: runtimeCmd, - installCache: forever(), - langTag, - installTag: "install", - image: opts?.image, - base: opts?.base, - }); - - // Layer the package manager onto the runtime image when it isn't bundled. - const bootstrap = pmBootstrap(pm, runtime, pmVersion); - const bootstrapCache = pmVersion != null ? onChange(`${path}/package.json`) : forever(); - const pmReady = - bootstrap == null - ? runtimeInstalled - : runtimeInstalled.sh(bootstrap, { - label: `:${langTag}: ${pm}`, - cache: bootstrapCache, - }); - - const depsInstalled = pmReady.sh(`cd ${path} && ${DEPS_CMD[pm]}`, { - label: `:${langTag}: deps`, - cache: onChange(`${path}/${LOCKFILES[pm]}`), - }); - - return new JsProject(path, depsInstalled, pm, langTag); -} - -export const js = { project: makeProject }; -export const ts = js; diff --git a/crates/hm-dsl-engine/harmont-ts/src/toolchains/py/index.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/py/index.ts deleted file mode 100644 index ee526dc5..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/py/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { uv, UvProject, type UvOptions } from "./uv.js"; diff --git a/crates/hm-dsl-engine/harmont-ts/src/toolchains/py/uv.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/py/uv.ts deleted file mode 100644 index 289dc242..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/py/uv.ts +++ /dev/null @@ -1,134 +0,0 @@ -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)$/; - -function resolvePaths(paths?: string | string[]): string { - if (paths == null) return "."; - return Array.isArray(paths) ? paths.join(" ") : paths; -} - -export interface UvOptions { - readonly path?: string; - readonly version?: string; - readonly image?: string; - readonly base?: Step; -} - -type ActionOptions = Omit; - -export class UvProject { - 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 & { paths?: string | string[] }): Step { - const target = resolvePaths(opts?.paths); - const { paths: _, ...rest } = opts ?? {}; - return this._installed.sh(`cd ${this.path} && uv run ty check ${target}`, { - label: ":python: typecheck", - ...rest, - }); - } - - run(cmd: string, opts?: ActionOptions): Step { - const firstWord = cmd.split(/\s+/)[0] ?? "run"; - return this._installed.sh(`cd ${this.path} && uv run ${cmd}`, { - label: `:python: ${firstWord}`, - ...opts, - }); - } - - build(opts?: ActionOptions): Step { - return this._installed.sh(`cd ${this.path} && uv build`, { - label: ":python: build", - ...opts, - }); - } - - lockCheck(opts?: ActionOptions): Step { - return this._installed.sh(`cd ${this.path} && uv lock --check`, { - label: ":python: lock-check", - ...opts, - }); - } - - publish(opts?: ActionOptions): Step { - return this._installed.sh(`cd ${this.path} && uv publish`, { - label: ":python: publish", - ...opts, - }); - } -} - -export function uv(opts?: UvOptions): UvProject { - const path = opts?.path ?? "."; - const version = opts?.version ?? "latest"; - - if (!VERSION_RE.test(version)) { - throw new Error( - `py.uv: invalid version "${version}"\n → use "latest" or a semver like "0.4.18"`, - ); - } - - const uvEnvPrefix = - version === "latest" ? "" : `UV_VERSION=${version} `; - 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 UvProject(path, synced); -} diff --git a/crates/hm-dsl-engine/harmont-ts/src/toolchains/python.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/python.ts deleted file mode 100644 index 6c16e1ad..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/python.ts +++ /dev/null @@ -1,114 +0,0 @@ -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)$/; - -function resolvePaths(paths?: string | string[]): string { - if (paths == null) return "."; - return Array.isArray(paths) ? paths.join(" ") : paths; -} - -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; - } - - /** Append a post-install command and return an advanced toolchain; chainable. - * For prep steps the toolchain's actions must depend on but the SDK does not - * model natively (codegen, fixtures, extra tooling). Action methods on the - * returned object fork from this step. - * @example hm.python({ path: "." }).setup("uv run python gen.py").test() */ - setup(cmd: string, opts?: StepOptions): PythonToolchain { - return new PythonToolchain(this.path, this._installed.sh(cmd, opts)); - } - - 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 & { paths?: string | string[] }): Step { - const target = resolvePaths(opts?.paths); - const { paths: _, ...rest } = opts ?? {}; - return this._installed.sh(`cd ${this.path} && uv run ty check ${target}`, { - label: ":python: typecheck", - ...rest, - }); - } -} - -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/crates/hm-dsl-engine/harmont-ts/src/toolchains/rust.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/rust.ts deleted file mode 100644 index f160eae6..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/rust.ts +++ /dev/null @@ -1,406 +0,0 @@ -import type { Step, StepOptions } from "../step.js"; -import { type CachePolicy, forever, onChange } from "../cache.js"; -import { makeInstallChain } from "./shared.js"; -import { type CargoOpts, cargoFlags, shQuote } from "./cargo.js"; - -const APT_PACKAGES = [ - "curl", - "ca-certificates", - "build-essential", - "pkg-config", - "libssl-dev", -] as const; -const VERSION_RE = /^[a-z0-9.-]+$/; - -export interface RustToolchainOptions { - readonly path?: string; - readonly version?: string; - readonly image?: string; - readonly components?: readonly string[]; - readonly base?: Step; -} - -export interface RustProjectOptions extends RustToolchainOptions { - readonly cache?: CachePolicy; -} - -type ActionOptions = Omit; -type CargoActionOptions = CargoOpts & ActionOptions; - -export interface FeaturePowersetOptions extends ActionOptions { - readonly subcommand?: string; - readonly depth?: number; - readonly eachFeature?: boolean; - readonly noDevDeps?: boolean; - readonly skip?: readonly string[]; - readonly includeFeatures?: readonly string[]; - readonly keepGoing?: boolean; - readonly flags?: readonly string[]; -} - -// --- pure command builders (shared by both classes) --- - -function splitCargo(opts: CargoActionOptions | undefined): { - cargo: CargoOpts; - step: ActionOptions; -} { - const o = (opts ?? {}) as CargoActionOptions; - const { - workspace, - packages, - exclude, - allFeatures, - noDefaultFeatures, - features, - target, - allTargets, - release, - profile, - locked, - flags, - ...step - } = o; - return { - cargo: { - workspace, - packages, - exclude, - allFeatures, - noDefaultFeatures, - features, - target, - allTargets, - release, - profile, - locked, - flags, - }, - step: step as ActionOptions, - }; -} - -function buildCmd(c: CargoOpts): string { - return `cargo build${cargoFlags(c)}`; -} -function testCmd(c: CargoOpts, nextest: boolean): string { - return `${nextest ? "cargo nextest run" : "cargo test"}${cargoFlags(c)}`; -} -function doctestCmd(c: CargoOpts): string { - return `cargo test${cargoFlags(c)} --doc`; -} -function clippyCmd( - c: CargoOpts, - denyWarnings: boolean, - extraLints: readonly string[], -): string { - const mid = cargoFlags(c); - const trail = [...(denyWarnings ? ["-D warnings"] : []), ...extraLints]; - return `cargo clippy${mid}${trail.length ? " -- " + trail.join(" ") : ""}`; -} -function fmtCmd(all: boolean, check: boolean, flags: readonly string[]): string { - const toks = ["cargo fmt"]; - if (all) toks.push("--all"); - if (check) toks.push("--check"); - toks.push(...flags); - return toks.join(" "); -} -function docCmd(c: CargoOpts, noDeps: boolean, privateItems: boolean): string { - const pre: string[] = []; - if (noDeps) pre.push("--no-deps"); - if (privateItems) pre.push("--document-private-items"); - return `cargo doc${pre.length ? " " + pre.join(" ") : ""}${cargoFlags(c)}`; -} -function hackCmd(o: FeaturePowersetOptions): string { - const toks = ["cargo hack", o.subcommand ?? "check"]; - if (o.eachFeature) toks.push("--each-feature"); - else toks.push("--feature-powerset", "--depth", String(o.depth ?? 2)); - if (o.noDevDeps ?? true) toks.push("--no-dev-deps"); - if (o.skip?.length) toks.push("--skip " + o.skip.map(shQuote).join(",")); - if (o.includeFeatures?.length) - toks.push("--include-features " + o.includeFeatures.map(shQuote).join(",")); - if (o.keepGoing) toks.push("--keep-going"); - toks.push(...(o.flags ?? [])); - return toks.join(" "); -} -function denyDocEnv(step: ActionOptions, deny: boolean): ActionOptions { - if (!deny) return step; - return { ...step, env: { RUSTDOCFLAGS: "-D warnings", ...(step.env ?? {}) } }; -} - -function withTargetAdd(cmd: string, target: string | undefined, addTarget: boolean): string { - return target !== undefined && addTarget - ? `rustup target add ${shQuote(target)} && ${cmd}` - : cmd; -} - -// --- classes --- - -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; - } - - /** Append a post-install command and return an advanced toolchain; chainable. - * For prep steps the toolchain's actions must depend on but the SDK does not - * model natively (codegen, fixtures, extra tooling). Action methods on the - * returned object fork from this step. (On warmup-based projects, splice prep - * here, pre-warmup: hm.rust.toolchain().setup("…").project({ path: "." }).) - * @example hm.rust.toolchain().setup("cargo install sqlx-cli").build() */ - setup(cmd: string, opts?: StepOptions): RustToolchain { - return new RustToolchain(this.path, this._installed.sh(cmd, opts)); - } - - _cargo(cmd: string, label: string, opts?: ActionOptions): Step { - return this._installed.sh( - `. $HOME/.cargo/env && cd ${this.path} && ${cmd}`, - { label, ...opts }, - ); - } - - build(opts?: CargoActionOptions & { addTarget?: boolean }): Step { - const { addTarget, ...rest } = opts ?? {}; - const { cargo, step } = splitCargo(rest); - const cmd = withTargetAdd(buildCmd(cargo), cargo.target, addTarget ?? true); - return this._cargo(cmd, ":rust: build", step); - } - - test(opts?: CargoActionOptions & { nextest?: boolean; addTarget?: boolean }): Step { - const { nextest, addTarget, ...rest } = opts ?? {}; - const { cargo, step } = splitCargo(rest); - const cmd = withTargetAdd(testCmd(cargo, nextest ?? false), cargo.target, addTarget ?? true); - return this._cargo(cmd, ":rust: test", step); - } - - doctest(opts?: CargoActionOptions & { addTarget?: boolean }): Step { - const { addTarget, ...rest } = opts ?? {}; - const { cargo, step } = splitCargo(rest); - const cmd = withTargetAdd(doctestCmd(cargo), cargo.target, addTarget ?? true); - return this._cargo(cmd, ":rust: doctest", step); - } - - clippy( - opts?: CargoActionOptions & { - denyWarnings?: boolean; - extraLints?: readonly string[]; - addTarget?: boolean; - }, - ): Step { - const { denyWarnings, extraLints, addTarget, ...rest } = opts ?? {}; - const { cargo, step } = splitCargo({ allTargets: true, ...rest }); - const cmd = withTargetAdd(clippyCmd(cargo, denyWarnings ?? true, extraLints ?? []), cargo.target, addTarget ?? true); - return this._cargo(cmd, ":rust: clippy", step); - } - - fmt( - opts?: ActionOptions & { - all?: boolean; - check?: boolean; - flags?: readonly string[]; - }, - ): Step { - const { all, check, flags, ...step } = opts ?? {}; - return this._cargo( - fmtCmd(all ?? true, check ?? true, flags ?? []), - ":rust: fmt", - step, - ); - } - - doc( - opts?: CargoActionOptions & { - noDeps?: boolean; - documentPrivateItems?: boolean; - denyWarnings?: boolean; - addTarget?: boolean; - }, - ): Step { - const { noDeps, documentPrivateItems, denyWarnings, addTarget, ...rest } = opts ?? {}; - const { cargo, step } = splitCargo(rest); - const cmd = withTargetAdd(docCmd(cargo, noDeps ?? true, documentPrivateItems ?? false), cargo.target, addTarget ?? true); - return this._cargo(cmd, ":rust: doc", denyDocEnv(step, denyWarnings ?? true)); - } - - warmup(opts?: ActionOptions): Step { - return this._cargo( - "cargo build --workspace --tests --locked", - ":rust: warmup", - opts, - ); - } - - featurePowerset(opts?: FeaturePowersetOptions): Step { - const { - subcommand, - depth, - eachFeature, - noDevDeps, - skip, - includeFeatures, - keepGoing, - flags, - ...step - } = opts ?? {}; - // Global install — no crate dir, so don't cd; keeps the forever-cache key - // identical across toolchains regardless of path. - const installedHack = this._installed.sh( - ". $HOME/.cargo/env && cargo install cargo-hack --locked", - { label: ":rust: install cargo-hack", cache: forever() }, - ); - return installedHack.sh( - `. $HOME/.cargo/env && cd ${this.path} && ${hackCmd({ subcommand, depth, eachFeature, noDevDeps, skip, includeFeatures, keepGoing, flags })}`, - { label: ":rust: feature-powerset", ...step }, - ); - } -} - -export class RustProject { - readonly toolchain: RustToolchain; - readonly warmup: Step; - - constructor(toolchain: RustToolchain, warmup: Step) { - this.toolchain = toolchain; - this.warmup = warmup; - } - - private _emit(cmd: string, label: string, step: ActionOptions): Step { - return this.warmup.sh( - `. $HOME/.cargo/env && cd ${this.toolchain.path} && ${cmd}`, - { label, ...step }, - ); - } - - build(opts?: CargoActionOptions & { addTarget?: boolean }): Step { - const { addTarget, ...rest } = opts ?? {}; - const { cargo, step } = splitCargo({ workspace: true, ...rest }); - const cmd = withTargetAdd(buildCmd(cargo), cargo.target, addTarget ?? true); - return this._emit(cmd, ":rust: build", step); - } - - test(opts?: CargoActionOptions & { nextest?: boolean; addTarget?: boolean }): Step { - const { nextest, addTarget, ...rest } = opts ?? {}; - const { cargo, step } = splitCargo({ workspace: true, ...rest }); - const cmd = withTargetAdd(testCmd(cargo, nextest ?? false), cargo.target, addTarget ?? true); - return this._emit(cmd, ":rust: test", step); - } - - doctest(opts?: CargoActionOptions & { addTarget?: boolean }): Step { - const { addTarget, ...rest } = opts ?? {}; - const { cargo, step } = splitCargo({ workspace: true, ...rest }); - const cmd = withTargetAdd(doctestCmd(cargo), cargo.target, addTarget ?? true); - return this._emit(cmd, ":rust: doctest", step); - } - - clippy( - opts?: CargoActionOptions & { - denyWarnings?: boolean; - extraLints?: readonly string[]; - addTarget?: boolean; - }, - ): Step { - const { denyWarnings, extraLints, addTarget, ...rest } = opts ?? {}; - const { cargo, step } = splitCargo({ workspace: true, allTargets: true, ...rest }); - const cmd = withTargetAdd(clippyCmd(cargo, denyWarnings ?? true, extraLints ?? []), cargo.target, addTarget ?? true); - return this._emit(cmd, ":rust: clippy", step); - } - - fmt( - opts?: ActionOptions & { - all?: boolean; - check?: boolean; - flags?: readonly string[]; - }, - ): Step { - // fmt has no warmup dependency; chain off install like the toolchain does. - return this.toolchain.fmt(opts); - } - - doc( - opts?: CargoActionOptions & { - noDeps?: boolean; - documentPrivateItems?: boolean; - denyWarnings?: boolean; - addTarget?: boolean; - }, - ): Step { - const { noDeps, documentPrivateItems, denyWarnings, addTarget, ...rest } = opts ?? {}; - const { cargo, step } = splitCargo({ workspace: true, ...rest }); - const cmd = withTargetAdd(docCmd(cargo, noDeps ?? true, documentPrivateItems ?? false), cargo.target, addTarget ?? true); - return this._emit(cmd, ":rust: doc", denyDocEnv(step, denyWarnings ?? true)); - } - - featurePowerset(opts?: FeaturePowersetOptions): Step { - return this.toolchain.featurePowerset(opts); - } - - ci(opts?: { nextest?: boolean; doc?: boolean }): Step[] { - const nextest = opts?.nextest ?? false; - const steps: Step[] = [this.test({ nextest })]; - if (nextest) steps.push(this.doctest()); - steps.push(this.clippy()); - steps.push(this.fmt()); - if (opts?.doc) steps.push(this.doc()); - return steps; - } -} - -function makeToolchain(opts?: RustToolchainOptions): RustToolchain { - const path = opts?.path ?? "."; - const version = opts?.version ?? "stable"; - const components = opts?.components ?? ["clippy", "rustfmt"]; - - if (!VERSION_RE.test(version)) { - throw new Error( - `rust.toolchain: 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); -} - -function makeProject(opts?: RustProjectOptions): RustProject { - const path = opts?.path ?? "."; - const tc = makeToolchain(opts); - - const lockPath = path !== "." ? `${path}/Cargo.lock` : "Cargo.lock"; - const tomlGlob = path !== "." ? `${path}/**/Cargo.toml` : "**/Cargo.toml"; - const rsGlob = path !== "." ? `${path}/**/*.rs` : "**/*.rs"; - const warmupCache = opts?.cache ?? onChange(lockPath, tomlGlob, rsGlob); - - const warm = tc._cargo( - "cargo build --workspace --tests --locked", - ":rust: warmup", - { cache: warmupCache }, - ); - - return new RustProject(tc, warm); -} - -export const rust = { - toolchain: makeToolchain, - project: makeProject, -}; diff --git a/crates/hm-dsl-engine/harmont-ts/src/toolchains/shared.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/shared.ts deleted file mode 100644 index d2365931..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/shared.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 bunInstallCmd(version?: string): string { - const versionArg = version != null ? ` -s "bun-v${version}"` : ""; - return `curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr/local bash${versionArg}`; -} - -export function denoInstallCmd(version?: string): string { - const versionArg = version != null ? ` -s "v${version}"` : ""; - return `curl -fsSL https://deno.land/install.sh | sh${versionArg} && ln -sf $HOME/.deno/bin/deno /usr/local/bin/deno`; -} - -export function aptBase(opts: { - packages: readonly string[]; - image?: string; - label?: string; -}): Step { - return scratch({ image: opts.image }).sh(aptInstallCmd(opts.packages), { - label: opts.label ?? ":apt: base", - cache: ttl(APT_TTL_SECONDS), - }); -} - -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/crates/hm-dsl-engine/harmont-ts/src/toolchains/zig.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/zig.ts deleted file mode 100644 index f61d846e..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/zig.ts +++ /dev/null @@ -1,133 +0,0 @@ -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]+$/; -const NEW_URL_FORMAT_VERSION = [0, 14, 1] as const; - -function versionAtLeast( - version: string, - min: readonly [number, number, number], -): boolean { - const parts = version.split(".").map(Number); - for (let i = 0; i < 3; i++) { - if ((parts[i] ?? 0) !== min[i]) return (parts[i] ?? 0) > min[i]; - } - return true; -} - -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; - } - - /** Append a post-install command and return an advanced toolchain; chainable. - * For prep steps the toolchain's actions must depend on but the SDK does not - * model natively (codegen, fixtures, extra tooling). Action methods on the - * returned object fork from this step. - * @example hm.zig().setup("zig build gen").project(".") */ - setup(cmd: string, opts?: StepOptions): ZigToolchain { - return new ZigToolchain(this._installed.sh(cmd, opts)); - } - - 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; - } - - /** Append a post-install command and return an advanced project; chainable. - * For prep steps the toolchain's actions must depend on but the SDK does not - * model natively (codegen, fixtures, extra tooling). Action methods on the - * returned object fork from this step. - * @example hm.zig({ path: "." }).setup("zig build gen").build() */ - setup(cmd: string, opts?: StepOptions): ZigProject { - return new ZigProject(this.path, this._installed.sh(cmd, opts)); - } - - 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.14.1"; - - if (!VERSION_RE.test(version)) { - throw new Error( - `hm.zig: invalid version "${version}"\n → use a semver like "0.14.1"`, - ); - } - - const tarball = versionAtLeast(version, NEW_URL_FORMAT_VERSION) - ? `zig-x86_64-linux-${version}.tar.xz` - : `zig-linux-x86_64-${version}.tar.xz`; - - const installCmd = [ - `curl -fsSL https://ziglang.org/download/${version}/${tarball} -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/crates/hm-dsl-engine/harmont-ts/src/triggers.ts b/crates/hm-dsl-engine/harmont-ts/src/triggers.ts deleted file mode 100644 index ac8da7ba..00000000 --- a/crates/hm-dsl-engine/harmont-ts/src/triggers.ts +++ /dev/null @@ -1,88 +0,0 @@ -export type Trigger = PushTrigger | PullRequestTrigger; - -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]); -} - diff --git a/crates/hm-dsl-engine/harmont-ts/tests/cache.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/cache.test.ts deleted file mode 100644 index cee168de..00000000 --- a/crates/hm-dsl-engine/harmont-ts/tests/cache.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -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/crates/hm-dsl-engine/harmont-ts/tests/duration.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/duration.test.ts deleted file mode 100644 index 6da13749..00000000 --- a/crates/hm-dsl-engine/harmont-ts/tests/duration.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -// tests/duration.test.ts -import { describe, expect, it } from "vitest"; - -import { parseDuration } from "../src/duration.js"; - -describe("parseDuration", () => { - it.each([ - ["30s", 30], - ["5m", 300], - ["1h", 3600], - ["1h30m", 5400], - ["2h15m30s", 8130], - [45, 45], - ])("parses %s -> %d", (value, expected) => { - expect(parseDuration(value as string | number)).toBe(expected); - }); - - it.each(["", "30", "30 s", "1d", "m", "-5s", "0s", 0, -3, NaN])( - "rejects %s", - (bad) => { - expect(() => parseDuration(bad as string | number)).toThrow(); - }, - ); -}); diff --git a/crates/hm-dsl-engine/harmont-ts/tests/e2e-fixtures.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/e2e-fixtures.test.ts deleted file mode 100644 index c5d872c6..00000000 --- a/crates/hm-dsl-engine/harmont-ts/tests/e2e-fixtures.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -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 { js } from "../src/toolchains/js.js"; -import { rust } from "../src/toolchains/rust.js"; -import { zig } from "../src/toolchains/zig.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 = js.project({ 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" } }, - ); - - expect(ir.version).toBe("0"); - const monorepoChildIdxs = new Set( - ir.graph.edges.filter((e: any) => e[2] === "builds_in").map((e: any) => e[1]), - ); - const monorepoRoots = ir.graph.nodes.filter((_: any, i: number) => !monorepoChildIdxs.has(i)); - expect(monorepoRoots.length).toBeGreaterThan(0); - expect(monorepoRoots.every((n: any) => "image" in n.step)).toBe(true); - expect(ir.graph.nodes.length).toBeGreaterThan(0); - assertFixture("monorepo-ci", ir); - }); - - it("rust-release", () => { - const project = rust.toolchain({ path: "." }); - - const ir = pipeline( - [project.build(), project.test(), project.clippy(), project.fmt(), project.doc()], - { env: { CI: "true" } }, - ); - - 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 = js.project({ 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" } }, - ); - - expect(ir.version).toBe("0"); - assertFixture("zig-node-polyglot", ir); - }); - - it("kitchen-sink", () => { - const cProject = cmake({ path: "infra/agent" }); - const pyWeb = python({ path: "services/web" }); - - const ir = pipeline( - [cProject.build(), cProject.test(), cProject.fmt(), pyWeb.test(), pyWeb.lint()], - { env: { CI: "true" } }, - ); - - expect(ir.version).toBe("0"); - assertFixture("kitchen-sink", ir); - }); - - it("cmake-advanced", () => { - const project = cmake({ - path: ".", - compiler: "clang-18", - defines: { CMAKE_BUILD_TYPE: "Release", CMAKE_CXX_STANDARD: "20" }, - }); - - const ir = pipeline( - [project.test(), project.lint(), project.fmt()], - { env: { CI: "true" } }, - ); - - expect(ir.version).toBe("0"); - expect(ir.graph.nodes.length).toBeGreaterThan(3); - assertFixture("cmake-advanced", ir); - }); -}); diff --git a/crates/hm-dsl-engine/harmont-ts/tests/envelope.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/envelope.test.ts deleted file mode 100644 index 58a3711f..00000000 --- a/crates/hm-dsl-engine/harmont-ts/tests/envelope.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { forever } from "../src/cache.js"; -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"); - }); - - it("resolves cache keys when basePath is provided", () => { - const tmp = mkdtempSync(join(tmpdir(), "envelope-test-")); - const def: PipelineDefinition = { - slug: "ci", - pipeline: pipeline([sh("apt-get update", { label: "apt", cache: forever() })]), - }; - const json = renderEnvelope([def], { basePath: tmp, now: 1000000 }); - const parsed = JSON.parse(json); - const cache = parsed.pipelines[0].definition.graph.nodes[0].step.cache; - expect(cache.key).toBeTypeOf("string"); - expect(cache.key.length).toBe(64); - }); - - it("throws when cached steps exist but basePath is missing", () => { - const def: PipelineDefinition = { - slug: "ci", - pipeline: pipeline([sh("apt-get update", { label: "apt", cache: forever() })]), - }; - expect(() => renderEnvelope([def])).toThrowError(/basePath/); - }); - - it("allows omitting basePath when no steps are cached", () => { - const def: PipelineDefinition = { - slug: "ci", - pipeline: pipeline([sh("echo hello", { label: "greet" })]), - }; - expect(() => renderEnvelope([def])).not.toThrow(); - }); -}); diff --git a/crates/hm-dsl-engine/harmont-ts/tests/examples.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/examples.test.ts deleted file mode 100644 index dec87182..00000000 --- a/crates/hm-dsl-engine/harmont-ts/tests/examples.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -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, ".hm", "pipeline.ts")), - ) - .map((d) => d.name) - .sort(); -} - -const examples = exampleDirs(); - -describe.skipIf(examples.length === 0)("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, - ".hm", - "pipeline.ts", - ); - const mod = await import(pipelinePath); - const definitions = mod.default ?? mod.pipelines; - - 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"); - - // Every root step (no builds_in parent) must have an image stamped on it - const nodes = ci.pipeline.graph.nodes; - const childIdxs = new Set( - ci.pipeline.graph.edges.filter((e: any) => e[2] === "builds_in").map((e: any) => e[1]), - ); - const roots = nodes.filter((_: any, i: number) => !childIdxs.has(i)); - expect(roots.length).toBeGreaterThan(0); - expect(roots.every((n: any) => "image" in n.step)).toBe(true); - - // 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); - } - }); - } - -}); diff --git a/crates/hm-dsl-engine/harmont-ts/tests/integration.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/integration.test.ts deleted file mode 100644 index 6c022bbd..00000000 --- a/crates/hm-dsl-engine/harmont-ts/tests/integration.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { describe, expect, it, beforeEach } from "vitest"; -import { - Step, - scratch, - sh, - wait, - timeout, - forever, - ttl, - onChange, - compose, - pipeline, - target, - clearTargetCache, - renderEnvelope, - push, - pullRequest, - PushTrigger, - PullRequestTrigger, - type PipelineDefinition, -} from "../src/index.js"; - -beforeEach(() => { - clearTargetCache(); -}); - -describe("full pipeline build", () => { - it("creates install -> build -> test chain with cache, env", () => { - 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 = timeout(300, build - .sh("npm test", { label: "test" })); - - const ir = pipeline([test], { - env: { CI: "true" }, - }); - - // 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]; - - const base = { DEBIAN_FRONTEND: "noninteractive", TERM: "dumb" }; - expect(installNode.env).toEqual({ ...base, CI: "true" }); - expect(buildNode.env).toEqual({ ...base, CI: "true", NODE_ENV: "production" }); - expect(testNode.env).toEqual({ ...base, CI: "true" }); - - // ubuntu:24.04 is automatically stamped on root steps (no builds_in parent) - const childIdxs = new Set( - ir.graph.edges.filter((e) => e[2] === "builds_in").map((e) => e[1]), - ); - const roots = ir.graph.nodes.filter((_n, i) => !childIdxs.has(i)); - expect(roots).toHaveLength(1); - expect(roots[0].step.image).toBe("ubuntu:24.04"); - 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" }), - ], - 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(2); - expect(p.triggers[0]).toEqual({ event: "push", branches: ["main"] }); - expect(p.triggers[1]).toEqual({ - event: "pull_request", - branches: ["develop"], - types: ["opened", "synchronize", "reopened"], - }); - // 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 = timeout(600, scratch().sh("make", { - label: "build", - cache: onChange("src/", "lib/"), - })); - const ir = pipeline([s]); - const json = JSON.stringify(ir); - - // Root imageless step gets ubuntu:24.04 stamped on it - expect(json).toContain('"image":"ubuntu:24.04"'); - // Must NOT contain the removed top-level default_image field - expect(json).not.toContain('"default_image"'); - - // Must contain snake_case keys - 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"); - // Trigger classes are exported as values for instanceof checks - expect(PushTrigger).toBeDefined(); - expect(PullRequestTrigger).toBeDefined(); - const t = push({ branch: "main" }); - expect(t instanceof PushTrigger).toBe(true); - }); -}); diff --git a/crates/hm-dsl-engine/harmont-ts/tests/keygen.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/keygen.test.ts deleted file mode 100644 index 9804a8a0..00000000 --- a/crates/hm-dsl-engine/harmont-ts/tests/keygen.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { createHash } from "node:crypto"; -import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { describe, expect, it, beforeEach } from "vitest"; -import { resolvePipelineCacheKeys, type CacheKeyOptions } from "../src/keygen.js"; -import { pipeline, type PipelineIR } from "../src/pipeline.js"; -import { sh } from "../src/step.js"; -import { forever, ttl, onChange } from "../src/cache.js"; - -function sha256(s: string): string { - return createHash("sha256").update(s, "utf8").digest("hex"); -} - -const NUL = "\0"; - -function makeOpts(overrides?: Partial): CacheKeyOptions { - return { - pipelineOrg: "test-org", - pipelineSlug: "ci", - now: 1000000, - basePath: "/tmp/test", - env: {}, - ...overrides, - }; -} - -describe("resolvePipelineCacheKeys", () => { - it("adds cache.key to forever-cached steps", () => { - const ir = pipeline([sh("echo hello", { label: "greet", cache: forever() })]); - const opts = makeOpts(); - - resolvePipelineCacheKeys(ir.graph, opts); - - const cache = ir.graph.nodes[0].step.cache as Record; - expect(cache.key).toBeTypeOf("string"); - expect((cache.key as string).length).toBe(64); - }); - - it("produces deterministic keys", () => { - const ir1 = pipeline([sh("echo hello", { label: "greet", cache: forever() })]); - const ir2 = pipeline([sh("echo hello", { label: "greet", cache: forever() })]); - const opts = makeOpts(); - - resolvePipelineCacheKeys(ir1.graph, opts); - resolvePipelineCacheKeys(ir2.graph, opts); - - const k1 = (ir1.graph.nodes[0].step.cache as Record).key; - const k2 = (ir2.graph.nodes[0].step.cache as Record).key; - expect(k1).toBe(k2); - }); - - it("different commands produce different keys", () => { - const ir1 = pipeline([sh("echo a", { label: "a", cache: forever() })]); - const ir2 = pipeline([sh("echo b", { label: "b", cache: forever() })]); - const opts = makeOpts(); - - resolvePipelineCacheKeys(ir1.graph, opts); - resolvePipelineCacheKeys(ir2.graph, opts); - - const k1 = (ir1.graph.nodes[0].step.cache as Record).key; - const k2 = (ir2.graph.nodes[0].step.cache as Record).key; - expect(k1).not.toBe(k2); - }); - - it("different orgs produce different keys", () => { - const ir1 = pipeline([sh("echo a", { label: "a", cache: forever() })]); - const ir2 = pipeline([sh("echo a", { label: "a", cache: forever() })]); - - resolvePipelineCacheKeys(ir1.graph, makeOpts({ pipelineOrg: "org-a" })); - resolvePipelineCacheKeys(ir2.graph, makeOpts({ pipelineOrg: "org-b" })); - - const k1 = (ir1.graph.nodes[0].step.cache as Record).key; - const k2 = (ir2.graph.nodes[0].step.cache as Record).key; - expect(k1).not.toBe(k2); - }); - - it("skips steps with no cache", () => { - const ir = pipeline([sh("echo hello", { label: "greet" })]); - resolvePipelineCacheKeys(ir.graph, makeOpts()); - expect(ir.graph.nodes[0].step.cache).toBeUndefined(); - }); - - it("ttl bucket changes key", () => { - const ir1 = pipeline([sh("apt-get", { label: "apt", cache: ttl(86400) })]); - const ir2 = pipeline([sh("apt-get", { label: "apt", cache: ttl(86400) })]); - - resolvePipelineCacheKeys(ir1.graph, makeOpts({ now: 86400 * 10 })); - resolvePipelineCacheKeys(ir2.graph, makeOpts({ now: 86400 * 11 })); - - const k1 = (ir1.graph.nodes[0].step.cache as Record).key; - const k2 = (ir2.graph.nodes[0].step.cache as Record).key; - expect(k1).not.toBe(k2); - }); - - it("ttl same bucket produces same key", () => { - const ir1 = pipeline([sh("apt-get", { label: "apt", cache: ttl(86400) })]); - const ir2 = pipeline([sh("apt-get", { label: "apt", cache: ttl(86400) })]); - - resolvePipelineCacheKeys(ir1.graph, makeOpts({ now: 86400 * 10 + 100 })); - resolvePipelineCacheKeys(ir2.graph, makeOpts({ now: 86400 * 10 + 200 })); - - const k1 = (ir1.graph.nodes[0].step.cache as Record).key; - const k2 = (ir2.graph.nodes[0].step.cache as Record).key; - expect(k1).toBe(k2); - }); - - it("on_change hashes file contents", () => { - const tmp = mkdtempSync(join(tmpdir(), "keygen-test-")); - writeFileSync(join(tmp, "CMakeLists.txt"), "cmake_minimum_required(VERSION 3.20)"); - - const ir = pipeline([ - sh("cmake ..", { label: "build", cache: onChange("./CMakeLists.txt") }), - ]); - resolvePipelineCacheKeys(ir.graph, makeOpts({ basePath: tmp })); - - const cache = ir.graph.nodes[0].step.cache as Record; - expect(cache.key).toBeTypeOf("string"); - expect((cache.key as string).length).toBe(64); - }); - - it("on_change different file contents produce different keys", () => { - const tmp1 = mkdtempSync(join(tmpdir(), "keygen-test-")); - const tmp2 = mkdtempSync(join(tmpdir(), "keygen-test-")); - writeFileSync(join(tmp1, "f.txt"), "version A"); - writeFileSync(join(tmp2, "f.txt"), "version B"); - - const ir1 = pipeline([sh("cmd", { label: "x", cache: onChange("./f.txt") })]); - const ir2 = pipeline([sh("cmd", { label: "x", cache: onChange("./f.txt") })]); - - resolvePipelineCacheKeys(ir1.graph, makeOpts({ basePath: tmp1 })); - resolvePipelineCacheKeys(ir2.graph, makeOpts({ basePath: tmp2 })); - - const k1 = (ir1.graph.nodes[0].step.cache as Record).key; - const k2 = (ir2.graph.nodes[0].step.cache as Record).key; - expect(k1).not.toBe(k2); - }); - - it("forever key matches Python algorithm", () => { - const ir = pipeline([sh("echo hi", { label: "test", cache: forever() })]); - const opts = makeOpts({ pipelineOrg: "myorg", pipelineSlug: "myslug" }); - - resolvePipelineCacheKeys(ir.graph, opts); - - const stepKey = ir.graph.nodes[0].step.key as string; - const cmd = "echo hi"; - const policyRes = "forever-" + sha256(cmd + NUL + ""); - const expected = sha256( - "myorg" + NUL + "myslug" + NUL + stepKey + NUL + "scratch" + NUL + policyRes, - ); - - const cache = ir.graph.nodes[0].step.cache as Record; - expect(cache.key).toBe(expected); - }); - - it("child step uses parent resolved key", () => { - const base = sh("apt-get install", { label: "apt", cache: forever() }); - const child = base.sh("make", { label: "build", cache: forever() }); - - const ir = pipeline([child]); - resolvePipelineCacheKeys(ir.graph, makeOpts()); - - const parentCache = ir.graph.nodes[0].step.cache as Record; - const childCache = ir.graph.nodes[1].step.cache as Record; - expect(parentCache.key).toBeTypeOf("string"); - expect(childCache.key).toBeTypeOf("string"); - expect(parentCache.key).not.toBe(childCache.key); - }); - - it("golden hash: cross-SDK reference pipeline", () => { - const graph: PipelineIR["graph"] = { - nodes: [ - { - step: { - key: "build", - cmd: "make build", - cache: { policy: "forever", env_keys: [] }, - }, - env: {}, - }, - ], - node_holes: [], - edge_property: "directed", - edges: [], - }; - - const opts: CacheKeyOptions = { - pipelineOrg: "acme", - pipelineSlug: "ci", - now: 1000000, - basePath: "/nonexistent", - env: {}, - }; - - resolvePipelineCacheKeys(graph, opts); - - const policyRes = "forever-" + sha256("make build" + NUL); - const expected = sha256( - "acme" + NUL + "ci" + NUL + "build" + NUL + "scratch" + NUL + policyRes, - ); - - const cache = graph.nodes[0].step.cache as Record; - expect(cache.key).toBe(expected); - }); - - it("golden hash: cross-SDK chained pipeline", () => { - const graph: PipelineIR["graph"] = { - nodes: [ - { - step: { - key: "setup", - cmd: "apt-get update && apt-get install -y gcc", - cache: { policy: "forever", env_keys: [] }, - }, - env: {}, - }, - { - step: { - key: "compile", - cmd: "gcc -o main main.c", - cache: { policy: "forever", env_keys: [] }, - }, - env: {}, - }, - ], - node_holes: [], - edge_property: "directed", - edges: [[0, 1, "builds_in"]], - }; - - const opts: CacheKeyOptions = { - pipelineOrg: "acme", - pipelineSlug: "ci", - now: 1000000, - basePath: "/nonexistent", - env: {}, - }; - - resolvePipelineCacheKeys(graph, opts); - - const parentPolicyRes = - "forever-" + sha256("apt-get update && apt-get install -y gcc" + NUL); - const parentKey = sha256( - "acme" + NUL + "ci" + NUL + "setup" + NUL + "scratch" + NUL + parentPolicyRes, - ); - - const childPolicyRes = "forever-" + sha256("gcc -o main main.c" + NUL); - const childKey = sha256( - "acme" + NUL + "ci" + NUL + "compile" + NUL + parentKey + NUL + childPolicyRes, - ); - - const parentCache = graph.nodes[0].step.cache as Record; - const childCache = graph.nodes[1].step.cache as Record; - expect(parentCache.key).toBe(parentKey); - expect(childCache.key).toBe(childKey); - }); -}); diff --git a/crates/hm-dsl-engine/harmont-ts/tests/keys.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/keys.test.ts deleted file mode 100644 index 593468fd..00000000 --- a/crates/hm-dsl-engine/harmont-ts/tests/keys.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -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/crates/hm-dsl-engine/harmont-ts/tests/pipeline.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/pipeline.test.ts deleted file mode 100644 index 81a604ff..00000000 --- a/crates/hm-dsl-engine/harmont-ts/tests/pipeline.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { pipeline } from "../src/pipeline.js"; -import { scratch, sh, wait, timeout } 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 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"); - }); - -}); - -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({ - DEBIAN_FRONTEND: "noninteractive", - TERM: "dumb", - 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 = timeout(600, scratch().sh("make", { - label: "build", - 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("stamps ubuntu:24.04 on an imageless root", () => { - const s = scratch().sh("echo hi", { label: "a" }); - const ir = pipeline([s]); - expect(ir.graph.nodes[0].step.image).toBe("ubuntu:24.04"); - }); - - it("preserves an explicit root image", () => { - const s = scratch({ image: "alpine:3.20" }).sh("echo hi", { label: "a" }); - const ir = pipeline([s]); - expect(ir.graph.nodes[0].step.image).toBe("alpine:3.20"); - }); - - it("leaves child steps imageless", () => { - const root = scratch().sh("echo p", { label: "p" }); - const child = root.sh("echo c", { label: "c" }); - const ir = pipeline([child]); - const byKey = Object.fromEntries( - ir.graph.nodes.map((n) => [n.step.key as string, n.step]), - ); - expect("image" in byKey["c"]).toBe(false); - }); - - it("emits no top-level default_image key", () => { - const ir = pipeline([scratch().sh("echo hi", { label: "a" })]); - expect("default_image" in ir).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"); - }); -}); - -describe("pipeline: timeout", () => { - it("emits a top-level timeout_seconds when set", () => { - const ir = pipeline([sh("x")], { timeout: "30m" }); - expect(ir.timeout_seconds).toBe(1800); - }); - - it("omits timeout_seconds when unset", () => { - const ir = pipeline([sh("x")]); - expect(ir.timeout_seconds).toBeUndefined(); - }); -}); diff --git a/crates/hm-dsl-engine/harmont-ts/tests/setup.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/setup.test.ts deleted file mode 100644 index 709ef5b2..00000000 --- a/crates/hm-dsl-engine/harmont-ts/tests/setup.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { pipeline } from "@harmont/hm"; -import * as hm from "@harmont/hm/toolchains"; - -function cmdsOf(leaf: any): string[] { - const ir = pipeline([leaf]); - return ir.graph.nodes.map((n: any) => n.step.cmd).filter(Boolean); -} - -describe("toolchain .setup()", () => { - it("advances the install chain and is immutable", () => { - const proj = hm.elixir({ path: "." }); - const before = proj.install(); - const advanced = proj.setup("echo __SETUP_MARKER__"); - expect(advanced).not.toBe(proj); - expect(advanced.install()).not.toBe(before); - const cmds = cmdsOf(advanced.install()); - expect(cmds.some((c) => c.includes("__SETUP_MARKER__"))).toBe(true); - }); - - it("is chainable", () => { - const proj = hm.elixir({ path: "." }).setup("echo __ONE__").setup("echo __TWO__"); - const cmds = cmdsOf(proj.install()); - expect(cmds.some((c) => c.includes("__ONE__"))).toBe(true); - expect(cmds.some((c) => c.includes("__TWO__"))).toBe(true); - }); -}); - -const FACTORIES: Array<[string, () => any]> = [ - ["elixir", () => hm.elixir({ path: "." })], - ["python", () => hm.python({ path: "." })], - ["go", () => hm.go({ path: "." })], - ["js", () => hm.js.project({ path: "." })], - ["zigProject", () => hm.zig({ path: "." })], - ["zigToolchain", () => hm.zig()], - ["rustToolchain", () => hm.rust.toolchain()], - ["cmakeToolchain", () => hm.cmake()], -]; - -describe.each(FACTORIES)("%s .setup()", (_label, make) => { - it("advances the chain (renders the setup cmd)", () => { - const advanced = make().setup("echo __MARK__"); - const ir = pipeline([advanced.install()]); - const cmds = ir.graph.nodes.map((n: any) => n.step.cmd).filter(Boolean); - expect(cmds.some((c: string) => c.includes("__MARK__"))).toBe(true); - }); -}); diff --git a/crates/hm-dsl-engine/harmont-ts/tests/step.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/step.test.ts deleted file mode 100644 index 6cb3ba06..00000000 --- a/crates/hm-dsl-engine/harmont-ts/tests/step.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { scratch, sh, wait, Step, timeout } 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 = timeout(600, sh("make", { - label: "build", - 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/crates/hm-dsl-engine/harmont-ts/tests/target.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/target.test.ts deleted file mode 100644 index 5460ee6f..00000000 --- a/crates/hm-dsl-engine/harmont-ts/tests/target.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -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/crates/hm-dsl-engine/harmont-ts/tests/timeout.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/timeout.test.ts deleted file mode 100644 index 6fa36e0c..00000000 --- a/crates/hm-dsl-engine/harmont-ts/tests/timeout.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -// tests/timeout.test.ts -import { describe, expect, it } from "vitest"; - -import { sh, timeout, wait } from "../src/index.js"; - -describe("timeout", () => { - it("sets the timeout in seconds on a step", () => { - const step = timeout("30s", sh("echo foo")); - expect(step._timeoutSeconds).toBe(30); - expect(step._cmd).toBe("echo foo"); - }); - - it("accepts a number of seconds", () => { - expect(timeout(45, sh("x"))._timeoutSeconds).toBe(45); - }); - - it("does not mutate the original and last-wins on re-wrap", () => { - const base = sh("x"); - const wrapped = timeout("5m", base); - expect(base._timeoutSeconds).toBeUndefined(); - expect(timeout("1m", wrapped)._timeoutSeconds).toBe(60); - }); - - it("rejects wrapping a wait barrier", () => { - expect(() => timeout("30s", wait())).toThrow(/wait/); - }); -}); diff --git a/crates/hm-dsl-engine/harmont-ts/tests/toolchains/cargo.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/toolchains/cargo.test.ts deleted file mode 100644 index 99929ae2..00000000 --- a/crates/hm-dsl-engine/harmont-ts/tests/toolchains/cargo.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { cargoFlags, shQuote } from "../../src/toolchains/cargo.js"; - -describe("shQuote", () => { - it("leaves simple identifiers alone", () => { - expect(shQuote("harmont-core")).toBe("harmont-core"); - }); - it("quotes values with shell metacharacters", () => { - expect(shQuote("a; rm -rf /")).toBe("'a; rm -rf /'"); - }); - it("escapes embedded single quotes like shlex.quote", () => { - expect(shQuote("a'b")).toBe("'a'\"'\"'b'"); - }); - it("quotes the empty string", () => { - expect(shQuote("")).toBe("''"); - }); -}); - -describe("cargoFlags", () => { - it("emits only --locked for empty opts", () => { - expect(cargoFlags({})).toBe(" --locked"); - }); - it("locked can be disabled", () => { - expect(cargoFlags({ locked: false })).toBe(""); - }); - it("workspace scope", () => { - expect(cargoFlags({ workspace: true })).toBe(" --workspace --locked"); - }); - it("packages take precedence and quote values", () => { - expect(cargoFlags({ workspace: true, packages: ["a", "b c"] })).toBe( - " -p a -p 'b c' --locked", - ); - }); - it("exclude pairs with workspace", () => { - expect(cargoFlags({ workspace: true, exclude: ["b"] })).toBe( - " --workspace --exclude b --locked", - ); - }); - it("exclude without workspace throws", () => { - expect(() => cargoFlags({ exclude: ["b"] })).toThrow("workspace"); - }); - it("exclude with packages throws", () => { - expect(() => cargoFlags({ packages: ["a"], exclude: ["b"] })).toThrow("exclude"); - }); - it("all-features", () => { - expect(cargoFlags({ allFeatures: true })).toBe(" --all-features --locked"); - }); - it("features joined comma", () => { - expect(cargoFlags({ features: ["x", "y"] })).toBe(" --features x,y --locked"); - }); - it("no-default-features with features", () => { - expect(cargoFlags({ noDefaultFeatures: true, features: ["x"] })).toBe( - " --no-default-features --features x --locked", - ); - }); - it("full token order", () => { - expect( - cargoFlags({ - packages: ["core"], - allTargets: true, - noDefaultFeatures: true, - features: ["a", "b"], - target: "x86_64-unknown-linux-gnu", - profile: "ci", - flags: ["--keep-going"], - }), - ).toBe( - " -p core --all-targets --no-default-features --features a,b" + - " --target x86_64-unknown-linux-gnu --profile ci --locked --keep-going", - ); - }); - it("flags are verbatim", () => { - expect(cargoFlags({ locked: false, flags: ["--features=a b"] })).toBe( - " --features=a b", - ); - }); - it("throws on all-features + features conflict", () => { - expect(() => cargoFlags({ allFeatures: true, features: ["x"] })).toThrow( - "all-features", - ); - }); - it("throws on release + profile conflict", () => { - expect(() => cargoFlags({ release: true, profile: "ci" })).toThrow("profile"); - }); -}); diff --git a/crates/hm-dsl-engine/harmont-ts/tests/toolchains/cmake.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/toolchains/cmake.test.ts deleted file mode 100644 index 1143fde1..00000000 --- a/crates/hm-dsl-engine/harmont-ts/tests/toolchains/cmake.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { cmake, CMakeToolchain, CMakeProject } from "../../src/toolchains/cmake.js"; -import { pipeline } from "../../src/pipeline.js"; - -describe("cmake factory", () => { - it("returns a CMakeToolchain when no path is given", () => { - const tc = cmake(); - expect(tc).toBeInstanceOf(CMakeToolchain); - expect(tc.install()._cmd).toContain("cmake --version"); - expect(tc.install()._cmd).toContain("ninja --version"); - }); - - it("returns a CMakeProject when path is given", () => { - const proj = cmake({ path: "." }); - expect(proj).toBeInstanceOf(CMakeProject); - expect(proj.path).toBe("."); - }); - - it("rejects invalid compiler", () => { - expect(() => cmake({ compiler: "msvc" })).toThrow("invalid compiler"); - }); - - it("rejects invalid generator", () => { - expect(() => cmake({ generator: "borland" as any })).toThrow( - "invalid generator", - ); - }); - - it("accepts gcc compiler", () => { - const tc = cmake({ compiler: "gcc-14" }); - expect(tc.install()._cmd).toContain("gcc-14 --version"); - }); - - it("accepts clang compiler", () => { - const tc = cmake({ compiler: "clang-18" }); - expect(tc.install()._cmd).toContain("clang-18 --version"); - }); - - it("disables ccache", () => { - const tc = cmake({ ccache: false }); - expect(tc.install()._cmd).not.toContain("ccache"); - }); -}); - -describe("cmake toolchain.project()", () => { - it("creates a CMakeProject from a toolchain", () => { - const tc = cmake(); - const proj = tc.project({ path: "lib" }); - expect(proj).toBeInstanceOf(CMakeProject); - expect(proj.path).toBe("lib"); - }); -}); - -describe("cmake project actions", () => { - it("build returns the warmup step", () => { - const proj = cmake({ path: "." }); - expect(proj.build()._cmd).toContain("cmake --build"); - expect(proj.build()._label).toBe(":cmake: build"); - }); - - it("test runs ctest off built", () => { - const proj = cmake({ path: "." }); - const step = proj.test(); - expect(step._cmd).toContain("ctest --test-dir ./build --output-on-failure"); - expect(step._label).toBe(":cmake: test"); - }); - - it("install runs cmake --install off built", () => { - const proj = cmake({ path: "." }); - const step = proj.install(); - expect(step._cmd).toContain("cmake --install ./build"); - expect(step._label).toBe(":cmake: install"); - }); - - it("fmt runs clang-format off toolchain.install()", () => { - const proj = cmake({ path: "." }); - const step = proj.fmt(); - expect(step._cmd).toContain("clang-format --dry-run --Werror"); - expect(step._label).toBe(":cmake: fmt"); - // fmt branches off toolchain.install(), not built - expect(step._parent).toBe(proj.toolchain.install()); - }); - - it("lint runs run-clang-tidy off built", () => { - const proj = cmake({ path: "." }); - const step = proj.lint(); - expect(step._cmd).toContain("run-clang-tidy -p build"); - expect(step._label).toBe(":cmake: lint"); - }); - - it("package runs cpack off built", () => { - const proj = cmake({ path: "." }); - const step = proj.package(); - expect(step._cmd).toContain("cpack"); - expect(step._label).toBe(":cmake: package"); - }); -}); - -describe("cmake with preset", () => { - it("configure uses --preset when preset is given", () => { - const proj = cmake({ path: ".", preset: "release" }); - expect(proj.build()._cmd).toContain("cmake --preset release"); - expect(proj.build()._cmd).not.toContain("-DCMAKE_BUILD_TYPE"); - }); -}); - -describe("cmake with vcpkg", () => { - it("inserts a vcpkg step", () => { - const proj = cmake({ path: ".", deps: "vcpkg" }); - // The built step's parent should be the vcpkg step (warmup parent) - const builtStep = proj.build(); - expect(builtStep._parent?._cmd).toContain("vcpkg install"); - expect(builtStep._parent?._label).toBe(":cmake: vcpkg"); - }); -}); - -describe("cmake in pipeline", () => { - it("produces valid IR", () => { - const proj = cmake({ path: "." }); - const ir = pipeline([proj.build(), proj.test()]); - expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(3); - }); -}); diff --git a/crates/hm-dsl-engine/harmont-ts/tests/toolchains/detect.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/toolchains/detect.test.ts deleted file mode 100644 index 6e95da2e..00000000 --- a/crates/hm-dsl-engine/harmont-ts/tests/toolchains/detect.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { describe, expect, it, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { - detectFromPackageJson, - detectFromLockfiles, - detect, -} from "../../src/toolchains/detect.js"; - -// --------------------------------------------------------------------------- -// detectFromPackageJson -// --------------------------------------------------------------------------- - -describe("detectFromPackageJson", () => { - it("returns empty for empty object", () => { - expect(detectFromPackageJson({})).toEqual({}); - }); - - it("detects runtime=node from engines.node", () => { - expect(detectFromPackageJson({ engines: { node: ">=18" } })).toEqual({ - runtime: "node", - }); - }); - - it("detects runtime=bun and pm=bun from engines.bun", () => { - expect(detectFromPackageJson({ engines: { bun: ">=1.0" } })).toEqual({ - runtime: "bun", - pm: "bun", - }); - }); - - it("detects runtime=deno from engines.deno", () => { - expect(detectFromPackageJson({ engines: { deno: ">=2.0" } })).toEqual({ - runtime: "deno", - }); - }); - - it("detects pm=pnpm from packageManager field", () => { - expect( - detectFromPackageJson({ packageManager: "pnpm@8.15.4" }), - ).toEqual({ pm: "pnpm", pmVersion: "8.15.4" }); - }); - - it("detects pm=bun from packageManager field", () => { - expect(detectFromPackageJson({ packageManager: "bun@1.1.0" })).toEqual({ - pm: "bun", - pmVersion: "1.1.0", - }); - }); - - it("detects pm=npm from packageManager field", () => { - expect( - detectFromPackageJson({ packageManager: "npm@10.2.4" }), - ).toEqual({ pm: "npm", pmVersion: "10.2.4" }); - }); - - it("detects yarn-classic from packageManager yarn@1.x", () => { - expect( - detectFromPackageJson({ packageManager: "yarn@1.22.22" }), - ).toEqual({ pm: "yarn-classic", pmVersion: "1.22.22" }); - }); - - it("detects yarn-berry from packageManager yarn@4.x", () => { - expect( - detectFromPackageJson({ packageManager: "yarn@4.0.0" }), - ).toEqual({ pm: "yarn-berry", pmVersion: "4.0.0" }); - }); - - it("engines.bun overrides packageManager for pm", () => { - expect( - detectFromPackageJson({ - engines: { bun: ">=1.0" }, - packageManager: "pnpm@8", - }), - ).toEqual({ runtime: "bun", pm: "bun" }); - }); - - it("engines.node + packageManager=pnpm both contribute", () => { - expect( - detectFromPackageJson({ - engines: { node: ">=18" }, - packageManager: "pnpm@8", - }), - ).toEqual({ runtime: "node", pm: "pnpm", pmVersion: "8" }); - }); - - it("omits pmVersion when packageManager has no @version", () => { - expect( - detectFromPackageJson({ packageManager: "pnpm" }), - ).toEqual({ pm: "pnpm" }); - }); -}); - -// --------------------------------------------------------------------------- -// detectFromLockfiles -// --------------------------------------------------------------------------- - -describe("detectFromLockfiles", () => { - it("returns empty for no files", () => { - expect(detectFromLockfiles([])).toEqual({}); - }); - - it("detects bun from bun.lock", () => { - expect(detectFromLockfiles(["bun.lock"])).toEqual({ - pm: "bun", - runtime: "bun", - }); - }); - - it("detects bun from bun.lockb (legacy binary format)", () => { - expect(detectFromLockfiles(["bun.lockb"])).toEqual({ - pm: "bun", - runtime: "bun", - }); - }); - - it("detects pnpm from pnpm-lock.yaml", () => { - expect(detectFromLockfiles(["pnpm-lock.yaml"])).toEqual({ pm: "pnpm" }); - }); - - it("detects deno from deno.lock", () => { - expect(detectFromLockfiles(["deno.lock"])).toEqual({ runtime: "deno" }); - }); - - it("detects npm from package-lock.json", () => { - expect(detectFromLockfiles(["package-lock.json"])).toEqual({ - pm: "npm", - }); - }); - - it("detects yarn-classic from yarn.lock", () => { - expect(detectFromLockfiles(["yarn.lock"])).toEqual({ pm: "yarn-classic" }); - }); - - it("bun.lock takes priority over package-lock.json", () => { - expect( - detectFromLockfiles(["package-lock.json", "bun.lock"]), - ).toEqual({ pm: "bun", runtime: "bun" }); - }); -}); - -// --------------------------------------------------------------------------- -// detect (filesystem integration) -// --------------------------------------------------------------------------- - -describe("detect", () => { - let tmp: string; - - beforeEach(() => { - tmp = mkdtempSync(join(tmpdir(), "hm-detect-")); - }); - - afterEach(() => { - rmSync(tmp, { recursive: true, force: true }); - }); - - it("returns empty for directory with no package.json or lockfiles", () => { - expect(detect(tmp)).toEqual({}); - }); - - it("detects from package.json engines", () => { - writeFileSync( - join(tmp, "package.json"), - JSON.stringify({ engines: { bun: ">=1.0" } }), - ); - expect(detect(tmp)).toEqual({ runtime: "bun", pm: "bun" }); - }); - - it("detects from lockfile", () => { - writeFileSync(join(tmp, "pnpm-lock.yaml"), ""); - expect(detect(tmp)).toEqual({ pm: "pnpm" }); - }); - - it("package.json pm takes priority over lockfile pm", () => { - writeFileSync( - join(tmp, "package.json"), - JSON.stringify({ packageManager: "pnpm@8" }), - ); - writeFileSync(join(tmp, "bun.lock"), ""); - const result = detect(tmp); - expect(result.pm).toBe("pnpm"); - expect(result.runtime).toBe("bun"); - expect(result.pmVersion).toBe("8"); - }); - - it("merges package.json runtime with lockfile pm", () => { - writeFileSync( - join(tmp, "package.json"), - JSON.stringify({ engines: { node: ">=18" } }), - ); - writeFileSync(join(tmp, "pnpm-lock.yaml"), ""); - expect(detect(tmp)).toEqual({ runtime: "node", pm: "pnpm" }); - }); - - it("returns empty for nonexistent path", () => { - expect(detect(join(tmp, "does-not-exist"))).toEqual({}); - }); - - it("detects yarn-berry from packageManager field", () => { - writeFileSync( - join(tmp, "package.json"), - JSON.stringify({ packageManager: "yarn@4.5.0" }), - ); - writeFileSync(join(tmp, "yarn.lock"), ""); - expect(detect(tmp)).toEqual({ pm: "yarn-berry", pmVersion: "4.5.0" }); - }); - - it("passes pmVersion through from package.json", () => { - writeFileSync( - join(tmp, "package.json"), - JSON.stringify({ packageManager: "pnpm@9.1.0" }), - ); - const result = detect(tmp); - expect(result.pmVersion).toBe("9.1.0"); - }); - - it("omits pmVersion when no packageManager field", () => { - writeFileSync(join(tmp, "pnpm-lock.yaml"), ""); - const result = detect(tmp); - expect(result.pmVersion).toBeUndefined(); - }); -}); diff --git a/crates/hm-dsl-engine/harmont-ts/tests/toolchains/elixir.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/toolchains/elixir.test.ts deleted file mode 100644 index b5f9c7a0..00000000 --- a/crates/hm-dsl-engine/harmont-ts/tests/toolchains/elixir.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { elixir } from "../../src/toolchains/elixir.js"; -import { sh, timeout } from "../../src/step.js"; -import { pipeline } from "../../src/pipeline.js"; - -describe("elixir factory", () => { - it("returns an ElixirProject with defaults", () => { - const ex = elixir(); - expect(ex.path).toBe("."); - expect(ex.install()._cmd).toContain("mix deps.get"); - }); - - it("accepts path and versions", () => { - const ex = elixir({ path: "apps/api", elixirVersion: "1.18.3", otpVersion: "27.3.3" }); - expect(ex.path).toBe("apps/api"); - expect(ex.install()._parent!._cmd).toContain("1.18.3"); - }); - - it("rejects invalid elixir version", () => { - expect(() => elixir({ elixirVersion: "abc" })).toThrow("invalid elixir version"); - }); - - it("rejects invalid otp version", () => { - expect(() => elixir({ otpVersion: "xyz" })).toThrow("invalid otp version"); - }); -}); - -describe("elixir actions", () => { - it("compile runs mix compile --warnings-as-errors", () => { - expect(elixir().compile()._cmd).toContain("mix compile --warnings-as-errors"); - }); - - it("test runs mix test", () => { - expect(elixir().test()._cmd).toContain("mix test"); - }); - - it("format runs mix format --check-formatted", () => { - expect(elixir().format()._cmd).toContain("mix format --check-formatted"); - }); - - it("credo runs mix credo --strict", () => { - expect(elixir().credo()._cmd).toContain("mix credo --strict"); - }); - - it("plt builds PLT with onChange cache", () => { - const ex = elixir(); - const step = ex.plt(); - expect(step._cmd).toContain("mix dialyzer --plt"); - expect(step._label).toBe(":ex: plt"); - expect(step._cache).toEqual({ kind: "on_change", paths: ["./mix.lock"] }); - }); - - it("dialyzer chains through plt step", () => { - const ex = elixir(); - const step = ex.dialyzer(); - expect(step._cmd).toContain("mix dialyzer"); - expect(step._cmd).not.toContain("--plt"); - expect(step._parent!._label).toBe(":ex: plt"); - }); - - it("plt is memoized per project instance", () => { - const ex = elixir(); - expect(ex.plt()).toBe(ex.plt()); - }); - - it("sobelow runs mix sobelow --exit", () => { - expect(elixir().sobelow()._cmd).toContain("mix sobelow --exit"); - }); - - it("depsAudit runs mix deps.audit", () => { - expect(elixir().depsAudit()._cmd).toContain("mix deps.audit"); - }); - - it("hexAudit runs mix hex.audit", () => { - expect(elixir().hexAudit()._cmd).toContain("mix hex.audit"); - }); - - it("release runs MIX_ENV=prod mix release", () => { - const step = elixir().release(); - expect(step._cmd).toContain("MIX_ENV=prod mix release"); - }); - - it("test with cover flag", () => { - expect(elixir().test({ cover: true })._cmd).toContain("--cover"); - }); - - it("test with partitions flag", () => { - expect(elixir().test({ partitions: 4 })._cmd).toContain("--partitions 4"); - }); - - it("release with custom env", () => { - expect(elixir().release({ mixEnv: "staging" })._cmd).toContain("MIX_ENV=staging"); - }); - - it("actions chain from install step", () => { - const ex = elixir(); - expect(ex.compile()._parent).toBe(ex.install()); - }); - - it("accepts step options", () => { - const ex = elixir(); - const t = timeout(600, ex.test({ label: "my test" })); - expect(t._label).toBe("my test"); - expect(t._timeoutSeconds).toBe(600); - }); - - it("default labels use :ex: prefix", () => { - const ex = elixir(); - expect(ex.compile()._label).toBe(":ex: compile"); - expect(ex.test()._label).toBe(":ex: test"); - expect(ex.format()._label).toBe(":ex: format"); - expect(ex.credo()._label).toBe(":ex: credo"); - expect(ex.dialyzer()._label).toBe(":ex: dialyzer"); - }); -}); - -describe("elixir install chain", () => { - it("chain is: scratch → apt-base → erlang → elixir → mix-deps", () => { - const ex = elixir(); - const deps = ex.install(); - expect(deps._label).toBe(":ex: mix-deps"); - - const elixirInstall = deps._parent!; - expect(elixirInstall._label).toBe(":ex: elixir-install"); - - const erlangInstall = elixirInstall._parent!; - expect(erlangInstall._label).toBe(":ex: erlang-install"); - }); - - it("accepts base step", () => { - const base = sh("custom base"); - const ex = elixir({ base }); - // chain: base → erlang-install → elixir-install → mix-deps - const elixirInstall = ex.install()._parent!; - const erlangInstall = elixirInstall._parent!; - expect(erlangInstall._parent).toBe(base); - }); - - it("accepts custom image", () => { - const ex = elixir({ image: "debian:12" }); - const deps = ex.install(); - const elixirStep = deps._parent!; - const erlangStep = elixirStep._parent!; - const aptBase = erlangStep._parent!; - const root = aptBase._parent!; - expect(root._image).toBe("debian:12"); - }); -}); - -describe("elixir in pipeline", () => { - it("produces valid IR", () => { - const ex = elixir(); - const ir = pipeline([ex.compile(), ex.test(), ex.format()]); - expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(5); - expect(ir.version).toBe("0"); - }); -}); diff --git a/crates/hm-dsl-engine/harmont-ts/tests/toolchains/go.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/toolchains/go.test.ts deleted file mode 100644 index 7b0e1f09..00000000 --- a/crates/hm-dsl-engine/harmont-ts/tests/toolchains/go.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { go } from "../../src/toolchains/go.js"; -import { sh, timeout } 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 = timeout(300, g.test({ label: "my test" })); - 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()]); - expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(3); - expect(ir.version).toBe("0"); - }); -}); diff --git a/crates/hm-dsl-engine/harmont-ts/tests/toolchains/js.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/toolchains/js.test.ts deleted file mode 100644 index d80772b5..00000000 --- a/crates/hm-dsl-engine/harmont-ts/tests/toolchains/js.test.ts +++ /dev/null @@ -1,606 +0,0 @@ -import { describe, expect, it, beforeEach, afterEach } from "vitest"; -import { js, ts, JsProject } from "../../src/toolchains/js.js"; -import { sh, timeout } from "../../src/step.js"; -import { pipeline } from "../../src/pipeline.js"; -import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; - -// --------------------------------------------------------------------------- -// Factory defaults -// --------------------------------------------------------------------------- - -describe("js.project factory defaults", () => { - it("returns JsProject with defaults (node + npm), path '.'", () => { - const p = js.project(); - expect(p).toBeInstanceOf(JsProject); - expect(p.path).toBe("."); - expect(p.install()._cmd).toContain("npm ci"); - }); - - it("accepts path option", () => { - const p = js.project({ path: "packages/app" }); - expect(p.path).toBe("packages/app"); - expect(p.install()._cmd).toContain("packages/app"); - }); - - it("accepts node version", () => { - const p = js.project({ version: "22" }); - expect(p.install()._parent!._cmd).toContain("setup_22"); - }); - - it("defaults pm to bun when runtime=bun", () => { - const p = js.project({ runtime: "bun" }); - expect(p.install()._cmd).toContain("bun install"); - }); -}); - -// --------------------------------------------------------------------------- -// Version validation -// --------------------------------------------------------------------------- - -describe("js.project version validation", () => { - it("rejects invalid node version", () => { - expect(() => js.project({ version: "abc" })).toThrow("invalid version"); - }); - - it("accepts node version with .x suffix", () => { - expect(() => js.project({ version: "22.x" })).not.toThrow(); - }); - - it("rejects invalid bun version", () => { - expect(() => js.project({ runtime: "bun", version: "abc" })).toThrow( - "invalid version", - ); - }); - - it("accepts bun two-part semver", () => { - expect(() => js.project({ runtime: "bun", version: "1.2" })).not.toThrow(); - }); - - it("accepts bun three-part semver", () => { - expect(() => - js.project({ runtime: "bun", version: "1.2.3" }), - ).not.toThrow(); - }); - - it("rejects invalid deno version", () => { - expect(() => js.project({ runtime: "deno", version: "abc" })).toThrow( - "invalid version", - ); - }); - - it("accepts deno semver", () => { - expect(() => - js.project({ runtime: "deno", version: "2.0.0" }), - ).not.toThrow(); - }); -}); - -// --------------------------------------------------------------------------- -// PM / runtime validation -// --------------------------------------------------------------------------- - -describe("js.project PM/runtime validation", () => { - it("rejects pm=npm with runtime=bun", () => { - expect(() => js.project({ pm: "npm", runtime: "bun" })).toThrow( - 'runtime="bun" only supports pm="bun"', - ); - }); - - it("rejects pm=pnpm with runtime=bun", () => { - expect(() => js.project({ pm: "pnpm", runtime: "bun" })).toThrow( - 'runtime="bun" only supports pm="bun"', - ); - }); - - it("rejects pm option with runtime=deno", () => { - expect(() => js.project({ pm: "npm", runtime: "deno" })).toThrow( - "do not set pm", - ); - }); - - it("allows pm=bun with runtime=node", () => { - expect(() => js.project({ pm: "bun", runtime: "node" })).not.toThrow(); - }); - - it("allows pm=bun with runtime=bun", () => { - expect(() => js.project({ pm: "bun", runtime: "bun" })).not.toThrow(); - }); - - it("allows yarn-classic / yarn-berry with runtime=node", () => { - expect(() => js.project({ pm: "yarn-classic" })).not.toThrow(); - expect(() => js.project({ pm: "yarn-berry" })).not.toThrow(); - }); - - it("rejects yarn with runtime=bun", () => { - expect(() => js.project({ pm: "yarn-berry", runtime: "bun" })).toThrow( - 'runtime="bun" only supports pm="bun"', - ); - }); - - it("rejects pm=deno with non-deno runtime", () => { - expect(() => js.project({ pm: "deno", runtime: "node" })).toThrow( - 'pm="deno" is not valid', - ); - expect(() => js.project({ pm: "deno", runtime: "bun" })).toThrow( - 'pm="deno" is not valid', - ); - }); -}); - -// --------------------------------------------------------------------------- -// Install chain structure — node + npm -// --------------------------------------------------------------------------- - -describe("js install chain: node + npm", () => { - it("chain is: scratch → apt-base → node-install → npm-ci", () => { - const p = js.project(); - const npmCi = p.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 - }); -}); - -// --------------------------------------------------------------------------- -// Install chain structure — node + pnpm -// --------------------------------------------------------------------------- - -describe("js install chain: node + pnpm", () => { - it("chain is: scratch → apt-base → node-install → corepack-pnpm → pnpm-deps", () => { - const p = js.project({ pm: "pnpm" }); - const pnpmDeps = p.install(); - expect(pnpmDeps._cmd).toContain("pnpm install --frozen-lockfile"); - - const corepack = pnpmDeps._parent!; - expect(corepack._cmd).toContain("corepack enable pnpm"); - - const nodeInstall = corepack._parent!; - expect(nodeInstall._cmd).toContain("nodejs"); - - const aptBase = nodeInstall._parent!; - expect(aptBase._cmd).toContain("apt-get"); - - const root = aptBase._parent!; - expect(root._cmd).toBeNull(); - }); -}); - -// --------------------------------------------------------------------------- -// Install chain structure — node + yarn (classic + berry) -// --------------------------------------------------------------------------- - -describe("js install chain: node + yarn", () => { - it("yarn-classic: corepack enable → yarn install --frozen-lockfile", () => { - const p = js.project({ pm: "yarn-classic" }); - const deps = p.install(); - expect(deps._cmd).toContain("yarn install --frozen-lockfile"); - expect(deps._cache).toBeDefined(); - - const corepack = deps._parent!; - expect(corepack._cmd).toContain("corepack enable"); - - const nodeInstall = corepack._parent!; - expect(nodeInstall._cmd).toContain("nodejs"); - }); - - it("yarn-berry: corepack enable → yarn install --immutable", () => { - const p = js.project({ pm: "yarn-berry" }); - const deps = p.install(); - expect(deps._cmd).toContain("yarn install --immutable"); - - const corepack = deps._parent!; - expect(corepack._cmd).toContain("corepack enable"); - }); - - it("both yarn variants watch yarn.lock", () => { - for (const pm of ["yarn-classic", "yarn-berry"] as const) { - const deps = js.project({ pm }).install(); - const cache = deps._cache as { paths?: string[] }; - expect(JSON.stringify(cache)).toContain("yarn.lock"); - } - }); -}); - -// --------------------------------------------------------------------------- -// Install chain structure — bun + bun -// --------------------------------------------------------------------------- - -describe("js install chain: bun + bun", () => { - it("chain is: scratch → apt-base(+unzip) → bun-install → bun-deps", () => { - const p = js.project({ runtime: "bun" }); - const bunDeps = p.install(); - expect(bunDeps._cmd).toContain("bun install --frozen-lockfile"); - - const bunSetup = bunDeps._parent!; - expect(bunSetup._cmd).toContain("bun.sh/install"); - expect(bunSetup._cache).toBeDefined(); - - const aptBase = bunSetup._parent!; - expect(aptBase._cmd).toContain("apt-get"); - expect(aptBase._cmd).toContain("unzip"); - - const root = aptBase._parent!; - expect(root._cmd).toBeNull(); - }); -}); - -// --------------------------------------------------------------------------- -// Install chain structure — node + bun (PM) -// --------------------------------------------------------------------------- - -describe("js install chain: node + bun (as PM)", () => { - it("chain is: scratch → apt-base(+unzip) → node-install → bun-pm-install → bun-deps", () => { - const p = js.project({ runtime: "node", pm: "bun" }); - const bunDeps = p.install(); - expect(bunDeps._cmd).toContain("bun install --frozen-lockfile"); - - const bunPmInstall = bunDeps._parent!; - expect(bunPmInstall._cmd).toContain("bun.sh/install"); - - const nodeInstall = bunPmInstall._parent!; - expect(nodeInstall._cmd).toContain("nodejs"); - - const aptBase = nodeInstall._parent!; - expect(aptBase._cmd).toContain("apt-get"); - expect(aptBase._cmd).toContain("unzip"); - - const root = aptBase._parent!; - expect(root._cmd).toBeNull(); - }); -}); - -// --------------------------------------------------------------------------- -// Install chain structure — deno -// --------------------------------------------------------------------------- - -describe("js install chain: deno", () => { - it("chain is: scratch → apt-base(+unzip) → deno-install → deno-deps", () => { - const p = js.project({ runtime: "deno" }); - const denoDeps = p.install(); - expect(denoDeps._cmd).toContain("deno install"); - - const denoSetup = denoDeps._parent!; - expect(denoSetup._cmd).toContain("deno.land/install.sh"); - expect(denoSetup._cache).toBeDefined(); - - const aptBase = denoSetup._parent!; - expect(aptBase._cmd).toContain("apt-get"); - expect(aptBase._cmd).toContain("unzip"); - - const root = aptBase._parent!; - expect(root._cmd).toBeNull(); - }); -}); - -// --------------------------------------------------------------------------- -// Install chain — base step and custom image -// --------------------------------------------------------------------------- - -describe("js install chain: base step and custom image", () => { - it("accepts base step", () => { - const customBase = sh("custom base"); - const p = js.project({ base: customBase }); - const npmCi = p.install(); - const nodeInstall = npmCi._parent!; - expect(nodeInstall._parent).toBe(customBase); - }); - - it("accepts custom image", () => { - const p = js.project({ image: "debian:12" }); - const npmCi = p.install(); - const nodeInstall = npmCi._parent!; - const aptBase = nodeInstall._parent!; - const root = aptBase._parent!; - expect(root._image).toBe("debian:12"); - }); -}); - -// --------------------------------------------------------------------------- -// Actions — uniform run() across all PMs/runtimes -// --------------------------------------------------------------------------- - -describe("js.project actions", () => { - it("run() executes arbitrary script via npm run", () => { - const p = js.project(); - const r = p.run("typecheck"); - expect(r._cmd).toContain("npm run typecheck"); - }); - - it("run() uses pnpm run for pnpm PM", () => { - const p = js.project({ pm: "pnpm" }); - expect(p.run("typecheck")._cmd).toContain("pnpm run typecheck"); - }); - - it("run() uses yarn run for yarn PMs", () => { - expect(js.project({ pm: "yarn-classic" }).run("test")._cmd).toContain( - "yarn run test", - ); - expect(js.project({ pm: "yarn-berry" }).run("test")._cmd).toContain( - "yarn run test", - ); - }); - - it("run() uses bun run for bun runtime", () => { - const p = js.project({ runtime: "bun" }); - expect(p.run("typecheck")._cmd).toContain("bun run typecheck"); - }); - - it("run() uses deno task for deno runtime", () => { - const p = js.project({ runtime: "deno" }); - expect(p.run("typecheck")._cmd).toContain("deno task typecheck"); - }); - - it("actions attach to install (fan-out)", () => { - const p = js.project(); - expect(p.run("test")._parent).toBe(p.install()); - expect(p.run("lint")._parent).toBe(p.install()); - }); - - it("actions respect custom path", () => { - const p = js.project({ path: "packages/ui" }); - expect(p.run("test")._cmd).toContain("cd packages/ui"); - }); - - it("actions accept step options (label, timeoutSeconds)", () => { - const p = js.project(); - const t = timeout(300, p.run("test", { label: "my test" })); - expect(t._label).toBe("my test"); - expect(t._timeoutSeconds).toBe(300); - }); - - it("default label is ::