name: CI

on:
  push:
    branches: [main]
  pull_request:

env:
  CARGO_TERM_COLOR: always
  RUSTFLAGS: "-D warnings"

# Cancel a pull request's superseded runs instead of letting them burn
# to completion. This project rebases heavily — the ledger re-conflicts
# on nearly every merge — so a branch routinely takes several pushes
# while an earlier run is still going, and each of those runs is
# obsolete the moment the next push lands. macOS minutes are the
# expensive ones and the macOS leg is the critical path, so superseded
# runs are exactly where the waste concentrates.
#
# Scoped to pull requests deliberately. `github.event.pull_request.number`
# is empty for a push to `main`, so the fallback keys those runs by SHA:
# every `main` commit gets its own group and none can cancel another.
# Cancelling a `main` run would leave the branch-protection record
# ambiguous about a commit that has already landed — the one place this
# saving is not worth having.
concurrency:
  group: ci-${{ github.event.pull_request.number || github.sha }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

# Every job carries `timeout-minutes`. Without one a job inherits
# GitHub's 360-minute default, so a single hung test burns six hours —
# times four on the test matrix — and reports nothing useful at the end
# of it.
#
# The ceilings are justified against OBSERVED EXECUTION, and the
# numbers are a reading rather than a constant, so re-measure before
# trusting them:
#
#   * observed max, 25-run window: 17 min (macOS/luajit)
#   * observed max, 12-run window: 15.8 min (same job)
#   * every other job: under 4 min
#
# `timeout-minutes` counts EXECUTION, not queue time — a 33-minute
# wall-clock run in that window executed its longest job in 17 — so no
# run in the observed history would have been killed by these values.
#
# The exposure is the case the window does NOT contain: a COLD CACHE.
# A stable-toolchain bump invalidates Swatinem's key on every leg at
# once, and a cold macOS debug build of this workspace plus the suite is
# the plausible way a HEALTHY run exceeds its ceiling. The test job
# therefore gets 35 rather than 25 — roughly 2x its observed max — while
# everything else keeps 25 against a sub-4-minute observed max, except
# `m6-perf-gates`, which keeps its own tighter 15.
#
# DIAGNOSIS, WRITTEN BEFORE IT HAPPENS: four test legs timing out
# simultaneously, shortly after a Rust release, is a cold cache and not
# a hang. Rerun, or raise this number. A single leg timing out while its
# siblings pass is the hang case these ceilings exist to catch.
#
# This is also the gate that has to exist before the basedpyright-class
# hang can ever be armed — see `PMACS_REQUIRE_PYRIGHT`, deliberately
# never set, in the test job below. 35 still beats the 360-minute
# default by an order of magnitude.
jobs:
  fmt:
    name: Format
    runs-on: ubuntu-latest
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: rustfmt
      - run: cargo fmt --all --check

  clippy:
    name: Lint (${{ matrix.lua }})
    runs-on: ubuntu-latest
    timeout-minutes: 25
    strategy:
      fail-fast: false
      matrix:
        # mlua's Lua-flavor features are mutually exclusive, so we can't
        # use --all-features. Run clippy under each supported flavor.
        lua: [luajit, lua54]
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: clippy
      - uses: Swatinem/rust-cache@v2
      - run: cargo clippy --all-targets --no-default-features --features ${{ matrix.lua }} -- -D warnings
      # pmacs-gpu is Lua-flavor-independent (it depends on pmacs-protocol,
      # not pmacs), so the root-package clippy above never lints it. Lint
      # it explicitly or its warnings slip through CI (audit F-001).
      - run: cargo clippy -p pmacs-gpu --all-targets -- -D warnings
      # pmacs-protocol is likewise never linted by the root-package
      # clippy above: the workspace default member is only `pmacs`. The
      # local `--workspace` gate covers it, so it passes today — CI has
      # simply never checked, and a warning introduced through a
      # protocol-only PR would reach `main` unseen.
      - run: cargo clippy -p pmacs-protocol --all-targets -- -D warnings

  gpu-render:
    name: GPU Render (headless)
    runs-on: ubuntu-latest
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      # The headless render harness (audit F-014) needs a Vulkan adapter;
      # the runner has no GPU, so install the mesa software rasterizer
      # (lavapipe). This job also runs pmacs-gpu's other tests, which the
      # workspace test job (root package only) never executes.
      - name: Install lavapipe (Vulkan software rasterizer)
        run: |
          sudo apt-get update
          sudo apt-get install -y mesa-vulkan-drivers vulkan-tools
      - name: Confirm a Vulkan adapter is present
        run: |
          ls -la /usr/share/vulkan/icd.d/ || true
          vulkaninfo --summary || true
      - name: pmacs-gpu tests (render harness included)
        env:
          # Force the Vulkan backend so wgpu uses lavapipe. Do NOT set
          # VK_ICD_FILENAMES — the loader's default ICD discovery already
          # finds lavapipe (vulkaninfo above proves it), and pinning a
          # path that doesn't match the runner hides every ICD. The
          # PMACS_REQUIRE_GPU guard turns a missing adapter into a hard
          # failure so a broken setup can't pass as a silent skip.
          WGPU_BACKEND: vulkan
          LIBGL_ALWAYS_SOFTWARE: "1"
          PMACS_REQUIRE_GPU: "1"
        run: cargo test -p pmacs-gpu -- --test-threads=1

  test:
    name: Test (${{ matrix.os }} / ${{ matrix.lua }})
    runs-on: ${{ matrix.os }}
    # 35, not 25: this is the only job whose observed max is minutes
    # rather than seconds, and the only one a cold cache can plausibly
    # push past a 25-minute ceiling on all four legs at once. See the
    # note above `jobs:` for the measurements and the cold-cache
    # diagnosis.
    timeout-minutes: 35
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest]
        lua: [luajit, lua54]
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      # Arm the external-tool-gated tests (see `TEST_IMPROVEMENT.md`
      # §1.2). Before this step nothing installed these tools, so every
      # test guarded on them returned early and reported GREEN without
      # executing its body — a whole block of real-language-server and
      # multi-shell coverage that had never once run in CI. Installing
      # them is only half the fix; the `PMACS_REQUIRE_*` variables below
      # are what turn a future missing tool back into a failure instead
      # of silently restoring the vacuum.
      #
      # Linux only for now, deliberately. macOS would need the brew
      # equivalents and roughly doubles the install cost on the slowest
      # leg of the matrix; arming one platform already converts these
      # from never-executed to executed, and the second is incremental.
      # The tests still skip cleanly on macOS because the variables are
      # unset there.
      - name: Install external tools that gate acceptance tests (Linux)
        if: runner.os == 'Linux'
        run: |
          sudo apt-get update
          sudo apt-get install -y clangd zsh fish lua5.4
          # `locate_lua` looks for `lua` or `luajit` by name; the
          # distro package installs `lua5.4` only.
          sudo ln -sf "$(command -v lua5.4)" /usr/local/bin/lua
          # rust-analyzer belongs HERE, not on the shared toolchain
          # step. `components:` there applies to every matrix leg, so it
          # would install the binary on macOS too — and *presence*, not
          # PMACS_REQUIRE_LSP, is what decides whether a gated test body
          # runs. That would have executed the rust-analyzer tests on
          # macOS for the first time ever, on the legs that are both the
          # CI critical path and the documented flake surface, while
          # this lane's text claimed Linux only.
          rustup component add rust-analyzer
          # Versions are PINNED. `@latest` and bare `npm install -g`
          # make CI behaviour drift with upstream releases: a bad gopls
          # or yaml-language-server publish then breaks CI with no
          # commit in this repository to bisect against.
          go install golang.org/x/tools/gopls@v0.16.2
          echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
          npm install -g vscode-langservers-extracted@4.10.0 \
                         yaml-language-server@1.15.0
      - run: cargo build --all-targets --no-default-features --features ${{ matrix.lua }}
      # Several acceptance binaries spawn real daemon / PTY child
      # processes. Keep the harness serial so macOS runners do not
      # expose cross-test process lifecycle races that are unrelated
      # to the behavior under test.
      #
      # PMACS_REQUIRE_* make a missing tool fatal rather than a silent
      # skip, exactly as PMACS_REQUIRE_GPU already does for the headless
      # render job. Set only where the install step ran.
      #
      # PMACS_REQUIRE_PYRIGHT is deliberately NOT set and basedpyright
      # is deliberately NOT installed. Both original reasons are now
      # gone: the hang's root cause was the stdin-field drop ordering in
      # `RuntimeHandles::drop` and is fixed, and this job now carries
      # `timeout-minutes`, so a hang could no longer burn six hours.
      # The ONE remaining reason is the plain one --- basedpyright is not
      # installed here, so arming the variable would fail rather than
      # test anything. Installing it (a uv + bundled-node download on
      # every leg) is its own decision, not a rider on the hang fix.
      #
      # PMACS_REQUIRE_SETSID arms the teardown-deadlock unit test. Its
      # fixture orphans a grandchild with `setsid --fork`, which is
      # util-linux rather than coreutils, so the test skips when the
      # binary is absent (a minimal container must not fail `--lib`
      # without ever testing pmacs) and this variable is what makes the
      # skip fatal where the tool is guaranteed.
      #
      # PMACS_REQUIRE_BASH arms the signal diagnostic's job-control
      # corroboration test, which needs `/bin/bash` and `-m` putting a
      # foreground job in its own process group with the terminal.
      #
      # Linux only, and NOT because macOS lacks bash — it ships 3.2. CI
      # measured the difference: on both macOS legs a non-interactive
      # `bash -m` kept the terminal on the leader for a full 10s wait, so
      # the divergence the test needs does not occur there. The test skips
      # on non-Linux by platform check; arming it on macOS would only make
      # a missing binary fatal for a test that cannot run anyway.
      #
      # The divergent case itself is pinned on every platform by
      # injecting the foreground group instead (framing Bet 1's stated
      # fallback, after the real fixture failed its own falsifier).
      - run: cargo test --all-targets --no-default-features --features ${{ matrix.lua }} -- --test-threads=1
        env:
          PMACS_REQUIRE_LSP: ${{ runner.os == 'Linux' && '1' || '' }}
          PMACS_REQUIRE_SHELLS: ${{ runner.os == 'Linux' && '1' || '' }}
          PMACS_REQUIRE_LUA: ${{ runner.os == 'Linux' && '1' || '' }}
          PMACS_REQUIRE_SETSID: ${{ runner.os == 'Linux' && '1' || '' }}
          PMACS_REQUIRE_BASH: ${{ runner.os == 'Linux' && '1' || '' }}
      - run: cargo test --doc --no-default-features --features ${{ matrix.lua }}
      # The workspace default member is only the root `pmacs` package, so
      # the runs above never execute pmacs-protocol's own tests — the
      # shared wire format (encode/decode, transport framing) the daemon,
      # TUI, and GPU all depend on. Run them explicitly (Lua-flavor
      # independent, so once is enough; runs on every matrix leg cheaply
      # off the shared cache).
      - run: cargo test -p pmacs-protocol --all-targets

  # crdt-test: the reason this job exists is that NOTHING in this
  # workflow ever enabled the `crdt` feature. Every
  # `#[cfg(feature = "crdt")]` test was therefore NOT COMPILED — not
  # skipped, not filtered, not reported. Measured at 4223dd3: 3,467
  # tests under the `test` job's flags versus 3,746 with `crdt`, so 279
  # tests had never executed in CI. 186 of them are in the library,
  # whose `cargo test --lib --features crdt` invocation CLAUDE.md lists
  # as a REQUIRED pre-PR gate — a required gate CI had never run.
  #
  # Eight test binaries also contained zero tests under the old flags.
  # They built, ran, and reported `ok` with nothing in them.
  #
  # ONE JOB, NOT TWO, and deliberately so. The obvious split — non-GPU
  # suites here, GPU-requiring ones onto `gpu-render` — was rejected
  # because `gpu-render` runs `cargo test -p pmacs-gpu`, a DIFFERENT
  # PACKAGE from the root-package suites that would move there. Splitting
  # would also require enumerating which suites are GPU-requiring, and a
  # suite added later would silently land in whichever job did not need
  # a GPU and skip there forever. Running the whole corpus in one place
  # with a working adapter cannot develop that hole.
  #
  # ubuntu-only and luajit-only to start, per the standing guidance to
  # take macOS from evidence rather than assumption; `crdt` is
  # orthogonal to the Lua flavor, the same reasoning m5/m6-perf-gates
  # already apply.
  #
  # The external-tool block from the `test` job is deliberately NOT
  # duplicated here. It gates m4_acceptance, m6_5_repl_acceptance and
  # m6_8_multi_repl_acceptance, and MEASURED: none of those has a single
  # dark test, so installing clangd/zsh/fish/lua/rust-analyzer/gopls/npm
  # servers again would cost minutes to change nothing. The only
  # tool-gated code in the dark set is src/process.rs, whose two
  # variables need no install on this runner and are set below.
  #
  # OBSERVED EXECUTION (local, 2026-08-01): the full serialized sweep —
  # this job's exact test command plus PMACS_REQUIRE_GPU=1 — ran in 366s
  # (6.1 min) for 3,715 passed / 0 failed / 30 ignored. The ceiling is
  # 35 rather than something near that for two reasons: a hosted runner
  # is slower per-core than the measuring machine and renders the GPU
  # suites through lavapipe rather than a real adapter, and a cold cache
  # adds a full debug workspace build. 35 matches the `test` job, which
  # runs the same corpus without `crdt` and whose own observed max is
  # 17 min — the closest available comparison, and the reason this
  # number is not smaller.
  crdt-test:
    name: Test (crdt)
    runs-on: ubuntu-latest
    timeout-minutes: 35
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      # Several dark suites drive a real GPU frontend. Without an adapter
      # they do not fail — a37 and its siblings `eprintln!` a skip and
      # return `ok`, measured at 9/9 in 0.17s versus ~4s for a real run.
      # lavapipe plus PMACS_REQUIRE_GPU below is what makes that skip
      # fatal instead of silent.
      - name: Install lavapipe (Vulkan software rasterizer)
        run: |
          sudo apt-get update
          sudo apt-get install -y mesa-vulkan-drivers vulkan-tools
      - name: Confirm a Vulkan adapter is present
        run: vulkaninfo --summary || true
      # Lint the crdt targets HERE rather than in the `clippy` job.
      # Clearing these lints once is not enough: the `clippy` job matrixes
      # over Lua flavor and never enables `crdt`, so without this step the
      # crdt targets would drift straight back out of compliance and the
      # next job to compile them would be red on arrival — which is
      # exactly the state this lane found and fixed. Keeping it beside
      # the crdt build and test means one job owns "crdt compiles, lints,
      # and passes."
      #
      # `crdt` is orthogonal to the Lua flavor, so this runs once rather
      # than matrixed. --keep-going reports every finding in one run
      # instead of aborting at the first failing target, which is what
      # made the original inventory of these lints a lower bound rather
      # than a list.
      - run: cargo clippy --workspace --all-targets --no-default-features --features luajit,crdt --keep-going -- -D warnings
      # --workspace, not the root package: a37 and the gpu_* suites
      # locate the `pmacs-gpu` binary beside `pmacs`, and a root-only
      # build leaves it absent. This is the documented cause of twelve
      # gpu_invocation_acceptance failures on a crdt sweep.
      - run: cargo build --workspace --no-default-features --features luajit,crdt
      # Serial for the same reason the `test` job is: these suites spawn
      # real daemons and real PTYs, and this job adds MORE of them.
      - run: cargo test --all-targets --no-default-features --features luajit,crdt -- --test-threads=1
        env:
          PMACS_REQUIRE_GPU: "1"
          PMACS_REQUIRE_SETSID: "1"
          PMACS_REQUIRE_BASH: "1"
      - run: cargo test --doc --no-default-features --features luajit,crdt
      # pmacs-protocol under `crdt`, which nothing else runs. Its own
      # feature is NOT inert: it gates no tests, so a test census sees
      # 17 either way, but it changes `cfg!(feature = "crdt")`
      # EXPRESSIONS inside `InstanceCapabilities::default` and
      # `FrontendCapabilities::default` — so the same 17 tests exercise
      # different runtime values. CI had only ever run the non-crdt
      # ones.
      #
      # This is also a blind spot of scripts/feature-census by
      # construction: it lists the workspace DEFAULT MEMBER (`pmacs`),
      # so no per-test census of the root package can ever surface a
      # sibling crate's coverage. The `test` job carries the same
      # explicit invocation for the same underlying reason.
      - run: cargo test -p pmacs-protocol --all-targets --features crdt

  acceptance:
    name: M1 Acceptance Gates
    runs-on: ubuntu-latest
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - name: perf, memory, and 30s fuzz gates
        run: cargo test --release --test acceptance -- --ignored --nocapture --test-threads=1

  m4-perf-gates:
    name: M4 Perf Gates
    runs-on: ubuntu-latest
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - name: tree-sitter parse + highlight latency budgets
        run: cargo test --release --test m4_acceptance -- --ignored --nocapture --test-threads=1

  # m5-perf-gates: luajit-only by design.
  # The keystroke-to-render gate measures protocol-path latency (Unix
  # socket frontend ↔ daemon RTT plus the daemon's keymap-dispatch /
  # buffer-mutate / repaint pipeline). Lua flavor is irrelevant — the
  # measured path doesn't enter the Lua VM. Matrixing over flavors
  # doubles CI cost without adding signal. If a future perf gate
  # measures Lua-touching code paths (command dispatch via Lua,
  # hook invocation), revisit then.
  m5-perf-gates:
    name: M5 Perf Gates
    runs-on: ubuntu-latest
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - name: keystroke-to-render p99 over loopback LocalSocket
        run: cargo test --release --test m5_perf_acceptance -- --ignored --nocapture

  # m6-perf-gates: luajit-only by design. The M6.6/M6.7 gates measure
  # rope-append throughput, RSS ceiling, cancel-response latency,
  # scrollback navigation latency, and scrollback search latency —
  # none of which exercise the Lua VM's hot path differently across
  # flavors. Same reasoning as m5-perf-gates above.
  #
  # --test-threads=1 is required: the M6.6 RSS-ceiling gate samples
  # process-wide /proc/self/status, and concurrent ingest / scrollback
  # populate from sibling tests inflate that reading. Serializing
  # gives each test a clean baseline. The wall-clock cost of serial
  # execution (~5 minutes) is acceptable for a perf-gates job that
  # only runs on push/PR.
  m6-perf-gates:
    name: M6 Perf Gates
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - name: ingest rate, RSS ceiling, cancel p99, navigation p99, search p99
        env:
          # Full cancel profile remains the test default. Hosted CI has
          # an effective five-minute ceiling for this job and variable
          # PTY throughput, so keep the per-PR profile short and stable.
          PMACS_M6_INGEST_MIN_BYTES_PER_SEC: "67108864"
          PMACS_M6_CANCEL_TRIALS: "30"
          PMACS_M6_CANCEL_MAX_DELAY_MS: "500"
        run: cargo test --release --test m6_perf_acceptance -- --ignored --nocapture --test-threads=1

  # m10-perf-gates: the M10 suites, which were dark for TWO independent
  # reasons. Both are fixed here.
  #
  # 1. They are `crdt`-gated, and nothing in this workflow enabled the
  #    feature, so they were never compiled. That is this lane's subject.
  # 2. Even setting `crdt` aside, NO job named them. Grepping this file
  #    for `--test` before this job existed yielded exactly four suites:
  #    acceptance, m4_acceptance, m5_perf_acceptance, m6_perf_acceptance.
  #    Their `#[ignore]` is deliberate; their absence from CI was not.
  #
  # luajit-only, for the same reason m5-perf-gates and m6-perf-gates are:
  # the measured paths (CRDT buffer mutation, socket round-trips) do not
  # enter the Lua VM, so matrixing over flavors doubles cost for no
  # signal.
  #
  # WHAT EACH SUITE ACTUALLY GATES — these differ, and the difference
  # matters for how a red run is read:
  #
  #   * m10_11_perf asserts ONE budget: cross-frontend propagation p99
  #     under 50ms. Observed 1.47ms locally (2026-08-01), a ~34x margin,
  #     so a red here is a real regression rather than runner noise.
  #
  #   * m10_2_perf asserts NOTHING. It is six measurement benches that
  #     print throughput numbers — the baselines M10.2's 391x unicode
  #     finding and v0.2+ optimization work compare against. It cannot
  #     fail a budget because it has none.
  #
  #     It is here anyway, and NOT as a perf gate: `run_workload` drives
  #     30 seconds of randomized mixed edits against both the v0.1 and
  #     CRDT buffer paths, and nothing else in the corpus exercises a
  #     sustained randomized CRDT workload. Its CI value is soak and
  #     panic detection. Do not "fix" a future silent run by adding
  #     budget assertions — the numbers are deliberately reported, not
  #     enforced, and asserting throughput on shared runners is how perf
  #     jobs become flaky.
  #
  # OBSERVED EXECUTION (local, 2026-08-01, release): m10_2_perf 79s
  # (6 tests), m10_11_perf 5s (1 test) — about 85s combined. The ceiling
  # is 25 rather than something near that, because a perf job's cost is
  # dominated by its COLD-CACHE RELEASE BUILD, not its tests; this is
  # the same reasoning that gives m5-perf-gates 25.
  m10-perf-gates:
    name: M10 Perf Gates (crdt)
    runs-on: ubuntu-latest
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - name: CRDT buffer throughput baselines (soak; asserts nothing)
        run: cargo test --release --features crdt --test m10_2_perf -- --ignored --nocapture
      - name: cross-frontend propagation p99 over a real socket
        run: cargo test --release --features crdt --test m10_11_perf -- --ignored --nocapture
