From 31f67901efc3c2275951232ce416333238489b70 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 21:10:38 -0700 Subject: [PATCH 01/39] feat(ts-dsl): scaffold harmont-ts package --- dsls/harmont-ts/CLAUDE.md | 28 + dsls/harmont-ts/package-lock.json | 1610 +++++++++++++++++++++++++++++ dsls/harmont-ts/package.json | 28 + dsls/harmont-ts/src/index.ts | 1 + dsls/harmont-ts/tsconfig.json | 19 + dsls/harmont-ts/vitest.config.ts | 8 + 6 files changed, 1694 insertions(+) create mode 100644 dsls/harmont-ts/CLAUDE.md create mode 100644 dsls/harmont-ts/package-lock.json create mode 100644 dsls/harmont-ts/package.json create mode 100644 dsls/harmont-ts/src/index.ts create mode 100644 dsls/harmont-ts/tsconfig.json create mode 100644 dsls/harmont-ts/vitest.config.ts diff --git a/dsls/harmont-ts/CLAUDE.md b/dsls/harmont-ts/CLAUDE.md new file mode 100644 index 0000000..06eb7d8 --- /dev/null +++ b/dsls/harmont-ts/CLAUDE.md @@ -0,0 +1,28 @@ +# harmont (TypeScript DSL) + +TypeScript pipeline DSL — equivalent of `dsls/harmont-py/`. + +## Commands + +- `npm test` — run Vitest test suite +- `npm run build` — compile TypeScript to `dist/` + +## Architecture + +- `src/step.ts` — Step class (immutable chain primitive) +- `src/cache.ts` — Cache policy discriminated unions +- `src/triggers.ts` — Trigger factory functions +- `src/keys.ts` — Step key resolution (slug/hash) +- `src/pipeline.ts` — Lowering pass (step chains → petgraph IR) +- `src/target.ts` — Memoized reusable targets +- `src/envelope.ts` — Envelope rendering (schema_version:1) +- `src/toolchains/` — Language toolchain abstractions +- `src/index.ts` — Public API barrel export + +## IR Compatibility + +Output must match the v0 IR that `crates/hm-pipeline-ir/` deserializes. +The Rust `CommandStep` accepts: key, cmd, label?, image?, env?, timeout_seconds?, cache?, runner?, runner_args?. +The Rust `Cache` accepts: policy, key?. +Edge kinds: `builds_in`, `depends_on`. +Envelope: `{ schema_version: "1", pipelines: [...] }`. diff --git a/dsls/harmont-ts/package-lock.json b/dsls/harmont-ts/package-lock.json new file mode 100644 index 0000000..1d799fe --- /dev/null +++ b/dsls/harmont-ts/package-lock.json @@ -0,0 +1,1610 @@ +{ + "name": "harmont", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "harmont", + "version": "0.1.0", + "devDependencies": { + "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/@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/vite": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz", + "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/dsls/harmont-ts/package.json b/dsls/harmont-ts/package.json new file mode 100644 index 0000000..1116f30 --- /dev/null +++ b/dsls/harmont-ts/package.json @@ -0,0 +1,28 @@ +{ + "name": "harmont", + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./toolchains": { + "import": "./dist/toolchains/index.js", + "types": "./dist/toolchains/index.d.ts" + } + }, + "files": ["dist"], + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "typescript": "^5.8.0", + "vitest": "^3.2.0" + }, + "engines": { + "node": ">=18" + } +} diff --git a/dsls/harmont-ts/src/index.ts b/dsls/harmont-ts/src/index.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dsls/harmont-ts/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/dsls/harmont-ts/tsconfig.json b/dsls/harmont-ts/tsconfig.json new file mode 100644 index 0000000..bc951f4 --- /dev/null +++ b/dsls/harmont-ts/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/dsls/harmont-ts/vitest.config.ts b/dsls/harmont-ts/vitest.config.ts new file mode 100644 index 0000000..ab67925 --- /dev/null +++ b/dsls/harmont-ts/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + passWithNoTests: true, + }, +}); From b4e65b65d274f72a0de27ea3361647d72fb1be6f Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 21:12:21 -0700 Subject: [PATCH 02/39] feat(ts-dsl): Step primitive with sh/scratch/wait/fork --- dsls/harmont-ts/src/cache.ts | 1 + dsls/harmont-ts/src/step.ts | 113 +++++++++++++++++++++++++++++ dsls/harmont-ts/tests/step.test.ts | 103 ++++++++++++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 dsls/harmont-ts/src/cache.ts create mode 100644 dsls/harmont-ts/src/step.ts create mode 100644 dsls/harmont-ts/tests/step.test.ts diff --git a/dsls/harmont-ts/src/cache.ts b/dsls/harmont-ts/src/cache.ts new file mode 100644 index 0000000..7e487b8 --- /dev/null +++ b/dsls/harmont-ts/src/cache.ts @@ -0,0 +1 @@ +export type CachePolicy = { readonly kind: string }; diff --git a/dsls/harmont-ts/src/step.ts b/dsls/harmont-ts/src/step.ts new file mode 100644 index 0000000..2b95ce6 --- /dev/null +++ b/dsls/harmont-ts/src/step.ts @@ -0,0 +1,113 @@ +import type { CachePolicy } from "./cache.js"; + +export interface StepOptions { + readonly label?: string; + readonly cache?: CachePolicy; + readonly env?: Readonly>; + readonly timeoutSeconds?: number; + readonly image?: string; + readonly runner?: string; + readonly runnerArgs?: Readonly>; + readonly key?: string; + readonly cwd?: string; +} + +let nextId = 0; + +export class Step { + readonly _id: number; + readonly _cmd: string | null; + readonly _parent: Step | null; + readonly _isWait: boolean; + readonly _continueOnFailure: boolean; + readonly _label: string | undefined; + readonly _cache: CachePolicy | undefined; + readonly _env: Readonly> | undefined; + readonly _timeoutSeconds: number | undefined; + readonly _image: string | undefined; + readonly _runner: string | undefined; + readonly _runnerArgs: Readonly> | undefined; + readonly _keyOverride: string | undefined; + + /** @internal */ + constructor(init: { + cmd: string | null; + parent: Step | null; + isWait?: boolean; + continueOnFailure?: boolean; + label?: string; + cache?: CachePolicy; + env?: Record; + timeoutSeconds?: number; + image?: string; + runner?: string; + runnerArgs?: Record; + keyOverride?: string; + }) { + this._id = nextId++; + this._cmd = init.cmd; + this._parent = init.parent; + this._isWait = init.isWait ?? false; + this._continueOnFailure = init.continueOnFailure ?? false; + this._label = init.label; + this._cache = init.cache; + this._env = init.env; + this._timeoutSeconds = init.timeoutSeconds; + this._image = init.image; + this._runner = init.runner; + this._runnerArgs = init.runnerArgs; + this._keyOverride = init.keyOverride; + } + + sh(cmd: string, opts?: StepOptions): Step { + if (opts?.cwd === "") { + throw new Error( + 'hm: cwd must be a non-empty path\n → omit cwd to run in the workspace root, or pass cwd="some/dir"', + ); + } + const effectiveCmd = opts?.cwd != null ? `cd ${opts.cwd} && ${cmd}` : cmd; + const effectiveImage = + opts?.image != null + ? opts.image + : this._cmd === null + ? this._image + : undefined; + return new Step({ + cmd: effectiveCmd, + parent: this, + label: opts?.label, + cache: opts?.cache, + env: opts?.env, + timeoutSeconds: opts?.timeoutSeconds, + image: effectiveImage, + runner: opts?.runner, + runnerArgs: opts?.runnerArgs, + keyOverride: opts?.key, + }); + } + + fork(opts?: { label?: string }): Step { + return new Step({ + cmd: null, + parent: this, + label: opts?.label, + }); + } +} + +export function scratch(opts?: { image?: string }): Step { + return new Step({ cmd: null, parent: null, image: opts?.image }); +} + +export function sh(cmd: string, opts?: StepOptions): Step { + return scratch().sh(cmd, opts); +} + +export function wait(opts?: { continueOnFailure?: boolean }): Step { + return new Step({ + cmd: null, + parent: null, + isWait: true, + continueOnFailure: opts?.continueOnFailure ?? false, + }); +} diff --git a/dsls/harmont-ts/tests/step.test.ts b/dsls/harmont-ts/tests/step.test.ts new file mode 100644 index 0000000..d3b2ed8 --- /dev/null +++ b/dsls/harmont-ts/tests/step.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { scratch, sh, wait, Step } from "../src/step.js"; + +describe("scratch", () => { + it("creates a root step with no cmd or parent", () => { + const s = scratch(); + expect(s).toBeInstanceOf(Step); + expect(s._cmd).toBeNull(); + expect(s._parent).toBeNull(); + expect(s._isWait).toBe(false); + }); +}); + +describe("sh", () => { + it("creates a step with cmd and implicit scratch parent", () => { + const s = sh("echo hello"); + expect(s._cmd).toBe("echo hello"); + expect(s._parent).not.toBeNull(); + expect(s._parent!._cmd).toBeNull(); + }); + + it("passes options through", () => { + const s = sh("make", { + label: "build", + timeoutSeconds: 600, + env: { CI: "true" }, + image: "ubuntu:24.04", + key: "my-key", + }); + expect(s._label).toBe("build"); + expect(s._timeoutSeconds).toBe(600); + expect(s._env).toEqual({ CI: "true" }); + expect(s._image).toBe("ubuntu:24.04"); + expect(s._keyOverride).toBe("my-key"); + }); + + it("prepends cd when cwd is set", () => { + const s = sh("npm test", { cwd: "packages/app" }); + expect(s._cmd).toBe("cd packages/app && npm test"); + }); + + it("rejects empty cwd", () => { + expect(() => sh("echo", { cwd: "" })).toThrow("cwd must be a non-empty path"); + }); +}); + +describe("Step.sh", () => { + it("chains a child step with parent pointer", () => { + const parent = sh("install"); + const child = parent.sh("build"); + expect(child._cmd).toBe("build"); + expect(child._parent).toBe(parent); + }); + + it("inherits image from scratch parent", () => { + const base = scratch({ image: "alpine:3.20" }); + const child = base.sh("echo"); + expect(child._image).toBe("alpine:3.20"); + }); + + it("does not inherit image from command parent", () => { + const parent = sh("install", { image: "ubuntu:24.04" }); + const child = parent.sh("build"); + expect(child._image).toBeUndefined(); + }); + + it("explicit image overrides inherited image", () => { + const base = scratch({ image: "alpine:3.20" }); + const child = base.sh("echo", { image: "ubuntu:24.04" }); + expect(child._image).toBe("ubuntu:24.04"); + }); +}); + +describe("Step.fork", () => { + it("creates a cmd-less step with parent pointer", () => { + const parent = sh("install"); + const branch = parent.fork({ label: "branch-a" }); + expect(branch._cmd).toBeNull(); + expect(branch._parent).toBe(parent); + expect(branch._label).toBe("branch-a"); + }); +}); + +describe("wait", () => { + it("creates a wait step", () => { + const w = wait(); + expect(w._isWait).toBe(true); + expect(w._continueOnFailure).toBe(false); + }); + + it("accepts continueOnFailure", () => { + const w = wait({ continueOnFailure: true }); + expect(w._continueOnFailure).toBe(true); + }); +}); + +describe("step identity", () => { + it("each step gets a unique id", () => { + const a = sh("a"); + const b = sh("b"); + expect(a._id).not.toBe(b._id); + }); +}); From ad03b57f1a177030904178d559acce223b7c5fee Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 21:13:20 -0700 Subject: [PATCH 03/39] feat(ts-dsl): cache policy types (forever/ttl/onChange/compose) --- dsls/harmont-ts/src/cache.ts | 42 +++++++++++++++++++++- dsls/harmont-ts/tests/cache.test.ts | 56 +++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 dsls/harmont-ts/tests/cache.test.ts diff --git a/dsls/harmont-ts/src/cache.ts b/dsls/harmont-ts/src/cache.ts index 7e487b8..fc423c6 100644 --- a/dsls/harmont-ts/src/cache.ts +++ b/dsls/harmont-ts/src/cache.ts @@ -1 +1,41 @@ -export type CachePolicy = { readonly kind: string }; +export interface CacheForever { + readonly kind: "forever"; + readonly envKeys: readonly string[]; +} + +export interface CacheTTL { + readonly kind: "ttl"; + readonly durationSeconds: number; + readonly envKeys: readonly string[]; +} + +export interface CacheOnChange { + readonly kind: "on_change"; + readonly paths: readonly string[]; +} + +export interface CacheCompose { + readonly kind: "compose"; + readonly policies: readonly CachePolicy[]; +} + +export type CachePolicy = CacheForever | CacheTTL | CacheOnChange | CacheCompose; + +export function forever(opts?: { envKeys?: string[] }): CacheForever { + return { kind: "forever", envKeys: opts?.envKeys ?? [] }; +} + +export function ttl( + durationSeconds: number, + opts?: { envKeys?: string[] }, +): CacheTTL { + return { kind: "ttl", durationSeconds, envKeys: opts?.envKeys ?? [] }; +} + +export function onChange(...paths: string[]): CacheOnChange { + return { kind: "on_change", paths }; +} + +export function compose(...policies: CachePolicy[]): CacheCompose { + return { kind: "compose", policies }; +} diff --git a/dsls/harmont-ts/tests/cache.test.ts b/dsls/harmont-ts/tests/cache.test.ts new file mode 100644 index 0000000..cee168d --- /dev/null +++ b/dsls/harmont-ts/tests/cache.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { forever, ttl, onChange, compose, type CachePolicy } from "../src/cache.js"; + +describe("forever", () => { + it("creates a forever policy with no env keys", () => { + const p = forever(); + expect(p).toEqual({ kind: "forever", envKeys: [] }); + }); + + it("accepts env keys", () => { + const p = forever({ envKeys: ["NODE_ENV"] }); + expect(p.envKeys).toEqual(["NODE_ENV"]); + }); +}); + +describe("ttl", () => { + it("creates a ttl policy with duration in seconds", () => { + const p = ttl(3600); + expect(p).toEqual({ kind: "ttl", durationSeconds: 3600, envKeys: [] }); + }); + + it("accepts env keys", () => { + const p = ttl(86400, { envKeys: ["CI"] }); + expect(p.envKeys).toEqual(["CI"]); + }); +}); + +describe("onChange", () => { + it("creates an on_change policy with paths", () => { + const p = onChange("src/", "package.json"); + expect(p).toEqual({ kind: "on_change", paths: ["src/", "package.json"] }); + }); +}); + +describe("compose", () => { + it("composes multiple policies", () => { + const p = compose(ttl(86400), onChange("src/")); + expect(p.kind).toBe("compose"); + expect(p.policies).toHaveLength(2); + expect(p.policies[0].kind).toBe("ttl"); + expect(p.policies[1].kind).toBe("on_change"); + }); +}); + +describe("type discrimination", () => { + it("kind field enables type narrowing", () => { + const p: CachePolicy = forever(); + switch (p.kind) { + case "forever": + expect(p.envKeys).toEqual([]); + break; + default: + throw new Error("unexpected kind"); + } + }); +}); From ad2ddd19bdbed9f381c4c239e29a66ef59557ae4 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 21:13:51 -0700 Subject: [PATCH 04/39] feat(ts-dsl): trigger types (push/pullRequest/schedule) --- dsls/harmont-ts/src/triggers.ts | 118 +++++++++++++++++++++++++ dsls/harmont-ts/tests/triggers.test.ts | 69 +++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 dsls/harmont-ts/src/triggers.ts create mode 100644 dsls/harmont-ts/tests/triggers.test.ts diff --git a/dsls/harmont-ts/src/triggers.ts b/dsls/harmont-ts/src/triggers.ts new file mode 100644 index 0000000..212059c --- /dev/null +++ b/dsls/harmont-ts/src/triggers.ts @@ -0,0 +1,118 @@ +export type Trigger = PushTrigger | PullRequestTrigger | ScheduleTrigger; + +function normalizeGlobs( + value: string | readonly string[] | undefined, +): string[] | undefined { + if (value === undefined) return undefined; + if (typeof value === "string") return [value]; + return [...value]; +} + +export class PushTrigger { + readonly branches: string[] | undefined; + readonly tags: string[] | undefined; + + constructor(branches: string[] | undefined, tags: string[] | undefined) { + this.branches = branches; + this.tags = tags; + } + + toJSON(): Record { + const out: Record = { event: "push" }; + if (this.branches !== undefined) out.branches = this.branches; + if (this.tags !== undefined) out.tags = this.tags; + return out; + } +} + +export function push( + opts: { branch: string | string[]; tag?: undefined } | { tag: string | string[]; branch?: undefined }, +): PushTrigger { + const branch = "branch" in opts ? opts.branch : undefined; + const tag = "tag" in opts ? opts.tag : undefined; + const branches = normalizeGlobs(branch); + const tags = normalizeGlobs(tag); + if ((branches === undefined) === (tags === undefined)) { + throw new Error( + 'hm.push: pass exactly one of branch or tag\n → e.g. push({ branch: "main" }) or push({ tag: "v*" })', + ); + } + return new PushTrigger(branches, tags); +} + +const PR_TYPES = new Set([ + "opened", + "synchronize", + "reopened", + "closed", + "ready_for_review", +] as const); + +type PrEventType = "opened" | "synchronize" | "reopened" | "closed" | "ready_for_review"; + +const DEFAULT_PR_TYPES: PrEventType[] = ["opened", "synchronize", "reopened"]; + +export class PullRequestTrigger { + readonly branches: string[] | undefined; + readonly types: string[]; + + constructor(branches: string[] | undefined, types: string[]) { + this.branches = branches; + this.types = types; + } + + toJSON(): Record { + const out: Record = { event: "pull_request" }; + if (this.branches !== undefined) out.branches = this.branches; + out.types = this.types; + return out; + } +} + +export function pullRequest(opts?: { + branches?: string | string[]; + types?: PrEventType[]; +}): PullRequestTrigger { + const types = opts?.types ?? DEFAULT_PR_TYPES; + if (types.length === 0) { + throw new Error("hm.pullRequest: types must be non-empty"); + } + for (const t of types) { + if (!PR_TYPES.has(t as any)) { + const valid = [...PR_TYPES].sort().join(", "); + throw new Error(`unknown pull_request type "${t}"\n → valid: ${valid}`); + } + } + return new PullRequestTrigger(normalizeGlobs(opts?.branches), [...types]); +} + +export class ScheduleTrigger { + readonly cron: string; + + constructor(cron: string) { + this.cron = cron; + } + + toJSON(): Record { + return { event: "schedule", cron: this.cron }; + } +} + +const CRON_FIELD_RE = /^(\*|[0-9]+(-[0-9]+)?(\/[0-9]+)?|(\*\/[0-9]+))$/; + +function isValidCron(expr: string): boolean { + const fields = expr.trim().split(/\s+/); + if (fields.length !== 5) return false; + return fields.every((f) => { + return f.split(",").every((part) => CRON_FIELD_RE.test(part)); + }); +} + +export function schedule(cron: string): ScheduleTrigger { + if (!isValidCron(cron)) { + throw new Error( + `hm.schedule: invalid cron expression "${cron}"\n → five-field crontab, UTC, e.g. '0 4 * * *'`, + ); + } + return new ScheduleTrigger(cron); +} diff --git a/dsls/harmont-ts/tests/triggers.test.ts b/dsls/harmont-ts/tests/triggers.test.ts new file mode 100644 index 0000000..1d8c50b --- /dev/null +++ b/dsls/harmont-ts/tests/triggers.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { push, pullRequest, schedule } from "../src/triggers.js"; + +describe("push", () => { + it("creates a branch trigger from string", () => { + const t = push({ branch: "main" }); + expect(t.toJSON()).toEqual({ event: "push", branches: ["main"] }); + }); + + it("creates a branch trigger from array", () => { + const t = push({ branch: ["main", "develop"] }); + expect(t.toJSON()).toEqual({ event: "push", branches: ["main", "develop"] }); + }); + + it("creates a tag trigger", () => { + const t = push({ tag: "v*" }); + expect(t.toJSON()).toEqual({ event: "push", tags: ["v*"] }); + }); + + it("rejects when neither branch nor tag", () => { + expect(() => push({} as any)).toThrow("exactly one of branch or tag"); + }); + + it("rejects when both branch and tag", () => { + expect(() => push({ branch: "main", tag: "v*" } as any)).toThrow( + "exactly one of branch or tag", + ); + }); +}); + +describe("pullRequest", () => { + it("uses default types when none specified", () => { + const t = pullRequest(); + expect(t.toJSON()).toEqual({ + event: "pull_request", + types: ["opened", "synchronize", "reopened"], + }); + }); + + it("accepts branch filter", () => { + const t = pullRequest({ branches: ["main"] }); + const json = t.toJSON(); + expect(json.branches).toEqual(["main"]); + }); + + it("accepts custom types", () => { + const t = pullRequest({ types: ["opened", "closed"] }); + expect(t.toJSON().types).toEqual(["opened", "closed"]); + }); + + it("rejects invalid types", () => { + expect(() => pullRequest({ types: ["invalid" as any] })).toThrow("unknown pull_request type"); + }); + + it("rejects empty types", () => { + expect(() => pullRequest({ types: [] })).toThrow("types must be non-empty"); + }); +}); + +describe("schedule", () => { + it("creates a cron trigger", () => { + const t = schedule("0 4 * * *"); + expect(t.toJSON()).toEqual({ event: "schedule", cron: "0 4 * * *" }); + }); + + it("rejects invalid cron", () => { + expect(() => schedule("not a cron")).toThrow("invalid cron expression"); + }); +}); From 8c79be87263c9e92cbce40e7cbc551d77d7fa9cc Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 21:14:45 -0700 Subject: [PATCH 05/39] feat(ts-dsl): step key resolution (slugify/hash/resolve) --- dsls/harmont-ts/package-lock.json | 18 ++++++ dsls/harmont-ts/package.json | 5 +- dsls/harmont-ts/src/keys.ts | 84 ++++++++++++++++++++++++++++ dsls/harmont-ts/tests/keys.test.ts | 88 ++++++++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 dsls/harmont-ts/src/keys.ts create mode 100644 dsls/harmont-ts/tests/keys.test.ts diff --git a/dsls/harmont-ts/package-lock.json b/dsls/harmont-ts/package-lock.json index 1d799fe..e3ce34f 100644 --- a/dsls/harmont-ts/package-lock.json +++ b/dsls/harmont-ts/package-lock.json @@ -8,6 +8,7 @@ "name": "harmont", "version": "0.1.0", "devDependencies": { + "@types/node": "^25.9.1", "typescript": "^5.8.0", "vitest": "^3.2.0" }, @@ -839,6 +840,16 @@ "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", @@ -1418,6 +1429,13 @@ "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", diff --git a/dsls/harmont-ts/package.json b/dsls/harmont-ts/package.json index 1116f30..100c747 100644 --- a/dsls/harmont-ts/package.json +++ b/dsls/harmont-ts/package.json @@ -12,13 +12,16 @@ "types": "./dist/toolchains/index.d.ts" } }, - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "tsc", "test": "vitest run", "test:watch": "vitest" }, "devDependencies": { + "@types/node": "^25.9.1", "typescript": "^5.8.0", "vitest": "^3.2.0" }, diff --git a/dsls/harmont-ts/src/keys.ts b/dsls/harmont-ts/src/keys.ts new file mode 100644 index 0000000..75408b3 --- /dev/null +++ b/dsls/harmont-ts/src/keys.ts @@ -0,0 +1,84 @@ +import { createHash } from "node:crypto"; +import type { Step } from "./step.js"; + +const EMOJI_SHORTCODE_RE = /:[a-z0-9_+-]+:/g; +const NON_ALNUM_RE = /[^a-z0-9]+/g; + +export function slugifyLabel(label: string): string { + let s = label.toLowerCase(); + s = s.replace(EMOJI_SHORTCODE_RE, " "); + s = s.replace(NON_ALNUM_RE, "-"); + s = s.replace(/^-+|-+$/g, ""); + return s; +} + +export function hashKey(parentKey: string, cmd: string, position: number): string { + const h = createHash("sha256"); + h.update(parentKey, "utf8"); + h.update("\0"); + h.update(cmd, "utf8"); + h.update("\0"); + h.update(String(position), "utf8"); + return h.digest("hex").slice(0, 12); +} + +export function resolveKeys(steps: readonly Step[]): Map { + const overrides = new Map(); + const naturalSlugs = new Map(); + + for (const s of steps) { + if (s._keyOverride != null) { + overrides.set(s._id, s._keyOverride); + } + if (s._label != null) { + const slug = slugifyLabel(s._label); + if (slug) { + naturalSlugs.set(s._id, slug); + } + } + } + + const reserved = new Set(overrides.values()); + + const slugCounts = new Map(); + for (const slug of naturalSlugs.values()) { + slugCounts.set(slug, (slugCounts.get(slug) ?? 0) + 1); + } + + const labelSlugs = new Map(); + for (const [id, slug] of naturalSlugs) { + if (!overrides.has(id)) { + labelSlugs.set(id, slug); + } + } + + const keys = new Map(); + for (let position = 0; position < steps.length; position++) { + const s = steps[position]; + const sid = s._id; + + if (overrides.has(sid)) { + keys.set(sid, overrides.get(sid)!); + continue; + } + + const candidateSlug = labelSlugs.get(sid); + if ( + candidateSlug != null && + !reserved.has(candidateSlug) && + slugCounts.get(candidateSlug) === 1 + ) { + keys.set(sid, candidateSlug); + reserved.add(candidateSlug); + continue; + } + + let parentKey = ""; + if (s._parent != null && keys.has(s._parent._id)) { + parentKey = keys.get(s._parent._id)!; + } + keys.set(sid, hashKey(parentKey, s._cmd ?? "", position)); + } + + return keys; +} diff --git a/dsls/harmont-ts/tests/keys.test.ts b/dsls/harmont-ts/tests/keys.test.ts new file mode 100644 index 0000000..593468f --- /dev/null +++ b/dsls/harmont-ts/tests/keys.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { slugifyLabel, hashKey, resolveKeys } from "../src/keys.js"; +import { scratch, sh } from "../src/step.js"; + +describe("slugifyLabel", () => { + it("lowercases and replaces non-alnum with dashes", () => { + expect(slugifyLabel("Hello World")).toBe("hello-world"); + }); + + it("strips emoji shortcodes", () => { + expect(slugifyLabel(":rust: build")).toBe("build"); + }); + + it("trims leading/trailing dashes", () => { + expect(slugifyLabel("--hello--")).toBe("hello"); + }); + + it("returns empty string for non-ASCII-only labels", () => { + expect(slugifyLabel("构建")).toBe(""); + }); + + it("handles mixed emoji and text", () => { + expect(slugifyLabel(":node: deps install")).toBe("deps-install"); + }); +}); + +describe("hashKey", () => { + it("returns a 12-char hex string", () => { + const key = hashKey("parent", "echo hello", 0); + expect(key).toMatch(/^[0-9a-f]{12}$/); + }); + + it("is deterministic", () => { + expect(hashKey("p", "cmd", 1)).toBe(hashKey("p", "cmd", 1)); + }); + + it("differs for different inputs", () => { + expect(hashKey("p", "cmd1", 0)).not.toBe(hashKey("p", "cmd2", 0)); + }); +}); + +describe("resolveKeys", () => { + it("uses slugified label when unique", () => { + const a = scratch().sh("install", { label: "install" }); + const b = a.sh("build", { label: "build" }); + const keys = resolveKeys([a, b]); + expect(keys.get(a._id)).toBe("install"); + expect(keys.get(b._id)).toBe("build"); + }); + + it("falls back to hash when label slugs collide", () => { + const a = scratch().sh("cmd a", { label: "test" }); + const b = scratch().sh("cmd b", { label: "test" }); + const keys = resolveKeys([a, b]); + expect(keys.get(a._id)).toMatch(/^[0-9a-f]{12}$/); + expect(keys.get(b._id)).toMatch(/^[0-9a-f]{12}$/); + expect(keys.get(a._id)).not.toBe(keys.get(b._id)); + }); + + it("explicit key override wins over label", () => { + const a = scratch().sh("echo", { label: "hello", key: "my-key" }); + const keys = resolveKeys([a]); + expect(keys.get(a._id)).toBe("my-key"); + }); + + it("explicit override reserves slug, colliding label falls back to hash", () => { + const a = scratch().sh("cmd a", { label: "build", key: "build" }); + const b = scratch().sh("cmd b", { label: "build" }); + const keys = resolveKeys([a, b]); + expect(keys.get(a._id)).toBe("build"); + expect(keys.get(b._id)).toMatch(/^[0-9a-f]{12}$/); + }); + + it("falls back to hash when label is empty after slugify", () => { + const a = scratch().sh("echo", { label: "构建" }); + const keys = resolveKeys([a]); + expect(keys.get(a._id)).toMatch(/^[0-9a-f]{12}$/); + }); + + it("uses parent key for hash computation", () => { + const parent = scratch().sh("install", { label: "install" }); + const child = parent.sh("build"); + const keys = resolveKeys([parent, child]); + expect(keys.get(parent._id)).toBe("install"); + const expected = hashKey("install", "build", 1); + expect(keys.get(child._id)).toBe(expected); + }); +}); From 7a026ba9973e34e6f3333bca4ce8a43d1502579a Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 21:17:00 -0700 Subject: [PATCH 06/39] feat(ts-dsl): target system (memoized pipeline building blocks) --- dsls/harmont-ts/src/target.ts | 20 +++++++++ dsls/harmont-ts/tests/target.test.ts | 64 ++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 dsls/harmont-ts/src/target.ts create mode 100644 dsls/harmont-ts/tests/target.test.ts diff --git a/dsls/harmont-ts/src/target.ts b/dsls/harmont-ts/src/target.ts new file mode 100644 index 0000000..7b3e86f --- /dev/null +++ b/dsls/harmont-ts/src/target.ts @@ -0,0 +1,20 @@ +import type { Step } from "./step.js"; + +const cache = new Map(); + +export function target( + _name: string, + fn: () => Step, +): () => Step { + const key = Symbol(_name); + return () => { + if (!cache.has(key)) { + cache.set(key, fn()); + } + return cache.get(key)!; + }; +} + +export function clearTargetCache(): void { + cache.clear(); +} diff --git a/dsls/harmont-ts/tests/target.test.ts b/dsls/harmont-ts/tests/target.test.ts new file mode 100644 index 0000000..818c41a --- /dev/null +++ b/dsls/harmont-ts/tests/target.test.ts @@ -0,0 +1,64 @@ +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"); + }); +}); From b992bd5e5d91107e4384dbba5af835ce7eef60d2 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 21:17:54 -0700 Subject: [PATCH 07/39] =?UTF-8?q?feat(ts-dsl):=20pipeline=20lowering=20(st?= =?UTF-8?q?ep=20chains=20=E2=86=92=20petgraph=20IR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsls/harmont-ts/src/pipeline.ts | 209 ++++++++++++++++++++++++ dsls/harmont-ts/tests/pipeline.test.ts | 213 +++++++++++++++++++++++++ 2 files changed, 422 insertions(+) create mode 100644 dsls/harmont-ts/src/pipeline.ts create mode 100644 dsls/harmont-ts/tests/pipeline.test.ts diff --git a/dsls/harmont-ts/src/pipeline.ts b/dsls/harmont-ts/src/pipeline.ts new file mode 100644 index 0000000..29674f0 --- /dev/null +++ b/dsls/harmont-ts/src/pipeline.ts @@ -0,0 +1,209 @@ +import type { CachePolicy } from "./cache.js"; +import { resolveKeys } from "./keys.js"; +import type { Step } from "./step.js"; + +export interface PipelineOptions { + readonly env?: Readonly>; + readonly defaultImage?: string; +} + +export interface PipelineIR { + version: string; + default_image?: string; + graph: { + nodes: GraphNode[]; + node_holes: never[]; + edge_property: "directed"; + edges: [number, number, string][]; + }; +} + +interface GraphNode { + step: Record; + env: Record; +} + +export function pipeline(...args: (Step | PipelineOptions)[]): PipelineIR { + if (args.length === 0) { + throw new Error( + "pipeline must have at least one leaf — pass the terminal step(s) of each branch as positional args", + ); + } + + let leaves: Step[]; + let opts: PipelineOptions | undefined; + + const last = args[args.length - 1]; + if (last && typeof last === "object" && !("_id" in last)) { + opts = last as PipelineOptions; + leaves = args.slice(0, -1) as Step[]; + } else { + leaves = args as Step[]; + } + + if (leaves.length === 0) { + throw new Error( + "pipeline must have at least one leaf — pass the terminal step(s) of each branch as positional args", + ); + } + + const ir: PipelineIR = { version: "0", graph: lowerToGraph(leaves, opts) }; + if (opts?.defaultImage != null) { + ir.default_image = opts.defaultImage; + } + return ir; +} + +function lowerToGraph( + leaves: Step[], + opts?: PipelineOptions, +): PipelineIR["graph"] { + const ordered = topoCollect(leaves); + const commandSteps = ordered.filter((s) => s._cmd !== null && !s._isWait); + const keys = resolveKeys(commandSteps); + + const idxById = new Map(); + for (let i = 0; i < commandSteps.length; i++) { + idxById.set(commandSteps[i]._id, i); + } + + const hasBuildsInParent = new Set(); + const nodes: GraphNode[] = []; + const edges: [number, number, string][] = []; + + let preWaitIndices: number[] = []; + let pendingDependsOn: number[] = []; + + for (const s of ordered) { + if (s._isWait) { + pendingDependsOn = [...preWaitIndices]; + preWaitIndices = []; + continue; + } + + if (s._cmd === null) continue; + + const nodeIdx = idxById.get(s._id)!; + const stepKey = keys.get(s._id)!; + + const stepDict: Record = { + key: stepKey, + cmd: s._cmd, + }; + if (s._label != null) stepDict.label = s._label; + if (s._cache != null) stepDict.cache = cachePolicyToDict(s._cache); + if (s._timeoutSeconds != null) stepDict.timeout_seconds = s._timeoutSeconds; + if (s._image != null) stepDict.image = s._image; + if (s._runner != null) stepDict.runner = s._runner; + if (s._runnerArgs != null) stepDict.runner_args = s._runnerArgs; + + const mergedEnv: Record = {}; + if (opts?.env) Object.assign(mergedEnv, opts.env); + if (s._env) Object.assign(mergedEnv, s._env); + + nodes.push({ step: stepDict, env: mergedEnv }); + + const parentKey = resolvedParentKey(s, keys); + if (parentKey !== null) { + const parentIdx = findIdxByKey(parentKey, commandSteps, keys, idxById); + edges.push([parentIdx, nodeIdx, "builds_in"]); + hasBuildsInParent.add(nodeIdx); + } + + for (const depIdx of pendingDependsOn) { + edges.push([depIdx, nodeIdx, "depends_on"]); + } + + preWaitIndices.push(nodeIdx); + } + + if (opts?.defaultImage != null) { + for (let i = 0; i < nodes.length; i++) { + if (!hasBuildsInParent.has(i) && !("image" in nodes[i].step)) { + nodes[i].step.image = opts.defaultImage; + } + } + } + + return { + nodes, + node_holes: [], + edge_property: "directed", + edges, + }; +} + +function topoCollect(leaves: Step[]): Step[] { + const seen = new Set(); + const ordered: Step[] = []; + + for (const leaf of leaves) { + if (leaf._isWait) { + ordered.push(leaf); + continue; + } + const chain: Step[] = []; + let node: Step | null = leaf; + while (node !== null) { + if (seen.has(node._id)) break; + chain.push(node); + node = node._parent; + } + for (let i = chain.length - 1; i >= 0; i--) { + const s = chain[i]; + if (seen.has(s._id)) continue; + seen.add(s._id); + ordered.push(s); + } + } + + return ordered; +} + +function resolvedParentKey( + s: Step, + keys: Map, +): string | null { + let node = s._parent; + while (node !== null) { + if (node._cmd !== null && !node._isWait) { + return keys.get(node._id) ?? null; + } + node = node._parent; + } + return null; +} + +function findIdxByKey( + key: string, + commandSteps: Step[], + keys: Map, + idxById: Map, +): number { + for (const s of commandSteps) { + if (keys.get(s._id) === key) { + return idxById.get(s._id)!; + } + } + throw new Error(`BUG: no step with key "${key}"`); +} + +function cachePolicyToDict(policy: CachePolicy): Record { + switch (policy.kind) { + case "forever": + return { policy: "forever", env_keys: [...policy.envKeys] }; + case "ttl": + return { + policy: "ttl", + duration_seconds: policy.durationSeconds, + env_keys: [...policy.envKeys], + }; + case "on_change": + return { policy: "on_change", paths: [...policy.paths] }; + case "compose": + return { + policy: "compose", + sub_policies: policy.policies.map(cachePolicyToDict), + }; + } +} diff --git a/dsls/harmont-ts/tests/pipeline.test.ts b/dsls/harmont-ts/tests/pipeline.test.ts new file mode 100644 index 0000000..c008167 --- /dev/null +++ b/dsls/harmont-ts/tests/pipeline.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "vitest"; +import { pipeline } from "../src/pipeline.js"; +import { scratch, sh, wait } from "../src/step.js"; +import { forever, onChange } from "../src/cache.js"; + +function stepKeys(ir: any): string[] { + return ir.graph.nodes.map((n: any) => n.step.key); +} + +function buildsInEdges(ir: any): [number, number][] { + return ir.graph.edges + .filter((e: any) => e[2] === "builds_in") + .map((e: any) => [e[0], e[1]]); +} + +function dependsOnEdges(ir: any): [number, number][] { + return ir.graph.edges + .filter((e: any) => e[2] === "depends_on") + .map((e: any) => [e[0], e[1]]); +} + +function parentKeyMap(ir: any): Record { + const keyByIdx: Record = {}; + for (let i = 0; i < ir.graph.nodes.length; i++) { + keyByIdx[i] = ir.graph.nodes[i].step.key; + } + const result: Record = {}; + for (const n of ir.graph.nodes) { + result[n.step.key] = null; + } + for (const [src, dst, kind] of ir.graph.edges) { + if (kind === "builds_in") { + result[keyByIdx[dst]] = keyByIdx[src]; + } + } + return result; +} + +describe("pipeline", () => { + it("returns v0 IR dict", () => { + const p = pipeline(scratch().sh("echo", { label: "echo" })); + expect(p.version).toBe("0"); + expect(p.graph).toBeDefined(); + expect(p.graph.nodes).toHaveLength(1); + }); + + it("rejects no leaves", () => { + expect(() => pipeline()).toThrow("at least one leaf"); + }); + + it("sets default_image on IR when provided", () => { + const p = pipeline(sh("echo", { label: "a", image: "ubuntu:24.04" }), { + defaultImage: "alpine:3.20", + }); + expect(p.default_image).toBe("alpine:3.20"); + expect(p.graph.nodes[0].step.image).toBe("ubuntu:24.04"); + }); +}); + +describe("lowering: single chain", () => { + it("emits nodes in parent-first order with builds_in edges", () => { + const a = scratch().sh("step a", { label: "a" }); + const b = a.sh("step b", { label: "b" }); + const c = b.sh("step c", { label: "c" }); + const ir = pipeline(c); + expect(stepKeys(ir)).toEqual(["a", "b", "c"]); + const parents = parentKeyMap(ir); + expect(parents.a).toBeNull(); + expect(parents.b).toBe("a"); + expect(parents.c).toBe("b"); + }); +}); + +describe("lowering: fork", () => { + it("fork nodes are not emitted, children inherit grandparent", () => { + const base = scratch().sh("install", { label: "install" }); + const branch = base.fork({ label: "branch-a" }); + const leaf = branch.sh("test", { label: "test" }); + const ir = pipeline(leaf); + expect(stepKeys(ir)).toEqual(["install", "test"]); + const parents = parentKeyMap(ir); + expect(parents.install).toBeNull(); + expect(parents.test).toBe("install"); + }); + + it("two branches share parent", () => { + const base = scratch().sh("install", { label: "install" }); + const a = base.fork().sh("test-a", { label: "test-a" }); + const b = base.fork().sh("test-b", { label: "test-b" }); + const ir = pipeline(a, b); + const parents = parentKeyMap(ir); + expect(parents["test-a"]).toBe("install"); + expect(parents["test-b"]).toBe("install"); + }); +}); + +describe("lowering: wait", () => { + it("emits depends_on edges from pre-wait to post-wait steps", () => { + const a = scratch().sh("a", { label: "a" }); + const b = scratch().sh("b", { label: "b" }); + const c = scratch().sh("c", { label: "c" }); + const ir = pipeline(a, b, wait(), c); + const keys = stepKeys(ir); + const idxA = keys.indexOf("a"); + const idxB = keys.indexOf("b"); + const idxC = keys.indexOf("c"); + const deps = dependsOnEdges(ir); + expect(deps).toContainEqual([idxA, idxC]); + expect(deps).toContainEqual([idxB, idxC]); + }); +}); + +describe("lowering: env merge", () => { + it("merges pipeline env with per-step env", () => { + const s = scratch().sh("make", { env: { STEP: "1" } }); + const ir = pipeline(s, { env: { PIPE: "true" } }); + expect(ir.graph.nodes[0].env).toEqual({ PIPE: "true", STEP: "1" }); + }); + + it("step env overrides pipeline env", () => { + const s = scratch().sh("make", { env: { X: "step" } }); + const ir = pipeline(s, { env: { X: "pipe" } }); + expect(ir.graph.nodes[0].env.X).toBe("step"); + }); +}); + +describe("lowering: optional fields", () => { + it("omits label/timeout/cache when unset", () => { + const s = scratch().sh("make"); + const ir = pipeline(s); + const step = ir.graph.nodes[0].step; + expect(step.key).toBeDefined(); + expect(step.cmd).toBe("make"); + expect("label" in step).toBe(false); + expect("timeout_seconds" in step).toBe(false); + expect("cache" in step).toBe(false); + }); + + it("includes label/timeout/cache when set", () => { + const s = scratch().sh("make", { + label: "build", + timeoutSeconds: 600, + cache: forever(), + }); + const ir = pipeline(s); + const step = ir.graph.nodes[0].step; + expect(step.label).toBe("build"); + expect(step.timeout_seconds).toBe(600); + expect(step.cache).toEqual({ policy: "forever", env_keys: [] }); + }); +}); + +describe("lowering: cache serialization", () => { + it("serializes forever cache", () => { + const s = sh("echo", { cache: forever({ envKeys: ["CI"] }) }); + const ir = pipeline(s); + expect(ir.graph.nodes[0].step.cache).toEqual({ + policy: "forever", + env_keys: ["CI"], + }); + }); + + it("serializes onChange cache", () => { + const s = sh("echo", { cache: onChange("src/", "lib/") }); + const ir = pipeline(s); + expect(ir.graph.nodes[0].step.cache).toEqual({ + policy: "on_change", + paths: ["src/", "lib/"], + }); + }); +}); + +describe("lowering: dedup", () => { + it("shared ancestor appears once when reachable from multiple leaves", () => { + const base = scratch().sh("install", { label: "install" }); + const a = base.sh("a", { label: "a" }); + const b = base.sh("b", { label: "b" }); + const ir = pipeline(a, b); + const keys = stepKeys(ir); + expect(keys.filter((k) => k === "install")).toHaveLength(1); + }); +}); + +describe("lowering: default_image", () => { + it("applies default_image to root nodes without explicit image", () => { + const s = scratch().sh("echo"); + const ir = pipeline(s, { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes[0].step.image).toBe("ubuntu:24.04"); + }); + + it("does not override explicit image", () => { + const s = scratch().sh("echo", { image: "alpine:3.20" }); + const ir = pipeline(s, { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes[0].step.image).toBe("alpine:3.20"); + }); + + it("does not apply to child nodes with builds_in parent", () => { + const parent = scratch().sh("a", { label: "a" }); + const child = parent.sh("b", { label: "b" }); + const ir = pipeline(child, { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes[0].step.image).toBe("ubuntu:24.04"); + expect("image" in ir.graph.nodes[1].step).toBe(false); + }); +}); + +describe("lowering: graph structure", () => { + it("emits petgraph-serde structure", () => { + const s = scratch().sh("echo", { label: "hello" }); + const ir = pipeline(s); + expect(ir.graph.node_holes).toEqual([]); + expect(ir.graph.edge_property).toBe("directed"); + }); +}); From 8814a1ed544dc81c5b1adeddd234f7401d6227a6 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 21:19:07 -0700 Subject: [PATCH 08/39] feat(ts-dsl): envelope rendering (schema_version:1) --- dsls/harmont-ts/src/envelope.ts | 37 ++++++++++++++++ dsls/harmont-ts/tests/envelope.test.ts | 60 ++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 dsls/harmont-ts/src/envelope.ts create mode 100644 dsls/harmont-ts/tests/envelope.test.ts diff --git a/dsls/harmont-ts/src/envelope.ts b/dsls/harmont-ts/src/envelope.ts new file mode 100644 index 0000000..f7ce0a8 --- /dev/null +++ b/dsls/harmont-ts/src/envelope.ts @@ -0,0 +1,37 @@ +import type { PipelineIR } from "./pipeline.js"; +import type { Trigger } from "./triggers.js"; + +export interface PipelineDefinition { + readonly slug: string; + readonly name?: string; + readonly allowManual?: boolean; + readonly triggers?: readonly Trigger[]; + readonly ir: PipelineIR; +} + +interface EnvelopeJSON { + schema_version: string; + pipelines: EnvelopePipelineJSON[]; +} + +interface EnvelopePipelineJSON { + slug: string; + name: string; + allow_manual: boolean; + triggers: Record[]; + definition: PipelineIR; +} + +export function renderEnvelope(definitions: readonly PipelineDefinition[]): string { + const envelope: EnvelopeJSON = { + schema_version: "1", + pipelines: definitions.map((def) => ({ + slug: def.slug, + name: def.name ?? def.slug, + allow_manual: def.allowManual ?? true, + triggers: (def.triggers ?? []).map((t) => t.toJSON()), + definition: def.ir, + })), + }; + return JSON.stringify(envelope); +} diff --git a/dsls/harmont-ts/tests/envelope.test.ts b/dsls/harmont-ts/tests/envelope.test.ts new file mode 100644 index 0000000..e256fba --- /dev/null +++ b/dsls/harmont-ts/tests/envelope.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { renderEnvelope, type PipelineDefinition } from "../src/envelope.js"; +import { pipeline } from "../src/pipeline.js"; +import { sh } from "../src/step.js"; +import { push, pullRequest } from "../src/triggers.js"; + +function makeDef(overrides?: Partial): PipelineDefinition { + return { + slug: "ci", + ir: 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"); + }); +}); From 104c6b0b99d9b30e24539c0342d4e0ebfacb32b2 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 21:21:18 -0700 Subject: [PATCH 09/39] feat(ts-dsl): public API barrel export + integration tests --- dsls/harmont-ts/src/index.ts | 25 ++- dsls/harmont-ts/tests/integration.test.ts | 240 ++++++++++++++++++++++ 2 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 dsls/harmont-ts/tests/integration.test.ts diff --git a/dsls/harmont-ts/src/index.ts b/dsls/harmont-ts/src/index.ts index cb0ff5c..26987f1 100644 --- a/dsls/harmont-ts/src/index.ts +++ b/dsls/harmont-ts/src/index.ts @@ -1 +1,24 @@ -export {}; +export { Step, scratch, sh, wait, type StepOptions } from "./step.js"; +export { + type CachePolicy, + type CacheForever, + type CacheTTL, + type CacheOnChange, + type CacheCompose, + forever, + ttl, + onChange, + compose, +} from "./cache.js"; +export { + type Trigger, + PushTrigger, + PullRequestTrigger, + ScheduleTrigger, + push, + pullRequest, + schedule, +} from "./triggers.js"; +export { pipeline, type PipelineIR, type PipelineOptions } from "./pipeline.js"; +export { target, clearTargetCache } from "./target.js"; +export { renderEnvelope, type PipelineDefinition } from "./envelope.js"; diff --git a/dsls/harmont-ts/tests/integration.test.ts b/dsls/harmont-ts/tests/integration.test.ts new file mode 100644 index 0000000..a8ad211 --- /dev/null +++ b/dsls/harmont-ts/tests/integration.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { + Step, + scratch, + sh, + wait, + forever, + ttl, + onChange, + compose, + pipeline, + target, + clearTargetCache, + renderEnvelope, + push, + pullRequest, + schedule, + PushTrigger, + PullRequestTrigger, + ScheduleTrigger, + type PipelineDefinition, +} from "../src/index.js"; + +beforeEach(() => { + clearTargetCache(); +}); + +describe("full pipeline build", () => { + it("creates install -> build -> test chain with cache, env, defaultImage", () => { + const install = scratch() + .sh("npm ci", { label: "install", cache: forever() }); + const build = install + .sh("npm run build", { label: "build", env: { NODE_ENV: "production" } }); + const test = build + .sh("npm test", { label: "test", timeoutSeconds: 300 }); + + const ir = pipeline(test, { + env: { CI: "true" }, + defaultImage: "node:22-alpine", + }); + + // version + expect(ir.version).toBe("0"); + + // node count + expect(ir.graph.nodes).toHaveLength(3); + + // edge count — two builds_in edges (install->build, build->test) + expect(ir.graph.edges).toHaveLength(2); + expect(ir.graph.edges.every((e) => e[2] === "builds_in")).toBe(true); + + // env merge: pipeline env CI=true merges with step env + const installNode = ir.graph.nodes[0]; + const buildNode = ir.graph.nodes[1]; + const testNode = ir.graph.nodes[2]; + + expect(installNode.env).toEqual({ CI: "true" }); + expect(buildNode.env).toEqual({ CI: "true", NODE_ENV: "production" }); + expect(testNode.env).toEqual({ CI: "true" }); + + // default_image applies to root node only (install), not children + expect(ir.default_image).toBe("node:22-alpine"); + expect(installNode.step.image).toBe("node:22-alpine"); + expect("image" in buildNode.step).toBe(false); + expect("image" in testNode.step).toBe(false); + + // cache on install step + expect(installNode.step.cache).toEqual({ policy: "forever", env_keys: [] }); + + // timeout on test step + expect(testNode.step.timeout_seconds).toBe(300); + }); +}); + +describe("wait barrier", () => { + it("creates depends_on edges from pre-wait steps to post-wait steps", () => { + const a = scratch().sh("step a", { label: "a" }); + const b = scratch().sh("step b", { label: "b" }); + const c = scratch().sh("step c", { label: "c" }); + const ir = pipeline(a, b, wait(), c); + + const keys = ir.graph.nodes.map((n) => n.step.key); + const idxA = keys.indexOf("a"); + const idxB = keys.indexOf("b"); + const idxC = keys.indexOf("c"); + + const dependsOnEdges = ir.graph.edges.filter((e) => e[2] === "depends_on"); + + // c depends_on both a and b + expect(dependsOnEdges).toContainEqual([idxA, idxC, "depends_on"]); + expect(dependsOnEdges).toContainEqual([idxB, idxC, "depends_on"]); + expect(dependsOnEdges).toHaveLength(2); + }); +}); + +describe("target memoization", () => { + it("shared target appears once in graph when used in two branches", () => { + const nodeBase = target("node-base", () => + sh("apt-get install -y nodejs", { + label: "node-base", + cache: forever(), + }), + ); + + const branchA = nodeBase().sh("npm run lint", { label: "lint" }); + const branchB = nodeBase().sh("npm test", { label: "test" }); + + const ir = pipeline(branchA, branchB); + + // node-base should appear exactly once (memoized) + const keys = ir.graph.nodes.map((n) => n.step.key); + expect(keys.filter((k) => k === "node-base")).toHaveLength(1); + + // total nodes: node-base, lint, test + expect(ir.graph.nodes).toHaveLength(3); + + // both branches build from node-base + const nodeBaseIdx = keys.indexOf("node-base"); + const lintIdx = keys.indexOf("lint"); + const testIdx = keys.indexOf("test"); + + const buildsInEdges = ir.graph.edges.filter((e) => e[2] === "builds_in"); + expect(buildsInEdges).toContainEqual([nodeBaseIdx, lintIdx, "builds_in"]); + expect(buildsInEdges).toContainEqual([nodeBaseIdx, testIdx, "builds_in"]); + }); +}); + +describe("envelope", () => { + it("renders a complete envelope with triggers", () => { + const ir = pipeline(sh("echo hello", { label: "hello" })); + + const def: PipelineDefinition = { + slug: "my-ci", + name: "My CI Pipeline", + allowManual: false, + triggers: [ + push({ branch: "main" }), + pullRequest({ branches: "develop" }), + schedule("0 4 * * *"), + ], + ir, + }; + + const json = renderEnvelope([def]); + const parsed = JSON.parse(json); + + // schema_version + expect(parsed.schema_version).toBe("1"); + + // pipeline metadata + expect(parsed.pipelines).toHaveLength(1); + const p = parsed.pipelines[0]; + expect(p.slug).toBe("my-ci"); + expect(p.name).toBe("My CI Pipeline"); + expect(p.allow_manual).toBe(false); + + // triggers + expect(p.triggers).toHaveLength(3); + expect(p.triggers[0]).toEqual({ event: "push", branches: ["main"] }); + expect(p.triggers[1]).toEqual({ + event: "pull_request", + branches: ["develop"], + types: ["opened", "synchronize", "reopened"], + }); + expect(p.triggers[2]).toEqual({ event: "schedule", cron: "0 4 * * *" }); + + // definition is the IR + expect(p.definition.version).toBe("0"); + expect(p.definition.graph.nodes).toHaveLength(1); + }); +}); + +describe("JSON snake_case output", () => { + it("uses snake_case keys in IR, not camelCase", () => { + const s = scratch().sh("make", { + label: "build", + timeoutSeconds: 600, + cache: onChange("src/", "lib/"), + }); + const ir = pipeline(s, { defaultImage: "ubuntu:24.04" }); + const json = JSON.stringify(ir); + + // Must contain snake_case keys + expect(json).toContain('"default_image"'); + expect(json).toContain('"timeout_seconds"'); + expect(json).toContain('"edge_property"'); + expect(json).toContain('"node_holes"'); + expect(json).toContain('"on_change"'); + + // Must NOT contain camelCase equivalents + expect(json).not.toContain('"defaultImage"'); + expect(json).not.toContain('"timeoutSeconds"'); + expect(json).not.toContain('"edgeProperty"'); + expect(json).not.toContain('"nodeHoles"'); + expect(json).not.toContain('"onChange"'); + }); + + it("envelope uses snake_case keys", () => { + const def: PipelineDefinition = { + slug: "ci", + allowManual: true, + ir: pipeline(sh("echo")), + }; + const json = renderEnvelope([def]); + + expect(json).toContain('"schema_version"'); + expect(json).toContain('"allow_manual"'); + expect(json).not.toContain('"schemaVersion"'); + expect(json).not.toContain('"allowManual"'); + }); +}); + +describe("public API completeness", () => { + it("exports all expected symbols", () => { + // Classes and functions are values + expect(Step).toBeDefined(); + expect(typeof scratch).toBe("function"); + expect(typeof sh).toBe("function"); + expect(typeof wait).toBe("function"); + expect(typeof forever).toBe("function"); + expect(typeof ttl).toBe("function"); + expect(typeof onChange).toBe("function"); + expect(typeof compose).toBe("function"); + expect(typeof pipeline).toBe("function"); + expect(typeof target).toBe("function"); + expect(typeof clearTargetCache).toBe("function"); + expect(typeof renderEnvelope).toBe("function"); + expect(typeof push).toBe("function"); + expect(typeof pullRequest).toBe("function"); + expect(typeof schedule).toBe("function"); + + // Trigger classes are exported as values for instanceof checks + expect(PushTrigger).toBeDefined(); + expect(PullRequestTrigger).toBeDefined(); + expect(ScheduleTrigger).toBeDefined(); + + const t = push({ branch: "main" }); + expect(t instanceof PushTrigger).toBe(true); + }); +}); From c1f01742eaee9249d33d3881e254a8b171ad9a7a Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 21:21:26 -0700 Subject: [PATCH 10/39] feat(ts-dsl): npm toolchain (install chain + test/lint/run) --- dsls/harmont-ts/src/toolchains/index.ts | 1 + dsls/harmont-ts/src/toolchains/npm.ts | 79 ++++++++++++++ dsls/harmont-ts/src/toolchains/shared.ts | 37 +++++++ dsls/harmont-ts/tests/toolchains/npm.test.ts | 108 +++++++++++++++++++ 4 files changed, 225 insertions(+) create mode 100644 dsls/harmont-ts/src/toolchains/index.ts create mode 100644 dsls/harmont-ts/src/toolchains/npm.ts create mode 100644 dsls/harmont-ts/src/toolchains/shared.ts create mode 100644 dsls/harmont-ts/tests/toolchains/npm.test.ts diff --git a/dsls/harmont-ts/src/toolchains/index.ts b/dsls/harmont-ts/src/toolchains/index.ts new file mode 100644 index 0000000..e5ed693 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/index.ts @@ -0,0 +1 @@ +export { npm, NpmProject, type NpmOptions } from "./npm.js"; diff --git a/dsls/harmont-ts/src/toolchains/npm.ts b/dsls/harmont-ts/src/toolchains/npm.ts new file mode 100644 index 0000000..c7e8407 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/npm.ts @@ -0,0 +1,79 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever, onChange } from "../cache.js"; +import { makeInstallChain, nodeInstallCmd } from "./shared.js"; + +const APT_PACKAGES = ["curl", "ca-certificates"] as const; +const VERSION_RE = /^[0-9]+(\.x)?$/; + +export interface NpmOptions { + readonly path?: string; + readonly version?: string; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class NpmProject { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + run(script: string, opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && npm run ${script}`, { + label: `:node: ${script}`, + ...opts, + }); + } + + test(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && npm test`, { + label: ":node: test", + ...opts, + }); + } + + lint(opts?: ActionOptions): Step { + return this.run("lint", opts); + } + + fmt(opts?: ActionOptions): Step { + return this.run("fmt", opts); + } +} + +export function npm(opts?: NpmOptions): NpmProject { + const path = opts?.path ?? "."; + const version = opts?.version ?? "20"; + + if (!VERSION_RE.test(version)) { + throw new Error( + `hm.npm: invalid version "${version}"\n → use a Node major version like "20" or "20.x"`, + ); + } + + const nodeInstalled = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd: nodeInstallCmd(version), + installCache: forever(), + langTag: "node", + installTag: "install", + image: opts?.image, + base: opts?.base, + }); + + const npmCi = nodeInstalled.sh(`cd ${path} && npm ci`, { + label: ":node: deps", + cache: onChange(`${path}/package-lock.json`), + }); + + return new NpmProject(path, npmCi); +} diff --git a/dsls/harmont-ts/src/toolchains/shared.ts b/dsls/harmont-ts/src/toolchains/shared.ts new file mode 100644 index 0000000..f7a3da3 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/shared.ts @@ -0,0 +1,37 @@ +import { scratch, type Step, type StepOptions } from "../step.js"; +import { ttl, type CachePolicy } from "../cache.js"; + +const APT_TTL_SECONDS = 86400; // 1 day + +export function aptInstallCmd(packages: readonly string[]): string { + return `apt-get update && apt-get install -y ${packages.join(" ")}`; +} + +export function nodeInstallCmd(version: string): string { + const major = version.replace(/\.x$/, ""); + return `curl -fsSL https://deb.nodesource.com/setup_${major}.x | bash - && apt-get install -y nodejs`; +} + +export function makeInstallChain(opts: { + aptPackages: readonly string[]; + installCmd: string; + installCache: CachePolicy; + langTag: string; + installTag: string; + image?: string; + base?: Step; +}): Step { + let parent: Step; + if (opts.base == null) { + parent = scratch({ image: opts.image }).sh(aptInstallCmd(opts.aptPackages), { + label: `:${opts.langTag}: apt-base`, + cache: ttl(APT_TTL_SECONDS), + }); + } else { + parent = opts.base; + } + return parent.sh(opts.installCmd, { + label: `:${opts.langTag}: ${opts.installTag}`, + cache: opts.installCache, + }); +} diff --git a/dsls/harmont-ts/tests/toolchains/npm.test.ts b/dsls/harmont-ts/tests/toolchains/npm.test.ts new file mode 100644 index 0000000..c3a5d7b --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/npm.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { npm } from "../../src/toolchains/index.js"; +import { sh } from "../../src/step.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("npm factory", () => { + it("returns an NpmProject with install chain", () => { + const n = npm(); + expect(n.path).toBe("."); + const installed = n.install(); + expect(installed._cmd).toContain("npm ci"); + }); + + it("accepts path and version", () => { + const n = npm({ path: "packages/app", version: "22" }); + expect(n.path).toBe("packages/app"); + expect(n.install()._cmd).toContain("packages/app"); + }); + + it("rejects invalid version", () => { + expect(() => npm({ version: "abc" })).toThrow("invalid version"); + }); + + it("accepts version with .x suffix", () => { + expect(() => npm({ version: "20.x" })).not.toThrow(); + }); +}); + +describe("npm actions", () => { + it("test returns a step chained from install", () => { + const n = npm(); + const t = n.test(); + expect(t._cmd).toContain("npm test"); + expect(t._parent).toBe(n.install()); + }); + + it("lint runs npm run lint", () => { + const n = npm(); + const l = n.lint(); + expect(l._cmd).toContain("npm run lint"); + }); + + it("run executes arbitrary script", () => { + const n = npm(); + const r = n.run("typecheck"); + expect(r._cmd).toContain("npm run typecheck"); + }); + + it("actions accept step options", () => { + const n = npm(); + const t = n.test({ label: "my test", timeoutSeconds: 300 }); + expect(t._label).toBe("my test"); + expect(t._timeoutSeconds).toBe(300); + }); + + it("default labels use :node: prefix", () => { + const n = npm(); + expect(n.test()._label).toBe(":node: test"); + expect(n.lint()._label).toBe(":node: lint"); + }); +}); + +describe("npm install chain structure", () => { + it("chain is: scratch → apt-base → node-install → npm-ci", () => { + const n = npm(); + const npmCi = n.install(); + expect(npmCi._cmd).toContain("npm ci"); + + const nodeInstall = npmCi._parent!; + expect(nodeInstall._cmd).toContain("nodejs"); + expect(nodeInstall._cache).toBeDefined(); + + const aptBase = nodeInstall._parent!; + expect(aptBase._cmd).toContain("apt-get"); + + const root = aptBase._parent!; + expect(root._cmd).toBeNull(); // scratch + }); + + it("accepts base step to skip apt chain", () => { + const customBase = sh("custom base"); + const n = npm({ base: customBase }); + const npmCi = n.install(); + const nodeInstall = npmCi._parent!; + // When base is provided, it's used directly + expect(nodeInstall._parent).toBe(customBase); + }); + + it("accepts custom image", () => { + const n = npm({ image: "debian:12" }); + const npmCi = n.install(); + const nodeInstall = npmCi._parent!; + const aptBase = nodeInstall._parent!; + const root = aptBase._parent!; + expect(root._image).toBe("debian:12"); + }); +}); + +describe("npm in pipeline", () => { + it("produces valid IR when used as pipeline leaves", () => { + const n = npm(); + const ir = pipeline(n.test(), n.lint(), { defaultImage: "ubuntu:24.04" }); + // Should have at least: apt-base, node-install, npm-ci, test, lint + // (test and lint share npm-ci as parent) + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(4); + expect(ir.version).toBe("0"); + }); +}); From 884e7c0cdbdff7717a31cc1c16e64f2046670bff Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 21:24:58 -0700 Subject: [PATCH 11/39] chore(ts-dsl): remove passWithNoTests scaffold flag --- dsls/harmont-ts/vitest.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/dsls/harmont-ts/vitest.config.ts b/dsls/harmont-ts/vitest.config.ts index ab67925..19384e8 100644 --- a/dsls/harmont-ts/vitest.config.ts +++ b/dsls/harmont-ts/vitest.config.ts @@ -3,6 +3,5 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { include: ["tests/**/*.test.ts"], - passWithNoTests: true, }, }); From e0842dae8181b7b976b4f55f863a6519f86dfa2a Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 21:36:03 -0700 Subject: [PATCH 12/39] feat(ts-dsl): add vitest resolve aliases for harmont imports --- dsls/harmont-ts/vitest.config.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/dsls/harmont-ts/vitest.config.ts b/dsls/harmont-ts/vitest.config.ts index 19384e8..eb1dac9 100644 --- a/dsls/harmont-ts/vitest.config.ts +++ b/dsls/harmont-ts/vitest.config.ts @@ -1,7 +1,14 @@ +import path from "node:path"; import { defineConfig } from "vitest/config"; export default defineConfig({ test: { include: ["tests/**/*.test.ts"], }, + resolve: { + alias: { + "harmont/toolchains": path.resolve(__dirname, "src/toolchains/index.ts"), + harmont: path.resolve(__dirname, "src/index.ts"), + }, + }, }); From 86de27db6c6266adca88ccddeea161f3e45e5e19 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 21:38:40 -0700 Subject: [PATCH 13/39] feat(ts-dsl): add pipeline.ts to typescript/react/nextjs examples --- examples/nextjs/.harmont/pipeline.ts | 17 +++++++++++++++++ examples/react/.harmont/pipeline.ts | 17 +++++++++++++++++ examples/typescript/.harmont/pipeline.ts | 17 +++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 examples/nextjs/.harmont/pipeline.ts create mode 100644 examples/react/.harmont/pipeline.ts create mode 100644 examples/typescript/.harmont/pipeline.ts diff --git a/examples/nextjs/.harmont/pipeline.ts b/examples/nextjs/.harmont/pipeline.ts new file mode 100644 index 0000000..0c876c1 --- /dev/null +++ b/examples/nextjs/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { npm } from "harmont/toolchains"; + +const project = npm({ path: "." }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + ir: pipeline(project.run("build"), project.run("test"), project.run("lint"), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/react/.harmont/pipeline.ts b/examples/react/.harmont/pipeline.ts new file mode 100644 index 0000000..0c876c1 --- /dev/null +++ b/examples/react/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { npm } from "harmont/toolchains"; + +const project = npm({ path: "." }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + ir: pipeline(project.run("build"), project.run("test"), project.run("lint"), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/typescript/.harmont/pipeline.ts b/examples/typescript/.harmont/pipeline.ts new file mode 100644 index 0000000..0c876c1 --- /dev/null +++ b/examples/typescript/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { npm } from "harmont/toolchains"; + +const project = npm({ path: "." }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + ir: pipeline(project.run("build"), project.run("test"), project.run("lint"), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; From c5b2c1f9e359a425f55ec06582ebc30247f14ddf Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 21:39:50 -0700 Subject: [PATCH 14/39] feat(ts-dsl): add pipeline.ts to go example (raw sh API) --- examples/go/.harmont/pipeline.ts | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 examples/go/.harmont/pipeline.ts diff --git a/examples/go/.harmont/pipeline.ts b/examples/go/.harmont/pipeline.ts new file mode 100644 index 0000000..b777b20 --- /dev/null +++ b/examples/go/.harmont/pipeline.ts @@ -0,0 +1,37 @@ +import { + pipeline, + sh, + target, + push, + forever, + ttl, + type PipelineDefinition, +} from "harmont"; + +const goInstalled = target("go-installed", () => + sh("apt-get update && apt-get install -y curl ca-certificates", { + label: ":go: apt-base", + cache: ttl(86400), + }).sh( + "curl -fsSL https://go.dev/dl/go1.22.4.linux-amd64.tar.gz | tar -C /usr/local -xzf -", + { label: ":go: install", cache: forever() }, + ), +); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + ir: pipeline( + goInstalled().sh("go build ./...", { label: ":go: build" }), + goInstalled().sh("go test ./...", { label: ":go: test" }), + goInstalled().sh("go vet ./...", { label: ":go: vet" }), + { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }, + ), + }, +]; + +export default pipelines; From cb23830d01799e748a3f9ab0e3a8eb75b805aad4 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 21:41:23 -0700 Subject: [PATCH 15/39] feat(ts-dsl): examples rendering test (mirrors Python test_examples_render) --- dsls/harmont-ts/tests/examples.test.ts | 67 ++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 dsls/harmont-ts/tests/examples.test.ts diff --git a/dsls/harmont-ts/tests/examples.test.ts b/dsls/harmont-ts/tests/examples.test.ts new file mode 100644 index 0000000..3a0aa27 --- /dev/null +++ b/dsls/harmont-ts/tests/examples.test.ts @@ -0,0 +1,67 @@ +import { readdirSync, existsSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { beforeEach, describe, expect, it } from "vitest"; +import { clearTargetCache } from "../src/target.js"; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const EXAMPLES_ROOT = resolve(__dir, "../../../examples"); + +function exampleDirs(): string[] { + if (!existsSync(EXAMPLES_ROOT)) return []; + return readdirSync(EXAMPLES_ROOT, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .filter((d) => + existsSync(join(EXAMPLES_ROOT, d.name, ".harmont", "pipeline.ts")), + ) + .map((d) => d.name) + .sort(); +} + +const examples = exampleDirs(); + +describe("examples render to v0 IR", () => { + beforeEach(() => { + clearTargetCache(); + }); + + for (const name of examples) { + it(`${name}: produces valid CI pipeline IR`, async () => { + const pipelinePath = join( + EXAMPLES_ROOT, + name, + ".harmont", + "pipeline.ts", + ); + const mod = await import(pipelinePath); + const definitions = mod.default; + + expect(Array.isArray(definitions)).toBe(true); + expect(definitions.length).toBeGreaterThan(0); + + const ci = definitions.find((d: any) => d.slug === "ci"); + expect(ci).toBeDefined(); + expect(ci.ir.version).toBe("0"); + expect(ci.ir.graph.nodes.length).toBeGreaterThan(0); + expect(ci.ir.graph.edge_property).toBe("directed"); + + // Verify all nodes have required fields + for (const node of ci.ir.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.ir.graph.edges) { + expect(src).toBeLessThan(ci.ir.graph.nodes.length); + expect(dst).toBeLessThan(ci.ir.graph.nodes.length); + expect(["builds_in", "depends_on"]).toContain(kind); + } + }); + } + + it("discovered at least 4 example pipeline.ts files", () => { + expect(examples.length).toBeGreaterThanOrEqual(4); + }); +}); From eee4a377ac28eb8ab9f7cfe8aea3da3dd0540711 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 21:42:36 -0700 Subject: [PATCH 16/39] ci: add harmont-ts type check + vitest job --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ dsls/harmont-ts/tests/examples.test.ts | 1 + 2 files changed, 25 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e0e067..695a091 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,30 @@ jobs: --deselect tests/test_gradle.py \ --deselect tests/test_haskell.py + ts-dsl: + name: harmont-ts (vitest + tsc) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: dsls/harmont-ts/package-lock.json + + - name: Install + working-directory: dsls/harmont-ts + run: npm ci + + - name: Type check + working-directory: dsls/harmont-ts + run: npx tsc --noEmit + + - name: Test + working-directory: dsls/harmont-ts + run: npm test + integration: name: docker-gated integration test runs-on: ubuntu-latest diff --git a/dsls/harmont-ts/tests/examples.test.ts b/dsls/harmont-ts/tests/examples.test.ts index 3a0aa27..3e60ab1 100644 --- a/dsls/harmont-ts/tests/examples.test.ts +++ b/dsls/harmont-ts/tests/examples.test.ts @@ -44,6 +44,7 @@ describe("examples render to v0 IR", () => { expect(ci.ir.version).toBe("0"); expect(ci.ir.graph.nodes.length).toBeGreaterThan(0); expect(ci.ir.graph.edge_property).toBe("directed"); + expect(ci.ir.default_image).toBeTruthy(); // Verify all nodes have required fields for (const node of ci.ir.graph.nodes) { From 61f368d11a684066b71b8034d00fccaa1f2dabca Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 22:05:58 -0700 Subject: [PATCH 17/39] feat(ts-dsl): make target() generic for non-Step memoization --- dsls/harmont-ts/src/target.ts | 11 +++-------- dsls/harmont-ts/tests/target.test.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/dsls/harmont-ts/src/target.ts b/dsls/harmont-ts/src/target.ts index 7b3e86f..0aa289d 100644 --- a/dsls/harmont-ts/src/target.ts +++ b/dsls/harmont-ts/src/target.ts @@ -1,17 +1,12 @@ -import type { Step } from "./step.js"; +const cache = new Map(); -const cache = new Map(); - -export function target( - _name: string, - fn: () => Step, -): () => Step { +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)!; + return cache.get(key) as T; }; } diff --git a/dsls/harmont-ts/tests/target.test.ts b/dsls/harmont-ts/tests/target.test.ts index 818c41a..5460ee6 100644 --- a/dsls/harmont-ts/tests/target.test.ts +++ b/dsls/harmont-ts/tests/target.test.ts @@ -61,4 +61,12 @@ describe("target", () => { 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); + }); }); From b03144292ecd4ab6c13a9316c8170586df7b24d4 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 22:07:40 -0700 Subject: [PATCH 18/39] feat(ts-dsl): go toolchain --- dsls/harmont-ts/src/toolchains/go.ts | 88 ++++++++++++++++ dsls/harmont-ts/tests/toolchains/go.test.ts | 105 ++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 dsls/harmont-ts/src/toolchains/go.ts create mode 100644 dsls/harmont-ts/tests/toolchains/go.test.ts diff --git a/dsls/harmont-ts/src/toolchains/go.ts b/dsls/harmont-ts/src/toolchains/go.ts new file mode 100644 index 0000000..f04364a --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/go.ts @@ -0,0 +1,88 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = ["curl", "ca-certificates", "git"] as const; +const VERSION_RE = /^[0-9]+\.[0-9]+(\.[0-9]+)?$/; + +export interface GoOptions { + readonly path?: string; + readonly version?: string; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class GoToolchain { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + build(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && go build ./...`, { + label: ":go: build", + ...opts, + }); + } + + test(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && go test ./...`, { + label: ":go: test", + ...opts, + }); + } + + vet(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && go vet ./...`, { + label: ":go: vet", + ...opts, + }); + } + + fmt(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && test -z "$(gofmt -l .)"`, + { label: ":go: fmt", ...opts }, + ); + } +} + +export function go(opts?: GoOptions): GoToolchain { + const path = opts?.path ?? "."; + const version = opts?.version ?? "1.23.2"; + + if (!VERSION_RE.test(version)) { + throw new Error( + `hm.go: invalid version "${version}"\n → use a semver like "1.23" or "1.23.2"`, + ); + } + + const installCmd = [ + `curl -fsSL https://go.dev/dl/go${version}.linux-amd64.tar.gz -o /tmp/go.tgz`, + "rm -rf /usr/local/go && tar -C /usr/local -xzf /tmp/go.tgz", + "ln -sf /usr/local/go/bin/go /usr/local/bin/go", + "ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt", + "go version", + ].join(" && "); + + const installed = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd, + installCache: forever(), + langTag: "go", + installTag: "install", + image: opts?.image, + base: opts?.base, + }); + + return new GoToolchain(path, installed); +} diff --git a/dsls/harmont-ts/tests/toolchains/go.test.ts b/dsls/harmont-ts/tests/toolchains/go.test.ts new file mode 100644 index 0000000..da97b93 --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/go.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { go } from "../../src/toolchains/go.js"; +import { sh } from "../../src/step.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("go factory", () => { + it("returns a GoToolchain with defaults", () => { + const g = go(); + expect(g.path).toBe("."); + expect(g.install()._cmd).toContain("go version"); + }); + + it("accepts path and version", () => { + const g = go({ path: "cmd/server", version: "1.22" }); + expect(g.path).toBe("cmd/server"); + expect(g.install()._cmd).toContain("go1.22"); + }); + + it("rejects invalid version", () => { + expect(() => go({ version: "abc" })).toThrow("invalid version"); + }); + + it("accepts two-part version", () => { + expect(() => go({ version: "1.23" })).not.toThrow(); + }); +}); + +describe("go actions", () => { + it("build runs go build", () => { + const g = go(); + expect(g.build()._cmd).toContain("go build ./..."); + }); + + it("test runs go test", () => { + const g = go(); + expect(g.test()._cmd).toContain("go test ./..."); + }); + + it("vet runs go vet", () => { + const g = go(); + expect(g.vet()._cmd).toContain("go vet ./..."); + }); + + it("fmt runs gofmt check", () => { + const g = go(); + expect(g.fmt()._cmd).toContain("gofmt -l"); + }); + + it("actions chain from install step", () => { + const g = go(); + expect(g.build()._parent).toBe(g.install()); + }); + + it("accepts step options", () => { + const g = go(); + const t = g.test({ label: "my test", timeoutSeconds: 300 }); + expect(t._label).toBe("my test"); + expect(t._timeoutSeconds).toBe(300); + }); + + it("default labels use :go: prefix", () => { + const g = go(); + expect(g.build()._label).toBe(":go: build"); + expect(g.test()._label).toBe(":go: test"); + expect(g.vet()._label).toBe(":go: vet"); + expect(g.fmt()._label).toBe(":go: fmt"); + }); +}); + +describe("go install chain", () => { + it("chain is: scratch → apt-base → go-install", () => { + const g = go(); + const install = g.install(); + expect(install._cmd).toContain("go version"); + + const aptBase = install._parent!; + expect(aptBase._cmd).toContain("apt-get"); + + const root = aptBase._parent!; + expect(root._cmd).toBeNull(); + }); + + it("accepts base step", () => { + const base = sh("custom base"); + const g = go({ base }); + expect(g.install()._parent).toBe(base); + }); + + it("accepts custom image", () => { + const g = go({ image: "debian:12" }); + const install = g.install(); + const aptBase = install._parent!; + const root = aptBase._parent!; + expect(root._image).toBe("debian:12"); + }); +}); + +describe("go in pipeline", () => { + it("produces valid IR", () => { + const g = go(); + const ir = pipeline(g.build(), g.test(), { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(3); + expect(ir.version).toBe("0"); + }); +}); From db99b12df979ef1fe883d3ad08bf213933d544e7 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 22:09:18 -0700 Subject: [PATCH 19/39] feat(ts-dsl): rust toolchain --- dsls/harmont-ts/src/toolchains/rust.ts | 100 +++++++++++++++ dsls/harmont-ts/tests/toolchains/rust.test.ts | 117 ++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 dsls/harmont-ts/src/toolchains/rust.ts create mode 100644 dsls/harmont-ts/tests/toolchains/rust.test.ts diff --git a/dsls/harmont-ts/src/toolchains/rust.ts b/dsls/harmont-ts/src/toolchains/rust.ts new file mode 100644 index 0000000..2ee5b59 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/rust.ts @@ -0,0 +1,100 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = [ + "curl", + "ca-certificates", + "build-essential", + "pkg-config", + "libssl-dev", +] as const; +const VERSION_RE = /^[a-z0-9.-]+$/; + +export interface RustOptions { + readonly path?: string; + readonly version?: string; + readonly image?: string; + readonly components?: readonly string[]; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class RustToolchain { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + private _cargo(cmd: string, label: string, opts?: ActionOptions): Step { + return this._installed.sh( + `. $HOME/.cargo/env && cd ${this.path} && ${cmd}`, + { label, ...opts }, + ); + } + + build(opts?: ActionOptions & { release?: boolean }): Step { + const cmd = opts?.release ? "cargo build --release" : "cargo build"; + return this._cargo(cmd, ":rust: build", opts); + } + + test(opts?: ActionOptions & { release?: boolean }): Step { + const cmd = opts?.release ? "cargo test --release" : "cargo test"; + return this._cargo(cmd, ":rust: test", opts); + } + + clippy(opts?: ActionOptions): Step { + return this._cargo( + "cargo clippy --all-targets -- -D warnings", + ":rust: clippy", + opts, + ); + } + + fmt(opts?: ActionOptions): Step { + return this._cargo("cargo fmt --check", ":rust: fmt", opts); + } + + doc(opts?: ActionOptions): Step { + return this._cargo("cargo doc --no-deps", ":rust: doc", opts); + } +} + +export function rust(opts?: RustOptions): RustToolchain { + const path = opts?.path ?? "."; + const version = opts?.version ?? "stable"; + const components = opts?.components ?? ["clippy", "rustfmt"]; + + if (!VERSION_RE.test(version)) { + throw new Error( + `hm.rust: invalid version "${version}"\n → use "stable", "nightly", or a semver like "1.81.0"`, + ); + } + + const componentFlag = + components.length > 0 ? ` --component ${components.join(",")}` : ""; + const installCmd = [ + `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain ${version} --profile minimal${componentFlag}`, + `. $HOME/.cargo/env && rustc --version && cargo --version`, + ].join(" && "); + + const installed = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd, + installCache: forever(), + langTag: "rust", + installTag: "rustup", + image: opts?.image, + base: opts?.base, + }); + + return new RustToolchain(path, installed); +} diff --git a/dsls/harmont-ts/tests/toolchains/rust.test.ts b/dsls/harmont-ts/tests/toolchains/rust.test.ts new file mode 100644 index 0000000..6f3546b --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/rust.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { rust } from "../../src/toolchains/rust.js"; +import { sh } from "../../src/step.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("rust factory", () => { + it("returns a RustToolchain with defaults", () => { + const r = rust(); + expect(r.path).toBe("."); + expect(r.install()._cmd).toContain("rustc --version"); + }); + + it("accepts path and version", () => { + const r = rust({ path: "crates/core", version: "nightly" }); + expect(r.path).toBe("crates/core"); + expect(r.install()._cmd).toContain("nightly"); + }); + + it("accepts custom components", () => { + const r = rust({ components: ["clippy", "rustfmt", "miri"] }); + expect(r.install()._cmd).toContain("clippy,rustfmt,miri"); + }); + + it("rejects invalid version", () => { + expect(() => rust({ version: "not valid!" })).toThrow("invalid version"); + }); +}); + +describe("rust actions", () => { + it("build runs cargo build", () => { + const r = rust(); + expect(r.build()._cmd).toContain("cargo build"); + expect(r.build()._cmd).not.toContain("--release"); + }); + + it("build --release", () => { + const r = rust(); + expect(r.build({ release: true })._cmd).toContain("cargo build --release"); + }); + + it("test runs cargo test", () => { + const r = rust(); + expect(r.test()._cmd).toContain("cargo test"); + }); + + it("clippy runs with -D warnings", () => { + const r = rust(); + expect(r.clippy()._cmd).toContain("cargo clippy --all-targets -- -D warnings"); + }); + + it("fmt runs cargo fmt --check", () => { + const r = rust(); + expect(r.fmt()._cmd).toContain("cargo fmt --check"); + }); + + it("doc runs cargo doc --no-deps", () => { + const r = rust(); + expect(r.doc()._cmd).toContain("cargo doc --no-deps"); + }); + + it("actions source cargo env", () => { + const r = rust(); + expect(r.build()._cmd).toContain(". $HOME/.cargo/env"); + }); + + it("actions chain from install", () => { + const r = rust(); + expect(r.build()._parent).toBe(r.install()); + }); + + it("accepts step options", () => { + const r = rust(); + const t = r.test({ label: "my test", timeoutSeconds: 600 }); + expect(t._label).toBe("my test"); + expect(t._timeoutSeconds).toBe(600); + }); + + it("default labels use :rust: prefix", () => { + const r = rust(); + expect(r.build()._label).toBe(":rust: build"); + expect(r.test()._label).toBe(":rust: test"); + expect(r.clippy()._label).toBe(":rust: clippy"); + expect(r.fmt()._label).toBe(":rust: fmt"); + expect(r.doc()._label).toBe(":rust: doc"); + }); +}); + +describe("rust install chain", () => { + it("chain is: scratch → apt-base → rustup", () => { + const r = rust(); + const install = r.install(); + expect(install._label).toBe(":rust: rustup"); + + const aptBase = install._parent!; + expect(aptBase._cmd).toContain("apt-get"); + + const root = aptBase._parent!; + expect(root._cmd).toBeNull(); + }); + + it("accepts base step", () => { + const base = sh("custom base"); + const r = rust({ base }); + expect(r.install()._parent).toBe(base); + }); +}); + +describe("rust in pipeline", () => { + it("produces valid IR", () => { + const r = rust(); + const ir = pipeline(r.build(), r.test(), r.clippy(), r.fmt(), { + defaultImage: "ubuntu:24.04", + }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(4); + expect(ir.version).toBe("0"); + }); +}); From ebb94546744640962b52db46a4f52f6f69bf7cc0 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 22:10:58 -0700 Subject: [PATCH 20/39] feat(ts-dsl): python (uv) toolchain --- dsls/harmont-ts/src/toolchains/python.ts | 98 +++++++++++++++++++ .../tests/toolchains/python.test.ts | 93 ++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 dsls/harmont-ts/src/toolchains/python.ts create mode 100644 dsls/harmont-ts/tests/toolchains/python.test.ts diff --git a/dsls/harmont-ts/src/toolchains/python.ts b/dsls/harmont-ts/src/toolchains/python.ts new file mode 100644 index 0000000..78fc144 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/python.ts @@ -0,0 +1,98 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever, onChange } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = [ + "curl", + "ca-certificates", + "python3", + "python3-venv", +] as const; +const VERSION_RE = /^([0-9]+\.[0-9]+\.[0-9]+|latest)$/; + +export interface PythonOptions { + readonly path?: string; + readonly uvVersion?: string; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class PythonToolchain { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + test(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && uv run pytest`, { + label: ":python: test", + ...opts, + }); + } + + lint(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && uv run ruff check .`, { + label: ":python: lint", + ...opts, + }); + } + + fmt(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && uv run ruff format --check .`, + { label: ":python: fmt", ...opts }, + ); + } + + typecheck(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && uv run mypy .`, { + label: ":python: typecheck", + ...opts, + }); + } +} + +export function python(opts?: PythonOptions): PythonToolchain { + const path = opts?.path ?? "."; + const uvVersion = opts?.uvVersion ?? "latest"; + + if (!VERSION_RE.test(uvVersion)) { + throw new Error( + `hm.python: invalid uv version "${uvVersion}"\n → use "latest" or a semver like "0.2.0"`, + ); + } + + const uvEnvPrefix = + uvVersion === "latest" ? "" : `UV_VERSION=${uvVersion} `; + const uvInstallCmd = [ + `${uvEnvPrefix}curl -LsSf https://astral.sh/uv/install.sh | sh`, + "ln -sf /root/.local/bin/uv /usr/local/bin/uv", + "uv --version", + ].join(" && "); + + const uvInstalled = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd: uvInstallCmd, + installCache: forever(), + langTag: "python", + installTag: "uv-install", + image: opts?.image, + base: opts?.base, + }); + + const synced = uvInstalled.sh(`cd ${path} && uv sync --all-extras`, { + label: ":python: uv-sync", + cache: onChange(`${path}/uv.lock`, `${path}/pyproject.toml`), + }); + + return new PythonToolchain(path, synced); +} diff --git a/dsls/harmont-ts/tests/toolchains/python.test.ts b/dsls/harmont-ts/tests/toolchains/python.test.ts new file mode 100644 index 0000000..7dfbfd4 --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/python.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { python } from "../../src/toolchains/python.js"; +import { sh } from "../../src/step.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("python factory", () => { + it("returns a PythonToolchain with defaults", () => { + const p = python(); + expect(p.path).toBe("."); + expect(p.install()._cmd).toContain("uv sync"); + }); + + it("accepts path and uvVersion", () => { + const p = python({ path: "backend", uvVersion: "0.2.0" }); + expect(p.path).toBe("backend"); + expect(p.install()._parent!._cmd).toContain("UV_VERSION=0.2.0"); + }); + + it("rejects invalid uvVersion", () => { + expect(() => python({ uvVersion: "abc" })).toThrow("invalid uv version"); + }); + + it("latest uvVersion omits UV_VERSION env prefix", () => { + const p = python({ uvVersion: "latest" }); + expect(p.install()._parent!._cmd).not.toContain("UV_VERSION"); + }); +}); + +describe("python actions", () => { + it("test runs uv run pytest", () => { + const p = python(); + expect(p.test()._cmd).toContain("uv run pytest"); + }); + + it("lint runs uv run ruff check", () => { + const p = python(); + expect(p.lint()._cmd).toContain("uv run ruff check ."); + }); + + it("fmt runs uv run ruff format --check", () => { + const p = python(); + expect(p.fmt()._cmd).toContain("uv run ruff format --check ."); + }); + + it("typecheck runs uv run mypy", () => { + const p = python(); + expect(p.typecheck()._cmd).toContain("uv run mypy ."); + }); + + it("actions chain from install (sync step)", () => { + const p = python(); + expect(p.test()._parent).toBe(p.install()); + }); + + it("default labels use :python: prefix", () => { + const p = python(); + expect(p.test()._label).toBe(":python: test"); + expect(p.lint()._label).toBe(":python: lint"); + expect(p.fmt()._label).toBe(":python: fmt"); + expect(p.typecheck()._label).toBe(":python: typecheck"); + }); +}); + +describe("python install chain", () => { + it("chain is: scratch → apt-base → uv-install → uv-sync", () => { + const p = python(); + const sync = p.install(); + expect(sync._label).toBe(":python: uv-sync"); + + const uvInstall = sync._parent!; + expect(uvInstall._label).toBe(":python: uv-install"); + + const aptBase = uvInstall._parent!; + expect(aptBase._cmd).toContain("apt-get"); + }); + + it("accepts base step", () => { + const base = sh("custom"); + const p = python({ base }); + const sync = p.install(); + const uvInstall = sync._parent!; + expect(uvInstall._parent).toBe(base); + }); +}); + +describe("python in pipeline", () => { + it("produces valid IR", () => { + const p = python(); + const ir = pipeline(p.test(), p.lint(), { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(4); + expect(ir.version).toBe("0"); + }); +}); From f27c4800cf6fe0acc90b8c0d6f6bf29ec5a6af67 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 22:11:51 -0700 Subject: [PATCH 21/39] feat(ts-dsl): cmake (c/cpp) toolchain --- dsls/harmont-ts/src/toolchains/cmake.ts | 86 +++++++++++++++++++ .../harmont-ts/tests/toolchains/cmake.test.ts | 54 ++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 dsls/harmont-ts/src/toolchains/cmake.ts create mode 100644 dsls/harmont-ts/tests/toolchains/cmake.test.ts diff --git a/dsls/harmont-ts/src/toolchains/cmake.ts b/dsls/harmont-ts/src/toolchains/cmake.ts new file mode 100644 index 0000000..47ca9d1 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/cmake.ts @@ -0,0 +1,86 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = [ + "build-essential", + "cmake", + "ninja-build", + "clang-format", +] as const; + +export interface CMakeOptions { + readonly path?: string; + readonly lang?: "c" | "cpp"; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class CMakeProject { + readonly path: string; + private readonly _installed: Step; + private readonly _tag: string; + + constructor(path: string, installed: Step, tag: string) { + this.path = path; + this._installed = installed; + this._tag = tag; + } + + install(): Step { + return this._installed; + } + + configure(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && cmake -S . -B build`, { + label: `:${this._tag}: configure`, + ...opts, + }); + } + + build(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && cmake -S . -B build && cmake --build build`, + { label: `:${this._tag}: build`, ...opts }, + ); + } + + test(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && cmake -S . -B build && cmake --build build && ctest --test-dir build --output-on-failure`, + { label: `:${this._tag}: test`, ...opts }, + ); + } + + fmt(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && find src tests -name '*.[ch]' -o -name '*.cpp' -o -name '*.hpp' | xargs clang-format --dry-run --Werror`, + { label: `:${this._tag}: fmt`, ...opts }, + ); + } +} + +export function cmake(opts?: CMakeOptions): CMakeProject { + const path = opts?.path ?? "."; + const lang = opts?.lang ?? "c"; + + if (lang !== "c" && lang !== "cpp") { + throw new Error( + `hm.cmake: invalid lang "${lang}"\n → use "c" or "cpp"`, + ); + } + + const installed = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd: "cmake --version && clang-format --version", + installCache: forever(), + langTag: lang, + installTag: "cmake-verify", + image: opts?.image, + base: opts?.base, + }); + + return new CMakeProject(path, installed, lang); +} diff --git a/dsls/harmont-ts/tests/toolchains/cmake.test.ts b/dsls/harmont-ts/tests/toolchains/cmake.test.ts new file mode 100644 index 0000000..db162b1 --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/cmake.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { cmake } from "../../src/toolchains/cmake.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("cmake factory", () => { + it("returns a CMakeProject with defaults", () => { + const c = cmake(); + expect(c.path).toBe("."); + expect(c.install()._cmd).toContain("cmake --version"); + }); + + it("accepts lang cpp", () => { + const c = cmake({ lang: "cpp" }); + expect(c.build()._label).toBe(":cpp: build"); + }); + + it("rejects invalid lang", () => { + expect(() => cmake({ lang: "java" as any })).toThrow("invalid lang"); + }); +}); + +describe("cmake actions", () => { + it("configure runs cmake -S . -B build", () => { + expect(cmake().configure()._cmd).toContain("cmake -S . -B build"); + }); + + it("build runs cmake --build", () => { + expect(cmake().build()._cmd).toContain("cmake --build build"); + }); + + it("test runs ctest", () => { + expect(cmake().test()._cmd).toContain("ctest --test-dir build"); + }); + + it("fmt runs clang-format", () => { + expect(cmake().fmt()._cmd).toContain("clang-format --dry-run --Werror"); + }); + + it("labels use lang tag", () => { + const c = cmake({ lang: "c" }); + expect(c.build()._label).toBe(":c: build"); + + const cpp = cmake({ lang: "cpp" }); + expect(cpp.build()._label).toBe(":cpp: build"); + }); +}); + +describe("cmake in pipeline", () => { + it("produces valid IR", () => { + const c = cmake(); + const ir = pipeline(c.build(), c.test(), { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(3); + }); +}); From 1b8ed3c0c65563b1385b1e88a441a1a032209ba3 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 22:12:38 -0700 Subject: [PATCH 22/39] feat(ts-dsl): gradle (java/kotlin) toolchain --- dsls/harmont-ts/src/toolchains/gradle.ts | 93 +++++++++++++++++++ .../tests/toolchains/gradle.test.ts | 53 +++++++++++ 2 files changed, 146 insertions(+) create mode 100644 dsls/harmont-ts/src/toolchains/gradle.ts create mode 100644 dsls/harmont-ts/tests/toolchains/gradle.test.ts diff --git a/dsls/harmont-ts/src/toolchains/gradle.ts b/dsls/harmont-ts/src/toolchains/gradle.ts new file mode 100644 index 0000000..b5bdb04 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/gradle.ts @@ -0,0 +1,93 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const GRADLE_VERSION = "8.10"; +const JDK_RE = /^(11|17|21)$/; + +export interface GradleOptions { + readonly path?: string; + readonly jdk?: string; + readonly kotlin?: boolean; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class GradleProject { + readonly path: string; + private readonly _installed: Step; + private readonly _tag: string; + + constructor(path: string, installed: Step, tag: string) { + this.path = path; + this._installed = installed; + this._tag = tag; + } + + install(): Step { + return this._installed; + } + + build(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && gradle build`, { + label: `:${this._tag}: build`, + ...opts, + }); + } + + test(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && gradle test`, { + label: `:${this._tag}: test`, + ...opts, + }); + } + + lint(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && gradle check`, { + label: `:${this._tag}: lint`, + ...opts, + }); + } +} + +export function gradle(opts?: GradleOptions): GradleProject { + const path = opts?.path ?? "."; + const jdk = opts?.jdk ?? "21"; + const kotlin = opts?.kotlin ?? false; + const tag = kotlin ? "kotlin" : "java"; + + if (!JDK_RE.test(jdk)) { + throw new Error( + `hm.gradle: invalid jdk "${jdk}"\n → use "11", "17", or "21"`, + ); + } + + const aptPackages = [ + "curl", + "ca-certificates", + "unzip", + `openjdk-${jdk}-jdk-headless`, + ]; + + const installCmd = [ + `curl -fsSL https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip -o /tmp/gradle.zip`, + "unzip -q /tmp/gradle.zip -d /opt", + `ln -sf /opt/gradle-${GRADLE_VERSION}/bin/gradle /usr/local/bin/gradle`, + "rm /tmp/gradle.zip", + "java -version && gradle --version", + ].join(" && "); + + const installed = makeInstallChain({ + aptPackages, + installCmd, + installCache: forever(), + langTag: tag, + installTag: "jdk", + image: opts?.image, + base: opts?.base, + }); + + return new GradleProject(path, installed, tag); +} diff --git a/dsls/harmont-ts/tests/toolchains/gradle.test.ts b/dsls/harmont-ts/tests/toolchains/gradle.test.ts new file mode 100644 index 0000000..877d262 --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/gradle.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { gradle } from "../../src/toolchains/gradle.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("gradle factory", () => { + it("returns a GradleProject with defaults", () => { + const g = gradle(); + expect(g.path).toBe("."); + expect(g.install()._cmd).toContain("gradle --version"); + }); + + it("accepts jdk and kotlin flag", () => { + const g = gradle({ jdk: "17", kotlin: true }); + expect(g.install()._parent!._cmd).toContain("openjdk-17"); + expect(g.build()._label).toBe(":kotlin: build"); + }); + + it("rejects invalid jdk", () => { + expect(() => gradle({ jdk: "8" })).toThrow("invalid jdk"); + }); +}); + +describe("gradle actions", () => { + it("build runs gradle build", () => { + expect(gradle().build()._cmd).toContain("gradle build"); + }); + + it("test runs gradle test", () => { + expect(gradle().test()._cmd).toContain("gradle test"); + }); + + it("lint runs gradle check", () => { + expect(gradle().lint()._cmd).toContain("gradle check"); + }); + + it("java labels use :java: prefix", () => { + const g = gradle(); + expect(g.build()._label).toBe(":java: build"); + }); + + it("kotlin labels use :kotlin: prefix", () => { + const g = gradle({ kotlin: true }); + expect(g.build()._label).toBe(":kotlin: build"); + }); +}); + +describe("gradle in pipeline", () => { + it("produces valid IR", () => { + const g = gradle(); + const ir = pipeline(g.build(), g.test(), { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(3); + }); +}); From 34ea469639a0eb439063e28ba744c8e3868b61b1 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 22:13:21 -0700 Subject: [PATCH 23/39] feat(ts-dsl): dotnet (c#) toolchain --- dsls/harmont-ts/src/toolchains/dotnet.ts | 81 +++++++++++++++++++ .../tests/toolchains/dotnet.test.ts | 49 +++++++++++ 2 files changed, 130 insertions(+) create mode 100644 dsls/harmont-ts/src/toolchains/dotnet.ts create mode 100644 dsls/harmont-ts/tests/toolchains/dotnet.test.ts diff --git a/dsls/harmont-ts/src/toolchains/dotnet.ts b/dsls/harmont-ts/src/toolchains/dotnet.ts new file mode 100644 index 0000000..3512f1b --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/dotnet.ts @@ -0,0 +1,81 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = ["curl", "ca-certificates", "libicu-dev"] as const; +const CHANNEL_RE = /^([0-9]+\.[0-9]+|LTS|STS)$/; + +export interface DotnetOptions { + readonly path?: string; + readonly channel?: string; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class DotnetProject { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + build(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && dotnet build`, { + label: ":dotnet: build", + ...opts, + }); + } + + test(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && dotnet test`, { + label: ":dotnet: test", + ...opts, + }); + } + + fmt(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && dotnet format --verify-no-changes`, + { label: ":dotnet: fmt", ...opts }, + ); + } +} + +export function dotnet(opts?: DotnetOptions): DotnetProject { + const path = opts?.path ?? "."; + const channel = opts?.channel ?? "8.0"; + + if (!CHANNEL_RE.test(channel)) { + throw new Error( + `hm.dotnet: invalid channel "${channel}"\n → use "8.0", "LTS", or "STS"`, + ); + } + + const installCmd = [ + "curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh", + "chmod +x /tmp/dotnet-install.sh", + `/tmp/dotnet-install.sh --channel ${channel} --install-dir /usr/local/dotnet`, + "ln -sf /usr/local/dotnet/dotnet /usr/local/bin/dotnet", + "dotnet --info", + ].join(" && "); + + const installed = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd, + installCache: forever(), + langTag: "dotnet", + installTag: "install", + image: opts?.image, + base: opts?.base, + }); + + return new DotnetProject(path, installed); +} diff --git a/dsls/harmont-ts/tests/toolchains/dotnet.test.ts b/dsls/harmont-ts/tests/toolchains/dotnet.test.ts new file mode 100644 index 0000000..c882a93 --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/dotnet.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { dotnet } from "../../src/toolchains/dotnet.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("dotnet factory", () => { + it("returns a DotnetProject with defaults", () => { + const d = dotnet(); + expect(d.path).toBe("."); + expect(d.install()._cmd).toContain("dotnet --info"); + }); + + it("accepts channel", () => { + const d = dotnet({ channel: "LTS" }); + expect(d.install()._cmd).toContain("--channel LTS"); + }); + + it("rejects invalid channel", () => { + expect(() => dotnet({ channel: "bad" })).toThrow("invalid channel"); + }); +}); + +describe("dotnet actions", () => { + it("build runs dotnet build", () => { + expect(dotnet().build()._cmd).toContain("dotnet build"); + }); + + it("test runs dotnet test", () => { + expect(dotnet().test()._cmd).toContain("dotnet test"); + }); + + it("fmt runs dotnet format --verify-no-changes", () => { + expect(dotnet().fmt()._cmd).toContain("dotnet format --verify-no-changes"); + }); + + it("default labels use :dotnet: prefix", () => { + const d = dotnet(); + expect(d.build()._label).toBe(":dotnet: build"); + expect(d.test()._label).toBe(":dotnet: test"); + expect(d.fmt()._label).toBe(":dotnet: fmt"); + }); +}); + +describe("dotnet in pipeline", () => { + it("produces valid IR", () => { + const d = dotnet(); + const ir = pipeline(d.build(), d.test(), { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(3); + }); +}); From c4dd2cef1f5d23b8ff6c401afd2bfee9697e9077 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 22:14:35 -0700 Subject: [PATCH 24/39] feat(ts-dsl): ruby + perl toolchains --- dsls/harmont-ts/src/toolchains/perl.ts | 65 ++++++++++++++++ dsls/harmont-ts/src/toolchains/ruby.ts | 77 +++++++++++++++++++ dsls/harmont-ts/tests/toolchains/perl.test.ts | 51 ++++++++++++ dsls/harmont-ts/tests/toolchains/ruby.test.ts | 59 ++++++++++++++ 4 files changed, 252 insertions(+) create mode 100644 dsls/harmont-ts/src/toolchains/perl.ts create mode 100644 dsls/harmont-ts/src/toolchains/ruby.ts create mode 100644 dsls/harmont-ts/tests/toolchains/perl.test.ts create mode 100644 dsls/harmont-ts/tests/toolchains/ruby.test.ts diff --git a/dsls/harmont-ts/src/toolchains/perl.ts b/dsls/harmont-ts/src/toolchains/perl.ts new file mode 100644 index 0000000..57100ce --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/perl.ts @@ -0,0 +1,65 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever, onChange } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = ["perl", "cpanminus", "build-essential"] as const; + +export interface PerlOptions { + readonly path?: string; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class PerlProject { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + test(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && prove -lv t/`, { + label: ":perl: test", + ...opts, + }); + } + + lint(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && perlcritic lib/`, { + label: ":perl: lint", + ...opts, + }); + } +} + +export function perl(opts?: PerlOptions): PerlProject { + const path = opts?.path ?? "."; + + const cpanmInstalled = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd: "cpanm --notest --quiet Perl::Critic && perl --version", + installCache: forever(), + langTag: "perl", + installTag: "cpanm", + image: opts?.image, + base: opts?.base, + }); + + const deps = cpanmInstalled.sh( + `cd ${path} && cpanm --installdeps --notest .`, + { + label: ":perl: deps", + cache: onChange(`${path}/cpanfile`), + }, + ); + + return new PerlProject(path, deps); +} diff --git a/dsls/harmont-ts/src/toolchains/ruby.ts b/dsls/harmont-ts/src/toolchains/ruby.ts new file mode 100644 index 0000000..0086832 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/ruby.ts @@ -0,0 +1,77 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever, onChange } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = ["ruby-full", "build-essential", "git"] as const; +const VERSION_RE = /^(default|[0-9]+\.[0-9]+(\.[0-9]+)?)$/; + +export interface RubyOptions { + readonly path?: string; + readonly version?: string; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class RubyProject { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + test(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && bundle exec rspec`, { + label: ":ruby: test", + ...opts, + }); + } + + lint(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && bundle exec rubocop`, { + label: ":ruby: lint", + ...opts, + }); + } +} + +export function ruby(opts?: RubyOptions): RubyProject { + const path = opts?.path ?? "."; + const version = opts?.version ?? "default"; + + if (!VERSION_RE.test(version)) { + throw new Error( + `hm.ruby: invalid version "${version}"\n → use "default" or a semver like "3.3"`, + ); + } + + if (version !== "default") { + throw new Error( + `hm.ruby: pinned Ruby versions are not yet implemented\n → use version="default" (system apt package)`, + ); + } + + const bundlerInstalled = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd: "gem install bundler && bundle --version", + installCache: forever(), + langTag: "ruby", + installTag: "bundler", + image: opts?.image, + base: opts?.base, + }); + + const deps = bundlerInstalled.sh(`cd ${path} && bundle install`, { + label: ":ruby: deps", + cache: onChange(`${path}/Gemfile.lock`), + }); + + return new RubyProject(path, deps); +} diff --git a/dsls/harmont-ts/tests/toolchains/perl.test.ts b/dsls/harmont-ts/tests/toolchains/perl.test.ts new file mode 100644 index 0000000..d04ef13 --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/perl.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { perl } from "../../src/toolchains/perl.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("perl factory", () => { + it("returns a PerlProject with defaults", () => { + const p = perl(); + expect(p.path).toBe("."); + expect(p.install()._cmd).toContain("cpanm --installdeps"); + }); + + it("accepts path", () => { + const p = perl({ path: "lib" }); + expect(p.install()._cmd).toContain("lib"); + }); +}); + +describe("perl actions", () => { + it("test runs prove", () => { + expect(perl().test()._cmd).toContain("prove -lv t/"); + }); + + it("lint runs perlcritic", () => { + expect(perl().lint()._cmd).toContain("perlcritic lib/"); + }); + + it("default labels use :perl: prefix", () => { + const p = perl(); + expect(p.test()._label).toBe(":perl: test"); + expect(p.lint()._label).toBe(":perl: lint"); + }); +}); + +describe("perl install chain", () => { + it("chain is: scratch → apt-base → cpanm → deps", () => { + const p = perl(); + const deps = p.install(); + expect(deps._label).toBe(":perl: deps"); + + const cpanm = deps._parent!; + expect(cpanm._label).toBe(":perl: cpanm"); + }); +}); + +describe("perl in pipeline", () => { + it("produces valid IR", () => { + const p = perl(); + const ir = pipeline(p.test(), p.lint(), { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/dsls/harmont-ts/tests/toolchains/ruby.test.ts b/dsls/harmont-ts/tests/toolchains/ruby.test.ts new file mode 100644 index 0000000..fd4654c --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/ruby.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { ruby } from "../../src/toolchains/ruby.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("ruby factory", () => { + it("returns a RubyProject with defaults", () => { + const r = ruby(); + expect(r.path).toBe("."); + expect(r.install()._cmd).toContain("bundle install"); + }); + + it("accepts path", () => { + const r = ruby({ path: "apps/web" }); + expect(r.install()._cmd).toContain("apps/web"); + }); + + it("rejects invalid version", () => { + expect(() => ruby({ version: "abc" })).toThrow("invalid version"); + }); + + it("rejects pinned version (not implemented)", () => { + expect(() => ruby({ version: "3.3" })).toThrow("not yet implemented"); + }); +}); + +describe("ruby actions", () => { + it("test runs bundle exec rspec", () => { + expect(ruby().test()._cmd).toContain("bundle exec rspec"); + }); + + it("lint runs bundle exec rubocop", () => { + expect(ruby().lint()._cmd).toContain("bundle exec rubocop"); + }); + + it("default labels use :ruby: prefix", () => { + const r = ruby(); + expect(r.test()._label).toBe(":ruby: test"); + expect(r.lint()._label).toBe(":ruby: lint"); + }); +}); + +describe("ruby install chain", () => { + it("chain is: scratch → apt-base → bundler → deps", () => { + const r = ruby(); + const deps = r.install(); + expect(deps._label).toBe(":ruby: deps"); + + const bundler = deps._parent!; + expect(bundler._label).toBe(":ruby: bundler"); + }); +}); + +describe("ruby in pipeline", () => { + it("produces valid IR", () => { + const r = ruby(); + const ir = pipeline(r.test(), r.lint(), { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(4); + }); +}); From 90d1fa9355ae1a31b7eff81dd28c3be4469fc5f5 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 22:15:29 -0700 Subject: [PATCH 25/39] feat(ts-dsl): composer (php/laravel) toolchain --- dsls/harmont-ts/src/toolchains/composer.ts | 89 +++++++++++++++++++ .../tests/toolchains/composer.test.ts | 63 +++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 dsls/harmont-ts/src/toolchains/composer.ts create mode 100644 dsls/harmont-ts/tests/toolchains/composer.test.ts diff --git a/dsls/harmont-ts/src/toolchains/composer.ts b/dsls/harmont-ts/src/toolchains/composer.ts new file mode 100644 index 0000000..2f0092e --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/composer.ts @@ -0,0 +1,89 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever, onChange } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = [ + "php-cli", + "php-mbstring", + "php-xml", + "php-curl", + "php-sqlite3", + "composer", + "git", + "unzip", +] as const; + +export interface ComposerOptions { + readonly path?: string; + readonly laravel?: boolean; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class ComposerProject { + readonly path: string; + private readonly _installed: Step; + private readonly _tag: string; + private readonly _laravel: boolean; + + constructor( + path: string, + installed: Step, + tag: string, + laravel: boolean, + ) { + this.path = path; + this._installed = installed; + this._tag = tag; + this._laravel = laravel; + } + + install(): Step { + return this._installed; + } + + test(opts?: ActionOptions): Step { + const cmd = this._laravel + ? `cd ${this.path} && php artisan test` + : `cd ${this.path} && vendor/bin/phpunit`; + return this._installed.sh(cmd, { + label: `:${this._tag}: test`, + ...opts, + }); + } + + lint(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && vendor/bin/phpstan analyse`, + { label: `:${this._tag}: lint`, ...opts }, + ); + } +} + +export function composer(opts?: ComposerOptions): ComposerProject { + const path = opts?.path ?? "."; + const laravel = opts?.laravel ?? false; + const tag = laravel ? "laravel" : "php"; + + const composerVerified = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd: "composer --version && php --version", + installCache: forever(), + langTag: tag, + installTag: "composer", + image: opts?.image, + base: opts?.base, + }); + + const deps = composerVerified.sh( + `cd ${path} && composer install --no-interaction --prefer-dist`, + { + label: `:${tag}: deps`, + cache: onChange(`${path}/composer.lock`), + }, + ); + + return new ComposerProject(path, deps, tag, laravel); +} diff --git a/dsls/harmont-ts/tests/toolchains/composer.test.ts b/dsls/harmont-ts/tests/toolchains/composer.test.ts new file mode 100644 index 0000000..23a6825 --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/composer.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { composer } from "../../src/toolchains/composer.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("composer factory", () => { + it("returns a ComposerProject with defaults (php mode)", () => { + const c = composer(); + expect(c.path).toBe("."); + expect(c.install()._cmd).toContain("composer install"); + }); + + it("accepts laravel flag", () => { + const c = composer({ laravel: true }); + expect(c.test()._label).toBe(":laravel: test"); + }); +}); + +describe("composer actions", () => { + it("test runs phpunit by default", () => { + expect(composer().test()._cmd).toContain("vendor/bin/phpunit"); + }); + + it("test runs artisan test in laravel mode", () => { + expect(composer({ laravel: true }).test()._cmd).toContain( + "php artisan test", + ); + }); + + it("lint runs phpstan", () => { + expect(composer().lint()._cmd).toContain("vendor/bin/phpstan analyse"); + }); + + it("php labels use :php: prefix", () => { + const c = composer(); + expect(c.test()._label).toBe(":php: test"); + expect(c.lint()._label).toBe(":php: lint"); + }); + + it("laravel labels use :laravel: prefix", () => { + const c = composer({ laravel: true }); + expect(c.test()._label).toBe(":laravel: test"); + expect(c.lint()._label).toBe(":laravel: lint"); + }); +}); + +describe("composer install chain", () => { + it("chain is: scratch → apt-base → composer-verify → deps", () => { + const c = composer(); + const deps = c.install(); + expect(deps._label).toBe(":php: deps"); + + const composerVerify = deps._parent!; + expect(composerVerify._label).toBe(":php: composer"); + }); +}); + +describe("composer in pipeline", () => { + it("produces valid IR", () => { + const c = composer(); + const ir = pipeline(c.test(), c.lint(), { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(4); + }); +}); From c074ad7bac5377a1a2603c59df645e771369552d Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 22:16:36 -0700 Subject: [PATCH 26/39] feat(ts-dsl): elm toolchain --- dsls/harmont-ts/src/toolchains/elm.ts | 102 +++++++++++++++++++ dsls/harmont-ts/tests/toolchains/elm.test.ts | 78 ++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 dsls/harmont-ts/src/toolchains/elm.ts create mode 100644 dsls/harmont-ts/tests/toolchains/elm.test.ts diff --git a/dsls/harmont-ts/src/toolchains/elm.ts b/dsls/harmont-ts/src/toolchains/elm.ts new file mode 100644 index 0000000..dbfc954 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/elm.ts @@ -0,0 +1,102 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever } from "../cache.js"; +import { makeInstallChain, nodeInstallCmd } from "./shared.js"; + +const APT_PACKAGES = ["curl", "ca-certificates"] as const; +const ELM_VERSION_RE = /^[0-9]+(\.[0-9]+)+$/; +const NODE_VERSION_RE = /^[0-9]+(\.x)?$/; + +export interface ElmOptions { + readonly path?: string; + readonly elmVersion?: string; + readonly nodeVersion?: string; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class ElmProject { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + make(target: string, opts?: ActionOptions & { output?: string }): Step { + const outputFlag = opts?.output != null ? ` --output=${opts.output}` : ""; + return this._installed.sh( + `cd ${this.path} && elm make ${target}${outputFlag}`, + { label: `:elm: make ${target}`, ...opts }, + ); + } + + test(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && npx --yes elm-test`, { + label: ":elm: test", + ...opts, + }); + } + + review(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && npx --yes elm-review`, { + label: ":elm: review", + ...opts, + }); + } + + fmt(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && npx --yes elm-format --validate .`, + { label: ":elm: fmt", ...opts }, + ); + } +} + +export function elm(opts?: ElmOptions): ElmProject { + const path = opts?.path ?? "."; + const elmVersion = opts?.elmVersion ?? "0.19.1"; + const nodeVersion = opts?.nodeVersion ?? "20"; + + if (!ELM_VERSION_RE.test(elmVersion)) { + throw new Error( + `hm.elm: invalid elm version "${elmVersion}"\n → use a semver like "0.19.1"`, + ); + } + + if (!NODE_VERSION_RE.test(nodeVersion)) { + throw new Error( + `hm.elm: invalid node version "${nodeVersion}"\n → use a major version like "20"`, + ); + } + + const nodeInstalled = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd: nodeInstallCmd(nodeVersion), + installCache: forever(), + langTag: "elm", + installTag: "node", + image: opts?.image, + base: opts?.base, + }); + + const elmInstallCmd = [ + `curl -fsSL https://github.com/elm/compiler/releases/download/${elmVersion}/binary-for-linux-64-bit.gz -o /tmp/elm.gz`, + "gunzip /tmp/elm.gz", + "chmod +x /tmp/elm", + "mv /tmp/elm /usr/local/bin/elm", + ].join(" && "); + + const elmInstalled = nodeInstalled.sh(elmInstallCmd, { + label: ":elm: install", + cache: forever(), + }); + + return new ElmProject(path, elmInstalled); +} diff --git a/dsls/harmont-ts/tests/toolchains/elm.test.ts b/dsls/harmont-ts/tests/toolchains/elm.test.ts new file mode 100644 index 0000000..e74e2da --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/elm.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { elm } from "../../src/toolchains/elm.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("elm factory", () => { + it("returns an ElmProject with defaults", () => { + const e = elm(); + expect(e.path).toBe("."); + expect(e.install()._cmd).toContain("/usr/local/bin/elm"); + }); + + it("accepts elm and node versions", () => { + const e = elm({ elmVersion: "0.19.1", nodeVersion: "22" }); + expect(e.install()._cmd).toContain("0.19.1"); + expect(e.install()._parent!._cmd).toContain("setup_22"); + }); + + it("rejects invalid elm version", () => { + expect(() => elm({ elmVersion: "abc" })).toThrow("invalid elm version"); + }); + + it("rejects invalid node version", () => { + expect(() => elm({ nodeVersion: "abc" })).toThrow("invalid node version"); + }); +}); + +describe("elm actions", () => { + it("make compiles target", () => { + expect(elm().make("src/Main.elm")._cmd).toContain("elm make src/Main.elm"); + }); + + it("make accepts output flag", () => { + expect(elm().make("src/Main.elm", { output: "app.js" })._cmd).toContain( + "--output=app.js", + ); + }); + + it("test runs elm-test via npx", () => { + expect(elm().test()._cmd).toContain("npx --yes elm-test"); + }); + + it("review runs elm-review via npx", () => { + expect(elm().review()._cmd).toContain("npx --yes elm-review"); + }); + + it("fmt runs elm-format via npx", () => { + expect(elm().fmt()._cmd).toContain("npx --yes elm-format --validate"); + }); + + it("default labels use :elm: prefix", () => { + const e = elm(); + expect(e.test()._label).toBe(":elm: test"); + expect(e.review()._label).toBe(":elm: review"); + expect(e.fmt()._label).toBe(":elm: fmt"); + }); +}); + +describe("elm install chain", () => { + it("chain is: scratch → apt-base → node → elm-binary", () => { + const e = elm(); + const elmInstall = e.install(); + expect(elmInstall._label).toBe(":elm: install"); + + const nodeInstall = elmInstall._parent!; + expect(nodeInstall._label).toBe(":elm: node"); + + const aptBase = nodeInstall._parent!; + expect(aptBase._cmd).toContain("apt-get"); + }); +}); + +describe("elm in pipeline", () => { + it("produces valid IR", () => { + const e = elm(); + const ir = pipeline(e.test(), e.fmt(), { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(4); + }); +}); From 73dc0ccaa9451d557bafab1746b04613b316be5d Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 22:17:33 -0700 Subject: [PATCH 27/39] feat(ts-dsl): zig toolchain (dual-mode) --- dsls/harmont-ts/src/toolchains/zig.ts | 99 ++++++++++++++++++++ dsls/harmont-ts/tests/toolchains/zig.test.ts | 85 +++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 dsls/harmont-ts/src/toolchains/zig.ts create mode 100644 dsls/harmont-ts/tests/toolchains/zig.test.ts diff --git a/dsls/harmont-ts/src/toolchains/zig.ts b/dsls/harmont-ts/src/toolchains/zig.ts new file mode 100644 index 0000000..478f8ef --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/zig.ts @@ -0,0 +1,99 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = ["curl", "ca-certificates", "xz-utils"] as const; +const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+$/; + +export interface ZigOptions { + readonly path?: string; + readonly version?: string; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class ZigToolchain { + private readonly _installed: Step; + + constructor(installed: Step) { + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + project(path: string = "."): ZigProject { + return new ZigProject(path, this._installed); + } +} + +export class ZigProject { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + build(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && zig build`, { + label: `:zig: ${this.path} build`, + ...opts, + }); + } + + test(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && zig build test`, { + label: `:zig: ${this.path} test`, + ...opts, + }); + } + + fmt(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && zig fmt --check .`, { + label: `:zig: ${this.path} fmt`, + ...opts, + }); + } +} + +export function zig(opts: ZigOptions & { path: string }): ZigProject; +export function zig(opts?: ZigOptions): ZigToolchain; +export function zig(opts?: ZigOptions): ZigToolchain | ZigProject { + const version = opts?.version ?? "0.13.0"; + + if (!VERSION_RE.test(version)) { + throw new Error( + `hm.zig: invalid version "${version}"\n → use a semver like "0.13.0"`, + ); + } + + const installCmd = [ + `curl -fsSL https://ziglang.org/download/${version}/zig-linux-x86_64-${version}.tar.xz -o /tmp/zig.tar.xz`, + "rm -rf /usr/local/zig && mkdir -p /usr/local/zig", + "tar -xJf /tmp/zig.tar.xz -C /usr/local/zig --strip-components=1", + "ln -sf /usr/local/zig/zig /usr/local/bin/zig", + "zig version", + ].join(" && "); + + const installed = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd, + installCache: forever(), + langTag: "zig", + installTag: "install", + image: opts?.image, + base: opts?.base, + }); + + const toolchain = new ZigToolchain(installed); + return opts?.path != null ? toolchain.project(opts.path) : toolchain; +} diff --git a/dsls/harmont-ts/tests/toolchains/zig.test.ts b/dsls/harmont-ts/tests/toolchains/zig.test.ts new file mode 100644 index 0000000..4d3285f --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/zig.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { zig, ZigToolchain, ZigProject } from "../../src/toolchains/zig.js"; +import { sh } from "../../src/step.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("zig factory", () => { + it("returns ZigToolchain without path", () => { + const tc = zig(); + expect(tc).toBeInstanceOf(ZigToolchain); + }); + + it("returns ZigProject with path", () => { + const proj = zig({ path: "." }); + expect(proj).toBeInstanceOf(ZigProject); + expect(proj.path).toBe("."); + }); + + it("rejects invalid version", () => { + expect(() => zig({ version: "abc" })).toThrow("invalid version"); + }); +}); + +describe("zig toolchain", () => { + it("project creates ZigProject sharing install step", () => { + const tc = zig(); + const a = tc.project("lib-a"); + const b = tc.project("lib-b"); + expect(a.install()).toBe(b.install()); + expect(a.path).toBe("lib-a"); + expect(b.path).toBe("lib-b"); + }); +}); + +describe("zig project actions", () => { + it("build runs zig build", () => { + const p = zig({ path: "." }); + expect(p.build()._cmd).toContain("zig build"); + }); + + it("test runs zig build test", () => { + const p = zig({ path: "." }); + expect(p.test()._cmd).toContain("zig build test"); + }); + + it("fmt runs zig fmt --check", () => { + const p = zig({ path: "." }); + expect(p.fmt()._cmd).toContain("zig fmt --check"); + }); + + it("labels include project path", () => { + const p = zig({ path: "lib-a" }); + expect(p.build()._label).toBe(":zig: lib-a build"); + expect(p.test()._label).toBe(":zig: lib-a test"); + }); +}); + +describe("zig install chain", () => { + it("chain is: scratch → apt-base → zig-install", () => { + const tc = zig(); + const install = tc.install(); + expect(install._cmd).toContain("zig version"); + + const aptBase = install._parent!; + expect(aptBase._cmd).toContain("apt-get"); + }); + + it("accepts base step", () => { + const base = sh("custom"); + const tc = zig({ base }); + expect(tc.install()._parent).toBe(base); + }); +}); + +describe("zig multi-project pipeline", () => { + it("two projects share one install step in IR", () => { + const tc = zig(); + const a = tc.project("lib-a"); + const b = tc.project("lib-b"); + const ir = pipeline(a.build(), a.test(), b.build(), b.test(), { + defaultImage: "ubuntu:24.04", + }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(5); + expect(ir.version).toBe("0"); + }); +}); From 76f2010301b33f36f2b36d0ca5134400dc9b4e7d Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 22:18:29 -0700 Subject: [PATCH 28/39] feat(ts-dsl): ocaml toolchain --- dsls/harmont-ts/src/toolchains/ocaml.ts | 97 +++++++++++++++++++ .../harmont-ts/tests/toolchains/ocaml.test.ts | 60 ++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 dsls/harmont-ts/src/toolchains/ocaml.ts create mode 100644 dsls/harmont-ts/tests/toolchains/ocaml.test.ts diff --git a/dsls/harmont-ts/src/toolchains/ocaml.ts b/dsls/harmont-ts/src/toolchains/ocaml.ts new file mode 100644 index 0000000..8bb1ae7 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/ocaml.ts @@ -0,0 +1,97 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = [ + "opam", + "build-essential", + "git", + "m4", + "unzip", + "bubblewrap", +] as const; +const COMPILER_RE = /^[0-9]+\.[0-9]+\.[0-9]+$/; + +export interface OCamlOptions { + readonly path?: string; + readonly compiler?: string; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class OCamlProject { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + build(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && opam exec -- dune build`, + { label: ":ocaml: build", ...opts }, + ); + } + + test(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && opam exec -- dune runtest`, + { label: ":ocaml: test", ...opts }, + ); + } + + fmt(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && opam exec -- dune build @fmt`, + { label: ":ocaml: fmt", ...opts }, + ); + } +} + +export function ocaml(opts?: OCamlOptions): OCamlProject { + const path = opts?.path ?? "."; + const compiler = opts?.compiler ?? "5.1.1"; + + if (!COMPILER_RE.test(compiler)) { + throw new Error( + `hm.ocaml: invalid compiler "${compiler}"\n → use a semver like "5.1.1"`, + ); + } + + const opamInitCmd = [ + "opam init -y --disable-sandboxing --bare", + `opam switch create ${compiler} ${compiler}`, + "eval $(opam env)", + "opam install -y dune ocamlformat", + ].join(" && "); + + const opamInstalled = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd: opamInitCmd, + installCache: forever(), + langTag: "ocaml", + installTag: "opam", + image: opts?.image, + base: opts?.base, + }); + + const depsCmd = [ + `cd ${path}`, + 'if ls *.opam >/dev/null 2>&1; then opam install -y . --deps-only --with-test; else echo "no .opam files; skipping deps"; fi', + ].join(" && "); + + const deps = opamInstalled.sh(depsCmd, { + label: ":ocaml: deps", + cache: forever(), + }); + + return new OCamlProject(path, deps); +} diff --git a/dsls/harmont-ts/tests/toolchains/ocaml.test.ts b/dsls/harmont-ts/tests/toolchains/ocaml.test.ts new file mode 100644 index 0000000..d771c10 --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/ocaml.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { ocaml } from "../../src/toolchains/ocaml.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("ocaml factory", () => { + it("returns an OCamlProject with defaults", () => { + const o = ocaml(); + expect(o.path).toBe("."); + expect(o.install()._cmd).toContain("opam install"); + }); + + it("accepts compiler version", () => { + const o = ocaml({ compiler: "5.2.0" }); + expect(o.install()._parent!._cmd).toContain("5.2.0"); + }); + + it("rejects invalid compiler", () => { + expect(() => ocaml({ compiler: "bad" })).toThrow("invalid compiler"); + }); +}); + +describe("ocaml actions", () => { + it("build runs opam exec -- dune build", () => { + expect(ocaml().build()._cmd).toContain("opam exec -- dune build"); + }); + + it("test runs opam exec -- dune runtest", () => { + expect(ocaml().test()._cmd).toContain("opam exec -- dune runtest"); + }); + + it("fmt runs opam exec -- dune build @fmt", () => { + expect(ocaml().fmt()._cmd).toContain("opam exec -- dune build @fmt"); + }); + + it("default labels use :ocaml: prefix", () => { + const o = ocaml(); + expect(o.build()._label).toBe(":ocaml: build"); + expect(o.test()._label).toBe(":ocaml: test"); + expect(o.fmt()._label).toBe(":ocaml: fmt"); + }); +}); + +describe("ocaml install chain", () => { + it("chain is: scratch → apt-base → opam → deps", () => { + const o = ocaml(); + const deps = o.install(); + expect(deps._label).toBe(":ocaml: deps"); + + const opam = deps._parent!; + expect(opam._label).toBe(":ocaml: opam"); + }); +}); + +describe("ocaml in pipeline", () => { + it("produces valid IR", () => { + const o = ocaml(); + const ir = pipeline(o.build(), o.test(), { defaultImage: "ubuntu:24.04" }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(4); + }); +}); From d49dfc36b60dd03f385dd82020033d804fcae8cc Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 22:19:49 -0700 Subject: [PATCH 29/39] feat(ts-dsl): haskell toolchain (multi-package) --- dsls/harmont-ts/src/toolchains/haskell.ts | 133 ++++++++++++++++++ .../tests/toolchains/haskell.test.ts | 94 +++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 dsls/harmont-ts/src/toolchains/haskell.ts create mode 100644 dsls/harmont-ts/tests/toolchains/haskell.test.ts diff --git a/dsls/harmont-ts/src/toolchains/haskell.ts b/dsls/harmont-ts/src/toolchains/haskell.ts new file mode 100644 index 0000000..a0775d3 --- /dev/null +++ b/dsls/harmont-ts/src/toolchains/haskell.ts @@ -0,0 +1,133 @@ +import type { Step, StepOptions } from "../step.js"; +import { forever, onChange } from "../cache.js"; +import { makeInstallChain } from "./shared.js"; + +const APT_PACKAGES = [ + "curl", + "ca-certificates", + "build-essential", + "libgmp-dev", + "libffi-dev", + "libncurses-dev", + "zlib1g-dev", +] as const; +const GHC_RE = /^[a-zA-Z0-9.-]+$/; + +export interface HaskellOptions { + readonly ghc: string; + readonly cabal?: string; + readonly path?: string; + readonly image?: string; + readonly base?: Step; +} + +type ActionOptions = Omit; + +export class HaskellToolchain { + private readonly _installed: Step; + + constructor(installed: Step) { + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + cabal(path: string = ".", opts?: { cachePaths?: readonly string[] }): HaskellPackage { + const cachePaths = opts?.cachePaths ?? [`${path}/*.cabal`]; + const depsStep = this._installed.sh( + `cabal update && cd ${path} && cabal build all --only-dependencies`, + { + label: `:haskell: ${path} deps`, + cache: onChange(...cachePaths), + }, + ); + return new HaskellPackage(path, depsStep); + } +} + +export class HaskellPackage { + readonly path: string; + private readonly _installed: Step; + + constructor(path: string, installed: Step) { + this.path = path; + this._installed = installed; + } + + install(): Step { + return this._installed; + } + + build(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && cabal build all`, { + label: `:haskell: ${this.path} build`, + ...opts, + }); + } + + test(opts?: ActionOptions): Step { + return this._installed.sh(`cd ${this.path} && cabal test all`, { + label: `:haskell: ${this.path} test`, + ...opts, + }); + } + + lint(opts?: ActionOptions): Step { + return this._installed.sh( + `cd ${this.path} && cabal build all --flag werror`, + { label: `:haskell: ${this.path} lint`, ...opts }, + ); + } + + hlint(opts?: ActionOptions): Step { + return this._installed.sh(`hlint ${this.path}`, { + label: `:haskell: ${this.path} hlint`, + ...opts, + }); + } + + fmt(opts?: ActionOptions): Step { + return this._installed.sh(`fourmolu --mode check ${this.path}`, { + label: `:haskell: ${this.path} fmt`, + ...opts, + }); + } +} + +export function haskell(opts: HaskellOptions & { path: string }): HaskellPackage; +export function haskell(opts: HaskellOptions): HaskellToolchain; +export function haskell(opts: HaskellOptions): HaskellToolchain | HaskellPackage { + const ghc = opts.ghc; + const cabalVersion = opts.cabal ?? "latest"; + + if (!GHC_RE.test(ghc)) { + throw new Error( + `hm.haskell: invalid ghc version "${ghc}"\n → use a version like "9.6.7"`, + ); + } + + const installCmd = [ + "curl -fsSL https://downloads.haskell.org/~ghcup/x86_64-linux-ghcup -o /usr/local/bin/ghcup", + "chmod +x /usr/local/bin/ghcup", + `ghcup install ghc ${ghc} && ghcup install cabal ${cabalVersion}`, + `ghcup set ghc ${ghc} && ghcup set cabal ${cabalVersion}`, + "ln -sf /root/.ghcup/bin/* /usr/local/bin/", + "curl -fsSL https://github.com/fourmolu/fourmolu/releases/download/v0.18.0.0/fourmolu-0.18.0.0-linux-x86_64 -o /usr/local/bin/fourmolu", + "chmod +x /usr/local/bin/fourmolu", + ].join(" && "); + + const installed = makeInstallChain({ + aptPackages: [...APT_PACKAGES], + installCmd, + installCache: forever(), + langTag: "haskell", + installTag: "ghcup", + image: opts.image, + base: opts.base, + }); + + const toolchain = new HaskellToolchain(installed); + return opts.path != null ? toolchain.cabal(opts.path) : toolchain; +} diff --git a/dsls/harmont-ts/tests/toolchains/haskell.test.ts b/dsls/harmont-ts/tests/toolchains/haskell.test.ts new file mode 100644 index 0000000..d1ad374 --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/haskell.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + haskell, + HaskellToolchain, + HaskellPackage, +} from "../../src/toolchains/haskell.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("haskell factory", () => { + it("returns HaskellToolchain without path", () => { + const tc = haskell({ ghc: "9.6.7" }); + expect(tc).toBeInstanceOf(HaskellToolchain); + }); + + it("returns HaskellPackage with path", () => { + const pkg = haskell({ ghc: "9.6.7", path: "." }); + expect(pkg).toBeInstanceOf(HaskellPackage); + expect(pkg.path).toBe("."); + }); + + it("rejects invalid ghc version", () => { + expect(() => haskell({ ghc: "not valid!" })).toThrow("invalid ghc"); + }); +}); + +describe("haskell toolchain", () => { + it("cabal creates HaskellPackage with deps step", () => { + const tc = haskell({ ghc: "9.6.7" }); + const pkg = tc.cabal("."); + expect(pkg).toBeInstanceOf(HaskellPackage); + expect(pkg.install()._cmd).toContain("cabal build all --only-dependencies"); + expect(pkg.install()._label).toBe(":haskell: . deps"); + }); + + it("multiple packages share ghcup install", () => { + const tc = haskell({ ghc: "9.6.7" }); + const a = tc.cabal("pkg-a"); + const b = tc.cabal("pkg-b"); + expect(a.install()._parent).toBe(b.install()._parent); + }); +}); + +describe("haskell package actions", () => { + it("build runs cabal build all", () => { + const pkg = haskell({ ghc: "9.6.7", path: "." }); + expect(pkg.build()._cmd).toContain("cabal build all"); + }); + + it("test runs cabal test all", () => { + const pkg = haskell({ ghc: "9.6.7", path: "." }); + expect(pkg.test()._cmd).toContain("cabal test all"); + }); + + it("lint runs cabal build all --flag werror", () => { + const pkg = haskell({ ghc: "9.6.7", path: "." }); + expect(pkg.lint()._cmd).toContain("--flag werror"); + }); + + it("hlint runs hlint on path", () => { + const pkg = haskell({ ghc: "9.6.7", path: "src" }); + expect(pkg.hlint()._cmd).toContain("hlint src"); + }); + + it("fmt runs fourmolu on path", () => { + const pkg = haskell({ ghc: "9.6.7", path: "." }); + expect(pkg.fmt()._cmd).toContain("fourmolu --mode check ."); + }); + + it("labels include path", () => { + const pkg = haskell({ ghc: "9.6.7", path: "my-pkg" }); + expect(pkg.build()._label).toBe(":haskell: my-pkg build"); + expect(pkg.test()._label).toBe(":haskell: my-pkg test"); + }); +}); + +describe("haskell install chain", () => { + it("chain is: scratch → apt-base → ghcup", () => { + const tc = haskell({ ghc: "9.6.7" }); + const install = tc.install(); + expect(install._label).toBe(":haskell: ghcup"); + expect(install._cmd).toContain("ghcup install ghc 9.6.7"); + }); +}); + +describe("haskell in pipeline", () => { + it("produces valid IR", () => { + const pkg = haskell({ ghc: "9.6.7", path: "." }); + const ir = pipeline(pkg.build(), pkg.test(), pkg.fmt(), { + defaultImage: "ubuntu:24.04", + }); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(5); + expect(ir.version).toBe("0"); + }); +}); From e1714c8bde036622786e6dfd5a8a636f338dea77 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 22:20:25 -0700 Subject: [PATCH 30/39] feat(ts-dsl): export all language toolchains from barrel --- dsls/harmont-ts/src/toolchains/index.ts | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/dsls/harmont-ts/src/toolchains/index.ts b/dsls/harmont-ts/src/toolchains/index.ts index e5ed693..2de2c28 100644 --- a/dsls/harmont-ts/src/toolchains/index.ts +++ b/dsls/harmont-ts/src/toolchains/index.ts @@ -1 +1,28 @@ export { npm, NpmProject, type NpmOptions } from "./npm.js"; +export { go, GoToolchain, type GoOptions } from "./go.js"; +export { rust, RustToolchain, type RustOptions } from "./rust.js"; +export { python, PythonToolchain, type PythonOptions } from "./python.js"; +export { cmake, CMakeProject, type CMakeOptions } from "./cmake.js"; +export { gradle, GradleProject, type GradleOptions } from "./gradle.js"; +export { dotnet, DotnetProject, type DotnetOptions } from "./dotnet.js"; +export { ruby, RubyProject, type RubyOptions } from "./ruby.js"; +export { perl, PerlProject, type PerlOptions } from "./perl.js"; +export { + composer, + ComposerProject, + type ComposerOptions, +} from "./composer.js"; +export { elm, ElmProject, type ElmOptions } from "./elm.js"; +export { + zig, + ZigToolchain, + ZigProject, + type ZigOptions, +} from "./zig.js"; +export { ocaml, OCamlProject, type OCamlOptions } from "./ocaml.js"; +export { + haskell, + HaskellToolchain, + HaskellPackage, + type HaskellOptions, +} from "./haskell.js"; From 864d1bd11ec1a808247993d646ff635f2c893991 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 22:24:20 -0700 Subject: [PATCH 31/39] feat(ts-dsl): add pipeline.ts to all 18 examples --- dsls/harmont-ts/tests/examples.test.ts | 4 +-- examples/c/.harmont/pipeline.ts | 17 ++++++++++++ examples/cpp/.harmont/pipeline.ts | 17 ++++++++++++ examples/csharp/.harmont/pipeline.ts | 17 ++++++++++++ examples/go/.harmont/pipeline.ts | 33 ++++++----------------- examples/haskell/.harmont/pipeline.ts | 21 +++++++++++++++ examples/java/.harmont/pipeline.ts | 17 ++++++++++++ examples/kotlin/.harmont/pipeline.ts | 17 ++++++++++++ examples/ocaml/.harmont/pipeline.ts | 17 ++++++++++++ examples/perl/.harmont/pipeline.ts | 17 ++++++++++++ examples/php-laravel/.harmont/pipeline.ts | 17 ++++++++++++ examples/python-uv/.harmont/pipeline.ts | 20 ++++++++++++++ examples/ruby/.harmont/pipeline.ts | 17 ++++++++++++ examples/rust/.harmont/pipeline.ts | 20 ++++++++++++++ examples/zig-js/.harmont/pipeline.ts | 33 +++++++++++++++++++++++ examples/zig/.harmont/pipeline.ts | 17 ++++++++++++ 16 files changed, 274 insertions(+), 27 deletions(-) create mode 100644 examples/c/.harmont/pipeline.ts create mode 100644 examples/cpp/.harmont/pipeline.ts create mode 100644 examples/csharp/.harmont/pipeline.ts create mode 100644 examples/haskell/.harmont/pipeline.ts create mode 100644 examples/java/.harmont/pipeline.ts create mode 100644 examples/kotlin/.harmont/pipeline.ts create mode 100644 examples/ocaml/.harmont/pipeline.ts create mode 100644 examples/perl/.harmont/pipeline.ts create mode 100644 examples/php-laravel/.harmont/pipeline.ts create mode 100644 examples/python-uv/.harmont/pipeline.ts create mode 100644 examples/ruby/.harmont/pipeline.ts create mode 100644 examples/rust/.harmont/pipeline.ts create mode 100644 examples/zig-js/.harmont/pipeline.ts create mode 100644 examples/zig/.harmont/pipeline.ts diff --git a/dsls/harmont-ts/tests/examples.test.ts b/dsls/harmont-ts/tests/examples.test.ts index 3e60ab1..c7dda21 100644 --- a/dsls/harmont-ts/tests/examples.test.ts +++ b/dsls/harmont-ts/tests/examples.test.ts @@ -62,7 +62,7 @@ describe("examples render to v0 IR", () => { }); } - it("discovered at least 4 example pipeline.ts files", () => { - expect(examples.length).toBeGreaterThanOrEqual(4); + it("discovered at least 18 example pipeline.ts files", () => { + expect(examples.length).toBeGreaterThanOrEqual(18); }); }); diff --git a/examples/c/.harmont/pipeline.ts b/examples/c/.harmont/pipeline.ts new file mode 100644 index 0000000..840a1b2 --- /dev/null +++ b/examples/c/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { cmake } from "harmont/toolchains"; + +const project = cmake({ path: ".", lang: "c" }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + ir: pipeline(project.build(), project.test(), project.fmt(), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/cpp/.harmont/pipeline.ts b/examples/cpp/.harmont/pipeline.ts new file mode 100644 index 0000000..9c66b6c --- /dev/null +++ b/examples/cpp/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { cmake } from "harmont/toolchains"; + +const project = cmake({ path: ".", lang: "cpp" }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + ir: pipeline(project.build(), project.test(), project.fmt(), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/csharp/.harmont/pipeline.ts b/examples/csharp/.harmont/pipeline.ts new file mode 100644 index 0000000..6cdf7b5 --- /dev/null +++ b/examples/csharp/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { dotnet } from "harmont/toolchains"; + +const project = dotnet({ path: ".", channel: "8.0" }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + ir: pipeline(project.build(), project.test(), project.fmt(), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/go/.harmont/pipeline.ts b/examples/go/.harmont/pipeline.ts index b777b20..7fcfc0d 100644 --- a/examples/go/.harmont/pipeline.ts +++ b/examples/go/.harmont/pipeline.ts @@ -1,35 +1,18 @@ -import { - pipeline, - sh, - target, - push, - forever, - ttl, - type PipelineDefinition, -} from "harmont"; +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { go } from "harmont/toolchains"; -const goInstalled = target("go-installed", () => - sh("apt-get update && apt-get install -y curl ca-certificates", { - label: ":go: apt-base", - cache: ttl(86400), - }).sh( - "curl -fsSL https://go.dev/dl/go1.22.4.linux-amd64.tar.gz | tar -C /usr/local -xzf -", - { label: ":go: install", cache: forever() }, - ), -); +const project = go({ path: "." }); const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], ir: pipeline( - goInstalled().sh("go build ./...", { label: ":go: build" }), - goInstalled().sh("go test ./...", { label: ":go: test" }), - goInstalled().sh("go vet ./...", { label: ":go: vet" }), - { - env: { CI: "true" }, - defaultImage: "ubuntu:24.04", - }, + project.build(), + project.test(), + project.vet(), + project.fmt(), + { env: { CI: "true" }, defaultImage: "ubuntu:24.04" }, ), }, ]; diff --git a/examples/haskell/.harmont/pipeline.ts b/examples/haskell/.harmont/pipeline.ts new file mode 100644 index 0000000..c7c2e27 --- /dev/null +++ b/examples/haskell/.harmont/pipeline.ts @@ -0,0 +1,21 @@ +import { pipeline, push, target, type PipelineDefinition } from "harmont"; +import { haskell } from "harmont/toolchains"; + +const ghc = target("ghc", () => haskell({ ghc: "9.6.7" })); +const project = target("project", () => ghc().cabal(".")); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + ir: pipeline( + project().build(), + project().test(), + project().lint(), + project().fmt(), + { env: { CI: "true" }, defaultImage: "ubuntu:24.04" }, + ), + }, +]; + +export default pipelines; diff --git a/examples/java/.harmont/pipeline.ts b/examples/java/.harmont/pipeline.ts new file mode 100644 index 0000000..23ee064 --- /dev/null +++ b/examples/java/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { gradle } from "harmont/toolchains"; + +const project = gradle({ path: ".", jdk: "21" }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + ir: pipeline(project.build(), project.test(), project.lint(), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/kotlin/.harmont/pipeline.ts b/examples/kotlin/.harmont/pipeline.ts new file mode 100644 index 0000000..b656d58 --- /dev/null +++ b/examples/kotlin/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { gradle } from "harmont/toolchains"; + +const project = gradle({ path: ".", jdk: "21", kotlin: true }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + ir: pipeline(project.build(), project.test(), project.lint(), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/ocaml/.harmont/pipeline.ts b/examples/ocaml/.harmont/pipeline.ts new file mode 100644 index 0000000..22fe039 --- /dev/null +++ b/examples/ocaml/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { ocaml } from "harmont/toolchains"; + +const project = ocaml({ path: "." }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + ir: pipeline(project.build(), project.test(), project.fmt(), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/perl/.harmont/pipeline.ts b/examples/perl/.harmont/pipeline.ts new file mode 100644 index 0000000..bf8a471 --- /dev/null +++ b/examples/perl/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { perl } from "harmont/toolchains"; + +const project = perl({ path: "." }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + ir: pipeline(project.test(), project.lint(), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/php-laravel/.harmont/pipeline.ts b/examples/php-laravel/.harmont/pipeline.ts new file mode 100644 index 0000000..35f2f76 --- /dev/null +++ b/examples/php-laravel/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { composer } from "harmont/toolchains"; + +const project = composer({ path: ".", laravel: true }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + ir: pipeline(project.test(), project.lint(), { + env: { CI: "true", APP_ENV: "testing" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/python-uv/.harmont/pipeline.ts b/examples/python-uv/.harmont/pipeline.ts new file mode 100644 index 0000000..af925e6 --- /dev/null +++ b/examples/python-uv/.harmont/pipeline.ts @@ -0,0 +1,20 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { python } from "harmont/toolchains"; + +const project = python({ path: "." }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + ir: pipeline( + project.test(), + project.lint(), + project.fmt(), + project.typecheck(), + { env: { CI: "true" }, defaultImage: "ubuntu:24.04" }, + ), + }, +]; + +export default pipelines; diff --git a/examples/ruby/.harmont/pipeline.ts b/examples/ruby/.harmont/pipeline.ts new file mode 100644 index 0000000..ba28a36 --- /dev/null +++ b/examples/ruby/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { ruby } from "harmont/toolchains"; + +const project = ruby({ path: "." }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + ir: pipeline(project.test(), project.lint(), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; diff --git a/examples/rust/.harmont/pipeline.ts b/examples/rust/.harmont/pipeline.ts new file mode 100644 index 0000000..b5192ac --- /dev/null +++ b/examples/rust/.harmont/pipeline.ts @@ -0,0 +1,20 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { rust } from "harmont/toolchains"; + +const project = rust({ path: "." }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + ir: pipeline( + project.build(), + project.test(), + project.clippy(), + project.fmt(), + { env: { CI: "true" }, defaultImage: "ubuntu:24.04" }, + ), + }, +]; + +export default pipelines; diff --git a/examples/zig-js/.harmont/pipeline.ts b/examples/zig-js/.harmont/pipeline.ts new file mode 100644 index 0000000..2454299 --- /dev/null +++ b/examples/zig-js/.harmont/pipeline.ts @@ -0,0 +1,33 @@ +import { pipeline, push, scratch, target, ttl, type PipelineDefinition } from "harmont"; +import { npm, zig } from "harmont/toolchains"; + +const aptBase = target("apt-base", () => + scratch({ image: "ubuntu:24.04" }).sh( + "apt-get update && apt-get install -y --no-install-recommends curl ca-certificates xz-utils", + { label: ":apt: base", cache: ttl(86400) }, + ), +); + +const zigTc = target("zig", () => zig({ base: aptBase() })); +const zigLibA = target("zig-lib-a", () => zigTc().project("zig-a")); +const zigLibB = target("zig-lib-b", () => zigTc().project("zig-b")); +const webProject = target("web-project", () => npm({ path: "web", base: aptBase() })); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + ir: pipeline( + zigLibA().build(), + zigLibA().test(), + zigLibB().build(), + zigLibB().test(), + webProject().run("build"), + webProject().run("test"), + webProject().run("lint"), + { env: { CI: "true" }, defaultImage: "ubuntu:24.04" }, + ), + }, +]; + +export default pipelines; diff --git a/examples/zig/.harmont/pipeline.ts b/examples/zig/.harmont/pipeline.ts new file mode 100644 index 0000000..2ae28b1 --- /dev/null +++ b/examples/zig/.harmont/pipeline.ts @@ -0,0 +1,17 @@ +import { pipeline, push, type PipelineDefinition } from "harmont"; +import { zig } from "harmont/toolchains"; + +const project = zig({ path: "." }); + +const pipelines: PipelineDefinition[] = [ + { + slug: "ci", + triggers: [push({ branch: "main" })], + ir: pipeline(project.build(), project.test(), project.fmt(), { + env: { CI: "true" }, + defaultImage: "ubuntu:24.04", + }), + }, +]; + +export default pipelines; From 9f1d6d2c3d8cdaf0432b7bda586c630aeac3d90a Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 22:44:36 -0700 Subject: [PATCH 32/39] refactor(ts-dsl): rename PipelineDefinition.ir to .pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users shouldn't know about the IR layer — the field holds the result of calling pipeline() so .pipeline is the natural name. --- dsls/harmont-ts/src/envelope.ts | 4 ++-- dsls/harmont-ts/tests/envelope.test.ts | 2 +- dsls/harmont-ts/tests/examples.test.ts | 16 ++++++++-------- dsls/harmont-ts/tests/integration.test.ts | 6 ++---- examples/c/.harmont/pipeline.ts | 2 +- examples/cpp/.harmont/pipeline.ts | 2 +- examples/csharp/.harmont/pipeline.ts | 2 +- examples/go/.harmont/pipeline.ts | 2 +- examples/haskell/.harmont/pipeline.ts | 2 +- examples/java/.harmont/pipeline.ts | 2 +- examples/kotlin/.harmont/pipeline.ts | 2 +- examples/nextjs/.harmont/pipeline.ts | 2 +- examples/ocaml/.harmont/pipeline.ts | 2 +- examples/perl/.harmont/pipeline.ts | 2 +- examples/php-laravel/.harmont/pipeline.ts | 2 +- examples/python-uv/.harmont/pipeline.ts | 2 +- examples/react/.harmont/pipeline.ts | 2 +- examples/ruby/.harmont/pipeline.ts | 2 +- examples/rust/.harmont/pipeline.ts | 2 +- examples/typescript/.harmont/pipeline.ts | 2 +- examples/zig-js/.harmont/pipeline.ts | 2 +- examples/zig/.harmont/pipeline.ts | 2 +- 22 files changed, 31 insertions(+), 33 deletions(-) diff --git a/dsls/harmont-ts/src/envelope.ts b/dsls/harmont-ts/src/envelope.ts index f7ce0a8..eb43f2b 100644 --- a/dsls/harmont-ts/src/envelope.ts +++ b/dsls/harmont-ts/src/envelope.ts @@ -6,7 +6,7 @@ export interface PipelineDefinition { readonly name?: string; readonly allowManual?: boolean; readonly triggers?: readonly Trigger[]; - readonly ir: PipelineIR; + readonly pipeline: PipelineIR; } interface EnvelopeJSON { @@ -30,7 +30,7 @@ export function renderEnvelope(definitions: readonly PipelineDefinition[]): stri name: def.name ?? def.slug, allow_manual: def.allowManual ?? true, triggers: (def.triggers ?? []).map((t) => t.toJSON()), - definition: def.ir, + definition: def.pipeline, })), }; return JSON.stringify(envelope); diff --git a/dsls/harmont-ts/tests/envelope.test.ts b/dsls/harmont-ts/tests/envelope.test.ts index e256fba..a0a626a 100644 --- a/dsls/harmont-ts/tests/envelope.test.ts +++ b/dsls/harmont-ts/tests/envelope.test.ts @@ -7,7 +7,7 @@ import { push, pullRequest } from "../src/triggers.js"; function makeDef(overrides?: Partial): PipelineDefinition { return { slug: "ci", - ir: pipeline(sh("echo", { label: "test" })), + pipeline: pipeline(sh("echo", { label: "test" })), ...overrides, }; } diff --git a/dsls/harmont-ts/tests/examples.test.ts b/dsls/harmont-ts/tests/examples.test.ts index c7dda21..9cc8a6c 100644 --- a/dsls/harmont-ts/tests/examples.test.ts +++ b/dsls/harmont-ts/tests/examples.test.ts @@ -41,22 +41,22 @@ describe("examples render to v0 IR", () => { const ci = definitions.find((d: any) => d.slug === "ci"); expect(ci).toBeDefined(); - expect(ci.ir.version).toBe("0"); - expect(ci.ir.graph.nodes.length).toBeGreaterThan(0); - expect(ci.ir.graph.edge_property).toBe("directed"); - expect(ci.ir.default_image).toBeTruthy(); + expect(ci.pipeline.version).toBe("0"); + expect(ci.pipeline.graph.nodes.length).toBeGreaterThan(0); + expect(ci.pipeline.graph.edge_property).toBe("directed"); + expect(ci.pipeline.default_image).toBeTruthy(); // Verify all nodes have required fields - for (const node of ci.ir.graph.nodes) { + 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.ir.graph.edges) { - expect(src).toBeLessThan(ci.ir.graph.nodes.length); - expect(dst).toBeLessThan(ci.ir.graph.nodes.length); + 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/dsls/harmont-ts/tests/integration.test.ts b/dsls/harmont-ts/tests/integration.test.ts index a8ad211..da40ed2 100644 --- a/dsls/harmont-ts/tests/integration.test.ts +++ b/dsls/harmont-ts/tests/integration.test.ts @@ -127,8 +127,6 @@ describe("target memoization", () => { describe("envelope", () => { it("renders a complete envelope with triggers", () => { - const ir = pipeline(sh("echo hello", { label: "hello" })); - const def: PipelineDefinition = { slug: "my-ci", name: "My CI Pipeline", @@ -138,7 +136,7 @@ describe("envelope", () => { pullRequest({ branches: "develop" }), schedule("0 4 * * *"), ], - ir, + pipeline: pipeline(sh("echo hello", { label: "hello" })), }; const json = renderEnvelope([def]); @@ -199,7 +197,7 @@ describe("JSON snake_case output", () => { const def: PipelineDefinition = { slug: "ci", allowManual: true, - ir: pipeline(sh("echo")), + pipeline: pipeline(sh("echo")), }; const json = renderEnvelope([def]); diff --git a/examples/c/.harmont/pipeline.ts b/examples/c/.harmont/pipeline.ts index 840a1b2..adf6ece 100644 --- a/examples/c/.harmont/pipeline.ts +++ b/examples/c/.harmont/pipeline.ts @@ -7,7 +7,7 @@ const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], - ir: pipeline(project.build(), project.test(), project.fmt(), { + pipeline: pipeline(project.build(), project.test(), project.fmt(), { env: { CI: "true" }, defaultImage: "ubuntu:24.04", }), diff --git a/examples/cpp/.harmont/pipeline.ts b/examples/cpp/.harmont/pipeline.ts index 9c66b6c..1daa43a 100644 --- a/examples/cpp/.harmont/pipeline.ts +++ b/examples/cpp/.harmont/pipeline.ts @@ -7,7 +7,7 @@ const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], - ir: pipeline(project.build(), project.test(), project.fmt(), { + pipeline: pipeline(project.build(), project.test(), project.fmt(), { env: { CI: "true" }, defaultImage: "ubuntu:24.04", }), diff --git a/examples/csharp/.harmont/pipeline.ts b/examples/csharp/.harmont/pipeline.ts index 6cdf7b5..c67e4ac 100644 --- a/examples/csharp/.harmont/pipeline.ts +++ b/examples/csharp/.harmont/pipeline.ts @@ -7,7 +7,7 @@ const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], - ir: pipeline(project.build(), project.test(), project.fmt(), { + pipeline: pipeline(project.build(), project.test(), project.fmt(), { env: { CI: "true" }, defaultImage: "ubuntu:24.04", }), diff --git a/examples/go/.harmont/pipeline.ts b/examples/go/.harmont/pipeline.ts index 7fcfc0d..082a55e 100644 --- a/examples/go/.harmont/pipeline.ts +++ b/examples/go/.harmont/pipeline.ts @@ -7,7 +7,7 @@ const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], - ir: pipeline( + pipeline: pipeline( project.build(), project.test(), project.vet(), diff --git a/examples/haskell/.harmont/pipeline.ts b/examples/haskell/.harmont/pipeline.ts index c7c2e27..ed64b3a 100644 --- a/examples/haskell/.harmont/pipeline.ts +++ b/examples/haskell/.harmont/pipeline.ts @@ -8,7 +8,7 @@ const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], - ir: pipeline( + pipeline: pipeline( project().build(), project().test(), project().lint(), diff --git a/examples/java/.harmont/pipeline.ts b/examples/java/.harmont/pipeline.ts index 23ee064..41ae406 100644 --- a/examples/java/.harmont/pipeline.ts +++ b/examples/java/.harmont/pipeline.ts @@ -7,7 +7,7 @@ const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], - ir: pipeline(project.build(), project.test(), project.lint(), { + pipeline: pipeline(project.build(), project.test(), project.lint(), { env: { CI: "true" }, defaultImage: "ubuntu:24.04", }), diff --git a/examples/kotlin/.harmont/pipeline.ts b/examples/kotlin/.harmont/pipeline.ts index b656d58..fe4f4b8 100644 --- a/examples/kotlin/.harmont/pipeline.ts +++ b/examples/kotlin/.harmont/pipeline.ts @@ -7,7 +7,7 @@ const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], - ir: pipeline(project.build(), project.test(), project.lint(), { + pipeline: pipeline(project.build(), project.test(), project.lint(), { env: { CI: "true" }, defaultImage: "ubuntu:24.04", }), diff --git a/examples/nextjs/.harmont/pipeline.ts b/examples/nextjs/.harmont/pipeline.ts index 0c876c1..87f6389 100644 --- a/examples/nextjs/.harmont/pipeline.ts +++ b/examples/nextjs/.harmont/pipeline.ts @@ -7,7 +7,7 @@ const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], - ir: pipeline(project.run("build"), project.run("test"), project.run("lint"), { + pipeline: pipeline(project.run("build"), project.run("test"), project.run("lint"), { env: { CI: "true" }, defaultImage: "ubuntu:24.04", }), diff --git a/examples/ocaml/.harmont/pipeline.ts b/examples/ocaml/.harmont/pipeline.ts index 22fe039..0f3a38d 100644 --- a/examples/ocaml/.harmont/pipeline.ts +++ b/examples/ocaml/.harmont/pipeline.ts @@ -7,7 +7,7 @@ const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], - ir: pipeline(project.build(), project.test(), project.fmt(), { + pipeline: pipeline(project.build(), project.test(), project.fmt(), { env: { CI: "true" }, defaultImage: "ubuntu:24.04", }), diff --git a/examples/perl/.harmont/pipeline.ts b/examples/perl/.harmont/pipeline.ts index bf8a471..c0c8921 100644 --- a/examples/perl/.harmont/pipeline.ts +++ b/examples/perl/.harmont/pipeline.ts @@ -7,7 +7,7 @@ const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], - ir: pipeline(project.test(), project.lint(), { + pipeline: pipeline(project.test(), project.lint(), { env: { CI: "true" }, defaultImage: "ubuntu:24.04", }), diff --git a/examples/php-laravel/.harmont/pipeline.ts b/examples/php-laravel/.harmont/pipeline.ts index 35f2f76..e0959b9 100644 --- a/examples/php-laravel/.harmont/pipeline.ts +++ b/examples/php-laravel/.harmont/pipeline.ts @@ -7,7 +7,7 @@ const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], - ir: pipeline(project.test(), project.lint(), { + pipeline: pipeline(project.test(), project.lint(), { env: { CI: "true", APP_ENV: "testing" }, defaultImage: "ubuntu:24.04", }), diff --git a/examples/python-uv/.harmont/pipeline.ts b/examples/python-uv/.harmont/pipeline.ts index af925e6..94ee5ab 100644 --- a/examples/python-uv/.harmont/pipeline.ts +++ b/examples/python-uv/.harmont/pipeline.ts @@ -7,7 +7,7 @@ const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], - ir: pipeline( + pipeline: pipeline( project.test(), project.lint(), project.fmt(), diff --git a/examples/react/.harmont/pipeline.ts b/examples/react/.harmont/pipeline.ts index 0c876c1..87f6389 100644 --- a/examples/react/.harmont/pipeline.ts +++ b/examples/react/.harmont/pipeline.ts @@ -7,7 +7,7 @@ const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], - ir: pipeline(project.run("build"), project.run("test"), project.run("lint"), { + pipeline: pipeline(project.run("build"), project.run("test"), project.run("lint"), { env: { CI: "true" }, defaultImage: "ubuntu:24.04", }), diff --git a/examples/ruby/.harmont/pipeline.ts b/examples/ruby/.harmont/pipeline.ts index ba28a36..34b78fb 100644 --- a/examples/ruby/.harmont/pipeline.ts +++ b/examples/ruby/.harmont/pipeline.ts @@ -7,7 +7,7 @@ const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], - ir: pipeline(project.test(), project.lint(), { + pipeline: pipeline(project.test(), project.lint(), { env: { CI: "true" }, defaultImage: "ubuntu:24.04", }), diff --git a/examples/rust/.harmont/pipeline.ts b/examples/rust/.harmont/pipeline.ts index b5192ac..71c3ef1 100644 --- a/examples/rust/.harmont/pipeline.ts +++ b/examples/rust/.harmont/pipeline.ts @@ -7,7 +7,7 @@ const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], - ir: pipeline( + pipeline: pipeline( project.build(), project.test(), project.clippy(), diff --git a/examples/typescript/.harmont/pipeline.ts b/examples/typescript/.harmont/pipeline.ts index 0c876c1..87f6389 100644 --- a/examples/typescript/.harmont/pipeline.ts +++ b/examples/typescript/.harmont/pipeline.ts @@ -7,7 +7,7 @@ const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], - ir: pipeline(project.run("build"), project.run("test"), project.run("lint"), { + pipeline: pipeline(project.run("build"), project.run("test"), project.run("lint"), { env: { CI: "true" }, defaultImage: "ubuntu:24.04", }), diff --git a/examples/zig-js/.harmont/pipeline.ts b/examples/zig-js/.harmont/pipeline.ts index 2454299..6a1337b 100644 --- a/examples/zig-js/.harmont/pipeline.ts +++ b/examples/zig-js/.harmont/pipeline.ts @@ -17,7 +17,7 @@ const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], - ir: pipeline( + pipeline: pipeline( zigLibA().build(), zigLibA().test(), zigLibB().build(), diff --git a/examples/zig/.harmont/pipeline.ts b/examples/zig/.harmont/pipeline.ts index 2ae28b1..26df58f 100644 --- a/examples/zig/.harmont/pipeline.ts +++ b/examples/zig/.harmont/pipeline.ts @@ -7,7 +7,7 @@ const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], - ir: pipeline(project.build(), project.test(), project.fmt(), { + pipeline: pipeline(project.build(), project.test(), project.fmt(), { env: { CI: "true" }, defaultImage: "ubuntu:24.04", }), From 597426d0560e168a176d5b44cde95ebec57de206 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 23:03:01 -0700 Subject: [PATCH 33/39] feat: python E2E pipeline fixtures (4 scenarios) --- dsls/harmont-py/tests/test_e2e_fixtures.py | 161 ++++++++++ tests/e2e/fixtures/python/kitchen-sink.json | 270 ++++++++++++++++ tests/e2e/fixtures/python/monorepo-ci.json | 295 ++++++++++++++++++ tests/e2e/fixtures/python/rust-release.json | 122 ++++++++ .../fixtures/python/zig-node-polyglot.json | 192 ++++++++++++ 5 files changed, 1040 insertions(+) create mode 100644 dsls/harmont-py/tests/test_e2e_fixtures.py create mode 100644 tests/e2e/fixtures/python/kitchen-sink.json create mode 100644 tests/e2e/fixtures/python/monorepo-ci.json create mode 100644 tests/e2e/fixtures/python/rust-release.json create mode 100644 tests/e2e/fixtures/python/zig-node-polyglot.json diff --git a/dsls/harmont-py/tests/test_e2e_fixtures.py b/dsls/harmont-py/tests/test_e2e_fixtures.py new file mode 100644 index 0000000..97331bf --- /dev/null +++ b/dsls/harmont-py/tests/test_e2e_fixtures.py @@ -0,0 +1,161 @@ +"""E2E fixture generation + validation. + +Renders 4 complex pipeline scenarios to v0 IR JSON and writes +committed fixtures for Rust deserialization tests. + +Regenerate: UPDATE_E2E_FIXTURES=1 pytest tests/test_e2e_fixtures.py -v +""" +from __future__ import annotations + +import json +import os +from datetime import timedelta +from pathlib import Path + +import pytest + +import harmont as hm +from harmont.cmake import cmake +from harmont.go import go +from harmont.haskell import haskell +from harmont.npm import npm +from harmont.python import python as python_tc +from harmont.rust import rust +from harmont.zig import zig + +REPO_ROOT = Path(__file__).resolve().parents[3] +FIXTURES_DIR = REPO_ROOT / "tests" / "e2e" / "fixtures" / "python" + + +def _render(ir: dict) -> str: + return json.dumps(ir, indent=2, sort_keys=True, ensure_ascii=False) + + +def _assert_fixture(name: str, ir: dict) -> None: + rendered = _render(ir) + fixture_path = FIXTURES_DIR / f"{name}.json" + + if os.environ.get("UPDATE_E2E_FIXTURES"): + fixture_path.write_text(rendered + "\n") + return + + assert fixture_path.exists(), ( + f"Fixture {fixture_path} missing — run with UPDATE_E2E_FIXTURES=1" + ) + expected = json.loads(fixture_path.read_text()) + actual = json.loads(rendered) + assert actual == expected, ( + f"Fixture drift for {name}. Regenerate with UPDATE_E2E_FIXTURES=1" + ) + + +def _build_monorepo_ci() -> dict: + go_project = go(path="services/api") + py_project = python_tc(path="services/ml") + web_project = npm(path="web") + + return hm.pipeline( + go_project.build(), + go_project.test(), + go_project.vet(), + py_project.test(), + py_project.lint(), + py_project.typecheck(), + web_project.run("build"), + web_project.run("test"), + web_project.run("lint"), + env={"CI": "true"}, + default_image="ubuntu:24.04", + ) + + +def _build_rust_release() -> dict: + project = rust(path=".") + + return hm.pipeline( + project.build(), + project.test(), + project.clippy(), + project.fmt(), + project.doc(), + env={"CI": "true"}, + default_image="ubuntu:24.04", + ) + + +def _build_zig_node_polyglot() -> dict: + base = hm.sh( + "apt-get update && apt-get install -y --no-install-recommends " + "curl ca-certificates xz-utils", + label=":apt: base", + cache=hm.ttl(timedelta(days=1)), + image="ubuntu:24.04", + ) + zig_tc = zig(base=base) + proj_a = zig_tc.project(path="zig-a") + proj_b = zig_tc.project(path="zig-b") + web = npm(path="web", base=base) + + return hm.pipeline( + proj_a.build(), + proj_a.test(), + proj_b.build(), + proj_b.test(), + web.run("build"), + web.run("test"), + web.run("lint"), + env={"CI": "true"}, + default_image="ubuntu:24.04", + ) + + +def _build_kitchen_sink() -> dict: + hs_tc = haskell(ghc="9.6.7") + pkg_a = hs_tc.cabal(path="pkg-a") + pkg_b = hs_tc.cabal(path="pkg-b") + + c_project = cmake(path="infra/agent", lang="c") + + return hm.pipeline( + pkg_a.build(), + pkg_a.test(), + pkg_b.build(), + pkg_b.test(), + pkg_b.hlint(), + pkg_b.fmt(), + c_project.build(), + c_project.test(), + c_project.fmt(), + env={"CI": "true", "STACK_ROOT": "/tmp/.stack"}, + default_image="ubuntu:24.04", + ) + + +SCENARIOS = { + "monorepo-ci": _build_monorepo_ci, + "rust-release": _build_rust_release, + "zig-node-polyglot": _build_zig_node_polyglot, + "kitchen-sink": _build_kitchen_sink, +} + + +@pytest.mark.parametrize("name", SCENARIOS.keys()) +def test_e2e_fixture(name: str) -> None: + ir = SCENARIOS[name]() + + assert ir["version"] == "0" + assert ir["default_image"] == "ubuntu:24.04" + assert len(ir["graph"]["nodes"]) > 0 + assert ir["graph"]["edge_property"] == "directed" + + for node in ir["graph"]["nodes"]: + assert "key" in node["step"] + assert "cmd" in node["step"] + assert isinstance(node["env"], dict) + + for src, dst, kind in ir["graph"]["edges"]: + assert kind in ("builds_in", "depends_on") + assert src < len(ir["graph"]["nodes"]) + assert dst < len(ir["graph"]["nodes"]) + + _assert_fixture(name, ir) diff --git a/tests/e2e/fixtures/python/kitchen-sink.json b/tests/e2e/fixtures/python/kitchen-sink.json new file mode 100644 index 0000000..092f280 --- /dev/null +++ b/tests/e2e/fixtures/python/kitchen-sink.json @@ -0,0 +1,270 @@ +{ + "default_image": "ubuntu:24.04", + "graph": { + "edge_property": "directed", + "edges": [ + [ + 0, + 1, + "builds_in" + ], + [ + 1, + 2, + "builds_in" + ], + [ + 2, + 3, + "builds_in" + ], + [ + 2, + 4, + "builds_in" + ], + [ + 1, + 5, + "builds_in" + ], + [ + 5, + 6, + "builds_in" + ], + [ + 5, + 7, + "builds_in" + ], + [ + 5, + 8, + "builds_in" + ], + [ + 5, + 9, + "builds_in" + ], + [ + 10, + 11, + "builds_in" + ], + [ + 11, + 12, + "builds_in" + ], + [ + 11, + 13, + "builds_in" + ], + [ + 11, + 14, + "builds_in" + ] + ], + "node_holes": [], + "nodes": [ + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y curl ca-certificates build-essential libgmp-dev libffi-dev libncurses-dev zlib1g-dev", + "image": "ubuntu:24.04", + "key": "d9d2f7730236", + "label": ":haskell: apt-base" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -fsSL https://downloads.haskell.org/~ghcup/x86_64-linux-ghcup -o /usr/local/bin/ghcup && chmod +x /usr/local/bin/ghcup && ghcup install ghc 9.6.7 && ghcup install cabal latest && ghcup set ghc 9.6.7 && ghcup set cabal latest && ln -sf /root/.ghcup/bin/* /usr/local/bin/ && curl -fsSL https://github.com/fourmolu/fourmolu/releases/download/v0.18.0.0/fourmolu-0.18.0.0-linux-x86_64 -o /usr/local/bin/fourmolu && chmod +x /usr/local/bin/fourmolu", + "key": "ghcup", + "label": ":haskell: ghcup" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "paths": [], + "policy": "on_change" + }, + "cmd": "cabal update && cd pkg-a && cabal build all --only-dependencies", + "key": "pkg-a-deps", + "label": ":haskell: pkg-a deps" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd pkg-a && cabal build all", + "key": "pkg-a-build", + "label": ":haskell: pkg-a build" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd pkg-a && cabal test all", + "key": "pkg-a-test", + "label": ":haskell: pkg-a test" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "paths": [], + "policy": "on_change" + }, + "cmd": "cabal update && cd pkg-b && cabal build all --only-dependencies", + "key": "pkg-b-deps", + "label": ":haskell: pkg-b deps" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd pkg-b && cabal build all", + "key": "pkg-b-build", + "label": ":haskell: pkg-b build" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd pkg-b && cabal test all", + "key": "pkg-b-test", + "label": ":haskell: pkg-b test" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "hlint pkg-b", + "key": "pkg-b-hlint", + "label": ":haskell: pkg-b hlint" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "fourmolu --mode check pkg-b", + "key": "pkg-b-fmt", + "label": ":haskell: pkg-b fmt" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y build-essential cmake ninja-build clang-format", + "image": "ubuntu:24.04", + "key": "6dc3a82f7a67", + "label": ":c: apt-base" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "cmake --version && clang-format --version", + "key": "cmake-verify", + "label": ":c: cmake-verify" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd infra/agent && cmake -S . -B build && cmake --build build", + "key": "build", + "label": ":c: build" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd infra/agent && cmake -S . -B build && cmake --build build && ctest --test-dir build --output-on-failure", + "key": "test", + "label": ":c: test" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd infra/agent && find src tests -name '*.[ch]' -o -name '*.cpp' -o -name '*.hpp' | xargs clang-format --dry-run --Werror", + "key": "fmt", + "label": ":c: fmt" + } + } + ] + }, + "version": "0" +} diff --git a/tests/e2e/fixtures/python/monorepo-ci.json b/tests/e2e/fixtures/python/monorepo-ci.json new file mode 100644 index 0000000..f2d128e --- /dev/null +++ b/tests/e2e/fixtures/python/monorepo-ci.json @@ -0,0 +1,295 @@ +{ + "default_image": "ubuntu:24.04", + "graph": { + "edge_property": "directed", + "edges": [ + [ + 0, + 1, + "builds_in" + ], + [ + 1, + 2, + "builds_in" + ], + [ + 1, + 3, + "builds_in" + ], + [ + 1, + 4, + "builds_in" + ], + [ + 5, + 6, + "builds_in" + ], + [ + 6, + 7, + "builds_in" + ], + [ + 7, + 8, + "builds_in" + ], + [ + 7, + 9, + "builds_in" + ], + [ + 7, + 10, + "builds_in" + ], + [ + 11, + 12, + "builds_in" + ], + [ + 12, + 13, + "builds_in" + ], + [ + 13, + 14, + "builds_in" + ], + [ + 13, + 15, + "builds_in" + ], + [ + 13, + 16, + "builds_in" + ] + ], + "node_holes": [], + "nodes": [ + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y curl ca-certificates git", + "image": "ubuntu:24.04", + "key": "334b29e96b76", + "label": ":go: apt-base" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -fsSL https://go.dev/dl/go1.23.2.linux-amd64.tar.gz -o /tmp/go.tgz && rm -rf /usr/local/go && tar -C /usr/local -xzf /tmp/go.tgz && ln -sf /usr/local/go/bin/go /usr/local/bin/go && ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt && go version", + "key": "e0b494124562", + "label": ":go: install" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/api && go build ./...", + "key": "6f9493b7219f", + "label": ":go: build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/api && go test ./...", + "key": "1ad6d86b2c0a", + "label": ":go: test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/api && go vet ./...", + "key": "vet", + "label": ":go: vet" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y curl ca-certificates python3 python3-venv", + "image": "ubuntu:24.04", + "key": "c8d9fda86ff3", + "label": ":python: apt-base" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -LsSf https://astral.sh/uv/install.sh | sh && ln -sf /root/.local/bin/uv /usr/local/bin/uv && uv --version", + "key": "uv-install", + "label": ":python: uv-install" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "paths": [ + "services/ml/uv.lock", + "services/ml/pyproject.toml" + ], + "policy": "on_change" + }, + "cmd": "cd services/ml && uv sync --all-extras", + "key": "uv-sync", + "label": ":python: uv-sync" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/ml && uv run pytest", + "key": "847020e744bc", + "label": ":python: test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/ml && uv run ruff check .", + "key": "6c48498afb84", + "label": ":python: lint" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/ml && uv run mypy .", + "key": "typecheck", + "label": ":python: typecheck" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y curl ca-certificates", + "image": "ubuntu:24.04", + "key": "3c2cfedcad46", + "label": ":node: apt-base" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs", + "key": "6c25ac8f0830", + "label": ":node: install" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "paths": [ + "web/package-lock.json" + ], + "policy": "on_change" + }, + "cmd": "cd web && npm ci", + "key": "deps", + "label": ":node: deps" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run build", + "key": "a94a0f84e711", + "label": ":node: build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run test", + "key": "d2438adde70d", + "label": ":node: test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run lint", + "key": "74c52c9e5ef6", + "label": ":node: lint" + } + } + ] + }, + "version": "0" +} diff --git a/tests/e2e/fixtures/python/rust-release.json b/tests/e2e/fixtures/python/rust-release.json new file mode 100644 index 0000000..dd35019 --- /dev/null +++ b/tests/e2e/fixtures/python/rust-release.json @@ -0,0 +1,122 @@ +{ + "default_image": "ubuntu:24.04", + "graph": { + "edge_property": "directed", + "edges": [ + [ + 0, + 1, + "builds_in" + ], + [ + 1, + 2, + "builds_in" + ], + [ + 1, + 3, + "builds_in" + ], + [ + 1, + 4, + "builds_in" + ], + [ + 1, + 5, + "builds_in" + ], + [ + 1, + 6, + "builds_in" + ] + ], + "node_holes": [], + "nodes": [ + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y curl ca-certificates build-essential pkg-config libssl-dev", + "image": "ubuntu:24.04", + "key": "apt-base", + "label": ":rust: apt-base" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal --component clippy,rustfmt && . $HOME/.cargo/env && rustc --version && cargo --version", + "key": "rustup", + "label": ":rust: rustup" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": ". $HOME/.cargo/env && cd . && cargo build", + "key": "build", + "label": ":rust: build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": ". $HOME/.cargo/env && cd . && cargo test", + "key": "test", + "label": ":rust: test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": ". $HOME/.cargo/env && cd . && cargo clippy --all-targets -- -D warnings", + "key": "clippy", + "label": ":rust: clippy" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": ". $HOME/.cargo/env && cd . && cargo fmt --check", + "key": "fmt", + "label": ":rust: fmt" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": ". $HOME/.cargo/env && cd . && cargo doc --no-deps", + "key": "doc", + "label": ":rust: doc" + } + } + ] + }, + "version": "0" +} diff --git a/tests/e2e/fixtures/python/zig-node-polyglot.json b/tests/e2e/fixtures/python/zig-node-polyglot.json new file mode 100644 index 0000000..2743f1d --- /dev/null +++ b/tests/e2e/fixtures/python/zig-node-polyglot.json @@ -0,0 +1,192 @@ +{ + "default_image": "ubuntu:24.04", + "graph": { + "edge_property": "directed", + "edges": [ + [ + 0, + 1, + "builds_in" + ], + [ + 1, + 2, + "builds_in" + ], + [ + 1, + 3, + "builds_in" + ], + [ + 1, + 4, + "builds_in" + ], + [ + 1, + 5, + "builds_in" + ], + [ + 0, + 6, + "builds_in" + ], + [ + 6, + 7, + "builds_in" + ], + [ + 7, + 8, + "builds_in" + ], + [ + 7, + 9, + "builds_in" + ], + [ + 7, + 10, + "builds_in" + ] + ], + "node_holes": [], + "nodes": [ + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y --no-install-recommends curl ca-certificates xz-utils", + "image": "ubuntu:24.04", + "key": "base", + "label": ":apt: base" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -fsSL https://ziglang.org/download/0.13.0/zig-linux-x86_64-0.13.0.tar.xz -o /tmp/zig.tar.xz && rm -rf /usr/local/zig && mkdir -p /usr/local/zig && tar -xJf /tmp/zig.tar.xz -C /usr/local/zig --strip-components=1 && ln -sf /usr/local/zig/zig /usr/local/bin/zig && zig version", + "key": "b589fbff75ec", + "label": ":zig: install" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd zig-a && zig build", + "key": "zig-a-build", + "label": ":zig: zig-a build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd zig-a && zig build test", + "key": "zig-a-test", + "label": ":zig: zig-a test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd zig-b && zig build", + "key": "zig-b-build", + "label": ":zig: zig-b build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd zig-b && zig build test", + "key": "zig-b-test", + "label": ":zig: zig-b test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs", + "key": "e1242026cf31", + "label": ":node: install" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "paths": [ + "web/package-lock.json" + ], + "policy": "on_change" + }, + "cmd": "cd web && npm ci", + "key": "deps", + "label": ":node: deps" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run build", + "key": "build", + "label": ":node: build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run test", + "key": "test", + "label": ":node: test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run lint", + "key": "lint", + "label": ":node: lint" + } + } + ] + }, + "version": "0" +} From 6b6f9f862b3bd7d4ba2dc01746100cc29a5ae374 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 23:04:04 -0700 Subject: [PATCH 34/39] feat: typescript E2E pipeline fixtures (4 scenarios) --- dsls/harmont-ts/tests/e2e-fixtures.test.ts | 145 +++++++++ tests/e2e/fixtures/ts/kitchen-sink.json | 274 +++++++++++++++++ tests/e2e/fixtures/ts/monorepo-ci.json | 295 +++++++++++++++++++ tests/e2e/fixtures/ts/rust-release.json | 122 ++++++++ tests/e2e/fixtures/ts/zig-node-polyglot.json | 192 ++++++++++++ 5 files changed, 1028 insertions(+) create mode 100644 dsls/harmont-ts/tests/e2e-fixtures.test.ts create mode 100644 tests/e2e/fixtures/ts/kitchen-sink.json create mode 100644 tests/e2e/fixtures/ts/monorepo-ci.json create mode 100644 tests/e2e/fixtures/ts/rust-release.json create mode 100644 tests/e2e/fixtures/ts/zig-node-polyglot.json diff --git a/dsls/harmont-ts/tests/e2e-fixtures.test.ts b/dsls/harmont-ts/tests/e2e-fixtures.test.ts new file mode 100644 index 0000000..8f70b55 --- /dev/null +++ b/dsls/harmont-ts/tests/e2e-fixtures.test.ts @@ -0,0 +1,145 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { beforeEach, describe, expect, it } from "vitest"; +import { clearTargetCache } from "../src/target.js"; +import { pipeline } from "../src/pipeline.js"; +import { sh, scratch } from "../src/step.js"; +import { ttl, forever } from "../src/cache.js"; +import { go } from "../src/toolchains/go.js"; +import { python } from "../src/toolchains/python.js"; +import { npm } from "../src/toolchains/npm.js"; +import { rust } from "../src/toolchains/rust.js"; +import { zig } from "../src/toolchains/zig.js"; +import { haskell } from "../src/toolchains/haskell.js"; +import { cmake } from "../src/toolchains/cmake.js"; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const FIXTURES_DIR = resolve(__dir, "../../../tests/e2e/fixtures/ts"); + +function deepSortKeys(obj: unknown): unknown { + if (Array.isArray(obj)) return obj.map(deepSortKeys); + if (obj !== null && typeof obj === "object") { + const sorted: Record = {}; + for (const key of Object.keys(obj as Record).sort()) { + sorted[key] = deepSortKeys((obj as Record)[key]); + } + return sorted; + } + return obj; +} + +function assertFixture(name: string, ir: Record): void { + const rendered = JSON.stringify(deepSortKeys(ir), null, 2) + "\n"; + const fixturePath = resolve(FIXTURES_DIR, `${name}.json`); + + if (process.env.UPDATE_E2E_FIXTURES) { + mkdirSync(dirname(fixturePath), { recursive: true }); + writeFileSync(fixturePath, rendered); + return; + } + + if (!existsSync(fixturePath)) { + throw new Error( + `Fixture ${fixturePath} missing — run with UPDATE_E2E_FIXTURES=1`, + ); + } + const expected = JSON.parse(readFileSync(fixturePath, "utf-8")); + const actual = JSON.parse(rendered); + expect(actual).toEqual(expected); +} + +describe("E2E pipeline fixtures", () => { + beforeEach(() => { + clearTargetCache(); + }); + + it("monorepo-ci", () => { + const goProject = go({ path: "services/api" }); + const pyProject = python({ path: "services/ml" }); + const webProject = npm({ path: "web" }); + + const ir = pipeline( + goProject.build(), + goProject.test(), + goProject.vet(), + pyProject.test(), + pyProject.lint(), + pyProject.typecheck(), + webProject.run("build"), + webProject.run("test"), + webProject.run("lint"), + { env: { CI: "true" }, defaultImage: "ubuntu:24.04" }, + ); + + expect(ir.version).toBe("0"); + expect(ir.default_image).toBe("ubuntu:24.04"); + expect(ir.graph.nodes.length).toBeGreaterThan(0); + assertFixture("monorepo-ci", ir); + }); + + it("rust-release", () => { + const project = rust({ path: "." }); + + const ir = pipeline( + project.build(), + project.test(), + project.clippy(), + project.fmt(), + project.doc(), + { env: { CI: "true" }, defaultImage: "ubuntu:24.04" }, + ); + + expect(ir.version).toBe("0"); + assertFixture("rust-release", ir); + }); + + it("zig-node-polyglot", () => { + const base = sh( + "apt-get update && apt-get install -y --no-install-recommends " + + "curl ca-certificates xz-utils", + { label: ":apt: base", cache: ttl(86400), image: "ubuntu:24.04" }, + ); + const zigTc = zig({ base }); + const projA = zigTc.project("zig-a"); + const projB = zigTc.project("zig-b"); + const web = npm({ path: "web", base }); + + const ir = pipeline( + projA.build(), + projA.test(), + projB.build(), + projB.test(), + web.run("build"), + web.run("test"), + web.run("lint"), + { env: { CI: "true" }, defaultImage: "ubuntu:24.04" }, + ); + + expect(ir.version).toBe("0"); + assertFixture("zig-node-polyglot", ir); + }); + + it("kitchen-sink", () => { + const hsTc = haskell({ ghc: "9.6.7" }); + const pkgA = hsTc.cabal("pkg-a"); + const pkgB = hsTc.cabal("pkg-b"); + const cProject = cmake({ path: "infra/agent", lang: "c" }); + + const ir = pipeline( + pkgA.build(), + pkgA.test(), + pkgB.build(), + pkgB.test(), + pkgB.hlint(), + pkgB.fmt(), + cProject.build(), + cProject.test(), + cProject.fmt(), + { env: { CI: "true", STACK_ROOT: "/tmp/.stack" }, defaultImage: "ubuntu:24.04" }, + ); + + expect(ir.version).toBe("0"); + assertFixture("kitchen-sink", ir); + }); +}); diff --git a/tests/e2e/fixtures/ts/kitchen-sink.json b/tests/e2e/fixtures/ts/kitchen-sink.json new file mode 100644 index 0000000..4fcbe39 --- /dev/null +++ b/tests/e2e/fixtures/ts/kitchen-sink.json @@ -0,0 +1,274 @@ +{ + "default_image": "ubuntu:24.04", + "graph": { + "edge_property": "directed", + "edges": [ + [ + 0, + 1, + "builds_in" + ], + [ + 1, + 2, + "builds_in" + ], + [ + 2, + 3, + "builds_in" + ], + [ + 2, + 4, + "builds_in" + ], + [ + 1, + 5, + "builds_in" + ], + [ + 5, + 6, + "builds_in" + ], + [ + 5, + 7, + "builds_in" + ], + [ + 5, + 8, + "builds_in" + ], + [ + 5, + 9, + "builds_in" + ], + [ + 10, + 11, + "builds_in" + ], + [ + 11, + 12, + "builds_in" + ], + [ + 11, + 13, + "builds_in" + ], + [ + 11, + 14, + "builds_in" + ] + ], + "node_holes": [], + "nodes": [ + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y curl ca-certificates build-essential libgmp-dev libffi-dev libncurses-dev zlib1g-dev", + "image": "ubuntu:24.04", + "key": "d9d2f7730236", + "label": ":haskell: apt-base" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -fsSL https://downloads.haskell.org/~ghcup/x86_64-linux-ghcup -o /usr/local/bin/ghcup && chmod +x /usr/local/bin/ghcup && ghcup install ghc 9.6.7 && ghcup install cabal latest && ghcup set ghc 9.6.7 && ghcup set cabal latest && ln -sf /root/.ghcup/bin/* /usr/local/bin/ && curl -fsSL https://github.com/fourmolu/fourmolu/releases/download/v0.18.0.0/fourmolu-0.18.0.0-linux-x86_64 -o /usr/local/bin/fourmolu && chmod +x /usr/local/bin/fourmolu", + "key": "ghcup", + "label": ":haskell: ghcup" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "paths": [ + "pkg-a/*.cabal" + ], + "policy": "on_change" + }, + "cmd": "cabal update && cd pkg-a && cabal build all --only-dependencies", + "key": "pkg-a-deps", + "label": ":haskell: pkg-a deps" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd pkg-a && cabal build all", + "key": "pkg-a-build", + "label": ":haskell: pkg-a build" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd pkg-a && cabal test all", + "key": "pkg-a-test", + "label": ":haskell: pkg-a test" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "paths": [ + "pkg-b/*.cabal" + ], + "policy": "on_change" + }, + "cmd": "cabal update && cd pkg-b && cabal build all --only-dependencies", + "key": "pkg-b-deps", + "label": ":haskell: pkg-b deps" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd pkg-b && cabal build all", + "key": "pkg-b-build", + "label": ":haskell: pkg-b build" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd pkg-b && cabal test all", + "key": "pkg-b-test", + "label": ":haskell: pkg-b test" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "hlint pkg-b", + "key": "pkg-b-hlint", + "label": ":haskell: pkg-b hlint" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "fourmolu --mode check pkg-b", + "key": "pkg-b-fmt", + "label": ":haskell: pkg-b fmt" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y build-essential cmake ninja-build clang-format", + "image": "ubuntu:24.04", + "key": "6dc3a82f7a67", + "label": ":c: apt-base" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "cmake --version && clang-format --version", + "key": "cmake-verify", + "label": ":c: cmake-verify" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd infra/agent && cmake -S . -B build && cmake --build build", + "key": "build", + "label": ":c: build" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd infra/agent && cmake -S . -B build && cmake --build build && ctest --test-dir build --output-on-failure", + "key": "test", + "label": ":c: test" + } + }, + { + "env": { + "CI": "true", + "STACK_ROOT": "/tmp/.stack" + }, + "step": { + "cmd": "cd infra/agent && find src tests -name '*.[ch]' -o -name '*.cpp' -o -name '*.hpp' | xargs clang-format --dry-run --Werror", + "key": "fmt", + "label": ":c: fmt" + } + } + ] + }, + "version": "0" +} diff --git a/tests/e2e/fixtures/ts/monorepo-ci.json b/tests/e2e/fixtures/ts/monorepo-ci.json new file mode 100644 index 0000000..f2d128e --- /dev/null +++ b/tests/e2e/fixtures/ts/monorepo-ci.json @@ -0,0 +1,295 @@ +{ + "default_image": "ubuntu:24.04", + "graph": { + "edge_property": "directed", + "edges": [ + [ + 0, + 1, + "builds_in" + ], + [ + 1, + 2, + "builds_in" + ], + [ + 1, + 3, + "builds_in" + ], + [ + 1, + 4, + "builds_in" + ], + [ + 5, + 6, + "builds_in" + ], + [ + 6, + 7, + "builds_in" + ], + [ + 7, + 8, + "builds_in" + ], + [ + 7, + 9, + "builds_in" + ], + [ + 7, + 10, + "builds_in" + ], + [ + 11, + 12, + "builds_in" + ], + [ + 12, + 13, + "builds_in" + ], + [ + 13, + 14, + "builds_in" + ], + [ + 13, + 15, + "builds_in" + ], + [ + 13, + 16, + "builds_in" + ] + ], + "node_holes": [], + "nodes": [ + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y curl ca-certificates git", + "image": "ubuntu:24.04", + "key": "334b29e96b76", + "label": ":go: apt-base" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -fsSL https://go.dev/dl/go1.23.2.linux-amd64.tar.gz -o /tmp/go.tgz && rm -rf /usr/local/go && tar -C /usr/local -xzf /tmp/go.tgz && ln -sf /usr/local/go/bin/go /usr/local/bin/go && ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt && go version", + "key": "e0b494124562", + "label": ":go: install" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/api && go build ./...", + "key": "6f9493b7219f", + "label": ":go: build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/api && go test ./...", + "key": "1ad6d86b2c0a", + "label": ":go: test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/api && go vet ./...", + "key": "vet", + "label": ":go: vet" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y curl ca-certificates python3 python3-venv", + "image": "ubuntu:24.04", + "key": "c8d9fda86ff3", + "label": ":python: apt-base" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -LsSf https://astral.sh/uv/install.sh | sh && ln -sf /root/.local/bin/uv /usr/local/bin/uv && uv --version", + "key": "uv-install", + "label": ":python: uv-install" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "paths": [ + "services/ml/uv.lock", + "services/ml/pyproject.toml" + ], + "policy": "on_change" + }, + "cmd": "cd services/ml && uv sync --all-extras", + "key": "uv-sync", + "label": ":python: uv-sync" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/ml && uv run pytest", + "key": "847020e744bc", + "label": ":python: test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/ml && uv run ruff check .", + "key": "6c48498afb84", + "label": ":python: lint" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd services/ml && uv run mypy .", + "key": "typecheck", + "label": ":python: typecheck" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y curl ca-certificates", + "image": "ubuntu:24.04", + "key": "3c2cfedcad46", + "label": ":node: apt-base" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs", + "key": "6c25ac8f0830", + "label": ":node: install" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "paths": [ + "web/package-lock.json" + ], + "policy": "on_change" + }, + "cmd": "cd web && npm ci", + "key": "deps", + "label": ":node: deps" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run build", + "key": "a94a0f84e711", + "label": ":node: build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run test", + "key": "d2438adde70d", + "label": ":node: test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run lint", + "key": "74c52c9e5ef6", + "label": ":node: lint" + } + } + ] + }, + "version": "0" +} diff --git a/tests/e2e/fixtures/ts/rust-release.json b/tests/e2e/fixtures/ts/rust-release.json new file mode 100644 index 0000000..dd35019 --- /dev/null +++ b/tests/e2e/fixtures/ts/rust-release.json @@ -0,0 +1,122 @@ +{ + "default_image": "ubuntu:24.04", + "graph": { + "edge_property": "directed", + "edges": [ + [ + 0, + 1, + "builds_in" + ], + [ + 1, + 2, + "builds_in" + ], + [ + 1, + 3, + "builds_in" + ], + [ + 1, + 4, + "builds_in" + ], + [ + 1, + 5, + "builds_in" + ], + [ + 1, + 6, + "builds_in" + ] + ], + "node_holes": [], + "nodes": [ + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y curl ca-certificates build-essential pkg-config libssl-dev", + "image": "ubuntu:24.04", + "key": "apt-base", + "label": ":rust: apt-base" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal --component clippy,rustfmt && . $HOME/.cargo/env && rustc --version && cargo --version", + "key": "rustup", + "label": ":rust: rustup" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": ". $HOME/.cargo/env && cd . && cargo build", + "key": "build", + "label": ":rust: build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": ". $HOME/.cargo/env && cd . && cargo test", + "key": "test", + "label": ":rust: test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": ". $HOME/.cargo/env && cd . && cargo clippy --all-targets -- -D warnings", + "key": "clippy", + "label": ":rust: clippy" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": ". $HOME/.cargo/env && cd . && cargo fmt --check", + "key": "fmt", + "label": ":rust: fmt" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": ". $HOME/.cargo/env && cd . && cargo doc --no-deps", + "key": "doc", + "label": ":rust: doc" + } + } + ] + }, + "version": "0" +} diff --git a/tests/e2e/fixtures/ts/zig-node-polyglot.json b/tests/e2e/fixtures/ts/zig-node-polyglot.json new file mode 100644 index 0000000..2743f1d --- /dev/null +++ b/tests/e2e/fixtures/ts/zig-node-polyglot.json @@ -0,0 +1,192 @@ +{ + "default_image": "ubuntu:24.04", + "graph": { + "edge_property": "directed", + "edges": [ + [ + 0, + 1, + "builds_in" + ], + [ + 1, + 2, + "builds_in" + ], + [ + 1, + 3, + "builds_in" + ], + [ + 1, + 4, + "builds_in" + ], + [ + 1, + 5, + "builds_in" + ], + [ + 0, + 6, + "builds_in" + ], + [ + 6, + 7, + "builds_in" + ], + [ + 7, + 8, + "builds_in" + ], + [ + 7, + 9, + "builds_in" + ], + [ + 7, + 10, + "builds_in" + ] + ], + "node_holes": [], + "nodes": [ + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "duration_seconds": 86400, + "env_keys": [], + "policy": "ttl" + }, + "cmd": "apt-get update && apt-get install -y --no-install-recommends curl ca-certificates xz-utils", + "image": "ubuntu:24.04", + "key": "base", + "label": ":apt: base" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -fsSL https://ziglang.org/download/0.13.0/zig-linux-x86_64-0.13.0.tar.xz -o /tmp/zig.tar.xz && rm -rf /usr/local/zig && mkdir -p /usr/local/zig && tar -xJf /tmp/zig.tar.xz -C /usr/local/zig --strip-components=1 && ln -sf /usr/local/zig/zig /usr/local/bin/zig && zig version", + "key": "b589fbff75ec", + "label": ":zig: install" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd zig-a && zig build", + "key": "zig-a-build", + "label": ":zig: zig-a build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd zig-a && zig build test", + "key": "zig-a-test", + "label": ":zig: zig-a test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd zig-b && zig build", + "key": "zig-b-build", + "label": ":zig: zig-b build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd zig-b && zig build test", + "key": "zig-b-test", + "label": ":zig: zig-b test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "env_keys": [], + "policy": "forever" + }, + "cmd": "curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs", + "key": "e1242026cf31", + "label": ":node: install" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cache": { + "paths": [ + "web/package-lock.json" + ], + "policy": "on_change" + }, + "cmd": "cd web && npm ci", + "key": "deps", + "label": ":node: deps" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run build", + "key": "build", + "label": ":node: build" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run test", + "key": "test", + "label": ":node: test" + } + }, + { + "env": { + "CI": "true" + }, + "step": { + "cmd": "cd web && npm run lint", + "key": "lint", + "label": ":node: lint" + } + } + ] + }, + "version": "0" +} From c5b48a2ff8c03ae520bf3c86c7115c6607d07978 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 23:07:00 -0700 Subject: [PATCH 35/39] test: add Rust E2E deserialization + cross-DSL parity tests 14 tests: 8 per-scenario deserialization (4 Python + 4 TS), 1 structural invariant check, 5 parity assertions (node count, edge kinds, step labels, default image, env keys). --- crates/hm-pipeline-ir/tests/e2e_fixtures.rs | 250 ++++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 crates/hm-pipeline-ir/tests/e2e_fixtures.rs diff --git a/crates/hm-pipeline-ir/tests/e2e_fixtures.rs b/crates/hm-pipeline-ir/tests/e2e_fixtures.rs new file mode 100644 index 0000000..3258f78 --- /dev/null +++ b/crates/hm-pipeline-ir/tests/e2e_fixtures.rs @@ -0,0 +1,250 @@ +#![allow( + clippy::cargo_common_metadata, + clippy::multiple_crate_versions, + clippy::unwrap_used, + clippy::expect_used, + clippy::panic +)] + +use std::collections::BTreeSet; +use std::fs; +use std::path::PathBuf; + +use daggy::petgraph::visit::{EdgeRef, IntoNodeReferences}; +use hm_pipeline_ir::{EdgeKind, PipelineGraph}; + +const SCENARIOS: &[&str] = &[ + "monorepo-ci", + "rust-release", + "zig-node-polyglot", + "kitchen-sink", +]; + +fn fixtures_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/e2e/fixtures") +} + +fn load_fixture(dsl: &str, scenario: &str) -> PipelineGraph { + let path = fixtures_dir().join(dsl).join(format!("{scenario}.json")); + let bytes = + fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display())); + serde_json::from_slice(&bytes) + .unwrap_or_else(|e| panic!("parse {dsl}/{scenario}: {e}")) +} + +fn step_labels(g: &PipelineGraph) -> BTreeSet { + g.dag() + .graph() + .node_references() + .filter_map(|(_, t)| t.step.label.clone()) + .collect() +} + +fn edge_kinds(g: &PipelineGraph) -> (usize, usize) { + let mut builds_in = 0usize; + let mut depends_on = 0usize; + for e in g.dag().graph().edge_references() { + match e.weight() { + EdgeKind::BuildsIn => builds_in += 1, + EdgeKind::DependsOn => depends_on += 1, + } + } + (builds_in, depends_on) +} + +// ---- Python fixtures ---- + +#[test] +fn python_monorepo_ci() { + let g = load_fixture("python", "monorepo-ci"); + assert_eq!(g.default_image(), Some("ubuntu:24.04")); + assert!(g.node_count() >= 15, "nodes: {}", g.node_count()); + let labels = step_labels(&g); + assert!(labels.iter().any(|l| l.contains("go"))); + assert!(labels.iter().any(|l| l.contains("python") || l.contains("uv"))); + assert!(labels.iter().any(|l| l.contains("node") || l.contains("npm"))); +} + +#[test] +fn python_rust_release() { + let g = load_fixture("python", "rust-release"); + assert_eq!(g.default_image(), Some("ubuntu:24.04")); + assert!(g.node_count() >= 5, "nodes: {}", g.node_count()); + let labels = step_labels(&g); + assert!(labels.iter().any(|l| l.contains("rust"))); +} + +#[test] +fn python_zig_node_polyglot() { + let g = load_fixture("python", "zig-node-polyglot"); + assert_eq!(g.default_image(), Some("ubuntu:24.04")); + assert!(g.node_count() >= 10, "nodes: {}", g.node_count()); + let labels = step_labels(&g); + assert!(labels.iter().any(|l| l.contains("zig"))); + assert!(labels.iter().any(|l| l.contains("node") || l.contains("npm"))); +} + +#[test] +fn python_kitchen_sink() { + let g = load_fixture("python", "kitchen-sink"); + assert_eq!(g.default_image(), Some("ubuntu:24.04")); + assert!(g.node_count() >= 12, "nodes: {}", g.node_count()); + let labels = step_labels(&g); + assert!(labels.iter().any(|l| l.contains("haskell"))); + assert!(labels.iter().any(|l| l.contains("cmake") || l.contains(":c:"))); + for (_, t) in g.dag().graph().node_references() { + assert!(t.env.contains_key("CI"), "node {} missing CI env", t.step.key); + } +} + +// ---- TypeScript fixtures ---- + +#[test] +fn ts_monorepo_ci() { + let g = load_fixture("ts", "monorepo-ci"); + assert_eq!(g.default_image(), Some("ubuntu:24.04")); + assert!(g.node_count() >= 15); +} + +#[test] +fn ts_rust_release() { + let g = load_fixture("ts", "rust-release"); + assert_eq!(g.default_image(), Some("ubuntu:24.04")); + assert!(g.node_count() >= 5); +} + +#[test] +fn ts_zig_node_polyglot() { + let g = load_fixture("ts", "zig-node-polyglot"); + assert_eq!(g.default_image(), Some("ubuntu:24.04")); + assert!(g.node_count() >= 10); +} + +#[test] +fn ts_kitchen_sink() { + let g = load_fixture("ts", "kitchen-sink"); + assert_eq!(g.default_image(), Some("ubuntu:24.04")); + assert!(g.node_count() >= 12); +} + +// ---- Structural invariants on all fixtures ---- + +#[test] +fn all_fixtures_have_valid_structure() { + for dsl in ["python", "ts"] { + for scenario in SCENARIOS { + let g = load_fixture(dsl, scenario); + + for (_, t) in g.dag().graph().node_references() { + assert!(!t.step.key.is_empty(), "{dsl}/{scenario}: empty key"); + assert!( + !t.step.cmd.is_empty(), + "{dsl}/{scenario}: empty cmd for {}", + t.step.key, + ); + } + + let (bi, dep) = edge_kinds(&g); + assert!(bi + dep > 0, "{dsl}/{scenario}: no edges"); + + for e in g.dag().graph().edge_references() { + assert_ne!( + e.source(), + e.target(), + "{dsl}/{scenario}: self-loop", + ); + } + } + } +} + +// ---- Cross-DSL parity ---- + +#[test] +fn parity_node_count() { + for scenario in SCENARIOS { + let py = load_fixture("python", scenario); + let ts = load_fixture("ts", scenario); + assert_eq!( + py.node_count(), + ts.node_count(), + "parity/{scenario}: node count (py={}, ts={})", + py.node_count(), + ts.node_count(), + ); + } +} + +#[test] +fn parity_edge_kinds() { + for scenario in SCENARIOS { + let py = load_fixture("python", scenario); + let ts = load_fixture("ts", scenario); + let py_ek = edge_kinds(&py); + let ts_ek = edge_kinds(&ts); + assert_eq!( + py_ek, ts_ek, + "parity/{scenario}: edge kinds (py={py_ek:?}, ts={ts_ek:?})", + ); + } +} + +#[test] +fn parity_step_labels() { + for scenario in SCENARIOS { + let py = load_fixture("python", scenario); + let ts = load_fixture("ts", scenario); + let py_labels = step_labels(&py); + let ts_labels = step_labels(&ts); + assert_eq!( + py_labels, ts_labels, + "parity/{scenario}: labels\npy-only: {:?}\nts-only: {:?}", + py_labels.difference(&ts_labels).collect::>(), + ts_labels.difference(&py_labels).collect::>(), + ); + } +} + +#[test] +fn parity_default_image() { + for scenario in SCENARIOS { + let py = load_fixture("python", scenario); + let ts = load_fixture("ts", scenario); + assert_eq!( + py.default_image(), + ts.default_image(), + "parity/{scenario}: default_image", + ); + } +} + +#[test] +fn parity_env_keys() { + for scenario in SCENARIOS { + let py = load_fixture("python", scenario); + let ts = load_fixture("ts", scenario); + let py_labels = step_labels(&py); + let ts_labels = step_labels(&ts); + + for label in py_labels.intersection(&ts_labels) { + let py_env: BTreeSet<_> = py + .dag() + .graph() + .node_references() + .find(|(_, t)| t.step.label.as_deref() == Some(label)) + .map(|(_, t)| t.env.keys().cloned().collect()) + .unwrap(); + let ts_env: BTreeSet<_> = ts + .dag() + .graph() + .node_references() + .find(|(_, t)| t.step.label.as_deref() == Some(label)) + .map(|(_, t)| t.env.keys().cloned().collect()) + .unwrap(); + assert_eq!( + py_env, ts_env, + "parity/{scenario}/{label}: env keys", + ); + } + } +} From b392a863db09ef5f5835b939ac1c39686d85cf95 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 23:07:52 -0700 Subject: [PATCH 36/39] chore: accept schema snapshot after doc-comment additions --- ...snapshot__command_step_schema_is_stable.snap | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/hm-pipeline-ir/tests/snapshots/schema_snapshot__command_step_schema_is_stable.snap b/crates/hm-pipeline-ir/tests/snapshots/schema_snapshot__command_step_schema_is_stable.snap index 7198ebe..f3d4d5c 100644 --- a/crates/hm-pipeline-ir/tests/snapshots/schema_snapshot__command_step_schema_is_stable.snap +++ b/crates/hm-pipeline-ir/tests/snapshots/schema_snapshot__command_step_schema_is_stable.snap @@ -1,11 +1,11 @@ --- source: crates/hm-pipeline-ir/tests/schema_snapshot.rs -assertion_line: 6 expression: schema --- { "$schema": "http://json-schema.org/draft-07/schema#", "title": "CommandStep", + "description": "A single build command within a pipeline.\n\nSerialized as a JSON object inside each graph node's `step` field. The `key` is the unique identifier used to reference this step in edges and log output.", "type": "object", "required": [ "cmd", @@ -13,9 +13,11 @@ expression: schema ], "properties": { "key": { + "description": "Unique identifier for this step within the pipeline.", "type": "string" }, "label": { + "description": "Human-readable label shown in build output.", "default": null, "type": [ "string", @@ -23,9 +25,11 @@ expression: schema ] }, "cmd": { + "description": "Shell command to execute inside the container.", "type": "string" }, "image": { + "description": "Docker image to boot from. Root steps without an image inherit `PipelineGraph::default_image`; child steps boot from their parent's committed snapshot.", "default": null, "type": [ "string", @@ -33,6 +37,7 @@ expression: schema ] }, "env": { + "description": "Per-step environment variables merged on top of the pipeline env.", "default": null, "type": [ "object", @@ -43,6 +48,7 @@ expression: schema } }, "timeout_seconds": { + "description": "Maximum wall-clock seconds before the step is killed.", "default": null, "type": [ "integer", @@ -52,6 +58,7 @@ expression: schema "minimum": 0.0 }, "cache": { + "description": "Cache configuration for this step's committed snapshot.", "default": null, "anyOf": [ { @@ -63,24 +70,30 @@ expression: schema ] }, "runner": { + "description": "Step-executor plugin name. `None` falls back to the default runner (Docker in the shipped configuration).", "type": [ "string", "null" ] }, - "runner_args": true + "runner_args": { + "description": "Plugin-specific extra fields passed verbatim to the runner." + } }, "definitions": { "Cache": { + "description": "Snapshot cache configuration for a step.", "type": "object", "required": [ "policy" ], "properties": { "policy": { + "description": "Cache policy name (e.g. `\"content-hash\"`).", "type": "string" }, "key": { + "description": "Explicit cache key override; derived from the step if absent.", "default": null, "type": [ "string", From 51fa6cefc23951c37800cfd0bb3a1eec1d7b3557 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 23:12:36 -0700 Subject: [PATCH 37/39] fix: Python Haskell toolchain emit glob patterns instead of resolving The package() method was resolving *.cabal globs against the local filesystem at DSL evaluation time, producing empty paths when the dirs don't exist. Now emits pattern strings like the TS toolchain does. Also removes unused imports in TS e2e test. --- dsls/harmont-py/harmont/haskell.py | 6 +----- dsls/harmont-ts/tests/e2e-fixtures.test.ts | 4 ++-- tests/e2e/fixtures/python/kitchen-sink.json | 10 ++++++++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/dsls/harmont-py/harmont/haskell.py b/dsls/harmont-py/harmont/haskell.py index 810133c..9e51a83 100644 --- a/dsls/harmont-py/harmont/haskell.py +++ b/dsls/harmont-py/harmont/haskell.py @@ -19,7 +19,6 @@ import re from dataclasses import dataclass -from pathlib import Path from typing import TYPE_CHECKING, Any, overload from ._toolchain import make_install_chain @@ -130,10 +129,7 @@ def package( if cache_paths is not None: paths = cache_paths else: - paths = ( - tuple(sorted(p.as_posix() for p in Path(path).glob("*.cabal"))) - + ((f"{path}/cabal.project",) if Path(path, "cabal.project").exists() else ()) - ) + paths = (f"{path}/*.cabal", f"{path}/cabal.project") deps = self.installed.sh( f"cabal update && cd {path} && cabal build all --only-dependencies", label=f":haskell: {path} deps", diff --git a/dsls/harmont-ts/tests/e2e-fixtures.test.ts b/dsls/harmont-ts/tests/e2e-fixtures.test.ts index 8f70b55..d2ca635 100644 --- a/dsls/harmont-ts/tests/e2e-fixtures.test.ts +++ b/dsls/harmont-ts/tests/e2e-fixtures.test.ts @@ -4,8 +4,8 @@ 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, scratch } from "../src/step.js"; -import { ttl, forever } from "../src/cache.js"; +import { sh } from "../src/step.js"; +import { ttl } from "../src/cache.js"; import { go } from "../src/toolchains/go.js"; import { python } from "../src/toolchains/python.js"; import { npm } from "../src/toolchains/npm.js"; diff --git a/tests/e2e/fixtures/python/kitchen-sink.json b/tests/e2e/fixtures/python/kitchen-sink.json index 092f280..49cb00f 100644 --- a/tests/e2e/fixtures/python/kitchen-sink.json +++ b/tests/e2e/fixtures/python/kitchen-sink.json @@ -110,7 +110,10 @@ }, "step": { "cache": { - "paths": [], + "paths": [ + "pkg-a/*.cabal", + "pkg-a/cabal.project" + ], "policy": "on_change" }, "cmd": "cabal update && cd pkg-a && cabal build all --only-dependencies", @@ -147,7 +150,10 @@ }, "step": { "cache": { - "paths": [], + "paths": [ + "pkg-b/*.cabal", + "pkg-b/cabal.project" + ], "policy": "on_change" }, "cmd": "cabal update && cd pkg-b && cabal build all --only-dependencies", From 984e9217302e7a2ea4616b97c4f1a1842332de12 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 23:26:21 -0700 Subject: [PATCH 38/39] fix: suppress ruff S108 on test fixture string --- dsls/harmont-py/tests/test_e2e_fixtures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dsls/harmont-py/tests/test_e2e_fixtures.py b/dsls/harmont-py/tests/test_e2e_fixtures.py index 97331bf..eb48195 100644 --- a/dsls/harmont-py/tests/test_e2e_fixtures.py +++ b/dsls/harmont-py/tests/test_e2e_fixtures.py @@ -126,7 +126,7 @@ def _build_kitchen_sink() -> dict: c_project.build(), c_project.test(), c_project.fmt(), - env={"CI": "true", "STACK_ROOT": "/tmp/.stack"}, + env={"CI": "true", "STACK_ROOT": "/tmp/.stack"}, # noqa: S108 default_image="ubuntu:24.04", ) From d90e03e278df93f8f5ba65e044fb2dbefcc68292 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Sat, 23 May 2026 23:29:59 -0700 Subject: [PATCH 39/39] fix: keygen resolves glob patterns in on_change paths The on_change handler now expands glob patterns (*, ?, [) via Path.glob() and silently skips non-existent literal paths. This supports toolchains emitting declarative patterns instead of resolving them at DSL eval time. --- dsls/harmont-py/harmont/keygen.py | 11 +++++++++-- dsls/harmont-py/tests/test_haskell.py | 4 ++-- dsls/harmont-py/tests/test_keygen.py | 20 ++++++++++---------- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/dsls/harmont-py/harmont/keygen.py b/dsls/harmont-py/harmont/keygen.py index 5fba539..f7285c3 100644 --- a/dsls/harmont-py/harmont/keygen.py +++ b/dsls/harmont-py/harmont/keygen.py @@ -113,8 +113,15 @@ def _resolve_policy( env_keys = policy.get("env_keys", []) return "ttl-" + str(bucket) + "-" + _sha256_hex(cmd + NUL + _env_subset(env_keys, env)) if kind == "on_change": - paths = sorted(policy["paths"]) - pre = "".join(_path_hash(base_path / p) + NUL for p in paths) + resolved: list[Path] = [] + for p in sorted(policy["paths"]): + if any(c in p for c in ("*", "?", "[")): + resolved.extend(sorted(base_path.glob(p))) + else: + full = base_path / p + if full.exists(): + resolved.append(full) + pre = "".join(_path_hash(r) + NUL for r in resolved) return "sha-" + _sha256_hex(pre) if kind == "compose": subs = policy["sub_policies"] diff --git a/dsls/harmont-py/tests/test_haskell.py b/dsls/harmont-py/tests/test_haskell.py index 7833b1c..f832f15 100644 --- a/dsls/harmont-py/tests/test_haskell.py +++ b/dsls/harmont-py/tests/test_haskell.py @@ -80,7 +80,7 @@ def test_haskell_package_deps_cache_default(): p = hm.pipeline(api.test()) deps = _step_by_substring(p, "cabal build all --only-dependencies") assert deps["cache"]["policy"] == "on_change" - assert deps["cache"]["paths"] == ["api/harmont-api.cabal", "api/cabal.project"] + assert deps["cache"]["paths"] == ["api/*.cabal", "api/cabal.project"] def test_haskell_package_deps_cache_default_no_cabal_project(): @@ -89,7 +89,7 @@ def test_haskell_package_deps_cache_default_no_cabal_project(): p = hm.pipeline(fs.test()) deps = _step_by_substring(p, "cabal build all --only-dependencies") assert deps["cache"]["policy"] == "on_change" - assert deps["cache"]["paths"] == ["freestyle/freestyle.cabal"] + assert deps["cache"]["paths"] == ["freestyle/*.cabal", "freestyle/cabal.project"] def test_haskell_package_deps_cache_explicit_paths(): diff --git a/dsls/harmont-py/tests/test_keygen.py b/dsls/harmont-py/tests/test_keygen.py index 4b56693..c889dde 100644 --- a/dsls/harmont-py/tests/test_keygen.py +++ b/dsls/harmont-py/tests/test_keygen.py @@ -209,7 +209,7 @@ def test_on_change_handles_directory_paths(): assert out2["nodes"][0]["step"]["cache"]["key"] != key1 -def test_on_change_missing_path_raises(): +def test_on_change_missing_path_skipped(): with tempfile.TemporaryDirectory() as d: graph = _make_graph([ { @@ -221,15 +221,15 @@ def test_on_change_missing_path_raises(): "env": {}, }, ]) - with pytest.raises(FileNotFoundError, match="on_change path does not exist"): - resolve_pipeline_keys( - graph, - pipeline_org="default", - pipeline_slug="default", - now=0, - base_path=Path(d), - env={}, - ) + resolve_pipeline_keys( + graph, + pipeline_org="default", + pipeline_slug="default", + now=0, + base_path=Path(d), + env={}, + ) + assert graph["nodes"][0]["step"]["cache"]["key"] is not None def test_env_keys_are_sorted_and_picked_up():