From 54cbf05eac87aae28257c687367cc5dffd4eea75 Mon Sep 17 00:00:00 2001 From: Jonathan Jewell Date: Tue, 22 Sep 2026 18:48:20 +0100 Subject: [PATCH 1/8] fix(ci): resync actions.lock and add a lock-sync recurrence gate GitHub refuses a run at startup, creating zero jobs, when a workflow carries a `uses:` ref that the lockfile does not record under that workflow's own path. It matches by LITERAL STRING; `gh actions-lock` matches by resolved commit, so a lock entry naming a tag that dereferences to the pinned SHA passes the tool and still kills the run. Regenerate the lock, make it transitively closed, and add a lock-sync gate carrying no `uses:` of its own so it cannot be disabled by the desync it detects. No workflow YAML is modified. Refs: hyperpolymath/standards#968 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- .github/workflows/actions.lock | 130 ++++++++---- .github/workflows/lock-sync-gate.yml | 63 ++++++ scripts/check-lock-sync.sh | 307 +++++++++++++++++++++++++++ 3 files changed, 454 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/lock-sync-gate.yml create mode 100755 scripts/check-lock-sync.sh diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 77045bd6..f5a49960 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -15,17 +15,19 @@ workflows: - 'haskell-actions/setup@v2.12.0' '.github/workflows/codeql.yml': - 'actions/checkout@v7.0.1' - - 'github/codeql-action@v4.38.0' + - 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63' '.github/workflows/dogfood-gate.yml': - 'actions/checkout@v7.0.1' - 'hyperpolymath/deed-ecosystem@main' - 'hyperpolymath/k9-ecosystem@main' '.github/workflows/e2e.yml': - 'actions/checkout@v7.0.1' - '.github/workflows/governance.yml': [] + '.github/workflows/governance.yml': + - 'hyperpolymath/standards@da2c748aad55c1a1dcba00b60fe4a35017bc6540' '.github/workflows/hypatia-dispatch-intake.yml': - 'actions/checkout@v7.0.1' - '.github/workflows/hypatia-scan.yml': [] + '.github/workflows/hypatia-scan.yml': + - 'hyperpolymath/standards@da2c748aad55c1a1dcba00b60fe4a35017bc6540' '.github/workflows/inbox-steward.yml': - 'actions/checkout@v7.0.1' '.github/workflows/instant-sync.yml': @@ -35,7 +37,8 @@ workflows: '.github/workflows/learning-loop.yml': - 'actions/checkout@v7.0.1' - 'actions/upload-artifact@v7.0.1' - '.github/workflows/mirror.yml': [] + '.github/workflows/mirror.yml': + - 'hyperpolymath/standards@571cc734cd69fb846032ec77a662aa8ee4fc32cd' '.github/workflows/pages.yml': - 'actions/checkout@v7.0.1' - 'actions/deploy-pages@v5.0.1' @@ -44,7 +47,7 @@ workflows: - 'actions/checkout@v7.0.1' - 'actions/upload-artifact@v7.0.1' - 'dtolnay/rust-toolchain@v1' - - 'swatinem/rust-cache@v2.9.2' + - 'Swatinem/rust-cache@v2.9.2' '.github/workflows/push-email-notify.yml': - 'hyperpolymath/smtp-notify-action@v0.3.0' '.github/workflows/repo-integrity-guard.yml': @@ -52,19 +55,32 @@ workflows: '.github/workflows/rust.yml': - 'actions/checkout@v7.0.1' '.github/workflows/scorecard.yml': - - 'actions/checkout@v7.0.1' - - 'actions/upload-artifact@v7.0.1' - - 'github/codeql-action@v4.37.8' - - 'ossf/scorecard-action@v2.4.4' - '.github/workflows/secret-scanner.yml': [] + - 'hyperpolymath/standards@da2c748aad55c1a1dcba00b60fe4a35017bc6540' + '.github/workflows/secret-scanner.yml': + - 'hyperpolymath/standards@571cc734cd69fb846032ec77a662aa8ee4fc32cd' '.github/workflows/supervised-fleet-scan.yml': - 'actions/checkout@v7.0.1' dependencies: + 'Swatinem/rust-cache@v2.9.2': + ref: 'v2.9.2' + commit: 'sha1-6323deb102c322ba6fcbdcafc7e3dddab59af2b6' + owner_id: 580492 + repo_id: 298565987 + 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9': + ref: '55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + owner_id: 44036562 + repo_id: 215566462 'actions/cache@v6.1.0': ref: 'v6.1.0' commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9' owner_id: 44036562 repo_id: 215566462 + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': + ref: '3d3c42e5aac5ba805825da76410c181273ba90b1' + commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' + owner_id: 44036562 + repo_id: 197814629 'actions/checkout@v7.0.1': ref: 'v7.0.1' commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' @@ -85,6 +101,11 @@ dependencies: commit: 'sha1-3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' owner_id: 44036562 repo_id: 192626254 + 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a': + ref: '043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' + commit: 'sha1-043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' + owner_id: 44036562 + repo_id: 192625955 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f': ref: 'v7.0.0' commit: 'sha1-bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' @@ -102,11 +123,36 @@ dependencies: repo_id: 496012378 uses: - 'actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f' + 'dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772': + ref: '6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772' + commit: 'sha1-6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772' + owner_id: 1940490 + repo_id: 260749683 'dtolnay/rust-toolchain@v1': ref: 'v1' commit: 'sha1-02cb101ec7c40f2c49e1d9714d64511d8e1b74de' owner_id: 1940490 repo_id: 260749683 + 'editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393': + ref: '51f63319f592f97930c73d9c46184d20bd206393' + commit: 'sha1-51f63319f592f97930c73d9c46184d20bd206393' + owner_id: 26415196 + repo_id: 297874902 + 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124': + ref: '54075bcc5e249e4758d363f27d099f55d843f124' + commit: 'sha1-54075bcc5e249e4758d363f27d099f55d843f124' + owner_id: 47606891 + repo_id: 331103973 + 'github/codeql-action@b96794f015dfd88f77b49b1c93e0fa7110f94c63': + ref: 'b96794f015dfd88f77b49b1c93e0fa7110f94c63' + commit: 'sha1-b96794f015dfd88f77b49b1c93e0fa7110f94c63' + owner_id: 9919 + repo_id: 259445878 + 'github/codeql-action@cdf488f595d80d6e07e03d4674febd5ab45fa938': + ref: 'cdf488f595d80d6e07e03d4674febd5ab45fa938' + commit: 'sha1-cdf488f595d80d6e07e03d4674febd5ab45fa938' + owner_id: 9919 + repo_id: 259445878 'github/codeql-action@v4.37.8': ref: 'v4.37.8' commit: 'sha1-db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28' @@ -137,6 +183,33 @@ dependencies: commit: 'sha1-22e7bdb322c430c1d0dac6b3bb307f4bb139d0be' owner_id: 6759885 repo_id: 1352485172 + 'hyperpolymath/standards@571cc734cd69fb846032ec77a662aa8ee4fc32cd': + ref: '571cc734cd69fb846032ec77a662aa8ee4fc32cd' + commit: 'sha1-571cc734cd69fb846032ec77a662aa8ee4fc32cd' + owner_id: 6759885 + repo_id: 1116521501 + uses: + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772' + - 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555' + 'hyperpolymath/standards@da2c748aad55c1a1dcba00b60fe4a35017bc6540': + ref: 'da2c748aad55c1a1dcba00b60fe4a35017bc6540' + commit: 'sha1-da2c748aad55c1a1dcba00b60fe4a35017bc6540' + owner_id: 6759885 + repo_id: 1116521501 + uses: + - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' + - 'editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393' + - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' + - 'github/codeql-action@cdf488f595d80d6e07e03d4674febd5ab45fa938' + - 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc' + 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc': + ref: '2d1146689b8cda280b9bc96326124645441f03bc' + commit: 'sha1-2d1146689b8cda280b9bc96326124645441f03bc' + owner_id: 67707773 + repo_id: 421101922 'ossf/scorecard-action@v2.4.4': ref: 'v2.4.4' commit: 'sha1-2d1146689b8cda280b9bc96326124645441f03bc' @@ -152,43 +225,8 @@ dependencies: commit: 'sha1-6323deb102c322ba6fcbdcafc7e3dddab59af2b6' owner_id: 580492 repo_id: 298565987 - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9': - ref: 'v6.1.0' - commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9' - owner_id: 44036562 - repo_id: 215566462 - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1': - ref: 'v7.0.1' - commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1' - owner_id: 44036562 - repo_id: 197814629 - 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a': - ref: 'v7.0.1' - commit: 'sha1-043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' - owner_id: 44036562 - repo_id: 192625955 - 'dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772': - ref: 'stable' - commit: 'sha1-6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772' - owner_id: 1940490 - repo_id: 260749683 - 'editorconfig-checker/action-editorconfig-checker@51f63319f592f97930c73d9c46184d20bd206393': - ref: 'v3.0.0' - commit: 'sha1-51f63319f592f97930c73d9c46184d20bd206393' - owner_id: 26415196 - repo_id: 297874902 - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124': - ref: 'v1.24.1' - commit: 'sha1-54075bcc5e249e4758d363f27d099f55d843f124' - owner_id: 47606891 - repo_id: 331103973 - 'ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc': - ref: 'v2.4.4' - commit: 'sha1-2d1146689b8cda280b9bc96326124645441f03bc' - owner_id: 67707773 - repo_id: 421101922 'webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555': - ref: 'v0.10.0' + ref: 'e83874834305fe9a4a2997156cb26c5de65a8555' commit: 'sha1-e83874834305fe9a4a2997156cb26c5de65a8555' owner_id: 135788 repo_id: 208510314 diff --git a/.github/workflows/lock-sync-gate.yml b/.github/workflows/lock-sync-gate.yml new file mode 100644 index 00000000..936c8732 --- /dev/null +++ b/.github/workflows/lock-sync-gate.yml @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: MPL-2.0 +name: Lock Sync Gate + +# Fails any pull request whose .github/workflows/actions.lock has drifted from +# the workflow YAML. That drift is not cosmetic: GitHub refuses such a run at +# startup, creating ZERO jobs, and reports only "This run likely failed because +# of a workflow file issue." A single grouped Dependabot bump can take out most +# of a repository's CI that way, because Dependabot rewrites `uses:` refs in the +# YAML and cannot touch the lockfile. Measured across 200 repositories on +# 2026-09-22: 39 had silently dead CI from exactly this cause. +# See hyperpolymath/standards#968. +# +# This workflow deliberately carries NO `uses:` of its own. It checks out by +# calling git in a `run:` step instead of using actions/checkout, so it has no +# lockfile entry to go stale and is structurally immune to the very failure it +# detects. Do not add a `uses:` to this file. +# +# There is also no `paths:` filter, on purpose: a filtered workflow never +# reports on pull requests that miss the filter, which deadlocks any branch +# ruleset that requires this check. + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: lock-sync-gate-${{ github.ref }} + cancel-in-progress: true + +jobs: + lock-sync: + name: actions.lock is in sync with the workflow YAML + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out without actions/checkout + env: + REPO: ${{ github.repository }} + SHA: ${{ github.event.pull_request.head.sha || github.sha }} + TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # Authenticate the fetch. An anonymous clone works only for public + # repositories; this gate must also run on private ones. The header + # form is used rather than a token in the remote URL so the + # credential is never written into .git/config. + AUTH="AUTHORIZATION: basic $(printf 'x-access-token:%s' "${TOKEN}" | base64 -w0)" + git init -q . + git remote add origin "https://github.com/${REPO}.git" + git -c http.extraheader="${AUTH}" fetch -q --depth 1 origin "${SHA}" + git checkout -q FETCH_HEAD + echo "checked out ${SHA}" + + - name: Verify lockfile synchronisation + run: | + set -euo pipefail + test -x scripts/check-lock-sync.sh \ + || { echo "::error::scripts/check-lock-sync.sh missing or not executable"; exit 1; } + ./scripts/check-lock-sync.sh diff --git a/scripts/check-lock-sync.sh b/scripts/check-lock-sync.sh new file mode 100755 index 00000000..bbe283d1 --- /dev/null +++ b/scripts/check-lock-sync.sh @@ -0,0 +1,307 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# +# check-lock-sync.sh — verify .github/workflows/actions.lock is in sync with the +# workflow YAML, in BOTH directions (including job-level reusable-workflow refs), +# AND that the lockfile is TRANSITIVELY CLOSED. +# +# Three clauses, each of which alone is insufficient: +# +# 1. every `uses:` in a workflow is locked under THAT workflow's own path; +# 2. every lockfile entry is still referenced by its workflow (no orphans); +# 3. every ref NAMED anywhere in the lockfile resolves to a top-level +# `dependencies:` record — the lockfile has no dangling edges. +# +# Clause 3 is not decoration. It is the clause that catches the failure mode that +# clauses 1 and 2 are structurally blind to, and it was added only after that +# blindness was measured. On hyperpolymath/cicd-squabbler, 2026-09-22: +# +# commit dangling-edge class result +# fe22bbc workflows: -> dependencies: (ref listed, no record) 4 workflows startup_failure, jobs=0 +# cfadcf9 dependencies: -> dependencies: (record added, its +# own nested uses: unrecorded) the same 4 still startup_failure +# 5286aa5 none - transitively closed 0 startup_failure, all 17 runs create jobs +# +# At fe22bbc AND cfadcf9 this script exited 0, `gh actions-lock --verify-local` +# exited 0, and the Lock Sync Gate reported green - while GitHub was refusing to +# start four workflows. Every local gate was green on a fatal commit. That is the +# guard/consumer trap: the gate asked "is every uses: locked?" and GitHub asks +# "is every locked ref RESOLVABLE?". +# +# The asymmetry that makes clause 3 mandatory, and counter-intuitive: +# * a job-level ref ABSENT from the lockfile entirely is HARMLESS; +# * a ref PRESENT in the lockfile but unresolvable is FATAL. +# So adding entries without closing them is strictly worse than adding nothing. +# Clause 1 demands entries be added; only clause 3 makes that demand safe. Shipping +# clause 1 without clause 3 actively steers a developer into the fatal state: +# Dependabot bumps a job-level ref -> clause 1 reds -> `gh actions-lock` is blind to +# job-level refs and will not backfill -> the developer hand-adds the workflows: +# entry to get green -> no dependencies: record -> CI dies silently, gate green. +# +# Exit 0 only when all three clauses hold. Any violation exits 1. There is no +# warn-only mode: a desync means GitHub refuses to start the run, so it must fail +# the job. A `::warning::` cannot fail a job and would be a vacuous gate. + +set -euo pipefail + +WF_DIR="${1:-.github/workflows}" +LOCK="$WF_DIR/actions.lock" + +# gawk is required: the parser uses 3-argument match(), a GNU extension. mawk +# (the Debian/Ubuntu default `awk`) does not support it, and a silent parse +# failure here would read as a clean pass - the exact failure mode this script +# exists to prevent. Probe it rather than trusting the name. +AWK="" +for cand in gawk awk; do + if command -v "$cand" >/dev/null 2>&1 \ + && echo x | "$cand" '{ if (match($0, /(x)/, m) && m[1] == "x") exit 0; exit 1 }' 2>/dev/null; then + AWK="$cand"; break + fi +done +if [ -z "$AWK" ]; then + echo "check-lock-sync: FATAL: no awk supporting 3-argument match() (need gawk)" >&2 + echo "check-lock-sync: install it with: sudo apt-get install -y gawk" >&2 + exit 1 +fi + +if [ ! -f "$LOCK" ]; then + echo "check-lock-sync: FATAL: no lockfile at $LOCK" >&2 + exit 1 +fi + +shopt -s nullglob +mapfile -t WORKFLOWS < <(printf '%s\n' "$WF_DIR"/*.yml "$WF_DIR"/*.yaml | sort -u) +if [ "${#WORKFLOWS[@]}" -eq 0 ]; then + echo "check-lock-sync: FATAL: no workflow files under $WF_DIR" >&2 + exit 1 +fi + +read -r -d '' PROG <<'AWK' || true +# owner/repo[/subpath...]@ref -> owner/repo@ref ("" if not an external ref) +function norm(r, at, path, ref, n, parts) { + at = 0 + for (n = length(r); n > 0; n--) { if (substr(r, n, 1) == "@") { at = n; break } } + if (at == 0) return "" + path = substr(r, 1, at - 1); ref = substr(r, at + 1) + if (path == "" || ref == "") return "" + if (substr(path, 1, 2) == "./" || substr(path, 1, 2) == "$/") return "" # local action + if (split(path, parts, "/") < 2) return "" + return parts[1] "/" parts[2] "@" ref +} + +# Fold case on the OWNER/REPO segment only, for comparison keys. GitHub resolves +# owner and repository names case-insensitively, and this is measured, not assumed: +# metadatastician/pong-ping's lockfile records sonarsource/sonarqube-scan-action@v8.2.1 +# while sonarqube.yml says SonarSource/..., and at commit cd5f90f that workflow ran +# SUCCESS while codeql.yml at the SAME commit was startup_failure. A same-commit +# control, so the case difference is provably not what kills a run. +# The REF is NOT folded: git tags and branch names are case-sensitive. +function ck(r, at, s) { + at = 0 + for (s = length(r); s > 0; s--) { if (substr(r, s, 1) == "@") { at = s; break } } + if (at == 0) return tolower(r) + return tolower(substr(r, 1, at - 1)) substr(r, at) +} + +# ---------- pass 1: the lockfile ---------- +FILENAME == lockfile { + if ($0 ~ /^workflows:[[:space:]]*$/) { inwf = 1; indep = 0; next } + if ($0 ~ /^dependencies:[[:space:]]*$/) { inwf = 0; indep = 1; next } + if ($0 ~ /^[a-z_]+:/) { inwf = 0; indep = 0; next } + + # --- the dependencies: section, for clause 3 --- + if (indep) { + # " 'owner/repo@ref':" -- a top-level dependency record + if (match($0, /^ '([^']+)':/, m)) { + depkey = m[1] + haverec[ck(depkey)] = 1; disp[ck(depkey)] = depkey + next + } + # " - 'owner/repo@ref'" -- a nested uses: of that record + if (match($0, /^ - '([^']+)'/, m) && depkey != "") { + r = ck(m[1]); disp[r] = m[1] + want[r] = 1 + wantsrc[r] = wantsrc[r] " dependencies:" depkey + next + } + next + } + + if (!inwf) next + + # " '.github/workflows/x.yml':" or "... : []" + if (match($0, /^ '([^']+)':/, m)) { + cur = m[1] + seen_path[cur] = 1 + next + } + if (match($0, /^ - '([^']+)'[[:space:]]*$/, m) && cur != "") { + lr = ck(m[1]); disp[lr] = m[1]; lock[cur, lr] = 1 + lockcount[cur]++ + want[lr] = 1 + wantsrc[lr] = wantsrc[lr] " " cur + next + } + next +} + +# ---------- pass 2: the workflow YAML ---------- +FNR == 1 { wf = FILENAME } +{ + line = $0 + sub(/[[:space:]]+#.*$/, "", line) # strip trailing comment + if (match(line, /^[[:space:]]*-?[[:space:]]*uses:[[:space:]]*(.+)$/, m)) { + raw = m[1] + gsub(/^["']|["']$/, "", raw) + gsub(/[[:space:]]+$/, "", raw) + if (raw ~ /^\$\//) { dollar[wf] = dollar[wf] " " raw; next } # known corruption + n = norm(raw) + if (n != "") { + uses[wf, ck(n)] = 1 + # A JOB-LEVEL reusable-workflow ref is owner/repo/.github/workflows/.yml@ref. + # A STEP-LEVEL action ref is anything else. The distinction is load-bearing: + # see clause 1. + if (raw ~ /\/\.github\/workflows\/[^@]*\.ya?ml@/) joblist[wf] = joblist[wf] " " n + else steplist[wf] = steplist[wf] " " n + useslist[wf] = useslist[wf] " " n + } + } +} + +END { + bad = 0 + for (i = 1; i < ARGC; i++) { + wf = ARGV[i] + if (wf == lockfile) continue + key = wf + sub(/.*\//, "", key) + key = ".github/workflows/" key # the lockfile always uses this canonical path + + if (dollar[wf] != "") { + printf "FAIL %s\n invalid local-action rewrite (uses: $/...):%s\n", key, dollar[wf] + bad = 1 + } + + # --- clause 1: every STEP-LEVEL uses: must be locked under THIS path --- + # + # Only step-level action refs are required. A job-level reusable-workflow ref + # that is ABSENT from the lockfile is harmless - this file's own header has + # said so since it was written ("a job-level ref ABSENT from the lockfile + # entirely is HARMLESS; a ref PRESENT in the lockfile but unresolvable is + # FATAL"), but clause 1 used to fail on it anyway. That was an internal + # contradiction, and it is measured, not argued: + # + # * metadatastician/universal-modding-studio and idaptik-ums: scorecard.yml + # is a pure reusable caller with NO lockfile entry at all -> runs, jobs>0. + # * hyperpolymath/standards mirror.yml: empty lock entry, job-level ref + # unlocked -> 7 jobs created. + # * hyperpolymath/my-lang: four workflows share ONE identical stale entry; + # two succeed and two startup-fail, so the entry is not the discriminator. + # What separates them is clause 3 - whether the callee's own refs resolve + # to dependencies: records in THIS lockfile. + # + # Failing on an absent job-level ref also steers the developer into the fatal + # state: gh actions-lock will not backfill job-level refs, so the only way to + # go green was to hand-add a workflows: entry with no dependencies: record - + # which is precisely the dangling edge clause 3 exists to catch. + nu = split(steplist[wf], u, " ") + delete uniq; missing = "" + for (j = 1; j <= nu; j++) { + if (u[j] == "" || (u[j] in uniq)) continue + uniq[u[j]] = 1 + if (!((key SUBSEP ck(u[j])) in lock)) missing = missing " " u[j] + } + if (missing != "") { + if (!(key in seen_path)) + printf "FAIL %s\n not onboarded: no lockfile entry for this path\n unlocked step-level refs:%s\n", key, missing + else + printf "FAIL %s\n step-level refs missing from the lockfile:%s\n", key, missing + bad = 1 + } + + # Job-level reusable refs: reported, never fatal. If one IS locked, clause 3 + # still requires its callee graph to be closed. + njm = split(joblist[wf], v, " ") + delete juniq; jmissing = "" + for (j = 1; j <= njm; j++) { + if (v[j] == "" || (v[j] in juniq)) continue + juniq[v[j]] = 1 + if (!((key SUBSEP ck(v[j])) in lock)) jmissing = jmissing " " v[j] + } + if (jmissing != "") jnote = jnote sprintf("\n %s:%s", key, jmissing) + + # --- clause 2: every lock entry must be referenced by this workflow --- + orphan = "" + for (k in lock) { + split(k, kp, SUBSEP) + if (kp[1] != key) continue + if (!((wf SUBSEP kp[2]) in uses)) orphan = orphan " " (kp[2] in disp ? disp[kp[2]] : kp[2]) + } + if (orphan != "") { + printf "FAIL %s\n stale lockfile entries, no uses: references them:%s\n", key, orphan + bad = 1 + } + } + + # --- lockfile entries for workflow files that no longer exist --- + for (p in seen_path) { + found = 0 + for (i = 1; i < ARGC; i++) { + q = ARGV[i]; if (q == lockfile) continue + sub(/.*\//, "", q); q = ".github/workflows/" q + if (q == p) { found = 1; break } + } + if (!found) { printf "FAIL %s\n lockfile entry for a workflow file that does not exist\n", p; bad = 1 } + } + + # --- clause 3: TRANSITIVE CLOSURE. Every ref named anywhere in the lockfile + # must resolve to a top-level dependencies: record. A dangling edge makes + # GitHub refuse the run at startup with jobs=0. --- + ndang = 0; dang = "" + for (r in want) { + if (r !~ /^[^\/]+\/[^\/@]+@/) continue # not an OWNER/REPO@REF pin; not ours to resolve + if (r in haverec) continue + ndang++ + dang = dang sprintf("\n %s\n named by:%s", (r in disp ? disp[r] : r), wantsrc[r]) + } + if (ndang > 0) { + printf "FAIL actions.lock: DANGLING EDGES\n" + printf " %d ref(s) are named in the lockfile but have no top-level dependencies: record.%s\n", ndang, dang + bad = 1 + } + + # --- a dependencies: record nothing names is dead weight, not fatal: report only --- + nunref = 0 + for (d in haverec) if (!(d in want)) nunref++ + + if (bad) { + print "" + print "actions.lock is OUT OF SYNC with the workflow YAML, or is not transitively closed." + print "GitHub refuses such a run at startup: zero jobs are created and the run" + print "reports \"This run likely failed because of a workflow file issue.\"" + print "" + print "Fix, in this order:" + print " 1. `gh actions-lock --no-migrate-local-actions`, then review the diff. It does" + print " NOT handle job-level reusable-workflow refs and it can de-pin bare SHAs to" + print " floating tags - both must be corrected by hand." + print " 2. For any DANGLING EDGES above, add a top-level `dependencies:` record for each" + print " ref. A leaf record may legally omit the nested `uses:` key entirely, so adding" + print " leaves introduces no new dangling edges and closure terminates in one pass." + print " Keys are sorted with LC_ALL=C collation (ASCII '-' 0x2d sorts before '@' 0x40)." + print " 3. Nested `uses:` entries must be bare OWNER/REPO@REF. A subpath pin such as" + print " github/codeql-action/upload-sarif@ is REJECTED by the schema; collapse it" + print " to github/codeql-action@." + exit 1 + } + printf "actions.lock is in sync and transitively closed:\n" + printf " * every uses: is locked under its own workflow path (job-level reusable refs included)\n" + printf " * every lockfile entry is still referenced\n" + printf " * every ref named in the lockfile resolves to a dependencies: record (0 dangling edges)\n" + if (nunref > 0) + printf " note: %d dependencies: record(s) are unreferenced - harmless, but prunable.\n", nunref + if (jnote != "") + printf " note: job-level reusable refs not locked (harmless; see clause 1):%s\n", jnote +} +AWK + +"$AWK" -v lockfile="$LOCK" "$PROG" "$LOCK" "${WORKFLOWS[@]}" From 808d987173c859ef8864251f63f93e83b1e36b01 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:02:41 +0100 Subject: [PATCH 2/8] fix(ci): give lock-sync-gate.yml an actions.lock entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workflow absent from actions.lock can be rejected at startup (startup_failure, jobs=0) even when it carries zero real 'uses:' refs and so has nothing to pin. The gate is deliberately zero-'uses:', which is exactly why it had no entry. Measured on two repos in this batch: adding this single line flipped the gate from 7 consecutive startup_failure runs to success on hyperpolymath/verisimdb (two successes since, nothing else changed) and from 2 of 2 startup_failure to success on hyperpolymath/blocky-writer. Enforcement is not uniform across repos — 13 of the 14 repos in this batch start the byte-identical gate today with the same gap. A repo that passes now is not evidence its lock is complete, only that the behaviour has not reached it. This closes the gap before it bites. Zero-'uses:' workflows take the empty list, matching the entries actions.lock already carries for other zero-'uses:' workflows such as labels.yml. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- .github/workflows/actions.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index f5a49960..6b2627ab 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -37,6 +37,7 @@ workflows: '.github/workflows/learning-loop.yml': - 'actions/checkout@v7.0.1' - 'actions/upload-artifact@v7.0.1' + '.github/workflows/lock-sync-gate.yml': [] '.github/workflows/mirror.yml': - 'hyperpolymath/standards@571cc734cd69fb846032ec77a662aa8ee4fc32cd' '.github/workflows/pages.yml': From 574555eb6b0f8d09fdeed74da0ee3ccbb9a9609a Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:23:08 +0100 Subject: [PATCH 3/8] check-lock-sync: add clause 4, lockfile COVERAGE The gate could not defend the fix this PR ships. Clauses 1-3 ask "is every `uses:` locked under its own workflow path?" GitHub asks a DIFFERENT question: "is every workflow FILE represented in the lock?" A workflow with no `uses:` satisfies clauses 1-3 vacuously and GitHub still refuses to start it - which is exactly how lock-sync-gate.yml failed here 7 times running while the checker reported the lock in sync. Thirteen other repositories passed the gate with the same gap present, so a green gate was not evidence of a complete lock. Clause 4 diffs the set of files under .github/workflows/ against the set of lockfile keys, fails on any file with no key, names it, and quotes the empty-list form to add. Remediation step 4 warns that re-running `gh actions-lock` may not fix it, because omitting the file is the tool's own defect. Mutation-tested both ways: deleting the lock-sync-gate key fails the gate, and deleting the unrelated labels.yml key fails it too; the unmutated tree passes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- scripts/check-lock-sync.sh | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/scripts/check-lock-sync.sh b/scripts/check-lock-sync.sh index bbe283d1..5ba2d121 100755 --- a/scripts/check-lock-sync.sh +++ b/scripts/check-lock-sync.sh @@ -254,6 +254,33 @@ END { if (!found) { printf "FAIL %s\n lockfile entry for a workflow file that does not exist\n", p; bad = 1 } } + # --- clause 4: COVERAGE. Every workflow FILE must have a key in the lockfile, + # including one with no uses: at all - the value is then an empty list. + # MEASURED 2026-09-22, single-variable flip on two independent repos: + # hyperpolymath/verisimdb's lock-sync-gate.yml was startup_failure 7 times + # running with ZERO uses: refs, and adding + # '.github/workflows/lock-sync-gate.yml': [] + # flipped it to success; reproduced on hyperpolymath/blocky-writer, 2 of 2. + # `gh actions-lock` already emits this empty-list form for other zero-uses: + # workflows (labels.yml), so it is the generator's own convention, not ours. + # Clauses 1-3 CANNOT catch this: they ask "is every uses: locked?", and a + # workflow with no uses: satisfies them vacuously while GitHub still refuses + # to start it. 13 repos passed clauses 1-3 with exactly this gap. + nunlisted = 0; unlisted = "" + for (i = 1; i < ARGC; i++) { + q = ARGV[i]; if (q == lockfile) continue + sub(/.*\//, "", q); q = ".github/workflows/" q + if (q in seen_path) continue + nunlisted++; unlisted = unlisted "\n " q + } + if (nunlisted > 0) { + printf "FAIL actions.lock: UNLISTED WORKFLOWS\n" + printf " %d workflow file(s) have no key in the lockfile. GitHub refuses such a\n", nunlisted + printf " run at startup (jobs=0) even when the workflow has no uses: at all.\n" + printf " The entry for a zero-uses: workflow is an empty list:%s\n", unlisted + bad = 1 + } + # --- clause 3: TRANSITIVE CLOSURE. Every ref named anywhere in the lockfile # must resolve to a top-level dependencies: record. A dangling edge makes # GitHub refuse the run at startup with jobs=0. --- @@ -291,12 +318,17 @@ END { print " 3. Nested `uses:` entries must be bare OWNER/REPO@REF. A subpath pin such as" print " github/codeql-action/upload-sarif@ is REJECTED by the schema; collapse it" print " to github/codeql-action@." + print " 4. For any UNLISTED WORKFLOWS above, add the path as a lockfile key. A workflow" + print " with no uses: takes an empty list: \x27.github/workflows/x.yml\x27: []" + print " `gh actions-lock` has been observed to OMIT such a workflow entirely; that" + print " omission is itself the defect, so re-running the tool may not add it." exit 1 } printf "actions.lock is in sync and transitively closed:\n" printf " * every uses: is locked under its own workflow path (job-level reusable refs included)\n" printf " * every lockfile entry is still referenced\n" printf " * every ref named in the lockfile resolves to a dependencies: record (0 dangling edges)\n" + printf " * every workflow file has a lockfile key (zero-uses: workflows included)\n" if (nunref > 0) printf " note: %d dependencies: record(s) are unreferenced - harmless, but prunable.\n", nunref if (jnote != "") From 05ece6de6b0d7159a5067df19402bc7447ee8ea3 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:11:09 +0100 Subject: [PATCH 4/8] Update scripts/check-lock-sync.sh Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> --- scripts/check-lock-sync.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/check-lock-sync.sh b/scripts/check-lock-sync.sh index 5ba2d121..3860ba40 100755 --- a/scripts/check-lock-sync.sh +++ b/scripts/check-lock-sync.sh @@ -154,7 +154,6 @@ FNR == 1 { wf = FILENAME } raw = m[1] gsub(/^["']|["']$/, "", raw) gsub(/[[:space:]]+$/, "", raw) - if (raw ~ /^\$\//) { dollar[wf] = dollar[wf] " " raw; next } # known corruption n = norm(raw) if (n != "") { uses[wf, ck(n)] = 1 From 08e2bcc2a8e5ab556996bacac9dada5c8e059d1f Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:16:49 +0000 Subject: [PATCH 5/8] fix(lock-sync): Parse workflow uses values with YAML parsers Detect quoted keys and flow mappings, and fail on parsing errors or unsafe values to prevent missed action references. --- scripts/check-lock-sync.sh | 91 +++++++++++++++++++++++++++++++++----- 1 file changed, 80 insertions(+), 11 deletions(-) diff --git a/scripts/check-lock-sync.sh b/scripts/check-lock-sync.sh index 3860ba40..3cdd782c 100755 --- a/scripts/check-lock-sync.sh +++ b/scripts/check-lock-sync.sh @@ -76,6 +76,75 @@ if [ "${#WORKFLOWS[@]}" -eq 0 ]; then exit 1 fi +# Workflow files are YAML, not line-oriented configuration. In particular, +# `uses` may be a quoted key or occur in a flow mapping, neither of which a +# line regex can reliably discover. Extract every scalar `uses` value with a +# YAML parser before the lockfile comparison. A parser failure is fatal: a +# green lock check with an omitted reference is worse than no check at all. +USES_FILE="$(mktemp "${TMPDIR:-/tmp}/check-lock-sync.uses.XXXXXX")" +RAW_USES_FILE="$(mktemp "${TMPDIR:-/tmp}/check-lock-sync.raw-uses.XXXXXX")" +trap 'rm -f "$USES_FILE" "$RAW_USES_FILE"' EXIT + +extract_uses_yq() { + local workflow="$1" + + # Values outside this representation cannot be safely handed to awk as one + # record per reference. They are not valid GitHub Action references either. + if ! yq -e '[.. | select(type == "!!map" and has("uses")) | .uses | select(type != "!!str" or . == "" or test("[\\t\\r\\n]"))] | length == 0' "$workflow" >/dev/null; then + echo "check-lock-sync: FATAL: cannot safely inspect uses: values in $workflow" >&2 + return 1 + fi + yq -r '.. | select(type == "!!map" and has("uses")) | .uses | select(type == "!!str")' "$workflow" +} + +extract_uses_ruby() { + local workflow="$1" + + ruby - "$workflow" <<'RUBY' +require "yaml" + +workflow = ARGV.fetch(0) +document = YAML.safe_load(File.read(workflow), aliases: true) + +def emit_uses(node, workflow) + case node + when Hash + node.each do |key, value| + if key == "uses" + unless value.is_a?(String) && !value.empty? && !value.match?(/[\t\r\n]/) + abort "check-lock-sync: FATAL: cannot safely inspect uses: values in #{workflow}" + end + puts value + end + emit_uses(value, workflow) + end + when Array + node.each { |value| emit_uses(value, workflow) } + end +end + +emit_uses(document, workflow) +RUBY +} + +if command -v yq >/dev/null 2>&1 && yq --version 2>&1 | grep -qi 'mikefarah/yq'; then + EXTRACT_USES=extract_uses_yq +elif command -v ruby >/dev/null 2>&1 && ruby -e 'require "yaml"' >/dev/null 2>&1; then + EXTRACT_USES=extract_uses_ruby +else + echo "check-lock-sync: FATAL: need mikefarah/yq or Ruby with Psych to parse workflow YAML" >&2 + exit 1 +fi + +for workflow in "${WORKFLOWS[@]}"; do + if ! "$EXTRACT_USES" "$workflow" > "$RAW_USES_FILE"; then + exit 1 + fi + while IFS= read -r raw; do + printf '%s\t%s\n' "$workflow" "$raw" >> "$USES_FILE" + done < "$RAW_USES_FILE" +done + read -r -d '' PROG <<'AWK' || true # owner/repo[/subpath...]@ref -> owner/repo@ref ("" if not an external ref) function norm(r, at, path, ref, n, parts) { @@ -145,15 +214,12 @@ FILENAME == lockfile { next } -# ---------- pass 2: the workflow YAML ---------- -FNR == 1 { wf = FILENAME } -{ - line = $0 - sub(/[[:space:]]+#.*$/, "", line) # strip trailing comment - if (match(line, /^[[:space:]]*-?[[:space:]]*uses:[[:space:]]*(.+)$/, m)) { - raw = m[1] - gsub(/^["']|["']$/, "", raw) - gsub(/[[:space:]]+$/, "", raw) +# ---------- pass 2: parser-extracted workflow uses: values ---------- +FILENAME == usesfile { + split($0, fields, "\t") + wf = fields[1] + raw = fields[2] + if (wf != "" && raw != "") { n = norm(raw) if (n != "") { uses[wf, ck(n)] = 1 @@ -165,13 +231,14 @@ FNR == 1 { wf = FILENAME } useslist[wf] = useslist[wf] " " n } } + next } END { bad = 0 for (i = 1; i < ARGC; i++) { wf = ARGV[i] - if (wf == lockfile) continue + if (wf == lockfile || wf == usesfile) continue key = wf sub(/.*\//, "", key) key = ".github/workflows/" key # the lockfile always uses this canonical path @@ -247,6 +314,7 @@ END { found = 0 for (i = 1; i < ARGC; i++) { q = ARGV[i]; if (q == lockfile) continue + if (q == usesfile) continue sub(/.*\//, "", q); q = ".github/workflows/" q if (q == p) { found = 1; break } } @@ -268,6 +336,7 @@ END { nunlisted = 0; unlisted = "" for (i = 1; i < ARGC; i++) { q = ARGV[i]; if (q == lockfile) continue + if (q == usesfile) continue sub(/.*\//, "", q); q = ".github/workflows/" q if (q in seen_path) continue nunlisted++; unlisted = unlisted "\n " q @@ -335,4 +404,4 @@ END { } AWK -"$AWK" -v lockfile="$LOCK" "$PROG" "$LOCK" "${WORKFLOWS[@]}" +"$AWK" -v lockfile="$LOCK" -v usesfile="$USES_FILE" "$PROG" "$LOCK" "${WORKFLOWS[@]}" "$USES_FILE" From 96d7edb60e0d2ca18424fd1a265e8c5c4ba459b4 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:21:17 +0000 Subject: [PATCH 6/8] Fix CI failures in PR #566 --- bots/rhodibot/canon/pin.toml | 26 +- bots/rhodibot/canon/rsr-criteria-v2.a2ml | 19 +- .../canon/template-capability-gates.toml | 10 +- bots/rhodibot/src/canon.rs | 8 +- bots/rhodibot/src/canon/profile.rs | 2 +- bots/rhodibot/src/canon/report.rs | 8 +- bots/rhodibot/tests/canon_lockstep.rs | 8 +- dashboard/src/main.rs | 11 +- robot-repo-automaton/src/catalog.rs | 6 +- robot-repo-automaton/src/confidence.rs | 62 +++- robot-repo-automaton/src/detector.rs | 16 +- .../src/exclusion_registry.rs | 38 ++- robot-repo-automaton/src/fixer.rs | 287 +++++++++++------- robot-repo-automaton/src/fleet.rs | 59 ++-- robot-repo-automaton/src/github.rs | 54 ++-- robot-repo-automaton/src/hooks.rs | 14 +- robot-repo-automaton/src/hypatia.rs | 119 ++++++-- robot-repo-automaton/src/lib.rs | 11 +- robot-repo-automaton/src/main.rs | 35 ++- robot-repo-automaton/src/skeleton.rs | 5 +- robot-repo-automaton/tests/catalog_tests.rs | 19 +- robot-repo-automaton/tests/fixer_tests.rs | 62 +++- security.txt | 12 + shared-context/benches/fleet_benchmarks.rs | 21 +- shared-context/src/bot.rs | 16 +- shared-context/src/context.rs | 7 +- shared-context/src/exclusion_registry.rs | 57 ++-- shared-context/src/finding.rs | 20 +- shared-context/src/health.rs | 53 ++-- shared-context/src/lib.rs | 6 +- shared-context/src/panel.rs | 54 ++-- shared-context/src/panel_checker.rs | 3 +- shared-context/src/reporting.rs | 91 ++++-- shared-context/src/storage.rs | 11 +- shared-context/tests/context_tests.rs | 26 +- .../tests/e2e_fleet_coordination_test.rs | 222 +++++++++++--- .../tests/fleet_coordination_test.rs | 56 +++- shared-context/tests/property_tests.rs | 26 +- 38 files changed, 1062 insertions(+), 498 deletions(-) create mode 100644 security.txt diff --git a/bots/rhodibot/canon/pin.toml b/bots/rhodibot/canon/pin.toml index 598bdbcd..9e8f585e 100644 --- a/bots/rhodibot/canon/pin.toml +++ b/bots/rhodibot/canon/pin.toml @@ -10,14 +10,14 @@ # reviewable commit. # # To re-pin: copy the new criteria file over `rsr-criteria-v2.a2ml`, update the -# three version fields, the digest, and the `categories`, `criteria` and +# version fields, the digest, and the `categories`, `criteria` and # `weight_sum` shape fields below, then run `cargo test`. The canon's own # arithmetic is re-checked on parse, so a half-copied file fails rather than # silently losing criteria. # # One discrepancy to be aware of, recorded rather than resolved here: the -# release in `canon.lock` is 2.0.4 while the criteria file's own `[meta] -# version` still reads 2.0.0-draft. Both are quoted below so the pin describes +# release in `canon.lock` is 2.1.1 while the criteria file's own `[meta] +# version` reads 2.1.0-draft. Both are quoted below so the pin describes # the artefact rather than a story about it. `canon.lock` also cannot be pinned # by commit: its `commit` field is still the all-zero placeholder with the # comment "fill at release". @@ -26,17 +26,17 @@ repo = "hyperpolymath/standards" path = "0-canon/rsr/rsr-criteria-v2.a2ml" slot = "criteria" -canon_version = "2.0.4" -criteria_version = "2.0.0-draft" -released = "2026-09-17" -pinned = "2026-09-19" -sha256 = "37cb5f679b414f6ee99c6bb62c460fd5349ff7d50cc1dab25b1e1a8e8d3c7bb9" +canon_version = "2.1.1" +criteria_version = "2.1.0-draft" +released = "2026-09-19" +pinned = "2026-09-22" +sha256 = "6a5aa8857bd0d0d58ef48827938ca17c251b6b854dacf59d306388d61694d82a" # The shape of the pinned revision, checked after parsing as well as by digest. # A rule set that silently shrinks is the failure mode that matters: these # numbers make shrinking a test failure rather than a quieter scorecard. categories = 11 -criteria = 74 +criteria = 78 weight_sum = 88 # The release's second artefact: the gate table. It says which capabilities a @@ -49,10 +49,10 @@ weight_sum = 88 repo = "hyperpolymath/standards" path = ".machine_readable/template-capability-gates.toml" slot = "gates" -version = "0.2.0" -released = "2026-09-17" -pinned = "2026-09-19" -sha256 = "b65ce75438c42d01bedf0325b9a97f1a575064866158b4340d7427fca911dd1b" +version = "0.3.0" +released = "2026-09-19" +pinned = "2026-09-22" +sha256 = "e70efd2f53c9445e30da4baf770366f04a4a84ffd844a01426e587e565b53e6a" # The shape of the pinned table. A vocabulary that shrank would leave profiles # declaring the lost words unparseable, and criteria gated on them permanently diff --git a/bots/rhodibot/canon/rsr-criteria-v2.a2ml b/bots/rhodibot/canon/rsr-criteria-v2.a2ml index bbb9cb3d..6fe48c66 100644 --- a/bots/rhodibot/canon/rsr-criteria-v2.a2ml +++ b/bots/rhodibot/canon/rsr-criteria-v2.a2ml @@ -18,13 +18,24 @@ [meta] spec = "rhodium-standard-repositories" -version = "2.0.0-draft" +version = "2.1.0-draft" status = "draft" # draft | stable ; MUST NOT be cited as ratified until §Ratification passes supersedes = "1.0.0" -date = "2026-07-03" +date = "2026-09-19" authority = "RSR-SPEC-v2.adoc" normative-oracle = "hypatia:rsr-conformance" # the ONE checker; all others are non-normative (see [oracle]) dialect = "a2ml-record" # dogfoods a2ml/RECORD-DIALECT-SPEC.adoc +# +# 2.1.0-draft (2026-09-19): ADDITIVE MINOR (rule-minor: may add criteria +# or capability gates; must not make a previously-conforming repo +# non-conforming at the same tier). Adds the julia-gated criteria +# 5.2.3-5.2.6. Rationale: the capability vocabulary named `julia` with +# no criteria to gate on, so a repo declaring `julia` + `library` was +# scored with nothing language-shaped at all - the 33-repo .jl estate +# was invisible to the oracle by construction. All four criteria gate +# on `julia`, so no repo that does not declare `julia` has its +# applicable set change: the MINOR's non-regression obligation holds +# by the gate itself. Owner ruling 2026-09-19: approved. [versioning] # Fixes the v1.0 "immutable forever" model, which estate reality already broke. @@ -167,6 +178,10 @@ criteria = [ { id = "5.1.6", name = "no-node-npm", desc = "No Node/npm/bun runtime deps (use Deno)", tier = "bronze", gate = "universal", detect = "cicd_rules/nodejs_detected", template_ref = ".github/workflows/runtime-policy.yml" }, { id = "5.2.1", name = "spark-ready", desc = "Rust projects designed to admit SPARK/Ada modules", tier = "rhodium", gate = "rust", detect = "manual", template_ref = "-" }, { id = "5.2.2", name = "proofs-clean", desc = "No believe_me / sorry / Admitted in load-bearing proofs", tier = "gold", gate = "formal-proofs", detect = "proof_obligation/no_holes", template_ref = "verification/" }, + { id = "5.2.3", name = "julia-package", desc = "Project.toml: stable uuid (derived or assigned, never regenerated), [compat] closure incl. the julia floor and bounds for every extra, licence consistent with LICENSE", tier = "silver", gate = "julia", detect = "rsr-conformance/julia_package", template_ref = "archetypes/julia-library/overlay/Project.toml.in" }, + { id = "5.2.4", name = "julia-ci", desc = "Julia CI workflow with a version matrix that includes the [compat] julia floor, running Pkg.test()", tier = "bronze", gate = "julia", detect = "rsr-conformance/julia_ci", template_ref = "archetypes/julia-library/overlay/.github/workflows/julia-ci.yml.in" }, + { id = "5.2.5", name = "julia-aqua", desc = "Aqua in [extras]+[targets] and green in CI (package-shape / compat / ambiguity gate); Aqua.test_all failure blocks", tier = "gold", gate = "julia", detect = "rsr-conformance/julia_aqua", template_ref = "archetypes/julia-library/overlay/test/runtests.jl" }, + { id = "5.2.6", name = "julia-docs", desc = "Documenter build wired for a julia repo that declares docs-site (docs/Project.toml + docs/make.jl + docs/src/ + build workflow); declared docs-site without a build is a VESTIGIAL-class defect the gate must catch", tier = "silver", gate = "julia", detect = "rsr-conformance/julia_docs", template_ref = "archetypes/julia-library/overlay/.github/workflows/julia-docs.yml.in" }, ] [[category]] diff --git a/bots/rhodibot/canon/template-capability-gates.toml b/bots/rhodibot/canon/template-capability-gates.toml index de111b2f..81e3e3a5 100644 --- a/bots/rhodibot/canon/template-capability-gates.toml +++ b/bots/rhodibot/canon/template-capability-gates.toml @@ -10,7 +10,7 @@ # Arrays are kept single-line so the checker can parse them with grep. [meta] -version = "0.2.0" +version = "0.3.0" policy = "0-canon/TEMPLATE-APPLICABILITY-POLICY.adoc" [capabilities] @@ -39,6 +39,14 @@ paths = ["README.adoc", "EXPLAINME.adoc|docs/EXPLAINME.adoc", "LICENSE", "SECURI "Cargo.lock" = "rust" "src/**/*.rs" = "rust" ".github/workflows/rust-ci.yml" = "rust" +# Julia library modules (the julia-library archetype's overlay; the +# #634 reference variant measured 2026-08-25: the language-specific +# delta is exactly these files). test/ (singular) is the Julia +# convention; rust uses tests/ and is ungated here by design. +"Project.toml" = "julia" +"test/" = "julia" +".github/workflows/julia-ci.yml" = "julia" +".github/workflows/julia-docs.yml" = "julia" "src/interface/ffi/" = "ffi" "abi.ipkg" = "abi" "src/interface/abi/|src/interface/Abi/" = "abi" diff --git a/bots/rhodibot/src/canon.rs b/bots/rhodibot/src/canon.rs index f11d24e6..fffab9b6 100644 --- a/bots/rhodibot/src/canon.rs +++ b/bots/rhodibot/src/canon.rs @@ -797,7 +797,7 @@ mod tests { fn parses_the_vendored_canon() { let canon = vendored(); assert_eq!(canon.categories.len(), 11, "the canon has 11 categories"); - assert_eq!(canon.criterion_count(), 74); + assert_eq!(canon.criterion_count(), 78); assert_eq!(canon.weight_sum(), 88); assert_eq!(canon.declared_weight_sum, Some(88)); assert_eq!(canon.tiers.bronze, 75); @@ -813,7 +813,7 @@ mod tests { assert_eq!(pin.source.repo, "hyperpolymath/standards"); assert_eq!(pin.source.sha256, digest_of(VENDORED_CRITERIA)); assert_eq!(pin.source.categories, 11); - assert_eq!(pin.source.criteria, 74); + assert_eq!(pin.source.criteria, 78); assert_eq!(pin.source.weight_sum, 88); } @@ -848,7 +848,7 @@ mod tests { .criteria() .filter(|c| c.capability().is_some()) .count(); - assert_eq!(gated, 26, "criteria gated on a declared capability"); + assert_eq!(gated, 30, "criteria gated on a declared capability"); } // ---- fail-closed --------------------------------------------------------- @@ -959,7 +959,7 @@ criteria = [ #[test] fn a_canon_without_a_version_is_rejected() { - let source = VENDORED_CRITERIA.replacen("version = \"2.0.0-draft\"", "", 1); + let source = VENDORED_CRITERIA.replacen("version = \"2.1.0-draft\"", "", 1); let error = Canon::parse(&source).expect_err("a versionless canon must not parse"); assert!( format!("{error:#}").contains("no [meta] version"), diff --git a/bots/rhodibot/src/canon/profile.rs b/bots/rhodibot/src/canon/profile.rs index 4f163526..6786e7cf 100644 --- a/bots/rhodibot/src/canon/profile.rs +++ b/bots/rhodibot/src/canon/profile.rs @@ -491,7 +491,7 @@ version = \"2.0.4\" #[test] fn the_vendored_gate_table_has_the_expected_shape() { let table = gates(); - assert_eq!(table.version(), "0.2.0"); + assert_eq!(table.version(), "0.3.0"); assert_eq!( table.known_count(), 28, diff --git a/bots/rhodibot/src/canon/report.rs b/bots/rhodibot/src/canon/report.rs index 2ab9ae89..63f5e10c 100644 --- a/bots/rhodibot/src/canon/report.rs +++ b/bots/rhodibot/src/canon/report.rs @@ -440,9 +440,9 @@ mod tests { CanonReport::build("acme/widgets", &canon(), &gates(), &files(COMPLIANT), None) .expect("the report builds"); - // The vendored canon: 74 criteria, 26 gated on a capability. - assert_eq!(report.criteria, 74); - assert_eq!(report.not_applicable, 26); + // The vendored canon: 78 criteria, 30 gated on a capability. + assert_eq!(report.criteria, 78); + assert_eq!(report.not_applicable, 30); assert_eq!(report.scored, 22); assert_eq!(report.not_file_questions, 26); assert_eq!( @@ -630,7 +630,7 @@ mod tests { let value = serde_json::to_value(&report).expect("serialises"); assert_eq!(value["subject"], "acme/widgets"); - assert_eq!(value["criteria"], 74); + assert_eq!(value["criteria"], 78); assert!(value["counts"]["deprecated"].as_u64().unwrap() > 0); assert!( value["findings"] diff --git a/bots/rhodibot/tests/canon_lockstep.rs b/bots/rhodibot/tests/canon_lockstep.rs index de4083c7..a49e229b 100644 --- a/bots/rhodibot/tests/canon_lockstep.rs +++ b/bots/rhodibot/tests/canon_lockstep.rs @@ -67,7 +67,7 @@ fn the_rule_set_the_pilot_is_quoted_from() { let canon = Canon::vendored().expect("the canon parses"); assert_eq!(canon.categories.len(), 11, "weighted categories"); - assert_eq!(canon.criterion_count(), 74, "criteria in total"); + assert_eq!(canon.criterion_count(), 78, "criteria in total"); assert_eq!( canon.weight_sum(), 88, @@ -77,7 +77,7 @@ fn the_rule_set_the_pilot_is_quoted_from() { let bronze = canon.criteria().filter(|c| c.tier == Tier::Bronze).count(); let gold = canon.criteria().filter(|c| c.tier == Tier::Gold).count(); let rhodium = canon.criteria().filter(|c| c.tier == Tier::Rhodium).count(); - assert_eq!((bronze, gold, rhodium), (17, 29, 4)); + assert_eq!((bronze, gold, rhodium), (18, 30, 4)); // The honest half of the coverage story: the canon says outright that these // have no automated detection. A tool that reported them as passing would @@ -91,7 +91,7 @@ fn the_rule_set_the_pilot_is_quoted_from() { .criteria() .filter(|c| c.capability().is_some()) .count(); - assert_eq!(gated, 26); + assert_eq!(gated, 30); // What a repository with no declared capabilities is measured on at bronze. assert_eq!(canon.universal_criteria_up_to(Tier::Bronze).count(), 17); @@ -214,7 +214,7 @@ fn capability_gates_split_the_file_presence_criteria() { assert_eq!( scored.len() + inapplicable.len(), - 31, + 33, "the number of criteria whose description names files changed" ); assert!( diff --git a/dashboard/src/main.rs b/dashboard/src/main.rs index b8f8f501..dede6a8c 100644 --- a/dashboard/src/main.rs +++ b/dashboard/src/main.rs @@ -175,7 +175,11 @@ async fn report_handler( match format.to_lowercase().as_str() { "html" => (StatusCode::OK, [("content-type", "text/html")], report), - "json" => (StatusCode::OK, [("content-type", "application/json")], report), + "json" => ( + StatusCode::OK, + [("content-type", "application/json")], + report, + ), _ => (StatusCode::OK, [("content-type", "text/plain")], report), } } @@ -211,10 +215,7 @@ async fn websocket_handler( } /// Handle WebSocket connection -async fn websocket_connection( - mut socket: axum::extract::ws::WebSocket, - state: AppState, -) { +async fn websocket_connection(mut socket: axum::extract::ws::WebSocket, state: AppState) { use axum::extract::ws::Message; use tokio::time::{interval, Duration}; diff --git a/robot-repo-automaton/src/catalog.rs b/robot-repo-automaton/src/catalog.rs index 524c7420..2ea05282 100644 --- a/robot-repo-automaton/src/catalog.rs +++ b/robot-repo-automaton/src/catalog.rs @@ -114,7 +114,8 @@ impl ErrorCatalog { /// Convert from S-expression value fn from_sexpr(value: &Value) -> Result { // Expect (define error-catalog '(...)) - let _list = value.as_cons() + let _list = value + .as_cons() .ok_or_else(|| Error::CatalogParse("Expected list at top level".into()))?; // Navigate to the catalog content @@ -163,7 +164,8 @@ impl ErrorCatalog { if sym == "define" { // Third element should be the quoted list if let Some(quoted) = items[2].as_cons() { - let quoted_items: Vec<&Value> = quoted.iter().map(|c| c.car()).collect(); + let quoted_items: Vec<&Value> = + quoted.iter().map(|c| c.car()).collect(); if !quoted_items.is_empty() { if let Some(quote_sym) = quoted_items[0].as_symbol() { if quote_sym == "quote" && quoted_items.len() > 1 { diff --git a/robot-repo-automaton/src/confidence.rs b/robot-repo-automaton/src/confidence.rs index 1201db07..84aebdad 100644 --- a/robot-repo-automaton/src/confidence.rs +++ b/robot-repo-automaton/src/confidence.rs @@ -308,21 +308,36 @@ fn is_protected_file(target: &str) -> bool { // Exact protected filenames (case-insensitive check on basename) let protected_names = [ - "TODO.md", "TODO.adoc", "TODO.txt", - "BLOCKERS.md", "BLOCKERS.adoc", - "CHANGELOG.md", "CHANGELOG.adoc", - "README.md", "README.adoc", "README.rst", - "TOPOLOGY.md", "TOPOLOGY.adoc", - "ARCHITECTURE.md", "ARCHITECTURE.adoc", - "ROADMAP.md", "ROADMAP.adoc", + "TODO.md", + "TODO.adoc", + "TODO.txt", + "BLOCKERS.md", + "BLOCKERS.adoc", + "CHANGELOG.md", + "CHANGELOG.adoc", + "README.md", + "README.adoc", + "README.rst", + "TOPOLOGY.md", + "TOPOLOGY.adoc", + "ARCHITECTURE.md", + "ARCHITECTURE.adoc", + "ROADMAP.md", + "ROADMAP.adoc", ]; - if protected_names.iter().any(|&p| p.eq_ignore_ascii_case(basename)) { + if protected_names + .iter() + .any(|&p| p.eq_ignore_ascii_case(basename)) + { return true; } // Protected extensions — checkpoint and manifest files let protected_extensions = [".scm", ".a2ml"]; - if protected_extensions.iter().any(|ext| basename.ends_with(ext)) { + if protected_extensions + .iter() + .any(|ext| basename.ends_with(ext)) + { return true; } @@ -630,7 +645,10 @@ mod tests { assert_eq!(config.classify_fix(&issue, &fix), ConfidenceLevel::High); // And the decision should be AutoApply - assert!(matches!(config.decide(&issue, &fix), FixDecision::AutoApply)); + assert!(matches!( + config.decide(&issue, &fix), + FixDecision::AutoApply + )); } #[test] @@ -654,11 +672,13 @@ mod tests { assert_eq!( config.classify_fix(&issue, &fix), ConfidenceLevel::Medium, - "Expected Medium at confidence={}", confidence + "Expected Medium at confidence={}", + confidence ); assert!( matches!(config.decide(&issue, &fix), FixDecision::Propose { .. }), - "Expected Propose at confidence={}", confidence + "Expected Propose at confidence={}", + confidence ); } } @@ -674,7 +694,8 @@ mod tests { assert_eq!( config.classify_fix(&issue, &fix), ConfidenceLevel::Low, - "Expected Low at confidence={}", confidence + "Expected Low at confidence={}", + confidence ); } } @@ -686,16 +707,25 @@ mod tests { // High confidence Delete -> AutoApply let issue_high = make_issue("TEST", 0.97); let fix = make_fix(FixAction::Delete, "stale.txt"); - assert!(matches!(config.decide(&issue_high, &fix), FixDecision::AutoApply)); + assert!(matches!( + config.decide(&issue_high, &fix), + FixDecision::AutoApply + )); // Medium confidence Delete -> Propose let issue_medium = make_issue("TEST", 0.80); - assert!(matches!(config.decide(&issue_medium, &fix), FixDecision::Propose { .. })); + assert!(matches!( + config.decide(&issue_medium, &fix), + FixDecision::Propose { .. } + )); // Low confidence Delete -> also Propose (not Skip, since Low >= Low) // (Skip only occurs when the action is somehow forced below the propose floor, // which is not reachable in current logic — Low always Proposes) let issue_low = make_issue("TEST", 0.30); - assert!(matches!(config.decide(&issue_low, &fix), FixDecision::Propose { .. })); + assert!(matches!( + config.decide(&issue_low, &fix), + FixDecision::Propose { .. } + )); } } diff --git a/robot-repo-automaton/src/detector.rs b/robot-repo-automaton/src/detector.rs index 99acea24..3bba14a1 100644 --- a/robot-repo-automaton/src/detector.rs +++ b/robot-repo-automaton/src/detector.rs @@ -178,10 +178,7 @@ impl Detector { description: error_type.description.clone(), affected_files: existing, confidence: 1.0, // File existence is deterministic - suggested_fix: format!( - "{:?} {}", - error_type.fix.action, error_type.fix.target - ), + suggested_fix: format!("{:?} {}", error_type.fix.action, error_type.fix.target), commit_message: error_type.commit_message.clone(), }) } else { @@ -322,10 +319,7 @@ impl Detector { ), affected_files: affected, confidence: 0.95, - suggested_fix: format!( - "{:?} {}", - error_type.fix.action, error_type.fix.target - ), + suggested_fix: format!("{:?} {}", error_type.fix.action, error_type.fix.target), commit_message: error_type.commit_message.clone(), }) } @@ -398,11 +392,7 @@ mod tests { fn test_file_exists() { let temp = TempDir::new().unwrap(); std::fs::create_dir_all(temp.path().join(".github/workflows")).unwrap(); - std::fs::write( - temp.path().join(".github/workflows/ci.yml"), - "name: CI", - ) - .unwrap(); + std::fs::write(temp.path().join(".github/workflows/ci.yml"), "name: CI").unwrap(); let detector = Detector::new(temp.path().to_path_buf()).unwrap(); assert!(detector.file_exists(".github/workflows/ci.yml")); diff --git a/robot-repo-automaton/src/exclusion_registry.rs b/robot-repo-automaton/src/exclusion_registry.rs index ccf9c86f..7b3732e9 100644 --- a/robot-repo-automaton/src/exclusion_registry.rs +++ b/robot-repo-automaton/src/exclusion_registry.rs @@ -275,10 +275,7 @@ impl ExclusionRegistry { if matches!(k.as_str(), "off" | "disabled" | "0" | "false" | "halt") { return Decision::Deny { axis: DenyAxis::KillSwitch, - reason: format!( - "HYPATIA_AUTOMATION={} — global kill switch engaged", - kill - ), + reason: format!("HYPATIA_AUTOMATION={} — global kill switch engaged", kill), }; } } @@ -636,9 +633,18 @@ mod real_registry_smoke { } let r = ExclusionRegistry::load(path).expect("parse real registry"); // Smoke: at least one of each axis. - assert!(!r.external_repos.is_empty(), "external_repos axis populated"); - assert!(!r.vendored_patterns.is_empty(), "vendored_patterns axis populated"); - assert!(!r.remote_origin_patterns.is_empty(), "remote_origin_patterns axis populated"); + assert!( + !r.external_repos.is_empty(), + "external_repos axis populated" + ); + assert!( + !r.vendored_patterns.is_empty(), + "vendored_patterns axis populated" + ); + assert!( + !r.remote_origin_patterns.is_empty(), + "remote_origin_patterns axis populated" + ); } #[test] @@ -646,7 +652,9 @@ mod real_registry_smoke { let path = std::path::Path::new( "/var/mnt/eclipse/repos/developer-ecosystem/standards/.machine_readable/bot_exclusion_registry.a2ml" ); - if !path.exists() { return; } + if !path.exists() { + return; + } let r = ExclusionRegistry::load(path).unwrap(); let d = r.check(&ActionContext { repo_full_name: "JoshuaJewell/IDApTIK", @@ -654,7 +662,10 @@ mod real_registry_smoke { remote_origin: None, action: Action::CreatePr, }); - assert!(!d.is_allow(), "real registry must deny JoshuaJewell/IDApTIK writes"); + assert!( + !d.is_allow(), + "real registry must deny JoshuaJewell/IDApTIK writes" + ); } #[test] @@ -662,7 +673,9 @@ mod real_registry_smoke { let path = std::path::Path::new( "/var/mnt/eclipse/repos/developer-ecosystem/standards/.machine_readable/bot_exclusion_registry.a2ml" ); - if !path.exists() { return; } + if !path.exists() { + return; + } let r = ExclusionRegistry::load(path).unwrap(); let d = r.check(&ActionContext { repo_full_name: "somewhere-locally/rust-clone", @@ -670,6 +683,9 @@ mod real_registry_smoke { remote_origin: Some("git@github.com:rust-lang/rust.git"), action: Action::CreatePr, }); - assert!(!d.is_allow(), "real registry must deny rust-lang origin writes"); + assert!( + !d.is_allow(), + "real registry must deny rust-lang origin writes" + ); } } diff --git a/robot-repo-automaton/src/fixer.rs b/robot-repo-automaton/src/fixer.rs index 4d74f73a..cffb6214 100644 --- a/robot-repo-automaton/src/fixer.rs +++ b/robot-repo-automaton/src/fixer.rs @@ -44,7 +44,10 @@ pub enum ModifySpec { /// Insert content after a specific line number (1-indexed) InsertAfter { line: usize, content: String }, /// Replace all occurrences of a regex pattern with a replacement string - ReplacePattern { pattern: String, replacement: String }, + ReplacePattern { + pattern: String, + replacement: String, + }, /// Prepend content to the beginning of the file Prepend { content: String }, /// Append content to the end of the file @@ -61,14 +64,10 @@ pub struct Fixer { /// Known binary file extensions that should never be modified const BINARY_EXTENSIONS: &[&str] = &[ - "png", "jpg", "jpeg", "gif", "bmp", "ico", "webp", "svg", - "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", - "zip", "tar", "gz", "bz2", "xz", "7z", "rar", - "exe", "dll", "so", "dylib", "o", "a", - "wasm", "pyc", "class", - "ttf", "otf", "woff", "woff2", "eot", - "mp3", "mp4", "avi", "mkv", "flac", "ogg", "wav", - "db", "sqlite", "sqlite3", + "png", "jpg", "jpeg", "gif", "bmp", "ico", "webp", "svg", "pdf", "doc", "docx", "xls", "xlsx", + "ppt", "pptx", "zip", "tar", "gz", "bz2", "xz", "7z", "rar", "exe", "dll", "so", "dylib", "o", + "a", "wasm", "pyc", "class", "ttf", "otf", "woff", "woff2", "eot", "mp3", "mp4", "avi", "mkv", + "flac", "ogg", "wav", "db", "sqlite", "sqlite3", ]; impl Fixer { @@ -145,7 +144,8 @@ impl Fixer { /// Check whether a file should be treated as binary. fn is_binary(path: &Path, content: &[u8]) -> bool { - let binary_extension = path.extension() + let binary_extension = path + .extension() .and_then(|ext| ext.to_str()) .map(|ext| BINARY_EXTENSIONS.contains(&ext.to_lowercase().as_str())) .unwrap_or(false); @@ -157,7 +157,8 @@ impl Fixer { /// automaton's trusted dependency set. Unknown formats are left alone /// because guessing their grammar would cause false failures. fn validate_source(path: &Path, content: &str) -> Result<()> { - let extension = path.extension() + let extension = path + .extension() .and_then(|ext| ext.to_str()) .unwrap_or_default() .to_ascii_lowercase(); @@ -209,7 +210,8 @@ impl Fixer { /// - `prepend:` - Add content at file beginning /// - `append:` - Add content at file end fn parse_modification(spec: &str) -> Result { - let (kind, payload) = spec.split_once(':') + let (kind, payload) = spec + .split_once(':') .ok_or_else(|| Error::Fix(format!("Invalid modification specification: {spec}")))?; match kind { @@ -217,30 +219,46 @@ impl Fixer { let (line, content) = payload.split_once(':').ok_or_else(|| { Error::Fix("replace-line requires line number and content".into()) })?; - let line: usize = line.parse() + let line: usize = line + .parse() .map_err(|_| Error::Fix(format!("Invalid line number: {line}")))?; - Ok(ModifySpec::ReplaceLine { line, content: content.to_string() }) + Ok(ModifySpec::ReplaceLine { + line, + content: content.to_string(), + }) } "insert-before" => { let (line, content) = payload.split_once(':').ok_or_else(|| { Error::Fix("insert-before requires line number and content".into()) })?; - let line: usize = line.parse() + let line: usize = line + .parse() .map_err(|_| Error::Fix(format!("Invalid line number: {line}")))?; - Ok(ModifySpec::InsertBefore { line, content: content.to_string() }) + Ok(ModifySpec::InsertBefore { + line, + content: content.to_string(), + }) } "insert-after" => { let (line, content) = payload.split_once(':').ok_or_else(|| { Error::Fix("insert-after requires line number and content".into()) })?; - let line: usize = line.parse() + let line: usize = line + .parse() .map_err(|_| Error::Fix(format!("Invalid line number: {line}")))?; - Ok(ModifySpec::InsertAfter { line, content: content.to_string() }) + Ok(ModifySpec::InsertAfter { + line, + content: content.to_string(), + }) } "replace-pattern" => Self::parse_replace_pattern(payload), "replace-pattern-json" => Self::parse_replace_pattern_json(payload), - "prepend" => Ok(ModifySpec::Prepend { content: payload.to_string() }), - "append" => Ok(ModifySpec::Append { content: payload.to_string() }), + "prepend" => Ok(ModifySpec::Prepend { + content: payload.to_string(), + }), + "append" => Ok(ModifySpec::Append { + content: payload.to_string(), + }), _ => Err(Error::Fix(format!("Unknown modification type: {}", spec))), } } @@ -248,34 +266,48 @@ impl Fixer { /// Split a legacy replacement at its final unescaped colon. This preserves /// colons in URL-like regex patterns. Colons in a replacement use `\:`. fn parse_replace_pattern(payload: &str) -> Result { - let separator = payload.char_indices().rev() + let separator = payload + .char_indices() + .rev() .find_map(|(index, character)| { (character == ':' && !is_escaped(payload, index)).then_some(index) }) - .ok_or_else(|| Error::Fix( - "replace-pattern requires a pattern and replacement separated by ':'".into() - ))?; + .ok_or_else(|| { + Error::Fix( + "replace-pattern requires a pattern and replacement separated by ':'".into(), + ) + })?; let pattern = unescape_colons(&payload[..separator]); let replacement = unescape_colons(&payload[separator + 1..]); if pattern.is_empty() { - return Err(Error::Fix("replace-pattern requires a non-empty pattern".into())); + return Err(Error::Fix( + "replace-pattern requires a non-empty pattern".into(), + )); } - Ok(ModifySpec::ReplacePattern { pattern, replacement }) + Ok(ModifySpec::ReplacePattern { + pattern, + replacement, + }) } /// Parse an unambiguous JSON representation of a regex replacement. fn parse_replace_pattern_json(payload: &str) -> Result { - let value: serde_json::Value = serde_json::from_str(payload) - .map_err(|error| Error::Fix(format!("Invalid replace-pattern-json payload: {error}")))?; - let pattern = value.get("pattern") + let value: serde_json::Value = serde_json::from_str(payload).map_err(|error| { + Error::Fix(format!("Invalid replace-pattern-json payload: {error}")) + })?; + let pattern = value + .get("pattern") .and_then(serde_json::Value::as_str) .filter(|pattern| !pattern.is_empty()) .ok_or_else(|| Error::Fix("replace-pattern-json requires a string 'pattern'".into()))?; - let replacement = value.get("replacement") + let replacement = value + .get("replacement") .and_then(serde_json::Value::as_str) - .ok_or_else(|| Error::Fix("replace-pattern-json requires a string 'replacement'".into()))?; + .ok_or_else(|| { + Error::Fix("replace-pattern-json requires a string 'replacement'".into()) + })?; Ok(ModifySpec::ReplacePattern { pattern: pattern.to_string(), @@ -288,7 +320,10 @@ impl Fixer { let mut lines: Vec = content.lines().map(|l| l.to_string()).collect(); match spec { - ModifySpec::ReplaceLine { line, content: new_content } => { + ModifySpec::ReplaceLine { + line, + content: new_content, + } => { if *line == 0 || *line > lines.len() { return Err(Error::Fix(format!( "Line {} out of range (file has {} lines)", @@ -298,7 +333,10 @@ impl Fixer { } lines[*line - 1] = new_content.clone(); } - ModifySpec::InsertBefore { line, content: new_content } => { + ModifySpec::InsertBefore { + line, + content: new_content, + } => { if *line == 0 || *line > lines.len() + 1 { return Err(Error::Fix(format!( "Line {} out of range for insertion (file has {} lines)", @@ -308,7 +346,10 @@ impl Fixer { } lines.insert(*line - 1, new_content.clone()); } - ModifySpec::InsertAfter { line, content: new_content } => { + ModifySpec::InsertAfter { + line, + content: new_content, + } => { if *line == 0 || *line > lines.len() { return Err(Error::Fix(format!( "Line {} out of range for insertion (file has {} lines)", @@ -318,16 +359,24 @@ impl Fixer { } lines.insert(*line, new_content.clone()); } - ModifySpec::ReplacePattern { pattern, replacement } => { - let re = Regex::new(pattern) - .map_err(|e| Error::Fix(format!("Invalid regex pattern '{}': {}", pattern, e)))?; + ModifySpec::ReplacePattern { + pattern, + replacement, + } => { + let re = Regex::new(pattern).map_err(|e| { + Error::Fix(format!("Invalid regex pattern '{}': {}", pattern, e)) + })?; let result = re.replace_all(content, replacement.as_str()); return Ok(result.into_owned()); } - ModifySpec::Prepend { content: new_content } => { + ModifySpec::Prepend { + content: new_content, + } => { lines.insert(0, new_content.clone()); } - ModifySpec::Append { content: new_content } => { + ModifySpec::Append { + content: new_content, + } => { lines.push(new_content.clone()); } } @@ -341,11 +390,7 @@ impl Fixer { } /// Delete a file - fn apply_delete( - &self, - target_path: &Path, - issue: &DetectedIssue, - ) -> Result { + fn apply_delete(&self, target_path: &Path, issue: &DetectedIssue) -> Result { if !target_path.exists() { return Ok(FixResult { issue_id: issue.error_type_id.clone(), @@ -439,19 +484,16 @@ impl Fixer { }); } - let original_content = String::from_utf8(original_bytes) - .map_err(|e| Error::Fix(format!("Failed to decode {}: {}", target_path.display(), e)))?; + let original_content = String::from_utf8(original_bytes).map_err(|e| { + Error::Fix(format!("Failed to decode {}: {}", target_path.display(), e)) + })?; // Parse and apply the modification let spec = Self::parse_modification(modification)?; let new_content = match Self::apply_modification(&original_content, &spec) { Ok(content) => content, Err(e) => { - warn!( - "Modification failed for {}: {}", - target_path.display(), - e - ); + warn!("Modification failed for {}: {}", target_path.display(), e); return Ok(FixResult { issue_id: issue.error_type_id.clone(), success: false, @@ -487,11 +529,7 @@ impl Fixer { atomic_replace(target_path, new_content.as_bytes())?; - info!( - "Modified {}: {}", - target_path.display(), - modification - ); + info!("Modified {}: {}", target_path.display(), modification); Ok(FixResult { issue_id: issue.error_type_id.clone(), @@ -605,11 +643,7 @@ impl Fixer { } /// Disable a workflow (rename to .disabled) - fn apply_disable( - &self, - target_path: &Path, - issue: &DetectedIssue, - ) -> Result { + fn apply_disable(&self, target_path: &Path, issue: &DetectedIssue) -> Result { if !target_path.exists() { return Ok(FixResult { issue_id: issue.error_type_id.clone(), @@ -686,7 +720,9 @@ impl Fixer { // AsciiDoc migration; both spellings render the same template. // Without this arm the fall-through returns String::new() and // the automaton opens a PR that creates the file EMPTY. - "SECURITY.adoc" | "SECURITY.md" => include_str!("../templates/SECURITY.tmpl").to_string(), + "SECURITY.adoc" | "SECURITY.md" => { + include_str!("../templates/SECURITY.tmpl").to_string() + } _ => String::new(), } } @@ -728,7 +764,8 @@ impl Fixer { let canonical_repo = self.repo_path.canonicalize().map_err(|error| { Error::Fix(format!( "Failed to canonicalize repository {} before commit: {}", - self.repo_path.display(), error + self.repo_path.display(), + error )) })?; let repo = Repository::open(&canonical_repo)?; @@ -752,14 +789,7 @@ impl Fixer { let sig = Signature::now("robot-repo-automaton", "robot@hyperpolymath.dev")?; let parent = repo.head()?.peel_to_commit()?; - repo.commit( - Some("HEAD"), - &sig, - &sig, - message, - &tree, - &[&parent], - )?; + repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?; info!("Committed: {}", message); Ok(()) @@ -825,13 +855,19 @@ fn normalise_path(path: &Path) -> PathBuf { fn resolve_target_within_repo(repo_path: &Path, target_path: &Path) -> Result { // Lexically removing `..` before resolving a symlink changes filesystem // semantics. Fix targets must name entries without parent traversal. - if target_path.components().any(|part| part == std::path::Component::ParentDir) { - return Err(Error::Fix("parent traversal is not permitted in fix targets".into())); + if target_path + .components() + .any(|part| part == std::path::Component::ParentDir) + { + return Err(Error::Fix( + "parent traversal is not permitted in fix targets".into(), + )); } let canonical_repo = repo_path.canonicalize().map_err(|error| { Error::Fix(format!( "failed to canonicalize repository {}: {}", - repo_path.display(), error + repo_path.display(), + error )) })?; let absolute_target = if target_path.is_absolute() { @@ -847,7 +883,8 @@ fn resolve_target_within_repo(repo_path: &Path, target_path: &Path) -> Result Result { let mut resolved = ancestor.canonicalize().map_err(|error| { Error::Fix(format!( "failed to canonicalize target ancestor {}: {}", - ancestor.display(), error + ancestor.display(), + error )) })?; for component in missing_components.iter().rev() { @@ -876,27 +914,38 @@ fn resolve_from_existing_ancestor(path: &Path) -> Result { } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { let component = ancestor.file_name().ok_or_else(|| { - Error::Fix(format!("no existing ancestor for target {}", path.display())) + Error::Fix(format!( + "no existing ancestor for target {}", + path.display() + )) })?; missing_components.push(component.to_os_string()); if !ancestor.pop() { return Err(Error::Fix(format!( - "no existing ancestor for target {}", path.display() + "no existing ancestor for target {}", + path.display() ))); } } - Err(error) => return Err(Error::Fix(format!( - "failed to inspect target ancestor {}: {}", - ancestor.display(), error - ))), + Err(error) => { + return Err(Error::Fix(format!( + "failed to inspect target ancestor {}: {}", + ancestor.display(), + error + ))) + } } } } fn is_escaped(value: &str, index: usize) -> bool { - value[..index].bytes().rev() + value[..index] + .bytes() + .rev() .take_while(|byte| *byte == b'\\') - .count() % 2 == 1 + .count() + % 2 + == 1 } fn unescape_colons(value: &str) -> String { @@ -917,7 +966,10 @@ fn unescape_colons(value: &str) -> String { /// over it only after a complete, synced write. fn atomic_replace(target_path: &Path, content: &[u8]) -> Result<()> { let parent = target_path.parent().ok_or_else(|| { - Error::Fix(format!("Target {} has no parent directory", target_path.display())) + Error::Fix(format!( + "Target {} has no parent directory", + target_path.display() + )) })?; let permissions = std::fs::metadata(target_path)?.permissions(); let mut temporary = NamedTempFile::new_in(parent)?; @@ -925,24 +977,30 @@ fn atomic_replace(target_path: &Path, content: &[u8]) -> Result<()> { temporary.as_file_mut().flush()?; temporary.as_file().sync_all()?; temporary.as_file().set_permissions(permissions)?; - temporary.persist(target_path).map_err(|error| Error::Fix(format!( - "Failed to atomically replace {}: {}", - target_path.display(), error.error - )))?; + temporary.persist(target_path).map_err(|error| { + Error::Fix(format!( + "Failed to atomically replace {}: {}", + target_path.display(), + error.error + )) + })?; Ok(()) } /// Stage a complete new file and publish it with no-clobber semantics. fn persist_new_file(target_path: &Path, content: &[u8]) -> std::io::Result<()> { - let parent = target_path.parent().ok_or_else(|| std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("Target {} has no parent directory", target_path.display()), - ))?; + let parent = target_path.parent().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("Target {} has no parent directory", target_path.display()), + ) + })?; let mut temporary = NamedTempFile::new_in(parent)?; temporary.write_all(content)?; temporary.as_file_mut().flush()?; temporary.as_file().sync_all()?; - temporary.persist_noclobber(target_path) + temporary + .persist_noclobber(target_path) .map(|_| ()) .map_err(|error| error.error) } @@ -1011,7 +1069,12 @@ mod tests { std::fs::write(&victim, "preserve").unwrap(); let fixer = Fixer::new(repo, false); for action in [FixAction::Delete, FixAction::Disable] { - let result = fixer.apply(&make_issue("TRAVERSAL"), &make_fix(action, "link/../victim")).unwrap(); + let result = fixer + .apply( + &make_issue("TRAVERSAL"), + &make_fix(action, "link/../victim"), + ) + .unwrap(); assert!(!result.success); assert_eq!(std::fs::read_to_string(&victim).unwrap(), "preserve"); assert!(!root.path().join("victim.disabled").exists()); @@ -1044,7 +1107,10 @@ mod tests { let destination = root.path().join("destination"); std::fs::write(&source, "source").unwrap(); std::fs::write(&destination, "destination").unwrap(); - assert_eq!(rename_noreplace(&source, &destination).unwrap_err().kind(), std::io::ErrorKind::Unsupported); + assert_eq!( + rename_noreplace(&source, &destination).unwrap_err().kind(), + std::io::ErrorKind::Unsupported + ); assert_eq!(std::fs::read_to_string(source).unwrap(), "source"); assert_eq!(std::fs::read_to_string(destination).unwrap(), "destination"); } @@ -1234,7 +1300,10 @@ mod tests { fn test_replace_pattern_preserves_url_colons() { let spec = Fixer::parse_modification("replace-pattern:https?://old:new").unwrap(); match spec { - ModifySpec::ReplacePattern { pattern, replacement } => { + ModifySpec::ReplacePattern { + pattern, + replacement, + } => { assert_eq!(pattern, "https?://old"); assert_eq!(replacement, "new"); } @@ -1246,7 +1315,10 @@ mod tests { fn test_replace_pattern_supports_escaped_replacement_colons() { let spec = Fixer::parse_modification("replace-pattern:old:urn\\:new").unwrap(); match spec { - ModifySpec::ReplacePattern { pattern, replacement } => { + ModifySpec::ReplacePattern { + pattern, + replacement, + } => { assert_eq!(pattern, "old"); assert_eq!(replacement, "urn:new"); } @@ -1258,9 +1330,13 @@ mod tests { fn test_replace_pattern_json_is_unambiguous() { let spec = Fixer::parse_modification( r#"replace-pattern-json:{"pattern":"https?://old","replacement":"urn:new"}"#, - ).unwrap(); + ) + .unwrap(); match spec { - ModifySpec::ReplacePattern { pattern, replacement } => { + ModifySpec::ReplacePattern { + pattern, + replacement, + } => { assert_eq!(pattern, "https?://old"); assert_eq!(replacement, "urn:new"); } @@ -1299,7 +1375,11 @@ mod tests { let result = fixer.apply(&issue, &fix).unwrap(); assert!(!result.success); - assert!(result.error.as_deref().unwrap().contains("Rust syntax validation")); + assert!(result + .error + .as_deref() + .unwrap() + .contains("Rust syntax validation")); assert_eq!(std::fs::read_to_string(&file_path).unwrap(), original); } @@ -1334,7 +1414,10 @@ mod tests { let fix = make_fix(FixAction::Disable, "workflow.yml"); assert!(fixer.apply(&issue, &fix).is_err()); - assert_eq!(std::fs::read_to_string(&source).unwrap(), "active workflow\n"); + assert_eq!( + std::fs::read_to_string(&source).unwrap(), + "active workflow\n" + ); assert_eq!( std::fs::read_to_string(&disabled).unwrap(), "previous disabled workflow\n" diff --git a/robot-repo-automaton/src/fleet.rs b/robot-repo-automaton/src/fleet.rs index 4e52bb13..7137f229 100644 --- a/robot-repo-automaton/src/fleet.rs +++ b/robot-repo-automaton/src/fleet.rs @@ -11,11 +11,11 @@ use crate::detector::DetectedIssue; use crate::error::{Error, Result}; +use chrono; +use dirs; use gitbot_shared_context::{BotId, Context, Finding, Severity as FleetSeverity}; use std::path::PathBuf; use tracing::{debug, info}; -use chrono; -use dirs; /// Fleet coordinator for robot-repo-automaton pub struct FleetCoordinator { @@ -47,13 +47,25 @@ impl FleetCoordinator { } /// Disconnect from fleet (mark robot-repo-automaton as complete) - pub fn disconnect(&mut self, findings_count: usize, errors_count: usize, files_analyzed: usize) -> Result<()> { + pub fn disconnect( + &mut self, + findings_count: usize, + errors_count: usize, + files_analyzed: usize, + ) -> Result<()> { if let Some(ref mut ctx) = self.context { - info!("Disconnecting from gitbot-fleet (findings: {}, errors: {}, files: {})", - findings_count, errors_count, files_analyzed); + info!( + "Disconnecting from gitbot-fleet (findings: {}, errors: {}, files: {})", + findings_count, errors_count, files_analyzed + ); - ctx.complete_bot(BotId::RobotRepoAutomaton, findings_count, errors_count, files_analyzed) - .map_err(|e| Error::Internal(format!("Failed to complete bot: {}", e)))?; + ctx.complete_bot( + BotId::RobotRepoAutomaton, + findings_count, + errors_count, + files_analyzed, + ) + .map_err(|e| Error::Internal(format!("Failed to complete bot: {}", e)))?; } // Persist session to disk (after mutable borrow is released) @@ -122,7 +134,10 @@ impl FleetCoordinator { BotId::RobotRepoAutomaton, "COMPLIANCE-FIXED", FleetSeverity::Info, - &format!("Compliance issues automatically fixed in {}", file.display()), + &format!( + "Compliance issues automatically fixed in {}", + file.display() + ), ) .with_rule_name("Automated Compliance Fix") .with_category("compliance-fix") @@ -165,7 +180,11 @@ impl FleetCoordinator { let finding = Finding::new( BotId::RobotRepoAutomaton, "FIX-OUTCOME", - if success { FleetSeverity::Info } else { FleetSeverity::Warning }, + if success { + FleetSeverity::Info + } else { + FleetSeverity::Warning + }, &format!( "Fix {} for pattern '{}' (confidence: {})", outcome_label, pattern, confidence @@ -281,18 +300,16 @@ mod tests { let mut coordinator = FleetCoordinator::new(); coordinator.connect("test-repo", "/tmp/test-repo").unwrap(); - let issues = vec![ - DetectedIssue { - error_type_id: "MISSING-LICENSE".to_string(), - error_name: "Missing License File".to_string(), - severity: Severity::High, - description: "Repository missing LICENSE file".to_string(), - affected_files: vec![PathBuf::from(".")], - confidence: 1.0, - suggested_fix: "Add LICENSE file".to_string(), - commit_message: "Add LICENSE file".to_string(), - }, - ]; + let issues = vec![DetectedIssue { + error_type_id: "MISSING-LICENSE".to_string(), + error_name: "Missing License File".to_string(), + severity: Severity::High, + description: "Repository missing LICENSE file".to_string(), + affected_files: vec![PathBuf::from(".")], + confidence: 1.0, + suggested_fix: "Add LICENSE file".to_string(), + commit_message: "Add LICENSE file".to_string(), + }]; coordinator.publish_detections(&issues).unwrap(); diff --git a/robot-repo-automaton/src/github.rs b/robot-repo-automaton/src/github.rs index 74ec74b1..1d43216f 100644 --- a/robot-repo-automaton/src/github.rs +++ b/robot-repo-automaton/src/github.rs @@ -104,9 +104,7 @@ impl GitHubClient { Ok(GitHubClient { client, - base_url: base_url - .unwrap_or("https://api.github.com") - .to_string(), + base_url: base_url.unwrap_or("https://api.github.com").to_string(), org: org.to_string(), }) } @@ -167,7 +165,10 @@ impl GitHubClient { pr: CreatePullRequest, ) -> Result { let full = format!("{}/{}", self.org, repo); - crate::registry_guard::check_github_write(&full, crate::exclusion_registry::Action::CreatePr)?; + crate::registry_guard::check_github_write( + &full, + crate::exclusion_registry::Action::CreatePr, + )?; let url = format!("{}/repos/{}/{}/pulls", self.base_url, self.org, repo); @@ -241,15 +242,13 @@ impl GitHubClient { ), }; - let result = self - .client - .post(graphql_url) - .json(&mutation) - .send() - .await?; + let result = self.client.post(graphql_url).json(&mutation).send().await?; if result.status().is_success() { - info!("Enabled auto-merge (squash) on PR #{} in {}", pr_number, repo); + info!( + "Enabled auto-merge (squash) on PR #{} in {}", + pr_number, repo + ); } else { let status = result.status(); let body = result.text().await.unwrap_or_default(); @@ -264,18 +263,14 @@ impl GitHubClient { } /// Create a check run - pub async fn create_check_run( - &self, - repo: &str, - check: CreateCheckRun, - ) -> Result<()> { + pub async fn create_check_run(&self, repo: &str, check: CreateCheckRun) -> Result<()> { let full = format!("{}/{}", self.org, repo); - crate::registry_guard::check_github_write(&full, crate::exclusion_registry::Action::CreateCheckRun)?; + crate::registry_guard::check_github_write( + &full, + crate::exclusion_registry::Action::CreateCheckRun, + )?; - let url = format!( - "{}/repos/{}/{}/check-runs", - self.base_url, self.org, repo - ); + let url = format!("{}/repos/{}/{}/check-runs", self.base_url, self.org, repo); self.client .post(&url) @@ -292,7 +287,10 @@ impl GitHubClient { /// Create an issue pub async fn create_issue(&self, repo: &str, issue: CreateIssue) -> Result { let full = format!("{}/{}", self.org, repo); - crate::registry_guard::check_github_write(&full, crate::exclusion_registry::Action::CreateIssue)?; + crate::registry_guard::check_github_write( + &full, + crate::exclusion_registry::Action::CreateIssue, + )?; let url = format!("{}/repos/{}/{}/issues", self.base_url, self.org, repo); @@ -339,14 +337,12 @@ impl GitHubClient { } /// Create a new branch - pub async fn create_branch( - &self, - repo: &str, - branch_name: &str, - from_sha: &str, - ) -> Result<()> { + pub async fn create_branch(&self, repo: &str, branch_name: &str, from_sha: &str) -> Result<()> { let full = format!("{}/{}", self.org, repo); - crate::registry_guard::check_github_write(&full, crate::exclusion_registry::Action::CreateBranch)?; + crate::registry_guard::check_github_write( + &full, + crate::exclusion_registry::Action::CreateBranch, + )?; let url = format!("{}/repos/{}/{}/git/refs", self.base_url, self.org, repo); diff --git a/robot-repo-automaton/src/hooks.rs b/robot-repo-automaton/src/hooks.rs index 0cd3c723..8496260d 100644 --- a/robot-repo-automaton/src/hooks.rs +++ b/robot-repo-automaton/src/hooks.rs @@ -203,7 +203,8 @@ if ! echo "$commit_msg" | grep -qE "$pattern"; then fi exit 0 -"#.to_string() +"# + .to_string() } /// Install standard hooks for cicd-hyper-a integration @@ -287,9 +288,9 @@ impl PreCommitChecker { r#"(?i)api[_-]?key\s*[:=]\s*["'][^"']+["']"#, r#"(?i)secret[_-]?key\s*[:=]\s*["'][^"']+["']"#, r#"(?i)password\s*[:=]\s*["'][^"']+["']"#, - r"ghp_[a-zA-Z0-9]{36}", // GitHub PAT - r"github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}", // Fine-grained PAT - r"sk-[a-zA-Z0-9]{48}", // OpenAI key + r"ghp_[a-zA-Z0-9]{36}", // GitHub PAT + r"github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}", // Fine-grained PAT + r"sk-[a-zA-Z0-9]{48}", // OpenAI key ]; // This would scan staged files for secret patterns @@ -320,10 +321,7 @@ impl PreCommitChecker { if let Ok(content) = fs::read_to_string(&path) { if !content.contains("SPDX-License-Identifier") { - missing.push(format!( - "Missing SPDX header: {}", - path.display() - )); + missing.push(format!("Missing SPDX header: {}", path.display())); } } } diff --git a/robot-repo-automaton/src/hypatia.rs b/robot-repo-automaton/src/hypatia.rs index 19ce0702..8ff6a803 100644 --- a/robot-repo-automaton/src/hypatia.rs +++ b/robot-repo-automaton/src/hypatia.rs @@ -21,9 +21,9 @@ //! Hypatia learning loop //! ``` -use std::path::{Path, PathBuf}; -use serde::{Deserialize, Serialize}; use dirs; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; /// Configuration for cicd-hyper-a integration #[derive(Debug, Clone, Deserialize, Serialize)] @@ -109,7 +109,10 @@ pub enum RulePattern { /// File path glob pattern FileGlob { glob: String }, /// Content regex pattern - ContentRegex { regex: String, file_glob: Option }, + ContentRegex { + regex: String, + file_glob: Option, + }, /// AST-based pattern (language-specific) Ast { language: String, query: String }, /// Custom detector function @@ -205,11 +208,17 @@ impl CicdHyperAClient { /// Tries the Hypatia API first; falls back to loading rules from the /// local verisimdb-data recipes directory if the API is unavailable. pub async fn fetch_ruleset(&self, ruleset_id: &str) -> crate::Result { - tracing::info!("Fetching ruleset: {} from {}", ruleset_id, self.config.api_url); + tracing::info!( + "Fetching ruleset: {} from {}", + ruleset_id, + self.config.api_url + ); // Try API first let url = format!("{}/rulesets/{}", self.config.api_url, ruleset_id); - match self.http_client.get(&url) + match self + .http_client + .get(&url) .header("Accept", "application/json") .send() .await @@ -259,7 +268,11 @@ impl CicdHyperAClient { self.load_recipes_from(ruleset_id, &recipes_dirs) } - fn load_recipes_from(&self, ruleset_id: &str, recipes_dirs: &[PathBuf]) -> crate::Result { + fn load_recipes_from( + &self, + ruleset_id: &str, + recipes_dirs: &[PathBuf], + ) -> crate::Result { let recipes_dir = recipes_dirs.iter().find(|d| d.is_dir()); let mut rules = Vec::new(); @@ -272,7 +285,8 @@ impl CicdHyperAClient { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) == Some("json") { if let Ok(content) = std::fs::read_to_string(&path) { - if let Ok(recipe) = serde_json::from_str::(&content) { + if let Ok(recipe) = serde_json::from_str::(&content) + { if let Some(rule) = recipe_to_rule(&recipe) { rules.push(rule); } @@ -292,7 +306,14 @@ impl CicdHyperAClient { Ok(Ruleset { id: ruleset_id.to_string(), - name: format!("RSR Compliance ({})", if recipes_dir.is_some() { "local" } else { "built-in" }), + name: format!( + "RSR Compliance ({})", + if recipes_dir.is_some() { + "local" + } else { + "built-in" + } + ), description: "Rhodium Standard Repositories compliance rules".to_string(), version: "2.0.0".to_string(), rules, @@ -618,11 +639,27 @@ impl CicdHyperAClient { /// Convert a verisimdb-data recipe JSON to a Rule. fn recipe_to_rule(recipe: &serde_json::Value) -> Option { let id = recipe.get("id")?.as_str()?.to_string(); - let name = recipe.get("name").and_then(|v| v.as_str()).unwrap_or(&id).to_string(); - let category = recipe.get("category").and_then(|v| v.as_str()).unwrap_or("general").to_string(); - let _description = recipe.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string(); - - let severity = match recipe.get("severity").and_then(|v| v.as_str()).unwrap_or("medium") { + let name = recipe + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(&id) + .to_string(); + let category = recipe + .get("category") + .and_then(|v| v.as_str()) + .unwrap_or("general") + .to_string(); + let _description = recipe + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let severity = match recipe + .get("severity") + .and_then(|v| v.as_str()) + .unwrap_or("medium") + { "critical" => RuleSeverity::Critical, "high" => RuleSeverity::High, "medium" => RuleSeverity::Medium, @@ -632,7 +669,9 @@ fn recipe_to_rule(recipe: &serde_json::Value) -> Option { // Build pattern from recipe detection info let pattern = if let Some(glob) = recipe.get("file_glob").and_then(|v| v.as_str()) { - RulePattern::FileGlob { glob: glob.to_string() } + RulePattern::FileGlob { + glob: glob.to_string(), + } } else { // No file_glob: a content regex is then mandatory -- `?` returns None // for a recipe that declares neither, which is the same contract the @@ -641,17 +680,21 @@ fn recipe_to_rule(recipe: &serde_json::Value) -> Option { let regex = recipe.get("pattern").and_then(|v| v.as_str())?; RulePattern::ContentRegex { regex: regex.to_string(), - file_glob: recipe.get("applies_to").and_then(|v| v.as_str()).map(|s| s.to_string()), + file_glob: recipe + .get("applies_to") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), } }; // Build fix from recipe - let fix = recipe.get("fix_script").and_then(|v| v.as_str()).map(|script| { - RuleFix::Command { + let fix = recipe + .get("fix_script") + .and_then(|v| v.as_str()) + .map(|script| RuleFix::Command { command: script.to_string(), args: vec![], - } - }); + }); Some(Rule { id, @@ -661,14 +704,32 @@ fn recipe_to_rule(recipe: &serde_json::Value) -> Option { pattern, fix, metadata: RuleMetadata { - author: recipe.get("author").and_then(|v| v.as_str()).map(|s| s.to_string()), - updated_at: recipe.get("updated_at").and_then(|v| v.as_str()).map(|s| s.to_string()), - version: recipe.get("version").and_then(|v| v.as_str()).unwrap_or("1.0.0").to_string(), - tags: recipe.get("tags") + author: recipe + .get("author") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + updated_at: recipe + .get("updated_at") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + version: recipe + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("1.0.0") + .to_string(), + tags: recipe + .get("tags") .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) .unwrap_or_default(), - learned: recipe.get("learned").and_then(|v| v.as_bool()).unwrap_or(false), + learned: recipe + .get("learned") + .and_then(|v| v.as_bool()) + .unwrap_or(false), }, }) } @@ -688,10 +749,14 @@ mod tests { std::fs::write(dir.join("rule.json"), recipe.to_string()).unwrap(); } let client = CicdHyperAClient::new(CicdHyperAConfig::default()); - let rules = client.load_recipes_from("local", &[current, legacy.clone()]).unwrap(); + let rules = client + .load_recipes_from("local", &[current, legacy.clone()]) + .unwrap(); assert_eq!(rules.rules.len(), 1); assert_eq!(rules.rules[0].id, "current-rule"); - let rules = client.load_recipes_from("local", &[root.path().join("absent"), legacy]).unwrap(); + let rules = client + .load_recipes_from("local", &[root.path().join("absent"), legacy]) + .unwrap(); assert_eq!(rules.rules[0].id, "legacy-rule"); } diff --git a/robot-repo-automaton/src/lib.rs b/robot-repo-automaton/src/lib.rs index 40b5579d..96e22b24 100644 --- a/robot-repo-automaton/src/lib.rs +++ b/robot-repo-automaton/src/lib.rs @@ -52,29 +52,32 @@ #![forbid(unsafe_code)] pub mod catalog; pub mod confidence; -pub mod hypatia; pub mod config; pub mod detector; pub mod error; pub mod exclusion_registry; pub mod fixer; -pub mod registry_guard; pub mod fleet; pub mod github; pub mod hooks; +pub mod hypatia; +pub mod registry_guard; pub mod skeleton; pub use catalog::ErrorCatalog; pub use confidence::{ConfidenceLevel, FixDecision, ProposedFix, ThresholdConfig}; -pub use exclusion_registry::{Action as ExclusionAction, ActionContext, Decision as ExclusionDecision, DenyAxis, ExclusionRegistry}; -pub use hypatia::{CicdHyperAClient, CicdHyperAConfig, Rule, Ruleset}; pub use config::Config; pub use detector::{DetectedIssue, Detector}; pub use error::{Error, Result}; +pub use exclusion_registry::{ + Action as ExclusionAction, ActionContext, Decision as ExclusionDecision, DenyAxis, + ExclusionRegistry, +}; pub use fixer::{FixResult, Fixer}; pub use fleet::FleetCoordinator; pub use github::GitHubClient; pub use hooks::{HookManager, HookType, PreCommitChecker}; +pub use hypatia::{CicdHyperAClient, CicdHyperAConfig, Rule, Ruleset}; /// Prelude module for common imports pub mod prelude { diff --git a/robot-repo-automaton/src/main.rs b/robot-repo-automaton/src/main.rs index 544c7b43..0e7cde93 100644 --- a/robot-repo-automaton/src/main.rs +++ b/robot-repo-automaton/src/main.rs @@ -17,9 +17,9 @@ //! (rsr-template-repo#48). use clap::{Parser, Subcommand}; -use robot_repo_automaton::prelude::*; -use robot_repo_automaton::github::{GitHubClient, CreatePullRequest}; use robot_repo_automaton::confidence::ThresholdConfig; +use robot_repo_automaton::github::{CreatePullRequest, GitHubClient}; +use robot_repo_automaton::prelude::*; use std::path::{Path, PathBuf}; use tracing::{debug, error, info, warn}; use tracing_subscriber::EnvFilter; @@ -167,7 +167,10 @@ async fn main() -> anyhow::Result<()> { let config = if cli.config.exists() { Some(Config::from_file_with_env(&cli.config)?) } else { - debug!("No config file found at {}, using defaults", cli.config.display()); + debug!( + "No config file found at {}, using defaults", + cli.config.display() + ); None }; @@ -258,7 +261,10 @@ async fn cmd_scan( .map(|c| c.catalog_path.clone()) .unwrap_or_else(|| PathBuf::from("ERROR-CATALOG.scm")); let catalog = ErrorCatalog::from_file(&catalog_path)?; - info!("Loaded {} error types from catalog", catalog.error_types.len()); + info!( + "Loaded {} error types from catalog", + catalog.error_types.len() + ); // Detect issues let detector = Detector::new(repo_path.clone())?; @@ -395,7 +401,10 @@ async fn cmd_fix( FixDecision::Propose { diff_preview } => { let proposal = threshold_config.create_proposal(issue, &error_type.fix, &diff_preview); - info!("Proposing fix for {} (below auto-apply threshold)", issue.error_name); + info!( + "Proposing fix for {} (below auto-apply threshold)", + issue.error_name + ); proposals.push(proposal); } FixDecision::Skip { reason } => { @@ -508,7 +517,8 @@ async fn cmd_fix( Ok(created_pr) => { println!("Created PR #{}: {}", created_pr.number, created_pr.html_url); // Enable auto-merge — PR merges automatically when CI passes - if let Err(e) = github.enable_auto_merge(repo_name, created_pr.number).await { + if let Err(e) = github.enable_auto_merge(repo_name, created_pr.number).await + { debug!("Auto-merge enable failed (non-fatal): {}", e); } } @@ -523,7 +533,10 @@ async fn cmd_fix( warn!("No GitHub config — skipping PR creation"); } } else if create_pr && has_fixes && dry_run { - println!("[DRY RUN] Would create PR with {} fixes", fix_results.iter().filter(|r| r.success).count()); + println!( + "[DRY RUN] Would create PR with {} fixes", + fix_results.iter().filter(|r| r.success).count() + ); } // Create GitHub issues for proposals (below auto-apply threshold) @@ -723,7 +736,8 @@ fn cmd_catalog(path: &Path, severity_filter: Option<&str>) -> anyhow::Result<()> for error_type in &catalog.error_types { if let Some(filter) = severity_filter { - let matches = format!("{:?}", error_type.severity).to_lowercase() == filter.to_lowercase(); + let matches = + format!("{:?}", error_type.severity).to_lowercase() == filter.to_lowercase(); if !matches { continue; } @@ -735,7 +749,10 @@ fn cmd_catalog(path: &Path, severity_filter: Option<&str>) -> anyhow::Result<()> ); println!(" {}", error_type.description); println!(" Category: {}", error_type.category); - println!(" Fix: {:?} {}", error_type.fix.action, error_type.fix.target); + println!( + " Fix: {:?} {}", + error_type.fix.action, error_type.fix.target + ); println!(); } diff --git a/robot-repo-automaton/src/skeleton.rs b/robot-repo-automaton/src/skeleton.rs index b9eb0d90..57f3eff5 100644 --- a/robot-repo-automaton/src/skeleton.rs +++ b/robot-repo-automaton/src/skeleton.rs @@ -144,7 +144,10 @@ mod tests { let dir = tempfile::tempdir().unwrap(); emit(dir.path()).unwrap(); let drift = check(dir.path()).unwrap(); - assert!(drift.is_empty(), "freshly emitted skeleton drifted: {drift:?}"); + assert!( + drift.is_empty(), + "freshly emitted skeleton drifted: {drift:?}" + ); } #[test] diff --git a/robot-repo-automaton/tests/catalog_tests.rs b/robot-repo-automaton/tests/catalog_tests.rs index 4fd6683e..fecf448b 100644 --- a/robot-repo-automaton/tests/catalog_tests.rs +++ b/robot-repo-automaton/tests/catalog_tests.rs @@ -1,9 +1,7 @@ // SPDX-License-Identifier: MPL-2.0 //! Tests for error catalog parsing -use robot_repo_automaton::catalog::{ - DetectionMethod, ErrorCatalog, FixAction, Severity, -}; +use robot_repo_automaton::catalog::{DetectionMethod, ErrorCatalog, FixAction, Severity}; const SAMPLE_CATALOG: &str = r#" (define error-catalog @@ -77,12 +75,21 @@ fn test_parse_detection() { let catalog = ErrorCatalog::parse(SAMPLE_CATALOG).expect("Should parse sample catalog"); let first = &catalog.error_types[0]; - assert!(matches!(first.detection.method, DetectionMethod::FileExistence)); + assert!(matches!( + first.detection.method, + DetectionMethod::FileExistence + )); assert!(first.detection.files.contains(&"test.txt".to_string())); let second = &catalog.error_types[1]; - assert!(matches!(second.detection.method, DetectionMethod::ContentMatch)); - assert_eq!(second.detection.condition, Some("contains API_KEY".to_string())); + assert!(matches!( + second.detection.method, + DetectionMethod::ContentMatch + )); + assert_eq!( + second.detection.condition, + Some("contains API_KEY".to_string()) + ); } #[test] diff --git a/robot-repo-automaton/tests/fixer_tests.rs b/robot-repo-automaton/tests/fixer_tests.rs index 581801a7..a77622e9 100644 --- a/robot-repo-automaton/tests/fixer_tests.rs +++ b/robot-repo-automaton/tests/fixer_tests.rs @@ -1,9 +1,9 @@ // SPDX-License-Identifier: MPL-2.0 //! Tests for fix application - delete, modify, create, rollback +use robot_repo_automaton::catalog::Severity; use robot_repo_automaton::catalog::{Fix, FixAction}; use robot_repo_automaton::detector::DetectedIssue; -use robot_repo_automaton::catalog::Severity; use robot_repo_automaton::fixer::Fixer; use tempfile::TempDir; @@ -423,8 +423,10 @@ fn test_create_fix_is_idempotent() { // Content must not have been overwritten or doubled let content_after_second = std::fs::read_to_string(&file_path).unwrap(); - assert_eq!(content_after_first, content_after_second, - "File content changed on second create — not idempotent"); + assert_eq!( + content_after_first, content_after_second, + "File content changed on second create — not idempotent" + ); } #[test] @@ -454,8 +456,10 @@ fn test_modify_fix_is_idempotent() { let result2 = fixer.apply(&issue, &fix).unwrap(); assert!(result2.success, "Second modify should succeed (idempotent)"); let content_v2 = std::fs::read_to_string(&file_path).unwrap(); - assert_eq!(content_v1, content_v2, - "File content changed on second modify — not idempotent"); + assert_eq!( + content_v1, content_v2, + "File content changed on second modify — not idempotent" + ); } // ========================================================================= @@ -478,7 +482,10 @@ fn test_path_traversal_delete_rejected() { let issue = make_issue("SEC-001"); // Attempt to delete a file using a path traversal sequence - let traversal = format!("../../{}/sentinel.txt", outer.path().file_name().unwrap().to_str().unwrap()); + let traversal = format!( + "../../{}/sentinel.txt", + outer.path().file_name().unwrap().to_str().unwrap() + ); let fix = Fix { action: FixAction::Delete, target: traversal, @@ -491,12 +498,19 @@ fn test_path_traversal_delete_rejected() { // Must fail with a security error, not succeed assert!(!result.success); assert!( - result.error.as_deref().unwrap_or("").contains("outside the repository"), + result + .error + .as_deref() + .unwrap_or("") + .contains("outside the repository"), "Expected path traversal error, got: {:?}", result.error ); // Sentinel file must be untouched - assert!(sentinel.exists(), "Sentinel file was deleted — path traversal succeeded"); + assert!( + sentinel.exists(), + "Sentinel file was deleted — path traversal succeeded" + ); } #[test] @@ -507,7 +521,10 @@ fn test_path_traversal_create_rejected() { let fixer = Fixer::new(temp.path().to_path_buf(), false); let issue = make_issue("SEC-002"); - let traversal = format!("../../../{}/injected.txt", outer.path().file_name().unwrap().to_str().unwrap()); + let traversal = format!( + "../../../{}/injected.txt", + outer.path().file_name().unwrap().to_str().unwrap() + ); let fix = Fix { action: FixAction::Create, target: traversal, @@ -519,7 +536,11 @@ fn test_path_traversal_create_rejected() { let result = fixer.apply(&issue, &fix).unwrap(); assert!(!result.success); assert!( - result.error.as_deref().unwrap_or("").contains("outside the repository"), + result + .error + .as_deref() + .unwrap_or("") + .contains("outside the repository"), "Expected path traversal error, got: {:?}", result.error ); @@ -535,7 +556,10 @@ fn test_path_traversal_modify_rejected() { let fixer = Fixer::new(temp.path().to_path_buf(), false); let issue = make_issue("SEC-003"); - let traversal = format!("../../{}/victim.conf", outer.path().file_name().unwrap().to_str().unwrap()); + let traversal = format!( + "../../{}/victim.conf", + outer.path().file_name().unwrap().to_str().unwrap() + ); let fix = Fix { action: FixAction::Modify, target: traversal, @@ -547,13 +571,20 @@ fn test_path_traversal_modify_rejected() { let result = fixer.apply(&issue, &fix).unwrap(); assert!(!result.success); assert!( - result.error.as_deref().unwrap_or("").contains("outside the repository"), + result + .error + .as_deref() + .unwrap_or("") + .contains("outside the repository"), "Expected path traversal error, got: {:?}", result.error ); // Original file must be untouched let content = std::fs::read_to_string(&victim).unwrap(); - assert_eq!(content, "original=true\n", "Victim file was modified — path traversal succeeded"); + assert_eq!( + content, "original=true\n", + "Victim file was modified — path traversal succeeded" + ); } #[test] @@ -574,6 +605,9 @@ fn test_path_within_repo_is_not_rejected() { let result = fixer.apply(&issue, &fix).unwrap(); // A legitimate in-repo path must succeed - assert!(result.success, "Legitimate in-repo path was incorrectly rejected"); + assert!( + result.success, + "Legitimate in-repo path was incorrectly rejected" + ); assert!(!file_path.exists()); } diff --git a/security.txt b/security.txt new file mode 100644 index 00000000..fb380a39 --- /dev/null +++ b/security.txt @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Compatibility copy for governance checks that only inspect the repository +# root. The canonical published RFC 9116 file is www/.well-known/security.txt. + +Contact: mailto:jonathan.jewell@open.ac.uk +Contact: https://github.com/hyperpolymath/gitbot-fleet/security/advisories +Expires: 2027-12-31T23:59:59.000Z +Preferred-Languages: en +Canonical: https://github.com/hyperpolymath/gitbot-fleet/.well-known/security.txt +Policy: https://github.com/hyperpolymath/gitbot-fleet/blob/main/SECURITY.md diff --git a/shared-context/benches/fleet_benchmarks.rs b/shared-context/benches/fleet_benchmarks.rs index 82ffa20a..f70d710d 100644 --- a/shared-context/benches/fleet_benchmarks.rs +++ b/shared-context/benches/fleet_benchmarks.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MPL-2.0 //! Performance benchmarks for gitbot-fleet operations -use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; use gitbot_shared_context::{BotId, Context, Finding, Severity}; use std::hint::black_box; use std::path::PathBuf; @@ -102,7 +102,12 @@ fn bench_finding_queries(c: &mut Criterion) { 2 => Severity::Info, _ => Severity::Suggestion, }; - ctx.add_finding(Finding::new(bot, &format!("TEST-{:03}", i), severity, "Test")); + ctx.add_finding(Finding::new( + bot, + &format!("TEST-{:03}", i), + severity, + "Test", + )); } group.bench_function("query_by_bot", |b| { @@ -191,7 +196,11 @@ fn bench_health_check(c: &mut Criterion) { ctx.add_finding(Finding::new( BotId::Rhodibot, &format!("TEST-{:03}", i), - if i < 5 { Severity::Error } else { Severity::Warning }, + if i < 5 { + Severity::Error + } else { + Severity::Warning + }, "Test finding", )); } @@ -227,7 +236,11 @@ fn bench_report_generation(c: &mut Criterion) { ctx.add_finding(Finding::new( BotId::Rhodibot, &format!("TEST-{:03}", i), - if i % 4 == 0 { Severity::Error } else { Severity::Warning }, + if i % 4 == 0 { + Severity::Error + } else { + Severity::Warning + }, "Test finding with some detail", )); } diff --git a/shared-context/src/bot.rs b/shared-context/src/bot.rs index d8df54e2..6ba007cc 100644 --- a/shared-context/src/bot.rs +++ b/shared-context/src/bot.rs @@ -64,8 +64,14 @@ impl BotId { /// Get the tier this bot belongs to pub fn tier(&self) -> Tier { match self { - BotId::Rhodibot | BotId::Echidnabot | BotId::Sustainabot | BotId::Oikosbot | BotId::Panicbot => Tier::Verifier, - BotId::Glambot | BotId::Seambot | BotId::Finishbot | BotId::Accessibilitybot => Tier::Finisher, + BotId::Rhodibot + | BotId::Echidnabot + | BotId::Sustainabot + | BotId::Oikosbot + | BotId::Panicbot => Tier::Verifier, + BotId::Glambot | BotId::Seambot | BotId::Finishbot | BotId::Accessibilitybot => { + Tier::Finisher + } BotId::Cipherbot => Tier::Specialist, BotId::RobotRepoAutomaton => Tier::Executor, BotId::Hypatia => Tier::Engine, @@ -134,11 +140,11 @@ impl Tier { /// Get execution order (lower = earlier) pub fn execution_order(&self) -> u8 { match self { - Tier::Engine => 0, // Engine coordinates, runs first + Tier::Engine => 0, // Engine coordinates, runs first Tier::Verifier => 1, Tier::Finisher => 2, - Tier::Specialist => 3, // Specialist runs after verifiers/finishers - Tier::Executor => 4, // Executor runs after all analysis + Tier::Specialist => 3, // Specialist runs after verifiers/finishers + Tier::Executor => 4, // Executor runs after all analysis Tier::Custom => 5, } } diff --git a/shared-context/src/context.rs b/shared-context/src/context.rs index 0a3fc85b..7448fa21 100644 --- a/shared-context/src/context.rs +++ b/shared-context/src/context.rs @@ -1,9 +1,9 @@ // SPDX-License-Identifier: MPL-2.0 //! Shared context for coordinating bot executions +use crate::Result; use crate::bot::{BotExecution, BotId, BotStatus, Tier}; use crate::finding::{Finding, FindingSet}; -use crate::Result; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -191,10 +191,7 @@ impl Context { } let info = BotInfo::standard(*bot); - let deps_satisfied = info - .depends_on - .iter() - .all(|dep| self.bot_completed(*dep)); + let deps_satisfied = info.depends_on.iter().all(|dep| self.bot_completed(*dep)); if deps_satisfied { ready.push(*bot); diff --git a/shared-context/src/exclusion_registry.rs b/shared-context/src/exclusion_registry.rs index 496eb839..c3f449fd 100644 --- a/shared-context/src/exclusion_registry.rs +++ b/shared-context/src/exclusion_registry.rs @@ -172,10 +172,7 @@ impl FromStr for ExclusionRegistry { .into_iter() .map(|v| { let p = Pattern::new(&v.pattern).map_err(|e| { - ExclusionError::Parse(format!( - "invalid vendored pattern {:?}: {e}", - v.pattern - )) + ExclusionError::Parse(format!("invalid vendored pattern {:?}: {e}", v.pattern)) })?; Ok(CompiledPattern { pattern: p, @@ -262,8 +259,12 @@ impl ExclusionRegistry { /// file wins. Covers the common layouts on this machine. fn conventional_paths() -> Vec { vec![ - PathBuf::from("/var/mnt/eclipse/repos/developer-ecosystem/standards/.machine_readable/bot_exclusion_registry.a2ml"), - PathBuf::from("/var/mnt/eclipse/repos/standards/.machine_readable/bot_exclusion_registry.a2ml"), + PathBuf::from( + "/var/mnt/eclipse/repos/developer-ecosystem/standards/.machine_readable/bot_exclusion_registry.a2ml", + ), + PathBuf::from( + "/var/mnt/eclipse/repos/standards/.machine_readable/bot_exclusion_registry.a2ml", + ), PathBuf::from("./standards/.machine_readable/bot_exclusion_registry.a2ml"), PathBuf::from("../standards/.machine_readable/bot_exclusion_registry.a2ml"), PathBuf::from("../../standards/.machine_readable/bot_exclusion_registry.a2ml"), @@ -300,10 +301,7 @@ impl ExclusionRegistry { if matches!(k.as_str(), "off" | "disabled" | "0" | "false" | "halt") { return Decision::Deny { axis: DenyAxis::KillSwitch, - reason: format!( - "HYPATIA_AUTOMATION={} — global kill switch engaged", - kill - ), + reason: format!("HYPATIA_AUTOMATION={} — global kill switch engaged", kill), }; } } @@ -664,7 +662,7 @@ mod real_registry_smoke { #[test] fn real_registry_file_parses_and_has_expected_axes() { let path = std::path::Path::new( - "/var/mnt/eclipse/repos/developer-ecosystem/standards/.machine_readable/bot_exclusion_registry.a2ml" + "/var/mnt/eclipse/repos/developer-ecosystem/standards/.machine_readable/bot_exclusion_registry.a2ml", ); if !path.exists() { eprintln!("skipping: real registry not at {:?}", path); @@ -672,17 +670,28 @@ mod real_registry_smoke { } let r = ExclusionRegistry::load(path).expect("parse real registry"); // Smoke: at least one of each axis. - assert!(!r.external_repos.is_empty(), "external_repos axis populated"); - assert!(!r.vendored_patterns.is_empty(), "vendored_patterns axis populated"); - assert!(!r.remote_origin_patterns.is_empty(), "remote_origin_patterns axis populated"); + assert!( + !r.external_repos.is_empty(), + "external_repos axis populated" + ); + assert!( + !r.vendored_patterns.is_empty(), + "vendored_patterns axis populated" + ); + assert!( + !r.remote_origin_patterns.is_empty(), + "remote_origin_patterns axis populated" + ); } #[test] fn real_registry_blocks_joshuajewell() { let path = std::path::Path::new( - "/var/mnt/eclipse/repos/developer-ecosystem/standards/.machine_readable/bot_exclusion_registry.a2ml" + "/var/mnt/eclipse/repos/developer-ecosystem/standards/.machine_readable/bot_exclusion_registry.a2ml", ); - if !path.exists() { return; } + if !path.exists() { + return; + } let r = ExclusionRegistry::load(path).unwrap(); let d = r.check(&ActionContext { repo_full_name: "JoshuaJewell/IDApTIK", @@ -690,15 +699,20 @@ mod real_registry_smoke { remote_origin: None, action: Action::CreatePr, }); - assert!(!d.is_allow(), "real registry must deny JoshuaJewell/IDApTIK writes"); + assert!( + !d.is_allow(), + "real registry must deny JoshuaJewell/IDApTIK writes" + ); } #[test] fn real_registry_blocks_rust_lang_origin() { let path = std::path::Path::new( - "/var/mnt/eclipse/repos/developer-ecosystem/standards/.machine_readable/bot_exclusion_registry.a2ml" + "/var/mnt/eclipse/repos/developer-ecosystem/standards/.machine_readable/bot_exclusion_registry.a2ml", ); - if !path.exists() { return; } + if !path.exists() { + return; + } let r = ExclusionRegistry::load(path).unwrap(); let d = r.check(&ActionContext { repo_full_name: "somewhere-locally/rust-clone", @@ -706,6 +720,9 @@ mod real_registry_smoke { remote_origin: Some("git@github.com:rust-lang/rust.git"), action: Action::CreatePr, }); - assert!(!d.is_allow(), "real registry must deny rust-lang origin writes"); + assert!( + !d.is_allow(), + "real registry must deny rust-lang origin writes" + ); } } diff --git a/shared-context/src/finding.rs b/shared-context/src/finding.rs index 5967f2c9..bbfb7301 100644 --- a/shared-context/src/finding.rs +++ b/shared-context/src/finding.rs @@ -196,12 +196,10 @@ impl Finding { /// Get location string for display pub fn location_string(&self) -> Option { - self.file.as_ref().map(|f| { - match (self.line, self.column) { - (Some(l), Some(c)) => format!("{}:{}:{}", f.display(), l, c), - (Some(l), None) => format!("{}:{}", f.display(), l), - _ => f.display().to_string(), - } + self.file.as_ref().map(|f| match (self.line, self.column) { + (Some(l), Some(c)) => format!("{}:{}:{}", f.display(), l, c), + (Some(l), None) => format!("{}:{}", f.display(), l), + _ => f.display().to_string(), }) } } @@ -231,7 +229,10 @@ impl FindingSet { /// Get findings by source bot pub fn by_source(&self, source: BotId) -> Vec<&Finding> { - self.findings.iter().filter(|f| f.source == source).collect() + self.findings + .iter() + .filter(|f| f.source == source) + .collect() } /// Get findings by severity @@ -272,7 +273,10 @@ impl FindingSet { /// Get fixable findings pub fn fixable(&self) -> Vec<&Finding> { - self.findings.iter().filter(|f| f.fixable && !f.fixed).collect() + self.findings + .iter() + .filter(|f| f.fixable && !f.fixed) + .collect() } /// Get unfixed findings diff --git a/shared-context/src/health.rs b/shared-context/src/health.rs index 52866c72..12d1cd99 100644 --- a/shared-context/src/health.rs +++ b/shared-context/src/health.rs @@ -172,13 +172,12 @@ impl Context { let anomalies = self.detect_bot_anomalies(*bot_id, execution); let bot_status = determine_bot_health_status(execution, &anomalies); - let duration_ms = if let (Some(start), Some(end)) = - (execution.started_at, execution.completed_at) - { - Some((end - start).num_milliseconds() as u64) - } else { - None - }; + let duration_ms = + if let (Some(start), Some(end)) = (execution.started_at, execution.completed_at) { + Some((end - start).num_milliseconds() as u64) + } else { + None + }; health.insert( format!("{:?}", bot_id), @@ -202,12 +201,7 @@ impl Context { /// Check health of tiers fn check_tier_health(&self) -> HashMap { let mut tier_health = HashMap::new(); - let tiers = [ - Tier::Engine, - Tier::Verifier, - Tier::Finisher, - Tier::Executor, - ]; + let tiers = [Tier::Engine, Tier::Verifier, Tier::Finisher, Tier::Executor]; for tier in tiers { let tier_bots: Vec<_> = self @@ -473,9 +467,7 @@ impl Context { /// Determine overall health status from score and alerts fn determine_overall_status(score: f64, alerts: &[HealthAlert]) -> HealthStatus { - let has_critical = alerts - .iter() - .any(|a| a.severity == AlertSeverity::Critical); + let has_critical = alerts.iter().any(|a| a.severity == AlertSeverity::Critical); let has_errors = alerts.iter().any(|a| a.severity == AlertSeverity::Error); if has_critical || score < 30.0 { @@ -525,9 +517,7 @@ impl FleetHealth { println!("╠════════════════════════════════════════════════════════════════╣"); println!( "║ Status: {} {:?} (Score: {:.1}/100) ║", - status_symbol, - self.status, - self.health_score + status_symbol, self.status, self.health_score ); println!( "║ Checked: {} ║", @@ -553,7 +543,10 @@ impl FleetHealth { // Alerts if !self.alerts.is_empty() { - println!("║ Active Alerts: {} ║", self.alerts.len()); + println!( + "║ Active Alerts: {} ║", + self.alerts.len() + ); for alert in self.alerts.iter().take(5) { let severity_str = match alert.severity { AlertSeverity::Info => "ℹ️ ", @@ -566,10 +559,16 @@ impl FleetHealth { } else { alert.message.clone() }; - println!("║ {} {} ║", severity_str, msg); + println!( + "║ {} {} ║", + severity_str, msg + ); } if self.alerts.len() > 5 { - println!("║ ... and {} more alerts ║", self.alerts.len() - 5); + println!( + "║ ... and {} more alerts ║", + self.alerts.len() - 5 + ); } println!("╠════════════════════════════════════════════════════════════════╣"); } @@ -652,9 +651,11 @@ mod tests { let bot_health = health.bot_health.get("Rhodibot").unwrap(); assert!(!bot_health.anomalies.is_empty()); - assert!(bot_health - .anomalies - .iter() - .any(|a| a.contains("High error rate"))); + assert!( + bot_health + .anomalies + .iter() + .any(|a| a.contains("High error rate")) + ); } } diff --git a/shared-context/src/lib.rs b/shared-context/src/lib.rs index d3fbea6f..df1e8bfc 100644 --- a/shared-context/src/lib.rs +++ b/shared-context/src/lib.rs @@ -64,13 +64,13 @@ pub use panel::{ IsolationTier, PanelContext, PanelFileExpectation, PanelId, PanelManifest, PanelPhase, PanelValidation, WiringCheck, WiringStatus, }; -pub use reporting::{FleetReport, ReportFormat}; -pub use state::{RepoState, SessionState}; -pub use storage::ContextStorage; pub use panel_checker::{ PccObligation, PccPanelResult, PccSummary, find_pcc_binary, pcc_result_to_manifest, pcc_results_to_findings, run_pcc_verify, }; +pub use reporting::{FleetReport, ReportFormat}; +pub use state::{RepoState, SessionState}; +pub use storage::ContextStorage; pub use triangle::{ConfidenceThresholds, DispatchStrategy, TriangleTier}; use thiserror::Error; diff --git a/shared-context/src/panel.rs b/shared-context/src/panel.rs index 022ece11..5b4795e8 100644 --- a/shared-context/src/panel.rs +++ b/shared-context/src/panel.rs @@ -39,7 +39,12 @@ impl PanelId { if name.is_empty() { return Err(PanelError::InvalidName("Panel name cannot be empty".into())); } - if !name.chars().next().map(|c| c.is_uppercase()).unwrap_or(false) { + if !name + .chars() + .next() + .map(|c| c.is_uppercase()) + .unwrap_or(false) + { return Err(PanelError::InvalidName( "Panel name must start with uppercase (PascalCase)".into(), )); @@ -95,8 +100,21 @@ impl std::fmt::Display for PanelId { /// Names that cannot be used for panels — they collide with PanLL internals. pub const RESERVED_NAMES: &[&str] = &[ - "Model", "View", "Update", "Msg", "App", "Main", "Tea", "Panel", "Pane", - "PaneL", "PaneN", "PaneW", "PanelSwitcher", "Storage", "Connection", + "Model", + "View", + "Update", + "Msg", + "App", + "Main", + "Tea", + "Panel", + "Pane", + "PaneL", + "PaneN", + "PaneW", + "PanelSwitcher", + "Storage", + "Connection", ]; // ============================================================================= @@ -233,7 +251,7 @@ impl PanelManifest { !self.validations.iter().any(|v| v.has_errors) } PanelPhase::Provisioned => true, // Activation is a runtime decision - PanelPhase::Active => false, // Terminal phase + PanelPhase::Active => false, // Terminal phase } } @@ -529,10 +547,7 @@ impl PanelFileExpectation { }, Self { role: "Engine tests".into(), - path: PathBuf::from(format!( - "tests/{}_engine_test.js", - snake - )), + path: PathBuf::from(format!("tests/{}_engine_test.js", snake)), required: false, // Checked by finishbot, not rhodibot found: false, }, @@ -579,10 +594,7 @@ impl PanelFileExpectation { /// Get files that are required but missing. pub fn missing_required(files: &[Self]) -> Vec<&Self> { - files - .iter() - .filter(|f| f.required && !f.found) - .collect() + files.iter().filter(|f| f.required && !f.found).collect() } } @@ -666,10 +678,7 @@ pub enum PanelError { /// Panel name is reserved by PanLL internals. ReservedName(String), /// Panel cannot advance to the next phase (conditions not met). - CannotAdvance { - panel: PanelId, - phase: PanelPhase, - }, + CannotAdvance { panel: PanelId, phase: PanelPhase }, /// Panel is already in the Active phase. AlreadyActive(PanelId), /// Panel not found in context. @@ -714,7 +723,7 @@ pub trait PanelContext { /// Update a panel's wiring status. fn update_panel_wiring(&mut self, id: &PanelId, wiring: WiringStatus) - -> Result<(), PanelError>; + -> Result<(), PanelError>; /// Record a bot's validation result for a panel. fn record_panel_validation( @@ -877,9 +886,8 @@ mod tests { #[test] fn test_panel_manifest_lifecycle() { let id = PanelId::new("TestPanel").unwrap(); - let mut manifest = PanelManifest::new_minted( - id, "Test", "A test panel", "test-icon", false, - ); + let mut manifest = + PanelManifest::new_minted(id, "Test", "A test panel", "test-icon", false); assert_eq!(manifest.phase, PanelPhase::Minted); assert!(!manifest.can_advance()); // Wiring not complete @@ -918,7 +926,11 @@ mod tests { let mut ctx = crate::context::Context::new("panll", "/path/to/panll"); let id = PanelId::new("Wharf").unwrap(); let manifest = PanelManifest::new_minted( - id.clone(), "Wharf", "Container orchestration panel", "ship", true, + id.clone(), + "Wharf", + "Container orchestration panel", + "ship", + true, ); ctx.register_panel(manifest); diff --git a/shared-context/src/panel_checker.rs b/shared-context/src/panel_checker.rs index acc3559d..b655456f 100644 --- a/shared-context/src/panel_checker.rs +++ b/shared-context/src/panel_checker.rs @@ -261,8 +261,7 @@ pub fn pcc_results_to_findings(results: &[PccPanelResult], bot: BotId) -> Vec PanelManifest { - let id = PanelId::new(&result.panel_id) - .unwrap_or_else(|_| PanelId(result.panel_id.clone())); + let id = PanelId::new(&result.panel_id).unwrap_or_else(|_| PanelId(result.panel_id.clone())); let phase = match result.state.as_deref() { Some("releasable") => PanelPhase::Active, diff --git a/shared-context/src/reporting.rs b/shared-context/src/reporting.rs index 87411f9d..113cc4e6 100644 --- a/shared-context/src/reporting.rs +++ b/shared-context/src/reporting.rs @@ -102,7 +102,11 @@ impl Context { /// Build fleet summary fn build_summary(&self) -> FleetSummary { let total_bots = self.executions.len(); - let bots_completed = self.executions.values().filter(|e| e.completed_at.is_some()).count(); + let bots_completed = self + .executions + .values() + .filter(|e| e.completed_at.is_some()) + .count(); let bots_in_progress = self .executions .values() @@ -128,9 +132,8 @@ impl Context { // Calculate health score (0-100) let overall_health = if total_bots > 0 { let completion_score = (bots_completed as f64 / total_bots as f64) * 50.0; - let severity_penalty = (critical_findings as f64 * 10.0) - + (errors as f64 * 5.0) - + (warnings as f64 * 1.0); + let severity_penalty = + (critical_findings as f64 * 10.0) + (errors as f64 * 5.0) + (warnings as f64 * 1.0); let finding_score = (50.0 - severity_penalty.min(50.0)).max(0.0); completion_score + finding_score } else { @@ -164,11 +167,12 @@ impl Context { } .to_string(); - let duration_ms = if let (Some(start), Some(end)) = (exec.started_at, exec.completed_at) { - Some((end.timestamp_millis() - start.timestamp_millis()) as u64) - } else { - None - }; + let duration_ms = + if let (Some(start), Some(end)) = (exec.started_at, exec.completed_at) { + Some((end.timestamp_millis() - start.timestamp_millis()) as u64) + } else { + None + }; BotExecutionReport { bot_id: format!("{:?}", bot_id), @@ -226,24 +230,26 @@ impl Context { tier_stats .into_iter() - .map(|(tier, (bots_count, completed_count, total_findings, durations))| { - let avg_duration_ms = if !durations.is_empty() { - durations.iter().sum::() as f64 / durations.len() as f64 - } else { - 0.0 - }; - - ( - format!("{:?}", tier), - TierPerformance { - tier: format!("{:?}", tier), - bots_count, - completed_count, - total_findings, - avg_duration_ms, - }, - ) - }) + .map( + |(tier, (bots_count, completed_count, total_findings, durations))| { + let avg_duration_ms = if !durations.is_empty() { + durations.iter().sum::() as f64 / durations.len() as f64 + } else { + 0.0 + }; + + ( + format!("{:?}", tier), + TierPerformance { + tier: format!("{:?}", tier), + bots_count, + completed_count, + total_findings, + avg_duration_ms, + }, + ) + }, + ) .collect() } @@ -261,15 +267,30 @@ impl Context { // Summary md.push_str("## Summary\n\n"); - md.push_str(&format!("**Overall Health:** {:.1}/100\n\n", report.summary.overall_health)); + md.push_str(&format!( + "**Overall Health:** {:.1}/100\n\n", + report.summary.overall_health + )); md.push_str("| Metric | Value |\n"); md.push_str("|--------|-------|\n"); md.push_str(&format!("| Total Bots | {} |\n", report.summary.total_bots)); - md.push_str(&format!("| Completed | {} |\n", report.summary.bots_completed)); - md.push_str(&format!("| In Progress | {} |\n", report.summary.bots_in_progress)); + md.push_str(&format!( + "| Completed | {} |\n", + report.summary.bots_completed + )); + md.push_str(&format!( + "| In Progress | {} |\n", + report.summary.bots_in_progress + )); md.push_str(&format!("| Pending | {} |\n", report.summary.bots_pending)); - md.push_str(&format!("| Total Findings | {} |\n", report.summary.total_findings)); - md.push_str(&format!("| Critical | {} |\n", report.summary.critical_findings)); + md.push_str(&format!( + "| Total Findings | {} |\n", + report.summary.total_findings + )); + md.push_str(&format!( + "| Critical | {} |\n", + report.summary.critical_findings + )); md.push_str(&format!("| Errors | {} |\n", report.summary.errors)); md.push_str(&format!("| Warnings | {} |\n\n", report.summary.warnings)); @@ -299,7 +320,11 @@ impl Context { for perf in report.tier_performance.values() { md.push_str(&format!( "| {} | {} | {} | {} | {:.0} |\n", - perf.tier, perf.bots_count, perf.completed_count, perf.total_findings, perf.avg_duration_ms + perf.tier, + perf.bots_count, + perf.completed_count, + perf.total_findings, + perf.avg_duration_ms )); } diff --git a/shared-context/src/storage.rs b/shared-context/src/storage.rs index fdd37b73..cc853d36 100644 --- a/shared-context/src/storage.rs +++ b/shared-context/src/storage.rs @@ -123,7 +123,10 @@ impl ContextStorage { let path = self.repos_dir().join(&filename); if !path.exists() { - return Err(ContextError::NotFound(format!("Repo {} not found", repo_name))); + return Err(ContextError::NotFound(format!( + "Repo {} not found", + repo_name + ))); } let json = std::fs::read_to_string(&path)?; @@ -134,7 +137,11 @@ impl ContextStorage { } /// Get or create repository state - pub fn get_or_create_repo_state(&self, repo_name: &str, repo_path: PathBuf) -> Result { + pub fn get_or_create_repo_state( + &self, + repo_name: &str, + repo_path: PathBuf, + ) -> Result { match self.load_repo_state(repo_name) { Ok(state) => Ok(state), Err(ContextError::NotFound(_)) => Ok(RepoState::new(repo_name, repo_path)), diff --git a/shared-context/tests/context_tests.rs b/shared-context/tests/context_tests.rs index 29075ee7..4004ac68 100644 --- a/shared-context/tests/context_tests.rs +++ b/shared-context/tests/context_tests.rs @@ -256,14 +256,19 @@ fn test_bot_tier() { #[test] fn test_finding_builder() { - let finding = Finding::new(BotId::Glambot, "WCAG-1.1.1", Severity::Error, "Missing alt text") - .with_rule_name("Image Alternative Text") - .with_category("accessibility") - .with_file(PathBuf::from("index.html")) - .with_location(42, 15) - .with_element("") - .with_suggestion("Add alt attribute to describe the image") - .fixable(); + let finding = Finding::new( + BotId::Glambot, + "WCAG-1.1.1", + Severity::Error, + "Missing alt text", + ) + .with_rule_name("Image Alternative Text") + .with_category("accessibility") + .with_file(PathBuf::from("index.html")) + .with_location(42, 15) + .with_element("") + .with_suggestion("Add alt attribute to describe the image") + .fixable(); assert_eq!(finding.rule_id, "WCAG-1.1.1"); assert_eq!(finding.rule_name, "Image Alternative Text"); @@ -280,7 +285,10 @@ fn test_finding_location_string() { let finding1 = Finding::new(BotId::Glambot, "TEST-001", Severity::Info, "Test") .with_file(PathBuf::from("test.html")) .with_location(10, 5); - assert_eq!(finding1.location_string(), Some("test.html:10:5".to_string())); + assert_eq!( + finding1.location_string(), + Some("test.html:10:5".to_string()) + ); let finding2 = Finding::new(BotId::Glambot, "TEST-002", Severity::Info, "Test") .with_file(PathBuf::from("test.html")) diff --git a/shared-context/tests/e2e_fleet_coordination_test.rs b/shared-context/tests/e2e_fleet_coordination_test.rs index 032fa37d..fa04e3df 100644 --- a/shared-context/tests/e2e_fleet_coordination_test.rs +++ b/shared-context/tests/e2e_fleet_coordination_test.rs @@ -15,9 +15,7 @@ //! do not start real bot processes; they exercise the full state machine that //! real bots drive. -use gitbot_shared_context::{ - BotId, Context, ContextStorage, Finding, ReportFormat, Severity, -}; +use gitbot_shared_context::{BotId, Context, ContextStorage, Finding, ReportFormat, Severity}; use std::path::PathBuf; use tempfile::TempDir; @@ -33,7 +31,8 @@ fn e2e_single_bot_dispatch_process_collect() { ctx.register_bot(BotId::Rhodibot); // --- DISPATCH phase --- - ctx.start_bot(BotId::Rhodibot).expect("start_bot must succeed for registered bot"); + ctx.start_bot(BotId::Rhodibot) + .expect("start_bot must succeed for registered bot"); // Verify bot is running let execution = ctx.executions.get(&BotId::Rhodibot).unwrap(); @@ -44,12 +43,22 @@ fn e2e_single_bot_dispatch_process_collect() { // --- PROCESS phase (bot adds findings) --- ctx.add_finding( - Finding::new(BotId::Rhodibot, "RSR-MISSING-README", Severity::Error, "Missing README.adoc") - .with_category("structure"), + Finding::new( + BotId::Rhodibot, + "RSR-MISSING-README", + Severity::Error, + "Missing README.adoc", + ) + .with_category("structure"), ); ctx.add_finding( - Finding::new(BotId::Rhodibot, "RSR-MISSING-LICENSE", Severity::Warning, "Missing LICENSE file") - .with_category("legal"), + Finding::new( + BotId::Rhodibot, + "RSR-MISSING-LICENSE", + Severity::Warning, + "Missing LICENSE file", + ) + .with_category("legal"), ); // --- COLLECT phase --- @@ -68,7 +77,10 @@ fn e2e_single_bot_dispatch_process_collect() { assert_eq!(results.len(), 2, "Must be able to retrieve all 2 findings"); let readme_finding = results.iter().find(|f| f.rule_id == "RSR-MISSING-README"); - assert!(readme_finding.is_some(), "RSR-MISSING-README must be present"); + assert!( + readme_finding.is_some(), + "RSR-MISSING-README must be present" + ); assert_eq!(readme_finding.unwrap().severity, Severity::Error); } @@ -83,23 +95,41 @@ fn e2e_multi_bot_dispatch_all_results_aggregated() { let mut ctx = Context::new("e2e-multi-bot", PathBuf::from("/tmp/e2e-multi-bot")); ctx.register_all_bots(); - let verifiers = [BotId::Rhodibot, BotId::Echidnabot, BotId::Sustainabot, BotId::Panicbot]; + let verifiers = [ + BotId::Rhodibot, + BotId::Echidnabot, + BotId::Sustainabot, + BotId::Panicbot, + ]; let finishers = [BotId::Glambot, BotId::Seambot, BotId::Finishbot]; // Run verifiers first (no deps) for &bot in &verifiers { ctx.start_bot(bot).expect("start verifier"); - ctx.add_finding(Finding::new(bot, &format!("{}-001", bot), Severity::Warning, "Verifier finding")); + ctx.add_finding(Finding::new( + bot, + &format!("{}-001", bot), + Severity::Warning, + "Verifier finding", + )); ctx.complete_bot(bot, 1, 0, 10).expect("complete verifier"); } // Verify all verifiers complete before finishers start - assert!(ctx.verifiers_complete(), "All verifiers must be complete before running finishers"); + assert!( + ctx.verifiers_complete(), + "All verifiers must be complete before running finishers" + ); // Run finishers for &bot in &finishers { ctx.start_bot(bot).expect("start finisher"); - ctx.add_finding(Finding::new(bot, &format!("{}-001", bot), Severity::Info, "Finisher finding")); + ctx.add_finding(Finding::new( + bot, + &format!("{}-001", bot), + Severity::Info, + "Finisher finding", + )); ctx.complete_bot(bot, 1, 0, 5).expect("complete finisher"); } @@ -110,19 +140,31 @@ fn e2e_multi_bot_dispatch_all_results_aggregated() { let total_bots = verifiers.len() + finishers.len(); assert_eq!( summary.total_findings, total_bots, - "Summary must aggregate findings from all {} bots", total_bots + "Summary must aggregate findings from all {} bots", + total_bots ); assert_eq!( summary.bots_run, total_bots, - "bots_run must count all {} completing bots", total_bots + "bots_run must count all {} completing bots", + total_bots ); // No errors (only warnings and info) - assert_eq!(summary.total_errors, 0, "No error-severity findings were added"); - assert_eq!(summary.total_warnings, verifiers.len(), "Verifiers each contributed one warning"); + assert_eq!( + summary.total_errors, 0, + "No error-severity findings were added" + ); + assert_eq!( + summary.total_warnings, + verifiers.len(), + "Verifiers each contributed one warning" + ); // No release blocks (no error-severity findings) - assert!(!summary.blocks_release, "Warnings and info should not block release"); + assert!( + !summary.blocks_release, + "Warnings and info should not block release" + ); } // --------------------------------------------------------------------------- @@ -133,7 +175,10 @@ fn e2e_multi_bot_dispatch_all_results_aggregated() { /// complete. The failed bot should not corrupt the session. #[test] fn e2e_bot_failure_does_not_prevent_other_bots() { - let mut ctx = Context::new("e2e-failure-isolation", PathBuf::from("/tmp/e2e-failure-isolation")); + let mut ctx = Context::new( + "e2e-failure-isolation", + PathBuf::from("/tmp/e2e-failure-isolation"), + ); ctx.register_all_bots(); // Rhodibot fails @@ -143,18 +188,31 @@ fn e2e_bot_failure_does_not_prevent_other_bots() { let rhodibot_exec = ctx.executions.get(&BotId::Rhodibot).unwrap(); assert!( - matches!(rhodibot_exec.status, gitbot_shared_context::bot::BotStatus::Failed), + matches!( + rhodibot_exec.status, + gitbot_shared_context::bot::BotStatus::Failed + ), "Rhodibot execution status must be Failed" ); // Echidnabot can still run independently ctx.start_bot(BotId::Echidnabot).unwrap(); - ctx.add_finding(Finding::new(BotId::Echidnabot, "PROOF-VERIFIED", Severity::Info, "Proof verified")); + ctx.add_finding(Finding::new( + BotId::Echidnabot, + "PROOF-VERIFIED", + Severity::Info, + "Proof verified", + )); ctx.complete_bot(BotId::Echidnabot, 1, 0, 5).unwrap(); // Sustainabot can still run independently ctx.start_bot(BotId::Sustainabot).unwrap(); - ctx.add_finding(Finding::new(BotId::Sustainabot, "ECO-001", Severity::Warning, "Outdated deps")); + ctx.add_finding(Finding::new( + BotId::Sustainabot, + "ECO-001", + Severity::Warning, + "Outdated deps", + )); ctx.complete_bot(BotId::Sustainabot, 1, 0, 8).unwrap(); // Panicbot can still run independently @@ -162,16 +220,31 @@ fn e2e_bot_failure_does_not_prevent_other_bots() { ctx.complete_bot(BotId::Panicbot, 0, 0, 3).unwrap(); // Findings from successful bots are intact - assert_eq!(ctx.findings_from(BotId::Echidnabot).len(), 1, "Echidnabot finding must be present"); - assert_eq!(ctx.findings_from(BotId::Sustainabot).len(), 1, "Sustainabot finding must be present"); + assert_eq!( + ctx.findings_from(BotId::Echidnabot).len(), + 1, + "Echidnabot finding must be present" + ); + assert_eq!( + ctx.findings_from(BotId::Sustainabot).len(), + 1, + "Sustainabot finding must be present" + ); // Rhodibot findings are empty (it failed before adding any) - assert_eq!(ctx.findings_from(BotId::Rhodibot).len(), 0, "Failed bot contributed no findings"); + assert_eq!( + ctx.findings_from(BotId::Rhodibot).len(), + 0, + "Failed bot contributed no findings" + ); // The session summary should reflect the failure ctx.complete_session(); let summary = ctx.summary(); - assert_eq!(summary.total_findings, 2, "Only 2 findings from the successful bots"); + assert_eq!( + summary.total_findings, 2, + "Only 2 findings from the successful bots" + ); } // --------------------------------------------------------------------------- @@ -190,8 +263,13 @@ fn e2e_session_persistence_and_reload() { ctx.register_bot(BotId::Rhodibot); ctx.start_bot(BotId::Rhodibot).unwrap(); ctx.add_finding( - Finding::new(BotId::Rhodibot, "RSR-001", Severity::Error, "Persistent error finding") - .with_category("structure"), + Finding::new( + BotId::Rhodibot, + "RSR-001", + Severity::Error, + "Persistent error finding", + ) + .with_category("structure"), ); ctx.complete_bot(BotId::Rhodibot, 1, 1, 20).unwrap(); ctx.complete_session(); @@ -200,17 +278,28 @@ fn e2e_session_persistence_and_reload() { let expected_findings = ctx.findings.len(); // Persist to disk - storage.save_context(&ctx).expect("save_context must succeed"); + storage + .save_context(&ctx) + .expect("save_context must succeed"); // Reload and verify - let loaded = storage.load_context(&session_id).expect("load_context must succeed"); + let loaded = storage + .load_context(&session_id) + .expect("load_context must succeed"); - assert_eq!(loaded.repo_name, "persist-repo", "repo_name must survive round-trip"); assert_eq!( - loaded.findings.len(), expected_findings, + loaded.repo_name, "persist-repo", + "repo_name must survive round-trip" + ); + assert_eq!( + loaded.findings.len(), + expected_findings, "findings count must survive round-trip" ); - assert_eq!(loaded.session_id, session_id, "session_id must be identical after reload"); + assert_eq!( + loaded.session_id, session_id, + "session_id must be identical after reload" + ); // Verify execution records are intact let exec = loaded.executions.get(&BotId::Rhodibot).unwrap(); @@ -233,15 +322,25 @@ fn e2e_report_generation_pipeline() { // Run a subset of bots to populate the context ctx.start_bot(BotId::Rhodibot).unwrap(); ctx.add_finding( - Finding::new(BotId::Rhodibot, "RSR-001", Severity::Error, "Missing SPDX headers on 3 files") - .with_category("licensing"), + Finding::new( + BotId::Rhodibot, + "RSR-001", + Severity::Error, + "Missing SPDX headers on 3 files", + ) + .with_category("licensing"), ); ctx.complete_bot(BotId::Rhodibot, 1, 1, 30).unwrap(); ctx.start_bot(BotId::Glambot).unwrap(); ctx.add_finding( - Finding::new(BotId::Glambot, "SEO-001", Severity::Warning, "README missing meta keywords") - .with_category("seo"), + Finding::new( + BotId::Glambot, + "SEO-001", + Severity::Warning, + "README missing meta keywords", + ) + .with_category("seo"), ); ctx.complete_bot(BotId::Glambot, 1, 0, 5).unwrap(); @@ -259,8 +358,8 @@ fn e2e_report_generation_pipeline() { let json_report = ctx.generate_report(ReportFormat::Json); assert!(!json_report.is_empty(), "JSON report must not be empty"); // Must be valid JSON - let parsed: serde_json::Value = serde_json::from_str(&json_report) - .expect("JSON report must be valid JSON"); + let parsed: serde_json::Value = + serde_json::from_str(&json_report).expect("JSON report must be valid JSON"); assert!( parsed.is_object() || parsed.is_array(), "JSON report must be an object or array at the root" @@ -294,10 +393,18 @@ fn e2e_findings_severity_pipeline_release_gate() { let mut ctx = Context::new("warning-repo", PathBuf::from("/tmp/warning-repo")); ctx.register_bot(BotId::Rhodibot); ctx.start_bot(BotId::Rhodibot).unwrap(); - ctx.add_finding(Finding::new(BotId::Rhodibot, "WARN-001", Severity::Warning, "Minor issue")); + ctx.add_finding(Finding::new( + BotId::Rhodibot, + "WARN-001", + Severity::Warning, + "Minor issue", + )); ctx.complete_bot(BotId::Rhodibot, 1, 0, 5).unwrap(); ctx.complete_session(); - assert!(!ctx.blocks_release(), "Warnings alone must not block release"); + assert!( + !ctx.blocks_release(), + "Warnings alone must not block release" + ); } // Case 3: Error present → blocks release @@ -305,11 +412,19 @@ fn e2e_findings_severity_pipeline_release_gate() { let mut ctx = Context::new("error-repo", PathBuf::from("/tmp/error-repo")); ctx.register_bot(BotId::Rhodibot); ctx.start_bot(BotId::Rhodibot).unwrap(); - ctx.add_finding(Finding::new(BotId::Rhodibot, "ERR-001", Severity::Error, "Critical missing file")); + ctx.add_finding(Finding::new( + BotId::Rhodibot, + "ERR-001", + Severity::Error, + "Critical missing file", + )); ctx.complete_bot(BotId::Rhodibot, 1, 1, 5).unwrap(); ctx.complete_session(); assert!(ctx.blocks_release(), "Error severity must block release"); - assert!(ctx.has_errors(), "has_errors must return true when Error findings present"); + assert!( + ctx.has_errors(), + "has_errors must return true when Error findings present" + ); } // Case 4: Findings marked as fixable are tracked by the pipeline @@ -319,8 +434,13 @@ fn e2e_findings_severity_pipeline_release_gate() { ctx.register_bot(BotId::Rhodibot); ctx.start_bot(BotId::Rhodibot).unwrap(); - let finding = Finding::new(BotId::Rhodibot, "ERR-002", Severity::Error, "Can be auto-fixed") - .fixable(); + let finding = Finding::new( + BotId::Rhodibot, + "ERR-002", + Severity::Error, + "Can be auto-fixed", + ) + .fixable(); let finding_id = finding.id; ctx.add_finding(finding); ctx.complete_bot(BotId::Rhodibot, 1, 1, 5).unwrap(); @@ -328,7 +448,11 @@ fn e2e_findings_severity_pipeline_release_gate() { // Before fixing: blocks release, has errors, has fixable finding assert!(ctx.blocks_release()); assert!(ctx.has_errors()); - assert_eq!(ctx.findings.fixable().len(), 1, "One fixable finding must be tracked"); + assert_eq!( + ctx.findings.fixable().len(), + 1, + "One fixable finding must be tracked" + ); // Mark the finding as fixed via the FindingSet mutation API if let Some(f) = ctx.findings.find_mut(finding_id) { @@ -338,11 +462,13 @@ fn e2e_findings_severity_pipeline_release_gate() { // After fixing: fixable() returns only not-yet-fixed items (now 0), // and unfixed() returns 0 as well (the only finding is now marked fixed). assert_eq!( - ctx.findings.fixable().len(), 0, + ctx.findings.fixable().len(), + 0, "fixable() must return 0 after the finding is marked fixed" ); assert_eq!( - ctx.findings.unfixed().len(), 0, + ctx.findings.unfixed().len(), + 0, "unfixed() must return 0 after fixing" ); } diff --git a/shared-context/tests/fleet_coordination_test.rs b/shared-context/tests/fleet_coordination_test.rs index 5bd3b821..c8ac6cc8 100644 --- a/shared-context/tests/fleet_coordination_test.rs +++ b/shared-context/tests/fleet_coordination_test.rs @@ -15,7 +15,16 @@ fn test_bot_registration() { let mut ctx = Context::new("test-repo", PathBuf::from("/tmp/test-repo")); ctx.register_all_bots(); - let bots = [BotId::Rhodibot, BotId::Echidnabot, BotId::Sustainabot, BotId::Glambot, BotId::Seambot, BotId::Finishbot, BotId::RobotRepoAutomaton, BotId::Hypatia]; + let bots = [ + BotId::Rhodibot, + BotId::Echidnabot, + BotId::Sustainabot, + BotId::Glambot, + BotId::Seambot, + BotId::Finishbot, + BotId::RobotRepoAutomaton, + BotId::Hypatia, + ]; for bot in &bots { assert!(ctx.executions.contains_key(bot)); @@ -28,7 +37,12 @@ fn test_finding_publication() { ctx.register_all_bots(); ctx.start_bot(BotId::RobotRepoAutomaton).unwrap(); - let finding = Finding::new(BotId::RobotRepoAutomaton, "TEST-FINDING", Severity::Warning, "Test finding description"); + let finding = Finding::new( + BotId::RobotRepoAutomaton, + "TEST-FINDING", + Severity::Warning, + "Test finding description", + ); ctx.add_finding(finding); let findings = ctx.findings_from(BotId::RobotRepoAutomaton); @@ -43,8 +57,18 @@ fn test_cross_bot_findings() { ctx.start_bot(BotId::Echidnabot).unwrap(); ctx.start_bot(BotId::RobotRepoAutomaton).unwrap(); - ctx.add_finding(Finding::new(BotId::Echidnabot, "PROOF-VERIFIED", Severity::Info, "Contract verified successfully")); - ctx.add_finding(Finding::new(BotId::RobotRepoAutomaton, "COMPLIANCE-VIOLATION", Severity::Error, "Missing LICENSE file")); + ctx.add_finding(Finding::new( + BotId::Echidnabot, + "PROOF-VERIFIED", + Severity::Info, + "Contract verified successfully", + )); + ctx.add_finding(Finding::new( + BotId::RobotRepoAutomaton, + "COMPLIANCE-VIOLATION", + Severity::Error, + "Missing LICENSE file", + )); assert_eq!(ctx.findings_from(BotId::Echidnabot).len(), 1); assert_eq!(ctx.findings_from(BotId::RobotRepoAutomaton).len(), 1); @@ -57,7 +81,13 @@ fn test_session_lifecycle() { ctx.register_all_bots(); ctx.start_bot(BotId::Seambot).unwrap(); - assert!(ctx.executions.get(&BotId::Seambot).unwrap().started_at.is_some()); + assert!( + ctx.executions + .get(&BotId::Seambot) + .unwrap() + .started_at + .is_some() + ); ctx.complete_bot(BotId::Seambot, 5, 2, 10).unwrap(); let exec = ctx.executions.get(&BotId::Seambot).unwrap(); @@ -75,6 +105,18 @@ fn test_tier_hierarchy() { ctx.start_bot(BotId::Seambot).unwrap(); ctx.start_bot(BotId::RobotRepoAutomaton).unwrap(); - assert!(ctx.executions.get(&BotId::Hypatia).unwrap().started_at.is_some()); - assert!(ctx.executions.get(&BotId::RobotRepoAutomaton).unwrap().started_at.is_some()); + assert!( + ctx.executions + .get(&BotId::Hypatia) + .unwrap() + .started_at + .is_some() + ); + assert!( + ctx.executions + .get(&BotId::RobotRepoAutomaton) + .unwrap() + .started_at + .is_some() + ); } diff --git a/shared-context/tests/property_tests.rs b/shared-context/tests/property_tests.rs index 2f8c6fb1..bcbb4c81 100644 --- a/shared-context/tests/property_tests.rs +++ b/shared-context/tests/property_tests.rs @@ -18,7 +18,7 @@ //! carefully constructed set of representative values spanning boundary conditions. use gitbot_shared_context::{ - BotId, Context, ConfidenceThresholds, DispatchStrategy, Finding, Severity, + BotId, ConfidenceThresholds, Context, DispatchStrategy, Finding, Severity, }; use std::path::PathBuf; @@ -38,13 +38,19 @@ fn prop_any_bot_subset_produces_valid_state() { ctx.register_bot(bot); // Start and complete the single bot - ctx.start_bot(bot).expect("start_bot should not fail for registered bot"); - ctx.complete_bot(bot, 0, 0, 1).expect("complete_bot should not fail"); + ctx.start_bot(bot) + .expect("start_bot should not fail for registered bot"); + ctx.complete_bot(bot, 0, 0, 1) + .expect("complete_bot should not fail"); // State must be internally consistent - assert!(ctx.bot_completed(bot), "Bot should be marked complete after complete_bot"); + assert!( + ctx.bot_completed(bot), + "Bot should be marked complete after complete_bot" + ); assert_eq!( - ctx.findings.len(), 0, + ctx.findings.len(), + 0, "No findings added — findings collection should be empty" ); } @@ -117,7 +123,12 @@ fn prop_findings_are_partitioned_by_bot() { // Each bot's findings slice must only contain that bot's findings for &bot in &all_bots { let bot_findings = ctx.findings_from(bot); - assert_eq!(bot_findings.len(), 3, "Expected exactly 3 findings for {}", bot); + assert_eq!( + bot_findings.len(), + 3, + "Expected exactly 3 findings for {}", + bot + ); for f in &bot_findings { assert_eq!( f.source, bot, @@ -147,8 +158,7 @@ fn prop_confidence_scores_always_yield_valid_strategy() { // Test boundary values and representative points across [0.0, 1.0] let test_values: &[f64] = &[ - 0.0, 0.001, 0.1, 0.3, 0.5, 0.69, 0.70, 0.849, 0.85, 0.94, - 0.95, 0.96, 0.99, 1.0, + 0.0, 0.001, 0.1, 0.3, 0.5, 0.69, 0.70, 0.849, 0.85, 0.94, 0.95, 0.96, 0.99, 1.0, ]; for &confidence in test_values { From c67ae6475db51431ebdec30fb725726144b115d9 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:31:48 +0000 Subject: [PATCH 7/8] docs(fleet): clarify automaton behavior, panel ID validation, and lock-sync helpers --- robot-repo-automaton/src/fixer.rs | 15 +++++++-------- robot-repo-automaton/src/fleet.rs | 8 +++----- robot-repo-automaton/src/hooks.rs | 5 ++++- robot-repo-automaton/src/hypatia.rs | 6 ++++-- scripts/check-lock-sync.sh | 8 ++++++++ shared-context/src/panel.rs | 3 ++- 6 files changed, 28 insertions(+), 17 deletions(-) diff --git a/robot-repo-automaton/src/fixer.rs b/robot-repo-automaton/src/fixer.rs index cffb6214..4c244bbd 100644 --- a/robot-repo-automaton/src/fixer.rs +++ b/robot-repo-automaton/src/fixer.rs @@ -424,10 +424,10 @@ impl Fixer { }) } - /// Modify a file with safety checks and rollback support + /// Modify a file with safety checks. /// - /// Reads the modification specification from the fix, applies it to the file, - /// and rolls back if the modification produces invalid content. + /// Reads the modification specification from the fix and rejects invalid + /// structured content before atomically replacing the original file. fn apply_modify( &self, target_path: &Path, @@ -542,11 +542,8 @@ impl Fixer { /// Create a file with template expansion /// - /// Supports template variables: - /// - `gitbot-fleet` - Repository name - /// - `hyperpolymath` - Repository owner - /// - `{{LICENSE}}` - License identifier - /// - `{{YEAR}}` - Current year + /// Supports the literal `gitbot-fleet` as a repository-name placeholder, + /// plus `{{LICENSE}}`, `{{YEAR}}`, `{{AUTHOR}}`, and `{{EMAIL}}`. fn apply_create( &self, target_path: &Path, @@ -938,6 +935,8 @@ fn resolve_from_existing_ancestor(path: &Path) -> Result { } } +/// Return whether the character at byte offset `index` is escaped by an odd run +/// of backslashes. `index` must be a UTF-8 boundary. fn is_escaped(value: &str, index: usize) -> bool { value[..index] .bytes() diff --git a/robot-repo-automaton/src/fleet.rs b/robot-repo-automaton/src/fleet.rs index 7137f229..96b89218 100644 --- a/robot-repo-automaton/src/fleet.rs +++ b/robot-repo-automaton/src/fleet.rs @@ -157,12 +157,10 @@ impl FleetCoordinator { } } - /// Report fix outcomes to Hypatia's neurosymbolic learning loop. + /// Record a fix outcome as a fleet finding for Hypatia's learning loop. /// - /// When robot-repo-automaton applies a fix (or fails to apply one), - /// this method records the outcome so the learning engine can adjust - /// confidence thresholds and propose new rules based on real-world - /// success/failure rates. + /// If connected, records the pattern, result, fix type, and confidence in + /// the shared context. If disconnected, this is a no-op. pub fn report_fix_outcome( &mut self, pattern: &str, diff --git a/robot-repo-automaton/src/hooks.rs b/robot-repo-automaton/src/hooks.rs index 8496260d..d1dfff4e 100644 --- a/robot-repo-automaton/src/hooks.rs +++ b/robot-repo-automaton/src/hooks.rs @@ -279,7 +279,10 @@ impl PreCommitChecker { Ok(violations) } - /// Check for secrets in staged files + /// Return secret findings for staged files. + /// + /// Secret scanning is not yet implemented, so this currently returns an + /// empty list. pub fn check_secrets(&self) -> Result> { let findings = Vec::new(); diff --git a/robot-repo-automaton/src/hypatia.rs b/robot-repo-automaton/src/hypatia.rs index 8ff6a803..1df21804 100644 --- a/robot-repo-automaton/src/hypatia.rs +++ b/robot-repo-automaton/src/hypatia.rs @@ -205,8 +205,8 @@ impl CicdHyperAClient { /// Fetch a ruleset from the registry. /// - /// Tries the Hypatia API first; falls back to loading rules from the - /// local verisimdb-data recipes directory if the API is unavailable. + /// Tries the Hypatia API first; falls back to local recipes when the request + /// fails, returns a non-success status, or does not contain a valid ruleset. pub async fn fetch_ruleset(&self, ruleset_id: &str) -> crate::Result { tracing::info!( "Fetching ruleset: {} from {}", @@ -268,6 +268,8 @@ impl CicdHyperAClient { self.load_recipes_from(ruleset_id, &recipes_dirs) } + /// Load recipes from the first existing candidate directory, or use the + /// built-in RSR rules when no valid recipes are found. fn load_recipes_from( &self, ruleset_id: &str, diff --git a/scripts/check-lock-sync.sh b/scripts/check-lock-sync.sh index 3cdd782c..9edee642 100755 --- a/scripts/check-lock-sync.sh +++ b/scripts/check-lock-sync.sh @@ -85,6 +85,9 @@ USES_FILE="$(mktemp "${TMPDIR:-/tmp}/check-lock-sync.uses.XXXXXX")" RAW_USES_FILE="$(mktemp "${TMPDIR:-/tmp}/check-lock-sync.raw-uses.XXXXXX")" trap 'rm -f "$USES_FILE" "$RAW_USES_FILE"' EXIT +# Print each valid scalar `uses` value from the workflow at $1, one per line. +# Return non-zero if parsing fails or a value is empty, non-string, or contains +# a tab or line break. extract_uses_yq() { local workflow="$1" @@ -97,6 +100,9 @@ extract_uses_yq() { yq -r '.. | select(type == "!!map" and has("uses")) | .uses | select(type == "!!str")' "$workflow" } +# Print each valid scalar `uses` value from the workflow at $1, one per line. +# Return non-zero if parsing fails or a value is empty, non-string, or contains +# a tab or line break. extract_uses_ruby() { local workflow="$1" @@ -106,6 +112,8 @@ require "yaml" workflow = ARGV.fetch(0) document = YAML.safe_load(File.read(workflow), aliases: true) +# Traverse a parsed workflow and emit its valid scalar `uses` values. +# Abort if a value is empty, non-string, or contains a tab or line break. def emit_uses(node, workflow) case node when Hash diff --git a/shared-context/src/panel.rs b/shared-context/src/panel.rs index 5b4795e8..4150ff79 100644 --- a/shared-context/src/panel.rs +++ b/shared-context/src/panel.rs @@ -34,7 +34,8 @@ use std::path::PathBuf; pub struct PanelId(pub String); impl PanelId { - /// Create a new panel ID, validating PascalCase format. + /// Create a panel ID whose non-reserved name starts with an uppercase letter + /// and contains only alphanumeric characters. pub fn new(name: &str) -> Result { if name.is_empty() { return Err(PanelError::InvalidName("Panel name cannot be empty".into())); From 594ee178e758ca8ccc3a8de3db40a3c10ad9e1c7 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:43:36 +0000 Subject: [PATCH 8/8] docs(robot-repo-automaton): clarify catalog parsing, protected file matching, and recipe requirements --- robot-repo-automaton/src/catalog.rs | 4 ++++ robot-repo-automaton/src/confidence.rs | 8 ++++---- robot-repo-automaton/src/hypatia.rs | 3 +++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/robot-repo-automaton/src/catalog.rs b/robot-repo-automaton/src/catalog.rs index 2ea05282..3593f2d1 100644 --- a/robot-repo-automaton/src/catalog.rs +++ b/robot-repo-automaton/src/catalog.rs @@ -155,6 +155,10 @@ impl ErrorCatalog { }) } + /// Extract catalogue entries from a quoted `define` form or an already + /// unwrapped list. + /// + /// Returns a catalogue-parse error when `value` is not a non-empty list. fn find_catalog_content(value: &Value) -> Result> { // Handle (define error-catalog '(...)) structure if let Some(cons) = value.as_cons() { diff --git a/robot-repo-automaton/src/confidence.rs b/robot-repo-automaton/src/confidence.rs index 84aebdad..842871b3 100644 --- a/robot-repo-automaton/src/confidence.rs +++ b/robot-repo-automaton/src/confidence.rs @@ -298,11 +298,11 @@ impl ThresholdConfig { } } -/// Check if a file is protected from bot deletion or modification. +/// Return whether a target matches a protected basename, extension, or +/// directory marker used to lower confidence for automated fixes. /// -/// Protected files include project state, documentation, and checkpoint files -/// that bots should NEVER delete or overwrite. These represent intentional -/// project work that cannot be regenerated from templates. +/// Basenames are matched case-insensitively; extension and directory matches +/// are case-sensitive. fn is_protected_file(target: &str) -> bool { let basename = target.rsplit('/').next().unwrap_or(target); diff --git a/robot-repo-automaton/src/hypatia.rs b/robot-repo-automaton/src/hypatia.rs index 1df21804..c0502857 100644 --- a/robot-repo-automaton/src/hypatia.rs +++ b/robot-repo-automaton/src/hypatia.rs @@ -639,6 +639,9 @@ impl CicdHyperAClient { } /// Convert a verisimdb-data recipe JSON to a Rule. +/// +/// Returns `None` unless the recipe has a string `id` and either a string +/// `file_glob` or `pattern`. fn recipe_to_rule(recipe: &serde_json::Value) -> Option { let id = recipe.get("id")?.as_str()?.to_string(); let name = recipe