diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b48f2912..d4730fd77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ on: branches: [main] pull_request: branches: [main] - # Drafts run fixture + unit only. Ready PRs use selected coverage. + # Drafts default to fixture + unit; ci:full also expands drafts. # Label/draft/head transitions cancel superseded PR runs via concurrency. types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, labeled, unlabeled] workflow_call: @@ -35,6 +35,9 @@ permissions: env: CARGO_TERM_COLOR: always + # CI selects the latest patch on this MRI line. The local minimum lives in + # .ruby-version; keep both anchors aligned. Exact repro pins remain separate. + MRI_RUBY: '3.4' # The Rails apps the /ide/ + /playground/ demos ship (in-browser). Pinned # so the page — and any blog post's claims about it — stay stable against # upstream churn; bump deliberately. Each bundle embeds the app's LICENSE @@ -63,7 +66,7 @@ jobs: spinel-revision: ${{ steps.plan.outputs.spinel-revision }} spinel-tests: ${{ steps.plan.outputs.spinel-tests }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 with: fetch-depth: 2 - name: Select required coverage from the event's actual tree @@ -76,7 +79,7 @@ jobs: run: python3 scripts/ci-plan.py plan writebook-inventory: - needs: [unit, plan] + needs: plan if: ${{ contains(fromJSON(needs.plan.outputs.jobs), 'writebook-inventory') }} runs-on: ubuntu-latest timeout-minutes: 30 @@ -84,7 +87,7 @@ jobs: contents: read actions: read steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust - uses: Swatinem/rust-cache@v2 - name: Fetch pinned Writebook source @@ -162,7 +165,7 @@ jobs: env: BUNDLE_JOBS: '4' steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Isolate fixture gems # Runner paths are available in steps, not in job-level env expressions. run: | @@ -171,7 +174,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: "3.4" + ruby-version: ${{ env.MRI_RUBY }} - name: Identify fixture inputs id: gems env: @@ -277,7 +280,7 @@ jobs: # goes red it is the analyzer's next entry, not noise. # ------------------------------------------------------------------- store-check: - needs: [generate-fixture, unit, plan] + needs: [generate-fixture, plan] if: ${{ contains(fromJSON(needs.plan.outputs.jobs), 'store-check') }} runs-on: ubuntu-latest timeout-minutes: 30 @@ -285,7 +288,7 @@ jobs: contents: read actions: read steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust - uses: Swatinem/rust-cache@v2 # generate-fixture already builds the store and runs the guide's @@ -332,16 +335,15 @@ jobs: # Build the in-browser compiler wasm (roundhouse_wasm.wasm) from source # and upload it as an artifact for build-site. Runs in PARALLEL with # generate-fixture — they're independent (the wasm embeds the compiler; - # the fixture is runtime input fed in the browser), so this multi-minute - # LTO build hides behind work build-site already waits on, adding ~0 to - # the critical path to deploy. Rebuilds via the in-repo vendored + # the fixture is runtime input fed in the browser). Neither producer + # waits for unit tests. Rebuilds via the in-repo vendored # ruby-rbs-sys (wasm/vendor/ — wasm32 build support upstream-pending as # ruby/rbs#2992), so the 3.8 MB binary is NOT committed and the published # demos (/playground/, /studio/) always track main. The only external # need is the WASI SDK (a manual tarball, cached here). # ------------------------------------------------------------------- build-wasm: - needs: [unit, plan] + needs: plan if: ${{ contains(fromJSON(needs.plan.outputs.jobs), 'build-wasm') }} runs-on: ubuntu-latest # The job normally finishes in a few minutes; a hung apt install @@ -351,7 +353,7 @@ jobs: env: WASI_SDK_VERSION: "33.0" steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust with: targets: wasm32-wasip1 @@ -363,7 +365,7 @@ jobs: "$GITHUB_WORKSPACE/scripts/ci-apt-install" libclang-dev - name: Cache WASI SDK id: wasi-sdk - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: /opt/wasi-sdk key: wasi-sdk-${{ env.WASI_SDK_VERSION }}-x86_64-linux @@ -402,7 +404,7 @@ jobs: # A failed lookup falls back to fresh master and cannot save a checkpoint. # ------------------------------------------------------------------- build-spinel: - needs: [unit, plan] + needs: plan if: ${{ contains(fromJSON(needs.plan.outputs.jobs), 'build-spinel') }} outputs: artifact-id: ${{ steps.toolchain.outputs.artifact-id }} @@ -425,7 +427,7 @@ jobs: SCCACHE_DIRECT: "false" SCCACHE_IDLE_TIMEOUT: "0" steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 with: repository: matz/spinel ref: ${{ needs.plan.outputs.spinel-revision }} @@ -532,10 +534,12 @@ jobs: needs: generate-fixture runs-on: ubuntu-latest timeout-minutes: 30 - # Current-run debug compiler for compatible Campfire consumers (#317). - # Empty artifact-id means the consumers must not run as selected greens. - outputs: - roundhouse-bin-artifact-id: ${{ steps.roundhouse-bin.outputs.artifact-id }} + strategy: + # A failed shard must not cancel coverage or diagnostic output from others. + fail-fast: false + max-parallel: 3 + matrix: + shard: [0, 1, 2] env: # Share first-party split DWARF instead of copying it into every test # executable. Integration sidecars are freed with their finished batch; @@ -543,7 +547,7 @@ jobs: # Test-profile-only, Linux-job-only: dev, release and other hosts unchanged. CARGO_PROFILE_TEST_SPLIT_DEBUGINFO: unpacked steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust - uses: Swatinem/rust-cache@v2 - uses: actions/download-artifact@v8 @@ -569,7 +573,7 @@ jobs: # on the same one. - uses: ruby/setup-ruby@v1 with: - ruby-version: '3.4' + ruby-version: ${{ env.MRI_RUBY }} - name: Install the gem the overlay escape surface uses run: gem install rails-html-sanitizer --no-document # The native Date reference uses ActiveRecord against in-memory @@ -580,8 +584,14 @@ jobs: # run the emitted Ruby tree, whose database layer is the sqlite3 # gem. The second skipped without it and passed: before this step # the unit job ran it as a no-op. The first refuses to skip. - - name: Install the gem the emitted Ruby tree runs on - run: gem install sqlite3 --no-document + # + # Campfire launcher regressions (`tests/ci_campfire_optimization_test.py`, + # invoked from `tests/ci_policy_workflow.rs`) preload + # `scripts/campfire-test-bcrypt.rb`, which `require "bcrypt"`. A + # runner without the gem fails those tests; a dev box that has it + # hides the gap. + - name: Install gems used by emitted Ruby and Campfire harness tests + run: gem install sqlite3 bcrypt --no-document # Bounded build → run → free waves: every --all-targets identity still # goes through Cargo (lib, bins, each integration target). Finished # integration executables and their own split-DWARF sidecars are freed @@ -592,12 +602,12 @@ jobs: - name: Build and run all test targets in batches run: | python3 scripts/ci-resources.py --out "$RUNNER_TEMP/unit-resources/tests" -- \ - python3 scripts/ci-unit-tests.py + python3 scripts/ci-unit-tests.py --shard-index ${{ strategy.job-index }} --shard-count ${{ strategy.job-total }} - name: Upload test build timings if: always() uses: actions/upload-artifact@v7 with: - name: unit-build-timings + name: unit-build-timings-${{ matrix.shard }} path: target/cargo-timings/ if-no-files-found: ignore # scripts/bench emits nine of its lanes with `cargo run --bin @@ -608,6 +618,7 @@ jobs: # off the published bench page. This is the harness's own shape, # run here so the next one fails a PR instead of a nightly. - name: Emit every bench lane in the debug profile (scripts/bench's shape) + if: matrix.shard == 0 run: | python3 scripts/ci-resources.py --out "$RUNNER_TEMP/unit-resources/bench" -- \ bash -euo pipefail -c ' @@ -619,17 +630,25 @@ jobs: if: always() uses: actions/upload-artifact@v7 with: - name: unit-resources + name: unit-resources-${{ matrix.shard }} path: ${{ runner.temp }}/unit-resources/ if-no-files-found: ignore - # Share the Debug-profile compiler with Campfire conformance and - # comparison/db-differential (#317). Same profile as cargo test and - # the harness scripts' historical `cargo run` (not release). The - # binary alone is not enough: consumers must set ROUNDHOUSE_BIN so - # scripts/lib/roundhouse-bin.sh execs it instead of rebuilding. - # cargo test builds integration harnesses under profile.test; the - # `roundhouse` bin itself is profile.dev — rebuild is a cache hit - # after the bench emit step and guarantees the staged path exists. + + # Only selected consumers need this compiler. Produce it without waiting + # for fixtures/tests; their success remains independently required at the gate. + build-roundhouse: + needs: plan + if: ${{ contains(fromJSON(needs.plan.outputs.jobs), 'build-roundhouse') }} + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + roundhouse-bin-artifact-id: ${{ steps.roundhouse-bin.outputs.artifact-id }} + steps: + - uses: actions/checkout@v7 + - uses: ./.github/actions/setup-rust + - uses: Swatinem/rust-cache@v2 + # Keep the historical dev profile for Campfire, not a release substitute. + # Consumers must set ROUNDHOUSE_BIN so the harness does not rebuild it. - name: Stage current-run debug roundhouse binary run: | set -euo pipefail @@ -642,7 +661,7 @@ jobs: echo "source_sha=${GITHUB_SHA}" echo "profile=debug" echo "binary=roundhouse" - echo "producer_job=unit" + echo "producer_job=build-roundhouse" echo "rustc_host=$(rustc -vV | awk '/^host:/{print $2}')" echo "rustc_release=$(rustc -vV | awk '/^release:/{print $2}')" ./roundhouse-debug-bin/roundhouse --version | sed 's/^/version=/' @@ -689,7 +708,7 @@ jobs: # NOT from the published archive. Playwright config gates on $CI # (GitHub sets it) → fresh server + forbidOnly + github reporter. browser-smoke-typescript: - needs: [generate-fixture, unit, plan] + needs: [generate-fixture, plan] if: ${{ contains(fromJSON(needs.plan.outputs.jobs), 'browser-smoke-typescript') }} runs-on: ubuntu-latest timeout-minutes: 30 @@ -697,7 +716,7 @@ jobs: contents: read actions: read steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Use an explicit browser payload directory run: echo "PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TEMP/ci-reuse-browsers" >> "$GITHUB_ENV" - uses: ./.github/actions/setup-rust @@ -708,9 +727,9 @@ jobs: - name: Extract fixture run: tar -xzf real-blog.tar.gz - name: Install Node - uses: actions/setup-node@v5 + uses: actions/setup-node@v7 with: - node-version: '20' + node-version: '24' # Warm downloads for the generated app, not node_modules or dist. cache: npm cache-dependency-path: | @@ -774,7 +793,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: actions/download-artifact@v8 with: name: roundhouse-wasm @@ -785,12 +804,12 @@ jobs: - name: Extract fixture run: tar -xzf real-blog.tar.gz - name: Install Node - uses: actions/setup-node@v5 + uses: actions/setup-node@v7 with: - node-version: '20' + node-version: '24' - name: Cache Mastodon source bundle id: mastodon-src - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: wasm/ide/app-src.json key: mastodon-app-src-${{ env.MASTODON_SHA }}-v2 @@ -807,7 +826,7 @@ jobs: # switch (blog + lobsters + mastodon). Mirrors the build-site assembly. - name: Cache lobsters source bundle id: lobsters-src - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: /tmp/app-lobsters.json key: lobsters-app-src-${{ env.RUBY_BENCH_SHA }}-v1 @@ -823,7 +842,7 @@ jobs: --open app/controllers/stories_controller.rb - name: Cache campfire source bundle id: campfire-src - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: /tmp/app-campfire.json key: campfire-app-src-${{ env.CAMPFIRE_SHA }}-v1 @@ -928,7 +947,7 @@ jobs: timeout-minutes: 30 continue-on-error: true steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust - uses: Swatinem/rust-cache@v2 - uses: actions/download-artifact@v8 @@ -996,10 +1015,10 @@ jobs: # walk against the compiled binary and stay advisory, because spinel # is tracked unpinned. campfire-compare: - needs: [unit, plan] - # Require the unit producer's debug binary artifact id so a missing + needs: [build-roundhouse, plan] + # Require the producer's debug binary artifact id so a missing # upload cannot skip into a green compact gate (#317). - if: ${{ contains(fromJSON(needs.plan.outputs.jobs), 'campfire-compare') && needs.unit.outputs.roundhouse-bin-artifact-id != '' }} + if: ${{ contains(fromJSON(needs.plan.outputs.jobs), 'campfire-compare') && needs.build-roundhouse.outputs.roundhouse-bin-artifact-id != '' }} runs-on: ubuntu-latest timeout-minutes: 45 services: @@ -1011,8 +1030,8 @@ jobs: # so the prepared-tree cache carries the gems with it. BUNDLE_PATH: vendor/bundle steps: - - uses: actions/checkout@v5 - # No setup-rust: emission uses the unit job's current-run debug binary. + - uses: actions/checkout@v7 + # No setup-rust: emission uses the current-run debug binary. - uses: actions/download-artifact@v8 with: name: roundhouse-debug-bin @@ -1033,7 +1052,7 @@ jobs: echo "ROUNDHOUSE_BIN_TRACE=1" >> "$GITHUB_ENV" - uses: ruby/setup-ruby@v1 with: - ruby-version: '3.4' + ruby-version: ${{ env.MRI_RUBY }} - name: Fetch pinned once-campfire run: | curl -fsSL "https://codeload.github.com/basecamp/once-campfire/tar.gz/${CAMPFIRE_SHA}" -o /tmp/campfire.tar.gz @@ -1048,10 +1067,10 @@ jobs: run: sudo apt-get update -qq && sudo apt-get install -y -qq libvips42 sqlite3 - name: Cache the prepared oracle id: oracle - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: build/campfire-oracle - key: campfire-oracle-${{ env.CAMPFIRE_SHA }}-ruby34-v1 + key: campfire-oracle-${{ env.CAMPFIRE_SHA }}-ruby${{ env.MRI_RUBY }}-v1 - name: Prepare the Rails oracle (cache miss) if: steps.oracle.outputs.cache-hit != 'true' run: scripts/campfire-oracle prepare --app /tmp/campfire @@ -1111,7 +1130,7 @@ jobs: artifact-id: ${{ steps.binary.outputs.artifact-id }} execution: ${{ steps.result.outputs.status }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust - uses: Swatinem/rust-cache@v2 - uses: actions/download-artifact@v8 @@ -1247,7 +1266,7 @@ jobs: verify-gen: ${{ steps.result.outputs.verify-gen }} strategy: fail-fast: false - max-parallel: 1 + max-parallel: 3 matrix: include: - gc: default @@ -1263,12 +1282,12 @@ jobs: env: BUNDLE_PATH: vendor/bundle steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust - uses: Swatinem/rust-cache@v2 - uses: ruby/setup-ruby@v1 with: - ruby-version: '3.4' + ruby-version: ${{ env.MRI_RUBY }} - uses: actions/download-artifact@v8 with: name: spinel-dist @@ -1297,10 +1316,10 @@ jobs: mkdir -p /tmp/campfire && tar -xzf /tmp/campfire.tar.gz -C /tmp/campfire --strip-components=1 - name: Cache the prepared oracle id: oracle - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: build/campfire-oracle - key: campfire-oracle-${{ env.CAMPFIRE_SHA }}-ruby34-v1 + key: campfire-oracle-${{ env.CAMPFIRE_SHA }}-ruby${{ env.MRI_RUBY }}-v1 - name: Prepare the Rails oracle (cache miss) if: steps.oracle.outputs.cache-hit != 'true' run: scripts/campfire-oracle prepare --app /tmp/campfire @@ -1333,12 +1352,12 @@ jobs: env: BUNDLE_PATH: vendor/bundle steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust - uses: Swatinem/rust-cache@v2 - uses: ruby/setup-ruby@v1 with: - ruby-version: '3.4' + ruby-version: ${{ env.MRI_RUBY }} - uses: actions/download-artifact@v8 with: name: spinel-dist @@ -1355,10 +1374,10 @@ jobs: mkdir -p /tmp/campfire && tar -xzf /tmp/campfire.tar.gz -C /tmp/campfire --strip-components=1 - name: Cache the prepared oracle id: oracle - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: build/campfire-oracle - key: campfire-oracle-${{ env.CAMPFIRE_SHA }}-ruby34-v1 + key: campfire-oracle-${{ env.CAMPFIRE_SHA }}-ruby${{ env.MRI_RUBY }}-v1 - name: Prepare the Rails oracle (cache miss) if: steps.oracle.outputs.cache-hit != 'true' run: scripts/campfire-oracle prepare --app /tmp/campfire @@ -1367,15 +1386,15 @@ jobs: - *advisory-result campfire-conformance: - needs: [unit, plan] - # Require the unit producer's debug binary artifact id so a missing + needs: [build-roundhouse, plan] + # Require the producer's debug binary artifact id so a missing # upload cannot skip into a green compact gate (#317). - if: ${{ contains(fromJSON(needs.plan.outputs.jobs), 'campfire-conformance') && needs.unit.outputs.roundhouse-bin-artifact-id != '' }} + if: ${{ contains(fromJSON(needs.plan.outputs.jobs), 'campfire-conformance') && needs.build-roundhouse.outputs.roundhouse-bin-artifact-id != '' }} runs-on: ubuntu-latest timeout-minutes: 45 steps: - - uses: actions/checkout@v5 - # No setup-rust: strict-emit and campfire-suite use the unit debug binary. + - uses: actions/checkout@v7 + # No setup-rust: strict-emit and campfire-suite use the shared debug binary. - uses: actions/download-artifact@v8 with: name: roundhouse-debug-bin @@ -1396,7 +1415,7 @@ jobs: echo "ROUNDHOUSE_BIN_TRACE=1" >> "$GITHUB_ENV" - uses: ruby/setup-ruby@v1 with: - ruby-version: '3.4' + ruby-version: ${{ env.MRI_RUBY }} # Two gems, from two different places, both needed by a bare # `ruby -Itest` — which is how the suite runs each file, so they # have to land on the DEFAULT gem path; a bundler-cached @@ -1525,7 +1544,7 @@ jobs: # (rubys/roundhouse#71). This step is the run the ledger's # "zero errors" invariant is actually about. # - # Same debug binary as the suite (ROUNDHOUSE_BIN from unit), so the + # Same debug binary as the suite (ROUNDHOUSE_BIN from build-roundhouse), so the # compiler is shared rather than paid twice (#317). - name: Enforce the strict-emit ceiling env: @@ -1777,7 +1796,7 @@ jobs: # failed. - name: Upload the tally if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: campfire-conformance-tally path: /tmp/campfire-tally.txt @@ -1843,7 +1862,7 @@ jobs: timeout-minutes: 30 continue-on-error: true steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust - uses: Swatinem/rust-cache@v2 - uses: actions/download-artifact@v8 @@ -1867,7 +1886,7 @@ jobs: "$GITHUB_WORKSPACE/scripts/ci-apt-install" libsqlite3-dev libjemalloc-dev - uses: ruby/setup-ruby@v1 with: - ruby-version: '3.4' + ruby-version: ${{ env.MRI_RUBY }} bundler-cache: true working-directory: ./runtime/spinel/scaffold - name: cargo test --test spinel_toolchain -- --ignored @@ -1941,7 +1960,7 @@ jobs: # below: two setup-ruby steps / an extra build-spinel dependency don't # fit this shape. compare: - needs: [generate-fixture, unit, plan] + needs: [generate-fixture, plan] if: ${{ contains(fromJSON(needs.plan.outputs.jobs), 'compare') }} runs-on: ubuntu-latest timeout-minutes: 45 @@ -1954,7 +1973,7 @@ jobs: matrix: target: [rust, typescript] steps: &compare-steps - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust - uses: Swatinem/rust-cache@v2 - uses: actions/download-artifact@v8 @@ -1972,13 +1991,13 @@ jobs: crystal: latest - name: Install JDK (Kotlin) if: matrix.target == 'kotlin' - uses: actions/setup-java@v4 + uses: actions/setup-java@v6 with: distribution: temurin java-version: '21' - name: Install Gradle (Kotlin) if: matrix.target == 'kotlin' - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v5 with: gradle-version: '9.5.1' - name: Install Swift @@ -1992,17 +2011,17 @@ jobs: "$GITHUB_WORKSPACE/scripts/ci-apt-install" libsqlite3-dev - name: Install .NET (C#) if: matrix.target == 'csharp' - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: '10.0.x' - name: Install Node (TypeScript) if: matrix.target == 'typescript' - uses: actions/setup-node@v5 + uses: actions/setup-node@v7 with: - node-version: '20' + node-version: '24' - name: Install Go if: matrix.target == 'go' - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version: '1.24' cache: false @@ -2014,16 +2033,18 @@ jobs: otp-version: '27' - name: Install Python if: matrix.target == 'python' - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.14' - name: Install uv (Python) if: matrix.target == 'python' - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v10.2.0 + with: + cache-dependency-glob: src/emit/python/pyproject.rs # setup-ruby LAST — see the job comment (MRI 3.4 must win PATH). - uses: ruby/setup-ruby@v1 with: - ruby-version: '3.4' + ruby-version: ${{ env.MRI_RUBY }} bundler-cache: true working-directory: ./fixtures/real-blog # Not separate jobs: each target's framework-tests and toolchain suites ride the compare job that already installs its toolchain (#273). @@ -2088,7 +2109,7 @@ jobs: # with the Rust/TypeScript publication floor. Anchors preserve physical job # names used by the existing strict PR-local execution receipts. compare-extra: - needs: [generate-fixture, unit, plan] + needs: [generate-fixture, plan] if: ${{ contains(fromJSON(needs.plan.outputs.jobs), 'compare-extra') }} runs-on: ubuntu-latest timeout-minutes: 45 @@ -2097,7 +2118,7 @@ jobs: actions: read strategy: fail-fast: false - max-parallel: 2 + max-parallel: 7 matrix: target: ${{ fromJSON(needs.plan.outputs.extra-compare) }} steps: *compare-steps @@ -2109,12 +2130,12 @@ jobs: # for tailwindcss asset compilation; no target compile step # (Ruby is interpreted). compare-ruby: - needs: [generate-fixture, unit, plan] + needs: [generate-fixture, plan] if: ${{ contains(fromJSON(needs.plan.outputs.jobs), 'compare-ruby') }} runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust - uses: Swatinem/rust-cache@v2 - uses: actions/download-artifact@v8 @@ -2124,17 +2145,17 @@ jobs: run: tar -xzf real-blog.tar.gz - uses: ruby/setup-ruby@v1 with: - ruby-version: '3.4' + ruby-version: ${{ env.MRI_RUBY }} bundler-cache: true working-directory: ./fixtures/real-blog - uses: ruby/setup-ruby@v1 with: - ruby-version: '3.4' + ruby-version: ${{ env.MRI_RUBY }} bundler-cache: true working-directory: ./runtime/spinel/scaffold - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v7 with: - node-version: '20' + node-version: '24' # Not a separate job: framework-tests-ruby needs this same MRI + scaffold bundle (#273). - name: cargo test --test framework_tests_ruby -- --ignored run: cargo test --test framework_tests_ruby -- --ignored --nocapture @@ -2160,12 +2181,12 @@ jobs: # assets aren't built (the DOM diff compares asset tags, not files — # see scripts/compare's jruby branch). compare-jruby: - needs: [generate-fixture, unit, plan] + needs: [generate-fixture, plan] if: ${{ contains(fromJSON(needs.plan.outputs.jobs), 'compare-jruby') }} runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust - uses: Swatinem/rust-cache@v2 - uses: actions/download-artifact@v8 @@ -2175,7 +2196,7 @@ jobs: run: tar -xzf real-blog.tar.gz # JRuby 10 requires Java 21+ (it refuses to boot on older JDKs). # setup-ruby provisions JRuby but not the JDK, so pin Java first. - - uses: actions/setup-java@v4 + - uses: actions/setup-java@v6 with: distribution: temurin java-version: '21' @@ -2188,7 +2209,7 @@ jobs: ruby-version: 'jruby-10.0' - uses: ruby/setup-ruby@v1 with: - ruby-version: '3.4' + ruby-version: ${{ env.MRI_RUBY }} bundler-cache: true working-directory: ./fixtures/real-blog - name: scripts/compare jruby @@ -2212,7 +2233,7 @@ jobs: timeout-minutes: 30 continue-on-error: true steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust - uses: Swatinem/rust-cache@v2 - uses: actions/download-artifact@v8 @@ -2233,17 +2254,17 @@ jobs: "$GITHUB_WORKSPACE/scripts/ci-apt-install" libsqlite3-dev libjemalloc-dev - uses: ruby/setup-ruby@v1 with: - ruby-version: '3.4' + ruby-version: ${{ env.MRI_RUBY }} bundler-cache: true working-directory: ./fixtures/real-blog - uses: ruby/setup-ruby@v1 with: - ruby-version: '3.4' + ruby-version: ${{ env.MRI_RUBY }} bundler-cache: true working-directory: ./runtime/spinel/scaffold - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v7 with: - node-version: '20' + node-version: '24' - name: scripts/compare spinel run: scripts/compare spinel - *advisory-result @@ -2295,11 +2316,11 @@ jobs: actions: read strategy: fail-fast: false - max-parallel: 2 + max-parallel: 6 matrix: target: ${{ fromJSON(needs.plan.outputs.smoke) }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Use an explicit browser payload directory run: echo "PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TEMP/ci-reuse-browsers" >> "$GITHUB_ENV" - uses: actions/download-artifact@v8 @@ -2319,13 +2340,13 @@ jobs: crystal: latest - name: Install JDK (Kotlin, JRuby) if: matrix.target == 'kotlin' || matrix.target == 'jruby' - uses: actions/setup-java@v4 + uses: actions/setup-java@v6 with: distribution: temurin java-version: '21' - name: Install Gradle (Kotlin) if: matrix.target == 'kotlin' - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v5 with: gradle-version: '9.5.1' - name: Install Swift @@ -2339,12 +2360,12 @@ jobs: "$GITHUB_WORKSPACE/scripts/ci-apt-install" libsqlite3-dev - name: Install .NET (C#) if: matrix.target == 'csharp' - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v6 with: dotnet-version: '10.0.x' - name: Install Go if: matrix.target == 'go' - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version: '1.24' cache: false @@ -2356,25 +2377,48 @@ jobs: otp-version: '27' - name: Install Python if: matrix.target == 'python' - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.14' - name: Install uv (Python) if: matrix.target == 'python' - uses: astral-sh/setup-uv@v7 - - name: Install Ruby (MRI 3.4) + uses: astral-sh/setup-uv@v10.2.0 + with: + cache-dependency-glob: src/emit/python/pyproject.rs + # Use setup-ruby's existing compiled-gem cache and identity, not a second + # download-only cache. Smoke still extracts afresh and runs bundle install. + - name: Prepare archive bundle cache inputs + if: matrix.target == 'ruby' || matrix.target == 'jruby' + env: + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/smoke-bundle-source" + tar -xzf "browse/$TARGET.tgz" -C "$RUNNER_TEMP/smoke-bundle-source" + # JRuby deliberately has no MRI lock; setup-ruby resolves its own. + test -f "$RUNNER_TEMP/smoke-bundle-source/$TARGET/Gemfile" + - name: Install Ruby (MRI) if: matrix.target == 'ruby' uses: ruby/setup-ruby@v1 with: - ruby-version: '3.4' + ruby-version: ${{ env.MRI_RUBY }} + bundler-cache: true + working-directory: ${{ runner.temp }}/smoke-bundle-source/${{ matrix.target }} - name: Install JRuby 10 if: matrix.target == 'jruby' uses: ruby/setup-ruby@v1 with: ruby-version: 'jruby-10.0' - - uses: actions/setup-node@v5 + bundler-cache: true + working-directory: ${{ runner.temp }}/smoke-bundle-source/${{ matrix.target }} + - name: Reuse prepared gems in the fresh README smoke + if: matrix.target == 'ruby' || matrix.target == 'jruby' + env: + TARGET: ${{ matrix.target }} + run: echo "BUNDLE_PATH=$RUNNER_TEMP/smoke-bundle-source/$TARGET/vendor/bundle" >> "$GITHUB_ENV" + - uses: actions/setup-node@v7 with: - node-version: '20' + node-version: '24' - name: Pre-warm Playwright (chromium + system deps) run: | cd e2e @@ -2449,7 +2493,7 @@ jobs: timeout-minutes: 20 continue-on-error: true steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: actions/download-artifact@v8 with: name: browse-archives @@ -2465,9 +2509,9 @@ jobs: - name: Install libsqlite3-dev + libjemalloc-dev run: | "$GITHUB_WORKSPACE/scripts/ci-apt-install" libsqlite3-dev libjemalloc-dev - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v7 with: - node-version: '20' + node-version: '24' - name: Pre-warm Playwright (chromium + system deps) run: | cd e2e @@ -2533,12 +2577,12 @@ jobs: # that times out four minutes short is a flake waiting to happen. timeout-minutes: 60 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust - uses: Swatinem/rust-cache@v2 - uses: ruby/setup-ruby@v1 with: - ruby-version: '3.4' + ruby-version: ${{ env.MRI_RUBY }} - uses: actions/download-artifact@v8 continue-on-error: true with: @@ -2605,7 +2649,7 @@ jobs: timeout-minutes: 30 continue-on-error: true steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: actions/download-artifact@v8 with: name: campfire-archive @@ -2623,7 +2667,7 @@ jobs: id: docker-cache # Fail-open: a cache outage must not skip the README build. continue-on-error: true - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v6 with: path: ${{ env.CAMPFIRE_DOCKER_CACHE }} key: campfire-docker-apt-${{ runner.os }}-${{ runner.arch }}-${{ steps.apt-window.outputs.bucket }}-v1 @@ -2673,7 +2717,7 @@ jobs: # an exact hit already holds this window's layers. Fail-open. if: steps.smoke.outcome == 'success' && steps.docker-cache.outputs.cache-hit != 'true' continue-on-error: true - uses: actions/cache/save@v4 + uses: actions/cache/save@v6 with: path: ${{ env.CAMPFIRE_DOCKER_CACHE }} key: campfire-docker-apt-${{ runner.os }}-${{ runner.arch }}-${{ steps.apt-window.outputs.bucket }}-v1 @@ -2716,7 +2760,7 @@ jobs: timeout-minutes: 30 continue-on-error: true steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: actions/download-artifact@v8 with: name: campfire-archive @@ -2734,9 +2778,9 @@ jobs: # declares image variants), and the package links -lvips. run: | "$GITHUB_WORKSPACE/scripts/ci-apt-install" libsqlite3-dev libjemalloc-dev libvips-dev - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v7 with: - node-version: '20' + node-version: '24' - name: Pre-warm Playwright (chromium + system deps) run: | cd e2e/campfire @@ -2787,7 +2831,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ./.github/actions/setup-rust - uses: Swatinem/rust-cache@v2 - uses: actions/download-artifact@v8 @@ -2822,9 +2866,9 @@ jobs: # and preserves subdirs, so controllers/*.js land at # static/assets/controllers/*.js — matching the `controllers/*` pins. - name: Install Node - uses: actions/setup-node@v5 + uses: actions/setup-node@v7 with: - node-version: '20' + node-version: '24' # Also warm the live demo's downloads; always build current output. cache: npm cache-dependency-path: | @@ -2833,7 +2877,7 @@ jobs: - name: Install Ruby (for the turbo-rails / stimulus-rails JS bundles) uses: ruby/setup-ruby@v1 with: - ruby-version: '3.4' + ruby-version: ${{ env.MRI_RUBY }} bundler-cache: true working-directory: ./runtime/spinel/scaffold - name: Build static asset graph for archives @@ -2937,7 +2981,7 @@ jobs: - name: Cache Mastodon source bundle if: needs.plan.outputs.site == 'true' id: mastodon-src-site - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: wasm/ide/app-src.json key: mastodon-app-src-${{ env.MASTODON_SHA }}-v2 @@ -2969,7 +3013,7 @@ jobs: - name: Cache lobsters source bundle if: needs.plan.outputs.site == 'true' id: lobsters-src-site - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: /tmp/app-lobsters.json key: lobsters-app-src-${{ env.RUBY_BENCH_SHA }}-v1 @@ -2986,7 +3030,7 @@ jobs: - name: Cache campfire source bundle if: needs.plan.outputs.site == 'true' id: campfire-src-site - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: /tmp/app-campfire.json key: campfire-app-src-${{ env.CAMPFIRE_SHA }}-v1 @@ -3281,7 +3325,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Collect this run's archive evidence continue-on-error: true uses: actions/download-artifact@v8 @@ -3315,10 +3359,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - uses: ruby/setup-ruby@v1 with: - ruby-version: '3.4' + ruby-version: ${{ env.MRI_RUBY }} - uses: actions/download-artifact@v8 with: name: site-base @@ -3402,14 +3446,14 @@ jobs: run: echo 'ready=true' >> "$GITHUB_OUTPUT" compact-required: - needs: [plan, generate-fixture, unit, store-check, compare, compare-ruby, browser-smoke-typescript, campfire-conformance, campfire-compare] + needs: [plan, generate-fixture, unit, build-roundhouse, store-check, compare, compare-ruby, browser-smoke-typescript, campfire-conformance, campfire-compare] if: always() runs-on: ubuntu-latest timeout-minutes: 5 outputs: passed: ${{ steps.gate.outputs.passed }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Require every selected compact check to succeed id: gate env: @@ -3422,14 +3466,14 @@ jobs: # Informational validation summary; no branch-protection or merge policy. ci-summary: name: CI summary - needs: [plan, generate-fixture, unit, store-check, compare, compare-ruby, browser-smoke-typescript, campfire-conformance, campfire-compare, compact-required, compare-extra, compare-jruby, build-wasm, browser-smoke-ide, build-site, smoke, writebook-inventory, build-spinel, framework-tests-spinel, build-campfire-compare-spinel, campfire-compare-spinel, campfire-db-differential-spinel, toolchain-spinel, compare-spinel, smoke-spinel, build-campfire-archive, smoke-campfire, smoke-campfire-docker, archive-results, assemble-site] + needs: [plan, generate-fixture, unit, build-roundhouse, store-check, compare, compare-ruby, browser-smoke-typescript, campfire-conformance, campfire-compare, compact-required, compare-extra, compare-jruby, build-wasm, browser-smoke-ide, build-site, smoke, writebook-inventory, build-spinel, framework-tests-spinel, build-campfire-compare-spinel, campfire-compare-spinel, campfire-db-differential-spinel, toolchain-spinel, compare-spinel, smoke-spinel, build-campfire-archive, smoke-campfire, smoke-campfire-docker, archive-results, assemble-site] if: always() runs-on: ubuntu-latest timeout-minutes: 5 outputs: complete: ${{ steps.gate.outputs.complete }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Report missing, skipped or failed selected checks id: gate env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39928b293..5672b68fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,7 +55,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false submodules: recursive @@ -115,7 +115,7 @@ jobs: - name: enable windows longpaths run: | git config --global core.longpaths true - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false submodules: recursive @@ -174,7 +174,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false submodules: recursive @@ -224,7 +224,7 @@ jobs: outputs: val: ${{ steps.host.outputs.manifest }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false submodules: recursive @@ -289,7 +289,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: persist-credentials: false submodules: recursive diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 000000000..2f4b60750 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.4 diff --git a/AGENTS.md b/AGENTS.md index a7c692c37..5ab8f09ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,31 +5,31 @@ languages, plus an inference engine (LSP/MCP/in-browser IDE) that types Rails without annotations. This file is the orientation an AI agent or new contributor needs *before* touching the code: where to look, and the invariants not to break. -**Source of truth for current state is [`RELEASES.md`](RELEASES.md) and CI** — -which targets are live, what each snapshot proves, the known gaps; the -[user guide](docs/guide/README.md) says what each door does today, and the -[bench page](https://rubys.github.io/roundhouse/bench/) carries the numbers. -[`README.md`](README.md) is the landing page. The older docs below are -accurate on *architecture* but may narrate migrations that have since -landed. **When a status claim anywhere disagrees with RELEASES.md or CI, -RELEASES.md and CI win.** +Current implementation and executed CI are authoritative for behavior. +[`RELEASES.md`](RELEASES.md) records dated snapshot claims, not a live main +status page. The [user guide](docs/guide/README.md) describes product usage; +the [bench page](https://rubys.github.io/roundhouse/bench/) carries measurements. ## Start here | You want… | Read | |---|---| | What the project is | [`README.md`](README.md) — the landing page | -| Current state, per snapshot, and the known gaps | [`RELEASES.md`](RELEASES.md) — authoritative with CI | +| Release snapshots and their known gaps | [`RELEASES.md`](RELEASES.md) | | Using it (check / editor / MCP / transpile / Spinel) | [`docs/guide/`](docs/guide/README.md) | -| The dev loop, `roundhouse-ast`, adding an IR variant | [`DEVELOPMENT.md`](DEVELOPMENT.md) | +| Set up a checkout | [`docs/development/README.md`](docs/development/README.md) | +| Choose tests or use `bin/rh verify` | [`docs/development/testing.md`](docs/development/testing.md) | +| Inspect AST, IR, or emitted output | [`docs/development/debugging.md`](docs/development/debugging.md) | +| Change IR, lowering, runtime, or an emitter | [`docs/development/compiler-changes.md`](docs/development/compiler-changes.md) | +| Read/request hosted checks | [`docs/ci/README.md`](docs/ci/README.md) | | Pipeline internals (analyze / lower / emit / runtime / verification) | [`docs/pipeline/`](docs/pipeline/) — architecture, not status | | Compiler inputs (Ruby+ERB, schema/routes/seeds, method catalog, DB adapter) | [`docs/data/`](docs/data/) | | Why do this at all (the argument, option value) | [`WHY.md`](WHY.md) | | Why this attempt is different (lineage, the three bets, risks) | [`BETS.md`](BETS.md) | Pipeline shape: `Ruby AST → analyze (typed IR) → lower (target-neutral IR) → -emit (per-target project + runtime// glue)`. Key files are mapped in -DEVELOPMENT.md § "Pipeline at a glance." +emit (per-target project + runtime// glue)`. The +[ownership map](docs/development/compiler-changes.md#ownership-map) locates each stage. ## Invariants — do not break these @@ -87,57 +87,18 @@ defect even if the build is green. you changed. End commit messages with the standard `Co-Authored-By` trailer. - **Outside contributors: fork, and open a pull request against `main`.** - CI runs a compact floor plus targeted lanes. For broad/risky changes, - ask a maintainer to apply `ci:full` for the complete PR matrix. - This expands validation only; PR runs never publish. Drafts retain - fixture + unit coverage even with the label. Full main validation and - publication are scheduled every four hours and execute freshly every cycle. - `CI summary` reports selected checks, not a mandatory merge gate; - maintainers decide when to merge. See [CI coverage](docs/ci-reuse.md). - You do not need every toolchain locally; CI covers the missing lanes. - Before opening one: `bin/rh fixture` (the test fixtures are generated, - not checked in — see below), `cargo test --lib` plus the targeted - integration test for what you touched, and a test that pins the fix. - When the fix removes an error diagnostic, that test goes through - `tests/emit_and_run.rs` (invariant 6): CI's toolchain lanes emit only - the fixtures, so a construct the fixtures do not use is exercised by - nothing else, and every lane stays green while it is broken. - A reported repro with a patch in the issue is welcome; the same patch - as a PR is better, because the lanes you cannot run will run. -- **Fixtures are generated.** `fixtures/real-blog` and `fixtures/store` - are `.gitignore`d; a fresh clone has neither, and the tests that read - them fail until `bin/rh fixture` (~60s, needs Ruby and - `gem install rails`) and `scripts/create-store` have run. Tests reach - them through `roundhouse::fixtures::real_blog()` / `store()`, which - say so — with the command — when one is absent. -- **Test cycle:** `cargo build --tests` + the targeted test for what you - touched + a round-trip check (`roundhouse-ast --round-trip`). Use - `cargo test --all-targets` at milestones. Real-toolchain tests are - `#[ignore]`-gated (`cargo test --test _toolchain -- --ignored`); CI - runs each in its own job. -- **Focused local loop:** `bin/rh verify --plan --test ` previews; - `bin/rh verify --test ` builds and runs only library tests and the - selected suites sequentially, not all test programs. Repeat `--test`; opt - into native checks with `--toolchain `. Default `--test` skips - ignored tests; use `--test --ignored` for ignored integration checks. - Prepare both fixtures and dependencies first. - The JSON report (`--json`) separates executed local checks from unexecuted - hosted coverage; neither a preview nor a passing subset proves full CI. - Before/after filesystem-space snapshots and low-space warnings are advisory; - an unavailable probe does not block tests. The runner never cleans caches. - Do not run another build/test in that checkout concurrently. See - DEVELOPMENT.md § "Focused local verification" for scope and prerequisites. -- **CI is deliberately not uniformly gating.** The core `cargo test` job is - non-advisory. Jobs marked `continue-on-error: true` track current Spinel - master and other moving toolchains on purpose — **red is a signal, not a - regression to shim away.** A Spinel red can come from Roundhouse runtime, RBS - or packaging, or from upstream; it is not by itself proof of an upstream - fault. Don't add workarounds just to make an advisory job green. + Include the repro, a regression test, and actual verification results. +- Prepare the [fixtures and dependencies](docs/development/README.md#setup) + before testing. Iterate with focused suites; follow the + [test cycle](docs/development/testing.md) before committing. +- A local preview/subset is not full CI or merge approval. Report missing SDK + coverage and request [broader checks](docs/ci/README.md) for broad/risky changes. +- Do not suppress diagnostics, comparison differences, or advisory failures + merely to make a check green. Establish the failing input and cause first. ## The actual goal The endpoint is not "does the fixture compile." It is a **per-target ledger of how much of Rails transpiles** — the honest unsupported list, driven down over -time. Don't trade that real goal for a locally reachable one. The proving lanes -today are the real-blog fixture (every target, DOM-equivalent to Rails in full -validation) and lobsters/Mastodon on the inference + Spinel path (see README). +time. Don't trade that real goal for a locally reachable one. Inspect the +relevant input, target, and executed gate before making a support claim. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 94e7135c1..778db3cf5 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,511 +1,27 @@ # Developing Roundhouse -Day-to-day reference for working on roundhouse itself — build commands, -the `roundhouse-ast` debugging tool, how the pipeline stages compose, and -the pattern for adding a new IR variant. - -For deeper architecture and per-stage internals, see [`docs/`](docs/). - -## Build & test - -Install Rust through rustup. [`rust-toolchain.toml`](rust-toolchain.toml) pins -the compiler to **1.98.1** for local builds, CI and release builds; rustup -selects it automatically, including inside `wasm/`. CI also retains that -toolchain when testing generated Rust projects outside the checkout. -Upgrade the pin deliberately, after the native suite and browser WASM gates -pass. Keep the explicit WASM stack budget in `.cargo/config.toml`: the pin -is not a replacement for it. `Cargo.toml`'s `rust-version` is the minimum -supported version, not the selected build toolchain. - -```bash -cargo build # debug build -cargo build --release # release build -cargo build --bin roundhouse-ast # just the CLI debug tool - -cargo test # full default suite (unit + integration) -cargo test --test ingest # one integration test file -cargo test --test real_blog # the real-blog forcing functions -cargo test --lib erb:: # the ERB compiler unit tests - -cargo test --test rust_toolchain -- --ignored # real Rust build -cargo test --test typescript_toolchain -- --ignored # real TS build -# ...one _toolchain per target (crystal, go, elixir, python, -# kotlin, swift, csharp, ruby, spinel, roda) - -cargo test --test framework_tests_ruby -- --ignored # framework runtime's -# own test suite (runtime/ruby/test/) run against a target's transpile; -# framework_tests_{crystal,kotlin,rust,spinel,swift,typescript} likewise -``` - -The default test suite is the forcing function and must pass before -any commit. The test profile uses `line-tables-only` debug information: -backtraces retain file/line locations without repeating module metadata in -hundreds of integration-test binaries. Code stays unoptimized. For full -debugger information, use `CARGO_PROFILE_TEST_DEBUG=2 cargo test`. - -To separate compiler work from the emitted program's runtime, opt into phase -timings. Ruby-family emission reports assembly and text-level tree shaking -separately; timing is disabled by default: - -```bash -ROUNDHOUSE_TIMINGS=1 cargo test --test emit_and_run the_unedited_blog_runs -- --nocapture -``` - -Toolchain and framework tests are `#[ignore]`-gated so a -local `cargo test` doesn't require every target runtime installed — -CI covers them via per-target jobs and the `smoke` matrix (which -executes each published archive's README verbatim; several toolchain -jobs were retired in its favor and remain dev-loop harnesses — see -the comment in `.github/workflows/ci.yml`). - -New IR or recognizer work lands with a paired test — see [Adding a new -IR variant](#adding-a-new-ir-variant). - -## Workflow runner (`bin/rh`) - -`bin/rh` is the single entry point for the repository's own workflows -— the fixture, the per-target builds, the compare and bench harnesses, -the site. Ruby is the only prerequisite for the onboarding -subcommands; the build subcommands shell out to `cargo`. `bin/rh ---help` lists the surface, `bin/rh --help` the options. - -Onboarding (no Rust required): - -- `bin/rh doctor` — check prerequisites; list which subcommands work today. -- `bin/rh fetch ` — download a pre-transpiled archive into `downloads//`. -- `bin/rh fixture` — generate the Rails source fixture via `rails new` + scaffold. - -Build (requires Rust): - -- `bin/rh transpile ` — build `fixtures/real-blog` into `build/transpiled-blog-/`. -- `bin/rh dev | test | run ` — transpile, then run the emitted tree's dev/test/run action (ruby today). -- `bin/rh compare []` — fetch the same URL from Rails and the target, diff canonicalized DOM. -- `bin/rh bench [...]` — HTTP throughput + RSS benchmark across targets. -- `bin/rh site` — build the full multi-target Pages site. - -### Focused local verification - -`bin/rh verify` is a foreground, fail-fast developer/agent loop, **not full -CI or merge approval**. It builds and runs library tests, then only the -integration and ignored toolchain suites you explicitly select. Cargo builds -the programs needed by those suites (including application binaries for -integration tests); unrelated test programs are not built. The full -`cargo build --tests` / `cargo test --all-targets` guidance above still applies -at milestones. - -```sh -bin/rh verify --plan --base main --test ingest -bin/rh verify --test ingest --test real_blog -bin/rh verify --toolchain ruby --json > verification.json -bin/rh verify --test framework_tests_ruby --ignored -``` - -Like Cargo, default library/`--test` checks exclude `#[ignore]` tests. -An ignored-only suite therefore executes no tests by default. Use `--ignored` -with `--test` to run only its ignored tests instead; library tests remain -unchanged. `--toolchain TARGET` already selects ignored tests in -`TARGET_toolchain`. Ignored tests are never enabled implicitly: mixed suites -can contain checks requiring additional SDKs or deliberately unsupported work. - -It needs Git, repository-pinned Rust, Ruby/test gems and any selected target -toolchain. Python 3 (stdlib only) is optional for the hosted-coverage preview; -planner failures are reported as unavailable and do not block local checks. -Fixtures and dependencies required by the executed tests must already be -prepared; missing fixtures are listed, but each test owns its prerequisites -and reports failures itself. The runner never installs them. `--plan` -only reads Git and the existing `scripts/ci-plan.py` policy; it does not call -Cargo, create a lock, generate Python bytecode, or execute tests. - -`--base REF` compares the **current working tree** with that exact commit -(default `HEAD`), including tracked edits, deletions and untracked non-ignored -files. It does not fetch, find a merge base, or simulate GitHub's PR merge -tree. The hosted coverage selection is informational: those jobs are not -executed locally, and the planner cannot infer draft/label/full-call context. -Choose integration tests from the behavior you changed, not just file names. -Inherited Git repository-location variables are cleared for verification -subprocesses, so reports, locks and tests use this checkout rather than a -repository selected by a calling Git hook. The caller's environment is untouched. - -Commands run sequentially with one Rust test thread. Cargo defaults to at -most four workers and non-incremental builds. Repository debug profiles are -left intact, including the test profile's space-saving line tables for -backtraces. Explicit Cargo environment settings are preserved, and `--jobs N` -overrides the worker count. No optimization or debug-assertion setting is changed. -Debug bench emissions and browser/DOM/corpus gates are not part of this loop. -The tool performs no cleanup, autofixes, publishing or Git writes beyond its -worktree-local verification lock. Do not run other builds/tests concurrently -in the same checkout: the lock coordinates `verify` callers, not arbitrary -Cargo commands. - -Before and after execution (also after a failed check), the runner reports -available and total filesystem space for the checkout and Cargo target -directory. Cargo's offline, locked metadata resolves the target location, -including `CARGO_TARGET_DIR` and Cargo configuration; a missing target directory -is measured at its nearest existing parent without creating it. The optional -probe uses POSIX `df -P -k` (Linux/macOS); missing tools or unsupported output, -including environments without `df`, are reported as unavailable and never -block tests or override their exit codes. `--plan` performs neither probe nor -Cargo metadata lookup. Less than **5 GiB** available produces an advisory -warning on stderr, not an enforced build budget or automatic cleanup. - -These are filesystem snapshots, **not the size of this run's artifacts**: -other processes and shared caches affect free space. Existing artifacts are -retained; selecting fewer suites prevents unnecessary new builds but does not -remove old ones. Check disk space with your platform tools before large runs. - -For a smaller build footprint, Cargo can omit symbol tables when readable -native backtraces/debugging are not needed. This is opt-in, target-dependent, -and does not change optimization, debug assertions or overflow checks. Set -both profile variables using your shell's environment syntax; POSIX example: - -```sh -CARGO_PROFILE_DEV_STRIP=symbols CARGO_PROFILE_TEST_STRIP=symbols \ - bin/rh verify --test ingest --json > verification.json -``` - -No separate `rh` option or stripping of existing executables is needed. -Caller-supplied dev/test `DEBUG`, `STRIP` and `OPT_LEVEL` environment values are -included in `build_environment`; absent values leave Cargo configuration -alone. That report is not a complete effective Cargo configuration. -Checks that require symbolicated backtraces, including `ci_policy_workflow`, -need debug information and symbols; do not disable them for those suites. -Changing profile settings can create additional cached artifact variants; -stripping does not shrink old binaries or dependency archives. Higher -optimization can reduce artifact size but increase compile time: measure -the cold-build and repeated-run tradeoff before choosing it for a CI loop. - -For an occasional package-cache reset, **first stop all builds, tests and -generated programs using it**, confirm the target directory from the report, -and do not clean a directory shared with other checkouts. Preview Cargo's -dev/test package cleanup before executing it: - -```sh -cargo clean --package roundhouse --profile dev --offline --locked --dry-run --verbose -# Only after inspecting the preview, remove --dry-run to perform cleanup. -``` - -Use `--target-dir PATH` when necessary to identify the dedicated cache -explicitly. The default dev and test profiles share Cargo's `debug/` -output directory, so `--profile dev` covers both here. Package cleanup -retains dependency artifacts but removes Roundhouse dev/test programs, -which must be rebuilt. Do not clean after -every run: stable build settings and reuse of a dedicated cache are faster. -The verifier never performs this cleanup automatically. - -`--json` sends a report to stdout and child output to stderr. The report names -HEAD, changed paths, base, build settings, each command, its exit code/time and -`passed`, `failed` or `not-run` status. `disk_space` contains before/after -snapshots in bytes or probe errors (null snapshots for a preview). -It includes the working tree but is not a content fingerprint or reusable -execution receipt; do not reuse it merely because HEAD matches. -A preview is `planned`; a failed child stops subsequent -checks and preserves its exit code. Invalid arguments or missing Git are -reported on stderr with exit 2; an unavailable Cargo command exits 127. - -Cleanup: `bin/rh clean `. - -The working demo in two commands — a transpiled blog with articles, -comments, live Turbo Stream broadcasts over WebSocket, SQLite -persistence and Tailwind — is `bin/rh fixture` (~60s) then `bin/rh -dev ruby` (transpile + assets + serve on :3000, ~3-5 min cold). +Start with the [development handbook](docs/development/README.md) for setup +and the local loop. Read [AGENTS.md](AGENTS.md) for invariants before changing +compiler behavior. + +| Task | Reference | +|---|---| +| Choose tests or use `bin/rh verify` | [Testing](docs/development/testing.md) | +| Inspect AST, lowered IR, or emitted code | [Debugging](docs/development/debugging.md) | +| Change IR, lowering, runtime, or emitters | [Compiler changes](docs/development/compiler-changes.md) | +| Understand/request GitHub checks | [CI for contributors](docs/ci/README.md) | +| Understand the pipeline | [Architecture](docs/pipeline/) and [inputs](docs/data/) | ## Fixtures -### tiny-blog - -`fixtures/tiny-blog/` is the minimal always-works fixture. Its gates -run in the default suite: zero diagnostics (`tests/analyze.rs`) and -IR-shape assertions (`tests/ingest.rs`), plus per-target toolchain -gates for the absent-feature shape real-blog can't test. Checked -into the repo — safe to edit directly when extending coverage. - -### real-blog - -`fixtures/real-blog/` is the Phase-1 target — a modernized Rails 8 -blog. It is **not checked in**; it's derived on demand from -`scripts/create-blog` (a frozen snapshot of the ruby2js upstream -generator, reproducible without an external git checkout). - -```bash -bin/rh fixture # regenerate into fixtures/real-blog/ -bin/rh clean fixture # remove it -``` - -The fixture is `.gitignore`d, so a fresh clone does not have it, and -neither `fixtures/store` (the Rails Guides store: -`cd fixtures && ../scripts/create-store store`). Tests reach both -through `roundhouse::fixtures::real_blog()` / `store()`, which panic -with the generating command when the directory is absent; and -`ingest_app` refuses a root that is not a directory rather than -returning an empty app, so a wrong path fails at the path, not at the -first model it cannot find. +`fixtures/real-blog` and `fixtures/store` are generated, not checked in. +Tests using [`src/fixtures.rs`](src/fixtures.rs) report the generating command +when either is absent. From the repository root: -CI prepares both fixtures in `generate-fixture` and shares this run's artifact -across unit and per-target jobs. Cache and freshness rules are documented in -[CI coverage and reuse](docs/ci-reuse.md#fixture-inputs). - -`tests/real_blog.rs` pairs against the generated tree; its -load-bearing gates: - -1. `ingests_without_errors` / `ingests_without_parse_diagnostics` — - loud regression guards. -2. `type_analysis_coverage` — the contract test: zero error - diagnostics and zero unresolved types across the whole fixture. -3. `model_tests_ingest_into_test_modules` / `fixtures_ingest_into_app` - — the test/fixture ingest surface. - -The emit-side forcing functions live in `tests/lowered_ruby_emit.rs` -and `tests/spinel_toolchain.rs` (whole-app source-equivalence -round-trip was retired in favor of compile-equivalence via Spinel — -see the header of `src/emit/ruby.rs`). - -### Writebook inventory - -The ignored, pinned external-corpus gate is documented in -[`docs/writebook.md`](docs/writebook.md). It inventories ingest, analysis, -lowering, Ruby/Spinel emission diagnostics, and source coverage; it is -intentionally not a compilation/runtime conformance claim. - -## Debugging tools - -### `roundhouse-ast` - -The pipeline has four distinct stages (Prism parse → ERB compile → ingest -→ emit), and debugging almost always means "show me what stage N produced -for this input." Rust's `{:?}` Debug output isn't readable for our IR -at scale, and shelling out to `ruby --dump=parsetree` only shows Prism's -view. `roundhouse-ast` is the structural-dump tool. - -Run it via `cargo run --bin roundhouse-ast --` or build once and invoke -`target/debug/roundhouse-ast` directly. - -**Quick examples:** - -```bash -# Default: ingest a Ruby snippet, show the IR as JSON -cargo run --bin roundhouse-ast -- -e '[:a, :b]' - -# See what Prism produced before our ingest ran -cargo run --bin roundhouse-ast -- --stage prism -e '@x.y do end' - -# See the ERB compiler's Ruby output -cargo run --bin roundhouse-ast -- --stage compile-erb view.html.erb - -# Emit Ruby back from one expression's IR -cargo run --bin roundhouse-ast -- --stage emit-ruby -e '"a#{x}b"' - -# Run every stage in pipeline order, with headers -cargo run --bin roundhouse-ast -- --stages --erb -e '<%= x %>' - -# End-to-end round-trip: ingest → emit → ingest, IR-diff on divergence -# (Ruby input only — ERB round-trip retired with the parsed-AST emitter) -cargo run --bin roundhouse-ast -- --round-trip -e '[:a, :b]' -cargo run --bin roundhouse-ast -- --round-trip fixtures/tiny-blog/app/models/post.rb -``` - -**Flag reference:** - -| Flag | Purpose | -|------|---------| -| `-e CODE` | Inline Ruby source | -| `PATH` | Positional file (`.rb` or `.erb`; extension chooses ERB mode) | -| `--erb` | Force ERB compilation on inline input | -| `--stage NAME` | `prism`, `compile-erb`, `ingest` (default), `emit-ruby` | -| `--stages` | Run every stage, print each with a header | -| `--round-trip` | Ingest → emit → re-ingest; exit non-zero if IR diverges | -| `-h`, `--help` | Print usage | - -The JSON output for IR stages uses `serde_json::to_string_pretty` on the -`Expr` type — one field per line, deterministic key ordering — which is -also why structural diffs fall out naturally when two IRs disagree. - -### `dump_ir` - -Dump the *lowered* IR for a fixture — what the emitters actually -consume, after analyze and the post-analyze passes. Takes a -`Class#method` selector to narrow output. When an emitter produces -wrong code, this is the first question: is the IR wrong, or the -emitter's walk of it? - -```bash -cargo run --bin dump_ir -- --help -``` - -### `emit_preview` - -Emit one target's tree from a fixture straight to disk (default -`/tmp/rh--pass2`, override with `--out`) — the fastest way to -eyeball what a change did to emitted output without `--site` -packaging. See the header of `src/bin/emit_preview.rs`. - -### `roundhouse-compare` - -Cross-runtime HTML equivalence check. Boot Rails on one port, boot a -roundhouse-emitted runtime on another, hand `roundhouse-compare` a URL -list, and it walks the canonicalized DOM trees side-by-side looking for -the first structural divergence. Lives in `tools/compare/` (a -standalone crate — build it there, or use `scripts/compare` which -drives the whole flow). See -[`docs/pipeline/verification.md`](docs/pipeline/verification.md). - -### Round-trip debugging recipe - -When `roundhouse-ast --round-trip` fails on a file or expression, it -dumps both IR JSONs and diffs them automatically — the unified diff -highlights exactly which IR fields flipped, and a one-line change in -the source is almost always a one-hunk change in the JSON. For -emit-side divergence (the lowered-IR gates in -`tests/lowered_ruby_emit.rs`), pair the failing assertion with -`dump_ir` on the same selector to see the IR the emitter was handed. - -## Pipeline at a glance - -``` - Ruby source ─────────► Prism Node - │ - │ ingest::ingest_expr (src/ingest/expr.rs) - ▼ - ERB / HAML ─► compiled Expr / App (core IR, src/expr.rs + dialect) - Ruby │ - (src/erb.rs, │ analyze::Analyzer (src/analyze/) - src/haml.rs) ▼ - Expr (+ types + effects) - │ - │ lower::apply_post_analyze_lowerings - │ + the *_to_library passes (src/lower/) - ▼ - LibraryClass / LibraryFunction / FlatRoute / ... - │ - │ emit::{ruby, rust, typescript, ...} - │ dispatched by src/project.rs::target_files - ▼ - emitted source code + runtime// glue +```sh +bin/rh fixture +(cd fixtures && ../scripts/create-store store) ``` -Key files: - -- **`src/expr.rs`** — core `Expr` / `ExprNode`. Every new language - feature typically lands here first. -- **`src/dialect.rs`** — Rails-level structures (`Model`, `Controller`, - `View`, `RouteTable`, …) and the lowered `LibraryClass` / - `LibraryFunction` contract emitters consume. -- **`src/ingest/`** — Prism → IR. `expr.rs` holds `ingest_expr`, one - match arm per node kind; per-concern modules (model, controller, - routes, view, …) sit alongside — `mod.rs` is the roster. Unknown - constructs return `IngestError::Unsupported` in strict mode; survey - mode records and continues (`src/ingest/survey.rs`). -- **`src/erb.rs` / `src/haml.rs`** — template → Ruby source string. - Output is the input to the regular Ruby ingest path - (`src/ingest/view.rs` is the engine seam). -- **`src/analyze/`** — type inference + effect inference. The type - walk is `BodyTyper` in `src/analyze/body/mod.rs`; the effect walk is - in `src/analyze/effects.rs`; `mod.rs` orchestrates the fixpoint. See - [`docs/pipeline/analyze.md`](docs/pipeline/analyze.md). -- **`src/catalog/`** — method catalog; single source of truth for the - AR method surface (plus the gem catalog in `gems.rs`). See - [`docs/data/catalog.md`](docs/data/catalog.md). -- **`src/adapter.rs`** — `DatabaseAdapter` trait. See - [`docs/data/adapter.md`](docs/data/adapter.md). -- **`src/lower/`** — target-neutral lowerings. - `POST_ANALYZE_PASS_ORDER` in `src/lower/mod.rs` is the ordering - authority for the post-analyze pass pipeline; the `*_to_library` - modules produce the lowered shapes. See - [`docs/pipeline/lower.md`](docs/pipeline/lower.md). -- **`src/emit/`** — one module per target (`.rs` + - `/` submodules). Dispatch lives in - `src/project.rs::target_files`. See - [`docs/pipeline/emit.md`](docs/pipeline/emit.md). -- **`src/runtime_loader.rs`** — transpiles `runtime/ruby/` (the - framework runtime) into each target at emit time. -- **`runtime//`** — hand-written per-target glue copied - verbatim into emitted projects. See - [`docs/pipeline/runtime.md`](docs/pipeline/runtime.md). - -## Adding a new IR variant - -The pattern today (example: adding `ExprNode::Array`): - -1. **Declare the variant.** Add to `src/expr.rs`. Include any surface- - preservation fields needed for byte-for-byte round-trip (e.g. - `ArrayStyle` for `[:a]` vs `%i[a]` vs `%w[a]`). - -2. **Ingest.** Add an arm to `ingest_expr` in `src/ingest/expr.rs` - matching the relevant `as_*_node()`. Extract surface-preservation - fields from location bytes when needed. - -3. **Analyze.** Add cases in both `BodyTyper::compute` - (`src/analyze/body/mod.rs`, the type walk) and `visit_effects` - (`src/analyze/effects.rs`, the effect walk). Omit either at - your peril — missing effect propagation is a silent bug. - -4. **Emit.** Add a match arm in each live emitter's expression module - (`src/emit/ruby/expr.rs`, `src/emit/typescript/expr.rs`, - `src/emit/rust/expr/`, …). Ruby's arm must invert the ingest — - that's what `roundhouse-ast --round-trip` checks; other targets can - be approximations until a fixture sharpens them. - -5. **Test.** Add a unit test to `tests/ingest.rs` (one `parse_one` - helper call per surface form you claim to preserve). Run - `cargo test --test ingest`. If the new variant appears in - tiny-blog, its zero-diagnostics and IR-shape gates will also - catch regressions automatically. - -6. **Verify.** `cargo run --bin roundhouse-ast -- --round-trip -e 'EXAMPLE'` - should print `ok: IR stable across …`. - -**Common traps:** - -- Forgetting to add the match arm in `visit_effects` — code compiles - (it's a catch-all match), effects silently don't propagate. -- Normalizing source detail at ingest (e.g. stripping `%i[…]` style) - silently rewrites the author's spelling on emit. Keep a distinct IR - field for anything that would diverge — that's what the - surface-preservation fields on `Expr` exist for. -- Emit-side parens: `emit_send_base` respects `parenthesized` for both - implicit-self and explicit-receiver calls — don't regress this. -- Adjacent text chunks in ERB must stay merged across comment tags; - `compile_erb` buffers `pending_text` and only flushes on meaningful - tags. Bypass at your peril. - -## Repo map - -Beyond `src/` and `tests/`, the directories a newcomer will meet: - -- **`src/bin/`** — seven binaries: `roundhouse` (the main CLI: - `--target` / `--site`), `roundhouse-ast`, `roundhouse-check`, - `roundhouse-lsp` and `roundhouse-mcp` (the inference engine's LSP - and MCP servers), `dump_ir`, `emit_preview`. -- **`runtime/`** — per-target primitive runtimes plus `runtime/ruby/`, - the framework runtime transpiled into every target (and its own - test suite under `runtime/ruby/test/`). -- **`fixtures/`** — `tiny-blog/` (checked in), `real-blog/` - (generated; see above), `roda-blog/` (the experimental Roda target's - fixture). -- **`tools/compare/`** — standalone crate: the `roundhouse-compare` - DOM/JSON differ. -- **`scripts/`** — workflow scripts; the load-bearing ones are - `create-blog`, `compare`, `bench`, `smoke` (executes published - archives' READMEs verbatim — a CI contract), `e2e`, and the - `campfire-*` / `lobsters-*` conformance harnesses. -- **`e2e/`** — Playwright specs for the dynamic behavior a static DOM - diff can't reach (see `e2e/README.md`); `tests/browser_smoke/` is - the browser-side smoke harness. -- **`wasm/`** — a second crate: the compiler built for WebAssembly, - plus the in-browser playground / IDE / studio surfaces. -- **`editors/vscode/`** — VS Code extension wrapping `roundhouse-lsp`. -- **`kotlin-reference/` / `swift-reference/`** — hand-written - reference apps that served as forcing functions for those emitters - (see their READMEs). -- **`site/`** — static assets for the GitHub Pages site; `bench/` — - benchmark harness inputs. - -## See also - -- [`docs/README.md`](docs/README.md) — index of all architecture docs - and working plans. -- [`docs/data/`](docs/data/) — the compiler's inputs (Ruby + ERB, - schema/routes/seeds, method catalog, database adapter). -- [`docs/pipeline/`](docs/pipeline/) — analyze, lower, emit, runtime - integration, verification. -- [`AGENTS.md`](AGENTS.md) — orientation and invariants for agents and - new contributors. +Prerequisites and checked-in fixtures: [testing](docs/development/testing.md#fixtures). +Using Roundhouse rather than developing it: [user guide](docs/guide/README.md). diff --git a/README.md b/README.md index 96c60ed57..30f9392cd 100644 --- a/README.md +++ b/README.md @@ -153,10 +153,11 @@ Using it: the [user guide](docs/guide/README.md) — one page per door above, starting at [install](docs/guide/install.md) — and [`RELEASES.md`](RELEASES.md). -Working on it: [`DEVELOPMENT.md`](DEVELOPMENT.md) (build, test, the -`bin/rh` workflow runner, debugging tools, repo map), -[`AGENTS.md`](AGENTS.md) (the invariants not to break), and -[`docs/`](docs/README.md) — the architecture: the compiler's +Working on it: [`DEVELOPMENT.md`](DEVELOPMENT.md) is the short entry to the +[development handbook](docs/development/README.md); +[`AGENTS.md`](AGENTS.md) holds the invariants and +[CI for contributors](docs/ci/README.md) explains hosted checks. +[`docs/`](docs/README.md) maps the architecture: the compiler's [inputs](docs/data/), the [pipeline](docs/pipeline/) (analyze, lower, emit, runtime, verification), and the working plans. [`BETS.md`](BETS.md) is why this attempt is shaped differently from @@ -170,20 +171,12 @@ its predecessors; [`WHY.md`](WHY.md) is why do it at all. ## Contributing -Issues and pull requests are both welcome, and a PR does not need a -conversation first: CI runs a compact correctness/runtime floor plus -targeted checks. Ask a maintainer to apply `ci:full` for full PR validation; -the label expands tests, never publication. The complete matrix also runs -freshly every four hours. See [CI coverage](docs/ci-reuse.md) for requesting -full/fresh validation and the separate publication controls. -`CI summary` reports selected checks without imposing a merge requirement; -maintainers decide when to merge. Spinel and Campfire jobs marked -`continue-on-error` track current Spinel master, and red there is a signal -to investigate Roundhouse runtime, RBS or packaging as well as possible -upstream drift—not proof of an upstream fault. Setup, the test cycle, -and what a PR should carry are in -[`DEVELOPMENT.md`](DEVELOPMENT.md); the invariants not to break are in -[`AGENTS.md`](AGENTS.md). +Issues and pull requests are welcome; a PR does not need a conversation first. +Include a repro, a regression test, and what you verified. Start with +[contributor setup](docs/development/README.md) and the +[invariants](AGENTS.md). CI selects a compact floor plus targeted checks; +see [CI for contributors](docs/ci/README.md) to request full/fresh validation +and interpret advisory results. PR validation never deploys Pages. ## License diff --git a/bin/rh b/bin/rh index 885db056b..798490ccf 100755 --- a/bin/rh +++ b/bin/rh @@ -140,6 +140,12 @@ def version_of(output, re = /(\d+\.\d+(?:\.\d+)?)/) m ? m[1] : nil end +def requirement_met?(found, minimum) + found && (minimum.nil? || Gem::Version.new(found) >= Gem::Version.new(minimum)) +rescue ArgumentError + false +end + # Highest installed `rails` gem version satisfying a requirement string # (e.g. "~> 8.0.0"), as a concrete version usable with the `rails _X_` # selector. nil if none installed matches or the requirement is malformed. @@ -157,11 +163,11 @@ end # Subcommand: doctor DOCTOR_CHECKS = [ - { key: :ruby, label: 'Ruby', unlocks: 'always (rh itself)', min: '3.4' }, + { key: :ruby, label: 'Ruby', unlocks: 'always (rh itself)', min: File.read(File.join(ROUNDHOUSE_ROOT, '.ruby-version')).strip }, { key: :rust, label: 'Rust', unlocks: 'transpile, site, compare, verify' }, { key: :git, label: 'Git', unlocks: 'verify / verify --plan' }, { key: :python, label: 'Python 3', unlocks: 'optional verify hosted-coverage preview' }, - { key: :node, label: 'Node', unlocks: 'typescript target' }, + { key: :node, label: 'Node', unlocks: 'typescript target', min: '24' }, { key: :sqlite, label: 'SQLite', unlocks: 'ruby/spinel/rust targets' }, { key: :rails, label: 'Rails gem', unlocks: 'fixture' }, { key: :wrk, label: 'wrk', unlocks: 'bench' }, @@ -195,9 +201,16 @@ def cmd_doctor(argv) puts '' DOCTOR_CHECKS.each do |c| v = versions[c[:key]] - mark = v ? "\u2713" : "\u2717" - val = v ? v.ljust(10) : 'not found'.ljust(10) - puts " #{c[:label].ljust(10)} #{mark} #{val} #{c[:unlocks]}" + old = v && !requirement_met?(v, c[:min]) + mark = v && !old ? "\u2713" : "\u2717" + val = if v.nil? + 'not found' + elsif old + "#{v} (< #{c[:min]})" + else + v + end + puts " #{c[:label].ljust(10)} #{mark} #{val.ljust(14)} #{c[:unlocks]}" end puts '' @@ -217,7 +230,9 @@ def cmd_doctor(argv) puts 'Available now:' available.each { |s| puts " bin/rh #{s}" } - missing = DOCTOR_CHECKS.reject { |c| [:ruby, :python].include?(c[:key]) || versions[c[:key]] } + missing = DOCTOR_CHECKS.reject do |c| + [:ruby, :python].include?(c[:key]) || requirement_met?(versions[c[:key]], c[:min]) + end unless missing.empty? puts '' puts 'Install to unlock more:' @@ -235,7 +250,7 @@ def install_hint(key) when :rust then 'https://rustup.rs' when :git then 'https://git-scm.com' when :python then 'https://www.python.org (optional for verify)' - when :node then 'https://nodejs.org or `brew install node`' + when :node then 'Node.js 24+ — https://nodejs.org or `brew install node`' when :sqlite then 'macOS: preinstalled; Debian/Ubuntu: apt install libsqlite3-dev' when :rails then 'gem install rails' when :wrk then 'macOS: brew install wrk; Debian/Ubuntu: apt install wrk' diff --git a/docs/README.md b/docs/README.md index 5497f4241..ee53c6565 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,11 +4,19 @@ The user guide is [`guide/`](guide/README.md) — install, and one door each for analyzing, transpiling and compiling a Rails app. Everything below is for people working on roundhouse itself. -Architecture references live in the two subdirectories; the loose files -at this level are working plans. Status discipline: these docs describe -*architecture*, not day-to-day status — when a status claim here -disagrees with [`RELEASES.md`](../RELEASES.md) or CI, RELEASES.md and CI win -(see [`AGENTS.md`](../AGENTS.md)). +Use the current code and executed checks for current behavior; +[`RELEASES.md`](../RELEASES.md) records dated release snapshots. Architecture +references explain design, while working plans are point-in-time proposals. + +## Working on Roundhouse + +- [`development/`](development/README.md) — setup and local workflow; + [test selection](development/testing.md), [debugging](development/debugging.md), + and [compiler changes](development/compiler-changes.md). +- [`ci/`](ci/README.md) — understand checks, request full/fresh validation, + and distinguish validation from publication. Implementation details stay + in the workflows, scripts, and their tests. +- [`../AGENTS.md`](../AGENTS.md) — invariants and agent task navigation. ## Compiler inputs — [`data/`](data/) @@ -34,14 +42,13 @@ disagrees with [`RELEASES.md`](../RELEASES.md) or CI, RELEASES.md and CI win (per-target primitives + transpiled framework Ruby), including the semantic-divergence ledger. - [`verification.md`](pipeline/verification.md) — how we know the - output is correct: the test layers and the CI gate topology. + output is correct: evidence layers and their limits, not CI job topology. - [`bytecode.md`](pipeline/bytecode.md) — experimental bytecode target; parked, test-only. ## Reference -- [`env-gates.md`](env-gates.md) — every `ROUNDHOUSE_*` environment - variable the codebase reads. +- [`writebook.md`](writebook.md) — pinned external-corpus inventory and its limits. ## Working plans diff --git a/docs/ci-reuse.md b/docs/ci-reuse.md deleted file mode 100644 index e2f562cdd..000000000 --- a/docs/ci-reuse.md +++ /dev/null @@ -1,418 +0,0 @@ -# CI coverage and conservative PR-local reuse - -## Compact PR checks and full validation - -`.github/workflows/ci.yml` runs a compact floor on PRs and main pushes: - -- Fixture generation and `unit` (every `cargo test --all-targets` identity, - including emitted Ruby execution tests and the debug-profile bench emission - checks). -- Store analysis, Ruby/Rust/TypeScript comparisons against live Rails. -- TypeScript SharedWorker browser tests, Campfire conformance, and Campfire - comparison including its model/database differential. - -The unit job runs `scripts/ci-unit-tests.py`: library and package binaries first, -then integration targets in bounded Cargo batches (default 20; override with -`--batch-size` or `ROUNDHOUSE_UNIT_BATCH_SIZE`). Each batch is still -`cargo test --locked --test …` for build and execution — identities, failure -propagation, and local `cargo test --test NAME` selection stay intact. -After a successful integration batch, only that batch's integration executables -and their own unpacked split-DWARF sidecars are deleted. Shared libraries, -package binaries (including `CARGO_BIN_EXE` helpers), fingerprints, and -dependency artifacts remain for later batches and for the independent -dev-profile bench emission gate. Test results are never reused or cached. -Compile-everything-before-any-execute is intentionally not preserved: a later -batch can fail to compile after earlier batches have already run. The -`unit-build-timings` artifact still retains Cargo's HTML report when produced -(lib/bin build and the first integration wave). The test profile keeps file/line -backtraces with `line-tables-only` debug info. - -The Linux unit job also sets `CARGO_PROFILE_TEST_SPLIT_DEBUGINFO=unpacked`. -First-party split DWARF sidecars can be shared instead of repeated in every -integration-test executable. Sidecars for a finished integration target are -freed with that target after its batch succeeds; shared library/bin sidecars -stay. Disk comparisons must include sidecars and object files, not only -executable sizes. This override does not change local platform defaults, dev or -release profiles. The policy suite checks real library and integration-test -file/line backtraces and the batch orchestrator's coverage/reclaim rules. - -### Current-run debug compiler for Campfire (#317) - -After its own tests and debug-profile bench emissions, `unit` stages -`target/debug/roundhouse` (profile.dev, same shape as the harness scripts' -historical `cargo run`) with an `identity.txt` that records `source_sha`, -`profile=debug`, host/toolchain and `roundhouse --version`. The artifact -`roundhouse-debug-bin` is current-run only. - -`campfire-conformance` and `campfire-compare` (including the model/database -differential in that job) download it, require a non-empty producer -`artifact-id`, set `ROUNDHOUSE_BIN`, and emit through -`scripts/lib/roundhouse-bin.sh`. They do not install Rust and must not -silently rebuild via `cargo run`. A missing or non-executable -`ROUNDHOUSE_BIN` fails the lane. Local development keeps the cargo fallback -when the variable is unset. Release-profile jobs, Spinel toolchain builds and -cross-run binary reuse are out of scope. - -Build/execution and debug-bench phases also retain `unit-resources`: five-second -CSV samples of whole-runner CPU busy/I/O wait, available RAM and workspace -filesystem space, plus per-phase JSON summaries and Cargo `deps`/`incremental`/ -`build` allocated sizes. Because batches free finished integration artifacts, -JSON also records `disk_used_peak_bytes` and a roughly once-per-minute -`deps_peak` sample so end-of-phase sizes are not mistaken for the high-water -mark. Initial/final samples cover short commands too. Reports are outside the -Cargo cache and uploaded on failure; commands and exit codes are preserved. -Measurement/report I/O failures are best-effort warnings, never replacements -for the command result. - -These are resource measurements, not a performance gate. CPU percentages and -available RAM include other runner processes and the OS; available RAM excludes -reclaimable-cache pressure. Child CPU time is cumulative and may exceed wall -time; child peak RSS is the largest single process, **not** concurrent tree RAM. -Five-second samples can miss shorter spikes. To reproduce a Linux measurement: - -```bash -CARGO_PROFILE_TEST_SPLIT_DEBUGINFO=unpacked CARGO_INCREMENTAL=0 \ -python3 scripts/ci-resources.py --out /tmp/unit-resources/tests -- \ - python3 scripts/ci-unit-tests.py -``` - -These are nine validation executions, plus three small orchestration jobs -(`plan`, `compact-required`, `ci-summary`). Drafts select only fixture and -unit validation. **`CI summary` is informational:** it reports missing, -skipped, cancelled or failed selected non-advisory checks as red. Unselected -jobs may skip; advisory failures remain separate signals. This workflow does -not configure branch protection or require the summary to pass before merging; -maintainers decide when to merge. Publication still requires the compact floor -and verified assembly. - -The planner compares the actual PR merge tree with its base, not just the last -commit. Renames/deletions retain both ownership sets. Unknown diff identity -expands to full validation. Documentation-only PRs still get the summary -status instead of being left pending by workflow-level path filters. - -The CI helpers use Python's standard library, following the existing receipt -helper, without additional Python packages. - -| Changed inputs | Additional coverage | -|---|---| -| Target emitter file **or directory**, target runtime, toolchain/framework test | Owning comparison and archive smoke; existing embedded framework/toolchain suites stay intact | -| Ruby emitter | Ruby/JRuby owners plus advisory native Spinel core (`build-spinel`, `toolchain-spinel`, `compare-spinel`) | -| `runtime/ruby/`, native `runtime/spinel/`, or a focused Spinel test | Advisory native core plus the relevant focused Spinel framework suite | -| Interpreter-only `db_jruby` / `markly_jruby` or `scaffold/ruby_overlay` | Interpreted Ruby/JRuby owners, not native fanout | -| Spinel scaffold packaging | Advisory native core plus the native archive smoke | -| Shared emitter helpers (`src/emit/shared/`) | Full validation across target languages | -| Shared analyzer or lowerer | Compact floor; no mandatory Spinel lane on an ordinary PR; reviewers can request `ci:full` for broad/risky changes | -| `wasm/` | WASM build and IDE/playground/studio browser verification | -| Site/guide sources | Site/archive build and WASM verification, without publishing | -| Shared compare, framework, archive, or E2E harness | The checks owned by that harness | -| Proven body-only edits in `src/project.rs`'s interpreted Ruby/JRuby builders | Ruby/JRuby comparison and archive smokes, plus Writebook inventory | -| Proven body-only edits in its shared Ruby/Spinel builders | Ruby/JRuby owners plus all native Spinel/Campfire consumers and Writebook inventory; no unrelated target or WASM fanout | -| Cross-target packaging, CI policy/workflows/planner, Cargo/build/toolchain policy, unknown new target | Full validation | - -Project assembly is narrowed only when the base and event trees differ solely -inside the bodies of `ruby_runtime_files`, `jruby_runtime_files`, -`ruby_family_runtime_files`, `spinel_files` or `spin_shape`. Signatures and -every byte outside those bodies must remain identical. Shared helpers, -constants, dispatch, new/deleted functions, mode changes and unrecognized -source shapes still select full validation. This deliberately conservative -recognizer is not a Rust parser: raw strings (`r`, `br`, `cr`) within builder -bodies and block comments retain full coverage. Other changed paths and -`ci:full` can still expand the combined plan; no last-commit or PR-title inference -is used. - -### Requesting broader or fresh validation - -For broad/risky changes, or when targeted coverage is insufficient, request -**`ci:full`**. Applying labels requires upstream repository triage access or -higher; fork contributors and their agents without that access should ask a -maintainer to apply the label. A request in a PR comment alone does not trigger -CI. The label must exist in the upstream repository before it can be applied. - -- The PR must be **ready for review**: drafts retain fixture + unit only, - even with `ci:full`. -- Applying the label starts a new full-matrix run of the current **PR merge - tree**, without a new commit. Superseded PR runs are cancelled. -- Further pushes retain full coverage while the label remains set. -- Removing it starts a run with automatic coverage selection. Policy/shared - emitter changes can still select full coverage without the label. -- Full **coverage** does not disable conservative PR execution-receipt reuse. - For fresh execution, ask a maintainer to select **Re-run all jobs** on the - full-matrix run. A rerun retains that run's original SHA and event coverage; - rerunning an older compact run does not expand coverage or test a newer head. - -A manual **Actions → Full validation → Run workflow** dispatch validates the -chosen ref freshly. A branch-head dispatch is not a replacement for the PR -merge-tree check. Leave **publish** unchecked for validation only. - -### Validation is not publication - -`full` selects coverage; `publish` separately authorizes publication work. -Applying `ci:full` never enables `publish`. - -| Trigger | Publication behavior | -|---|---| -| PR, including `ci:full`, or ordinary main push | Validation/artifacts only; no deploy | -| Manual Full validation, `publish=false` (default) | Fresh validation only; no deploy | -| Manual Full validation, `publish=true` | Accepted only on canonical `rubys/roundhouse` main; guarded publication | -| Four-hour schedule on canonical main | Fresh full validation plus guarded publication | - -The shared validator and PR jobs have no Pages/OIDC deployment privileges and -no deploy job. Only the separate full-workflow deploy job receives those -permissions. Publication requests outside canonical main and schedule/manual -events are rejected. Publication still requires the compact floor, verified -assembly and a live-main SHA check; the detailed archive contract follows below. - -### Current-run artifacts and the full cycle - -Targeted archive smokes use `roundhouse --archives rust,go` (selected -`browse/.{json,tgz,zip}` outputs) and do not build WASM, demos, website -assets or unrelated archives. The same archive writers power `--site`, which -keeps the complete developer-facing build. Full publication and smoke consume -the same producer bytes, without later re-emission. - -`.github/workflows/full-ci.yml` checks canonical main at **00:17, 04:17, -08:17, 12:17, 16:17 and 20:17 UTC**, bundling full validation and publication. -Every cycle executes freshly; validation results are not cached. Spinel master -is resolved once per run, and evidence records the compiler revision actually -used. Its lanes remain advisory. Ordinary build caches and the conservative PR -execution receipts described below are unchanged. Full cycles also regenerate -both Rails fixtures: the PR-only source snapshot cache is never restored here. - -Archive producers upload with `always()`, preserving any files already produced -if a later producer step fails. The outcome report names the source SHA, run and -attempt, actual byte size/hash, and applicable Spinel provenance, and classifies -each archive `passed`, `reused`, `failed`, `unverified`, or `not-selected`. -Validation is byte-specific: a TGZ smoke does not certify sibling ZIP or JSON -bytes. A Spinel source archive contains source, not a built compiler; its native -smoke witness therefore records the compiler revision separately. Likewise a -Docker producer's compiler revision is distinct provenance from a native -archive consumer's compiler revision. - -Publication requires the **compact** floor and assembled site, not passing all -extra-target or advisory lanes: failed or unverified archives can still be -useful repros, but are not described as validated. Assembly waits for the same -run's outcome report, copies only that run's archives, and verifies the actual -copied bytes against the report hashes. It never rebuilds from a newer main. -The published sidecar at `ci/archive-results.json` separately records whether -each archive is present for download. Older-attempt witnesses remain visible -but are conservatively `unverified` on a rerun, not evidence that it passed. -Deployment guards are unchanged: the deployment lock rechecks that canonical -main still equals the validated SHA; lookup failure or a superseded snapshot -prevents publication. A new main commit racing the final check cannot be made -atomic with Pages deployment. -Started background runs finish; only the newest pending request is retained. -Extra comparison/smoke matrices use `max-parallel: 2`, GC uses 1. These bound -fanouts, **not** total repository concurrency or a guaranteed PR runner priority. - -## Reuse within selected PR checks - -`scripts/ci-reuse.py` can reuse **executed, successful** checks from the same -pull request. The allowlist is `store-check`, `writebook-inventory`, Rust archive -smoke, SharedWorker browser smoke, and the Rust inflector framework suite. -Unit tests, emitted-artifact producers, DOM comparisons, other selected -toolchain lanes and all selected main-branch checks execute freshly. -Routing changes the required coverage, not the execution-receipt trust rules. - -The SharedWorker browser and site jobs cache npm's download store through -`setup-node`, keyed by their checked-in harness/asset lockfile and the -TypeScript package-manifest recipe (`src/emit/typescript/package.rs`). This -warms downloads for freshly emitted apps, not `node_modules`, browser binaries, -builds or test results. Installation and builds still run, without offline -resolution or a cache-hit skip; floating dependencies still resolve normally. -Missing caches only cost downloads. Downstream receipts continue to fingerprint -the actual installed modules and built output, not the npm cache key. - -### Fixture inputs - -Installed gems use an isolated `GEM_HOME`, keyed by observed runner image -version/architecture, exact Ruby engine/version/platform and RubyGems/Bundler. -Weekly fallback keys stay within that compatibility boundary. Fresh generation -still checks for the current Rails release; gems are not shipped in the artifact. - -First-attempt PRs may restore packed blog/store source under an exact key combining -`bin/rh`, generator/validation scripts, `ci.yml`, the same observed environment -and UTC day, without fallback keys. This deliberately holds floating gem resolution -within one day, not a claim of deterministic generation. Main, scheduled/manual -runs and reruns always regenerate; full PR coverage alone does not disable reuse. -Successful default-branch output can seed PR reads; PR writes remain PR-scoped. -Missing/unavailable caches fall back to generation; corrupt source fails closed. - -`scripts/test-store` installs the frozen bundle and runs both guide tests on fresh -and restored paths. Every run uploads its own artifact, excluding scratch/logs and -Store storage but retaining the seeded Blog database. The source snapshot contains -no compiled output or test receipt; downstream fingerprints remain unchanged. - -## What must match - -For the two source checks, the fingerprint combines: - -- The actual checkout's Git merge-tree entries: paths, modes and blob IDs. It - includes compiler sources, every runtime, build configuration, lockfiles, - scripts, workflow definitions, shared test support, fixtures and unknown - paths. Only unrelated top-level `tests/*.rs` integration-test binaries are - excluded. Writebook's own `tests/writebook.rs` remains included. -- Every file, directory, permission and byte in the actual downloaded - Writebook or generated store tree. Tar/file modification times are not - inputs; source text, migration names and generated credentials are **not** - normalized. Unsupported file types or symlinks disable reuse. -- The observed hosted runner image/architecture, installed Rust/Cargo/C - compiler versions, build flags and downloaded action code. This compares - actual resolved inputs, not the strings `stable` or `ubuntu-latest`. - -Both commands use `--locked`. They run Rust analysis/emit inventory, not Rails, -external Ruby, or a target-language runtime, and install no apt/gem/npm -dependencies. The native binary's commit stamp is provenance, not an input to -these checks: neither check exercises `--version` or MCP server information. -SHA-stamped WASM and its consumers are **not** eligible. - -Any shared compiler change invalidates both jobs. A change only to an unrelated -Rust integration test can reuse Writebook. Store reuse will often miss because -fresh Rails generation changes its actual contents. A PR source-cache hit -preserves actual source bytes (including migration names and generated -credentials); receipt reuse still checks those bytes and the compiler inputs, -never just the generator recipe. - -## Downstream consumers - -These checks use **fresh output identity**, not producer source identity. The -producer always runs; a compiler edit can reuse a target's validation only if -its actual consumed output is identical. Harness, workflow, Cargo configuration, -shared test support and installation-policy changes still invalidate reuse. -Python 3.11+ is required for parsing the generated Cargo manifests and locks. - -| Consumer | Preparation before lookup | Validation on a miss | -|---|---|---| -| `smoke (rust)` | Resolve Cargo and E2E npm dependencies in a **separate** archive extraction; install the README's Chromium payload | Extract the original archive again, execute its README blocks unchanged, and retain the existing test-count floors | -| `browser-smoke-typescript` | Fresh emission, generated npm dependency resolution, Vite build, harness/browser installation | Existing `npm run test-only`, including fresh Vite preview and all browser tests | -| Rust inflector in `compare (rust)` | Fresh framework ingestion/emission and `cargo generate-lockfile` | Inner `cargo test --locked`; require an actual successful emitted inflector test, not just hand-written runtime tests | - -Consumer fingerprints include emitted source/configuration, generated locks, -and (for SharedWorker) **built `dist` bytes**. Rust archive identity also includes -the original `.tgz` bytes. Browser consumers include applicable installed npm -trees, actual Node/npm versions, installed OS package revisions, sqlite3, -and the complete explicitly selected browser payload. Only root-level browser -`.links` install/GC bookkeeping is excluded; browser executables, resources and -installation markers remain inputs. Source symlinks still disable reuse; -installed tools may contain only witnessed, internal file symlinks. - -Before issuing any receipt, recompute its complete witness, including external -source trees and the environment for Store and Writebook. Rust smoke -compares the **pristine validation extraction's** resulting locks, modules and -source against the resolver extraction, never copying prepared dependencies -into validation. Resolution drift or unexpected source changes suppress the -receipt, not the real test result. This preserves README coverage: a missing -install command is not rescued by preparation. Like the existing smoke lane, -this proves README execution with prepared system/browser prerequisites, not a -completely cold browser install. - -Rust smoke accepts only the audited Build/Setup/Test/E2E command shape and -pinned Playwright manifest. Dependency preparation uses `scripts/smoke ---extract-blocks` so it audits the same parser as real README execution. -Unknown README commands, external/path Cargo -sources, global Cargo configuration, external database/server overrides, -browser/test-selection overrides or unsupported file types execute normally. -Dependency preparation remains real work even on a hit; the saved work is -compilation and validation, not fabricated freshness. This assumes ordinary -trusted package-manager/CI behavior, not attestation against transient side -effects or malicious workflow authors. - -Receipts are scoped to the exact physical matrix job as well as the logical -consumer. Inflector reuse never suppresses the Rust DOM comparison or other -framework suites. Its receipt is created only after **inner actual execution**; -the outer harness returning success on a hit cannot create a new receipt. - -### Remaining downstream lanes — audited, not blanket-skipped - -| Family | Why it still executes / prerequisite for safe reuse | -|---|---| -| Other archive smoke targets (Crystal, Kotlin, Swift, C#, TypeScript, Go, Elixir, Python, Ruby/JRuby) | Each README resolves a different dependency closure during execution. Capture its fresh resolved closure and toolchain, preserve pristine README validation, and compare the validation witnesses before enabling each lane. | -| DOM compare matrix, Ruby/JRuby compare | Also consumes the live Rails oracle, Bundler closure, generated assets and target dependency resolution. An unchanged transpiled archive alone is insufficient. | -| Other framework/toolchain suites | Emit projects internally and resolve dependencies during testing. Need per-harness output boundaries and actual-execution floors, not a producer-source filter. | -| IDE/WASM browser | WASM intentionally embeds the current commit SHA. Do not normalize away a changed consumer input to force a hit. | -| Spinel framework/toolchain/archive lanes | Need fresh emitted source plus the actual unpinned Spinel binary, `spin` package closure, C toolchain and native-library identities. Advisory failures remain signals, never reusable success. | -| Campfire CRuby/Spinel compare, model differential and conformance | Include pinned Campfire source, Rails/gem oracle closure, assets, Redis/DB scenarios, native packages, Spinel binary and GC mode as applicable. Current harnesses still resolve mutable external inputs. | -| Campfire Docker smoke | Build the current Docker context and run the three HTTP checks every time. Archive identity alone cannot certify the image. Apt layers may be reused for an eight-hour window (~460 MB BuildKit export under `actions/cache`, primary key only — no cross-bucket restore); a fresh `spin pack` still recompiles `pack/`, and the smoke never skips the image build or HTTP. Do not cache the make layer or replace the install with a prebuilt image. | -| Site/archive producers, assembly and publication | Must produce current-run outputs and provenance; site content also fetches live bench data. They are not reused validation results. | - -## Evidence, not just a green run - -After actual validation succeeds, the job uploads an execution receipt for its -exact run attempt. Writebook's receipt bundle includes the two generated -reports. A future run checks the receipt's fingerprint and PR/repository/ -workflow/job identity, then independently queries GitHub's attempt-specific -jobs API to verify the completed job and every required validation step. -Raw step outcomes must also have been successful. A successful job in an -otherwise failed or cancelled workflow can be reused; failed, skipped, -cancelled, neutral, ambiguous or masked failures cannot. - -On a hit, the job's summary links the original executed job. Writebook reports -are restored as bounded, explicitly named **data**, then uploaded under the -normal artifact name in the current run. No old executable or built binary is -restored. A reused job never produces another execution receipt; there are no -chains of "passed because an earlier job was reused." - -Receipts expire after seven days. Lookup examines at most 20 recent runs of -this PR's branch, paginates artifact/job lists and has a 90-second API budget. -Missing/expired/malformed evidence, API permission failures, timeouts or absent -environment metadata execute the original check. Receipt bookkeeping cannot -fail validation. This uses ordinary `pull_request` with job-local -`actions: read`, never privileged `pull_request_target`. Receipts do not contain -tokens or credentials and are not accepted across PRs or into main. - -This is ordinary GitHub CI provenance, not cryptographic attestation against -an author who can modify the PR's workflow itself. - -## Further load reductions and cancellation - -Superseded PR runs still cancel; main runs that have started still finish. -Completed successful jobs with receipts remain reusable even if another job -later fails or the overall run is cancelled. An unfinished or cancelled job is -not proof of successful validation. Keeping old full runs alive indiscriminately -would add concurrent work without proving the newest merge-tree inputs. -Stage-aware cancellation needs a separate controller to inspect the previous -run; GitHub's concurrency expression cannot inspect that run's job progress. -This PR does not add privileged cancellation machinery. - -A separate measured follow-up may evaluate sharing the `unit` job's Debug -binary with Campfire conformance/compare. This change does not copy Cargo target -directories, share images, introduce Bazel, or claim a measured queue-time -improvement. Any sharing proposal still needs compatible toolchains/build flags, -native-library ABI, current-commit provenance, and proof at each consumer. - -Matrix `fail-fast: false` still preserves cross-target diagnostic coverage; -advisory lanes remain signals rather than reasons to cancel independent checks. -Receipt reuse does not bypass selected checks' dependency or failure handling. -The Campfire Docker recipe no longer downloads a separate Dockerfile frontend: -its ordinary multi-stage instructions use the bundled frontend and COPY -preserves the archive's executable boot mode. The real Docker smoke remains -enabled. Measured 2026-10-03 (hosted job 111138541027 and a local rebuild of -the published archive): cold build is ~138–158 s, dominated by compiling -`pack/src/campfire.c` (~95 s). The build-apt and runtime-apt layers -(~30–38 s, parallel) are exported as a ~460 MB BuildKit cache and restored -for an eight-hour UTC window (primary key only, no cross-bucket restore) so a -later `spin pack` still recompiles while skipping apt. A `docker-container` -Buildx builder exports `type=local` on hosted runners; the image is loaded as -`campfire` and the three HTTP checks always run. Cache export uses -`ignore-error=true` so a cache backend failure does not skip those checks. -Do not cache the make result or ship a prebuilt image in place of the build. - -## Forcing a fresh check and extending the allowlist - -Rerun checks bypass matching receipts. Choose **Re-run all jobs** to execute -the entire selected graph freshly, rather than only failed or individual jobs. -The rerun keeps the original SHA, ref and coverage; the fresh attempt may -produce new evidence. Draft, `needs` and main-branch behavior is unchanged. - -Before adding another job, audit its complete input contract, including -generated artifacts, framework tests, executable README blocks and the actual -versions of external packages/toolchains it resolves. An unresolved mutable -input makes it ineligible. Do not substitute a PR-wide changed-files filter or -the last head-commit diff for merge-tree identity. Keep artifact producers fresh -unless current-run outputs and their provenance can be preserved honestly. - -Run `python3 -B tests/ci_reuse_test.py -v` and -`cargo test --test workflow_yaml_parses` when changing this policy. The Rust -workflow tests execute the Python adversarial suite, so normal unit CI gates it. -For coverage routing, archive evidence or full-workflow changes, also run -`cargo test --test ci_policy_workflow`; it executes the planner and archive -evidence suites and checks the publication boundaries. diff --git a/docs/ci/README.md b/docs/ci/README.md new file mode 100644 index 000000000..a55a30526 --- /dev/null +++ b/docs/ci/README.md @@ -0,0 +1,104 @@ +# CI for contributors + +Use this page to understand a PR's checks, request broader validation, or +read a failure. Local commands live in [development/testing.md](../development/testing.md). +The workflows and their tests own implementation details, not this handbook. + +## What runs + +Ready PRs and ordinary main pushes run a compact floor: fixture preparation, +unit tests, Store analysis, Ruby/Rust/TypeScript comparisons against Rails, +SharedWorker browser tests, and Campfire conformance/comparison. Three unit +shards cover all package test targets in bounded batches; ignored integrations +need selected toolchain lanes. Framework and toolchain suites also run inside +comparison jobs, not necessarily as standalone checks. + +Selected lanes start once their inputs are ready, without waiting for unit +tests to pass. Campfire consumes an independently built same-run debug compiler. +Speculative work may therefore finish even when a unit shard fails; the final +gate still requires all selected non-advisory checks, including the unit matrix. + +Additional checks are selected from the changed inputs. Target-specific +changes select owning lanes; shared emit, build, packaging, and CI-policy +implementation changes can select full coverage. Changes only to CI contract +tests retain the compact floor rather than expanding to every target. +Analyzer/lowerer changes do not automatically select every target: request +full coverage when the risk warrants it. +The planner uses the actual PR merge tree against its base, includes both +sides of a rename, and expands uncertain diff identity to full coverage. +See the run's **plan** job for its selected jobs and reasons. + +Drafts default to fixture preparation and unit tests only. `ci:full` overrides +that floor and runs full validation while the PR is still a draft. +Documentation-only PRs still receive checks; changes to the rendered user +guide also select site/browser coverage. + +## Request full or fresh validation + +- **More coverage:** ask a maintainer to apply `ci:full` to a ready or draft PR. The + label triggers a full run of the current PR merge tree and keeps full + coverage on later pushes. A comment requesting it is not itself a trigger. +- **Fresh execution:** select **Re-run all jobs** on the desired run. + Selected PR checks may otherwise reuse successful execution evidence on + identical inputs. Full coverage alone does not disable that reuse. +- **A newer head:** needs a new run. Reruns retain the original SHA and + coverage; rerunning an old compact run neither tests the new head nor + expands its matrix. +- **Manual full validation:** Actions → **Full validation** → **Run workflow**. + Leave `publish` unchecked. This executes freshly on the chosen ref; a + branch-head dispatch is not a substitute for a PR merge-tree check. + +Superseded PR runs cancel. Already-started main/full runs finish. Neither +dependency-cache hits nor restored fixture source are test results; check +the job summary for any explicitly reused execution evidence. + +## Read results honestly + +`CI summary` reports selected non-advisory checks that failed, skipped, were +cancelled, or are missing. Unselected skips are expected. It is informational: +the workflow does not impose branch protection or decide when to merge. + +Read advisory jobs and raw step outcomes too. `continue-on-error` can hide a +Spinel failure in the overall conclusion. A green summary is not proof that +every target passed, and a missing compiler/archive can block dependent checks +without those checks having executed. Spinel failures can originate in +Roundhouse, its runtime/RBS/packaging, or upstream; establish the cause before +attributing it. Do not add workarounds just to hide advisory failures. + +For a failing lane, inspect its logs and retained reports/repro artifacts, +then run the owning local harness. Do not regenerate corpus baselines or +broaden comparison masks merely to turn CI green. + +## Publication is separate + +PR checks and ordinary main pushes never deploy Pages. Full validation runs +every four hours on canonical `rubys/roundhouse` main; that scheduled cycle +also requests publication. Manual publication is opt-in on canonical main. + +Pages requires the compact floor, verified same-run assembly, and a live-main +SHA check before deployment. It does **not** require all extra/advisory lanes +to pass. Failed archives may be useful repro downloads, not validated output. +The published `ci/archive-results.json` reports archive presence and validation +separately. Evidence applies to exact bytes: testing a TGZ does not certify its +sibling ZIP/JSON. A commit racing the last main check is not atomic with deploy. + +CLI binary releases are different: the tag-triggered cargo-dist +[release workflow](../../.github/workflows/release.yml) creates GitHub Releases; +it does not inherit the Pages validation guards. + +## Changing CI + +Read the owner and its executable contract before editing: + +| Concern | Source | Tests | +|---|---|---| +| Coverage and execution | [ci.yml](../../.github/workflows/ci.yml), [ci-plan.py](../../scripts/ci-plan.py), [ci-unit-tests.py](../../scripts/ci-unit-tests.py) | `tests/ci_policy_workflow.rs`, `tests/workflow_yaml_parses.rs` | +| Toolchain selection | [ci.yml](../../.github/workflows/ci.yml), [`.ruby-version`](../../.ruby-version), [bin/rh](../../bin/rh) | `tests/ci_toolchain_workflow.rs`, `tests/rh_verify.rs` | +| Receipt reuse | [ci-reuse.py](../../scripts/ci-reuse.py) | `tests/ci_reuse_test.py` | +| Fixture caching | [generate-fixture in ci.yml](../../.github/workflows/ci.yml) | `tests/ci_fixture_workflow.rs` | +| Archive evidence and Pages | [ci-archive-evidence.py](../../scripts/ci-archive-evidence.py), [full-ci.yml](../../.github/workflows/full-ci.yml) | `tests/ci_policy_workflow.rs` | + +Run the relevant suites with `cargo test --test `; Python tests can run +directly with `python3 -B tests/ci_reuse_test.py -v`, for example. Detailed +cache keys, receipt fingerprints, resource sampling, and GC-mode witnesses +belong beside their implementation and tests, not in a parallel prose spec. diff --git a/docs/data/ruby-and-erb.md b/docs/data/ruby-and-erb.md index 5e6f0fc20..0cfc8b0d9 100644 --- a/docs/data/ruby-and-erb.md +++ b/docs/data/ruby-and-erb.md @@ -51,8 +51,8 @@ Unsupported constructs return `IngestError::Unsupported { file, message }` rather than silently dropping them. "Loud by design" — a missing arm is a signal that either the IR needs a new variant or the recognizer needs to widen. See [Adding a new IR -variant](../../DEVELOPMENT.md#adding-a-new-ir-variant) for the -six-step pattern. +variant](../development/compiler-changes.md#adding-an-ir-variant) for the +development checklist. That's the strict path, and it's the default. Survey mode — `roundhouse-check --continue`, or `ROUNDHOUSE_INGEST_SURVEY=1` — diff --git a/docs/data/schema-routes-seeds.md b/docs/data/schema-routes-seeds.md index e1fd93582..d8c2ecee9 100644 --- a/docs/data/schema-routes-seeds.md +++ b/docs/data/schema-routes-seeds.md @@ -279,7 +279,7 @@ apps get the same fallback for Sequel-DSL migrations via The real-blog fixture generator (`scripts/create-blog`) runs `rails db:prepare` after generating migrations, so `schema.rb` always exists by the time ingest runs. See -[`../../DEVELOPMENT.md`](../../DEVELOPMENT.md#fixtures). +[fixture setup](../development/testing.md#fixtures). ## Test fixtures: `test/fixtures/*.yml` diff --git a/docs/development/README.md b/docs/development/README.md new file mode 100644 index 000000000..1d75c9671 --- /dev/null +++ b/docs/development/README.md @@ -0,0 +1,63 @@ +# Contributor development + +This handbook is for changing Roundhouse. For using it on a Rails app, read +the [user guide](../guide/README.md). [AGENTS.md](../../AGENTS.md) holds the +invariants; [RELEASES.md](../../RELEASES.md) records dated release claims. + +## Setup + +Install Rust through rustup; the selected toolchain is +[`rust-toolchain.toml`](../../rust-toolchain.toml), not Cargo's minimum +`rust-version`. Keep the stack budgets in [`.cargo/config.toml`](../../.cargo/config.toml). +Toolchain upgrades need native and browser/WASM verification. + +Use the MRI line in [`.ruby-version`](../../.ruby-version). CI selects its +latest patch through `env.MRI_RUBY` in the workflow; `bin/rh doctor` reads +the local minimum from that file. Git and Ruby run `bin/rh verify --plan`; execution +also needs Cargo. Python 3 is optional for its hosted-coverage preview. +The default suite loads Ruby gems as well as Rust code. Prepare them and +the generated fixtures: + +```sh +gem install rails rails-html-sanitizer sqlite3 bcrypt minitest rake --no-document +gem install activerecord -v '~> 8.1.0' --no-document +bin/rh fixture +(cd fixtures && ../scripts/create-store store) +bin/rh doctor +cargo build --locked +``` + +Selected ignored integrations need their own SDKs and dependencies; see the +harness you intend to run. `doctor` reports installed tools, not complete +test readiness. Fixture generation uses Rails and can change with its release. + +## Local loop + +1. Read the [compiler ownership map](compiler-changes.md) for the affected path. +2. Pin the intended behavior with a regression test. Pick inputs that distinguish + the fix from a plausible wrong implementation, not just a no-crash case. +3. Iterate with [focused tests](testing.md), inspecting [IR/output](debugging.md) + when needed. Removing an error diagnostic requires emitted execution coverage. +4. Run the default `cargo test` suite before committing; use + `cargo test --all-targets` at milestones. A focused pass is not full CI. + +For a PR, include the repro, regression test, and what you actually verified. +Contributors use a fork and PR against upstream main; committers follow the +repository's main-branch convention. Stage only your own changes and include +the standard `Co-Authored-By` trailer. Missing local SDK coverage must be +reported, not described as passing; request [broader CI](../ci/README.md) when needed. + +## Repository workflows + +`bin/rh --help` lists commands; each subcommand has `--help`. + +| Workflow | Command | +|---|---| +| Inspect prerequisites / fetch an archive | `bin/rh doctor` / `bin/rh fetch ` | +| Emit the blog fixture | `bin/rh transpile ` | +| Run the emitted Ruby app | `bin/rh dev ruby` (also `test ruby`, `run ruby`) | +| Compare against Rails | `bin/rh compare ` | +| Benchmark / build the complete site | `bin/rh bench` / `bin/rh site` | + +`bin/rh clean ` removes that emitted build; `bin/rh clean fixture` +removes real-blog, not Store. Neither is a Cargo-cache cleanup command. diff --git a/docs/development/compiler-changes.md b/docs/development/compiler-changes.md new file mode 100644 index 000000000..861fa384e --- /dev/null +++ b/docs/development/compiler-changes.md @@ -0,0 +1,75 @@ +# Compiler changes + +Read [AGENTS.md](../../AGENTS.md) for invariants. This page helps locate the +owner of a change; [pipeline internals](../pipeline/) and [compiler inputs](../data/) +explain the individual stages. + +## Ownership map + +Application emission follows Ruby/template ingest → analysis → shared +post-analyze lowering → target emission → packaged project/runtime. +Source consumers (check/LSP/MCP and `emit_preview`) intentionally analyze +without that shared lowering sequence; see [`src/session.rs`](../../src/session.rs). + +| Responsibility | Start here | +|---|---| +| Expression IR / application structures | `src/expr.rs`, `src/dialect.rs` | +| Prism → IR | `src/ingest/expr.rs`; other inputs under `src/ingest/` | +| ERB/HAML → Ruby | `src/erb.rs`, `src/haml.rs`, `src/ingest/view.rs` | +| Type / effect inference | `src/analyze/body/mod.rs`, `src/analyze/effects.rs` | +| Method catalog / DB adapter | `src/catalog/`, `src/adapter.rs` | +| Shared lowering / pass order | `src/lower/mod.rs::POST_ANALYZE_PASS_ORDER` | +| Target syntax | `src/emit/` | +| Project assembly / target dispatch | `src/project.rs::target_files` | +| Framework sources / shared transpile driver | `runtime/ruby/`, `src/runtime_loader.rs` | +| Hand-written target glue | `runtime//` | + +Framework behavior belongs once in `runtime/ruby/` or a shared lowering, not +as the same special case in several emitters. The runtime loader shares its +transpile driver through target hooks. New Ruby runtime files also need +registration in `src/project.rs::spinel_files`. + +Other entry points: `src/bin/` (CLI/debug tools), `wasm/` (browser compiler), +`editors/vscode/` (LSP client), `tools/compare/` (standalone differential oracle). +`scripts/` drives workflows; `tests/`, `e2e/`, and `tests/browser_smoke/` carry +the regression and browser harnesses. + +## Adding an IR variant + +1. Declare the variant in `src/expr.rs`. Retain source distinctions needed + for expression IR round-trip; do not impose whole-app source preservation. +2. Ingest the construct in `src/ingest/expr.rs`. Unsupported shapes must + produce an honest diagnostic, not disappear. +3. Update both type inference and effect traversal. An exhaustive match + catches missing variants, not missing recursion or wrong effect propagation. +4. Add a shared lowering when targets need a simpler/common shape. Respect + declared pass ordering and inspect all downstream walkers of the new nodes. +5. Implement correct target emission or keep the unsupported boundary explicit. + Ruby expression emission must support snippet IR stability. Other targets + are not permitted to silently approximate a construct accepted by `check`. +6. Pin ingest, inferred/lowered shape, and semantics with the appropriate + [tests](testing.md#choosing-tests). Removing an error requires an emitted + regression in `tests/emit_and_run.rs`, not just fewer diagnostics. + +```sh +cargo test --test ingest +cargo run --bin roundhouse-ast -- --round-trip -e '[:a, :b]' +``` + +## Semantic checks + +Preserve evaluation order, short-circuiting, and once-only evaluation. Use +asymmetric values and an effectful operand in regression tests where duplication +or reordering could be hidden. A pass that cannot preserve semantics must leave +an explicit unsupported/residue boundary rather than emit plausible wrong code. + +The equivalence claims are different: + +- Expression ingest → Ruby emit → ingest checks **IR stability**. +- Whole-app byte-for-byte source round-trip is retired. +- `tests/lowered_ruby_emit.rs` checks lowered structure; + `tests/spinel_toolchain.rs` exercises native compilation; + `tests/emit_and_run.rs` exercises emitted Ruby behavior. + +A clean analyzer result alone proves none of the last two. Target-only runtime +gaps must remain named; do not claim support merely because another target runs. diff --git a/docs/development/debugging.md b/docs/development/debugging.md new file mode 100644 index 000000000..63472ced3 --- /dev/null +++ b/docs/development/debugging.md @@ -0,0 +1,72 @@ +# Debugging + +First locate the broken boundary: ingest IR, typed/lowered IR, emitted source, +or the running program. Build the relevant tool once or invoke it with +`cargo run --bin --`. + +## Inspect a Ruby expression + +[`roundhouse-ast`](../../src/bin/roundhouse-ast.rs) exposes Prism, template +compilation, ingest, and Ruby expression emission: + +```sh +cargo run --bin roundhouse-ast -- --help +cargo run --bin roundhouse-ast -- -e '[:a, :b]' +cargo run --bin roundhouse-ast -- --stage prism -e '@x.y do end' +cargo run --bin roundhouse-ast -- --stage compile-erb view.html.erb +cargo run --bin roundhouse-ast -- --stage emit-ruby -e '"a#{x}b"' +cargo run --bin roundhouse-ast -- --stages --erb -e '<%= x %>' +cargo run --bin roundhouse-ast -- --round-trip -e '[:a, :b]' +``` + +A positional `.rb`/`.erb` file replaces `-e`; `.erb` selects template mode. +IR stages print JSON. `--round-trip` accepts plain Ruby only and compares +ingest → emit-ruby → ingest IR; it does not prove whole-app source equality +or runtime correctness. On divergence it prints both IRs and a diff. + +## Inspect lowered IR + +[`dump_ir`](../../src/bin/dump_ir.rs) analyzes and lowers a fixture before +dumping the shape consumed by application emitters. Narrow it to the failing +class/method rather than reading the entire app: + +```sh +cargo run --bin dump_ir -- --help +cargo run --bin dump_ir -- fixtures/real-blog --select 'ArticlesController#create' +cargo run --bin dump_ir -- fixtures/real-blog --format json --select Article +``` + +`--raw-views` instead shows pre-lowering view bodies. Use the same selector +as the failing regression to distinguish a wrong lowering from a wrong emitter. + +## Inspect emitted files + +[`emit_preview`](../../src/bin/emit_preview.rs) is a narrow bench/browser +debugging tool. It analyzes source IR directly and emits bare files, **not** +the complete packaged application or the shared post-analyze lowering path: + +```sh +cargo run --bin emit_preview -- --target rust --out /tmp/rh-preview fixtures/real-blog +``` + +It has no `--help`; its source lists supported targets and TypeScript profiles. +It replaces the output directory, so use a disposable path. For normal +application assembly, including Ruby/Spinel, use `bin/rh transpile `. + +## Compare running output or measure phases + +`bin/rh compare ` drives Rails and the emitted server together. +The raw comparator is a [standalone crate](../../tools/compare/), not a root +Cargo binary. Using it on your own app: [the compare guide](../guide/verifying.md). +Do not mask a genuine translation bug as a variable value. + +To see compiler phase wall times and process peak RSS: + +```sh +ROUNDHOUSE_TIMINGS=1 cargo test --test emit_and_run the_unedited_blog_runs -- --nocapture +``` + +Timing is opt-in; a process high-water RSS is not per-phase allocated memory. +Other debugging controls are discoverable at their readers: +`rg -n 'ROUNDHOUSE_' src/ scripts/`. Matches include comments and generated +code; inspect the actual read before assuming a variable is live. diff --git a/docs/development/testing.md b/docs/development/testing.md new file mode 100644 index 000000000..054af1e92 --- /dev/null +++ b/docs/development/testing.md @@ -0,0 +1,100 @@ +# Testing + +Choose tests by the behavior you changed, not just the nearest filename. +[Setup](README.md#setup) prepares the default suite; selected integrations +own additional prerequisites. [Verification layers](../pipeline/verification.md) +explain what each kind of evidence proves. + +## Choosing tests + +| Change | Starting checks | +|---|---| +| Ingest / expression IR | `tests/ingest.rs`; snippet `roundhouse-ast --round-trip` | +| Analysis / diagnostics | `tests/analyze.rs`, `tests/real_blog.rs` | +| Lowering | Owning regression suite; inspect the lowered shape with `dump_ir` | +| Ruby emission | `tests/lowered_ruby_emit.rs`; emitted execution for semantics | +| Framework Ruby | `tests/runtime_ruby_unit.rs`, `tests/runtime_src_integration.rs`, selected `framework_tests_` | +| New supported construct (removed error) | `tests/emit_and_run.rs`: zero errors **and** correct emitted Ruby execution | +| A target's emitted project | `_toolchain` with `--ignored`; comparison against Rails when behavior changes | +| CI implementation | The owning [CI contract tests](../ci/README.md#changing-ci) | +| Documentation references | `tests/docs_references.rs` | + +A diagnostic-count test does not prove runtime support. Fixture lanes cannot +exercise constructs absent from their inputs; add a targeted emitted regression. +Framework suites may cover only a named subset (Rust CI uses the inflector +subset); a suite's green result is not a claim about everything in that target. + +## Fixtures + +- `fixtures/tiny-blog/` and `fixtures/tiny-api/` are checked in. Use them for + small shape/absent-feature gates; extend them deliberately. +- `fixtures/real-blog` is generated by `bin/rh fixture` using + [`scripts/create-blog`](../../scripts/create-blog). +- `fixtures/store` is generated by + `(cd fixtures && ../scripts/create-store store)`; its generator runs the + Rails guide tests too. +- External corpora have their own pins and setup. For example, + [Writebook](../writebook.md) is an inventory, not runtime conformance. + +The generated trees are ignored by Git; a fresh clone has neither. Accessors in +[`src/fixtures.rs`](../../src/fixtures.rs) name the missing tree and command. +`bin/rh verify` lists missing fixtures but never installs or generates them. +Missing Ruby, gems, or SDKs can cause a harness to skip or fail; read the +executed test output rather than assuming exit zero means coverage. + +## Focused verification + +`bin/rh verify` runs library tests, then only the integration suites you select, +sequentially and fail-fast. It is a foreground developer loop, **not** a hosted +CI emulator or merge approval. + +```sh +bin/rh verify --plan --base main --test ingest +bin/rh verify --test ingest --test real_blog +bin/rh verify --test framework_tests_ruby --ignored +bin/rh verify --toolchain ruby --json > /tmp/verification.json +``` + +- Repeat `--test` or `--toolchain` to select more suites. Default `--test` + excludes ignored tests; an ignored-only suite otherwise executes nothing. + `--ignored` runs only ignored tests in selected integration suites, not library + tests. `--toolchain` already selects ignored tests in `_toolchain`. +- `--plan` is read-only: no Cargo, installs, tests, or verification lock. +- `--base REF` compares the current working tree with that exact commit + (default `HEAD`), including non-ignored untracked files. It neither fetches + nor finds a merge base. The hosted selection preview is informational, + not GitHub's actual PR merge-tree/draft/label context. +- Execution uses locked Cargo commands and one test thread; Cargo workers + default to at most four unless configured. `--jobs` overrides workers. + Explicit Cargo environment settings are preserved; incremental defaults off. +- The worktree lock coordinates verifier callers only. Do not run unrelated + Cargo builds/tests concurrently in that checkout. +- `--json` separates executed, failed, and not-run checks; child output goes to + stderr. It includes filesystem-space snapshots, with advisory low-space + warnings. It is **not** a reusable receipt or content fingerprint. + +For all options, use `bin/rh verify --help`. The implementation and verifier +contract tests are [`bin/rh`](../../bin/rh) and +[`tests/rh_verify_test.rb`](../../tests/rh_verify_test.rb). + +## Direct Cargo checks + +```sh +cargo test --locked --lib +cargo test --locked --test ingest +cargo test --locked --test framework_tests_ruby -- --ignored --nocapture +cargo test --locked --test rust_toolchain -- --ignored --nocapture +cargo test --locked # default suite before commit +cargo test --locked --all-targets # milestones +``` + +Focused checks do not replace the default-suite commit bar. Native toolchain +and framework integrations are generally ignored so local default tests do not +require every SDK. They are not all separate GitHub jobs; see [CI](../ci/README.md). + +Keep repository debug profiles intact for readable backtraces. For debugger +locals, opt into `CARGO_PROFILE_TEST_DEBUG=2`; suites that assert file/line +backtraces need symbols. Neither the verifier nor a passing subset cleans old +build artifacts. If space is exhausted, stop builds and inspect the dedicated +target directory before removing disposable outputs; never strip live programs +or clean a cache shared with another checkout. diff --git a/docs/env-gates.md b/docs/env-gates.md deleted file mode 100644 index 959f60b4d..000000000 --- a/docs/env-gates.md +++ /dev/null @@ -1,51 +0,0 @@ -# Environment-variable gates - -Every `ROUNDHOUSE_*` environment variable the codebase reads, what it does, and -(for the migration toggles) when it can be deleted. Regenerate the authoritative -list with: - -```sh -grep -rhoE 'ROUNDHOUSE_[A-Z0-9_]+' src/ scripts/ | sort -u -``` - -A variable is **live** if a `std::env::var(...)` (Rust) or `${VAR:-…}` / `[[ "$VAR" … ]]` -(shell) actually reads it. Several `_V1`/`_V2`/`_LEGACY` names now appear **only in -comments** — their gate was removed when the new path became unconditional; those are -flagged *vestigial (remove)* below. - -## Build / analysis inputs (permanent) - -| Var | Default | Read at | Effect | -|-----|---------|---------|--------| -| `ROUNDHOUSE_APP_ROOT` | working directory | `src/mcp.rs:77` | MCP server's app root when `argv[1]` is absent. | -| `ROUNDHOUSE_ASSETS_DIR` | unset → no injection | `src/project.rs:859` | Directory of pre-compiled assets injected into the site archive as `static/assets/*` (the build-site CI job compiles once, then points every target's archive at it). | -| `ROUNDHOUSE_INGEST_SURVEY` | `0` (off; strict path) | `src/bin/roundhouse-check.rs:36` | `=1` (or `--continue`) activates survey mode: ingest keeps going past per-file errors instead of failing fast. | - -## Emitted-app runtime (not a roundhouse gate) - -| Var | Default | Read at | Effect | -|-----|---------|---------|--------| -| `ROUNDHOUSE_BASE` | `/` | emitted TS: `src/emit/typescript.rs:1272` | Base URL path baked into the TypeScript target's router (`process.env.ROUNDHOUSE_BASE`, trailing slash required, e.g. `/roundhouse/blog/`). Read by the *generated* app at its own runtime, not by roundhouse. | - -## Feature flags (permanent, opt-in/opt-out) - -| Var | Default | Read at | Effect | -|-----|---------|---------|--------| -| `ROUNDHOUSE_PARAM_BINDS` | `0` (off) | `src/lower/arel/visitor.rs:51` | `=1` emits the placeholder-bind form for `Db.prepare` read paths (prototype; default-off pending lobsters spinel hit-rate measurement). | -| `ROUNDHOUSE_RUST_V2_EMIT_TESTS` | on (only `=0` disables) | `src/emit/rust.rs` | Opt-*out* for emitting the Rust target's test files (the `_V2_` in the name is a strangler-era relic; the var is live). | - -## Migration toggles (temporary — track for removal) - -The rule for these: they exist to keep an old code path reachable during a -strangler-fig migration. Once the new path is unconditional and the old files are -deleted, the toggle has nothing to fall back to and should be removed along with any -comments that reference it. - -| Var | Default | State | When to remove | -|-----|---------|-------|----------------| -| `ROUNDHOUSE_ELIXIR_V1` | `0` | **live** (`scripts/compare`) — selects the legacy `App.Main.run` entry point and drops the `.json` paths. | Delete when the v1 Elixir app shell is removed (per the comment in `scripts/compare`). | - -> Removed 2026-08-19: `ROUNDHOUSE_RUST_V2`, `ROUNDHOUSE_RUST_V2_LEGACY`, -> `ROUNDHOUSE_GO_V2`, `ROUNDHOUSE_GO_V2_MODELS` — all four had become -> comment-only after their migrations went unconditional; the stale -> comment references are scrubbed. diff --git a/docs/guide/rails-coverage.md b/docs/guide/rails-coverage.md index cc22abae6..76e4bed22 100644 --- a/docs/guide/rails-coverage.md +++ b/docs/guide/rails-coverage.md @@ -25,7 +25,7 @@ tiers. comments, nested routes, validations, Turbo Streams, Action Cable, Tailwind, JSON endpoints) is the shared DOM-equivalence fixture for **every server target** in full validation. Ordinary PRs/main pushes run -the compact floor plus selected target lanes; see [CI coverage](../ci-reuse.md). +the compact floor plus selected target lanes; see [CI coverage](../ci/README.md). A passing target's comparison proves the blog's features on that target. **Campfire** (Basecamp's chat product — file attachments with image diff --git a/docs/guide/spinel.md b/docs/guide/spinel.md index 2f6fc5a8c..3acf4274f 100644 --- a/docs/guide/spinel.md +++ b/docs/guide/spinel.md @@ -54,7 +54,7 @@ calls it the Campfire tier for that reason. `brew install vips`) — only when the app declares image variants (`has_one_attached` with `variant`), in which case `spin.toml` lists `ruby-vips`. -- **Node.js 18+** — when the app builds Tailwind (the asset step runs +- **Node.js 24+** — when the app builds Tailwind (the asset step runs `npx @tailwindcss/cli`), and for the browser end-to-end suite. ## Build and run @@ -195,7 +195,7 @@ live updates over the socket — from `docker run -p 3000:3000`. [rubys.github.io/roundhouse/campfire/docker.tgz](https://rubys.github.io/roundhouse/campfire/docker.tgz) is that archive, refreshed by scheduled full validation or an explicitly publishing manual run on canonical main. PR archive checks never publish it; -see [CI coverage](../ci-reuse.md). It is the fastest way to see the door's +see [CI publication](../ci/README.md#publication-is-separate). It is the fastest way to see the door's end state before pointing it at your own app. ## What to expect diff --git a/docs/guide/targets.md b/docs/guide/targets.md index dce9a7619..24396f270 100644 --- a/docs/guide/targets.md +++ b/docs/guide/targets.md @@ -11,20 +11,20 @@ is tested — which is the honest measure of how much to trust it. |---|---|---| | `rust` | Cargo crate (axum, rusqlite) | Rust 1.85+, SQLite library | | `go` | Go module | Go 1.24+ | -| `typescript` | Node package | Node.js 18+ | +| `typescript` | Node package | Node.js 24+ | | `crystal` | shard | Crystal 1.10+, SQLite library | | `elixir` | Mix project | Elixir 1.15+ | | `kotlin` | Gradle build (JVM) | JDK 17+, Gradle 8+ | | `swift` | Swift package | Swift 6+; on Linux `libsqlite3-dev` | | `python` | Python project (`uv`) | Python 3.11+, `uv` | | `csharp` | .NET solution | .NET SDK 10+ | -| `ruby` | Ruby tree — the framework runtime in Ruby, no Rails | Ruby 3.4+, bundler, SQLite; Node for the asset build | +| `ruby` | Ruby tree — the framework runtime in Ruby, no Rails | Ruby [3.4+](../../.ruby-version), bundler, SQLite; Node for the asset build | All ten serve on `:3000`, speak Action Cable at `/cable`, use SQLite at `storage/development.sqlite3`, and are seeded by `sqlite3 storage/development.sqlite3 < db/seed.sql`. Each ships the app's model and controller tests and a Playwright `e2e/` suite; the -`sqlite3` CLI and Node.js 18+ are needed for the latter. +`sqlite3` CLI and Node.js 24+ are needed for the latter. ## The variations @@ -32,14 +32,14 @@ app's model and controller tests and a Playwright `e2e/` suite; the |---|---| | `jruby` | The `ruby` emit with prebuilt assets and JRuby run/test commands. JRuby 10+ (JDK 21+). | | `spinel` | The `ruby` shape packaged as a `spin` project for ahead-of-time compilation to a native binary. Needs the Spinel compiler; [`spinel.md`](spinel.md). | -| `typescript-worker` | The `typescript` emit bundled to run in a browser `SharedWorker`, with SQLite compiled to WebAssembly, for the in-browser demos. Node.js 18+ to bundle; no server. | +| `typescript-worker` | The `typescript` emit bundled to run in a browser `SharedWorker`, with SQLite compiled to WebAssembly, for the in-browser demos. Node.js 24+ to bundle; no server. | ## How far each is tested The lanes below run across targets in full validation, scheduled every four hours or requested manually. Ordinary PRs/main pushes use a compact floor plus targeted additions; maintainers can request full PR coverage with `ci:full`. -See [CI coverage](../ci-reuse.md). The lanes use the blog fixture +See [CI coverage](../ci/README.md). The lanes use the blog fixture (`fixtures/real-blog`: articles, comments, nested routes, validations, Turbo Streams over Action Cable, Tailwind) unless another app is named. A target's row in diff --git a/docs/guide/verifying.md b/docs/guide/verifying.md index 7d3c073a2..f384fc55c 100644 --- a/docs/guide/verifying.md +++ b/docs/guide/verifying.md @@ -117,9 +117,10 @@ which: The third kind is what the oracle exists to find, and the project's position is that every one of them is a roundhouse bug, never an -acceptable difference. That is why the compare matrix runs on every -push and why a target that goes red stays off the -[supported list](targets.md) until it is green. +acceptable difference. [CI](../ci/README.md) selects comparison lanes on +PRs/main pushes and runs the complete coverage in full validation. A green +selected subset is not evidence that an unselected target passed; check the +target's actual results before treating it as supported. ## Beyond the page diff --git a/docs/pipeline/emit.md b/docs/pipeline/emit.md index 214b74659..f73820d14 100644 --- a/docs/pipeline/emit.md +++ b/docs/pipeline/emit.md @@ -261,9 +261,9 @@ Practical consequences: entry point — the pattern rust, go, and elixir all followed. The public identity (`crate::emit::rust::emit`, the `--target` CLI surface) never moves; the entry file shrinks to a shim once the - 2-module carries everything. (The `ROUNDHOUSE__V2` env - flags from the early migrations are vestigial — see - `docs/env-gates.md`.) + 2-module carries everything. Temporary migration flags should leave + with the old path; inspect the actual readers rather than assuming + a historical flag still exists. - Flip the default once the new path is green; delete the old. - `expr.rs` is the exception — port forward, don't rewrite from scratch. diff --git a/docs/pipeline/verification.md b/docs/pipeline/verification.md index 9c70cf636..896c5cdbc 100644 --- a/docs/pipeline/verification.md +++ b/docs/pipeline/verification.md @@ -1,365 +1,73 @@ # Verification -How roundhouse knows its output is correct. Seven layers, each with a -different failure mode to catch. - -## The seven layers - -| Layer | What it catches | Where | -|-------|-----------------|-------| -| **Unit + analyze gates** | Ingest silently dropped or mis-typed a construct; the framework runtime lost its typing | `tests/real_blog.rs`, `tests/runtime_src_integration.rs` | -| **Round-trip identity** | IR is lossy — emit dropped information the ingester produced | `tests/roundtrip.rs`, `--round-trip` CLI | -| **Framework runtime tests** | A target's transpiled framework runtime drifts from the framework Ruby | `tests/framework_tests_.rs` | -| **Toolchain compile** | Emitted project doesn't build / type-check in its target language | `tests/_toolchain.rs` (`--ignored`) | -| **DOM / JSON compare** | A live emitted server renders differently from Rails | `tools/compare/`, `scripts/compare` | -| **Smoke** | The published archive's README doesn't work, or its tests silently ran nothing | `scripts/smoke` | -| **Browser + conformance floors** | Dynamic behavior a DOM diff can't reach; regressions against a real app's own suite | `e2e/`, `tests/browser_smoke/`, `scripts/campfire-suite` | - -Each layer has a different blind spot the next layer catches. - -## Unit + analyze gates - -**Claim:** the ingester loads `fixtures/real-blog` (generated by -`bin/rh fixture`) cleanly, the analyzer types every expression, zero -error diagnostics fire, and the framework Ruby is itself sound. - -**Files:** -- `tests/real_blog.rs::ingests_without_errors` — loud-by-design guard - against any unsupported construct. -- `tests/real_blog.rs::type_analysis_coverage` — the zero-error- - diagnostics gate; the subset of programs roundhouse can transpile. -- `tests/ingest.rs` — construct-level ingest coverage. -- `tests/runtime_src_integration.rs::every_runtime_method_body_is_fully_typed` - — every `runtime/ruby/*.rb` method body types end-to-end with no - inference residue (`Ty::Untyped` via RBS is the allowed gradual - escape). The runtime-side twin of `type_analysis_coverage`. -- `tests/runtime_ruby_unit.rs::framework_ruby_tests_pass` — runs - `runtime/ruby/test/` verbatim under CRuby; deliberately not - `#[ignore]`d, so it gates every `cargo test`. - -All of this runs in the `unit` CI job (`cargo test --all-targets`) — -part of the compact floor required for Pages publication, alongside -the other baseline checks and verified assembly (see CI topology). - -**Why it exists:** if the ingester silently drops a construct, the -emitter has nothing to emit and downstream tests trivially pass. -**Failure recipe:** almost always an ingest or analyzer gap. Add the -recognizer, don't relax the test. - -## Round-trip identity - -**Claim:** `App → JSON → App` is identity, and a typed framework -method survives emit → re-ingest unchanged. - -**Files:** -- `tests/roundtrip.rs::tiny_blog_round_trips` — hand-constructed `App` - through serde JSON; the forcing function for IR completeness. -- `tests/roundtrip.rs::literals_round_trip` — every literal kind. -- `tests/runtime_src_roundtrip.rs` — framework-Ruby `MethodDef` → - emit → re-ingest stability. -- `roundhouse-ast --round-trip -e ''` (or `PATH.rb`) — - per-snippet `ingest → emit-ruby → ingest`, asserting IR equality. - Plain Ruby only: the tool rejects ERB input, since ERB - reconstruction went away with the parsed-AST emitter. - -**Deliberately NOT a goal:** source-equivalence round-trip of whole -apps. The Ruby emitter is lowered-IR-only (see the module doc in -`src/emit/ruby.rs`); the forcing function is now *compile- -equivalence* — the lowered emit must compile and pass its tests under -Spinel (`tests/spinel_toolchain.rs`) and match the universal -post-lowering shape (`tests/lowered_ruby_emit.rs`). - -**Debugging failures:** narrow to one snippet with `--round-trip`, or -diff two `roundhouse-ast --stage ingest` dumps — deterministic key -ordering makes the divergence trivially localizable. - -## Framework runtime tests - -**Claim:** each target's *transpiled* framework runtime passes the -framework's own test suite. - -`runtime/ruby/` is the single framework source every target -transpiles ([two-layer runtime](runtime.md)); `runtime/ruby/test/` is -its spec, enforced twice: - -1. **Source rung** — `framework_ruby_tests_pass` (above) runs the - Ruby verbatim under CRuby, so bugs surface with a clean Ruby stack - before they reach any target. -2. **Transpiled rung** — `tests/framework_tests_.rs` - transpiles `runtime/ruby/test/**/*_test.rb` through the target's - emitter and runs the result under the native runner (`crystal - spec`, Gradle/JUnit 5, `cargo test`, XCTest, tsx, CRuby, - spinel-compiled binaries). Seven targets — crystal, kotlin, ruby, - rust, spinel, swift, typescript — each with its own - `framework-tests-` CI job. - -**Why it exists:** adapter-contract drift is invisible to app suites. -Canonical example (the `framework-tests-rust` job comment): the Rust -emitter collapsed `is_a?(TrueClass)`/`is_a?(FalseClass)` into one predicate, -so every `false` serialized as `true` — found by the *TypeScript* -gate, because rust had none and real-blog has no boolean column. Jobs -may run a scoped green subset while named gaps close (rust currently -runs only the inflector suite). Locally: - -```bash -cargo test --test framework_tests_crystal -- --ignored --nocapture -``` - -## Toolchain compile - -**Claim:** emitted projects build with the real target toolchain and -pass their transpiled test suites. - -**Files:** twelve `#[ignore]`-gated harnesses, -`tests/_toolchain.rs` (rust, typescript, go, crystal, elixir, -python, ruby, kotlin, swift, csharp, spinel, roda). Each generates -the target project in a scratch dir and drives the real toolchain -(`cargo build`, `tsc`, `crystal build`, `mix compile`, …): - -```bash -cargo test --test rust_toolchain -- --ignored --nocapture -``` - -**In CI, only a subset run as jobs:** `toolchain-crystal`, -`toolchain-typescript`, `toolchain-elixir`, `toolchain-python`, -`toolchain-csharp`, `toolchain-spinel`. The rust, go, kotlin, swift, -and ruby jobs were retired when the smoke matrix subsumed their -build+test coverage (see the "SHRUNK by the smoke consolidation" -comment in ci.yml); the harnesses remain dev-loop tools. Survivors -keep coverage smoke can't reach: tiny-blog absent-feature gates -(crystal, elixir, python), typescript's dual-profile `tsc` + -`node:test` run, csharp's floored `dotnet test`, spinel's advisory -AOT compile of the whole app. - -## DOM / JSON compare - -**Claim:** the HTML a roundhouse-emitted runtime serves is DOM- -equivalent to what Rails serves for the same request, live over HTTP. - -**Tool:** `tools/compare/` (the `roundhouse-compare` binary). - -**Contract** (per the tool's module doc): "same DOM when inspected by -JS or CSS". That means: - -- Tag tree must match exactly (same elements, same children). -- Text nodes match byte-for-byte (whitespace included). -- Attribute order is insignificant (canonicalized to sorted). -- HTML comments are insignificant (dropped during canon). -- Specific known-variable values (CSRF tokens, asset fingerprints, - session ids) get replaced with placeholders per a YAML ignore-rules - config. - -`.json` responses get a structural diff instead of a DOM walk: -`tools/compare/src/json_diff.rs` walks both `serde_json` trees in -lockstep, reporting the first divergence with a path. Its one -canonicalization: ISO-8601 fractional seconds truncate to millisecond -width on both sides (Rails emits microseconds, the runtime doesn't). - -**How to run:** `scripts/compare ` is the front door — it -regenerates the target, boots Rails and the target side-by-side, runs -the comparator, and tears everything down. `tools/compare` is a -*standalone crate* (own `Cargo.lock`, not a workspace member), so -`cargo run --bin roundhouse-compare` from the repo root does **not** -work; for the raw tool (`--reference`/`--target` URLs, repeatable -`--path`), build it with `cd tools/compare && cargo build --release`. - -**Ignore rules** (`tools/compare/config.example.yaml`; the built-in -defaults cover the same cases): - -- Drop ``, ``, - ``. -- Blank `authenticity_token` hidden form fields. -- Strip `?v=...` query strings from stylesheet/script URLs (Propshaft - fingerprints). -- Strip the signature suffix from `` — the base64'd channel name before `--` - must match; the HMAC after it differs. - -**In CI:** one matrixed `compare` job covers rust, crystal, kotlin, -swift, csharp, typescript, go, elixir, python; `compare-ruby`, -`compare-jruby`, and `compare-spinel` stand alone. `compare-ruby` -serves the emitted tree via Puma+Rack -(`runtime/spinel/scaffold/ruby_overlay/config.ru`) — not a shell-out; -`compare-jruby` runs the same tree on the JVM over JDBC; -`compare-spinel` DOM-diffs a live AOT-compiled native binary. - -**Failure recipe:** a genuine divergence is a bug — in the emitter's -lowering or a view helper's output shape. Extend the ignore-rules -only for structurally meaningful but legitimately per-request values. - -## Smoke: the archive runs its own README - -**Claim:** the published `.tgz` is a complete, self-contained -artifact — and its README is true. - -`scripts/smoke` extracts the archive and executes its README's -```` ```sh ```` blocks verbatim. No per-target build/seed/boot -knowledge lives in the script — docs-as-contract, so a wrong README -is a red job. The § Test section must additionally *prove it executed -something*: the script parses the runner's own summary and fails -below a per-target executed-count floor (default 21, the real-blog -app suite), because every test runner exits 0 having run nothing. - -**In CI:** the `smoke` matrix covers rust, crystal, kotlin, swift, -csharp, typescript, go, elixir, python, ruby, jruby, plus advisory -`smoke-spinel`. The jobs depend on `build-site` and test the exact -published bytes (the `browse-archives` artifact). This consolidation -retired the old per-target toolchain and e2e CI legs; `scripts/e2e` -survives as a dev-only harness — its header says CI has moved -entirely to `scripts/smoke`. - -## Browser layer - -Three Playwright harnesses, each aimed at behavior no server-side -diff can see: - -- **`e2e/`** — the archive-embedded spec suite: `index.spec.js`, - `validation.spec.js`, `tailwind.spec.js`, `turbo_comment.spec.js`, - `action_cable.spec.js`, `flash.spec.js`. Per `e2e/README.md`, these - exercise *dynamic* behavior the DOM diff can't reach — Turbo Stream - inserts, Action Cable broadcasts, computed Tailwind styles, - validation re-renders, flash sweep — asserting fixed expectations - against each archive's seed data, no reference server. Every archive - carries the suite (`ensure_e2e` in `src/project.rs`); each smoke job - runs it via the README's End-to-end section. -- **`tests/browser_smoke/`** — own `package.json` + - `playwright.config.ts`. The `browser-smoke-typescript` job emits - real-blog under the SharedWorker profile, vite-builds the SPA, and - drives headless Chromium — the only place the emitted framework - runtime runs *inside a SharedWorker*. -- **`browser-smoke-ide`** — drives the published /ide/ + /playground/ - WASM pages against pinned real apps (blog, lobsters, campfire, - Mastodon), asserting the demo's beats: typed hover and completion, - related files, the coverage ledger. - -## Conformance floors - -The `campfire-conformance` CI job transpiles ONCE Campfire at the -pinned `CAMPFIRE_SHA`, runs the app's own suite against the emit via -`scripts/campfire-suite`, and enforces a tests/files floor. The -harness reports a number and never gates on one (by design — see its -header); the floor lives in the CI job, `>=` so progress is never a -failure, with a notice to raise it when exceeded. - -Deliberately **blocking**, not advisory — its comment in ci.yml says -why: the input is pinned, so every movement in the number belongs to -a roundhouse commit. (Lobsters conformance is the opposite call: -`scripts/lobsters-specs` deliberately tracks upstream HEAD, so it -runs on the bench box, off CI.) - -Both lanes **publish a worklist**, not just a floor. -`scripts/campfire-suite --json` writes a `summary.json` — provenance, -per-file tally, spliced-stub ledger, and the failures clustered into -named causes by `bench/campfire/suite-causes.json` — which -`scripts/campfire-suite-report` renders to -`/bench/campfire-suite/`. Publication assembly consumes that artifact -from the same run and can render a failed conformance report for triage. -A failed compact floor still prevents Pages deployment; the raw CI -summary remains available for investigation. The -lobsters twin (`scripts/lobsters-spec-report` → -`/bench/lobsters-specs/`) keeps the same contract over data fetched -from the bench box. Re-running either report against a saved tally is -how cause rules are iterated — the clustering is a pure function of -the run, so triage costs a second rather than a suite run. The same principle pins every -upstream app CI consumes — `MASTODON_SHA`, `RUBY_BENCH_SHA`, -`CAMPFIRE_SHA` in ci.yml's env — so published metrics and demos move -only when a commit moves them. - -## `roundhouse-ast` — the interactive debugger - -Not a test, but the tool you'll reach for when a test fails. It -exposes every pipeline stage as a dump: - -```bash -roundhouse-ast -e '[:a, :b]' # ingest, print IR as JSON -roundhouse-ast --stage prism -e '@x.y do end' # what Prism produced -roundhouse-ast --stage compile-erb view.html.erb -roundhouse-ast --stage emit-ruby -e '"a#{x}b"' -roundhouse-ast --stages --erb -e '<%= x %>' # every stage, with headers -roundhouse-ast --round-trip snippet.rb # plain Ruby only -``` - -`--stage ingest` uses `serde_json::to_string_pretty`, so structural -diffs between two IRs drop out from plain `diff` across the outputs. - -## CI topology - -The current selection and publication policy is documented in -[CI coverage](../ci-reuse.md). The ownership boundaries are: - -- **`generate-fixture`** builds `fixtures/real-blog` once per run and - shares it as an artifact every downstream job downloads. -- **PRs/main pushes select coverage**, rather than running every target - on every push: compact floor plus owned additions, or full coverage - for policy/shared-emitter changes and `ci:full`. Drafts run fixture + unit. -- **`CI summary` reports selected results**, including missing/skipped/ - cancelled non-advisory checks. It does not configure mandatory merge - protection; maintainers decide when to merge. -- **Spinel master remains advisory.** Its revision is resolved once per - run and the actual built revision is recorded. Failures can come from - Roundhouse runtime/RBS/packaging or upstream. Inspect raw job outcomes - and archive evidence, not just the overall run conclusion. -- **Publication is separate**, in `.github/workflows/full-ci.yml`: - four-hour scheduled validation/publication on canonical main, or fresh - manual validation with publication opt-in. PR checks never deploy. - Pages requires the compact floor, verified same-run assembly and a - live-main SHA check. Extra-target/advisory failures may remain useful - repro archives, but are not presented as successful validation. - -The native Campfire comparison's GC modes run when that lane is selected, -not automatically for every native-runtime change: - -- **`campfire-compare-spinel (default)`, `(minor-gc)` and - `(verify-gen)`** serve the same comparison binary, produced by - `build-campfire-compare-spinel` using the shared `build-spinel` toolchain, - through the same Rails-oracle walk. `minor-gc` adds - `SPINEL_GC_MINOR=1`, spinel's generational collector, which is opt-in - until it goes default-on. matz asked for that leg - (matz/spinel#4260): campfire is the retained-heap shape the - default-on decision lacks. A red `minor-gc` beside a green `default` - is a mode-specific signal: reduce the failing case and establish its - cause before attributing it upstream. -- **`(verify-gen)` is why the other two can be believed.** `minor-gc` - only fails when a missed write barrier reaches the page; - `SPINEL_GC_VERIFY_GEN=1` re-marks the whole heap after every minor - cycle and makes the runtime print the young object the barrier failed - to record, with the scan hook of the old object holding it — the - reduction, written by the collector, which - `scripts/campfire-compare` now greps out of the emit's log and fails - on (it used to go to a log deleted on success). It is not a - replacement: the check keeps alive what the minor missed, so it - reports the defect rather than crashing on it, and nothing ships in - that mode. matz/spinel#4311 is the shape it produces. -- **All three legs attest what the collector did.** The emit is stopped - before the diff and `Tep.on_shutdown` prints `GC.stat` under - `TEP_GC_STAT=1`; a `minor-gc` leg reporting `remembered_peak=0` fails, - because a walk that never took a minor cycle with a live remembered - set is green about nothing. `full_runs` cannot stand in for it — it - counts full sweeps, not marks. -- **`campfire-conformance` is blocking** — pinned input, so no churn - to excuse noise. - -## Key files - -| File | Role | -|------|------| -| `tests/real_blog.rs` | Ingest + analyzer coverage; zero-error-diagnostics gate | -| `tests/runtime_src_integration.rs` | Framework-runtime typing gate | -| `tests/roundtrip.rs` | IR → JSON → IR identity | -| `tests/framework_tests_ruby.rs` | Transpiled framework-test gates (one per target, ×7) | -| `tests/ruby_toolchain.rs` | Real-toolchain builds (one per target, ×12, `--ignored`) | -| `tools/compare/` | Cross-runtime DOM + JSON comparator (standalone crate) | -| `scripts/compare` | Front door: boots Rails + target, runs the comparator | -| `scripts/smoke` | Runs a published archive's README verbatim, with test-count floors | -| `e2e/` | Archive-embedded Playwright specs | -| `tests/browser_smoke/` | SharedWorker + IDE/playground Playwright harnesses | -| `src/bin/roundhouse-ast.rs` | Interactive pipeline inspector | -| `.github/workflows/ci.yml` | CI topology | - -## Related docs - -- [`../../DEVELOPMENT.md`](../../DEVELOPMENT.md) — round-trip - debugging recipe. -- [`emit.md`](emit.md) — what the toolchain tests validate. -- [`runtime.md`](runtime.md) — the two-layer runtime the framework - tests and the DOM comparator validate. +Different checks prove different claims. No single green suite proves that +arbitrary Rails code transpiles correctly. Local test commands live in +[development/testing.md](../development/testing.md); GitHub selection and +publication live in [CI](../ci/README.md). + +## Evidence layers + +| Layer | Claim on the tested input | Important limit | +|---|---|---| +| Ingest and analysis | Source was retained and expressions typed without error diagnostics | No proof the emitted program runs | +| IR identity | Serialization or expression Ruby round-trip retains IR | No whole-app source equality or behavioral proof | +| Framework tests | Source/transpiled runtime satisfies the exercised assertions | Coverage differs by target and selected subset | +| Toolchain | Emitted project builds and selected tests execute | Compilation alone is not Rails equivalence | +| Live DOM/JSON comparison | Emitted responses match a Rails oracle after defined normalization | Only exercised requests and state; not every side effect | +| Archive smoke | Actual archive can execute its README commands and test floors | Does not certify sibling archive formats or arbitrary apps | +| Browser and app conformance | Dynamic interactions or upstream test scenarios work | Floors/subsets are not whole-app conformance | + +## Ingest, typing, and execution + +[`tests/real_blog.rs`](../../tests/real_blog.rs) guards zero errors and zero +unresolved types on the generated blog. The framework typing gate is +`tests/runtime_src_integration.rs::every_runtime_method_body_is_fully_typed`. +The CRuby source suite (`tests/runtime_ruby_unit.rs`) and transpiled +`framework_tests_` suites test the runtime itself. Some harnesses skip +when prerequisites are absent; some targets exercise only a subset. Read what +actually executed. + +Removing an error diagnostic claims emitted support. Pin that claim with +[`tests/emit_and_run.rs`](../../tests/emit_and_run.rs): the overlaid construct +must analyze cleanly **and** execute correctly in the emitted Ruby app. +Other target behavior still needs its own relevant evidence. + +## Three meanings of round-trip + +`tests/roundtrip.rs` checks `App → JSON → App`; `tests/runtime_src_roundtrip.rs` +and `roundhouse-ast --round-trip` check Ruby expression/method IR stability. +The CLI round-trip accepts Ruby input, not ERB. + +Whole-app source equivalence is retired. Application Ruby emission consumes +lowered IR: `tests/lowered_ruby_emit.rs` checks that shape and +`tests/spinel_toolchain.rs` exercises native compile-equivalence. Neither +serialization identity nor a stable expression implies correct runtime output. + +## Differential oracle + +[`tools/compare/`](../../tools/compare/) compares live Rails and emitted +responses. HTML element trees and text must match; attribute order and comments +are ignored. Defined masks handle legitimately variable values such as tokens +and asset fingerprints. JSON comparison is structural, with timestamp precision +normalization. The exact normalization lives beside the comparator. + +`scripts/compare` drives the fixture comparison; the +[user guide](../guide/verifying.md) explains applying the oracle to another app. +Do not extend masks for a translation bug. Translated application tests alone +are weaker: translating the code and its expectations incorrectly can still +produce agreement. + +## Packaging and dynamic behavior + +[`scripts/smoke`](../../scripts/smoke) extracts an archive and executes its +README shell blocks rather than maintaining another per-target recipe. It +enforces executed-test floors, so a successful empty test run is insufficient. + +[`e2e/`](../../e2e/) tests browser behavior such as Turbo updates, Action Cable, +validation, and styles. [`tests/browser_smoke/`](../../tests/browser_smoke/) +exercises SharedWorker and browser compiler surfaces. + +`scripts/campfire-suite` runs the pinned app's own tests and reports a worklist; +CI owns its conformance floors. Inventory-only corpus gates, such as +[Writebook](../writebook.md), do not run the app and must not be described as +compilation, runtime, or UI conformance. diff --git a/scripts/campfire-archive-files b/scripts/campfire-archive-files index 34c93013d..36f31a5a0 100755 --- a/scripts/campfire-archive-files +++ b/scripts/campfire-archive-files @@ -59,7 +59,7 @@ behave differently from the one this archive was generated against. - libvips (\`libvips-dev\` to build, \`libvips42\` to run; \`brew install vips\`) — \`spin.toml\` names \`ruby-vips\`, the image processor behind attachment thumbnails, avatars and the account logo. -- Node.js 18+ — for the End-to-end suite +- Node.js 24+ — for the End-to-end suite ## Build diff --git a/scripts/campfire-suite b/scripts/campfire-suite index 38f2814b4..c2d0df47a 100755 --- a/scripts/campfire-suite +++ b/scripts/campfire-suite @@ -31,12 +31,21 @@ # Three outcomes per file, and the three are the point of the lane: # didn't link / linked and diverged / linked and agreed. # -# The compiles are PARALLEL (`--jobs`, default 8) and the runs are +# The compiles are PARALLEL (`--jobs`, default 8) and the SPINEL runs are # SERIAL — the binaries share one sqlite file. Each compile is a whole- # program build, so this lane costs minutes where the ruby lane costs # seconds. Never batch several test files into one compile: that is what # found matz/spinel#4340, and it measures a different thing. # +# The RUBY runs are also parallel (`--jobs`), one process per file. Each +# file already opens its own sqlite path from `$0`, but Active Storage +# writes `storage/files` (or `tmp/storage` under RAILS_ENV=test). A parallel +# file bind-mounts private directories over both roots and keeps the emit +# as its working directory, so `$0`, `Dir.pwd` and `__dir__` stay what a serial run +# sees. The tally is still printed in file-list order. `--jobs 1`, or a +# box whose `unshare` cannot mount, runs the files in this process as +# before. +# # NO STUBS BY DEFAULT ON THAT LANE. walk_stubs.rb is Ruby spliced into # boot.rb, which every test requires — under spinel it must also COMPILE, # so a stub that does not takes all 54 files rather than the one it was @@ -48,7 +57,9 @@ # # Options: # --target T `ruby` (default) or `spinel` — see above. -# --jobs N Parallel compiles for --target spinel (default 8). +# --jobs N Parallel spinel compiles, and parallel ruby file runs +# (default 8). Ruby runs fall back to serial when the +# mount probe fails. Spinel RUNS stay serial either way. # --timeout N Seconds a single spinel binary may run (default 120). # --out DIR Emit into DIR and keep it (default: a temp dir, removed). # --keep Keep the temp emit and print its path. @@ -190,6 +201,7 @@ cleanup() { local code=$? if [[ -n "$TMP_TALLY" ]]; then rm -f "$TMP_TALLY"; fi if [[ -n "$TMP_FAIL" ]]; then rm -f "$TMP_FAIL"; fi + if [[ -n "${RUBY_RUNS:-}" ]]; then rm -rf "$RUBY_RUNS"; fi if (( KEEP )); then echo "" echo "emit retained at $OUT" @@ -314,7 +326,11 @@ if [[ "$TARGET" == spinel ]]; then echo " compiled in $((SECONDS - started))s; logs in $BUILD_LOGS" fi -step "running ${#TESTS[@]} test files" +if [[ "$TARGET" == ruby && "$JOBS" -gt 1 ]]; then + step "running ${#TESTS[@]} test files (-j $JOBS)" +else + step "running ${#TESTS[@]} test files" +fi cd "$OUT" pass=0 @@ -355,14 +371,65 @@ fail_lines="" # emitted source, same autorun shim, same output to parse. The timeout is # `perl -e alarm` because a binary that hangs has no analogue on the ruby # lane and macOS ships no coreutils `timeout`. +# +# Ruby's SECRET_KEY_BASE default lives HERE, not in the parallel wrapper: +# both paths call this function, so an unset key is the same string +# either way. run_test() { - local t="$1" + local t="$1" priv="${2:-}" export SECRET_KEY_BASE="${SECRET_KEY_BASE:-campfire-suite-secret}" if [[ "$TARGET" == spinel ]]; then perl -e 'alarm shift; exec @ARGV' "$TIMEOUT" "./build/$t" else - ruby -Itest -r "$REPO_ROOT/scripts/campfire-test-bcrypt.rb" "$t.rb" + local ruby_command=(ruby -Itest -r "$REPO_ROOT/scripts/campfire-test-bcrypt.rb" "$t.rb") + if [[ -n "$priv" ]]; then + ruby_isolated "$priv" "${ruby_command[@]}" + else + "${ruby_command[@]}" + fi + fi +} + +# Active Storage uses `storage/files` by default and `tmp/storage` under +# RAILS_ENV=test. Isolate both without changing the serial environment; +# the sqlite file is already named from `$0`. Run the same ruby the +# serial path uses, so argv0, cwd and the load path do not move. +# +# `--map-current-user` keeps the real uid. `--keep-caps` is what lets +# the mount happen inside that mapping; it also fills every capability +# set, including the ones a child would inherit. `setpriv` clears the +# inherited and ambient sets before ruby. Effective and permitted are +# already empty in the ruby process (it is not privileged), and the +# bounding set is left as the caller's — clearing it is irreversible +# and would make the process stricter than a serial run. +ruby_isolated() { + local priv="$1" + shift + mkdir -p "$priv/tmp-storage" "$priv/files" "$OUT/tmp/storage" "$OUT/storage/files" || return + unshare --user --map-current-user --keep-caps --mount bash -c ' + mount --bind "$1/tmp-storage" tmp/storage || exit $? + mount --bind "$1/files" storage/files || exit $? + shift + exec setpriv --inh-caps -all --ambient-caps -all "$@" + ' _ "$priv" "$@" +} + +# True when parallel ruby runs can isolate the storage roots. Probed once: +# a missing unshare, or a mount that the kernel refuses, is a serial +# run rather than a suite of failed files. +ruby_parallel_ok() { + command -v unshare >/dev/null 2>&1 || return 1 + command -v setpriv >/dev/null 2>&1 || return 1 + local probe + probe="$(mktemp -d -t campfire-suite-unshare.XXXXXX)" + mkdir -p "$probe/src" "$probe/dst" + if unshare --user --map-current-user --keep-caps --mount \ + mount --bind "$probe/src" "$probe/dst" >/dev/null 2>&1; then + rm -rf "$probe" + return 0 fi + rm -rf "$probe" + return 1 } # The first line of a failed compile, for the BUILD row's `why`. @@ -397,6 +464,40 @@ build_error() { printf '%s' "$line" | sed "s|$OUT/||g" | ruby -e 'print ARGF.read.force_encoding("UTF-8").scrub("?")[0, 160].to_s' } +# Ruby file runs may overlap. Their output is captured per file and +# folded below in the app's own file order, so the tally, the fail-log +# and the printed rows do not depend on which file finished first. +# Spinel stays in the loop: its binaries share one sqlite file. +RUBY_PARALLEL=0 +RUBY_RUNS="" +if [[ "$TARGET" == ruby && "$JOBS" -gt 1 && ${#TESTS[@]} -gt 0 ]] && ruby_parallel_ok; then + RUBY_PARALLEL=1 + RUBY_RUNS="$(mktemp -d -t campfire-suite-runs.XXXXXX)" + started=$SECONDS + # Functions are exported because xargs starts a new shell. The + # runs directory is an environment variable: xargs appends each + # file as $1 and would shift a positional argument out from under it. + export -f ruby_isolated run_test + export REPO_ROOT OUT TARGET TIMEOUT + export RUBY_RUNS + # NUL-separated slot/path pairs preserve each file's position and name. + listed=0 + for t in "${TESTS[@]}"; do + listed=$((listed + 1)) + printf '%s\0%s\0' "$listed" "$t" + done | xargs -0 -P "$JOBS" -n 2 bash -c ' + slot="$1"; t="$2" + # One combined stream, the same `2>&1` the serial path uses. + if run_test "$t" "$RUBY_RUNS/$slot" >"$RUBY_RUNS/$slot.log" 2>&1; then + printf 0 > "$RUBY_RUNS/$slot.status" + else + printf 1 > "$RUBY_RUNS/$slot.status" + fi + ' _ || true + echo " ruby files ran in $((SECONDS - started))s" +fi + +ruby_slot=0 for t in "${TESTS[@]}"; do # The emitted autorun shim (emit/ruby.rs::render_autorun_shim) runs # EVERY test and prints one `FAIL #: ` line per @@ -416,7 +517,18 @@ for t in "${TESTS[@]}"; do table+="BUILD|$t|0|$n|$why"$'\n' continue fi - if out=$(run_test "$t" 2>&1); then + ran_ok=0 + if (( RUBY_PARALLEL )); then + ruby_slot=$((ruby_slot + 1)) + # Missing status is a failed file, not a quiet skip: a worker + # that died before writing one must not be counted green. + status="$(cat "$RUBY_RUNS/$ruby_slot.status" 2>/dev/null || echo 1)" + out="$(cat "$RUBY_RUNS/$ruby_slot.log" 2>/dev/null || true)" + [[ "$status" == 0 ]] && ran_ok=1 + elif out=$(run_test "$t" 2>&1); then + ran_ok=1 + fi + if (( ran_ok )); then take_skips_out "$out" pass=$((pass + 1)) tests_pass=$((tests_pass + n)) diff --git a/scripts/ci-plan.py b/scripts/ci-plan.py index d71c65747..329478160 100755 --- a/scripts/ci-plan.py +++ b/scripts/ci-plan.py @@ -21,9 +21,10 @@ "ruby", "jruby", ] +DRAFT_FLOOR = ["generate-fixture", "unit"] BASE = [ - "generate-fixture", - "unit", + *DRAFT_FLOOR, + "build-roundhouse", "store-check", "compare", "compare-ruby", @@ -166,9 +167,9 @@ def archive_and_campfire_jobs(path, interpreter_only): def select(paths, *, draft=False, full=False, publish=False, project_scope=None): - if draft: + if draft and not full: return finish( - BASE[:2], + DRAFT_FLOOR, [], [], False, @@ -196,11 +197,6 @@ def select(paths, *, draft=False, full=False, publish=False, project_scope=None) "scripts/ci-plan.py", "scripts/ci-reuse.py", "scripts/ci-archive-evidence.py", - "tests/ci_plan_test.py", - "tests/ci_archive_evidence_test.py", - "tests/workflow_yaml_parses.rs", - "tests/ci_policy_workflow.rs", - "tests/ci_fixture_workflow.rs", "src/project.rs", "src/bin/roundhouse.rs", "Cargo.toml", @@ -479,7 +475,7 @@ def changed_inputs(event, event_name, sha): def check_results(plan, needs, *, compact=False): required = ( - BASE[:2] if plan["jobs"] == BASE[:2] else BASE if compact else plan["required"] + DRAFT_FLOOR if plan["jobs"] == DRAFT_FLOOR else BASE if compact else plan["required"] ) failures = [ f"{j}: {needs.get(j, {}).get('result', 'missing')}" diff --git a/scripts/ci-reuse.py b/scripts/ci-reuse.py index 67e54dd89..d15de91a4 100755 --- a/scripts/ci-reuse.py +++ b/scripts/ci-reuse.py @@ -2,7 +2,7 @@ """PR-local execution receipts; uncertainty always executes. This is not a dependency cache or a general CI scheduler. The allowlist below -owns the complete command contract. See docs/ci-reuse.md before expanding it. +owns the command contract; tests/ci_reuse_test.py exercises its trust boundaries. """ import argparse diff --git a/scripts/ci-unit-tests.py b/scripts/ci-unit-tests.py index f731eac58..ed59e3e26 100755 --- a/scripts/ci-unit-tests.py +++ b/scripts/ci-unit-tests.py @@ -12,6 +12,11 @@ Peak disk is reduced because only one integration batch is resident at a time. Compile-everything-before-any-execute is intentionally not preserved: a later batch can fail to compile after earlier batches have already run. + +Optional --shard-index/--shard-count stride sorted integration targets across +jobs. Coverage is checked on the global metadata set first. Shard 0 also runs +library and binary unit tests; every integration target still executes exactly +once across the shards. """ from __future__ import annotations @@ -82,6 +87,17 @@ def chunks(items: list[str], size: int) -> list[list[str]]: return [items[i : i + size] for i in range(0, len(items), size)] +def validate_shard(index: int, count: int) -> None: + if count < 1: + raise SystemExit("--shard-count must be >= 1") + if not 0 <= index < count: + raise SystemExit("--shard-index must satisfy 0 <= index < --shard-count") + + +def select_shard(names: list[str], index: int, count: int) -> list[str]: + return names[index::count] + + def verify_coverage(names: list[str]) -> None: roots = sorted(p.stem for p in Path("tests").glob("*.rs")) missing = [stem for stem in roots if stem not in names] @@ -231,35 +247,55 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument( "--list-only", action="store_true", - help="print discovered integration targets and exit", + help="print this shard's integration targets after coverage validation and exit", + ) + parser.add_argument( + "--shard-index", + type=int, + default=0, + help="zero-based shard to run (default 0)", + ) + parser.add_argument( + "--shard-count", + type=int, + default=1, + help="number of shards to partition integration targets across (default 1)", ) args = parser.parse_args(argv) + validate_shard(args.shard_index, args.shard_count) started = time.monotonic() names, target_root = package_plan(cargo_metadata()) verify_coverage(names) - batches = chunks(names, args.batch_size) + selected = select_shard(names, args.shard_index, args.shard_count) + batches = chunks(selected, args.batch_size) + shard_note = ( + f"shard {args.shard_index} of {args.shard_count}: " + f"{len(selected)} selected of {len(names)} integration targets" + ) if args.list_only: - for name in names: + for name in selected: print(name) print( - f"# {len(names)} integration targets in {len(batches)} batch(es) " - f"of up to {args.batch_size}", + f"# {shard_note}; {len(batches)} batch(es) of up to {args.batch_size}", file=sys.stderr, ) return 0 deps = target_root / "debug" / "deps" + lib_bins = args.shard_index == 0 print( - f"unit CI: {len(names)} integration targets, " + f"unit CI: {shard_note}, " f"{len(batches)} batch(es) of up to {args.batch_size}; " - f"lib+bins first; reclaim finished integration artifacts only", + f"{'lib+bins first; ' if lib_bins else 'lib+bins skipped; '}" + f"reclaim finished integration artifacts only", flush=True, ) - status = build_and_run_lib_bins(timings=args.timings) - if status != 0: - return status + if lib_bins: + status = build_and_run_lib_bins(timings=args.timings) + if status != 0: + return status for index, batch in enumerate(batches, start=1): status = build_and_run_integration_batch( diff --git a/scripts/smoke b/scripts/smoke index 86e41ed1d..bf893d03f 100755 --- a/scripts/smoke +++ b/scripts/smoke @@ -111,8 +111,8 @@ count_from_log() { match($0, /^[0-9]+ examples?/) { cr = num(substr($0, RSTART, RLENGTH)); next } # mix test / ExUnit match($0, /^[0-9]+ tests?,/) { ex = num(substr($0, RSTART, RLENGTH)); next } - # node:test TAP epilogue - match($0, /^# pass [0-9]+/) { node = num(substr($0, RSTART, RLENGTH)); next } + # node:test TAP or spec epilogue (Node 24 defaults to spec). + match($0, /^(#|ℹ) pass [0-9]+/) { node = num(substr($0, RSTART, RLENGTH)); next } # XCTest: per-suite lines AND a total line, so take the max match($0, /Executed [0-9]+ test/) { n = num(substr($0, RSTART, RLENGTH)) if (n > xc) xc = n; next } diff --git a/src/emit/typescript/package.rs b/src/emit/typescript/package.rs index 9479a36f8..ff76c403c 100644 --- a/src/emit/typescript/package.rs +++ b/src/emit/typescript/package.rs @@ -28,7 +28,7 @@ pub(super) fn emit_package_json() -> EmittedFile { ) }; let content = format!( - "{{\n \"name\": \"app\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {{\n \"start\": \"tsx main.ts\",\n \"test\": \"tsx --test test/*.test.ts\"\n }},\n \"dependencies\": {{\n{db_dep}\n \"linkedom\": \"^0.18.0\",\n \"ws\": \"^8.18.0\"\n }},\n \"devDependencies\": {{\n \"@types/node\": \"^20\",\n{db_types_dep} \"@types/ws\": \"^8.5.0\",\n \"typescript\": \"5.7.3\",\n \"tsx\": \"4.19.2\"\n }}\n}}\n", + "{{\n \"name\": \"app\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {{\n \"start\": \"tsx main.ts\",\n \"test\": \"tsx --test test/*.test.ts\"\n }},\n \"dependencies\": {{\n{db_dep}\n \"linkedom\": \"^0.18.0\",\n \"ws\": \"^8.18.0\"\n }},\n \"devDependencies\": {{\n \"@types/node\": \"^24\",\n{db_types_dep} \"@types/ws\": \"^8.5.0\",\n \"typescript\": \"5.7.3\",\n \"tsx\": \"4.19.2\"\n }}\n}}\n", ); EmittedFile { path: PathBuf::from("package.json"), diff --git a/src/project.rs b/src/project.rs index b5da38ded..4599efc0f 100644 --- a/src/project.rs +++ b/src/project.rs @@ -211,6 +211,8 @@ impl BuildTarget { /// and the regenerate command. For `ships_e2e` targets the `## ` /// sections are a CI contract — `scripts/smoke` executes their ```sh /// blocks verbatim against the published archive. +/// MRI prerequisites describe the minimum in `.ruby-version`, not a CI +/// patch pin. Keep the human-facing minimum aligned when that line changes. pub fn target_readme(target: BuildTarget) -> String { let name = target.as_str(); let body = match target { @@ -247,7 +249,7 @@ pub fn target_readme(target: BuildTarget) -> String { - libvips (`libvips-dev` to build, `libvips42` to run; `brew install vips`) — \ only when `spin.toml` lists `ruby-vips`, which it does when the app \ declares image variants (thumbnails, avatars)\n\ - - Node.js 18+ — for the End-to-end suite\n\n\ + - Node.js 24+ — for the End-to-end suite\n\n\ ## Build\n\ ```sh\n\ spin build\n\ @@ -502,7 +504,7 @@ pub fn target_readme(target: BuildTarget) -> String { } BuildTarget::Typescript => { "## Prerequisites\n\ - - Node.js 18+\n\n\ + - Node.js 24+\n\n\ ## Install dependencies\n\ ```sh\n\ npm install\n\ @@ -521,7 +523,7 @@ pub fn target_readme(target: BuildTarget) -> String { is loaded by a host HTML page — there's no standalone \ server.\n\n\ ## Prerequisites\n\ - - Node.js 18+ (for bundling)\n\n\ + - Node.js 24+ (for bundling)\n\n\ ## Install + build\n\ ```sh\n\ npm install\n\ @@ -600,7 +602,7 @@ pub fn target_readme(target: BuildTarget) -> String { // no target needs it now. (See the flash-wiring punch list memory.) format!( "## End-to-end\n\ - Browser smoke tests (Playwright). Needs Node.js 18+ and the \ + Browser smoke tests (Playwright). Needs Node.js 24+ and the \ `sqlite3` CLI; run after the Build steps above — the test \ config boots the server and seeds `db/seed.sql` itself:\n\ ```sh\n\ diff --git a/tests/ci_campfire_optimization_test.py b/tests/ci_campfire_optimization_test.py new file mode 100644 index 000000000..62c105f77 --- /dev/null +++ b/tests/ci_campfire_optimization_test.py @@ -0,0 +1,290 @@ +"""Ruby campfire-suite runs may overlap without changing the serial contract. + +Parallelism is acceptable only when each file still sees the serial +launch: the default SECRET_KEY_BASE, combined stdout/stderr order, the +real uid, and a private Active Storage root. A missing or refusing +unshare is a serial run, not a failed suite. +""" + +from __future__ import annotations + +import os +import shutil +import stat +import subprocess +import tempfile +import textwrap +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SUITE = ROOT / "scripts/campfire-suite" + +SHIM = textwrap.dedent( + """\ + class {klass} + def test_one + end + end + __t = {klass}.new + begin + __t.test_one + end + """ +) + + +def write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + + +def executable(path: Path, body: str) -> None: + write(path, body) + path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +class CampfireSuiteParallelTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix="campfire-suite-opt-") + self.addCleanup(self.tmp.cleanup) + self.emit = Path(self.tmp.name) / "emit" + write(self.emit / "db/seed.sql", "") + (self.emit / "tmp/storage").mkdir(parents=True) + + def _run(self, jobs: str, *, env_extra=None, unset=()) -> subprocess.CompletedProcess[str]: + # Unique names: two calls with the same jobs value must not share a file. + stamp = len(list(Path(self.tmp.name).glob("tally-*.txt"))) + tally = Path(self.tmp.name) / f"tally-{stamp}.txt" + fail_log = Path(self.tmp.name) / f"fail-{stamp}.txt" + env = os.environ.copy() + for key in unset: + env.pop(key, None) + if env_extra: + env.update(env_extra) + env["SUITE_TEST_RUN_ID"] = str(stamp) + result = subprocess.run( + [ + "bash", + str(SUITE), + "--reuse", + str(self.emit), + "--jobs", + jobs, + "--tally", + str(tally), + "--fail-log", + str(fail_log), + ], + check=False, + text=True, + capture_output=True, + env=env, + ) + result.tally_path = tally # type: ignore[attr-defined] + result.fail_path = fail_log # type: ignore[attr-defined] + return result + + def _note(self, tag: str) -> str: + return (self.emit / "tmp" / f"note-{tag}.txt").read_text() + + def _require_mounts(self): + if not shutil.which("unshare") or not shutil.which("setpriv"): + self.skipTest("unshare or setpriv is not installed") + source = Path(self.tmp.name) / "probe-source" + destination = Path(self.tmp.name) / "probe-destination" + source.mkdir() + destination.mkdir() + probe = subprocess.run( + ["unshare", "--user", "--map-current-user", "--keep-caps", "--mount", + "mount", "--bind", str(source), str(destination)], + capture_output=True, + ) + if probe.returncode: + self.skipTest("this host refuses unprivileged bind mounts") + + def _two_file_emit(self) -> None: + write( + self.emit / "Makefile", + "SPINEL_TESTS := test/models/alpha_test \\\n" + "\ttest/models/beta_test \\\n" + "\ttest/models/noisy_test\n\n", + ) + write( + self.emit / "test/models/noisy_test.rb", + textwrap.dedent( + """\ + warn "STDERR-BEFORE" + raise "STDOUT-AFTER" + __t = Object.new + def __t.test_noise; end + begin + __t.test_noise + end + puts "NoisyTest: 1 tests passed" + """ + ), + ) + # Both files write and then read the same storage filename. A + # shared root would let one file observe the other's bytes. + for tag, klass in (("alpha", "AlphaTest"), ("beta", "BetaTest")): + write( + self.emit / "test/models" / f"{tag}_test.rb", + SHIM.format(klass=klass) + + textwrap.dedent( + f"""\ + module ActionController; class Base; end; end + require "{ROOT / 'runtime/ruby/rails.rb'}" + require "{ROOT / 'runtime/spinel/active_storage_disk.rb'}" + Rails.env_name = ENV["RAILS_ENV"] + raise "argv0 changed" unless $0 == "test/models/{tag}_test.rb" + raise "arguments changed" unless ARGV.empty? + raise "cwd changed" unless Dir.pwd == File.expand_path("../..", __dir__) + warn "STDERR-FIRST-{tag}" + path = "tmp/storage/shared-name" + File.write(path, "{tag}\\n") + service = ActiveStorage::Service.new + service.upload("shared-name", "{tag}\\n") + if ENV["OVERLAP_SUITE_TESTS"] == "1" + ready = "tmp/ready-" + ENV.fetch("SUITE_TEST_RUN_ID") + "-" + File.write(ready + "{tag}", "ready") + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 10 + until File.exist?(ready + "{'beta' if tag == 'alpha' else 'alpha'}") + raise "sibling did not overlap" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline + sleep 0.01 + end + end + seen = File.read(path) + raise "shared storage leaked: " + seen.inspect unless seen == "{tag}\\n" + seen = service.download("shared-name") + raise "Active Storage leaked: " + seen.inspect unless seen == "{tag}\\n" + puts "STDOUT-SECOND-{tag}" + File.write("tmp/note-{tag}.txt", [ + "uid=#{{Process.uid}}", + "euid=#{{Process.euid}}", + "secret=#{{ENV.fetch("SECRET_KEY_BASE")}}", + ].join("\\n") + "\\n" + File.read("/proc/self/status").lines.grep(/^Cap/).join) + puts "{klass}: 1 tests passed" + """ + ), + ) + + def test_parallel_isolation_matches_serial_contract(self): + self._require_mounts() + self._two_file_emit() + for rails_env in (None, "test", "production"): + with self.subTest(rails_env=rails_env): + env = {} if rails_env is None else {"RAILS_ENV": rails_env} + self._assert_serial_contract(env) + + def _assert_serial_contract(self, env): + serial = self._run("1", env_extra=env, unset=("SECRET_KEY_BASE", "RAILS_ENV")) + serial_note = self._note("alpha") + parallel = self._run( + "2", env_extra={**env, "OVERLAP_SUITE_TESTS": "1"}, + unset=("SECRET_KEY_BASE", "RAILS_ENV"), + ) + self.assertEqual(serial.returncode, 0, serial.stderr + serial.stdout) + self.assertEqual(parallel.returncode, 0, parallel.stderr + parallel.stdout) + self.assertEqual(serial.tally_path.read_text(), parallel.tally_path.read_text()) + self.assertEqual(serial.fail_path.read_text(), parallel.fail_path.read_text()) + noisy = parallel.tally_path.read_text().split("noisy_test|", 1)[1] + # The first-error column is the first non-blank line of the + # combined stream. STDERR must still precede the raise. + self.assertIn("STDERR-BEFORE", noisy) + self.assertNotIn("STDOUT-AFTER", noisy.split("STDERR-BEFORE", 1)[0]) + self.assertIn("PASS|test/models/alpha_test|1|1|", parallel.tally_path.read_text()) + self.assertIn("PASS|test/models/beta_test|1|1|", parallel.tally_path.read_text()) + self.assertIn("ruby files ran in", parallel.stdout) + self.assertNotIn("ruby files ran in", serial.stdout) + # Isolation is the overlapping write of the same storage name. + # Both files passed, so neither saw the other's bytes. + note = self._note("alpha") + self.assertEqual(serial_note, note) + self.assertIn(f"uid={os.getuid()}", note) + self.assertIn(f"euid={os.geteuid()}", note) + self.assertIn("secret=campfire-suite-secret", note) + # Capability lines from the test process, not from the mount helper. + # Inherited and ambient must be clear; the bounding set must still + # be the caller's, which an ordinary process also has. + ordinary = subprocess.run( + ["ruby", "-e", 'puts File.read("/proc/self/status").lines.grep(/^Cap/)'], + check=True, + text=True, + capture_output=True, + ).stdout + for line in note.splitlines(): + if line.startswith("Cap"): + self.assertIn(line, ordinary.splitlines(), line) + + def test_refusing_unshare_falls_back_to_serial(self): + self._two_file_emit() + # Installed unshare that cannot mount. The suite must run the + # files in this process and still pass, not skip and not fail. + fake = Path(self.tmp.name) / "bin" / "unshare" + executable( + fake, + "#!/bin/sh\n" + "echo 'mount: permission denied' >&2\n" + "exit 1\n", + ) + result = self._run( + "2", + env_extra={"PATH": f"{fake.parent}:{os.environ['PATH']}"}, + unset=("SECRET_KEY_BASE",), + ) + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + self.assertNotIn("ruby files ran in", result.stdout) + self.assertIn("PASS|test/models/alpha_test|1|1|", result.tally_path.read_text()) + self.assertIn("secret=campfire-suite-secret", self._note("alpha")) + self.assertIn(f"uid={os.getuid()}", self._note("alpha")) + + def test_missing_unshare_falls_back_to_serial(self): + self._two_file_emit() + missing = Path(self.tmp.name) / "missing-unshare.sh" + # Simulate a failed command lookup without changing the suite's API + # or hiding the other tools that a serial run needs. + write(missing, """command() { + if [[ "$1" == -v && "$2" == unshare ]]; then return 1; fi + builtin command "$@" +} +""") + result = self._run( + "2", + env_extra={"BASH_ENV": str(missing)}, + unset=("SECRET_KEY_BASE",), + ) + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + self.assertNotIn("ruby files ran in", result.stdout) + self.assertIn("PASS|test/models/alpha_test|1|1|", result.tally_path.read_text()) + self.assertIn("secret=campfire-suite-secret", self._note("alpha")) + + def test_worker_mount_failure_cannot_run_with_shared_storage(self): + self._require_mounts() + self._two_file_emit() + fake = Path(self.tmp.name) / "bin" / "mount" + executable(fake, f"""#!/bin/sh +if [ "$3" = "$REFUSED_STORAGE_ROOT" ]; then + echo 'storage mount refused' >&2 + exit 1 +fi +exec "{shutil.which('mount')}" "$@" +""") + for root in ("tmp/storage", "storage/files"): + with self.subTest(root=root): + result = self._run( + "2", env_extra={ + "PATH": f"{fake.parent}:{os.environ['PATH']}", + "REFUSED_STORAGE_ROOT": root, + }, unset=("SECRET_KEY_BASE",), + ) + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + self.assertIn("ruby files ran in", result.stdout) + self.assertNotIn("PASS|", result.tally_path.read_text()) + self.assertEqual(result.tally_path.read_text().count("storage mount refused"), 3) + self.assertFalse((self.emit / "tmp/storage/shared-name").exists()) + self.assertFalse((self.emit / "storage/files/sh/ar/shared-name").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ci_plan_test.py b/tests/ci_plan_test.py index 51bcd6228..c7a34b329 100644 --- a/tests/ci_plan_test.py +++ b/tests/ci_plan_test.py @@ -104,13 +104,84 @@ def test_shared_runtime_rb_and_rbs_select_framework_native_coverage(self): self.assertEqual(plan["spinel_tests"], ["framework_tests_spinel"]) self.assertEqual(plan["archives"], []) - def test_draft_overrides_full_and_target_expansion(self): + def test_draft_without_full_retains_the_small_floor(self): plan = ci.select( ["src/emit/go/expressions.rs", ".github/workflows/ci.yml"], draft=True, - full=True, ) self.assertEqual(plan["required"], ["generate-fixture", "unit"]) + self.assertNotIn("build-roundhouse", plan["jobs"]) + + def test_draft_floor_and_gates_do_not_depend_on_base_order(self): + with patch.object(ci, "BASE", list(reversed(ci.BASE))): + plan = ci.select([], draft=True) + self.assertEqual(plan["jobs"], ["generate-fixture", "unit"]) + needs = { + job: {"result": "success"} + for job in ["plan", "compact-required", "generate-fixture", "unit"] + } + for compact in (False, True): + self.assertEqual(ci.check_results(plan, needs, compact=compact), ([], True)) + needs["unit"]["result"] = "failure" + self.assertTrue(ci.check_results(plan, needs, compact=compact)[0]) + needs["unit"]["result"] = "success" + + def test_full_overrides_draft_without_enabling_publication(self): + plan = ci.select(["README.md"], draft=True, full=True) + self.assertEqual(plan["smoke"], ci.TARGETS) + self.assertTrue(plan["site"]) + self.assertTrue(plan["wasm"]) + self.assertTrue(set(ci.SPINEL11).issubset(plan["jobs"])) + self.assertIn("build-roundhouse", plan["required"]) + self.assertIn("archive-results", plan["required"]) + self.assertNotIn("assemble-site", plan["jobs"]) + + def test_draft_label_events_reach_full_selection_and_unlabel_returns_to_floor(self): + with tempfile.TemporaryDirectory() as directory: + event = Path(directory) / "event.json" + env = { + "GITHUB_EVENT_PATH": str(event), + "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_SHA": "1" * 40, + "CI_SPINEL_REVISION": "2" * 40, + } + for labels, expected_smoke in [([{"name": "ci:full"}], ci.TARGETS), ([], [])]: + with self.subTest(labels=labels): + event.write_text(json.dumps({ + "pull_request": {"draft": True, "labels": labels} + })) + with ( + patch.dict(os.environ, env, clear=True), + patch("sys.argv", ["ci-plan.py", "plan"]), + patch.object(ci, "changed_inputs", return_value=(["README.md"], None)), + patch.object(ci, "write_outputs") as output, + ): + self.assertEqual(ci.main(), 0) + plan = output.call_args.args[0]["plan"] + self.assertEqual(plan["smoke"], expected_smoke) + self.assertNotIn("assemble-site", plan["jobs"]) + + def test_contract_tests_do_not_expand_the_exercised_workflows(self): + paths = [ + "tests/ci_plan_test.py", + "tests/ci_archive_evidence_test.py", + "tests/workflow_yaml_parses.rs", + "tests/ci_policy_workflow.rs", + "tests/ci_fixture_workflow.rs", + ] + for path in paths: + with self.subTest(path=path): + self.assertEqual(ci.select([path])["jobs"], ci.BASE) + self.assertEqual(ci.select(paths)["archives"], []) + # Test-only narrowing cannot hide a changed workflow or real owner. + self.assertEqual( + ci.select(paths + [".github/workflows/ci.yml"])["smoke"], ci.TARGETS + ) + partial = ci.select(paths + ["src/emit/go.rs"]) + self.assertEqual(partial["extra_compare"], ["go"]) + self.assertEqual(partial["smoke"], ["go"]) + self.assertNotIn("build-wasm", partial["jobs"]) + self.assertEqual(ci.select(paths, full=True)["smoke"], ci.TARGETS) def test_target_partial_does_not_pull_in_wasm_or_other_archives(self): plan = ci.select(["src/emit/go/expressions.rs"]) @@ -237,9 +308,8 @@ def test_packaging_and_unknown_target_changes_expand_to_full(self): "scripts/ci-plan.py", "Cargo.toml", "scripts/ci-reuse.py", - "tests/ci_archive_evidence_test.py", - "tests/ci_policy_workflow.rs", - "tests/ci_fixture_workflow.rs", + "scripts/ci-archive-evidence.py", + ".github/workflows/ci.yml", ]: with self.subTest(path=path): self.assertEqual(ci.select([path])["smoke"], ci.TARGETS) @@ -381,6 +451,17 @@ def test_extra_failure_does_not_block_compact_publication_floor(self): needs["compare"]["result"] = "failure" self.assertTrue(ci.check_results(plan, needs, compact=True)[0]) + def test_speculative_success_cannot_hide_unit_or_compiler_failure(self): + plan = ci.select([]) + for job in ["unit", "build-roundhouse"]: + for result in ["failure", "cancelled", "skipped", None]: + with self.subTest(job=job, result=result): + needs = self.needs(plan) + needs[job]["result"] = result + self.assertTrue(ci.check_results(plan, needs, compact=True)[0]) + self.assertTrue(ci.check_results(plan, needs)[0]) + self.assertFalse(ci.check_results(plan, needs)[1]) + def test_advisory_failure_is_visible_but_does_not_fail_required_gate(self): plan = ci.select([], full=True) needs = self.needs(plan) @@ -402,9 +483,15 @@ def test_each_gc_mode_must_actually_pass_for_completion(self): plan = ci.select([], full=True) self.assertEqual(ci.check_results(plan, self.needs(plan)), ([], True)) for mode in ["default", "minor-gc", "verify-gen"]: - needs = self.needs(plan) - needs["campfire-compare-spinel"]["outputs"][mode] = "failure" - self.assertEqual(ci.check_results(plan, needs), ([], False)) + for status in ["failure", "cancelled", "", None]: + with self.subTest(mode=mode, status=status): + needs = self.needs(plan) + outputs = needs["campfire-compare-spinel"]["outputs"] + if status is None: + del outputs[mode] + else: + outputs[mode] = status + self.assertEqual(ci.check_results(plan, needs), ([], False)) def test_unselected_jobs_may_skip_but_planner_must_succeed(self): plan = ci.select([]) diff --git a/tests/ci_policy_workflow.rs b/tests/ci_policy_workflow.rs index 8f53d8c51..377696eec 100644 --- a/tests/ci_policy_workflow.rs +++ b/tests/ci_policy_workflow.rs @@ -8,30 +8,63 @@ fn unit_batches_all_targets_without_reducing_coverage() { assert!(unit.get("if").is_none()); assert!(unit.get("continue-on-error").is_none()); assert_eq!(unit["runs-on"].as_str(), Some("ubuntu-latest")); + assert_eq!(unit["strategy"]["fail-fast"].as_bool(), Some(false)); + assert_eq!(unit["strategy"]["max-parallel"].as_u64(), Some(3)); + assert_eq!( + unit["strategy"]["matrix"]["shard"], + serde_yaml_ng::from_str::("[0, 1, 2]").unwrap() + ); + assert!( + unit.get("outputs").is_none(), + "no racing matrix artifact output" + ); assert_eq!( unit["env"]["CARGO_PROFILE_TEST_SPLIT_DEBUGINFO"].as_str(), Some("unpacked") ); - assert!(ci["env"].get("CARGO_PROFILE_TEST_SPLIT_DEBUGINFO").is_none()); + assert!( + ci["env"] + .get("CARGO_PROFILE_TEST_SPLIT_DEBUGINFO") + .is_none() + ); let steps = unit["steps"].as_sequence().unwrap(); - let tests = steps + let gems = steps .iter() .position(|step| { - step["name"].as_str() == Some("Build and run all test targets in batches") + step["name"].as_str() + == Some("Install gems used by emitted Ruby and Campfire harness tests") }) + .expect("install sqlite3 and bcrypt before the unit batches"); + let install = steps[gems]["run"].as_str().unwrap(); + assert!( + install.contains("gem install sqlite3") && install.contains("bcrypt"), + "{install}" + ); + let tests = steps + .iter() + .position(|step| step["name"].as_str() == Some("Build and run all test targets in batches")) .expect("batch every lib/bin/integration target through Cargo"); + assert!( + gems < tests, + "Campfire launcher regressions require bcrypt before the batches" + ); assert!(steps[tests].get("if").is_none()); assert!(steps[tests].get("continue-on-error").is_none()); let body = steps[tests]["run"].as_str().unwrap(); assert!(body.contains("--out \"$RUNNER_TEMP/unit-resources/tests\" --")); assert!(body.contains("python3 scripts/ci-unit-tests.py")); + assert!(body.contains( + "--shard-index ${{ strategy.job-index }} --shard-count ${{ strategy.job-total }}" + )); assert!( !body.contains("cargo test --locked --all-targets"), "all-target peak must not rebuild every integration executable at once" ); let timings = steps .iter() - .find(|step| step["with"]["name"].as_str() == Some("unit-build-timings")) + .find(|step| { + step["with"]["name"].as_str() == Some("unit-build-timings-${{ matrix.shard }}") + }) .expect("retain build timings for investigation"); assert_eq!(timings["if"].as_str(), Some("always()")); assert_eq!( @@ -45,7 +78,7 @@ fn unit_batches_all_targets_without_reducing_coverage() { == Some("Emit every bench lane in the debug profile (scripts/bench's shape)") }) .expect("retain the independent dev-profile stack-overflow gate"); - assert!(bench.get("if").is_none()); + assert_eq!(bench["if"].as_str(), Some("matrix.shard == 0")); assert!(bench.get("continue-on-error").is_none()); let body = bench["run"].as_str().unwrap(); assert!(body.contains("bash -euo pipefail -c")); @@ -53,44 +86,125 @@ fn unit_batches_all_targets_without_reducing_coverage() { assert!(body.contains("cargo run --quiet --bin emit_preview -- --target")); let resources = steps .iter() - .find(|step| step["with"]["name"].as_str() == Some("unit-resources")) + .find(|step| step["with"]["name"].as_str() == Some("unit-resources-${{ matrix.shard }}")) .expect("retain phase samples even when a command fails"); assert_eq!(resources["if"].as_str(), Some("always()")); assert_eq!( resources["with"]["path"].as_str(), Some("${{ runner.temp }}/unit-resources/") ); +} + +#[test] +fn speculative_fanout_retains_selection_and_real_prerequisites() { + let ci: serde_yaml_ng::Value = + serde_yaml_ng::from_str(&fs::read_to_string(".github/workflows/ci.yml").unwrap()).unwrap(); + let jobs = &ci["jobs"]; + assert_eq!(jobs["unit"]["needs"].as_str(), Some("generate-fixture")); + for name in [ + "build-roundhouse", + "build-wasm", + "build-spinel", + "writebook-inventory", + ] { + assert_eq!(jobs[name]["needs"].as_str(), Some("plan"), "{name}"); + } + for name in [ + "store-check", + "browser-smoke-typescript", + "compare", + "compare-extra", + "compare-ruby", + "compare-jruby", + ] { + assert_eq!( + jobs[name]["needs"], + serde_yaml_ng::from_str::("[generate-fixture, plan]").unwrap(), + "{name} must not wait for tests, or lose its fixture/selection" + ); + } + for name in [ + "build-roundhouse", + "build-wasm", + "build-spinel", + "writebook-inventory", + "store-check", + "browser-smoke-typescript", + "compare", + "compare-extra", + "compare-ruby", + "compare-jruby", + ] { + assert_eq!( + jobs[name]["if"].as_str(), + Some( + format!("${{{{ contains(fromJSON(needs.plan.outputs.jobs), '{name}') }}}}") + .as_str() + ), + "earlier fanout must still skip unselected jobs: {name}" + ); + } + for name in ["compact-required", "ci-summary"] { + let needs = jobs[name]["needs"].as_sequence().unwrap(); + for required in [ + "unit", + "build-roundhouse", + "campfire-conformance", + "campfire-compare", + ] { + assert!( + needs.iter().any(|v| v.as_str() == Some(required)), + "{name}: {required}" + ); + } + assert_eq!(jobs[name]["if"].as_str(), Some("always()")); + } +} + +#[test] +fn shared_debug_compiler_is_selected_and_built_without_waiting_for_tests() { + let ci: serde_yaml_ng::Value = + serde_yaml_ng::from_str(&fs::read_to_string(".github/workflows/ci.yml").unwrap()).unwrap(); + let producer = &ci["jobs"]["build-roundhouse"]; + assert_eq!(producer["needs"].as_str(), Some("plan")); + assert_eq!( + producer["if"].as_str(), + Some("${{ contains(fromJSON(needs.plan.outputs.jobs), 'build-roundhouse') }}") + ); + assert!(producer.get("continue-on-error").is_none()); + let steps = producer["steps"].as_sequence().unwrap(); // #317: current-run debug compiler for Campfire consumers. assert_eq!( - unit["outputs"]["roundhouse-bin-artifact-id"].as_str(), + producer["outputs"]["roundhouse-bin-artifact-id"].as_str(), Some("${{ steps.roundhouse-bin.outputs.artifact-id }}") ); let stage = steps .iter() .find(|step| step["name"].as_str() == Some("Stage current-run debug roundhouse binary")) - .expect("stage the debug bin after tests/bench emission"); + .expect("stage the debug bin before any consumer"); let stage_body = stage["run"].as_str().unwrap(); assert!(stage_body.contains("cargo build --locked --bin roundhouse")); assert!(stage_body.contains("roundhouse-debug-bin/identity.txt")); assert!(stage_body.contains("profile=debug")); assert!(stage_body.contains("source_sha=${GITHUB_SHA}")); + assert!(stage_body.contains("producer_job=build-roundhouse")); + assert!(stage.get("if").is_none()); + assert!(stage.get("continue-on-error").is_none()); let upload = steps .iter() .find(|step| step["id"].as_str() == Some("roundhouse-bin")) .expect("upload the staged debug binary"); - assert!(upload["uses"] - .as_str() - .unwrap() - .starts_with("actions/upload-artifact@")); + assert!( + upload["uses"] + .as_str() + .unwrap() + .starts_with("actions/upload-artifact@") + ); assert_eq!( upload["with"]["name"].as_str(), Some("roundhouse-debug-bin") ); assert_eq!(upload["with"]["retention-days"].as_u64(), Some(1)); - let resources_pos = steps - .iter() - .position(|step| step["with"]["name"].as_str() == Some("unit-resources")) - .unwrap(); let stage_pos = steps .iter() .position(|step| step["name"].as_str() == Some("Stage current-run debug roundhouse binary")) @@ -99,25 +213,21 @@ fn unit_batches_all_targets_without_reducing_coverage() { .iter() .position(|step| step["id"].as_str() == Some("roundhouse-bin")) .unwrap(); - assert!(resources_pos < stage_pos && stage_pos < upload_pos); + assert!(stage_pos < upload_pos); } #[test] -fn campfire_consumers_require_unit_debug_binary_and_do_not_rebuild() { +fn campfire_consumers_require_shared_debug_binary_and_do_not_rebuild() { let ci: serde_yaml_ng::Value = serde_yaml_ng::from_str(&fs::read_to_string(".github/workflows/ci.yml").unwrap()).unwrap(); for job_name in ["campfire-compare", "campfire-conformance"] { let job = &ci["jobs"][job_name]; - assert_eq!(job["needs"][0].as_str(), Some("unit")); + assert_eq!(job["needs"][0].as_str(), Some("build-roundhouse")); assert_eq!(job["needs"][1].as_str(), Some("plan")); let expected_if = format!( - "${{{{ contains(fromJSON(needs.plan.outputs.jobs), '{job_name}') && needs.unit.outputs.roundhouse-bin-artifact-id != '' }}}}" - ); - assert_eq!( - job["if"].as_str(), - Some(expected_if.as_str()), - "{job_name}" + "${{{{ contains(fromJSON(needs.plan.outputs.jobs), '{job_name}') && needs.build-roundhouse.outputs.roundhouse-bin-artifact-id != '' }}}}" ); + assert_eq!(job["if"].as_str(), Some(expected_if.as_str()), "{job_name}"); let steps = job["steps"].as_sequence().unwrap(); assert!( steps.iter().all(|step| { @@ -126,7 +236,7 @@ fn campfire_consumers_require_unit_debug_binary_and_do_not_rebuild() { .map(|u| !u.contains("setup-rust") && !u.contains("rust-cache")) .unwrap_or(true) }), - "{job_name} must not install Rust; it consumes the unit binary" + "{job_name} must not install Rust; it consumes the shared binary" ); let download = steps .iter() @@ -147,7 +257,9 @@ fn campfire_consumers_require_unit_debug_binary_and_do_not_rebuild() { .unwrap_or_else(|| panic!("{job_name}: stage shared binary")); let body = stage["run"].as_str().unwrap(); assert!(body.contains("chmod +x roundhouse-debug-bin/roundhouse")); - assert!(body.contains("ROUNDHOUSE_BIN=${GITHUB_WORKSPACE}/roundhouse-debug-bin/roundhouse")); + assert!( + body.contains("ROUNDHOUSE_BIN=${GITHUB_WORKSPACE}/roundhouse-debug-bin/roundhouse") + ); assert!(body.contains("ROUNDHOUSE_BIN_TRACE=1")); assert!(body.contains("source_sha=${GITHUB_SHA}")); assert!(body.contains("profile=debug")); @@ -181,9 +293,11 @@ fn campfire_consumers_require_unit_debug_binary_and_do_not_rebuild() { let strict_run = strict["run"].as_str().unwrap(); assert!(strict_run.contains("test -x \"$ROUNDHOUSE_BIN\"")); assert!(strict_run.contains("\"$ROUNDHOUSE_BIN\"")); - assert!(!strict_run - .lines() - .any(|l| !l.trim().starts_with('#') && l.contains("cargo run"))); + assert!( + !strict_run + .lines() + .any(|l| !l.trim().starts_with('#') && l.contains("cargo run")) + ); } #[test] @@ -201,7 +315,8 @@ fn test_backtraces_retain_library_and_integration_source_locations() { .unwrap() .as_nanos(); let root = std::env::temp_dir().join(format!( - "roundhouse-backtrace-{}-{unique}", std::process::id() + "roundhouse-backtrace-{}-{unique}", + std::process::id() )); fs::create_dir(&root).unwrap(); let output = std::process::Command::new(std::env::current_exe().unwrap()) @@ -222,7 +337,9 @@ fn test_backtraces_retain_library_and_integration_source_locations() { // The panic header includes a location even without debug info. // Require symbolicated stack frames, not just that header. assert!( - stderr.lines().any(|line| line.trim_start().starts_with("at ") && line.contains(source)), + stderr + .lines() + .any(|line| line.trim_start().starts_with("at ") && line.contains(source)), "missing file/line backtrace for {source}:\n{stderr}" ); } @@ -230,8 +347,13 @@ fn test_backtraces_retain_library_and_integration_source_locations() { #[test] #[cfg(target_os = "linux")] -fn resource_and_unit_batch_helpers_preserve_failures_and_contracts() { - for test in ["tests/ci_resources_test.py", "tests/ci_unit_tests_test.py"] { +fn resource_and_harness_helpers_preserve_failures_and_contracts() { + for test in [ + "tests/ci_resources_test.py", + "tests/ci_unit_tests_test.py", + "tests/ci_campfire_optimization_test.py", + "tests/ci_smoke_test.py", + ] { let result = std::process::Command::new("python3") .args(["-B", test, "-v"]) .output() @@ -273,9 +395,9 @@ fn compact_and_extra_compare_share_commands_but_not_results() { assert_eq!(jobs["compare"]["steps"], jobs["compare-extra"]["steps"]); assert_eq!( jobs["compare-extra"]["strategy"]["max-parallel"].as_u64(), - Some(2) + Some(7) ); - assert_eq!(jobs["smoke"]["strategy"]["max-parallel"].as_u64(), Some(2)); + assert_eq!(jobs["smoke"]["strategy"]["max-parallel"].as_u64(), Some(6)); let smoke_guard = jobs["smoke"]["if"].as_str().unwrap(); for condition in [ "!cancelled()", @@ -289,7 +411,29 @@ fn compact_and_extra_compare_share_commands_but_not_results() { } assert_eq!( jobs["campfire-compare-spinel"]["strategy"]["max-parallel"].as_u64(), - Some(1) + Some(3) + ); + let gc = &jobs["campfire-compare-spinel"]; + assert_eq!(gc["strategy"]["fail-fast"].as_bool(), Some(false)); + for mode in ["default", "minor-gc", "verify-gen"] { + assert_eq!( + gc["outputs"][mode].as_str(), + Some(format!("${{{{ steps.result.outputs.{mode} }}}}").as_str()), + "concurrent GC legs must report distinct mode keys" + ); + } + let report = gc["steps"] + .as_sequence() + .unwrap() + .iter() + .find(|step| step["id"].as_str() == Some("result")) + .unwrap(); + assert_eq!(report["if"].as_str(), Some("always()")); + assert_eq!(report["env"]["MODE"].as_str(), Some("${{ matrix.gc }}")); + assert_eq!(report["env"]["STATUS"].as_str(), Some("${{ job.status }}")); + assert_eq!( + report["run"].as_str(), + Some("echo \"$MODE=$STATUS\" >> \"$GITHUB_OUTPUT\"") ); assert!( ci["on"]["pull_request"].get("paths-ignore").is_none(), @@ -328,7 +472,7 @@ fn generated_npm_projects_cache_downloads_without_skipping_preparation() { .iter() .filter(|(_, job)| { job["steps"].as_sequence().unwrap().iter().any(|step| { - step["uses"] == "actions/setup-node@v5" && step["with"]["cache"] == "npm" + step["uses"] == "actions/setup-node@v7" && step["with"]["cache"] == "npm" }) }) .map(|(name, _)| name.as_str().unwrap()) @@ -358,7 +502,7 @@ fn generated_npm_projects_cache_downloads_without_skipping_preparation() { let steps = workflow["jobs"][job]["steps"].as_sequence().unwrap(); let setup = steps .iter() - .position(|step| step["uses"] == "actions/setup-node@v5") + .position(|step| step["uses"] == "actions/setup-node@v7") .unwrap(); assert!(steps[setup].get("if").is_none()); assert_eq!(steps[setup]["with"]["cache"], "npm"); @@ -368,9 +512,11 @@ fn generated_npm_projects_cache_downloads_without_skipping_preparation() { .lines() .collect(); assert_eq!(inputs, [lockfile, "src/emit/typescript/package.rs"]); - assert!(inputs - .iter() - .all(|input| std::path::Path::new(input).is_file())); + assert!( + inputs + .iter() + .all(|input| std::path::Path::new(input).is_file()) + ); for name in preparations { let prepare = steps.iter().position(|step| step["name"] == name).unwrap(); assert!(setup < prepare); @@ -380,6 +526,84 @@ fn generated_npm_projects_cache_downloads_without_skipping_preparation() { } } +#[cfg(unix)] +#[test] +fn archive_smoke_reuses_setup_ruby_cache_without_skipping_readme_execution() { + let workflow: serde_yaml_ng::Value = + serde_yaml_ng::from_str(&fs::read_to_string(".github/workflows/ci.yml").unwrap()).unwrap(); + let smoke = &workflow["jobs"]["smoke"]; + assert!(smoke["env"].get("BUNDLE_PATH").is_none()); + let steps = smoke["steps"].as_sequence().unwrap(); + let position = |name| { + steps + .iter() + .position(|step| step["name"].as_str() == Some(name)) + .unwrap() + }; + let prepare = position("Prepare archive bundle cache inputs"); + let export = position("Reuse prepared gems in the fresh README smoke"); + let run = position("scripts/smoke ${{ matrix.target }}"); + let guard = "matrix.target == 'ruby' || matrix.target == 'jruby'"; + assert_eq!(steps[prepare]["if"].as_str(), Some(guard)); + assert_eq!(steps[export]["if"].as_str(), Some(guard)); + // JRuby ships a Gemfile but intentionally omits the MRI lock. Exercise + // actual preparation/export commands so an accidental lock requirement fails. + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("archive-bundle-{}-{unique}", std::process::id())); + fs::create_dir(&root).unwrap(); + let output = std::process::Command::new("bash") + .args([ + "-euo", + "pipefail", + "-c", + &format!( + "mkdir browse jruby; touch jruby/Gemfile; tar -czf browse/jruby.tgz jruby;\n{}\n{}", + steps[prepare]["run"].as_str().unwrap(), + steps[export]["run"].as_str().unwrap() + ), + ]) + .current_dir(&root) + .env("TARGET", "jruby") + .env("RUNNER_TEMP", &root) + .env("GITHUB_ENV", root.join("env")) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + assert_eq!( + fs::read_to_string(root.join("env")).unwrap(), + format!( + "BUNDLE_PATH={}/smoke-bundle-source/jruby/vendor/bundle\n", + root.display() + ) + ); + fs::remove_dir_all(&root).unwrap(); + for (name, target, version) in [ + ("Install Ruby (MRI)", "ruby", "${{ env.MRI_RUBY }}"), + ("Install JRuby 10", "jruby", "jruby-10.0"), + ] { + let setup = position(name); + assert!(prepare < setup && setup < export && export < run); + assert_eq!(steps[setup]["uses"].as_str(), Some("ruby/setup-ruby@v1")); + assert_eq!(steps[setup]["with"]["ruby-version"].as_str(), Some(version)); + assert_eq!(steps[setup]["with"]["bundler-cache"].as_bool(), Some(true)); + assert_eq!( + steps[setup]["with"]["working-directory"].as_str(), + Some("${{ runner.temp }}/smoke-bundle-source/${{ matrix.target }}") + ); + assert_eq!( + steps[setup]["if"].as_str(), + Some(format!("matrix.target == '{target}'").as_str()) + ); + } + let body = steps[run]["run"].as_str().unwrap(); + assert!(body.contains("scripts/smoke")); + assert!(body.contains("--work-dir \"$RUNNER_TEMP/ci-smoke-validation\"")); + assert!(!steps[run]["if"].as_str().unwrap().contains("cache-hit")); +} + #[cfg(unix)] #[test] fn focused_framework_loop_runs_every_selection_and_preserves_failure() { @@ -489,11 +713,13 @@ fn spinel_jobs_are_selected_explicitly_and_archive_evidence_reaches_pages() { "smoke-campfire", "smoke-campfire-docker", ] { - assert!(report["needs"] - .as_sequence() - .unwrap() - .iter() - .any(|need| need.as_str() == Some(dependency))); + assert!( + report["needs"] + .as_sequence() + .unwrap() + .iter() + .any(|need| need.as_str() == Some(dependency)) + ); } let report_steps = report["steps"].as_sequence().unwrap(); assert_eq!( @@ -514,11 +740,13 @@ fn spinel_jobs_are_selected_explicitly_and_archive_evidence_reaches_pages() { ); let assemble = &jobs["assemble-site"]; - assert!(assemble["needs"] - .as_sequence() - .unwrap() - .iter() - .any(|need| need.as_str() == Some("archive-results"))); + assert!( + assemble["needs"] + .as_sequence() + .unwrap() + .iter() + .any(|need| need.as_str() == Some("archive-results")) + ); let steps = assemble["steps"].as_sequence().unwrap(); let verify = steps.iter().position(|step| step["run"].as_str() == Some("python3 scripts/ci-archive-evidence.py verify --root _site --report _site/ci/archive-results.json")).expect("archive verification step"); let pages = steps @@ -530,11 +758,13 @@ fn spinel_jobs_are_selected_explicitly_and_archive_evidence_reaches_pages() { }) .unwrap(); assert!(verify < pages); - assert!(jobs["ci-summary"]["needs"] - .as_sequence() - .unwrap() - .iter() - .any(|need| need.as_str() == Some("archive-results"))); + assert!( + jobs["ci-summary"]["needs"] + .as_sequence() + .unwrap() + .iter() + .any(|need| need.as_str() == Some("archive-results")) + ); } #[test] @@ -595,10 +825,12 @@ fn full_scheduler_runs_every_preflight_success_fresh_and_never_grants_pr_deploy_ ); assert!(deploy.get("continue-on-error").is_none()); assert_eq!(deploy["permissions"]["pages"].as_str(), Some("write")); - assert!(deploy["steps"][0]["run"] - .as_str() - .unwrap() - .contains("$VALIDATED_SHA")); + assert!( + deploy["steps"][0]["run"] + .as_str() + .unwrap() + .contains("$VALIDATED_SHA") + ); } #[cfg(unix)] diff --git a/tests/ci_smoke_test.py b/tests/ci_smoke_test.py new file mode 100644 index 000000000..054cd32a4 --- /dev/null +++ b/tests/ci_smoke_test.py @@ -0,0 +1,66 @@ +"""Archive smoke recognizes runner counts without weakening execution floors.""" + +import os +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path + +SMOKE = Path(__file__).resolve().parents[1] / "scripts/smoke" + + +class SmokeCounts(unittest.TestCase): + def test_node_reporters_count_passes_not_total_tests(self): + with tempfile.TemporaryDirectory() as directory: + log = Path(directory) / "tests.log" + for prefix, passed in [("#", 19), ("ℹ", 19), ("ℹ", 0)]: + with self.subTest(prefix=prefix, passed=passed): + log.write_text(f"{prefix} tests 35\n{prefix} pass {passed}\n{prefix} fail 16\n") + result = subprocess.run( + ["bash", str(SMOKE), "--explain-count", str(log)], + capture_output=True, text=True, check=True, + ) + self.assertEqual(result.stdout, f"count_from_log: {passed}\n") + log.write_text("application says pass 21\n") + result = subprocess.run( + ["bash", str(SMOKE), "--explain-count", str(log)], + capture_output=True, text=True, check=True, + ) + self.assertEqual(result.stdout, "count_from_log: \n") + + def test_spec_reporter_preserves_the_archive_floor_and_block_failures(self): + env = os.environ.copy() + env.pop("SMOKE_MIN_TESTS", None) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + app = root / "typescript" + app.mkdir() + archive = root / "typescript.tgz" + for passed, exit_command, success in [ + (0, "true", False), + (20, "true", False), + (21, "true", True), + (21, "false", False), + ]: + with self.subTest(passed=passed, exit_command=exit_command): + (app / "README.md").write_text( + "## Test\n```sh\n" + f"printf '%s\\n' 'ℹ tests 27' 'ℹ pass {passed}'\n" + f"{exit_command}\n```\n" + ) + with tarfile.open(archive, "w:gz") as tar: + tar.add(app, arcname="typescript") + result = subprocess.run( + ["bash", str(SMOKE), "--tgz", str(archive), "typescript"], + capture_output=True, text=True, env=env, + ) + self.assertEqual(result.returncode == 0, success, result.stdout + result.stderr) + if success: + self.assertIn("executed 21 tests (floor 21)", result.stdout) + elif exit_command == "true": + self.assertIn(f"executed {passed} tests, below the floor of 21", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/ci_toolchain_workflow.rs b/tests/ci_toolchain_workflow.rs new file mode 100644 index 000000000..ef8444d98 --- /dev/null +++ b/tests/ci_toolchain_workflow.rs @@ -0,0 +1,101 @@ +use std::fs; + +/// Inspect expanded steps, including YAML aliases: every MRI setup and +/// prepared-oracle key must follow the workflow selector. JRuby is separate. +#[test] +fn mri_jobs_and_oracle_caches_use_the_central_ruby_line() { + let source = fs::read_to_string(".github/workflows/ci.yml").unwrap(); + let workflow: serde_yaml_ng::Value = serde_yaml_ng::from_str(&source).unwrap(); + let minimum = fs::read_to_string(".ruby-version").unwrap(); + assert_eq!(workflow["env"]["MRI_RUBY"].as_str(), Some(minimum.trim())); + let (mut mri, mut jruby, mut oracles) = (0, 0, 0); + for (name, job) in workflow["jobs"].as_mapping().unwrap() { + assert!(job["env"].get("MRI_RUBY").is_none(), "{name:?} shadows MRI"); + for step in job["steps"].as_sequence().unwrap() { + assert!( + step["env"].get("MRI_RUBY").is_none(), + "{name:?} shadows MRI" + ); + let uses = step["uses"].as_str().unwrap_or(""); + if uses.starts_with("ruby/setup-ruby@") { + match step["with"]["ruby-version"].as_str() { + Some("${{ env.MRI_RUBY }}") => mri += 1, + Some("jruby-10.0") => jruby += 1, + version => panic!("{name:?} bypasses the MRI selector: {version:?}"), + } + } + let key = step["with"]["key"].as_str().unwrap_or(""); + if key.starts_with("campfire-oracle-") { + oracles += 1; + assert!(key.contains("ruby${{ env.MRI_RUBY }}"), "{name:?}: {key}"); + } + } + } + assert!(mri > 0 && jruby > 0 && oracles > 0); +} + +/// Active Node work uses one current major. A leftover Node 20 pin +/// recompiles `better-sqlite3` (no ABI 115 prebuild) and a mixed +/// `setup-node` major splits the cache and install contract. +#[test] +fn active_node_jobs_pin_node_24_with_setup_node_v7() { + let source = fs::read_to_string(".github/workflows/ci.yml").unwrap(); + let workflow: serde_yaml_ng::Value = serde_yaml_ng::from_str(&source).unwrap(); + let mut expanded = 0; + for (name, job) in workflow["jobs"].as_mapping().unwrap() { + for step in job["steps"].as_sequence().unwrap() { + let uses = step["uses"].as_str().unwrap_or(""); + if !uses.starts_with("actions/setup-node@") { + continue; + } + expanded += 1; + assert_eq!( + uses, "actions/setup-node@v7", + "{name:?} must use the current setup-node major" + ); + assert_eq!( + step["with"]["node-version"].as_str(), + Some("24"), + "{name:?} must install Node 24, not an older ABI" + ); + } + } + assert!( + expanded > 0, + "must inspect actual Node setup steps, including expanded YAML anchors" + ); + let files = roundhouse::emit::typescript::emit(&roundhouse::App::new()); + let package = files + .iter() + .find(|file| file.path == std::path::Path::new("package.json")) + .expect("typescript emit writes package.json"); + let package: serde_json::Value = serde_json::from_str(&package.content).unwrap(); + assert_eq!( + package["devDependencies"]["@types/node"].as_str(), + Some("^24"), + "emitted Node types must follow the runtime pin" + ); +} + +#[test] +fn uv_cache_keys_use_the_generated_dependency_source_before_emit() { + let workflow: serde_yaml_ng::Value = + serde_yaml_ng::from_str(&fs::read_to_string(".github/workflows/ci.yml").unwrap()).unwrap(); + for job in ["compare", "compare-extra", "smoke"] { + let steps = workflow["jobs"][job]["steps"].as_sequence().unwrap(); + let uv = steps + .iter() + .find(|step| { + step["uses"] + .as_str() + .unwrap_or("") + .starts_with("astral-sh/setup-uv@") + }) + .expect("Python lanes install uv before generating a project"); + let glob = uv["with"]["cache-dependency-glob"].as_str().unwrap(); + assert_eq!(glob, "src/emit/python/pyproject.rs", "{job}"); + assert!(std::path::Path::new(glob).is_file(), "{job}: {glob}"); + assert!(uv["with"].get("enable-cache").is_none()); + assert!(uv["with"].get("ignore-nothing-to-cache").is_none()); + } +} diff --git a/tests/ci_unit_tests_test.py b/tests/ci_unit_tests_test.py index f8d5249d2..7db0a8c67 100644 --- a/tests/ci_unit_tests_test.py +++ b/tests/ci_unit_tests_test.py @@ -3,6 +3,7 @@ from __future__ import annotations import importlib.util +import io import os import stat import tempfile @@ -142,6 +143,172 @@ def fake_run(args, **_kwargs): self.assertFalse(dwo.exists()) self.assertTrue(helper.exists()) + def test_invalid_shard_bounds_do_not_invoke_cargo(self): + cases = [ + ["--shard-count", "0"], + ["--shard-count", "-1"], + ["--shard-index", "-1"], + ["--shard-index", "1", "--shard-count", "1"], + ["--shard-index", "3", "--shard-count", "3"], + ] + for argv in cases: + with self.subTest(argv=argv): + with mock.patch.object(unit, "cargo_metadata") as meta, mock.patch.object( + unit, "run_cargo" + ) as cargo: + with self.assertRaises(SystemExit) as ctx: + unit.main(argv) + self.assertIsInstance(ctx.exception.code, str) + self.assertIn("shard", ctx.exception.code) + meta.assert_not_called() + cargo.assert_not_called() + + def test_select_shard_interleaves_nondivisible_names(self): + names = [f"t{i:02d}" for i in range(7)] + expected = { + 0: ["t00", "t03", "t06"], + 1: ["t01", "t04"], + 2: ["t02", "t05"], + } + shards = {index: unit.select_shard(names, index, 3) for index in range(3)} + self.assertEqual(shards, expected) + union = [name for index in range(3) for name in shards[index]] + self.assertEqual(sorted(union), names) + self.assertEqual(len(union), len(set(union))) + for left in range(3): + for right in range(left + 1, 3): + self.assertTrue(set(shards[left]).isdisjoint(shards[right])) + self.assertEqual(unit.select_shard(names, 0, 1), names) + + def test_list_only_prints_selected_shard_after_global_coverage(self): + names = [f"t{i:02d}" for i in range(7)] + expected = ["t01", "t04"] + meta = { + "target_directory": "/tmp", + "packages": [{ + "name": "roundhouse", + "targets": [{"name": name, "kind": ["test"]} for name in names], + }], + } + stdout = io.StringIO() + stderr = io.StringIO() + with mock.patch.object(unit, "cargo_metadata", return_value=meta), mock.patch.object( + unit, "verify_coverage" + ) as coverage, mock.patch.object(unit, "run_cargo") as cargo, mock.patch( + "sys.stdout", stdout + ), mock.patch("sys.stderr", stderr): + code = unit.main(["--list-only", "--shard-index", "1", "--shard-count", "3"]) + self.assertEqual(code, 0) + cargo.assert_not_called() + coverage.assert_called_once_with(names) + self.assertEqual(stdout.getvalue().splitlines(), expected) + err = stderr.getvalue() + self.assertIn("shard 1 of 3", err) + self.assertIn("2 selected of 7", err) + + def test_nonzero_shard_skips_library_and_binary_units(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + deps = root / "debug" / "deps" + deps.mkdir(parents=True) + alpha = deps / "alpha-aaaaaaaaaaaaaaaa" + beta = deps / "beta-bbbbbbbbbbbbbbbb" + gamma = deps / "gamma-cccccccccccccccc" + for path in (alpha, beta, gamma): + write_executable(path) + calls: list[list[str]] = [] + + def fake_run(args, **_kwargs): + calls.append(list(args)) + return 0 + + with mock.patch.object(unit, "cargo_metadata", return_value={ + "target_directory": str(root), + "packages": [{ + "name": "roundhouse", + "targets": [ + {"name": "alpha", "kind": ["test"]}, + {"name": "beta", "kind": ["test"]}, + {"name": "gamma", "kind": ["test"]}, + ], + }], + }), mock.patch.object(unit, "verify_coverage"), mock.patch.object( + unit, "run_cargo", side_effect=fake_run + ): + code = unit.main( + ["--shard-index", "1", "--shard-count", "2", "--no-timings"] + ) + self.assertEqual(code, 0) + self.assertFalse(any("--lib" in call or "--bins" in call for call in calls)) + tested = [ + call[call.index("--test") + 1] for call in calls if "--test" in call + ] + self.assertTrue(tested) + self.assertTrue(all(name == "beta" for name in tested)) + self.assertTrue(all("--locked" in call for call in calls)) + self.assertFalse(beta.exists()) + self.assertTrue(alpha.exists()) + self.assertTrue(gamma.exists()) + + def test_global_missing_metadata_is_rejected_despite_filtering(self): + stems = sorted(p.stem for p in Path("tests").glob("*.rs")) + self.assertGreater(len(stems), 3) + omitted = stems[0] + incomplete = [stem for stem in stems if stem != omitted] + with mock.patch.object(unit, "cargo_metadata", return_value={ + "target_directory": "/tmp", + "packages": [{ + "name": "roundhouse", + "targets": [{"name": stem, "kind": ["test"]} for stem in incomplete], + }], + }), mock.patch.object(unit, "run_cargo") as cargo: + with self.assertRaises(SystemExit) as ctx: + unit.main(["--list-only", "--shard-index", "1", "--shard-count", "3"]) + cargo.assert_not_called() + message = str(ctx.exception) + self.assertIn("missing", message) + self.assertIn(omitted, message) + + def test_sharded_failure_propagates_and_skips_reclaim(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + deps = root / "debug" / "deps" + deps.mkdir(parents=True) + keep = deps / "alpha-aaaaaaaaaaaaaaaa" + fails = deps / "beta-bbbbbbbbbbbbbbbb" + dwo = deps / "beta-bbbbbbbbbbbbbbbb.beta.dwo" + write_executable(keep) + write_executable(fails) + dwo.write_text("dwo") + calls: list[list[str]] = [] + + def fake_run(args, **_kwargs): + calls.append(list(args)) + if "--test" in args and "--no-run" not in args: + return 17 + return 0 + + with mock.patch.object(unit, "cargo_metadata", return_value={ + "target_directory": str(root), + "packages": [{ + "name": "roundhouse", + "targets": [ + {"name": "alpha", "kind": ["test"]}, + {"name": "beta", "kind": ["test"]}, + ], + }], + }), mock.patch.object(unit, "verify_coverage"), mock.patch.object( + unit, "run_cargo", side_effect=fake_run + ): + code = unit.main( + ["--shard-index", "1", "--shard-count", "2", "--no-timings"] + ) + self.assertEqual(code, 17) + self.assertFalse(any("--lib" in call for call in calls)) + self.assertTrue(fails.exists(), "failed batch artifacts must remain") + self.assertTrue(dwo.exists()) + self.assertTrue(keep.exists()) + if __name__ == "__main__": unittest.main() diff --git a/tests/docs_references.rs b/tests/docs_references.rs index c6dba2a9d..be8d18a73 100644 --- a/tests/docs_references.rs +++ b/tests/docs_references.rs @@ -7,7 +7,8 @@ //! path references can — this is the same move as //! `every_runtime_method_body_is_fully_typed`: turn a discipline into //! a gate. Scope is the curated docs (README, DEVELOPMENT, AGENTS, -//! WHY, BETS, docs/README, docs/data/, docs/pipeline/); working plans +//! WHY, BETS, docs/README, docs/data/, docs/pipeline/, docs/development/, +//! docs/ci/); working plans //! and docs/archive/ are point-in-time records and exempt. //! //! Extraction rules (deliberately conservative — a missed reference @@ -59,7 +60,7 @@ const EXEMPT_EMITTED: &[&str] = &["src/db.rs", "src/runtime.rs", "src/main.rs"]; fn doc_files() -> Vec { let mut files: Vec = DOCS.iter().map(|s| s.to_string()).collect(); - for dir in ["docs/data", "docs/pipeline"] { + for dir in ["docs/data", "docs/pipeline", "docs/development", "docs/ci"] { for entry in fs::read_dir(dir).expect(dir) { let p = entry.unwrap().path(); if p.extension().is_some_and(|e| e == "md") { diff --git a/tests/rh_verify_test.rb b/tests/rh_verify_test.rb index a45921b84..197bfc1b4 100644 --- a/tests/rh_verify_test.rb +++ b/tests/rh_verify_test.rb @@ -16,6 +16,7 @@ def setup FileUtils.mkdir_p(File.join(@root, dir)) end FileUtils.cp(File.join(SOURCE, 'bin/rh'), File.join(@root, 'bin/rh')) + FileUtils.cp(File.join(SOURCE, '.ruby-version'), File.join(@root, '.ruby-version')) FileUtils.cp(File.join(SOURCE, 'scripts/ci-plan.py'), File.join(@root, 'scripts/ci-plan.py')) File.write(File.join(@root, '.gitignore'), "mocks/\ncargo.log\n") File.write(File.join(@root, 'src/emit/go.rs'), 'before') @@ -353,6 +354,20 @@ def test_help_human_results_and_doctor_discover_verification assert_includes out, 'optional verify hosted-coverage preview' end + def test_doctor_reads_ruby_minimum_from_the_checkout_not_the_workflow_or_cwd + [['99', '✗', "#{RUBY_VERSION} (< 99)"], ['0', '✓', RUBY_VERSION]].each do |minimum, mark, version| + File.write(File.join(@root, '.ruby-version'), "#{minimum}\n") + out, err, status = Open3.capture3( + { 'PATH' => File.join(@root, 'mocks') }, + RbConfig.ruby, File.join(@root, 'bin/rh'), 'doctor', chdir: '/') + assert status.success?, err + ruby = out.lines.find { |line| line.start_with?(' Ruby ') } + assert_includes ruby, mark + assert_includes ruby, version + refute_includes ruby, '(< ' if minimum == '0' + end + end + def test_invalid_inputs_never_start_cargo [%w[--test ../example], %w[--toolchain unknown], %w[--jobs 0], %w[--base nonexistent], %w[--base --help], %w[--ignored], %w[extra]].each do |args| diff --git a/tests/workflow_yaml_parses.rs b/tests/workflow_yaml_parses.rs index c4405acd3..cab605e15 100644 --- a/tests/workflow_yaml_parses.rs +++ b/tests/workflow_yaml_parses.rs @@ -225,10 +225,7 @@ fn campfire_docker_smoke_caches_apt_for_eight_hours_and_always_builds() { let restore = step("Restore Campfire Docker apt layers"); assert_eq!(restore["id"].as_str(), Some("docker-cache")); assert_eq!(restore["continue-on-error"].as_bool(), Some(true)); - assert_eq!( - restore["uses"].as_str(), - Some("actions/cache/restore@v4") - ); + assert_eq!(restore["uses"].as_str(), Some("actions/cache/restore@v6")); assert_eq!( restore["with"]["path"].as_str(), Some("${{ env.CAMPFIRE_DOCKER_CACHE }}") @@ -270,7 +267,7 @@ fn campfire_docker_smoke_caches_apt_for_eight_hours_and_always_builds() { let save = step("Save Campfire Docker apt layers"); assert_eq!(save["continue-on-error"].as_bool(), Some(true)); - assert_eq!(save["uses"].as_str(), Some("actions/cache/save@v4")); + assert_eq!(save["uses"].as_str(), Some("actions/cache/save@v6")); assert_eq!( save["if"].as_str(), Some("steps.smoke.outcome == 'success' && steps.docker-cache.outputs.cache-hit != 'true'") @@ -284,11 +281,6 @@ fn campfire_docker_smoke_caches_apt_for_eight_hours_and_always_builds() { "local BuildKit cache under actions/cache; no build-push-action GHA backend" ); } - - let policy = fs::read_to_string("docs/ci-reuse.md").unwrap(); - assert!(policy.contains("eight-hour")); - assert!(policy.contains("Do not cache the make")); - assert!(policy.contains("primary key only") || policy.contains("no cross-bucket")); } #[test] @@ -607,24 +599,24 @@ fn campfire_comparisons_require_an_uploaded_binary_and_report_blocking() { #[cfg(unix)] #[test] -fn unit_debug_roundhouse_reaches_campfire_consumers_via_roundhouse_bin() { +fn shared_debug_roundhouse_reaches_campfire_consumers_via_roundhouse_bin() { use std::os::unix::fs::PermissionsExt; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; let workflow: serde_yaml_ng::Value = serde_yaml_ng::from_str(&fs::read_to_string(".github/workflows/ci.yml").unwrap()).unwrap(); - let unit = &workflow["jobs"]["unit"]; + let producer = &workflow["jobs"]["build-roundhouse"]; assert_eq!( - unit["outputs"]["roundhouse-bin-artifact-id"].as_str(), + producer["outputs"]["roundhouse-bin-artifact-id"].as_str(), Some("${{ steps.roundhouse-bin.outputs.artifact-id }}") ); - let upload = unit["steps"] + let upload = producer["steps"] .as_sequence() .unwrap() .iter() .find(|step| step["id"].as_str() == Some("roundhouse-bin")) - .expect("unit uploads the debug binary"); + .expect("producer uploads the debug binary"); assert_eq!( upload["with"]["name"].as_str(), Some("roundhouse-debug-bin") @@ -1105,16 +1097,16 @@ fn pr_reuse_never_masks_validation_failures_or_changes_the_job_graph() { let validation_ids: &[&str] = match name { "store-check" => { assert_eq!(job["needs"][0].as_str(), Some("generate-fixture")); - assert_eq!(job["needs"][1].as_str(), Some("unit")); + assert_eq!(job["needs"][1].as_str(), Some("plan")); &["build", "check"] } "writebook-inventory" => { - assert_eq!(job["needs"][0].as_str(), Some("unit")); + assert_eq!(job["needs"].as_str(), Some("plan")); &["inventory", "report"] } "browser-smoke-typescript" => { assert_eq!(job["needs"][0].as_str(), Some("generate-fixture")); - assert_eq!(job["needs"][1].as_str(), Some("unit")); + assert_eq!(job["needs"][1].as_str(), Some("plan")); &["browser"] } "smoke" => {