Merge pull request #204 from konjoai/feat/stack-level-maxx #683
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: "Konjo Quality Gate — Wall 2" | |
| on: | |
| pull_request: | |
| branches: [main] | |
| push: | |
| branches: [main] | |
| # Opt-in only -- review-pipeline Sprint P2b, section 4's real call site for | |
| # `konjo/mutation-hunt`. Not a default gate on every PR: the loop spends real model | |
| # tokens per round (see .konjo/kiban's plugins/konjo/skills/mutation-hunt/SKILL.md), | |
| # so it runs only when a human explicitly dispatches it against one crate. | |
| workflow_dispatch: | |
| inputs: | |
| crate: | |
| description: "Crate to run the mutation-hunt loop against (e.g. lopi-ratelimit)" | |
| required: true | |
| type: string | |
| round_cap: | |
| description: "Max rounds before the loop stops and suggests a waiver" | |
| required: false | |
| default: "3" | |
| type: string | |
| diff_base_ref: | |
| description: "Branch the production change under test is diffed against (cargo mutants --in-diff scope) -- must NOT be the same commit as the checked-out branch, or --in-diff sees no production lines changed" | |
| required: false | |
| default: "main" | |
| type: string | |
| concurrency: | |
| group: konjo-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| CARGO_TERM_COLOR: always | |
| RUST_BACKTRACE: 1 | |
| CARGO_INCREMENTAL: 0 # disable for reproducible CI builds | |
| # ── Gate summary job — all other jobs must pass before merge ──────────────────────────────────────────────────── | |
| # Added at the bottom; listed here for visibility. | |
| jobs: | |
| # ═══════════════════════════════════════════════════════════════════════════════════ | |
| # Gate 0 — Doc Staleness (kiban's `decays:` convention) | |
| # ═══════════════════════════════════════════════════════════════════════════════════ | |
| # Doc-Integrity sprint (2026-07-24): `docs/LOOP_ENGINEERING_ROADMAP.md` asserted | |
| # four capability gaps that were already closed on `main` — caught by a kill-test, | |
| # not by CI, because no gate checked whether a "current state" doc still matched | |
| # the code. This job is that gate. It is a HARD gate — not `continue-on-error: | |
| # true` — deliberately: an unstamped `decays: state` doc, or one whose | |
| # `verified-against` has drifted too far behind HEAD, fails the scan. Softening | |
| # this later reproduces the exact failure this job exists to close. | |
| doc-staleness: | |
| name: "G0 · Doc Staleness" | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 # decays: state docs are checked by commit distance from verified-against | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| - name: Install kiban's doc-staleness checker | |
| env: | |
| # Pinned deliberately — matches .konjo/kiban.ref (the session-plane pin). | |
| # Bump both together; a kiban change should not silently reach this gate. | |
| KIBAN_REF: "v1.19.0" | |
| run: | | |
| pip install --quiet pyyaml | |
| git clone --quiet --depth 1 --branch "$KIBAN_REF" https://github.com/konjoai/kiban.git /tmp/kiban | |
| - name: "Scan for stale decays: state docs" | |
| run: python3 /tmp/kiban/bin/konjo-doc-staleness scan --repo . | |
| # ═══════════════════════════════════════════════════════════════════════════════════ | |
| # Gate 1 — Static Analysis (format + lint + audit + deny) | |
| # ═══════════════════════════════════════════════════════════════════════════════════ | |
| static: | |
| name: "G1 · Static Analysis" | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 # Gate-Tiering-1, A4: scope_assert.py's --base-ref needs the real merge-base | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| components: rustfmt, clippy | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: konjo-${{ github.repository }} | |
| - name: rustfmt | |
| run: cargo fmt --all -- --check | |
| - name: clippy — zero warnings, all deny flags | |
| run: | | |
| cargo clippy --workspace --all-targets --all-features -- \ | |
| -D warnings \ | |
| -D clippy::unwrap_used \ | |
| -D clippy::expect_used \ | |
| -D clippy::panic \ | |
| -D clippy::todo \ | |
| -D clippy::unimplemented \ | |
| -D clippy::dbg_macro \ | |
| -D clippy::print_stdout \ | |
| -D clippy::print_stderr \ | |
| -W clippy::cognitive_complexity | |
| - name: clippy pedantic (advisory — non-blocking) | |
| # ADVISORY BY DESIGN, not deferred debt: clippy::pedantic is a large, | |
| # opinionated style/naming lint tier, not a correctness or security | |
| # signal — the standard clippy tier above already hard-fails on | |
| # actual bugs. Kept permanently soft. Next step, if ever: revisit | |
| # only if pedantic violations start correlating with real bugs | |
| # slipping through. | |
| continue-on-error: true | |
| run: | | |
| cargo clippy --workspace --all-targets -- -D clippy::pedantic 2>&1 \ | |
| | grep "^error" | head -20 || true | |
| echo "Pedantic lints above are advisory — fix over time." | |
| - name: cargo audit — zero known vulnerabilities | |
| # HARD GATE since Sprint S4 Phase 3. Sprint S10, Phase 2 (2026-07-27) | |
| # re-triaged this from scratch — an external audit found 11 | |
| # advisories affecting locked versions that this gate's own | |
| # `.cargo/audit.toml` had drifted out of sync with (a supply-chain | |
| # config that doesn't run cleanly is a decorative gate, per that | |
| # sprint's own framing). The sqlx 0.7 -> 0.8.6 upgrade resolved the | |
| # rustls-webpki/sqlx-format-injection chain outright (gone from the | |
| # dependency graph, not just ignored). What remains, all named | |
| # individually in `.cargo/audit.toml`: RUSTSEC-2023-0071 (rsa) — a | |
| # Cargo.lock entry unreachable in the actual build graph (`cargo tree | |
| # -i rsa` prints nothing) regardless of sqlx's major version; four | |
| # `unmaintained`/`unsound` findings on crates none of which is a | |
| # direct workspace dependency (paste/proc-macro-error/rustls-pemfile/ | |
| # lru, all 2+ hops transitive). A new, untriaged advisory still fails | |
| # this step. | |
| run: | | |
| cargo +stable install cargo-audit --locked --quiet 2>/dev/null || true | |
| cargo audit | |
| - name: cargo deny — license + advisory + bans | |
| # HARD GATE since Sprint S4 Phase 3. `.konjo/deny.toml` re-verified | |
| # in Sprint S10, Phase 2 (2026-07-27) after the sqlx upgrade changed | |
| # the dependency graph's license set — `webpki-roots`'s newer version | |
| # dropped its MPL-2.0 exception in favor of CDLA-Permissive-2.0 | |
| # (updated in `[licenses] allow`), and three now-unused license | |
| # allowances (`CC0-1.0`/`OpenSSL`/`Unicode-DFS-2016`) were removed | |
| # rather than left as dead config cargo-deny would otherwise flag | |
| # `unmatched`. `cargo deny check` returns a clean verdict: advisories | |
| # ok, bans ok, licenses ok, sources ok. | |
| run: | | |
| cargo +stable install cargo-deny --locked --quiet 2>/dev/null || true | |
| cargo deny --config .konjo/deny.toml check | |
| - name: dead code — zero tolerance | |
| run: | | |
| OUTPUT=$(RUSTFLAGS="-W dead_code" cargo check --workspace 2>&1) | |
| COUNT=$(echo "$OUTPUT" | grep -c "warning: dead_code" || true) | |
| echo "Dead code warnings: $COUNT" | |
| if [ "$COUNT" -gt 0 ]; then | |
| echo "$OUTPUT" | grep "warning: dead_code" | head -20 | |
| echo "::error::$COUNT dead code warning(s). Remove all dead code before merging." | |
| exit 1 | |
| fi | |
| - name: Scope assertion — no multi-tenant surface (Sprint S12, Phase 6) | |
| # HARD GATE. Sprint S12 removed lopi's multi-tenant surface (lopi-app, | |
| # open_for_customer, github_installations, CustomerTier/Stripe) as a | |
| # decision, not a fact the code enforces on its own — nothing stops a | |
| # future PR from reintroducing it one file at a time. This mechanizes | |
| # the decision so it can't drift back by omission. See | |
| # .konjo/scripts/scope_assert.py's own docstring for the exact term | |
| # list and why "installation" (the bare English word) is deliberately | |
| # narrowed to the specific removed identifiers instead. | |
| # | |
| # Gate-Tiering-1, A4: diff-scoped to the PR's own changed files via | |
| # --base-ref on a pull_request run — a BLOCKING gate must not fail a | |
| # PR over a pre-existing term elsewhere in the tree that PR never | |
| # touched. Push-to-main runs (no base ref) keep the full-tree scan. | |
| run: | | |
| if [ "${{ github.event_name }}" = "pull_request" ]; then | |
| python3 .konjo/scripts/scope_assert.py --base-ref "origin/${{ github.base_ref }}" | |
| else | |
| python3 .konjo/scripts/scope_assert.py | |
| fi | |
| - name: Reachability check (advisory) — pub mod not reachable from the binary | |
| # ADVISORY BY DESIGN — Sprint F0. `dead_code` above certifies rustc | |
| # reachability (a `pub` item in a library crate is never dead by | |
| # rustc's definition, no matter whether the shipped binary calls | |
| # it) — a different property from "reachable when you actually run | |
| # `lopi`". Sprint F0 found `lopi-remote::whatsapp` passing the | |
| # dead-code gate while being completely unreferenced anywhere else | |
| # in the workspace (see CHANGELOG.md, docs/security/TRIFECTA_PATHS.md). | |
| # This is a grep-shaped heuristic (see the script's own docstring | |
| # for its exact definition and known false-positive/negative | |
| # shapes), not a real call-graph analyzer — it must never fail the | |
| # build on its own. It exists purely so the next feature that | |
| # quietly becomes unreachable shows up here before it also lingers | |
| # in the README. | |
| continue-on-error: true | |
| run: python3 .konjo/scripts/reachability_check.py | |
| - name: Soft-gate convention lint | |
| # HARD GATE — Sprint S4 Phase 2. Makes the `KNOWN DEBT, verified | |
| # <date>` / `ADVISORY BY DESIGN` convention this file already | |
| # follows by hand mechanical: any future `continue-on-error: true` | |
| # step added without one of those two markers fails here, instead | |
| # of quietly joining the pile of undocumented soft gates the | |
| # convention exists to prevent. | |
| run: python3 .konjo/scripts/soft_gate_lint.py .github/workflows/*.yml | |
| # ═══════════════════════════════════════════════════════════════════════════════════ | |
| # Gate 1b — Static Analysis, web/ (Forge dashboard — SvelteKit, separate | |
| # npm ecosystem from the Rust `static` job above, so it gets its own job | |
| # rather than a step wedged into a Rust runner) | |
| # ═══════════════════════════════════════════════════════════════════════════════════ | |
| # Added Sprint S11, Phase 2 (2026-07-27): `web/` had zero `npm audit` CI | |
| # gate at all before this — the Rust half of the repo has had `cargo | |
| # audit`/`cargo deny` as hard gates since Sprint S4 Phase 3 (see `static` | |
| # above), but a real `npm audit` on `web/` found 13 advisories (4 high, 7 | |
| # moderate, 2 low) with no CI step ever catching drift. This job mirrors | |
| # `static`'s `cargo audit` step exactly: `npm ci` for a reproducible, | |
| # lockfile-exact install (the `npm install` equivalent of `cargo audit` | |
| # reading `Cargo.lock`), then a hard-fail audit at `--audit-level=high`. | |
| # | |
| # Phase 2 also upgraded every flagged runtime-shipped package to a patched | |
| # version — `svelte` 4.2.0 -> 5.56.8 (the six SSR/XSS advisories, e.g. | |
| # GHSA-rcqx-6q8c-2c42, GHSA-pr6f-5x2q-rwfp, all fixed only from 5.56.8; | |
| # see `.konjo/killtests/S11/KT-S11.2.md` for the companion finding that no | |
| # live sink in this app's component tree actually exercised those | |
| # vulnerable capabilities — upgraded anyway as hygiene, not because an | |
| # exploit path was closed), `@sveltejs/kit` 2.59.1 -> 2.70.1, `devalue` | |
| # 5.8.0 -> 5.8.2, `@sveltejs/vite-plugin-svelte` 3.x -> 5.1.1 + `vite` 5.x | |
| # -> 6.4.3 (paired major bumps — the moderate vite-plugin-svelte/vitefu | |
| # chain and the high/moderate esbuild+vite path-traversal advisories both | |
| # required leaving the vite 5 line), `svelte-check` 3.x -> 4.7.4 (clears a | |
| # transitive `brace-expansion`/`glob`/`minimatch`/`rimraf`/`sander`/ | |
| # `sorcery`/`svelte-preprocess` chain, all high), and `cookie` pinned to | |
| # `^0.7.0` via `package.json`'s `overrides` (GHSA-pxg6-pf52-xh8x — | |
| # `@sveltejs/kit@2.70.1`, the latest stable line, still declares | |
| # `cookie: ^0.6.0` itself; only the unstable `3.0.0-next` prerelease line | |
| # bumps that to `cookie ^2.0.0`, so an override was the only way to get | |
| # the patched `cookie` without adopting an unstable SvelteKit). Net | |
| # result, re-verified with a clean `rm -rf node_modules && npm ci && npm | |
| # audit`: 0 vulnerabilities — this gate does not need an ignore mechanism | |
| # (npm audit has no scoped per-advisory allow-list the way | |
| # `.cargo/audit.toml` does for cargo-audit; none was needed here since | |
| # the audit is fully clean, not because one was swallowed). | |
| web-audit: | |
| name: "G1b · npm audit (web/)" | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: "22" | |
| cache: "npm" | |
| cache-dependency-path: web/package-lock.json | |
| - name: npm ci — reproducible, lockfile-exact install | |
| working-directory: web | |
| run: npm ci | |
| - name: npm audit — zero high/critical advisories | |
| # HARD GATE since Sprint S11, Phase 2. `--audit-level=high` matches | |
| # this repo's Rust-side bar (`cargo audit` fails on ANY known | |
| # advisory reachable in the build graph); `high` rather than `low` | |
| # here because npm audit's low/moderate tier includes a much wider | |
| # long tail of dev-toolchain-only findings (see the KT-S11.2 | |
| # comment above) that would make this gate noisy without adding | |
| # real signal — runtime-shipped packages are held to the same | |
| # standard as `cargo audit` via the upgrade work itself (0 | |
| # vulnerabilities at any severity as of Phase 2), not by loosening | |
| # what this gate checks. | |
| working-directory: web | |
| run: npm audit --audit-level=high | |
| # ═══════════════════════════════════════════════════════════════════════════════════ | |
| # Gate 2 — Tests + Coverage (≥ 80% line coverage) | |
| # ═══════════════════════════════════════════════════════════════════════════════════ | |
| coverage: | |
| name: "G2 · Tests + Coverage" | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| components: llvm-tools-preview | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: konjo-${{ github.repository }} | |
| - name: Install cargo-nextest + cargo-llvm-cov | |
| run: | | |
| cargo +stable install cargo-nextest --locked --quiet 2>/dev/null || true | |
| cargo +stable install cargo-llvm-cov --locked --quiet 2>/dev/null || true | |
| - name: Eval-executor regression suite (A1 — HARD GATE, 24 fixtures) | |
| # The Research-1 reliability probe's 24 labelled fixtures (real | |
| # pass/fail + the 7 gaming patterns) are a committed regression suite | |
| # for the tiered eval executor. Every eval change must keep scoring all | |
| # 24 correctly, decide objective failures without a judge call, and | |
| # catch every gaming pattern. Deliberately NOT continue-on-error: a | |
| # gate that could pass when it errors is the one thing an evaluator | |
| # can't do, so this blocks the merge, unlike the soft coverage step. | |
| run: cargo test -p lopi-agent --test eval_regression --locked | |
| - name: Run tests with coverage | |
| # HARD GATE — Gate-Tiering-1. Previously carried `--fail-under-lines | |
| # 80` and `continue-on-error: true` together (backend-1 Phase 0), | |
| # which meant a genuine compile/test failure here was ALSO swallowed | |
| # by the same softness that was only meant to cover the coverage | |
| # percentage being below floor — "the tests pass" is not negotiable | |
| # (A3, this sprint) and must block on its own exit code. The 80%/95% | |
| # threshold check is `--fail-under-lines`'s job; it now lives only in | |
| # the "Coverage gate" step below, which stays soft, so a low | |
| # percentage can't masquerade as a build/test break here. | |
| run: | | |
| cargo llvm-cov nextest \ | |
| --workspace \ | |
| --all-features \ | |
| --lcov \ | |
| --output-path lcov.info | |
| - name: Upload coverage to Codecov | |
| if: always() | |
| uses: codecov/codecov-action@v4 | |
| with: | |
| files: lcov.info | |
| fail_ci_if_error: false # don't fail CI on Codecov outage | |
| - name: Coverage gate (80% floor, 95% target) | |
| # KNOWN DEBT, verified 2026-08-11 (Gate-Tiering-1, re-verifying | |
| # backend-1 Phase 0's original finding): real workspace line | |
| # coverage, measured correctly (see the LF/LH fix below), is 68.34% | |
| # (23355 lines found, 15960 hit) — below the 80% floor, but not by a | |
| # catastrophic margin. Not yet triaged into which crates need tests. | |
| # Next step: identify the lowest-coverage crates from lcov.info, add | |
| # tests until >=80%, then promote this step back to BLOCKING once | |
| # kiban's B2 promotion criteria (passing kill-test + a measured | |
| # false-positive rate below its stated ceiling) are met for it — | |
| # until then it stays ADVISORY tier per LEDGER.md's Gate-Tiering-1 | |
| # entry. | |
| # | |
| # Also fixed independent of the soft/hard question: `cargo llvm-cov | |
| # report --json` does NOT support `--workspace` (it errors outright | |
| # if you pass it) and silently defaults to the root binary crate's | |
| # own package scope — 24 files, ~26%, nowhere near the real number. | |
| # The previous version of this step was reading that undercounted | |
| # report and would have graded coverage on ~1/7th of the workspace | |
| # even after being flipped to hard-fail. Parsing the | |
| # `--workspace`-scoped lcov.info the prior step already wrote | |
| # (summing LF/LH directly) is the correct source of truth. | |
| continue-on-error: true | |
| run: | | |
| COVERAGE=$(python3 -c " | |
| lf = lh = 0 | |
| try: | |
| with open('lcov.info') as f: | |
| for line in f: | |
| if line.startswith('LF:'): | |
| lf += int(line.strip().split(':')[1]) | |
| elif line.startswith('LH:'): | |
| lh += int(line.strip().split(':')[1]) | |
| print(f'{100*lh/lf:.2f}' if lf else '0.0') | |
| except Exception: | |
| print('0.0') # conservative: fail if we can't parse | |
| ") | |
| echo "Line coverage: ${COVERAGE}%" | |
| python3 -c " | |
| cov = float('${COVERAGE}') | |
| if cov < 80: | |
| print(f'::error::Coverage {cov:.1f}% < 80% gate. Add tests before merging.') | |
| exit(1) | |
| elif cov < 95: | |
| print(f'::warning::Coverage {cov:.1f}% < 95% target. Aim higher.') | |
| " | |
| - name: Coverage floor gate (never regress below the locked value) | |
| # KNOWN DEBT, verified 2026-08-11 (Gate-Tiering-1): was a HARD GATE | |
| # under Konjo Forward Pillar 2 ("main is truth, gates only move up") | |
| # — that principle is unchanged, but A3 of this sprint's brief | |
| # explicitly scopes `coverage`'s BLOCKING authority down to "the | |
| # tests pass" only, so this percentage-based ratchet moves to | |
| # ADVISORY tier alongside the 80%/95% gate above, not because the | |
| # ratchet itself is wrong. Next step: once the 80%/95% gate above is | |
| # promoted back to BLOCKING (kiban B2 criteria: passing kill-test + | |
| # measured false-positive rate under the stated ceiling), promote | |
| # this floor-ratchet alongside it — a floor is only meaningful once | |
| # the thing it floors is enforced again. | |
| # | |
| # Reuses the same LF:/LH: summation as the soft gate above — see that | |
| # step's comment for why `cargo llvm-cov report --json` cannot be used | |
| # instead (it silently under-scopes to ~1/7th of the workspace). | |
| # Ratcheting up is allowed and expected: a PR that raises coverage may | |
| # bump .konjo/coverage-floor.txt to the new value in the same PR. | |
| continue-on-error: true | |
| run: | | |
| python3 .konjo/scripts/coverage_floor_check.py \ | |
| --lcov lcov.info \ | |
| --floor-file .konjo/coverage-floor.txt | |
| - name: Upload coverage artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: coverage-lcov | |
| path: lcov.info | |
| # ═══════════════════════════════════════════════════════════════════════════════════ | |
| # Gate 3 — Mutation Testing (≤ 10% survival on changed files) | |
| # Runs only on pull requests (too slow for push to main) | |
| # ═══════════════════════════════════════════════════════════════════════════════════ | |
| mutation: | |
| name: "G3 · Mutation Testing" | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'pull_request' | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: konjo-${{ github.repository }} | |
| - name: Install cargo-mutants | |
| run: cargo +stable install cargo-mutants --locked --quiet 2>/dev/null || true | |
| - name: Identify changed Rust files | |
| id: changed | |
| run: | | |
| CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD \ | |
| | grep '\.rs$' | grep -v 'test' | head -30 | tr '\n' ' ' | xargs || true) | |
| # Use heredoc to safely write multi-line-safe value to GITHUB_OUTPUT | |
| echo "files=$CHANGED" >> "$GITHUB_OUTPUT" | |
| echo "Changed Rust files to mutate:" | |
| echo "$CHANGED" | |
| - name: Run mutation testing on changed files | |
| if: steps.changed.outputs.files != '' | |
| run: | | |
| cargo mutants \ | |
| --in-diff <(git diff origin/${{ github.base_ref }}...HEAD) \ | |
| --timeout 60 \ | |
| --jobs 2 \ | |
| 2>&1 | tee mutation_report.txt | |
| - name: Evaluate mutation survival rate | |
| if: steps.changed.outputs.files != '' | |
| run: | | |
| # `grep -c` counts matching *lines*, not occurrences — cargo-mutants' | |
| # own one-line summary ("N mutants tested in Xm: A missed, B caught, | |
| # C unviable") always contains both the literal words "missed" and | |
| # "caught" regardless of A/B, so the old `grep -c "caught"`/ | |
| # `grep -c "missed"` always matched exactly 1 line each, pinning | |
| # CAUGHT=1 MISSED=1 (50% survival) on every run with any output at | |
| # all. Parse the actual counts out of that summary line instead. | |
| SUMMARY=$(grep -E "mutants tested in" mutation_report.txt | tail -1) | |
| MISSED=$(echo "$SUMMARY" | grep -oE '[0-9]+ missed' | sed -E 's/ missed//') | |
| CAUGHT=$(echo "$SUMMARY" | grep -oE '[0-9]+ caught' | sed -E 's/ caught//') | |
| MISSED=${MISSED:-0} | |
| CAUGHT=${CAUGHT:-0} | |
| TOTAL=$((CAUGHT + MISSED)) | |
| echo "Mutations: caught=$CAUGHT missed=$MISSED total=$TOTAL" | |
| if [ "$TOTAL" -gt 0 ]; then | |
| SURVIVAL=$(echo "scale=1; $MISSED * 100 / $TOTAL" | bc) | |
| echo "Mutation survival rate: ${SURVIVAL}%" | |
| python3 -c " | |
| rate = float('${SURVIVAL}') | |
| if rate > 10: | |
| print(f'::error::Mutation survival {rate:.1f}% > 10% gate.') | |
| print('Tests are insufficient. Surviving mutants indicate untested paths.') | |
| exit(1) | |
| elif rate > 0: | |
| print(f'::warning::Mutation survival {rate:.1f}% — aim for 0%') | |
| " | |
| fi | |
| - name: Upload mutation report | |
| if: always() && steps.changed.outputs.files != '' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: mutation-report | |
| path: mutation_report.txt | |
| # ═══════════════════════════════════════════════════════════════════════════════════ | |
| # Gate 4 — Complexity + Size + DRY | |
| # ═══════════════════════════════════════════════════════════════════════════════════ | |
| complexity: | |
| name: "G4 · Complexity + Size + DRY" | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 # Gate-Tiering-1, A4: dry_check.py/function_length_check.py/indexing_floor_check.py's --base-ref need the real merge-base (also fixes the pre-existing file-size gate's own base-ref diff below, which needed this all along) | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: konjo-${{ github.repository }} | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| - name: Cognitive complexity gate (clippy) | |
| run: | | |
| # Any function with cognitive complexity > 15 fails the gate | |
| VIOLATIONS=$(cargo clippy --workspace --all-targets -- \ | |
| -A clippy::all \ | |
| -W clippy::cognitive_complexity \ | |
| --message-format json 2>/dev/null \ | |
| | python3 -c " | |
| import json, sys | |
| count = 0 | |
| for line in sys.stdin: | |
| try: | |
| msg = json.loads(line) | |
| if msg.get('reason') == 'compiler-message': | |
| m = msg['message'] | |
| if 'cognitive_complexity' in str(m.get('code', {})): | |
| print(m['rendered']) | |
| count += 1 | |
| except: | |
| pass | |
| sys.exit(0 if count == 0 else 1) | |
| " || true) | |
| if [ -n "$VIOLATIONS" ]; then | |
| echo "$VIOLATIONS" | |
| echo "::error::Functions with cognitive complexity > 15 found. Refactor them." | |
| exit 1 | |
| fi | |
| echo "Cognitive complexity: all functions ≤ 15 ✓" | |
| - name: File size gate (changed files on PR, all files on push) — scoped to *.rs and *.py only | |
| # Scope, stated explicitly per Sprint F0 (konjoai/lopi): this gate has | |
| # only ever scanned Rust and Python (`\.(rs|py)$`). It does not cover | |
| # `web/` (TypeScript/Svelte) or `macos/` (Swift) — those ecosystems | |
| # have their own size conventions and were never audited against a | |
| # 500-line limit. `web/src/lib/stores/stack.ts` is ~2,200 lines and | |
| # is *not* a gate violation under the current scope; it is not | |
| # silently passing a check that was meant to catch it. Extending | |
| # this gate to web/macos, and the stack.ts split that would require | |
| # first, is tracked as follow-up work, not done here (Sprint F0 is | |
| # measurement/documentation only — see NEXT_SESSION_PROMPT.md). | |
| run: | | |
| if [ "${{ github.event_name }}" = "pull_request" ]; then | |
| SCAN_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD \ | |
| | grep -E '\.(rs|py)$' | grep -v target || true) | |
| else | |
| SCAN_FILES=$(find . \( -name "*.rs" -o -name "*.py" \) | grep -v target) | |
| fi | |
| OVERSIZED="" | |
| for f in $SCAN_FILES; do | |
| [ -f "$f" ] || continue | |
| LC=$(wc -l < "$f") | |
| if [ "$LC" -gt 500 ]; then | |
| OVERSIZED="${OVERSIZED}${f}: ${LC} lines\n" | |
| fi | |
| done | |
| if [ -n "$OVERSIZED" ]; then | |
| printf "Files exceeding 500-line limit:\n%b" "$OVERSIZED" | |
| echo "::error::File(s) exceeding 500-line limit. Split into smaller modules." | |
| exit 1 | |
| fi | |
| echo "File sizes: all within 500-line limit ✓" | |
| - name: DRY check | |
| # ADVISORY tier as of Gate-Tiering-1 (this job, G4, is entirely | |
| # advisory — see LEDGER.md and konjo-gate.yml's aggregator comment). | |
| # Diff-scoped on PR runs (A4): `--changed-only --base-ref` instead of | |
| # a full-tree scan, so the report reflects what the PR touched. | |
| run: | | |
| if [ "${{ github.event_name }}" = "pull_request" ]; then | |
| python3 .konjo/scripts/dry_check.py \ | |
| --threshold 0.85 \ | |
| --min-lines 20 \ | |
| --changed-only --base-ref "origin/${{ github.base_ref }}" \ | |
| --report dry_report.json 2>&1 | |
| else | |
| python3 .konjo/scripts/dry_check.py \ | |
| --threshold 0.85 \ | |
| --min-lines 20 \ | |
| --report dry_report.json 2>&1 | |
| fi | |
| COUNT=$(python3 -c "import json; d=json.load(open('dry_report.json')); print(d['count'])") | |
| if [ "$COUNT" -gt 0 ]; then | |
| echo "::error::$COUNT DRY violation(s). Abstract duplicate code into shared functions." | |
| exit 1 | |
| fi | |
| - name: Function length gate (<= 50 lines, ratchet against the locked ceiling) | |
| # ADVISORY tier as of Gate-Tiering-1 (this job, G4, is entirely | |
| # advisory). Sprint S13R, Phase B, decision item 3's original | |
| # reasoning is unchanged below; only the tier changed. CLAUDE.md's | |
| # "function body <= 50 lines" claim had zero mechanical enforcement | |
| # before this (only a WARNING-tier Wall-3 LLM review question that | |
| # cannot block a merge — see `PHASE0-STOP-RULE.md`). Real measured | |
| # count against this branch: 74 functions over 50 lines, | |
| # workspace-wide. This ratchets the same way the coverage floor does: | |
| # `.konjo/function-length-ceiling.txt` locks the current count, and | |
| # only a rise above it fails. Lower the ceiling file as functions get | |
| # split; never raise it without a recorded reason. | |
| # | |
| # A4: diff-scoped to the PR's own changed files via --base-ref on a | |
| # pull_request run — the ceiling file still holds the full-tree | |
| # ratchet value that `main` continues to update on push, so a | |
| # diff-scoped PR count compared against it is a lighter-weight | |
| # PR-time signal, not the authoritative ratchet check. | |
| run: | | |
| if [ "${{ github.event_name }}" = "pull_request" ]; then | |
| python3 .konjo/scripts/function_length_check.py \ | |
| --ceiling-file .konjo/function-length-ceiling.txt \ | |
| --base-ref "origin/${{ github.base_ref }}" | |
| else | |
| python3 .konjo/scripts/function_length_check.py \ | |
| --ceiling-file .konjo/function-length-ceiling.txt | |
| fi | |
| - name: Indexing floor gate (raw `[0]`/`[1]` sites, ratchet against the locked ceiling) | |
| # ADVISORY tier as of Gate-Tiering-1 (this job, G4, is entirely | |
| # advisory). Sprint S13R, Phase D's original reasoning is unchanged | |
| # below; only the tier changed. A raw index panics on an | |
| # out-of-bounds slice; this ratchets the standing count the same way | |
| # the coverage floor and function-length ceiling do. | |
| # `.konjo/indexing-floor.txt` locks the current count (211, | |
| # re-measured this sprint with a precisely stated method — see that | |
| # file's own comment for why it doesn't match the S13 brief's | |
| # earlier, less precisely specified "202"). Ratchet down as sites | |
| # convert to `.first()`/`.get(n)`. | |
| # | |
| # A4: diff-scoped to the PR's own changed files via --base-ref on a | |
| # pull_request run — see the function-length gate's comment above | |
| # for the same tradeoff against the full-tree-calibrated ceiling. | |
| run: | | |
| if [ "${{ github.event_name }}" = "pull_request" ]; then | |
| python3 .konjo/scripts/indexing_floor_check.py \ | |
| --ceiling-file .konjo/indexing-floor.txt \ | |
| --base-ref "origin/${{ github.base_ref }}" | |
| else | |
| python3 .konjo/scripts/indexing_floor_check.py \ | |
| --ceiling-file .konjo/indexing-floor.txt | |
| fi | |
| - name: Documentation gate (rustdoc) | |
| # KNOWN DEBT, verified 2026-07-29 (Sprint S13R, Phase B — decision item 2): | |
| # re-measured rather than assumed. The debt grew past what backend-1 Phase 0 | |
| # named: `lopi-agent` now has 11 unresolved links (StreamEvent x4, parse_line, | |
| # Self::from_str, is_stale, staleness_warning, describe x2, TranscriptEvent::Other), | |
| # `lopi-orchestrator` has 8 (reserve, estimate, ladder, detect, report, | |
| # is_favorable, types, JobScheduler, TopologyHint), and a new one appeared in | |
| # `lopi-mcp` (crate::config::LoopConfig in allowlist.rs) that Phase 0 never | |
| # named at all — the same systemic pattern (bare `[Type]`/`[func]` links inside | |
| # doc comments not resolving), just wider than last measured. Decision (Phase B): | |
| # this is real, scattered, multi-crate work, not a small fix-now item, so it | |
| # stays soft rather than a rushed hard-flip that would just move the debt to a | |
| # bypass. Owner: whichever sprint next touches `lopi-agent`/`lopi-orchestrator` | |
| # docs; target: before Sprint S14 closes, re-measure with the exact command | |
| # below and either clear it or re-file with a new date, so this marker can't | |
| # silently go stale itself. Hard-failing now would abort the doc build on | |
| # links, not missing docs, before missing_docs coverage is even checked | |
| # workspace-wide. Next step: grep for `//!`/`///` comments with bare | |
| # `[Type]`/`[func]` links in the three crates named above, qualify each with | |
| # its full path per lopi-core's already-fixed precedent, confirm | |
| # -D missing_docs also passes clean workspace-wide, then remove this line. | |
| continue-on-error: true | |
| run: | | |
| RUSTDOCFLAGS="-D missing_docs -D rustdoc::broken_intra_doc_links" \ | |
| cargo doc --workspace --no-deps 2>&1 | tee rustdoc_output.txt | |
| if grep -q "error" rustdoc_output.txt; then | |
| echo "::error::Missing documentation on public APIs. Add /// doc comments." | |
| exit 1 | |
| fi | |
| # ═══════════════════════════════════════════════════════════════════════════════════ | |
| # Gate 4b — Fuzz (Sprint S12, Phase 2) — short budget, PR-only | |
| # ═══════════════════════════════════════════════════════════════════════════════════ | |
| # Three targets over the parsers that eat attacker-influenceable input: | |
| # MCP server responses (jsonrpc.rs), the `claude` CLI's own stream-json | |
| # output (claude_events.rs), and GitHub webhook bodies pre-HMAC (github.rs). | |
| # 60s/target on PR, deliberately short — the sprint brief's own framing: | |
| # "A fuzz gate that adds ten minutes to every PR gets disabled, which is | |
| # the S10 anti-goal in a different costume." A longer scheduled run | |
| # (cron, not wired here) is the place for deeper campaigns. | |
| # | |
| fuzz: | |
| name: "G4b · Fuzz (60s × 3 targets)" | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'pull_request' | |
| # KNOWN DEBT, verified 2026-07-27: these harnesses (fuzz/, a separate | |
| # cargo-fuzz workspace) were authored and reasoned through against the | |
| # real parser APIs, but could not be compiled or run in the environment | |
| # that wrote them (no nightly toolchain, no cargo-fuzz, no crates.io | |
| # network access — see the commit that added this job). Next step: once | |
| # a real CI run confirms all three targets compile and run clean, remove | |
| # this continue-on-error and add `fuzz` to konjo-gate's `needs:` so a | |
| # genuine crash actually blocks merge instead of being logged only. | |
| # | |
| # Sprint S13R, Phase C added a repo-root `rust-toolchain.toml` pinning | |
| # 1.88.0 for the MSRV gate — rustup resolves that file for ANY cargo | |
| # invocation anywhere under this checkout, which silently overrode | |
| # `dtolnay/rust-toolchain@nightly`'s default and broke `cargo install | |
| # cargo-fuzz` here (`cargo-platform@0.3.3 requires rustc 1.91`, confirmed | |
| # via a real CI failure on PR #184 the day this job first exercised the | |
| # new pin). Every cargo invocation below now pins `+nightly` explicitly — | |
| # a `+toolchain` CLI override outranks a `rust-toolchain.toml` file in | |
| # rustup's resolution order, so this job keeps using nightly regardless | |
| # of the repo-root MSRV pin. | |
| continue-on-error: true | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: dtolnay/rust-toolchain@nightly | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: konjo-fuzz-${{ github.repository }} | |
| workspaces: fuzz -> target | |
| - name: Install cargo-fuzz | |
| run: cargo +nightly install cargo-fuzz --locked | |
| - name: Fuzz jsonrpc_response_fuzz (MCP server responses) | |
| run: cargo +nightly fuzz run jsonrpc_response_fuzz -- -max_total_time=60 | |
| working-directory: fuzz | |
| - name: Fuzz claude_events_fuzz (claude CLI stream-json) | |
| run: cargo +nightly fuzz run claude_events_fuzz -- -max_total_time=60 | |
| working-directory: fuzz | |
| - name: Fuzz github_webhook_fuzz (GitHub webhook bodies, pre-HMAC) | |
| run: cargo +nightly fuzz run github_webhook_fuzz -- -max_total_time=60 | |
| working-directory: fuzz | |
| - name: Upload crash artifacts | |
| if: failure() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: fuzz-crashes | |
| path: fuzz/artifacts/ | |
| if-no-files-found: ignore | |
| # ═══════════════════════════════════════════════════════════════════════════════════ | |
| # Gate K — konjo-gates (Sprint S13R, Phase A3 — "connect the pilot") | |
| # ═══════════════════════════════════════════════════════════════════════════════════ | |
| # Runs kiban's own CI-plane orchestrator (`bin/konjo-gates`) against | |
| # `.konjo/profile.yml` (authored in kiban as `profiles/lopi.yml`, Phase 13 — | |
| # see LEDGER.md's `Lopi-Gate-Reconciliation-1`). Deliberately additive, not a | |
| # replacement for G0-G5 above: per that reconciliation, most of lopi's real | |
| # checks stay repo-native (kiban has no detector for coverage-floor parsing, | |
| # cognitive-complexity-from-clippy-JSON, the DRY scanner, or the soft-gate | |
| # lint) — this job runs kiban-native gates lopi never had at all (G-POLARITY, | |
| # the CLAUDE.md section contract, the security-glob threat-model gate, the | |
| # one-way-door/prove trailer checks) plus one genuinely promoted repo-native | |
| # check (`cargo-audit`, now dispatched through kiban's generic tool runner | |
| # too — redundant with G1's own `cargo audit` step by design, the same | |
| # "double-check" precedent `profiles/squish.yml`/`profiles/vectro.yml` set). | |
| # `--no-self-test`: the self-test/eval-corpus gate needs a cassette recorded | |
| # against a live model for this profile's specialist set, which no session | |
| # has done yet for lopi (recorded in NEXT_SESSION_PROMPT.md as carried work). | |
| # G-POLARITY and the CLAUDE.md contract both ship `advisory: true` in the | |
| # profile (WARN, not FAIL) — the standard adoption ramp for a repo with an | |
| # existing baseline (see the standing count recorded in LEDGER.md) — so this | |
| # job is a HARD gate on its other checks (secrets, one-way-door, threat | |
| # model, can-fail, the promoted cargo-audit dispatch) without retroactively | |
| # blocking merges on the pre-existing polarity/contract baseline. | |
| konjo-gates: | |
| name: "GK · konjo-gates (kiban profile)" | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 # one_way_door/prove/threat_model read commit trailers from git log | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| components: rustfmt, clippy | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: konjo-${{ github.repository }} | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| - name: Install kiban at the pinned ref | |
| env: | |
| # Matches .konjo/kiban.ref and G0's KIBAN_REF — bump all three together. | |
| KIBAN_REF: "v1.19.0" | |
| run: | | |
| pip install --quiet pyyaml | |
| # .konjo/profile.yml's contract_gates names cargo-deny, a genuine | |
| # _TOOL_SCOPE entry in konjo_gates_py's dispatcher (unlike e.g. | |
| # dead-code/complexity, which are documentation-only names konjo-gates | |
| # can't run), so it really does invoke it, not just document that lopi's | |
| # own CI checks it too. Install alongside cargo-audit so it actually runs | |
| # instead of ERROR-ing as "not installed" (confirmed live: PR #184's first | |
| # run on this job). cargo-mutants is deliberately NOT installed here: the | |
| # profile's `mutation:` field is set to a "none"-prefixed value precisely | |
| # so it is *not* dispatched through this generic path — `newonly`'s | |
| # line-diff net-new mechanism can't scope cargo-mutants' timing-carrying | |
| # log output the way it can a linter's stable per-finding lines (also | |
| # confirmed live, same PR: every run "found" net-new lines regardless of | |
| # content). G3 above is the real, survival-rate-based mutation gate. | |
| cargo +stable install cargo-audit --locked --quiet 2>/dev/null || true | |
| cargo +stable install cargo-deny --locked --quiet 2>/dev/null || true | |
| git clone --quiet --depth 1 --branch "$KIBAN_REF" https://github.com/konjoai/kiban.git /tmp/kiban | |
| - name: Run konjo-gates against .konjo/profile.yml | |
| run: | | |
| PYTHONPATH=/tmp/kiban python3 /tmp/kiban/bin/konjo-gates \ | |
| --profile .konjo/profile.yml \ | |
| --base "origin/${{ github.base_ref || 'main' }}" \ | |
| --no-self-test | |
| # ═══════════════════════════════════════════════════════════════════════════════════ | |
| # Gate 5 — Adversarial Review (Wall 3, PRs only) | |
| # Uses Claude Opus — distinct from builder model (Sonnet) | |
| # ═══════════════════════════════════════════════════════════════════════════════════ | |
| review: | |
| name: "G5 · Adversarial Review (Wall 3)" | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'pull_request' | |
| needs: [static, coverage] # only run if basic gates pass | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| - name: Install anthropic SDK | |
| # ADVISORY BY DESIGN: legitimately safe to soft-fail (not a gate, a | |
| # prerequisite): if this fails, `_load_anthropic()` inside | |
| # konjo_review.py raises ImportError, which main() already catches | |
| # and treats as a soft WARNING (returns 0) rather than crashing — | |
| # see the review step's own comment below. There is no scenario | |
| # where this step failing silently produces a false "approved." | |
| continue-on-error: true | |
| run: pip install anthropic --quiet | |
| - name: Generate diff | |
| # ADVISORY BY DESIGN: legitimately safe to soft-fail: the next step | |
| # defensively checks for a missing/empty pr.diff and treats that as | |
| # "nothing to review," not a blocker — see below. Without that | |
| # guard this would have been a real gap (a git/fetch hiccup here | |
| # could otherwise crash the review script with an uncaught | |
| # exception, which Python exits as code 1 — indistinguishable from | |
| # a genuine BLOCKER). | |
| continue-on-error: true | |
| run: | | |
| git diff origin/${{ github.base_ref }}...HEAD > pr.diff | |
| echo "Diff size: $(wc -c < pr.diff) bytes" | |
| - name: Run Konjo Adversarial Review | |
| id: review | |
| # ADVISORY BY DESIGN: legitimately safe to soft-fail: this step's | |
| # own run: block never aborts (the `&& / ||` pattern below always | |
| # captures REVIEW_EXIT and the empty-diff guard means a missing | |
| # pr.diff can't reach the script at all), so continue-on-error here | |
| # is a defensive no-op, not a gate. The actual gate is REVIEW_EXIT's | |
| # value, hard-enforced by "Fail if BLOCKER verdict" below. | |
| continue-on-error: true | |
| env: | |
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | |
| run: | | |
| # A diff-generation hiccup (see the step above) must read as | |
| # "nothing to review," never as a script crash that Python would | |
| # exit 1 for — which "Fail if BLOCKER verdict" cannot distinguish | |
| # from a genuine blocker verdict. | |
| if [ ! -s pr.diff ]; then | |
| echo "REVIEW_EXIT=0" >> "$GITHUB_ENV" | |
| echo "[konjo-review] pr.diff missing or empty — skipping review, not treating as a blocker." >&2 | |
| else | |
| # --json writes JSON to stdout; --output writes the human report separately. | |
| # Run twice so we get both formats: once for the human report, once for JSON. | |
| python3 .konjo/scripts/konjo_review.py \ | |
| --diff-file pr.diff \ | |
| --output review_report.md \ | |
| 2>/dev/null \ | |
| && echo "REVIEW_EXIT=0" >> "$GITHUB_ENV" \ | |
| || echo "REVIEW_EXIT=$?" >> "$GITHUB_ENV" | |
| fi | |
| cat review_report.md 2>/dev/null || echo "(no review report generated — API key may be missing)" | |
| - name: Post review comment to PR | |
| if: always() | |
| continue-on-error: true # ADVISORY BY DESIGN: cosmetic UX only — a comment-post failure must never affect the verdict already captured in REVIEW_EXIT | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| let body = ''; | |
| try { | |
| body = fs.readFileSync('review_report.md', 'utf8'); | |
| } catch { | |
| body = '<!-- konjo-review: report file not found -->'; | |
| } | |
| const header = `<!-- konjo-adversarial-review --> | |
| > **Konjo Adversarial Review** — Wall 3 · Model: claude-opus-5 | |
| > Independent critic session. The builder wrote the code; this reviewer came in cold. | |
| `; | |
| await github.rest.issues.createComment({ | |
| issue_number: context.issue.number, | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| body: header + body | |
| }); | |
| - name: Fail if BLOCKER verdict | |
| # ADVISORY BY DESIGN — Gate-Tiering-1: demoted from a hard block | |
| # (backend-1 Phase 0's "non-negotiable" framing, kept below for | |
| # history). A single nondeterministic LLM verdict with no measured | |
| # false-positive rate must not hold unappealable authority over | |
| # merge; the verdict is still fully visible in the posted PR comment | |
| # above and in REVIEW_EXIT, and `review` is still named in the | |
| # aggregator's ADVISORY report (see gate_verdict.sh) whenever it | |
| # doesn't succeed. | |
| # | |
| # Original backend-1 Phase 0 reasoning, preserved: distinguishes | |
| # REVIEW_EXIT == 1 (the script ran to completion and found a real | |
| # blocker — see konjo_review.py's main(), which is the actual source | |
| # of truth, not its stale exit code docstring) from any other | |
| # nonzero (API/config/script failure), which the script already | |
| # treats as a soft "WARNING — could not reach API" and returns 0 | |
| # for. | |
| continue-on-error: true | |
| if: env.REVIEW_EXIT == '1' | |
| run: | | |
| echo "::error::Konjo Adversarial Review found BLOCKER(s). Fix them before merging." | |
| exit 1 | |
| # ════════════════════════════════════════════════════════════════════════════ | |
| # Final gate — merge requires all BLOCKING-tier jobs to pass | |
| # ════════════════════════════════════════════════════════════════════════════ | |
| # backend-1 Phase 0 fix: this used to omit `mutation` and `review` entirely, | |
| # so G3 (mutation testing) and G5 (Wall 3 adversarial review — including the | |
| # BLOCKER hard-fail in that job) could fail outright without blocking merge. | |
| # Both were added to `needs:` below; both are also legitimately `skipped` on | |
| # a push-to-main run (they're PR-only jobs), which must not itself count as | |
| # a failure. | |
| # | |
| # Gate-Tiering-1: that all-eight-must-succeed rule is exactly the problem | |
| # that sprint fixed. `needs:` is unchanged on purpose — every job below | |
| # still *runs* and is still visible in the checks UI — but the step's | |
| # verdict logic (now `.konjo/scripts/gate_verdict.sh`, extracted so it's | |
| # testable outside Actions — see `test_gate_tiering_killtest.sh`) now | |
| # reads two named tiers instead of one flat list: | |
| # | |
| # BLOCKING — deterministic, fast, catches real breakage: | |
| # static (fmt/clippy/audit/deny/dead-code/scope/soft-gate-lint), | |
| # coverage (test-failure only — see that job's own comments for why the | |
| # 80%-floor and floor-ratchet steps inside it are now soft) | |
| # | |
| # ADVISORY — reports, annotates, never blocks: | |
| # doc-staleness, web-audit, complexity, mutation, review, konjo-gates | |
| # | |
| # A `gate:override` PR label plus a `Konjo-Override: <reason>` trailer in | |
| # the PR body is the documented break-glass past a BLOCKING failure — see | |
| # gate_verdict.sh's own header. LEDGER.md's `Gate-Tiering-1` entry has the | |
| # full per-job tier rationale and the promotion criteria back to BLOCKING. | |
| konjo-gate: | |
| name: "Konjo Gate — All Walls Clear" | |
| runs-on: ubuntu-latest | |
| if: always() | |
| needs: [doc-staleness, static, web-audit, coverage, complexity, mutation, review, konjo-gates] | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Evaluate all required gates | |
| env: | |
| DOC_STALENESS: ${{ needs.doc-staleness.result }} | |
| STATIC: ${{ needs.static.result }} | |
| WEB_AUDIT: ${{ needs.web-audit.result }} | |
| COVERAGE: ${{ needs.coverage.result }} | |
| COMPLEXITY: ${{ needs.complexity.result }} | |
| MUTATION: ${{ needs.mutation.result }} | |
| REVIEW: ${{ needs.review.result }} | |
| KONJO_GATES: ${{ needs.konjo-gates.result }} | |
| GATE_OVERRIDE_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'gate:override') }} | |
| PR_BODY: ${{ github.event.pull_request.body }} | |
| ACTOR: ${{ github.actor }} | |
| run: bash .konjo/scripts/gate_verdict.sh | |
| # ═══════════════════════════════════════════════════════════════════════════════════ | |
| # Mutation Hunt (opt-in) — review-pipeline Sprint P2b, section 4's real call site | |
| # for `konjo/mutation-hunt`. Deliberately NOT in `konjo-gate`'s `needs:` above and | |
| # not triggered by pull_request/push — see the plan's own non-goals (no router, no | |
| # new default gate this sprint) and the workflow_dispatch trigger's comment. | |
| # ═══════════════════════════════════════════════════════════════════════════════════ | |
| mutation-hunt: | |
| name: "Mutation Hunt (opt-in, manual dispatch only)" | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'workflow_dispatch' | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| with: | |
| shared-key: konjo-${{ github.repository }} | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: "20" | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| - name: Clone kiban (pinned) | |
| env: | |
| # Matches .konjo/kiban.ref and G0/konjo-gates' own KIBAN_REF — bump all | |
| # three together (see this repo's CLAUDE.md "Pinning" section). The Sprint | |
| # P2b blocker noted below is now resolved: kiban v1.19.0 ships | |
| # bin/kiban-mutation-hunt (see kiban LEDGER.md's Review-Pipeline-Phase-2b | |
| # entry), so this job is live-runnable at this pin — still | |
| # workflow_dispatch-only, not part of the required konjo-gate summary. | |
| KIBAN_REF: "v1.19.0" | |
| run: git clone --quiet --depth 1 --branch "$KIBAN_REF" https://github.com/konjoai/kiban.git /tmp/kiban | |
| - name: Install cargo-llvm-cov, cargo-mutants, and the claude CLI | |
| run: | | |
| cargo install cargo-llvm-cov cargo-mutants --locked --quiet 2>/dev/null || true | |
| npm install -g @anthropic-ai/claude-code --silent | |
| - name: Build konjo-ast-diff | |
| run: cargo build --release --manifest-path /tmp/kiban/packages/konjo-ast-diff-rs/Cargo.toml | |
| - name: Generate coverage for the target crate | |
| run: cargo llvm-cov -p "${{ inputs.crate }}" --lcov --output-path lcov.info | |
| - name: Run the mutation-hunt loop | |
| env: | |
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | |
| run: | | |
| mkdir -p bench_results/mutation-hunt | |
| git fetch --quiet origin "${{ inputs.diff_base_ref }}" | |
| python3 /tmp/kiban/bin/kiban-mutation-hunt run \ | |
| --repo "$(pwd)" --base-ref "$(git rev-parse HEAD)" \ | |
| --diff-base-ref "origin/${{ inputs.diff_base_ref }}" --lcov lcov.info \ | |
| --crate "${{ inputs.crate }}" --path-prefix "crates/${{ inputs.crate }}/" \ | |
| --round-cap "${{ inputs.round_cap }}" \ | |
| --out bench_results/mutation-hunt | |
| - name: Upload result artifact | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: mutation-hunt-result | |
| path: bench_results/mutation-hunt/mutation_hunt_result.json | |
| if-no-files-found: warn |