From f6114c3f7378f40ff446f0a34d3df573569bf3eb Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Mon, 14 Sep 2026 15:14:08 -0400 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=91=B7=20Validate=20the=20Rust=20host?= =?UTF-8?q?=20bridge=20in=20CI:=20guards,=20published=20gem,=20and=20dep-h?= =?UTF-8?q?eads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust host bridge specs require structuredmerge_host_prototype, a native gem from the separate structuredmerge-rust repo that is not published, so tree_haver, ast-template and ast-merge-git suites failed whenever selected. - Skip host-dependent examples when the host is unavailable (common case). - Add a CI-only STRUCTUREDMERGE_RUST_HOST_PUBLISHED switch to the 12 host Gemfiles that requires the published gem. The Gem suite (current) and a new Coverage job set it, so they represent what is available today and fail until the gem is released. - Add Rust host @ HEAD (builds the x86_64-linux platform gem from structuredmerge-rust HEAD) and Deps @ HEAD (runs each changed gem against that host plus its runtime_heads dependencies) jobs, and include all three new jobs in Check. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01P8aveNcdgPobyMrq8DkD9r --- .github/workflows/current.yml | 266 +++++++++++++++++- gems/ast-crispr/Gemfile | 6 + gems/ast-merge-git/Gemfile | 6 + .../spec/real_git_integration_spec.rb | 2 + gems/ast-template/Gemfile | 6 + .../integration/rust_host_provider_spec.rb | 126 +++++---- gems/bash-merge/Gemfile | 6 + gems/go-merge/Gemfile | 6 + gems/json-merge/Gemfile | 6 + gems/kettle-jem/Gemfile | 6 + gems/markdown-merge/Gemfile | 6 + gems/rust-merge/Gemfile | 6 + gems/tree_haver/Gemfile | 6 + .../rust_tslp_json5_contract_spec.rb | 2 + .../rust_tslp_language_matrix_spec.rb | 2 + gems/typescript-merge/Gemfile | 6 + gems/yaml-merge/Gemfile | 6 + 17 files changed, 405 insertions(+), 65 deletions(-) diff --git a/.github/workflows/current.yml b/.github/workflows/current.yml index 80fd19d78..dbd15ad17 100644 --- a/.github/workflows/current.yml +++ b/.github/workflows/current.yml @@ -149,6 +149,9 @@ jobs: env: K_JEM_TEMPLATING: true STRUCTUREDMERGE_DEV: ${{ github.workspace }}/gems + # Current represents what is available today: require the published + # Rust host gem, which fails until structuredmerge_host_prototype is released. + STRUCTUREDMERGE_RUST_HOST_PUBLISHED: true run: | bundle config set frozen false for attempt in 1 2 3; do @@ -168,8 +171,243 @@ jobs: K_JEM_TEMPLATING: true K_SOUP_COV_DO: false STRUCTUREDMERGE_DEV: ${{ github.workspace }}/gems + STRUCTUREDMERGE_RUST_HOST_PUBLISHED: true run: timeout 12m bundle exec kettle-test + rust-host: + name: Rust host @ HEAD + needs: detect + if: ${{ needs.detect.outputs.count != '0' && needs.detect.outputs.release_mode == '' }} + runs-on: ubuntu-latest + steps: + - name: Clone structuredmerge-rust HEAD + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://github.com/structuredmerge/structuredmerge-rust.git "${RUNNER_TEMP}/structuredmerge-rust" + echo "structuredmerge-rust HEAD: $(git -C "${RUNNER_TEMP}/structuredmerge-rust" rev-parse HEAD)" + - name: Setup Rust + uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: stable + - name: Setup Ruby + uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1.314.0 + with: + ruby-version: ruby + bundler-cache: false + - name: Install Rust host package dependencies + shell: bash + working-directory: ${{ runner.temp }}/structuredmerge-rust/packages/ruby + run: bundle install --jobs 4 --retry 8 + - name: Install Alef + shell: bash + working-directory: ${{ runner.temp }}/structuredmerge-rust + run: | + cargo install --git https://github.com/structuredmerge/alef.git \ + --rev "$(cat workspace-scripts/alef-source-revision)" --locked + - name: Build platform gem + shell: bash + working-directory: ${{ runner.temp }}/structuredmerge-rust + env: + CARGO_BUILD_TARGET: x86_64-unknown-linux-gnu + CARGO_FEATURE_LINK_RUBY: "1" + PLATFORM: x86_64-linux + RUST_TARGET: x86_64-unknown-linux-gnu + TARGET: x86_64-unknown-linux-gnu + run: | + set -euo pipefail + mkdir -p dist + alef publish build --lang ruby + alef publish package --lang ruby --target "${TARGET}" --output dist + test "$(find dist -maxdepth 1 -name "*-${PLATFORM}.gem" -print | wc -l)" -eq 1 + - name: Upload Rust host gem + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: structuredmerge-host-prototype-head + path: ${{ runner.temp }}/structuredmerge-rust/dist/*-x86_64-linux.gem + if-no-files-found: error + retention-days: 7 + + dep-heads: + name: Deps @ HEAD (${{ matrix.gem }}) + needs: + - detect + - rust-host + if: ${{ needs.detect.outputs.count != '0' && needs.detect.outputs.release_mode == '' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.detect.outputs.matrix) }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Clone sibling fixtures repo + shell: bash + run: | + set -euo pipefail + fixtures_repository="$(ruby -ryaml -e 'puts YAML.load_file(".structuredmerge/fixtures.lock").fetch("repository")')" + fixtures_tag="$(ruby -ryaml -e 'puts YAML.load_file(".structuredmerge/fixtures.lock").fetch("tag")')" + fixtures_revision="$(ruby -ryaml -e 'puts YAML.load_file(".structuredmerge/fixtures.lock").fetch("revision")')" + git clone --filter=blob:none --no-checkout "${fixtures_repository}" ../fixtures + git -C ../fixtures fetch --depth 1 origin "refs/tags/${fixtures_tag}:refs/tags/${fixtures_tag}" + git -C ../fixtures checkout --detach "${fixtures_revision}" + test "$(git -C ../fixtures rev-parse HEAD)" = "${fixtures_revision}" + test "$(git -C ../fixtures rev-parse "refs/tags/${fixtures_tag}^{commit}")" = "${fixtures_revision}" + - name: Download Rust host gem + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: structuredmerge-host-prototype-head + path: ${{ runner.temp }}/structuredmerge-host + - name: Setup Ruby + uses: appraisal-rb/setup-ruby-flash@925395edf973d2dc0a629919f407f3547a03d4b5 # v2.1 + with: + ruby-version: ruby + bundler-cache: false + working-directory: ${{ matrix.gem }} + pre-bundle-gems: | + nomono --source https://gem.coop --source https://rubygems.org + - name: Unpack Rust host gem + shell: bash + run: | + set -euo pipefail + host_root="${RUNNER_TEMP}/structuredmerge-host" + gem_file="$(find "${host_root}" -maxdepth 1 -name '*-x86_64-linux.gem' -print -quit)" + test -n "${gem_file}" + gem unpack "${gem_file}" --target "${host_root}/unpacked" + host_dir="$(find "${host_root}/unpacked" -mindepth 1 -maxdepth 1 -type d -print -quit)" + gem specification "${gem_file}" --ruby > "${host_dir}/structuredmerge_host_prototype.gemspec" + echo "STRUCTUREDMERGE_RUST_HOST_DIR=${host_dir}" >> "${GITHUB_ENV}" + - name: Compose dep-heads bundle + shell: bash + working-directory: ${{ matrix.gem }} + run: | + set -euo pipefail + cat > dep_heads.gemfile <<'RUBY' + # frozen_string_literal: true + + # CI-only dep-heads bundle: the Rust host built from structuredmerge-rust HEAD, + # this gem's Gemfile, and the HEAD versions of its runtime dependencies. + # Like the dep-heads appraisal, HEAD declarations replace the Gemfile's + # released declarations of the same gems instead of conflicting with them. + runtime_heads = File.expand_path('gemfiles/modular/runtime_heads.gemfile', __dir__) + head_gem_names = Bundler::Dsl.new.tap { |dsl| dsl.eval_gemfile(runtime_heads) }.dependencies.map(&:name) + + gem 'structuredmerge_host_prototype', path: ENV.fetch('STRUCTUREDMERGE_RUST_HOST_DIR') + eval_gemfile File.expand_path('Gemfile', __dir__) + instance_variable_get(:@dependencies).reject! { |dependency| head_gem_names.include?(dependency.name) } + eval_gemfile runtime_heads + RUBY + - name: Install dependencies + working-directory: ${{ matrix.gem }} + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gem }}/dep_heads.gemfile + K_JEM_TEMPLATING: true + # Bundle for spec subprocesses (e.g. ast-merge-git Git drivers) that reset BUNDLE_GEMFILE. + KETTLE_FAMILY_BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gem }}/dep_heads.gemfile + STRUCTUREDMERGE_DEV: ${{ github.workspace }}/gems + run: | + bundle config set frozen false + for attempt in 1 2 3; do + if bundle install --jobs 4 --retry 8; then + exit 0 + fi + + if [[ "${attempt}" -lt 3 ]]; then + sleep "$((attempt * 5))" + fi + done + + exit 1 + - name: Run checks + working-directory: ${{ matrix.gem }} + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gem }}/dep_heads.gemfile + K_JEM_TEMPLATING: true + K_SOUP_COV_DO: false + KETTLE_FAMILY_BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gem }}/dep_heads.gemfile + STRUCTUREDMERGE_DEV: ${{ github.workspace }}/gems + run: timeout 12m bundle exec kettle-test + + coverage: + name: Coverage (${{ matrix.gem }}) + needs: detect + if: ${{ needs.detect.outputs.count != '0' && needs.detect.outputs.release_mode == '' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.detect.outputs.matrix) }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Clone sibling fixtures repo + shell: bash + run: | + set -euo pipefail + fixtures_repository="$(ruby -ryaml -e 'puts YAML.load_file(".structuredmerge/fixtures.lock").fetch("repository")')" + fixtures_tag="$(ruby -ryaml -e 'puts YAML.load_file(".structuredmerge/fixtures.lock").fetch("tag")')" + fixtures_revision="$(ruby -ryaml -e 'puts YAML.load_file(".structuredmerge/fixtures.lock").fetch("revision")')" + git clone --filter=blob:none --no-checkout "${fixtures_repository}" ../fixtures + git -C ../fixtures fetch --depth 1 origin "refs/tags/${fixtures_tag}:refs/tags/${fixtures_tag}" + git -C ../fixtures checkout --detach "${fixtures_revision}" + test "$(git -C ../fixtures rev-parse HEAD)" = "${fixtures_revision}" + test "$(git -C ../fixtures rev-parse "refs/tags/${fixtures_tag}^{commit}")" = "${fixtures_revision}" + - name: Setup Ruby + uses: appraisal-rb/setup-ruby-flash@925395edf973d2dc0a629919f407f3547a03d4b5 # v2.1 + with: + ruby-version: ruby + bundler-cache: false + working-directory: ${{ matrix.gem }} + pre-bundle-gems: | + nomono --source https://gem.coop --source https://rubygems.org + - name: Install dependencies + working-directory: ${{ matrix.gem }} + env: + K_JEM_TEMPLATING: true + STRUCTUREDMERGE_DEV: ${{ github.workspace }}/gems + # Coverage runs against the published Rust host gem, for maximum coverage once it + # is released; until then this fails like current. + STRUCTUREDMERGE_RUST_HOST_PUBLISHED: true + run: | + bundle config set frozen false + for attempt in 1 2 3; do + if bundle install --jobs 4 --retry 8; then + exit 0 + fi + + if [[ "${attempt}" -lt 3 ]]; then + sleep "$((attempt * 5))" + fi + done + + exit 1 + - name: Load coverage thresholds + shell: bash + working-directory: ${{ matrix.gem }} + run: | + set -euo pipefail + for key in K_SOUP_COV_MIN_BRANCH K_SOUP_COV_MIN_LINE K_SOUP_COV_MIN_HARD; do + value="$(sed -nE "s/^${key}[[:space:]]*=[[:space:]]*\"([^\"]*)\".*/\\1/p" mise.toml 2>/dev/null | head -1)" + if [[ -n "${value}" ]]; then + echo "${key}=${value}" >> "${GITHUB_ENV}" + fi + done + - name: Run checks with coverage + working-directory: ${{ matrix.gem }} + env: + K_JEM_TEMPLATING: true + K_SOUP_COV_COMMAND_NAME: Test Coverage + K_SOUP_COV_DO: true + K_SOUP_COV_FORMATTERS: xml,rcov,lcov,tty + K_SOUP_COV_MULTI_FORMATTERS: true + STRUCTUREDMERGE_DEV: ${{ github.workspace }}/gems + STRUCTUREDMERGE_RUST_HOST_PUBLISHED: true + run: timeout 20m bundle exec kettle-test + - name: Verify coverage reports + working-directory: ${{ matrix.gem }} + run: | + test -s coverage/lcov.info + test -s coverage/coverage.xml + family-suite: name: StructuredMerge family suite needs: detect @@ -424,6 +662,9 @@ jobs: needs: - detect - gem-suite + - rust-host + - dep-heads + - coverage - family-suite - benchmark-dev - rbs @@ -438,6 +679,9 @@ jobs: RELEASE_MODE: ${{ needs.detect.outputs.release_mode }} DETECT_RESULT: ${{ needs.detect.result }} GEM_SUITE_RESULT: ${{ needs.gem-suite.result }} + RUST_HOST_RESULT: ${{ needs.rust-host.result }} + DEP_HEADS_RESULT: ${{ needs.dep-heads.result }} + COVERAGE_RESULT: ${{ needs.coverage.result }} FAMILY_SUITE_RESULT: ${{ needs.family-suite.result }} BENCHMARK_DEV_RESULT: ${{ needs.benchmark-dev.result }} RBS_RESULT: ${{ needs.rbs.result }} @@ -447,6 +691,9 @@ jobs: echo "Selected gem suites: ${SELECTED_GEMS}" echo "Detect result: ${DETECT_RESULT}" echo "Gem suite result: ${GEM_SUITE_RESULT}" + echo "Rust host @ HEAD result: ${RUST_HOST_RESULT}" + echo "Deps @ HEAD result: ${DEP_HEADS_RESULT}" + echo "Coverage result: ${COVERAGE_RESULT}" echo "Family suite result: ${FAMILY_SUITE_RESULT}" echo "Dev benchmark result: ${BENCHMARK_DEV_RESULT}" echo "RBS result: ${RBS_RESULT}" @@ -462,6 +709,9 @@ jobs: if [[ "${FAMILY_SUITE_RESULT}" == "success" ]]; then [[ "${GEM_SUITE_RESULT}" == "skipped" ]] || exit 1 + [[ "${RUST_HOST_RESULT}" == "skipped" ]] || exit 1 + [[ "${DEP_HEADS_RESULT}" == "skipped" ]] || exit 1 + [[ "${COVERAGE_RESULT}" == "skipped" ]] || exit 1 [[ "${RBS_RESULT}" == "skipped" ]] || exit 1 [[ "${BENCHMARK_DEV_RESULT}" == "skipped" ]] || exit 1 [[ "${TSLP_COLD_CACHE_RESULT}" == "skipped" ]] || exit 1 @@ -472,10 +722,12 @@ jobs: [[ "${result}" == "success" ]] || exit 1 done - case "${GEM_SUITE_RESULT}" in - success|skipped) - ;; - *) - exit 1 - ;; - esac + for result in "${GEM_SUITE_RESULT}" "${RUST_HOST_RESULT}" "${DEP_HEADS_RESULT}" "${COVERAGE_RESULT}"; do + case "${result}" in + success|skipped) + ;; + *) + exit 1 + ;; + esac + done diff --git a/gems/ast-crispr/Gemfile b/gems/ast-crispr/Gemfile index 94a0c98af..b467db17b 100644 --- a/gems/ast-crispr/Gemfile +++ b/gems/ast-crispr/Gemfile @@ -26,6 +26,12 @@ if !structuredmerge_rust_dev.empty? && !%w[false 0 no off].include?(structuredme gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') end +# CI current and coverage jobs require the published Rust host gem; this fails until it is released. +if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && + instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } + gem 'structuredmerge_host_prototype', '~> 0.2' +end + # Local workspace dependency wiring for *_local.gemfile overrides gem 'nomono', '~> 1.1', '>= 1.1.5', require: false # ruby >= 3.2.0 diff --git a/gems/ast-merge-git/Gemfile b/gems/ast-merge-git/Gemfile index 44c1b6197..cdec3f5c8 100644 --- a/gems/ast-merge-git/Gemfile +++ b/gems/ast-merge-git/Gemfile @@ -13,6 +13,12 @@ if !structuredmerge_rust_dev.empty? && !%w[false 0 no off].include?(structuredme gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') end +# CI current and coverage jobs require the published Rust host gem; this fails until it is released. +if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && + instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } + gem 'structuredmerge_host_prototype', '~> 0.2' +end + git_source(:codeberg) { |repo_name| "https://codeberg.org/#{repo_name}" } git_source(:gitlab) { |repo_name| "https://gitlab.com/#{repo_name}" } diff --git a/gems/ast-merge-git/spec/real_git_integration_spec.rb b/gems/ast-merge-git/spec/real_git_integration_spec.rb index dd312d208..a1382358b 100644 --- a/gems/ast-merge-git/spec/real_git_integration_spec.rb +++ b/gems/ast-merge-git/spec/real_git_integration_spec.rb @@ -556,6 +556,8 @@ def text_git_baseline(base:, ours:, theirs:) } ].each do |provider| it "runs the explicit #{provider.fetch(:label)} selector through the installed Git-driver path" do + skip 'compiled Rust host is unavailable' unless Ast::Merge::Git::RustHostProvider.available? + base = provider.fetch(:source) ours = provider.fetch(:ours) theirs = provider.fetch(:theirs) diff --git a/gems/ast-template/Gemfile b/gems/ast-template/Gemfile index 7009c936b..3dcb46b5a 100644 --- a/gems/ast-template/Gemfile +++ b/gems/ast-template/Gemfile @@ -34,6 +34,12 @@ if !structuredmerge_rust_dev.empty? && !%w[false 0 no off].include?(structuredme gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') end +# CI current and coverage jobs require the published Rust host gem; this fails until it is released. +if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && + instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } + gem 'structuredmerge_host_prototype', '~> 0.2' +end + # The default adapter registry is exercised by the release suite with local # sibling wiring disabled, so its released adapter dependencies must be present # in the ordinary development bundle as well as in the templating bundle. diff --git a/gems/ast-template/spec/integration/rust_host_provider_spec.rb b/gems/ast-template/spec/integration/rust_host_provider_spec.rb index 21a32cad1..fce221b32 100644 --- a/gems/ast-template/spec/integration/rust_host_provider_spec.rb +++ b/gems/ast-template/spec/integration/rust_host_provider_spec.rb @@ -3,6 +3,12 @@ RSpec.describe Ast::Template::RustHostProvider do describe '.available?' do it 'reports the generated host bridge when loaded' do + begin + require 'structuredmerge_host_prototype' + rescue LoadError + skip 'compiled Rust host is unavailable' + end + expect(described_class).to be_available end end @@ -15,74 +21,78 @@ ) end - it 'reports missing roots through the portable options contract' do - report = described_class.new.report( - kind: 'options', - options: { - mode: 'plan', - template_root: '', - destination_root: '', - context: {}, - default_strategy: 'merge', - overrides: [], - replacements: {}, - allowed_families: nil - } - ) - - expect(report[:request_kind]).to eq('options') - expect(report[:ready]).to be(false) - expect(report[:diagnostics].map { |entry| entry['reason'] }).to eq( - %w[missing_destination_root missing_template_root] - ) - end - - it 'reports an unknown profile through the portable profile contract' do - report = described_class.new.report( - kind: 'profile', - profile_name: 'missing', - profiles: {}, - options: { - mode: 'plan', - template_root: '/templates', - destination_root: '/destination', - context: {}, - default_strategy: 'merge', - overrides: [], - replacements: {}, - allowed_families: nil - } - ) + context 'with the compiled Rust host' do + before { skip 'compiled Rust host is unavailable' unless described_class.available? } - expect(report[:request_kind]).to eq('profile') - expect(report[:ready]).to be(false) - expect(report[:diagnostics].map { |entry| entry['reason'] }).to include('missing_profile') - end + it 'reports missing roots through the portable options contract' do + report = described_class.new.report( + kind: 'options', + options: { + mode: 'plan', + template_root: '', + destination_root: '', + context: {}, + default_strategy: 'merge', + overrides: [], + replacements: {}, + allowed_families: nil + } + ) - it 'returns a read-only Rust template plan without claiming filesystem execution' do - Dir.mktmpdir('ast-template-rust-plan') do |root| - template_root = File.join(root, 'templates') - destination_root = File.join(root, 'destination') - FileUtils.mkdir_p(template_root) - FileUtils.mkdir_p(destination_root) - File.write(File.join(template_root, 'README.md'), "# {{PACKAGE_NAME}}\n") + expect(report[:request_kind]).to eq('options') + expect(report[:ready]).to be(false) + expect(report[:diagnostics].map { |entry| entry['reason'] }).to eq( + %w[missing_destination_root missing_template_root] + ) + end - report = described_class.new.plan( + it 'reports an unknown profile through the portable profile contract' do + report = described_class.new.report( + kind: 'profile', + profile_name: 'missing', + profiles: {}, options: { mode: 'plan', - template_root: template_root, - destination_root: destination_root, - context: { project_name: 'widget' }, - default_strategy: 'raw_copy', + template_root: '/templates', + destination_root: '/destination', + context: {}, + default_strategy: 'merge', overrides: [], - replacements: { 'PACKAGE_NAME' => 'widget' }, + replacements: {}, allowed_families: nil } ) - expect(report[:mode]).to eq('plan') - expect(report.dig(:runner_report, 'plan_report', 'entries')).not_to be_empty - expect(File.exist?(File.join(destination_root, 'README.md'))).to be(false) + expect(report[:request_kind]).to eq('profile') + expect(report[:ready]).to be(false) + expect(report[:diagnostics].map { |entry| entry['reason'] }).to include('missing_profile') + end + + it 'returns a read-only Rust template plan without claiming filesystem execution' do + Dir.mktmpdir('ast-template-rust-plan') do |root| + template_root = File.join(root, 'templates') + destination_root = File.join(root, 'destination') + FileUtils.mkdir_p(template_root) + FileUtils.mkdir_p(destination_root) + File.write(File.join(template_root, 'README.md'), "# {{PACKAGE_NAME}}\n") + + report = described_class.new.plan( + options: { + mode: 'plan', + template_root: template_root, + destination_root: destination_root, + context: { project_name: 'widget' }, + default_strategy: 'raw_copy', + overrides: [], + replacements: { 'PACKAGE_NAME' => 'widget' }, + allowed_families: nil + } + ) + + expect(report[:mode]).to eq('plan') + expect(report.dig(:runner_report, 'plan_report', 'entries')).not_to be_empty + expect(File.exist?(File.join(destination_root, 'README.md'))).to be(false) + end end end end diff --git a/gems/bash-merge/Gemfile b/gems/bash-merge/Gemfile index cf142c43f..26874c328 100644 --- a/gems/bash-merge/Gemfile +++ b/gems/bash-merge/Gemfile @@ -23,6 +23,12 @@ unless structuredmerge_rust_dev.empty? || %w[false 0 no off].include?(structured gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') end +# CI current and coverage jobs require the published Rust host gem; this fails until it is released. +if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && + instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } + gem 'structuredmerge_host_prototype', '~> 0.2' +end + gem 'kettle-family', '~> 1.2', '>= 1.2.100' # Local workspace dependency wiring for *_local.gemfile overrides diff --git a/gems/go-merge/Gemfile b/gems/go-merge/Gemfile index d8932e574..3ba8fe33b 100644 --- a/gems/go-merge/Gemfile +++ b/gems/go-merge/Gemfile @@ -23,6 +23,12 @@ unless structuredmerge_rust_dev.empty? || %w[false 0 no off].include?(structured gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') end +# CI current and coverage jobs require the published Rust host gem; this fails until it is released. +if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && + instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } + gem 'structuredmerge_host_prototype', '~> 0.2' +end + gem 'kettle-family', '~> 1.2', '>= 1.2.100' # Local workspace dependency wiring for *_local.gemfile overrides diff --git a/gems/json-merge/Gemfile b/gems/json-merge/Gemfile index 97a5edfbb..16d2dba24 100644 --- a/gems/json-merge/Gemfile +++ b/gems/json-merge/Gemfile @@ -23,6 +23,12 @@ unless structuredmerge_rust_dev.empty? || %w[false 0 no off].include?(structured gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') end +# CI current and coverage jobs require the published Rust host gem; this fails until it is released. +if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && + instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } + gem 'structuredmerge_host_prototype', '~> 0.2' +end + gem 'kettle-family', '~> 1.2', '>= 1.2.100' # Local workspace dependency wiring for *_local.gemfile overrides diff --git a/gems/kettle-jem/Gemfile b/gems/kettle-jem/Gemfile index 98ed8cd79..63691c8bb 100644 --- a/gems/kettle-jem/Gemfile +++ b/gems/kettle-jem/Gemfile @@ -23,6 +23,12 @@ unless structuredmerge_rust_dev.empty? || %w[false 0 no off].include?(structured gem "structuredmerge_host_prototype", path: File.join(structuredmerge_rust_dev, "packages/ruby") end +# CI current and coverage jobs require the published Rust host gem; this fails until it is released. +if ENV.fetch("STRUCTUREDMERGE_RUST_HOST_PUBLISHED", "false").casecmp("true").zero? && + instance_variable_get(:@dependencies).none? { |dependency| dependency.name == "structuredmerge_host_prototype" } + gem "structuredmerge_host_prototype", "~> 0.2" +end + gem "kettle-family", "~> 1.2", ">= 1.2.100" # Local workspace dependency wiring for *_local.gemfile overrides diff --git a/gems/markdown-merge/Gemfile b/gems/markdown-merge/Gemfile index 36d88df4e..ccc2b7293 100644 --- a/gems/markdown-merge/Gemfile +++ b/gems/markdown-merge/Gemfile @@ -23,6 +23,12 @@ unless structuredmerge_rust_dev.empty? || %w[false 0 no off].include?(structured gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') end +# CI current and coverage jobs require the published Rust host gem; this fails until it is released. +if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && + instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } + gem 'structuredmerge_host_prototype', '~> 0.2' +end + gem 'kettle-family', '~> 1.2', '>= 1.2.100' # Local workspace dependency wiring for *_local.gemfile overrides diff --git a/gems/rust-merge/Gemfile b/gems/rust-merge/Gemfile index a242ffd70..2bd240f4a 100644 --- a/gems/rust-merge/Gemfile +++ b/gems/rust-merge/Gemfile @@ -23,6 +23,12 @@ unless structuredmerge_rust_dev.empty? || %w[false 0 no off].include?(structured gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') end +# CI current and coverage jobs require the published Rust host gem; this fails until it is released. +if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && + instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } + gem 'structuredmerge_host_prototype', '~> 0.2' +end + gem 'kettle-family', '~> 1.2', '>= 1.2.100' # Local workspace dependency wiring for *_local.gemfile overrides diff --git a/gems/tree_haver/Gemfile b/gems/tree_haver/Gemfile index dd418b3a6..93d226ccc 100644 --- a/gems/tree_haver/Gemfile +++ b/gems/tree_haver/Gemfile @@ -23,6 +23,12 @@ unless structuredmerge_rust_dev.empty? || %w[false 0 no off].include?(structured gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') end +# CI current and coverage jobs require the published Rust host gem; this fails until it is released. +if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && + instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } + gem 'structuredmerge_host_prototype', '~> 0.2' +end + gem 'kettle-family', '~> 1.2', '>= 1.2.97' # Local workspace dependency wiring for *_local.gemfile overrides diff --git a/gems/tree_haver/spec/integration/rust_tslp_json5_contract_spec.rb b/gems/tree_haver/spec/integration/rust_tslp_json5_contract_spec.rb index 5399d920e..9d36c31fe 100644 --- a/gems/tree_haver/spec/integration/rust_tslp_json5_contract_spec.rb +++ b/gems/tree_haver/spec/integration/rust_tslp_json5_contract_spec.rb @@ -4,6 +4,8 @@ RSpec.describe TreeHaver::Backends::RustTslp do it 'normalizes JSON5 node names to the established TSLP contract' do + skip TreeHaver::Backends::RustTslp.unavailable_reason unless TreeHaver::Backends::RustTslp.available? + tree = TreeHaver.with_backend(:rust_tslp) do TreeHaver::GrammarFinder.new(:json5).register!(raise_on_missing: true) TreeHaver.parser_for(:json5).parse("{ answer: true }\n") diff --git a/gems/tree_haver/spec/integration/rust_tslp_language_matrix_spec.rb b/gems/tree_haver/spec/integration/rust_tslp_language_matrix_spec.rb index 48793a20f..1532cc349 100644 --- a/gems/tree_haver/spec/integration/rust_tslp_language_matrix_spec.rb +++ b/gems/tree_haver/spec/integration/rust_tslp_language_matrix_spec.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true RSpec.describe 'Rust TSLP language matrix' do + before { skip TreeHaver::Backends::RustTslp.unavailable_reason unless TreeHaver::Backends::RustTslp.available? } + samples = { bash: "echo hi\n", go: "package main\nfunc main() {}\n", diff --git a/gems/typescript-merge/Gemfile b/gems/typescript-merge/Gemfile index 944b505ee..3da71a648 100644 --- a/gems/typescript-merge/Gemfile +++ b/gems/typescript-merge/Gemfile @@ -23,6 +23,12 @@ unless structuredmerge_rust_dev.empty? || %w[false 0 no off].include?(structured gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') end +# CI current and coverage jobs require the published Rust host gem; this fails until it is released. +if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && + instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } + gem 'structuredmerge_host_prototype', '~> 0.2' +end + gem 'kettle-family', '~> 1.2', '>= 1.2.100' # Local workspace dependency wiring for *_local.gemfile overrides diff --git a/gems/yaml-merge/Gemfile b/gems/yaml-merge/Gemfile index 551ae90a3..5714d44b1 100644 --- a/gems/yaml-merge/Gemfile +++ b/gems/yaml-merge/Gemfile @@ -23,6 +23,12 @@ unless structuredmerge_rust_dev.empty? || %w[false 0 no off].include?(structured gem 'structuredmerge_host_prototype', path: File.join(structuredmerge_rust_dev, 'packages/ruby') end +# CI current and coverage jobs require the published Rust host gem; this fails until it is released. +if ENV.fetch('STRUCTUREDMERGE_RUST_HOST_PUBLISHED', 'false').casecmp('true').zero? && + instance_variable_get(:@dependencies).none? { |dependency| dependency.name == 'structuredmerge_host_prototype' } + gem 'structuredmerge_host_prototype', '~> 0.2' +end + gem 'kettle-family', '~> 1.2', '>= 1.2.100' # Local workspace dependency wiring for *_local.gemfile overrides From 54e0206741d0dfce248cf94b2fe139519291890f Mon Sep 17 00:00:00 2001 From: "Peter H. Boling" Date: Mon, 14 Sep 2026 15:44:57 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=91=B7=20Fail=20Deps=20@=20HEAD=20whe?= =?UTF-8?q?n=20the=20release-built=20Rust=20host=20cannot=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI must reveal when common end-user paths are broken. Build the Rust host exactly as structuredmerge-rust's release workflow does (Ruby 4.0, CARGO_FEATURE_LINK_RUBY), and before running specs require it under the dep-heads bundle on the standard rv Ruby. The release build currently links libruby.so.4.0, which rv pre-built Rubies do not provide, so host specs were silently skipping while the job passed; it now fails for gems that declare the host until the release build produces a gem that loads there. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01P8aveNcdgPobyMrq8DkD9r --- .github/workflows/current.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/current.yml b/.github/workflows/current.yml index dbd15ad17..76c549d39 100644 --- a/.github/workflows/current.yml +++ b/.github/workflows/current.yml @@ -190,10 +190,12 @@ jobs: uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 with: toolchain: stable + # Build exactly as structuredmerge-rust's release-ruby-host.yml does (including + # CARGO_FEATURE_LINK_RUBY), so Deps @ HEAD tests the gem end users will install. - name: Setup Ruby uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 # v1.314.0 with: - ruby-version: ruby + ruby-version: "4.0" bundler-cache: false - name: Install Rust host package dependencies shell: bash @@ -318,6 +320,22 @@ jobs: done exit 1 + - name: Verify Rust host loads + working-directory: ${{ matrix.gem }} + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gem }}/dep_heads.gemfile + K_JEM_TEMPLATING: true + STRUCTUREDMERGE_DEV: ${{ github.workspace }}/gems + shell: bash + run: | + set -euo pipefail + # Gems that declare the host must be able to load the release-built host on this + # standard rv Ruby; failing here keeps host specs from silently skipping. + if ! grep -q 'structuredmerge_host_prototype' Gemfile; then + echo "This gem does not declare structuredmerge_host_prototype; nothing to verify." + exit 0 + fi + bundle exec ruby -e 'require "structuredmerge_host_prototype"; puts "structuredmerge_host_prototype #{StructuredmergeHostPrototype::VERSION} loaded"' - name: Run checks working-directory: ${{ matrix.gem }} env: