diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index c500ec1..86222b1 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -12,6 +12,7 @@ workflows: - 'actions/deploy-pages@v5.0.1' - 'actions/upload-pages-artifact@v5.0.0' - 'haskell-actions/setup@v2.12.0' + '.github/workflows/changelog.yml': [] '.github/workflows/codeql.yml': - 'actions/checkout@v7.0.1' - 'github/codeql-action@v4.38.0' @@ -30,6 +31,8 @@ workflows: - 'actions/upload-pages-artifact@v5.0.0' '.github/workflows/push-email-notify.yml': - 'hyperpolymath/smtp-notify-action@v0.3.0' + '.github/workflows/rust-ci.yml': + - 'actions/checkout@v7.0.1' '.github/workflows/scorecard.yml': [] '.github/workflows/secret-scanner.yml': [] dependencies: diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..58fd258 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: MPL-2.0 +# CHANGELOG automation (issue #100): regenerates CHANGELOG.md from +# conventional commits on every push to main and commits it straight back +# (commit-back). Main has no branch protection (verified 2026-09-21), so no +# PR round-trip is needed; if protection is ever enabled, switch `mode` to +# `pr-back`. Uses the canonical cliff.toml from standards (no local +# cliff.toml by design — one config for the estate). CHANGELOG.adoc is +# retained as a frozen pointer to the generated CHANGELOG.md. +name: changelog +on: + push: + branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read +jobs: + changelog: + permissions: + contents: write + pull-requests: write + # Pinned to standards@main as of 2026-09-21 (commits/changelog: #886). + uses: hyperpolymath/standards/.github/workflows/changelog-reusable.yml@b77c53c85e5734756a793497845735485c9dc933 + with: + mode: commit-back diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml new file mode 100644 index 0000000..36fbbcb --- /dev/null +++ b/.github/workflows/rust-ci.yml @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: MPL-2.0 +# Root Rust CI gate for the aletheia crate. +# +# Restored 2026-09-21: the previous gate was deleted in the commit that +# moved the crate to aletheia/ and never re-pointed, so main has had no +# Rust CI since. This version re-points every step at aletheia/ and adds +# the clippy -D warnings job (the blocking gate half of issue #125). +# +# Action refs are tags resolved by .github/workflows/actions.lock (the +# estate mechanism — governance verifies the lock and forbids inline SHAs +# alongside it). Every job below has a same-named recipe in the root +# Justfile; keep them in lockstep so `just check` means the same thing +# locally as this gate. Toolchain is the runner's ambient Rust (the crate +# declares rust-version 1.80; there is deliberately no pinned toolchain +# action here, to keep the lockfile surface to checkout alone). No +# #![allow(dead_code)] workarounds: dead code here is either wired or +# deleted (see the never-allow-dead-code landmine in STATE.a2ml). +name: rust-ci +on: + push: + branches: [main] + paths: + - 'aletheia/**' + - 'Justfile' + - '.github/workflows/rust-ci.yml' + pull_request: + paths: + - 'aletheia/**' + - 'Justfile' + - '.github/workflows/rust-ci.yml' +permissions: + contents: read +concurrency: + group: rust-ci-${{ github.ref }} + cancel-in-progress: true +defaults: + run: + working-directory: aletheia +jobs: + build: + name: build (debug + release) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7.0.1 + - name: Toolchain versions + run: cargo --version && rustc --version + - name: Build all targets (debug, locked) + run: cargo build --locked --all-targets + - name: Build release (locked) + run: cargo build --locked --release + test: + name: test (unit + integration) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7.0.1 + - name: Full test suite (locked) + run: cargo test --locked + fmt: + name: fmt --check + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7.0.1 + - name: Check formatting + run: cargo fmt --check + clippy: + name: clippy -D warnings + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7.0.1 + - name: Lint all targets, warnings denied + run: cargo clippy --locked --all-targets -- -D warnings + deps-check: + name: zero-dependency check + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7.0.1 + - name: Enforce the zero-dependency RSR Bronze constraint + run: | + if cargo tree --depth 1 | tail -n +2 | grep -q '[a-z]'; then + echo "ERROR: Aletheia must have zero dependencies (see aletheia/CLAUDE.md)" + cargo tree --depth 1 + exit 1 + fi + echo "OK: zero dependencies" diff --git a/.hypatia-ignore b/.hypatia-ignore index 907741b..7befcdb 100644 --- a/.hypatia-ignore +++ b/.hypatia-ignore @@ -1,33 +1,23 @@ # SPDX-License-Identifier: MPL-2.0 # -# Estate-policy exemptions for legacy demonstration files under -# absolute-zero/ (submodule, pinned to hyperpolymath/absolute-zero) and -# aletheia/ subdirs. These pre-date the estate-wide -# ReScript→AffineScript migration; the absolute-zero/ entries mirror -# that repo's own .hypatia-ignore (kept here defensively in case -# hypatia scans through the submodule boundary). They are -# demonstration scaffolds, not load-bearing code paths. +# Hypatia scanner exemptions for maa-framework. There are currently NO +# active exemptions: this file is intentionally rules-free, kept as an +# audit record so the absence of exemptions is a deliberate state rather +# than a missing file. # -# TODO 2026-Q3: migrate these to AffineScript and drop the exemptions. - -cicd_rules/banned_language_file:absolute-zero/src/AuditTrail.res -cicd_rules/banned_language_file:absolute-zero/interpreters/rescript/malbolgeInterpreter.res -cicd_rules/banned_language_file:absolute-zero/examples/SafeDOMExample.res -cicd_rules/banned_language_file:aletheia/examples/SafeDOMExample.res - -# Go-language demonstration of "nop" semantics in the absolute-zero -# subdirectory. Mirrors the file in the standalone absolute-zero repo's -# own .hypatia-ignore. -cicd_rules/banned_language_file:absolute-zero/examples/go/nop.go - -# Java/Kotlin/Swift demonstration files in the absolute-zero examples -# tree. Same rationale as the .res/.go entries above — these are part of -# the vendored language-demo subtree, not load-bearing code. Will -# collapse to a single `absolute-zero/examples/**` file_pattern entry in -# .hypatia-baseline.json once standards#180 (file_pattern glob matching -# in in_baseline()) merges. -cicd_rules/banned_language_file:absolute-zero/examples/java/Nop.java -cicd_rules/banned_language_file:absolute-zero/examples/java/BalancedOps.java -cicd_rules/banned_language_file:absolute-zero/examples/kotlin/Nop.kt -cicd_rules/banned_language_file:absolute-zero/examples/kotlin/BalancedOps.kt -cicd_rules/banned_language_file:absolute-zero/examples/swift/Nop.swift +# HISTORY (pruned 2026-09-21, issue #98): +# - 10 cicd_rules/banned_language_file entries (9 absolute-zero/ demo +# files + aletheia/examples/SafeDOMExample.res) were dropped after +# verifying every referenced path dead at the pinned submodule commit: +# the ReScript->AffineScript migration landed upstream and the vendored +# demo subtree is gone. The TODO 2026-Q3 comment and the standards#180 +# note went with them (standards#180 has since merged — file_pattern +# globs are available if per-file exemptions ever return). +# - Upstream follow-up lives in the owning repo, not here: +# hyperpolymath/absolute-zero#77 (prune that repo's own stale entries). +# +# VERIFY (re-run before adding any exemption back): +# git submodule status absolute-zero # note the pinned commit +# for f in ; do test -e "$f" && echo "ALIVE: $f"; done +# An exemption for a path that does not exist at the pinned commit is +# theater, not coverage — do not re-add dead entries. diff --git a/.machine_readable/6a2/STATE.a2ml b/.machine_readable/6a2/STATE.a2ml index ad666ca..34bca5c 100644 --- a/.machine_readable/6a2/STATE.a2ml +++ b/.machine_readable/6a2/STATE.a2ml @@ -1,15 +1,15 @@ # SPDX-License-Identifier: MPL-2.0 # STATE.a2ml — Project state checkpoint -# Last updated: 2026-07-29 (CI-reality checkpoint; previous: 2026-06-12 governance) +# Last updated: 2026-09-21 (all-open-issues campaign: #98, #100, #102, #124, #125 closed; previous: 2026-07-29 CI-reality checkpoint) # # Human-readable counterpart: docs/STATE-OF-PLAY-2026-07-29.adoc [metadata] project = "maa-framework" version = "0.1.0" -last-updated = "2026-07-29" -status = "CI is genuinely green and each gate is proven falsifiable; aletheia CLI incomplete and blocked on one design ruling" -measured-against = "measured 2026-07-29 vs origin/main 7d964ee; re-verified 2026-08-03 vs origin/main 2bf7a13 — all figures still hold" +last-updated = "2026-09-21" +status = "Aletheia CLI built to the RSR v2 SSOT (111/111 tests, clippy blocking); root rust-ci restored; changelog automated; all 5 open issues closed" +measured-against = "measured 2026-07-29 vs origin/main 7d964ee; re-verified 2026-08-03 vs 2bf7a13; re-measured 2026-09-21 on fix/all-open-issues-98-100-102-124-125 vs main b77c53c" [project-context] name = "maa-framework" @@ -17,18 +17,18 @@ completion-percentage = 50 phase = "Governance & estate standardization (post-CRG-C; spec/implementation phases per ROADMAP.adoc still open)" [testing-status] -aletheia-unit-tests = "29 pass, 0 fail (was 26; +3 for SHA-pin detection in PR #144)" -aletheia-integration-tests = "2 pass, 27 FAIL — expected; the suite specifies an unbuilt CLI (issue #124)" -aletheia-clippy = "23 findings, NOT a blocking gate (issue #125, blocked on #124)" +aletheia-unit-tests = "66 pass, 0 fail (2026-09-21: CLI rebuild + lock-aware pins + Bun carve-out)" +aletheia-integration-tests = "45 pass, 0 fail — CLI built to the RSR v2 SSOT; issue #124 closed 2026-09-21" +aletheia-clippy = "0 findings; `clippy --all-targets -- -D warnings` blocks in rust-ci.yml + `just lint` (issue #125 closed 2026-09-21)" absolute-zero-tests = "13 brainfuck + 5 whitespace unit, 10 E2E, 11 property, 11 aspect — run in the upstream repo, not here" benchmarks = "criterion-based (absolute-zero/benches/cno_benchmarks.rs)" -test-result = "aletheia unit + fmt + zero-dep GREEN on main via root rust-ci.yml" -integration-test-caveat = "The 27 failures are NOT flaky and NOT a regression. tests/integration_tests.rs asserts on stdout SUBSTRINGS, so it is a UI contract, not a specification — every assertion can be satisfied by checks that verify nothing. Do not 'fix' it by writing checks to match the strings; see [open-questions].rsr-source-of-truth" +test-result = "ON THIS BRANCH: build + full suite (66+45) + fmt + clippy -D + zero-dep all GREEN via `just check`; root rust-ci.yml restored to run the same on main" +integration-test-caveat = "RETIRED 2026-09-21 (the warning was heeded): the suite still asserts on stdout substrings (a UI contract), but the checks were built to the upstream SSOT criteria — each cites its v2 criterion — not to the strings. Keep that direction: SSOT first, strings second." note = "absolute-zero/ is a git submodule since PR #89; aletheia/ is VENDORED plain files, not a submodule" [ci-status] # Measured 2026-07-29 on origin/main 7d964ee -rust-ci = "PASS — build debug+release, 29 unit tests, cargo fmt, zero-dependency assertion" +rust-ci = "RESTORED 2026-09-21 (the tree-move commit had deleted it without re-pointing): 5 jobs — build, full test suite, fmt, clippy -D warnings, zero-dep — each mirrored by a root Justfile recipe" governance = "PASS — 10 jobs; workflow linter and staleness both green" codeql = "PASS" secret-scanner = "PASS" @@ -40,7 +40,7 @@ boj-trigger = "PASS" disabled-manually = "GitHub Pages (Ddraig SSG), Instant Sync, Push email notification — these report NOTHING; a green board is not full coverage" [gating-reality] -only-real-rust-gate = ".github/workflows/rust-ci.yml AT THE REPOSITORY ROOT (added 2026-07-21 in PR #123)" +only-real-rust-gate = ".github/workflows/rust-ci.yml AT THE REPOSITORY ROOT (added 2026-07-21 in PR #123; deleted by the tree-move commit; restored 2026-09-21 re-pointed at aletheia/ with clippy blocking)" inert-workflows = "The 16 files under aletheia/.github/workflows/ have NEVER executed. GitHub Actions reads .github/workflows/ at the repository ROOT ONLY, and aletheia/ is vendored (mode 100644), not a submodule. Editing them has no effect on CI." cost-of-that-gap = "b5322c2 (2026-06-17) dropped a brace in aletheia/src/config.rs; main did not compile FOR OVER A MONTH because nothing was compiling it. Repaired in PR #123." local-gates = "just build/test/fmt/lint/deps-check/check now run the same commands as CI (PR #144, closes #99). Before that every recipe echoed 'not configured yet' and exited 0." @@ -84,6 +84,15 @@ pr-143 = "restored guix.scm identity + licence after the squisher-corpus clobber pr-144 = "aletheia SHA-pin check made real; root Justfile wired (closes #99); PROOF-NEEDS.md and aletheia/CLAUDE.md corrected" pr-145 = "removed the stray worktree gitlink breaking every recursive checkout — MERGED; OSSF Scorecard green on main since" +[recent-work-2026-09] +# The all-open-issues campaign, 2026-09-21. All five open issues closed. +issue-124 = "Aletheia CLI rebuilt on the RSR v2 SSOT (standards rsr-criteria-v2.a2ml v2.1.0-draft): 26-check offline subset, TOML config, human/JSON/SARIF/HTML/badge/quiet/verbose output, exit codes 0-4; 66 unit + 45 integration green" +issue-125 = "Clippy findings wired away (no allow(dead_code)); `clippy --all-targets -- -D warnings` blocks in rust-ci.yml + `just lint`" +issue-102 = "Root Justfile declared canonical; contractiles/Justfile stub (fake gates) replaced by a forwarding shim; repaired crg-grade/crg-badge (Make-style $$ broke every run)" +issue-100 = "changelog.yml adopted (commit-back); CHANGELOG.md seeded; CHANGELOG.adoc frozen as pointer" +issue-98 = ".hypatia-ignore pruned to a comments-only audit record (all 10 entries verified dead); upstream residue in absolute-zero#77" +rust-ci-restore = "Root rust-ci.yml restored re-pointed at aletheia/ (5 jobs mirroring `just check`); pin check taught the estate tags+actions.lock mechanism; Bun lockfile carve-out for no-node-runtime" + [incidents] squisher-corpus-clobber = "An automated sweep on 2026-07-26 ~16:00 BST copied hyper-repos/squisher-corpus/guix.scm BYTE-FOR-BYTE into a large number of estate repos instead of generating one per repo. Its own commit message names the bug: 'chore: update guix.scm from squisher-corpus'. Here it rewrote the package identity AND replaced MPL-2.0 with PMPL-1.0-or-later. Fixed in #143." clobber-is-a-policy-violation = "absolute-zero/.claude/CLAUDE.md lists PMPL-1.0 / PMPL-1.0-or-later under BANNED ('License: MPL-2.0 only'), and [recent-work].license-migration below records an estate-wide migration AWAY from PMPL to MPL-2.0. The licence half was a policy violation, not merely an error." @@ -93,7 +102,7 @@ dead-sha-pin-check = "aletheia's own Silver-level 'GitHub Actions SHA pinning' c [open-questions] # These need a human ruling; they are not research tasks. -rsr-source-of-truth = "BLOCKS #124 AND #125. What defines RSR compliance — hypatia's rsr-conformance oracle, a spec in standards, or aletheia's integration tests? If the oracle is canonical, the honest answer may be for aletheia to CONSUME it and DELETE the aspirational tests rather than build to their strings." +rsr-source-of-truth = "ANSWERED 2026-09-21 (closed #124 + #125): hypatia's rsr-conformance oracle is normative; the criteria text lives in standards 0-canon/rsr/rsr-criteria-v2.a2ml; aletheia evaluates the offline file-presence subset (26 checks) and labels itself non-normative. The tests were kept as the UI contract and the checks were built to the SSOT, not the strings." absolute-zero-pin = "Is 87902bb7 the intended CNO state? The re-pin was a SEMANTIC BUMP, not a restore — the original pin ad085baa was orphaned by an upstream force-push and its content is unrecoverable, so this cannot be verified from inside the repo." submodule-or-vendored = "Settle permanently. The repo has oscillated three times (#89 -> bef4c92 -> #117). While absolute-zero accepts force-pushes to main, ANY pin will be orphaned again by the next rollout." aletheia-licence = "Is PR #135's move of aletheia to PMPL-1.0-or-later + CC-BY-SA-4.0 intended? It runs against the written ban noted in [incidents].clobber-is-a-policy-violation." @@ -101,16 +110,14 @@ aletheia-licence = "Is PR #135's move of aletheia to PMPL-1.0-or-later + CC-BY-S [blockers] spec-uploads = "ROADMAP Phase 1 (specification documents for framework, Oblibeny, Aletheia) not yet uploaded" proof-debt = "See [proof-state]. Note the older figure in docs/proof-debt.md (80 Coq Axiom/Admitted + 54 Lean markers) is ESTATE/vendored scope; absolute-zero itself measures 0 Admitted + 23 Axiom as of 2026-07-29." -aletheia-cli = "issue #124 — 27/29 integration tests fail; blocked on [open-questions].rsr-source-of-truth" -aletheia-clippy = "issue #125 — 23 findings; blocked on #124 because the dead code IS the missing feature" [next-actions] -resolve-rsr-source-of-truth = "Answer [open-questions].rsr-source-of-truth — it unblocks both #124 and #125" +resolve-rsr-source-of-truth = "RESOLVED 2026-09-21: oracle normative, criteria in standards SSOT, aletheia takes the offline subset (see [open-questions].rsr-source-of-truth)" empty-merged-prs = "#146 (ci(codeql) cron weekly->monthly) and #147 (Worktree maa ci repair) are BOTH EMPTY — git diff between parent and merge is blank. #146 stated change was already present. A merged PR title is not evidence the change happened; check git diff --stat." soundness-axioms = "Fix or delete prob_nonneg, prob_normalized, shannon_entropy_maximum while they are still unused" -changelog-automation = "adopt changelog-reusable.yml (standards#206) so CHANGELOG.md stays in sync" -hypatia-ignore-prune = "drop stale aletheia/examples/SafeDOMExample.res entry (file deleted in #74); migrate demo exemptions per .hypatia-ignore TODO 2026-Q3" -submodule-docs = "document `git submodule update --init` for absolute-zero/ in QUICKSTART-DEV" +changelog-automation = "RESOLVED 2026-09-21 (#100): changelog.yml adopted (commit-back), CHANGELOG.md seeded with the exact reusable recipe, CHANGELOG.adoc frozen as a pointer" +hypatia-ignore-prune = "RESOLVED 2026-09-21 (#98): all 10 entries verified dead at the pinned submodule commit; .hypatia-ignore is a comments-only audit record, upstream residue in absolute-zero#77" +submodule-docs = "RESOLVED 2026-09-21: clone block in QUICKSTART-DEV documents `git submodule update --init`" [landmines] root-only-workflows = "GitHub Actions reads .github/workflows/ at the repository ROOT ONLY. The 16 files under aletheia/.github/workflows/ are inert." diff --git a/.machine_readable/bot_directives/hypatia.a2ml b/.machine_readable/bot_directives/hypatia.a2ml index d7e570e..c310722 100644 --- a/.machine_readable/bot_directives/hypatia.a2ml +++ b/.machine_readable/bot_directives/hypatia.a2ml @@ -4,7 +4,7 @@ [metadata] repo = "maa-framework" -last-updated = "2026-06-12" +last-updated = "2026-09-21" owner = "hyperpolymath" [scanner] @@ -13,10 +13,11 @@ workflow = ".github/workflows/hypatia-scan.yml" # thin wrapper of hyperpolymath report-format = "logtalk" # per 6a2/NEUROSYM.a2ml [hypatia-config] [accepted-findings] -# Known, deliberate exemptions — mirrored from .hypatia-ignore with reasons. -banned-language-absolute-zero-demos = "cicd_rules/banned_language_file entries for absolute-zero/ (.res/.go/.java/.kt/.swift demo files): absolute-zero/ is a submodule pinned to hyperpolymath/absolute-zero; the entries mirror that repo's own .hypatia-ignore and are kept defensively in case hypatia scans through the submodule boundary. Demonstration scaffolds, not load-bearing code (see #71)." -stale-aletheia-safedom-entry = "the aletheia/examples/SafeDOMExample.res entry is STALE — the file was deleted in PR #74; entry retained pending an ignore-file prune (TODO 2026-Q3 in .hypatia-ignore)." -glob-collapse-pending = "per .hypatia-ignore: the per-file absolute-zero/examples/** entries will collapse to a single file_pattern entry once standards#180 (glob matching in in_baseline()) merges." +# No active exemptions (2026-09-21, issue #98): the 10 mirrored +# banned_language_file entries were pruned after verifying every path dead +# at the pinned absolute-zero commit. Upstream residue is tracked in the +# owning repo (hyperpolymath/absolute-zero#77). standards#180 has merged, +# so file_pattern globs are available if per-file exemptions ever return. [prohibited-actions] auto-delete-branches = false diff --git a/.machine_readable/contractiles/Dustfile.a2ml b/.machine_readable/contractiles/Dustfile.a2ml index cada74f..8ff6bfb 100644 --- a/.machine_readable/contractiles/Dustfile.a2ml +++ b/.machine_readable/contractiles/Dustfile.a2ml @@ -41,7 +41,7 @@ status = "removed" date = "2026-05" evidence = "PRs #53 (Item 9 guidance retirement), #74 (SafeDOMExample.res fixture deleted), #88 (CLAUDE.md flip)" replacement = "AffineScript per the 2026-05-25 estate language policy" -residue = ".hypatia-ignore still carries absolute-zero demo exemptions plus a stale aletheia/examples/SafeDOMExample.res entry (file deleted in #74); prune per its TODO 2026-Q3" +residue = "RESOLVED 2026-09-21 (#98): .hypatia-ignore pruned to a comments-only audit record; upstream residue tracked in hyperpolymath/absolute-zero#77" [[deprecations]] name = "pmpl-license" diff --git a/.machine_readable/contractiles/Intentfile.a2ml b/.machine_readable/contractiles/Intentfile.a2ml index dc5d515..fe82004 100644 --- a/.machine_readable/contractiles/Intentfile.a2ml +++ b/.machine_readable/contractiles/Intentfile.a2ml @@ -60,12 +60,14 @@ status = "declared" [[intents]] name = "changelog-automation" description = "Adopt changelog-reusable.yml (standards#206) so CHANGELOG.md stays in sync with conventional commits" -status = "declared" +status = "done" +notes = "Closed #100 on 2026-09-21: changelog.yml (commit-back), CHANGELOG.md seeded, CHANGELOG.adoc frozen as pointer" [[intents]] name = "root-justfile-wiring" description = "Wire root Justfile build/test/fmt/lint recipes to the real aletheia/ cargo targets (currently placeholders)" -status = "declared" +status = "done" +notes = "Wired in #144 (closes #99); canonicalised in #102 (contractiles stub is now a forwarding shim); lint is -D warnings since #125" [[intents]] name = "crg-c-compliance" diff --git a/.machine_readable/contractiles/Justfile b/.machine_readable/contractiles/Justfile index 6fdd912..ca47c23 100644 --- a/.machine_readable/contractiles/Justfile +++ b/.machine_readable/contractiles/Justfile @@ -1,96 +1,20 @@ # SPDX-License-Identifier: MPL-2.0 -# justfile - Just recipes for this project -# See: https://github.com/hyperpolymath/mustfile - -# Default recipe -import? "contractile.just" - -default: - @just --list - -# Build the project -build: - @echo "Build not configured yet" - -# Run tests -test: - @echo "Tests not configured yet" - -# Format code -fmt: - @echo "Formatting not configured yet" - -# Lint code -lint: - @echo "Linting not configured yet" - -# Clean build artifacts -clean: - @echo "Clean not configured yet" - -# Run panic-attacker pre-commit scan -assail: - @command -v panic-attack >/dev/null 2>&1 && panic-attack assail . || echo "panic-attack not found — install from https://github.com/hyperpolymath/panic-attacker" - -# Self-diagnostic — checks dependencies, permissions, paths -doctor: - @echo "Running diagnostics for maa-framework..." - @echo "Checking required tools..." - @command -v just >/dev/null 2>&1 && echo " [OK] just" || echo " [FAIL] just not found" - @command -v git >/dev/null 2>&1 && echo " [OK] git" || echo " [FAIL] git not found" - @echo "Checking for hardcoded paths..." - @grep -rn '$HOME\|$ECLIPSE_DIR' --include='*.rs' --include='*.ex' --include='*.res' --include='*.gleam' --include='*.sh' . 2>/dev/null | head -5 || echo " [OK] No hardcoded paths" - @echo "Diagnostics complete." - -# Auto-repair common issues -heal: - @echo "Attempting auto-repair for maa-framework..." - @echo "Fixing permissions..." - @find . -name "*.sh" -exec chmod +x {} \; 2>/dev/null || true - @echo "Cleaning stale caches..." - @rm -rf .cache/stale 2>/dev/null || true - @echo "Repair complete." - -# Guided tour of key features -tour: - @echo "=== maa-framework Tour ===" - @echo "" - @echo "1. Project structure:" - @ls -la - @echo "" - @echo "2. Available commands: just --list" - @echo "" - @echo "3. Read README.adoc for full overview" - @echo "4. Read EXPLAINME.adoc for architecture decisions" - @echo "5. Run 'just doctor' to check your setup" - @echo "" - @echo "Tour complete! Try 'just --list' to see all available commands." - -# Open feedback channel with diagnostic context -help-me: - @echo "=== maa-framework Help ===" - @echo "Platform: $(uname -s) $(uname -m)" - @echo "Shell: $SHELL" - @echo "" - @echo "To report an issue:" - @echo " https://github.com/hyperpolymath/maa-framework/issues/new" - @echo "" - @echo "Include the output of 'just doctor' in your report." - - -# Print the current CRG grade (reads from READINESS.md '**Current Grade:** X' line) -crg-grade: - @grade=$$(grep -oP '(?<=\*\*Current Grade:\*\* )[A-FX]' READINESS.md 2>/dev/null | head -1); \ - [ -z "$$grade" ] && grade="X"; \ - echo "$$grade" - -# Generate a shields.io badge markdown for the current CRG grade -# Looks for '**Current Grade:** X' in READINESS.md; falls back to X -crg-badge: - @grade=$$(grep -oP '(?<=\*\*Current Grade:\*\* )[A-FX]' READINESS.md 2>/dev/null | head -1); \ - [ -z "$$grade" ] && grade="X"; \ - case "$$grade" in \ - A) color="brightgreen" ;; B) color="green" ;; C) color="yellow" ;; \ - D) color="orange" ;; E) color="red" ;; F) color="critical" ;; \ - *) color="lightgrey" ;; esac; \ - echo "[![CRG $$grade](https://img.shields.io/badge/CRG-$$grade-$$color?style=flat-square)](https://github.com/hyperpolymath/standards/tree/main/component-readiness-grades)" +# SHIM — do not add recipes here. +# +# Issue #102 decision: the canonical Justfile lives at the repo root. This +# estate-supplied path used to carry its own recipe bodies, which drifted +# into fake gates (build/test/fmt/lint/clean that echo "not configured yet" +# and exit 0 — the exact failure mode of issue #99). It now forwards +# everything to the root Justfile: the import re-exports root's recipes and +# the working-directory override executes them at the root, so +# `just -f .machine_readable/contractiles/Justfile ` behaves exactly +# like `just ` from the root (verified from root, contractiles/ and +# unrelated cwd). New recipes belong in the root Justfile, full stop. +# +# Scoped Justfiles that are NOT duplicates (each owns a different tree): +# Justfile (root) canonical, wired to rust-ci.yml +# aletheia/Justfile aletheia crate (run from aletheia/) +# aletheia/extraction/rhodibot/Justfile rhodibot crate (run from rhodibot/) +# absolute-zero/Justfile upstream-owned (submodule, untouched) +set working-directory := "../../" +import "../../Justfile" diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index b75ec5d..c12e2c8 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -1,3 +1,11 @@ +[NOTE] +==== +The live changelog is link:CHANGELOG.md[CHANGELOG.md], generated from +conventional commits by `.github/workflows/changelog.yml` (issue #100) on +every push to `main`. This `.adoc` file is frozen as of 2026-09-21 and +retained for history only — do not add entries here. +==== + == Changelog All notable changes to `+maa-framework+` will be documented in this diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9f14f8c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3178 @@ +# Changelog + +All notable changes to this project are documented here. + +This file is generated by [git-cliff](https://git-cliff.org) from conventional +commits. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Teach pin check the estate lockfile mechanism (d8a0483) +- Rebuild CLI surface on the RSR v2 SSOT (9323897) +- Estate label tooling + auto-triage for new issues (#166) (3913c4d) +- Add crg-grade and crg-badge justfile recipes (23a38ac) +- Add comprehensive test suite to achieve CRG C compliance (fc5e8bf) +- Add benchmarks, unit tests, E2E and aspect tests for absolute-zero (77af9b9) +- Add stapeln.toml container definition (abc3938) +- Deploy UX Manifesto infrastructure (12a1907) +- Add CLADE.a2ml — clade taxonomy declaration (49f2363) +- Add AI Gatekeeper Protocol manifest (8bbd63a) +- Enable Hypatia scanning (a71d44c) + + +### CI + +- Standardise the wrapper caller id to the canonical `hypatia` (#182) (0cd1756) +- Gate the dispatch on FARM_DISPATCH_TOKEN being present (#178) (97ca1cb) +- Cron weekly→monthly (cut 3, standards#288) (#146) (a9cfa26) +- Vendor validation scripts and remove remote action pins (#141) (f3c7ee1) +- SHA-pin the four unpinned actions in pages.yml (missed the #123 merge) (#128) (6a7ca7c) +- Deploy docs via Ddraig SSG (#121) (ba29a18) +- Secret Scanner caller must grant the reusable's job permissions (#119) (2b07677) +- Refresh standards reusable pins to current HEAD (d7c2271) (#112) (95b98b4) +- Adopt standards reusable workflows for Scorecard, Hypatia, and Governance (fb01868) +- Fix CI/CD configuration (campaigns C001-C005) (ca24a9e) +- Fix CI/CD configuration (campaigns C001-C005) (1ac4970) +- Fix CI/CD configuration (campaigns C001-C005) (1946e80) +- Add job-level permissions for reusable workflow (#90) (e5350be) +- Split score-threshold from publish job (OSSF run-step ban) (#87) (9fd8269) +- Redistribute concurrency-cancel guard to read-only check workflows (#58) (783f912) +- Bump actions/upload-artifact SHA to current v4 (#48) (cff9395) +- SHA-pin hyperpolymath validate-actions in dogfood-gate (46c7275) +- Fix top-level dir + benchmark/lsp filename matching (#47) (37dfb3e) +- TS check reads .claude/CLAUDE.md exemption table (#46) (8883fc9) +- Broaden TS allowlist (cli, mod.ts, lsp-server, *vscode*, deno-*) (#45) (b85f4ed) +- Allowlist legit TS bridge/adapter paths (#44) (737a8ca) +- Wire hypatia-scan.yml to query own Dependabot alerts (e06ac81) +- Deploy dogfood-gate, fix hypatia-scan, add pre-commit hooks (cb3b790) +- Add E2E + aspect + bench + panic-attack workflow for absolute-zero (69ba44a) +- Migrate CodeQL Action v3 → v4 (a9ca308) +- Update SHA pins for codeql-action and trufflehog (fa646ac) +- Deploy missing standard workflows (1 added) (1e61df9) + + +### Changed + +- Move root artefacts to their canonical locations (#176) (3098b7d) +- Migrate Markdown to AsciiDoc (#161) (43734c1) +- Migrate 6SCM → 6A2 (.scm → .a2ml format) (6f1db7d) + + +### Chores + +- Remove dead Codecov upload steps + lock pins (#181) (443a4e4) +- Migrate root .well-known/ to www/.well-known/ (#175) (1cb4d52) +- Relock drifted action pins (#173) (ca810e4) +- Repoint push-email-notify to smtp-notify-action (#169) (2bd3ee3) +- Delete Nix estate-wide (#138) (415e4b0) +- Quality pass - fix stub/invalid names (#139) (d1c2aab) +- SPDX headers on line 1 and Actions lockfile minted (#153) (15c0327) +- Make Scorecard periodic, not per-push (#151) (bf88376) +- Backfill [status] lifecycle block (#108) (133f70e) +- Normalise to MPL-2.0 + CC-BY-SA-4.0 (canonical pair) (#107) (a969c7a) +- Add dormant push-email notification workflow (#109) (efa1cab) +- Bump standards reusable workflow pins (#106) (73b3440) +- Retire local issue/discussion templates -> inherit org .github canon (f888c8f) +- Remove absolute-zero submodule — coordinate, don't vendor (bef4c92) +- Adopt estate-standardization wave + 2026-06-12 checkpoint (6a2 refresh, contractiles, bot_directives) (#97) (db66b53) +- Convert absolute-zero/ to git submodule pinned to upstream (closes #84) (#89) (b34e4f9) +- Replace scorecard.yml with reusable wrapper (#80) (7727db3) +- Replace secret-scanner.yml with reusable wrapper (#77) (a85f5a2) +- Replace mirror.yml with reusable wrapper (#76) (ee1dc62) +- Delete stale SafeDOMExample.res ReScript fixture (Refs gitbot-fleet#148, #208) (#74) (908defc) +- Bump hypatia-scan-reusable pin off orphan SHA to canonical main (#85) (4b4db46) +- Convert hypatia-scan.yml to wrapper of standards reusable (#73) (f4ba523) +- Exempt vendored absolute-zero Java/Kotlin demos (#71) (701c11f) +- Migrate license from PMPL-1.0-or-later to MPL-2.0 (e4e3db0) +- Remove stale session notes / obsolete CI / OS cruft (5bd8408) +- Replace per-repo governance copies with shared bundle wrapper (8f03d8d) +- Item 9 — retire ReScript guidance → AffineScript (#53) (2d92437) +- Bump k9/a2ml validate-action pins to current main (#51) (66e6b4f) +- Update codeql-action SHA to v3.35.2 (111b724) +- K9 → svc/, remove lust dir (ADR-001 2026-04-18) (6a3061b) +- Add bust/ + relocate k9 to svc/ (607c118) +- Remove lust (not in 6-verb set: intend trust must bust adjust dust) (ca02fc3) +- Estate-wide chore + stale-path sweep (2026-04-17) (3a45352) +- Update LICENSE to PMPL-1.0-or-later with MPL-2.0 fallback (5589e74) +- M5 CI/Workflow Sweep - final synchronisation (04c230b) +- M5 CI/Workflow Sweep - add blockers and contractile.just (5ef48b4) +- Commit 6a2 sweep — a2ml drift (d163f19) +- Clarify MPL-2.0 fallback — PMPL-1.0-or-later preferred (1a1c39c) +- Automated sync of local changes (d903b56) +- Update eclexiaiser.toml with real function entries (27cab88) +- Add eclexiaiser.toml manifests for container sub-services (3d9b7c0) +- Update STATE.a2ml with CRG C compliance achievement (1219f0a) +- Deploy UX infrastructure (contractile.just, .well-known, manifest) (66f71b3) +- Add UX infrastructure (quickstart, doctor, setup) (c87e331) +- Add agent_instructions methodology layer (452e3e0) +- Standardise, Justfile, (rename,, fix, parse, errors,, remove, useless, commands) (b5b882c) +- Batch RSR compliance — SPDX headers, SHA-pin actions, forbid(unsafe_code), CODE_OF_CONDUCT, CONTRIBUTING (e857469) +- Add foundational tool integrations (3235b58) +- Maximize ci/cd values via dependabot and permissions (320942e) +- Big Unification — attach to BoJ Server / Casket architecture (ec975c6) +- Big Unification — attach to BoJ Server / Casket architecture (c9a5488) +- RSR sync and mass repository update (8520a5b) +- Add #![forbid(unsafe_code)] to safe Rust crates (b1bbe31) +- Update manifest paths to .machine_readable/6a2/ (a807ea3) +- Restructure nested 6a2ml files into .machine_readable/6a2/ (7eb5b3b) +- Clean up legacy Trustfile.hs, example Trustfiles, and AI.djot files (c996519) +- Restructure 6a2ml files into .machine_readable/6a2/ (6883c63) +- RSR compliance — delete AI.djot, rename AI manifest, remove stale files (b94f0d2) +- RSR compliance — fix email, PMPL license, AGPL refs, placeholders, CITATIONS, editorconfig (f3054be) +- Sync local changes (2605828) +- Add /target/ to .gitignore (22ddfcd) +- Sync (c094097) +- Fix license headers to PMPL-1.0-or-later (a05211c) +- Commit local changes for sync (c4eda7c) +- Sync template files and configuration (d42b6f7) + + +### Documentation + +- Record measured state of play, for humans and for machines (#148) (de054c2) +- Convert README.adoc -> Markdown (renders on Glama/profile/community-health) (#110) (307d672) +- Flip ReScript→AffineScript per 2026-05-25 estate language policy (#88) (9dbf56b) +- Seed CHANGELOG.md (Keep-a-Changelog format) (#81) (b80aecf) +- Adopt docs-template/ skeleton from rsr-template-repo#75 (#79) (2386f0a) +- Record tech-debt audit findings (2026-05-26) (#75) (d05a500) +- Seed proof-debt.md per standards#203 schema (all 150 markers §(e) vendored) (#82) (6f66f90) +- Substantive CRG C annotation (EXPLAINME.adoc) (9917a32) +- Achieve CRG C — document all test categories passing (b0fa5f1) +- Add EXPLAINME.adoc — prove-it file backing README claims (58e6ffc) +- Update SCM files with project information (b38fc35) +- Add CONTRIBUTING.md (0848205) +- Add checkpoint files for state tracking (1d70b71) +- Update license from AGPL to PMPL (b6c9075) + + +### Fixed + +- Restore the root rust-ci gate, re-pointed at aletheia/ (5aa5041) +- Canonical root Justfile + forwarding shim, repair crg recipes (40b57fa) +- Gate on the DEED grammar, per the template and standards#837 (#183) (93e8625) +- Reconcile actions.lock so the lockfile validates (#184) (3beb02a) +- Reconcile the workflows with actions.lock (gh-actions-lock) (#180) (ef7691a) +- Pin third-party actions to full commit SHAs (#179) (afb63a6) +- Repoint references to the relocated contributing guide (#177) (45fdcb4) +- Make the dogfood gate .deed-aware (#174) (8285709) +- Remove rust-ci.yml as repo has no Cargo.toml (#171) (7d8feda) +- The invisible-character gate never matched anything (#165) (8ecd4cb) +- Unbreak workflow YAML and add a complete actions.lock (#164) (2e9b818) +- Repair workflows that are not valid YAML and have never run (#150) (d682669) +- Remove stray worktree gitlink that breaks every recursive checkout (#145) (649bd51) +- Real SHA-pinning check, real Justfile targets, and docs that match the code (#144) (7d964ee) +- Restore guix.scm identity and licence (squisher-corpus clobber is live on main) (#143) (b61df34) +- Repoint codeql-action at a SHA that exists (#142) (541f47f) +- Re-point validate-action references at the ecosystem repos (#140) (c5175f3) +- Remove the unrunnable SonarCloud step breaking Governance + CodeQL (#139) (f508c03) +- Close the governance gate — SPDX, permissions, SHA pins, reusable bump (#136) (75ab929) +- Group Dependabot updates into one PR per ecosystem (#127) (e895aa4) +- Aletheia has not compiled since June — repair it, then gate it (#123) (0392152) +- Drop invalid timeout-minutes from reusable-workflow calls (parse-dead) (#120) (85b7a22) +- Clear OSSF Scorecard startup_failure (#118) (dc4a8b7) +- Restore missing .gitmodules for absolute-zero submodule (#117) (082f45d) +- Unblock 2 baseline-rot checks blocking dependabot #69 (#70) (b834329) +- Pin upload-artifact to valid SHA in hypatia-scan.yml (Refs standards#48) (#57) (5d1a25f) +- Bump a2ml/k9-validate-action pins to canonical (#55) (9e01cf8) +- Sync hypatia-scan.yml to canonical (#54) (c701495) +- Build Hypatia escript from repo root (estate dogfood drift) (1fb5b1c) +- Adopt canonical hypatia-scan.yml (#52) (721e3ad) +- Rsr-antipattern.yml duplicate heredoc (#49) (f1c6f5e) +- Move secret-scanner Cargo.toml gate from job-level if: to step-level (#50) (9699724) +- Remove eval, quote vars, use mktemp in absolute-zero shell scripts (250311f) +- Eliminate all 3 sorry in LambdaCNO proofs (29259c0) +- Enforce granular permissions and add fuzzing placeholder (1fe0a35) +- Resolve 12 of 19 Lean4 sorry proofs in absolute-zero (4de6387) +- Correct email jonathan.jewell → j.d.a.jewell (47c4070) +- SPDX headers (AGPL→PMPL), email, author name (9e7755f) +- SPDX AGPL-3.0 → PMPL-1.0-or-later in dotfiles (588abe8) +- Remove duplicate SCM files from root (46a4513) + + +### Other + +- Bump the github-actions group with 2 updates (#172) (f00707d) +- Bump the github-actions group across 1 directory with 2 updates (#170) (9dfe403) +- Bun is tier 1, Deno is being removed (#163) (f19c544) +- Expand glossary with core concepts and components + +Added glossary entries for core concepts, components, architecture, and pronunciation guide. + +Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> (b446352) +- Add EXPLAINME documentation for MAA Framework + +This document provides a detailed claim-to-implementation map for the MAA Framework, outlining the implementation status and caveats for various components, including the Rust microkernel and CI workflows. + +Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> (a9fe4c1) +- Add README documentation for MAA Framework + +Added comprehensive documentation for the MAA Framework, including governance, project scope, known gaps, and build instructions. + +Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> (13aba6f) +- Delete README.md + +Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> (3a9b5d3) +- Change license to MPL-2.0 and revise project overview + +Updated license information and project description in README. + +Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> (80e3e36) +- Worktree maa ci repair (#147) + + +## Summary + + + +Closes # + +## Type of change + +- [ ] 🐛 Bug fix (non-breaking change that fixes an issue) +- [ ] ✨ New feature (non-breaking change that adds functionality) +- [ ] 💥 Breaking change (would change existing behaviour) +- [ ] 🕳️ Soundness fix (fixes a checker/proof false-negative) +- [ ] 📖 Documentation +- [ ] 🧹 Refactor / tech debt (behaviour-preserving) +- [ ] ⚡ Performance +- [ ] 🔧 Build / CI / tooling + +## How has this been verified? + + + +## Checklist + +- [ ] My commits are **signed** (`git commit -S`). +- [ ] I ran the project's own checks/tests locally and they pass. +- [ ] New files carry the correct `SPDX-License-Identifier` (code/config +`MPL-2.0`, + prose `CC-BY-SA-4.0`); I did not relicense existing files. +- [ ] Docs are updated, and no public claim now overstates what the code +does. +- [ ] I have not introduced a soundness hole (or I have flagged where I +might have). + +## Notes for reviewers + + + +--------- + +Co-authored-by: Claude Opus 4.8 (2bf7a13) +- Bump the github-actions group with 2 updates (#138) (7b6a293) +- Update/licensing to pmpl ccby (#135) + + +## Summary + + + +Closes # + +## Type of change + +- [ ] 🐛 Bug fix (non-breaking change that fixes an issue) +- [ ] ✨ New feature (non-breaking change that adds functionality) +- [ ] 💥 Breaking change (would change existing behaviour) +- [ ] 🕳️ Soundness fix (fixes a checker/proof false-negative) +- [ ] 📖 Documentation +- [ ] 🧹 Refactor / tech debt (behaviour-preserving) +- [ ] ⚡ Performance +- [ ] 🔧 Build / CI / tooling + +## How has this been verified? + + + +## Checklist + +- [ ] My commits are **signed** (`git commit -S`). +- [ ] I ran the project's own checks/tests locally and they pass. +- [ ] New files carry the correct `SPDX-License-Identifier` (code/config +`MPL-2.0`, + prose `CC-BY-SA-4.0`); I did not relicense existing files. +- [ ] Docs are updated, and no public claim now overstates what the code +does. +- [ ] I have not introduced a soundness hole (or I have flagged where I +might have). + +## Notes for reviewers + + + +--------- + +Co-authored-by: Mistral Vibe (276fa6d) +- Bump dawidd6/action-send-mail from c50dc4cc848ade21f848990889906d804fae78c5 to 12335b969ae3fb71bee5f2c6b829744261aec34c (#133) (da60a2a) +- Re-pin secret scanner onto the real gitleaks gate (#122) (729de3a) +- Bump dawidd6/action-send-mail from 994f270325d4f7257aff241a35488ef54ba364a4 to c50dc4cc848ade21f848990889906d804fae78c5 (#114) (34ee83b) +- Bump dawidd6/action-send-mail from 6e502825a508b867ab2954ad6343b68787624c01 to 994f270325d4f7257aff241a35488ef54ba364a4 (#111) (8b1eb07) +- Bump actions/cache from 5.0.5 to 6.0.0 (35efd74) +- Bump actions/checkout from 6.0.3 to 7.0.0 (5f66c0f) +- Remediate Track C and Track E findings (b5322c2) +- Bump actions/checkout from 6.0.2 to 6.0.3 (#95) (34b7301) +- Re-vendor absolute-zero/ subtree to upstream HEAD (trusted-base + 16-commit catch-up) (#83) (d22378d) +- Bump dtolnay/rust-toolchain from efa25f7f19611383d5b0ccf2d1c8914531636bf9 to 3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 (#69) (51700e7) +- Bump actions/checkout from 4.1.1 to 6.0.2 (#66) (83f5afa) +- Concurrency-cancel guard on canonical check workflows (#56) (7033454) +- Make CNO echo bridge relation-first (0538283) +- Add concrete echo bridge model for CNO (e80c5fb) +- Complete CNO composition proof and fix modulo parsing (1e7d169) +- Add Agda echo/CNO bridge scaffold and integration pointers (24fae59) +- Further proofs (56d29c8) +- Standardize workspace: Capitalize Justfile references in scripts and docs (a417457) +- Standardize workspace: Justfile migration and A2ML directive cleanup (cce7c4d) +- Add casket pages workflow (0881573) +- Remove template-only ABI files that falsely implied formal verification + +Template Idris2 ABI files (Types.idr, Layout.idr, Foreign.idr) contained +only RSR template scaffolding with unresolved placeholders and no +domain-specific proofs. Removed to prevent false impression of formal +verification coverage. + +Co-Authored-By: Claude Opus 4.6 (1M context) (3c1744f) +- Bump github/codeql-action from 4.33.0 to 4.34.0 (b4cfa8c) +- Bump actions/checkout from 4 to 6 (0456db0) +- Bump github/codeql-action from 4.32.6 to 4.33.0 (e4547e2) +- Bump erlef/setup-beam from 1.22.0 to 1.23.0 (f1c26e2) +- .scm → .a2ml state files in .machine_readable/ (7994051) +- SHA-pin GitHub Actions and upgrade deprecated checkout versions + +- Upgrade actions/checkout from v2/v3 to SHA-pinned v4 +- SHA-pin all unshelled action tags (pages, CodeQL, scorecard, + rust-cache, upload/download-artifact, setup-node, cache) +- Standardise scorecard-action to v2.4.0 +- Fix setup-node@v6 → SHA-pinned v4 + +Co-Authored-By: Claude Opus 4.6 (1M context) (5668860) +- Fix SPDX headers: AGPL-3.0-or-later → PMPL-1.0-or-later + +Replace legacy AGPL-3.0-or-later SPDX identifiers with the correct +PMPL-1.0-or-later (Palimpsest License) across justfiles, Mustfiles, +shell scripts, config files, dotfiles, and source code. + +Co-Authored-By: Claude Opus 4.6 (1M context) (7b86dcc) +- Bump webfactory/ssh-agent from 0.9.1 to 0.10.0 (b538011) +- Standardise license setup: MPL-2.0 + PMPL-1.0-or-later (REUSE) + +- LICENSE: pure MPL-2.0 text for machine detection (GitHub, registries) +- LICENSES/MPL-2.0.txt: standard MPL-2.0 text +- LICENSES/PMPL-1.0-or-later.txt: Palimpsest License text +- NOTICE: explains the dual-license relationship +- Source SPDX headers remain PMPL-1.0-or-later + +This ensures OSI compliance checks pass while preserving PMPL as +the governing license for source code. + +Co-Authored-By: Claude Opus 4.6 (d052e8e) +- Bump erlef/setup-beam from 1.21.0 to 1.22.0 (a61c7fe) +- Bump github/codeql-action from 4.32.5 to 4.32.6 (cd98b49) +- Sync changes [2026-03-05] (3f491d4) +- Fix stale submodule pointers after repo cleanup + +Co-Authored-By: Claude Opus 4.6 (25cf219) +- Bump github/codeql-action from 4.32.4 to 4.32.5 (3d95e19) +- Bump erlef/setup-beam from 1.20.4 to 1.21.0 (80b4802) +- Sync changes [2026-02-21] (639f389) +- Bump github/codeql-action from 4.32.3 to 4.32.4 (eab8654) +- Bump github/codeql-action from 4.32.2 to 4.32.3 (1c06e68) +- Bump dtolnay/rust-toolchain from f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 to efa25f7f19611383d5b0ccf2d1c8914531636bf9 (#27) (45efbc9) +- Update README.adoc + +Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> (e17936d) +- Update README.adoc + +Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> (6733ff2) +- Merge branch 'main' of github.com:hyperpolymath/maa-framework (972f66d) +- Add Rhodium dotfiles (173fdfb) +- Update issue templates (6793698) +- Bump trufflesecurity/trufflehog from 3.92.4 to 3.92.5 (#13) + +Bumps +[trufflesecurity/trufflehog](https://github.com/trufflesecurity/trufflehog) +from 3.92.4 to 3.92.5. +
+Release notes +

Sourced from trufflesecurity/trufflehog's +releases.

+
+

v3.92.5

+

What's Changed

+ +

New Contributors

+ +

Full Changelog: https://github.com/trufflesecurity/trufflehog/compare/v3.92.4...v3.92.5

+
+
+
+Commits +
    +
  • 116e717 +[INS-243] Fix jdbc detector detecting incomplete connection string and +fixed ...
  • +
  • b924c0b +added monthly requests limit to postman api request metrics collection +(#4667)
  • +
  • f3eff52 +fix: report accurate line numbers for chunked file scanning (#1876) +(#4615)
  • +
  • 6a0bc78 +fix(git): use --iso-strict git arg to prevent locale issue +(#4653)
  • +
  • fc3f35c +Gitlab Source: Backoff from Scan2 which is experimental to legacy +pagination ...
  • +
  • 728d71f +Rework JWT detector to better block local IPs; add HTTP instrumentation +(#4607)
  • +
  • 89cc34b +Fix typo in help description for Postman API metric (#4656)
  • +
  • 6904595 +detectors/twilio: add exponential backoff retry logic (#4652)
  • +
  • 4e02afb +[INS-170] Unify JDBC URL Parsing Across Detector and Analyzer +(Continued) (#4...
  • +
  • 964eab0 +[INS-232] Fix S3 Source "panic: runtime error: index out of +range" bug (#4610)
  • +
  • Additional commits viewable in compare +view
  • +
+
+
+ + +[![Dependabot compatibility +score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=trufflesecurity/trufflehog&package-manager=github_actions&previous-version=3.92.4&new-version=3.92.5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) + +Dependabot will resolve any conflicts with this PR as long as you don't +alter it yourself. You can also trigger a rebase manually by commenting +`@dependabot rebase`. + +[//]: # (dependabot-automerge-start) +[//]: # (dependabot-automerge-end) + +--- + +
+Dependabot commands and options +
+ +You can trigger Dependabot actions by commenting on this PR: +- `@dependabot rebase` will rebase this PR +- `@dependabot recreate` will recreate this PR, overwriting any edits +that have been made to it +- `@dependabot merge` will merge this PR after your CI passes on it +- `@dependabot squash and merge` will squash and merge this PR after +your CI passes on it +- `@dependabot cancel merge` will cancel a previously requested merge +and block automerging +- `@dependabot reopen` will reopen this PR if it is closed +- `@dependabot close` will close this PR and stop Dependabot recreating +it. You can achieve the same result by closing it manually +- `@dependabot show ignore conditions` will show all +of the ignore conditions of the specified dependency +- `@dependabot ignore this major version` will close this PR and stop +Dependabot creating any more for this major version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this minor version` will close this PR and stop +Dependabot creating any more for this minor version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this dependency` will close this PR and stop +Dependabot creating any more for this dependency (unless you reopen the +PR or upgrade to it yourself) + + +
+ +Signed-off-by: dependabot[bot] +Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> +Co-authored-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> (f59d518) +- Bump actions/checkout from 6.0.1 to 6.0.2 (#14) + +Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.1 +to 6.0.2. +
+Release notes +

Sourced from actions/checkout's +releases.

+
+

v6.0.2

+

What's Changed

+ +

Full Changelog: https://github.com/actions/checkout/compare/v6.0.1...v6.0.2

+
+
+
+Changelog +

Sourced from actions/checkout's +changelog.

+
+

Changelog

+

v6.0.2

+ +

v6.0.1

+ +

v6.0.0

+ +

v5.0.1

+ +

v5.0.0

+ +

v4.3.1

+ +

v4.3.0

+ +

v4.2.2

+ +

v4.2.1

+ +

v4.2.0

+ +

v4.1.7

+ +

v4.1.6

+ + +
+

... (truncated)

+
+
+Commits +
    +
  • de0fac2 +Fix tag handling: preserve annotations and explicit fetch-tags (#2356)
  • +
  • 064fe7f +Add orchestration_id to git user-agent when ACTIONS_ORCHESTRATION_ID is +set (...
  • +
  • See full diff in compare +view
  • +
+
+
+ + +[![Dependabot compatibility +score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=6.0.1&new-version=6.0.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) + +Dependabot will resolve any conflicts with this PR as long as you don't +alter it yourself. You can also trigger a rebase manually by commenting +`@dependabot rebase`. + +[//]: # (dependabot-automerge-start) +[//]: # (dependabot-automerge-end) + +--- + +
+Dependabot commands and options +
+ +You can trigger Dependabot actions by commenting on this PR: +- `@dependabot rebase` will rebase this PR +- `@dependabot recreate` will recreate this PR, overwriting any edits +that have been made to it +- `@dependabot merge` will merge this PR after your CI passes on it +- `@dependabot squash and merge` will squash and merge this PR after +your CI passes on it +- `@dependabot cancel merge` will cancel a previously requested merge +and block automerging +- `@dependabot reopen` will reopen this PR if it is closed +- `@dependabot close` will close this PR and stop Dependabot recreating +it. You can achieve the same result by closing it manually +- `@dependabot show ignore conditions` will show all +of the ignore conditions of the specified dependency +- `@dependabot ignore this major version` will close this PR and stop +Dependabot creating any more for this major version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this minor version` will close this PR and stop +Dependabot creating any more for this minor version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this dependency` will close this PR and stop +Dependabot creating any more for this dependency (unless you reopen the +PR or upgrade to it yourself) + + +
+ +Signed-off-by: dependabot[bot] +Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> +Co-authored-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> (cd0a0a4) +- Add STATE.scm + +Co-Authored-By: Claude Opus 4.5 (4e7183f) + + +### Security + +- Bump github/codeql-action from 4.37.7 to 4.37.8 in the github-actions group (#162) (8d726d5) +- Bump dawidd6/action-send-mail from 3.12.0 to 18 in the github-actions group (#160) (6519a5f) +- Bump github/codeql-action from 4.37.6 to 4.37.7 in the github-actions group (#159) (827237d) +- Bump github/codeql-action from 4.37.5 to 4.37.6 in the github-actions group (#154) (8caa296) +- Bump the github-actions group with 4 updates (#152) (88fa28e) +- Bump the github-actions group with 4 updates (#137) (54670cc) +- Bump github/codeql-action/analyze from 4.36.3 to 4.37.2 (#131) (f7d1b2a) +- Bump github/codeql-action/init from 4.36.3 to 4.37.2 (#132) (a79e6b2) +- Bump actions/checkout from 7.0.0 to 7.0.1 (#130) (9ade01f) +- Bump hyperpolymath/a2ml-validate-action from 59145c7d1039fa3059b3ecacdb50ee23d7505898 to 5468b7ff0f5129a74d25af5f2468df11a706dc70 (#134) (e9f4e08) +- Bump hyperpolymath/k9-validate-action from 2d96f43c538964b097d159ed3a56ba5b5ceca227 to 08958c9bab64f9941a85938a78122a0d4a3058e8 (#129) (d2e37ac) +- Bump github/codeql-action/analyze from 4.36.2 to 4.36.3 (#115) (8c13869) +- Bump github/codeql-action/init from 4.36.2 to 4.36.3 (#116) (60b0b29) +- Bump actions/cache from 6.0.0 to 6.1.0 (#113) (e8630e8) +- Bump github/codeql-action from 4.36.1 to 4.36.2 (#96) (0f0cf2f) +- Bump actions/upload-artifact from 5.0.0 to 7.0.1 (#94) (bf50a4e) +- Bump actions/download-artifact from 6.0.0 to 8.0.1 (#93) (0739268) +- Bump github/codeql-action from 4.34.0 to 4.36.1 (#92) (39b748a) +- Bump haskell-actions/setup from 2.7.5 to 2.11.0 (#68) (2ae2d5a) +- Bump actions/upload-pages-artifact from 3.0.1 to 5.0.0 (#67) (c902996) +- Bump actions/configure-pages from 5.0.0 to 6.0.0 (#65) (050d936) +- Bump actions/upload-artifact from 4.6.2 to 7.0.1 (#64) (1401d0b) +- Bump trufflesecurity/trufflehog from 3.93.8 to 3.95.3 (#63) (0402360) +- Bump actions/cache from 4.3.0 to 5.0.5 (#62) (1944bd5) +- Bump github/codeql-action from 4.32.6 to 4.36.0 (#61) (8bd70b7) +- Bump actions/github-script from 8.0.0 to 9.0.0 (#60) (943568a) +- Bump actions/deploy-pages from 4.0.5 to 5.0.0 (#59) (d2bbf8a) +- Bump trufflesecurity/trufflehog from 3.93.8 to 3.94.0 (8cc5a78) +- Bump trufflesecurity/trufflehog from 3.93.7 to 3.93.8 (0890277) +- Bump trufflesecurity/trufflehog from 3.93.6 to 3.93.7 (31292ec) +- Bump trufflesecurity/trufflehog from 3.93.4 to 3.93.6 (8f29647) +- Bump trufflesecurity/trufflehog from 3.93.3 to 3.93.4 (bff4326) +- Bump trufflesecurity/trufflehog from 3.93.1 to 3.93.3 (#24) (7fbab8e) +- Bump github/codeql-action from 4.32.1 to 4.32.2 (#23) (a36f7e2) +- Bump trufflesecurity/trufflehog from 3.93.0 to 3.93.1 (#22) (b78abd7) +- Bump github/codeql-action from 4.32.0 to 4.32.1 (#20) (5195125) +- Bump trufflesecurity/trufflehog from 3.92.5 to 3.93.0 (#21) (a5aef00) +- Bump actions/github-script from 7.0.1 to 8.0.0 (#18) (ce93d9b) +- Bump erlef/setup-beam from 1.17.5 to 1.20.4 (#17) (30d973f) +- Bump actions/checkout from 4.1.1 to 6.0.2 (#19) (7b00374) +- Bump github/codeql-action from 4.31.11 to 4.32.0 (#16) (004d691) +- Bump github/codeql-action from 4.31.10 to 4.31.11 (#15) (28bad6c) + +## [1.0.0] — 2026-01-17 + +### CI + +- Add hub-and-spoke mirror workflow (96c5ef6) + + +### Chores + +- Add RSR enforcement workflows (290a0dc) + + +### Fixed + +- Update editorconfig SHA and CodeQL language (3922b4b) + + +### Other + +- Add PMPL-1.0 license + +Co-Authored-By: Claude Opus 4.5 (65fd94b) +- Bump github/codeql-action from 4.31.9 to 4.31.10 (#12) + +Bumps [github/codeql-action](https://github.com/github/codeql-action) +from 4.31.9 to 4.31.10. +
+Release notes +

Sourced from github/codeql-action's +releases.

+
+

v4.31.10

+

CodeQL Action Changelog

+

See the releases +page for the relevant changes to the CodeQL CLI and language +packs.

+

4.31.10 - 12 Jan 2026

+
    +
  • Update default CodeQL bundle version to 2.23.9. #3393
  • +
+

See the full CHANGELOG.md +for more information.

+
+
+
+Changelog +

Sourced from github/codeql-action's +changelog.

+
+

CodeQL Action Changelog

+

See the releases +page for the relevant changes to the CodeQL CLI and language +packs.

+

[UNRELEASED]

+

No user facing changes.

+

4.31.10 - 12 Jan 2026

+
    +
  • Update default CodeQL bundle version to 2.23.9. #3393
  • +
+

4.31.9 - 16 Dec 2025

+

No user facing changes.

+

4.31.8 - 11 Dec 2025

+
    +
  • Update default CodeQL bundle version to 2.23.8. #3354
  • +
+

4.31.7 - 05 Dec 2025

+
    +
  • Update default CodeQL bundle version to 2.23.7. #3343
  • +
+

4.31.6 - 01 Dec 2025

+

No user facing changes.

+

4.31.5 - 24 Nov 2025

+
    +
  • Update default CodeQL bundle version to 2.23.6. #3321
  • +
+

4.31.4 - 18 Nov 2025

+

No user facing changes.

+

4.31.3 - 13 Nov 2025

+
    +
  • CodeQL Action v3 will be deprecated in December 2026. The Action now +logs a warning for customers who are running v3 but could be running v4. +For more information, see Upcoming +deprecation of CodeQL Action v3.
  • +
  • Update default CodeQL bundle version to 2.23.5. #3288
  • +
+

4.31.2 - 30 Oct 2025

+

No user facing changes.

+

4.31.1 - 30 Oct 2025

+
    +
  • The add-snippets input has been removed from the +analyze action. This input has been deprecated since CodeQL +Action 3.26.4 in August 2024 when this removal was announced.
  • +
+

4.31.0 - 24 Oct 2025

+ +
+

... (truncated)

+
+
+Commits +
    +
  • cdefb33 +Merge pull request #3394 +from github/update-v4.31.10-0fa411efd
  • +
  • cfa77c6 +Update changelog for v4.31.10
  • +
  • 0fa411e +Merge pull request #3393 +from github/update-bundle/codeql-bundle-v2.23.9
  • +
  • c284324 +Add changelog note
  • +
  • 83e7d00 +Update default bundle to codeql-bundle-v2.23.9
  • +
  • f6a16be +Merge pull request #3391 +from github/dependabot/npm_and_yarn/npm-minor-f1cdf5...
  • +
  • c1f5f1a +Rebuild
  • +
  • 1805d8d +Bump the npm-minor group with 2 updates
  • +
  • b2951d2 +Merge pull request #3353 +from github/kaspersv/bump-min-cli-v-for-overlay
  • +
  • 41448d9 +Merge pull request #3287 +from github/henrymercer/generate-mergeback-last
  • +
  • Additional commits viewable in compare +view
  • +
+
+
+ + +[![Dependabot compatibility +score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action&package-manager=github_actions&previous-version=4.31.9&new-version=4.31.10)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) + +Dependabot will resolve any conflicts with this PR as long as you don't +alter it yourself. You can also trigger a rebase manually by commenting +`@dependabot rebase`. + +[//]: # (dependabot-automerge-start) +[//]: # (dependabot-automerge-end) + +--- + +
+Dependabot commands and options +
+ +You can trigger Dependabot actions by commenting on this PR: +- `@dependabot rebase` will rebase this PR +- `@dependabot recreate` will recreate this PR, overwriting any edits +that have been made to it +- `@dependabot merge` will merge this PR after your CI passes on it +- `@dependabot squash and merge` will squash and merge this PR after +your CI passes on it +- `@dependabot cancel merge` will cancel a previously requested merge +and block automerging +- `@dependabot reopen` will reopen this PR if it is closed +- `@dependabot close` will close this PR and stop Dependabot recreating +it. You can achieve the same result by closing it manually +- `@dependabot show ignore conditions` will show all +of the ignore conditions of the specified dependency +- `@dependabot ignore this major version` will close this PR and stop +Dependabot creating any more for this major version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this minor version` will close this PR and stop +Dependabot creating any more for this minor version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this dependency` will close this PR and stop +Dependabot creating any more for this dependency (unless you reopen the +PR or upgrade to it yourself) + + +
+ +Signed-off-by: dependabot[bot] +Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> (359f66b) +- Remove legacy README.md (primary content is in README.adoc) (89f4d0c) +- Add MAINTAINERS.adoc (ad0e847) +- Bump trufflesecurity/trufflehog from 3.92.3 to 3.92.4 (#7) + +Bumps +[trufflesecurity/trufflehog](https://github.com/trufflesecurity/trufflehog) +from 3.92.3 to 3.92.4. +
+Release notes +

Sourced from trufflesecurity/trufflehog's +releases.

+
+

v3.92.4

+

What's Changed

+ +

Full Changelog: https://github.com/trufflesecurity/trufflehog/compare/v3.92.3...v3.92.4

+
+
+
+Commits +
    +
  • ef6e76c +enable line numbers for ghr (#4611)
  • +
  • 8c1219a +[INS-207] Add Role-Aware Resumption Support for Legacy S3 Scan (#4600)
  • +
  • 606a7ed +Update module golang.org/x/crypto to v0.45.0 [SECURITY] (#4562)
  • +
  • 8aea6cd +[INS-226] use pinned image for quay registry test (#4602)
  • +
  • 3cb2166 +Pagination and Rate-Limit Handling In Docker Registry Namespace API +Calls (#4...
  • +
  • 0fab92f +[INS-170] Unify JDBC URL parsing across detectors and analyzers (#4574)
  • +
  • See full diff in compare +view
  • +
+
+
+ + +[![Dependabot compatibility +score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=trufflesecurity/trufflehog&package-manager=github_actions&previous-version=3.92.3&new-version=3.92.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) + +Dependabot will resolve any conflicts with this PR as long as you don't +alter it yourself. You can also trigger a rebase manually by commenting +`@dependabot rebase`. + +[//]: # (dependabot-automerge-start) +[//]: # (dependabot-automerge-end) + +--- + +
+Dependabot commands and options +
+ +You can trigger Dependabot actions by commenting on this PR: +- `@dependabot rebase` will rebase this PR +- `@dependabot recreate` will recreate this PR, overwriting any edits +that have been made to it +- `@dependabot merge` will merge this PR after your CI passes on it +- `@dependabot squash and merge` will squash and merge this PR after +your CI passes on it +- `@dependabot cancel merge` will cancel a previously requested merge +and block automerging +- `@dependabot reopen` will reopen this PR if it is closed +- `@dependabot close` will close this PR and stop Dependabot recreating +it. You can achieve the same result by closing it manually +- `@dependabot show ignore conditions` will show all +of the ignore conditions of the specified dependency +- `@dependabot ignore this major version` will close this PR and stop +Dependabot creating any more for this major version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this minor version` will close this PR and stop +Dependabot creating any more for this minor version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this dependency` will close this PR and stop +Dependabot creating any more for this dependency (unless you reopen the +PR or upgrade to it yourself) + + +
+ +Signed-off-by: dependabot[bot] +Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> +Co-authored-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> (7bafa61) +- Bump actions/checkout from 4.1.1 to 6.0.1 (#6) + +Bumps [actions/checkout](https://github.com/actions/checkout) from 4.1.1 +to 6.0.1. +
+Release notes +

Sourced from actions/checkout's +releases.

+
+

v6.0.1

+

What's Changed

+ +

Full Changelog: https://github.com/actions/checkout/compare/v6...v6.0.1

+

v6.0.0

+

What's Changed

+ +

Full Changelog: https://github.com/actions/checkout/compare/v5.0.0...v6.0.0

+

v6-beta

+

What's Changed

+

Updated persist-credentials to store the credentials under +$RUNNER_TEMP instead of directly in the local git +config.

+

This requires a minimum Actions Runner version of v2.329.0 +to access the persisted credentials for Docker +container action scenarios.

+

v5.0.1

+

What's Changed

+ +

Full Changelog: https://github.com/actions/checkout/compare/v5...v5.0.1

+

v5.0.0

+

What's Changed

+ +

⚠️ Minimum Compatible Runner Version

+

v2.327.1
+Release +Notes

+

Make sure your runner is updated to this version or newer to use this +release.

+

Full Changelog: https://github.com/actions/checkout/compare/v4...v5.0.0

+

v4.3.1

+

What's Changed

+ + +
+

... (truncated)

+
+
+Changelog +

Sourced from actions/checkout's +changelog.

+
+

Changelog

+

v6.0.0

+ +

v5.0.1

+ +

v5.0.0

+ +

v4.3.1

+ +

v4.3.0

+ +

v4.2.2

+ +

v4.2.1

+ +

v4.2.0

+ +

v4.1.7

+ +

v4.1.6

+ +

v4.1.5

+ + +
+

... (truncated)

+
+
+Commits + +
+
+ + +[![Dependabot compatibility +score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=4.1.1&new-version=6.0.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) + +Dependabot will resolve any conflicts with this PR as long as you don't +alter it yourself. You can also trigger a rebase manually by commenting +`@dependabot rebase`. + +[//]: # (dependabot-automerge-start) +[//]: # (dependabot-automerge-end) + +--- + +
+Dependabot commands and options +
+ +You can trigger Dependabot actions by commenting on this PR: +- `@dependabot rebase` will rebase this PR +- `@dependabot recreate` will recreate this PR, overwriting any edits +that have been made to it +- `@dependabot merge` will merge this PR after your CI passes on it +- `@dependabot squash and merge` will squash and merge this PR after +your CI passes on it +- `@dependabot cancel merge` will cancel a previously requested merge +and block automerging +- `@dependabot reopen` will reopen this PR if it is closed +- `@dependabot close` will close this PR and stop Dependabot recreating +it. You can achieve the same result by closing it manually +- `@dependabot show ignore conditions` will show all +of the ignore conditions of the specified dependency +- `@dependabot ignore this major version` will close this PR and stop +Dependabot creating any more for this major version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this minor version` will close this PR and stop +Dependabot creating any more for this minor version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this dependency` will close this PR and stop +Dependabot creating any more for this dependency (unless you reopen the +PR or upgrade to it yourself) + + +
+ +Signed-off-by: dependabot[bot] +Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> (876a6e8) +- Bump webfactory/ssh-agent from 0.9.0 to 0.9.1 (#8) + +Bumps [webfactory/ssh-agent](https://github.com/webfactory/ssh-agent) +from 0.9.0 to 0.9.1. +
+Release notes +

Sourced from webfactory/ssh-agent's +releases.

+
+

v0.9.1

+

What's Changed

+ +

New Contributors

+ +

Full Changelog: https://github.com/webfactory/ssh-agent/compare/v0.9.0...v0.9.1

+
+
+
+Changelog +

Sourced from webfactory/ssh-agent's +changelog.

+
+

Changelog

+

All notable changes to this project will be documented in this +file.

+

The format is based on Keep a Changelog, +and this project adheres to Semantic Versioning.

+

[Unreleased]

+

v0.9.1 [2024-03-17]

+

Fixed

+
    +
  • Fix path used to execute ssh-agent in cleanup.js to respect custom +paths set by input (#235)
  • +
+

v0.9.0 [2024-02-06]

+

Changed

+
    +
  • Update all versions of actions/checkout to v4 (#199)
  • +
  • Update to Node 20 (#201)
  • +
+

v0.8.0 [2023-03-24]

+

Changed

+
    +
  • No longer writing GitHub's SSH host keys to known_hosts +(#171)
  • +
  • Update to actions/checkout@v3 (#143)
  • +
  • Allow the user to override the commands for git, ssh-agent, and +ssh-add (#154)
  • +
+

v0.7.0 [2022-10-19]

+

Added

+
    +
  • Add the log-public-key input that can be used to turn +off logging key identities (#122)
  • +
+

Fixed

+
    +
  • Fix path to git binary on Windows, assuming +GitHub-hosted runners (#136, +#137)
  • +
  • Fix a nonsensical log message (#139)
  • +
+

v0.6.0 [2022-10-19]

+

Changed

+ +

v0.5.4 [2021-11-21]

+

Fixed

+ +
+

... (truncated)

+
+
+Commits + +
+
+ + +[![Dependabot compatibility +score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=webfactory/ssh-agent&package-manager=github_actions&previous-version=0.9.0&new-version=0.9.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) + +Dependabot will resolve any conflicts with this PR as long as you don't +alter it yourself. You can also trigger a rebase manually by commenting +`@dependabot rebase`. + +[//]: # (dependabot-automerge-start) +[//]: # (dependabot-automerge-end) + +--- + +
+Dependabot commands and options +
+ +You can trigger Dependabot actions by commenting on this PR: +- `@dependabot rebase` will rebase this PR +- `@dependabot recreate` will recreate this PR, overwriting any edits +that have been made to it +- `@dependabot merge` will merge this PR after your CI passes on it +- `@dependabot squash and merge` will squash and merge this PR after +your CI passes on it +- `@dependabot cancel merge` will cancel a previously requested merge +and block automerging +- `@dependabot reopen` will reopen this PR if it is closed +- `@dependabot close` will close this PR and stop Dependabot recreating +it. You can achieve the same result by closing it manually +- `@dependabot show ignore conditions` will show all +of the ignore conditions of the specified dependency +- `@dependabot ignore this major version` will close this PR and stop +Dependabot creating any more for this major version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this minor version` will close this PR and stop +Dependabot creating any more for this minor version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this dependency` will close this PR and stop +Dependabot creating any more for this dependency (unless you reopen the +PR or upgrade to it yourself) + + +
+ +Signed-off-by: dependabot[bot] +Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> (2172844) +- Bump ossf/scorecard-action from 2.4.0 to 2.4.3 (#9) + +Bumps [ossf/scorecard-action](https://github.com/ossf/scorecard-action) +from 2.4.0 to 2.4.3. +
+Release notes +

Sourced from ossf/scorecard-action's +releases.

+
+

v2.4.3

+

What's Changed

+

This update bumps the Scorecard version to the v5.3.0 release. For a +complete list of changes, please refer to the Scorecard +v5.3.0 release notes.

+

Documentation

+ +

Other

+ +

New Contributors

+ +

Full Changelog: https://github.com/ossf/scorecard-action/compare/v2.4.2...v2.4.3

+

v2.4.2

+

What's Changed

+

This update bumps the Scorecard version to the v5.2.1 release. For a +complete list of changes, please refer to the Scorecard v5.2.0 +and v5.2.1 +release notes.

+

Full Changelog: https://github.com/ossf/scorecard-action/compare/v2.4.1...v2.4.2

+

v2.4.1

+

What's Changed

+
    +
  • This update bumps the Scorecard version to the v5.1.1 release. For a +complete list of changes, please refer to the v5.1.0 +and v5.1.1 +release notes.
  • +
  • Publishing results now uses half the API quota as before. The exact +savings depends on the repository in question. + +
  • +
  • Some errors were made into annotations to make them more visible + +
  • +
  • There is now an optional file_mode input which controls +how repository files are fetched from GitHub. The default is +archive, but git produces the most accurate +results for repositories with .gitattributes files at the +cost of analysis speed. + +
  • +
  • The underlying container for the action is now hosted +on GitHub Container Registry. There should be no functional changes. + +
  • +
+

Docs

+ +

New Contributors

+ +
+
+
+Commits +
    +
  • 4eaacf0 +bump docker to ghcr v2.4.3 (#1587)
  • +
  • 42e3a01 +:seedling: Bump the github-actions group with 3 updates (#1585)
  • +
  • 88c07ac +:seedling: Bump github.com/sigstore/cosign/v2 from 2.5.2 to 2.6.0 (#1579)
  • +
  • 6c690f2 +Bump github.com/ossf/scorecard/v5 from v5.2.1 to v5.3.0 (#1586)
  • +
  • 92083b5 +:book: Fix recommended command to test the image in development (#1583)
  • +
  • 7975ea6 +:seedling: Bump the docker-images group across 1 directory with 2 +updates (#1...
  • +
  • 0d1a743 +:seedling: Bump github.com/spf13/cobra from 1.9.1 to 1.10.1 (#1575)
  • +
  • 46e6e0c +:seedling: Bump the github-actions group with 2 updates (#1580)
  • +
  • c3f1350 +:seedling: Improve printing options (#1584)
  • +
  • 43e475b +:seedling: Bump golang.org/x/net from 0.42.0 to 0.44.0 (#1578)
  • +
  • Additional commits viewable in compare +view
  • +
+
+
+ + +[![Dependabot compatibility +score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ossf/scorecard-action&package-manager=github_actions&previous-version=2.4.0&new-version=2.4.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) + +Dependabot will resolve any conflicts with this PR as long as you don't +alter it yourself. You can also trigger a rebase manually by commenting +`@dependabot rebase`. + +[//]: # (dependabot-automerge-start) +[//]: # (dependabot-automerge-end) + +--- + +
+Dependabot commands and options +
+ +You can trigger Dependabot actions by commenting on this PR: +- `@dependabot rebase` will rebase this PR +- `@dependabot recreate` will recreate this PR, overwriting any edits +that have been made to it +- `@dependabot merge` will merge this PR after your CI passes on it +- `@dependabot squash and merge` will squash and merge this PR after +your CI passes on it +- `@dependabot cancel merge` will cancel a previously requested merge +and block automerging +- `@dependabot reopen` will reopen this PR if it is closed +- `@dependabot close` will close this PR and stop Dependabot recreating +it. You can achieve the same result by closing it manually +- `@dependabot show ignore conditions` will show all +of the ignore conditions of the specified dependency +- `@dependabot ignore this major version` will close this PR and stop +Dependabot creating any more for this major version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this minor version` will close this PR and stop +Dependabot creating any more for this minor version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this dependency` will close this PR and stop +Dependabot creating any more for this dependency (unless you reopen the +PR or upgrade to it yourself) + + +
+ +Signed-off-by: dependabot[bot] +Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> (48bbc37) +- Bump dtolnay/rust-toolchain from 56f84321dbccf38fb67ce29ab63e4754056677e0 to f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 (#11) + +Bumps +[dtolnay/rust-toolchain](https://github.com/dtolnay/rust-toolchain) from +56f84321dbccf38fb67ce29ab63e4754056677e0 to +f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561. +
+Commits +
    +
  • f7ccc83 +Merge pull request #177 +from dtolnay/permitcopyrename
  • +
  • 1c0547f +Permit cross-device copy
  • +
  • 0b1efab +Update actions/checkout@v5 -> v6
  • +
  • 0f44b27 +Add 1.91.1 patch release
  • +
  • 6d653ac +Merge pull request #171 +from dtolnay/up
  • +
  • 30dc51d +Update Linux arm64 runner to Ubuntu 24.04
  • +
  • e97e2d8 +Update actions/checkout@v4 -> v5
  • +
  • 3bd6ba1 +Merge pull request #168 +from dtolnay/sed
  • +
  • 0185c06 +Fix update-revs.sh to recognize only the intended required: +true
  • +
  • 350b817 +Merge pull request #166 +from dtolnay/fix1
  • +
  • Additional commits viewable in compare +view
  • +
+
+
+ + +Dependabot will resolve any conflicts with this PR as long as you don't +alter it yourself. You can also trigger a rebase manually by commenting +`@dependabot rebase`. + +[//]: # (dependabot-automerge-start) +[//]: # (dependabot-automerge-end) + +--- + +
+Dependabot commands and options +
+ +You can trigger Dependabot actions by commenting on this PR: +- `@dependabot rebase` will rebase this PR +- `@dependabot recreate` will recreate this PR, overwriting any edits +that have been made to it +- `@dependabot merge` will merge this PR after your CI passes on it +- `@dependabot squash and merge` will squash and merge this PR after +your CI passes on it +- `@dependabot cancel merge` will cancel a previously requested merge +and block automerging +- `@dependabot reopen` will reopen this PR if it is closed +- `@dependabot close` will close this PR and stop Dependabot recreating +it. You can achieve the same result by closing it manually +- `@dependabot show ignore conditions` will show all +of the ignore conditions of the specified dependency +- `@dependabot ignore this major version` will close this PR and stop +Dependabot creating any more for this major version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this minor version` will close this PR and stop +Dependabot creating any more for this minor version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this dependency` will close this PR and stop +Dependabot creating any more for this dependency (unless you reopen the +PR or upgrade to it yourself) + + +
+ +Signed-off-by: dependabot[bot] +Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> (db91c2b) +- Bump github/codeql-action from 3.27.0 to 4.31.9 (#10) + +Bumps [github/codeql-action](https://github.com/github/codeql-action) +from 3.27.0 to 4.31.9. +
+Release notes +

Sourced from github/codeql-action's +releases.

+
+

v4.31.9

+

CodeQL Action Changelog

+

See the releases +page for the relevant changes to the CodeQL CLI and language +packs.

+

4.31.9 - 16 Dec 2025

+

No user facing changes.

+

See the full CHANGELOG.md +for more information.

+

v4.31.8

+

CodeQL Action Changelog

+

See the releases +page for the relevant changes to the CodeQL CLI and language +packs.

+

4.31.8 - 11 Dec 2025

+
    +
  • Update default CodeQL bundle version to 2.23.8. #3354
  • +
+

See the full CHANGELOG.md +for more information.

+

v4.31.7

+

CodeQL Action Changelog

+

See the releases +page for the relevant changes to the CodeQL CLI and language +packs.

+

4.31.7 - 05 Dec 2025

+
    +
  • Update default CodeQL bundle version to 2.23.7. #3343
  • +
+

See the full CHANGELOG.md +for more information.

+

v4.31.6

+

CodeQL Action Changelog

+

See the releases +page for the relevant changes to the CodeQL CLI and language +packs.

+

4.31.6 - 01 Dec 2025

+

No user facing changes.

+

See the full CHANGELOG.md +for more information.

+

v4.31.5

+

CodeQL Action Changelog

+

See the releases +page for the relevant changes to the CodeQL CLI and language +packs.

+

4.31.5 - 24 Nov 2025

+ +
+

... (truncated)

+
+
+Changelog +

Sourced from github/codeql-action's +changelog.

+
+

CodeQL Action Changelog

+

See the releases +page for the relevant changes to the CodeQL CLI and language +packs.

+

[UNRELEASED]

+

No user facing changes.

+

4.31.9 - 16 Dec 2025

+

No user facing changes.

+

4.31.8 - 11 Dec 2025

+
    +
  • Update default CodeQL bundle version to 2.23.8. #3354
  • +
+

4.31.7 - 05 Dec 2025

+
    +
  • Update default CodeQL bundle version to 2.23.7. #3343
  • +
+

4.31.6 - 01 Dec 2025

+

No user facing changes.

+

4.31.5 - 24 Nov 2025

+
    +
  • Update default CodeQL bundle version to 2.23.6. #3321
  • +
+

4.31.4 - 18 Nov 2025

+

No user facing changes.

+

4.31.3 - 13 Nov 2025

+
    +
  • CodeQL Action v3 will be deprecated in December 2026. The Action now +logs a warning for customers who are running v3 but could be running v4. +For more information, see Upcoming +deprecation of CodeQL Action v3.
  • +
  • Update default CodeQL bundle version to 2.23.5. #3288
  • +
+

4.31.2 - 30 Oct 2025

+

No user facing changes.

+

4.31.1 - 30 Oct 2025

+
    +
  • The add-snippets input has been removed from the +analyze action. This input has been deprecated since CodeQL +Action 3.26.4 in August 2024 when this removal was announced.
  • +
+

4.31.0 - 24 Oct 2025

+
    +
  • Bump minimum CodeQL bundle version to 2.17.6. #3223
  • +
  • When SARIF files are uploaded by the analyze or +upload-sarif actions, the CodeQL Action automatically +performs post-processing steps to prepare the data for the upload. +Previously, these post-processing steps were only performed before an +upload took place. We are now changing this so that the post-processing +steps will always be performed, even when the SARIF files are not +uploaded. This does not change anything for the +upload-sarif action. For analyze, this may +affect Advanced Setup for CodeQL users who specify a value other than +always for the upload input. #3222
  • +
+ +
+

... (truncated)

+
+
+Commits +
    +
  • 5d4e8d1 +Merge pull request #3371 +from github/update-v4.31.9-998798e34
  • +
  • 1dc115f +Update changelog for v4.31.9
  • +
  • 998798e +Merge pull request #3352 +from github/nickrolfe/jar-min-ff-cleanup
  • +
  • 5eb7519 +Merge pull request #3358 +from github/henrymercer/database-upload-telemetry
  • +
  • d29eddb +Extract version number to constant
  • +
  • e962687 +Merge branch 'main' into henrymercer/database-upload-telemetry
  • +
  • 19c7f96 +Rename isOverlayBase
  • +
  • ae5de9a +Use getErrorMessage in log too
  • +
  • 0cb8633 +Prefer performance.now()
  • +
  • c07cc0d +Merge pull request #3351 +from github/henrymercer/ghec-dr-determine-tools-vers...
  • +
  • Additional commits viewable in compare +view
  • +
+
+
+ + +[![Dependabot compatibility +score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action&package-manager=github_actions&previous-version=3.27.0&new-version=4.31.9)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) + +Dependabot will resolve any conflicts with this PR as long as you don't +alter it yourself. You can also trigger a rebase manually by commenting +`@dependabot rebase`. + +[//]: # (dependabot-automerge-start) +[//]: # (dependabot-automerge-end) + +--- + +
+Dependabot commands and options +
+ +You can trigger Dependabot actions by commenting on this PR: +- `@dependabot rebase` will rebase this PR +- `@dependabot recreate` will recreate this PR, overwriting any edits +that have been made to it +- `@dependabot merge` will merge this PR after your CI passes on it +- `@dependabot squash and merge` will squash and merge this PR after +your CI passes on it +- `@dependabot cancel merge` will cancel a previously requested merge +and block automerging +- `@dependabot reopen` will reopen this PR if it is closed +- `@dependabot close` will close this PR and stop Dependabot recreating +it. You can achieve the same result by closing it manually +- `@dependabot show ignore conditions` will show all +of the ignore conditions of the specified dependency +- `@dependabot ignore this major version` will close this PR and stop +Dependabot creating any more for this major version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this minor version` will close this PR and stop +Dependabot creating any more for this minor version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this dependency` will close this PR and stop +Dependabot creating any more for this dependency (unless you reopen the +PR or upgrade to it yourself) + + +
+ +Signed-off-by: dependabot[bot] +Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> (fa9cd95) +- Ban Python completely (no exceptions) (3578391) +- Standardize repo structure (hyperpolymath/mustfile pattern) + +- Add .machine_readable/ with 6 SCM files (STATE, META, ECOSYSTEM, AGENTIC, NEUROSYM, PLAYBOOK) +- Add Mustfile and justfile (replacing any Makefiles) +- Update .claude/CLAUDE.md with .machine_readable reference +- Clean up duplicate documentation files + +🤖 Generated with [Claude Code](https://claude.com/claude-code) + +Co-Authored-By: Claude Opus 4.5 (ed73a90) +- Bump actions/checkout from 4.1.1 to 6.0.1 (#3) + +[//]: # (dependabot-start) +⚠️ **Dependabot is rebasing this PR** ⚠️ + +Rebasing might not happen immediately, so don't worry if this takes some +time. + +Note: if you make any changes to this PR yourself, they will take +precedence over the rebase. + +--- + +[//]: # (dependabot-end) + +Bumps [actions/checkout](https://github.com/actions/checkout) from 4.1.1 +to 6.0.1. +
+Release notes +

Sourced from actions/checkout's +releases.

+
+

v6.0.1

+

What's Changed

+ +

Full Changelog: https://github.com/actions/checkout/compare/v6...v6.0.1

+

v6.0.0

+

What's Changed

+ +

Full Changelog: https://github.com/actions/checkout/compare/v5.0.0...v6.0.0

+

v6-beta

+

What's Changed

+

Updated persist-credentials to store the credentials under +$RUNNER_TEMP instead of directly in the local git +config.

+

This requires a minimum Actions Runner version of v2.329.0 +to access the persisted credentials for Docker +container action scenarios.

+

v5.0.1

+

What's Changed

+ +

Full Changelog: https://github.com/actions/checkout/compare/v5...v5.0.1

+

v5.0.0

+

What's Changed

+ +

⚠️ Minimum Compatible Runner Version

+

v2.327.1
+Release +Notes

+

Make sure your runner is updated to this version or newer to use this +release.

+

Full Changelog: https://github.com/actions/checkout/compare/v4...v5.0.0

+

v4.3.1

+

What's Changed

+ + +
+

... (truncated)

+
+
+Changelog +

Sourced from actions/checkout's +changelog.

+
+

Changelog

+

v6.0.0

+ +

v5.0.1

+ +

v5.0.0

+ +

v4.3.1

+ +

v4.3.0

+ +

v4.2.2

+ +

v4.2.1

+ +

v4.2.0

+ +

v4.1.7

+ +

v4.1.6

+ +

v4.1.5

+ + +
+

... (truncated)

+
+
+Commits + +
+
+ + +[![Dependabot compatibility +score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=4.1.1&new-version=6.0.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) + +Dependabot will resolve any conflicts with this PR as long as you don't +alter it yourself. You can also trigger a rebase manually by commenting +`@dependabot rebase`. + +[//]: # (dependabot-automerge-start) +[//]: # (dependabot-automerge-end) + +--- + +
+Dependabot commands and options +
+ +You can trigger Dependabot actions by commenting on this PR: +- `@dependabot rebase` will rebase this PR +- `@dependabot recreate` will recreate this PR, overwriting any edits +that have been made to it +- `@dependabot merge` will merge this PR after your CI passes on it +- `@dependabot squash and merge` will squash and merge this PR after +your CI passes on it +- `@dependabot cancel merge` will cancel a previously requested merge +and block automerging +- `@dependabot reopen` will reopen this PR if it is closed +- `@dependabot close` will close this PR and stop Dependabot recreating +it. You can achieve the same result by closing it manually +- `@dependabot show ignore conditions` will show all +of the ignore conditions of the specified dependency +- `@dependabot ignore this major version` will close this PR and stop +Dependabot creating any more for this major version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this minor version` will close this PR and stop +Dependabot creating any more for this minor version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this dependency` will close this PR and stop +Dependabot creating any more for this dependency (unless you reopen the +PR or upgrade to it yourself) + + +
+ +Signed-off-by: dependabot[bot] +Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> +Co-authored-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> (6c0f17a) +- Bump peter-evans/repository-dispatch from 3.0.0 to 4.0.1 (#5) + +Bumps +[peter-evans/repository-dispatch](https://github.com/peter-evans/repository-dispatch) +from 3.0.0 to 4.0.1. +
+Release notes +

Sourced from peter-evans/repository-dispatch's +releases.

+
+

v4.0.1

+

What's Changed

+ +

Full Changelog: https://github.com/peter-evans/repository-dispatch/compare/v4.0.0...v4.0.1

+

Repository Dispatch v4.0.0

+

⚙️ Requires Actions +Runner v2.327.1 or later if you are using a self-hosted runner for +Node 24 support.

+

What's Changed

+ + +
+

... (truncated)

+
+
+Commits +
    +
  • 28959ce +Fix node version in actions.yml (#433)
  • +
  • 25d29c2 +build(deps-dev): bump @​types/node in the npm group (#432)
  • +
  • 830136c +build(deps): bump the github-actions group with 3 updates (#431)
  • +
  • 2c856c6 +ci: update dependabot config
  • +
  • 6673907 +build(deps-dev): bump @​types/node from 18.19.127 to +18.19.129 (#429)
  • +
  • 952a211 +build(deps): bump peter-evans/repository-dispatch from 3 to 4 (#428)
  • +
  • 5fc4efd +docs: update readme
  • +
  • a628c95 +feat: v4 (#427)
  • +
  • de78ac1 +build(deps-dev): bump @​vercel/ncc from 0.38.3 to 0.38.4 +(#425)
  • +
  • f49fa7f +build(deps-dev): bump @​types/node from 18.19.124 to +18.19.127 (#426)
  • +
  • Additional commits viewable in compare +view
  • +
+
+
+ + +[![Dependabot compatibility +score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=peter-evans/repository-dispatch&package-manager=github_actions&previous-version=3.0.0&new-version=4.0.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) + +Dependabot will resolve any conflicts with this PR as long as you don't +alter it yourself. You can also trigger a rebase manually by commenting +`@dependabot rebase`. + +[//]: # (dependabot-automerge-start) +[//]: # (dependabot-automerge-end) + +--- + +
+Dependabot commands and options +
+ +You can trigger Dependabot actions by commenting on this PR: +- `@dependabot rebase` will rebase this PR +- `@dependabot recreate` will recreate this PR, overwriting any edits +that have been made to it +- `@dependabot merge` will merge this PR after your CI passes on it +- `@dependabot squash and merge` will squash and merge this PR after +your CI passes on it +- `@dependabot cancel merge` will cancel a previously requested merge +and block automerging +- `@dependabot reopen` will reopen this PR if it is closed +- `@dependabot close` will close this PR and stop Dependabot recreating +it. You can achieve the same result by closing it manually +- `@dependabot show ignore conditions` will show all +of the ignore conditions of the specified dependency +- `@dependabot ignore this major version` will close this PR and stop +Dependabot creating any more for this major version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this minor version` will close this PR and stop +Dependabot creating any more for this minor version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this dependency` will close this PR and stop +Dependabot creating any more for this dependency (unless you reopen the +PR or upgrade to it yourself) + + +
+ +Signed-off-by: dependabot[bot] +Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> +Co-authored-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com> (a8430d3) +- Bump github/codeql-action from 3.27.0 to 4.31.9 (#4) + +Bumps [github/codeql-action](https://github.com/github/codeql-action) +from 3.27.0 to 4.31.9. +
+Release notes +

Sourced from github/codeql-action's +releases.

+
+

v4.31.9

+

CodeQL Action Changelog

+

See the releases +page for the relevant changes to the CodeQL CLI and language +packs.

+

4.31.9 - 16 Dec 2025

+

No user facing changes.

+

See the full CHANGELOG.md +for more information.

+

v4.31.8

+

CodeQL Action Changelog

+

See the releases +page for the relevant changes to the CodeQL CLI and language +packs.

+

4.31.8 - 11 Dec 2025

+
    +
  • Update default CodeQL bundle version to 2.23.8. #3354
  • +
+

See the full CHANGELOG.md +for more information.

+

v4.31.7

+

CodeQL Action Changelog

+

See the releases +page for the relevant changes to the CodeQL CLI and language +packs.

+

4.31.7 - 05 Dec 2025

+
    +
  • Update default CodeQL bundle version to 2.23.7. #3343
  • +
+

See the full CHANGELOG.md +for more information.

+

v4.31.6

+

CodeQL Action Changelog

+

See the releases +page for the relevant changes to the CodeQL CLI and language +packs.

+

4.31.6 - 01 Dec 2025

+

No user facing changes.

+

See the full CHANGELOG.md +for more information.

+

v4.31.5

+

CodeQL Action Changelog

+

See the releases +page for the relevant changes to the CodeQL CLI and language +packs.

+

4.31.5 - 24 Nov 2025

+ +
+

... (truncated)

+
+
+Changelog +

Sourced from github/codeql-action's +changelog.

+
+

CodeQL Action Changelog

+

See the releases +page for the relevant changes to the CodeQL CLI and language +packs.

+

[UNRELEASED]

+

No user facing changes.

+

4.31.9 - 16 Dec 2025

+

No user facing changes.

+

4.31.8 - 11 Dec 2025

+
    +
  • Update default CodeQL bundle version to 2.23.8. #3354
  • +
+

4.31.7 - 05 Dec 2025

+
    +
  • Update default CodeQL bundle version to 2.23.7. #3343
  • +
+

4.31.6 - 01 Dec 2025

+

No user facing changes.

+

4.31.5 - 24 Nov 2025

+
    +
  • Update default CodeQL bundle version to 2.23.6. #3321
  • +
+

4.31.4 - 18 Nov 2025

+

No user facing changes.

+

4.31.3 - 13 Nov 2025

+
    +
  • CodeQL Action v3 will be deprecated in December 2026. The Action now +logs a warning for customers who are running v3 but could be running v4. +For more information, see Upcoming +deprecation of CodeQL Action v3.
  • +
  • Update default CodeQL bundle version to 2.23.5. #3288
  • +
+

4.31.2 - 30 Oct 2025

+

No user facing changes.

+

4.31.1 - 30 Oct 2025

+
    +
  • The add-snippets input has been removed from the +analyze action. This input has been deprecated since CodeQL +Action 3.26.4 in August 2024 when this removal was announced.
  • +
+

4.31.0 - 24 Oct 2025

+
    +
  • Bump minimum CodeQL bundle version to 2.17.6. #3223
  • +
  • When SARIF files are uploaded by the analyze or +upload-sarif actions, the CodeQL Action automatically +performs post-processing steps to prepare the data for the upload. +Previously, these post-processing steps were only performed before an +upload took place. We are now changing this so that the post-processing +steps will always be performed, even when the SARIF files are not +uploaded. This does not change anything for the +upload-sarif action. For analyze, this may +affect Advanced Setup for CodeQL users who specify a value other than +always for the upload input. #3222
  • +
+ +
+

... (truncated)

+
+
+Commits +
    +
  • 5d4e8d1 +Merge pull request #3371 +from github/update-v4.31.9-998798e34
  • +
  • 1dc115f +Update changelog for v4.31.9
  • +
  • 998798e +Merge pull request #3352 +from github/nickrolfe/jar-min-ff-cleanup
  • +
  • 5eb7519 +Merge pull request #3358 +from github/henrymercer/database-upload-telemetry
  • +
  • d29eddb +Extract version number to constant
  • +
  • e962687 +Merge branch 'main' into henrymercer/database-upload-telemetry
  • +
  • 19c7f96 +Rename isOverlayBase
  • +
  • ae5de9a +Use getErrorMessage in log too
  • +
  • 0cb8633 +Prefer performance.now()
  • +
  • c07cc0d +Merge pull request #3351 +from github/henrymercer/ghec-dr-determine-tools-vers...
  • +
  • Additional commits viewable in compare +view
  • +
+
+
+ + +[![Dependabot compatibility +score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action&package-manager=github_actions&previous-version=3.27.0&new-version=4.31.9)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) + +Dependabot will resolve any conflicts with this PR as long as you don't +alter it yourself. You can also trigger a rebase manually by commenting +`@dependabot rebase`. + +[//]: # (dependabot-automerge-start) +[//]: # (dependabot-automerge-end) + +--- + +
+Dependabot commands and options +
+ +You can trigger Dependabot actions by commenting on this PR: +- `@dependabot rebase` will rebase this PR +- `@dependabot recreate` will recreate this PR, overwriting any edits +that have been made to it +- `@dependabot merge` will merge this PR after your CI passes on it +- `@dependabot squash and merge` will squash and merge this PR after +your CI passes on it +- `@dependabot cancel merge` will cancel a previously requested merge +and block automerging +- `@dependabot reopen` will reopen this PR if it is closed +- `@dependabot close` will close this PR and stop Dependabot recreating +it. You can achieve the same result by closing it manually +- `@dependabot show ignore conditions` will show all +of the ignore conditions of the specified dependency +- `@dependabot ignore this major version` will close this PR and stop +Dependabot creating any more for this major version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this minor version` will close this PR and stop +Dependabot creating any more for this minor version (unless you reopen +the PR or upgrade to it yourself) +- `@dependabot ignore this dependency` will close this PR and stop +Dependabot creating any more for this dependency (unless you reopen the +PR or upgrade to it yourself) + + +
+ +Signed-off-by: dependabot[bot] +Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> (c452393) +- Add license badges to README (6ea7de1) +- Add code of conduct (9e7b33d) +- Add contributing guide (a88ebf4) +- Add CodeQL security scanning (c0f670f) +- Add sponsorship configuration (39c4972) +- Add dependabot configuration (69c1a71) +- Add README.adoc and ROADMAP.adoc for project specification (#2) + +- README.adoc: Documents current project state (specification pending), + lists components (MAA Framework, Oblíbený language, Aletheia microkernel), + covers project scope, infrastructure, and language policy summary +- ROADMAP.adoc: Outlines 6 development phases from foundation through + ecosystem, includes architecture vision diagram + +Both files accurately reflect that this is currently a governance/ +infrastructure hub awaiting specification uploads. + +Co-authored-by: Claude (fe56bd2) +- Update README.adoc (310d8f4) +- Add instant-sync for automatic forge propagation (852e964) +- Update language policy: Add Gleam, Tauri 2.0+, Dioxus (6cd23c0) +- Add language policy CLAUDE.md (fa3e76b) +- Add README.adoc + +🤖 Generated with Claude Code (936f190) +- Remove contamination (3d9ba03) +- Create README.md (e955bc0) + + +### Security + +- Harden mirror workflow and add security documentation (#1) (238b896) + + diff --git a/EXPLAINME-new.adoc b/EXPLAINME-new.adoc index 71309b5..2258684 100644 --- a/EXPLAINME-new.adoc +++ b/EXPLAINME-new.adoc @@ -9,17 +9,17 @@ This file backs every factual claim in link:README.adoc[README.adoc] with code p == Claim-to-implementation map -=== Aletheia builds, passes 29 unit tests, has zero dependencies +=== Aletheia builds, passes 66 unit + 45 integration tests, has zero dependencies [quote, README.adoc] ____ -Aletheia microkernel (Rust): Builds, 29 unit tests passing, zero dependencies.____ +Aletheia microkernel (Rust): Builds, 66 unit + 45 integration tests passing, zero dependencies.____ How this is implemented:: -`link:aletheia/[]` contains the Rust microkernel. `link:.github/workflows/rust-ci.yml[]` gates `cargo build`, `cargo test`, and formatting. CI is green. The `Cargo.toml` has zero external dependencies (enforced by CI). +`link:aletheia/[]` contains the Rust microkernel. `link:.github/workflows/rust-ci.yml[]` gates `cargo build` (debug + release), the full `cargo test` suite, formatting, `clippy -- -D warnings`, and the zero-dependency assertion — each mirrored by a root Justfile recipe (`just check`). CI is green. The `Cargo.toml` has zero external dependencies (enforced by CI). Caveat:: -29 unit tests pass. 27 of 29 integration tests *fail by design*—the integration test suite specifies behaviour that the CLI does not yet implement (#124). The failing integration tests are not regressions; they are specifications of unwritten code. +66 unit + 45 integration tests pass. The integration suite *was* a specification for an unbuilt CLI (27 of 29 failing by design, #124); the CLI has since been built to the upstream RSR criteria and the suite is in the CI gate. === CI is green across every enabled workflow and each gate has been shown capable of failing @@ -41,7 +41,7 @@ How this is implemented:: `link:tests/[]` contains the test suite. The tier classification (CRG Tier-C) is documented in the estate standards. Caveat:: -The test suite exists and runs. The "E2E, property, aspect, benchmarks" categories describe the *structure* of the suite, not all passing results. The integration (E2E) tests largely fail by design (#124). +The test suite exists and runs. The "E2E, property, aspect, benchmarks" categories describe the *structure* of the suite, not all passing results. The integration (E2E) tests pass: 45/45 since the CLI rebuild closed #124. === absolute-zero is a git submodule pinned here @@ -63,7 +63,7 @@ How this is implemented:: The paradigm is defined in the governance and specification documents. Aletheia is the microkernel intended to enforce it. The paradigm definition exists; the enforcement mechanism is under construction. Caveat:: -**The paradigm is stated; the enforcement is not yet implemented.** Aletheia's CLI surface (the mechanism through which accountability checks would be invoked) is unbuilt (#124). The paradigm is a design intent with a nascent implementation, not a deployed system. +**The paradigm is stated; the enforcement covers the offline file-presence subset.** Aletheia's CLI surface exists since #124 closed (26 checks, machine-readable output), so invocation is no longer the gap — depth of checking (proofs, containers, Scorecard) still lives with the hypatia oracle, not here. == Dogfooded Across The Account @@ -94,12 +94,12 @@ Caveat:: [CAUTION] ==== -**CLI surface is unbuilt (Issue #124).** `main.rs` wires 3 checks. The integration test suite specifies far more. 27 of 29 integration tests fail because the implementing code does not exist yet. +**CLI surface rebuilt 2026-09-21 (was: unbuilt, #124).** 26 checks over the RSR v2 offline subset; 66 unit + 45 integration tests pass; hypatia oracle stays normative. ==== [CAUTION] ==== -**23 clippy findings (Issue #125).** Mostly dead code left by the unbuilt CLI. Not yet a blocking gate. +**Clippy clean and blocking since 2026-09-21 (was: 23 findings, #125).** Findings wired into real code; `-D warnings` in CI and `just lint`. ==== [CAUTION] @@ -124,7 +124,7 @@ Caveat:: | Path | Proves / Implements | `aletheia/` -| Rust microkernel (builds, 29 unit tests, zero dependencies) +| Rust microkernel (builds, 66 unit + 45 integration tests, zero dependencies) | `absolute-zero/` | CNO formalisation (git submodule, upstream pointer) diff --git a/EXPLAINME.adoc b/EXPLAINME.adoc index e6aded2..304642f 100644 --- a/EXPLAINME.adoc +++ b/EXPLAINME.adoc @@ -148,9 +148,9 @@ Hypatia's compliance scanner | `contractile.just` + `contractiles/` — shared build recipes | Universal across hyperpolymath repos -| *Guix/Nix* -| `guix.scm` + `flake.nix` — reproducible build environments -| Ecosystem-wide policy enforced by guix-nix-policy.yml CI +| *Guix (preferred dev shell; no manifest in tree)* +| QUICKSTART-DEV prescribes `guix shell`, but no `guix.scm`/`manifest.scm` is committed — environment setup is currently manual +| Ecosystem-wide where adopted (`flake.nix` retired estate-wide 2026-06; no guix-nix-policy gate in this repo) |=== == File Map diff --git a/Justfile b/Justfile index 8c7294f..ad91b21 100644 --- a/Justfile +++ b/Justfile @@ -17,12 +17,12 @@ build: cd aletheia && cargo build --locked --all-targets cd aletheia && cargo build --locked --release -# Run aletheia's unit tests (--bins only; see #124 note in recipe) +# Run aletheia's full test suite (unit + integration; mirrors rust-ci.yml) test: - # `--bins` is deliberate. The integration suite is a specification for a CLI - # that has not been built yet — 27 of 29 fail by design (issue #124). - # Do NOT add `--tests` here to make the bar look green. - cd aletheia && cargo test --locked --bins + # The integration suite used to be a specification for an unbuilt CLI + # (issue #124: 27 of 29 failing by design). It is green now, so the + # full suite runs here and in CI. + cd aletheia && cargo test --locked # Check formatting (does not modify files) fmt: @@ -32,13 +32,11 @@ fmt: fmt-fix: cd aletheia && cargo fmt -# Lint aletheia (not yet -D warnings — issue #125) +# Lint aletheia, warnings denied (mirrors rust-ci.yml; issue #125) lint: - # 23 findings remain, mostly dead code that exists because the CLI is - # unwired (#124). When #125 closes, add `-- -D warnings` here AND to - # rust-ci.yml in the same change, so local and CI never disagree about - # what "lint passes" means. - cd aletheia && cargo clippy --locked --all-targets + # The `-- -D warnings` flag is here AND in rust-ci.yml in the same + # change, so local and CI never disagree about what "lint passes" means. + cd aletheia && cargo clippy --locked --all-targets -- -D warnings # Enforce the zero-dependency RSR Bronze constraint (mirrors rust-ci.yml) deps-check: @@ -53,7 +51,7 @@ deps-check: echo "OK: zero dependencies" # Everything the root CI gate runs, in the same order -check: build test fmt deps-check +check: build test fmt lint deps-check # Self-verify: run aletheia against this repository self-verify: @@ -115,17 +113,17 @@ help-me: # Print the current CRG grade (reads from READINESS.md '**Current Grade:** X' line) crg-grade: - @grade=$$(grep -oP '(?<=\*\*Current Grade:\*\* )[A-FX]' READINESS.md 2>/dev/null | head -1); \ - [ -z "$$grade" ] && grade="X"; \ - echo "$$grade" + @grade=$(grep -oP '(?<=\*\*Current Grade:\*\* )[A-FX]' READINESS.md 2>/dev/null | head -1); \ + [ -z "$grade" ] && grade="X"; \ + echo "$grade" # Generate a shields.io badge markdown for the current CRG grade # Looks for '**Current Grade:** X' in READINESS.md; falls back to X crg-badge: - @grade=$$(grep -oP '(?<=\*\*Current Grade:\*\* )[A-FX]' READINESS.md 2>/dev/null | head -1); \ - [ -z "$$grade" ] && grade="X"; \ - case "$$grade" in \ + @grade=$(grep -oP '(?<=\*\*Current Grade:\*\* )[A-FX]' READINESS.md 2>/dev/null | head -1); \ + [ -z "$grade" ] && grade="X"; \ + case "$grade" in \ A) color="brightgreen" ;; B) color="green" ;; C) color="yellow" ;; \ D) color="orange" ;; E) color="red" ;; F) color="critical" ;; \ *) color="lightgrey" ;; esac; \ - echo "[![CRG $$grade](https://img.shields.io/badge/CRG-$$grade-$$color?style=flat-square)](https://github.com/hyperpolymath/standards/tree/main/component-readiness-grades)" + echo "[![CRG $grade](https://img.shields.io/badge/CRG-$grade-$color?style=flat-square)](https://github.com/hyperpolymath/standards/tree/main/component-readiness-grades)" diff --git a/QUICKSTART-DEV.adoc b/QUICKSTART-DEV.adoc index 65f59d9..d4c3770 100644 --- a/QUICKSTART-DEV.adoc +++ b/QUICKSTART-DEV.adoc @@ -32,6 +32,7 @@ nix develop ---- git clone https://github.com/hyperpolymath/maa-framework.git cd maa-framework +git submodule update --init # absolute-zero/ (pinned upstream proofs) just setup-dev ---- diff --git a/README.adoc b/README.adoc index 70c4f01..0b3cde8 100644 --- a/README.adoc +++ b/README.adoc @@ -68,14 +68,14 @@ The Certified Null Operation (CNO) formalisation is developed upstream in `link: == Known gaps -[CAUTION] +[NOTE] ==== -**Aletheia CLI is unbuilt.** `main.rs` wires 3 checks. The integration suite specifies far more. 27 of 29 integration tests fail by design (not by regression), because the code to make them pass does not exist yet. Tracked in Issue #124. +**Aletheia CLI rebuilt 2026-09-21 (was: unbuilt, #124).** The CLI now implements the RSR v2 offline file-presence subset (26 checks: 13 Bronze, 12 Silver, 1 Gold) with human/JSON/SARIF/HTML/badge/quiet/verbose output; 66 unit + 45 integration tests pass. The hypatia `rsr-conformance` oracle stays normative — aletheia evaluates only the offline subset and says so in verbose output. ==== -[CAUTION] +[NOTE] ==== -**23 clippy findings in Aletheia**, mostly dead code that exists because of the unbuilt CLI (#124). Not yet a blocking gate. Tracked in Issue #125. +**Clippy clean and blocking since 2026-09-21 (was: 23 findings, #125).** The findings were wired into real code, not silenced; `cargo clippy --all-targets -- -D warnings` gates in `rust-ci.yml` and `just lint`. ==== [CAUTION] diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc index 79f6471..ccfb46c 100644 --- a/TEST-NEEDS.adoc +++ b/TEST-NEEDS.adoc @@ -26,35 +26,36 @@ submodule — its tests run in the upstream repo, not in this repository’s CI. ____ -==== The 27 failing aletheia integration tests +____ +*Updated 2026-09-21.* aletheia unit tests went 29 → *66* +and the integration suite went 2 pass / 27 fail → *45 pass / 0 fail* +(issues #124 and #125 closed). The root `+rust-ci.yml+` gate was +restored re-pointed at `+aletheia/+` with clippy blocking. `+absolute-zero/+` +tests still run in the upstream repo. +____ + +==== The 45 passing aletheia integration tests (was: 27 failing by design) -They fail *by design, not by regression* — and the distinction matters: +*Resolved 2026-09-21 (issue #124).* The CLI the suite specified has been +written — against the upstream RSR criteria, not against the suite's +strings, exactly as the caution below demanded. `+tests/integration_tests.rs+` +(45 tests) runs the built binary end to end: 26 checks over the offline +file-presence subset, `+--help+`, `+--version+`, `+--verbose+`, +`+--badge+`, `+--init-hook+`, `+--format=+`, HTML output. The suite is in +the CI gate and in `+just test+` (full `+cargo test --locked+`). -`+tests/integration_tests.rs+` (806 lines, 32 tests) describes a CLI -that has never been written: 16 Bronze checks plus Silver, `+--help+`, -`+--version+`, `+--verbose+`, `+--badge+`, `+--init-hook+`, -`+--format=+`, HTML output. `+main.rs+` currently parses `++` -plus `+--json+`/`+--sarif+` and wires *three* checks. +The historical note is preserved because the warning still applies to +future checks: ____ -[!CAUTION] Every assertion in that suite is on a *stdout substring*, -e.g. -`+assert!(stdout.contains("Bronze-level RSR compliance: ACHIEVED"))+`. +[!CAUTION] Every assertion in that suite is on a *stdout substring*. That pins the _wording_ of the verdict and says nothing about what the checks must verify — so the whole suite can be satisfied by checks that -verify nothing. - -*Do not "`fix`" these by writing checks that emit the expected strings.* -That would invent a definition of RSR compliance and encode it as -canonical, while a definition already exists in the estate (hypatia’s -`+rsr-conformance+` oracle). Tracked as issue #124, blocked on that -source-of-truth ruling. +verify nothing. New checks must cite their upstream criterion (see +`+SSOT_PROVENANCE+` in `+aletheia/src/checks.rs+`); never write a check +to match a string. ____ -They are deliberately *not* in the CI gate and *not* in `+just test+` -(`+--bins+` only). Adding them without resolving #124 would make the bar -green by breaking it. - === Current State [width="100%",cols="50%,25%,25%",options="header",] diff --git a/aletheia/.github/workflows/README.md b/aletheia/.github/workflows/README.md index c81a136..b9727bc 100644 --- a/aletheia/.github/workflows/README.md +++ b/aletheia/.github/workflows/README.md @@ -32,10 +32,15 @@ broken for over a month. ## Where the real gate lives now -`/.github/workflows/rust-ci.yml`, at the repository root. It builds debug and release, -runs the 26 unit tests, checks formatting, and enforces the zero-dependency constraint -from `aletheia/CLAUDE.md`. Read its header comment for what it deliberately does *not* -gate yet. +`/.github/workflows/rust-ci.yml`, at the repository root. (It went missing for a +while: the commit that moved the crate to `aletheia/` deleted the old root gate +without re-pointing it, so main had no Rust CI until the gate was restored on +2026-09-21.) It builds debug (`--all-targets`) and release, runs the full test +suite (66 unit + 45 integration and counting), checks formatting, runs clippy +with warnings denied (issue #125), and enforces the zero-dependency constraint +from `aletheia/CLAUDE.md`. Each job has a same-named recipe in the root Justfile +— `just check` runs the same steps locally. Read the workflow header comment for +what it deliberately does *not* gate yet. ## If you are changing CI for aletheia diff --git a/aletheia/.well-known/ai.txt b/aletheia/.well-known/ai.txt index 2a408c4..41e14cc 100644 --- a/aletheia/.well-known/ai.txt +++ b/aletheia/.well-known/ai.txt @@ -15,7 +15,7 @@ Overlay: Palimpsest-0.8 # Attribution Requirements Attribution: required -Attribution-url: https://gitlab.com/maa-framework/6-the-foundation/aletheia +Attribution-url: https://github.com/hyperpolymath/maa-framework/tree/main/aletheia Attribution-name: MAA Framework Contributors # Consent Framework diff --git a/aletheia/.well-known/security.txt b/aletheia/.well-known/security.txt index 3bd1bc5..56d2ed0 100644 --- a/aletheia/.well-known/security.txt +++ b/aletheia/.well-known/security.txt @@ -1,14 +1,13 @@ # Aletheia Security Contact Information # RFC 9116 Compliant (https://www.rfc-editor.org/rfc/rfc9116.html) -# Last Updated: 2025-11-22 +# Last Updated: 2026-09-21 (repointed from the dead GitLab mirror to GitHub) Contact: mailto:security@maa-framework.org -Contact: https://gitlab.com/maa-framework/6-the-foundation/aletheia/-/issues/new?issue[confidential]=true +Contact: https://github.com/hyperpolymath/maa-framework/security/advisories/new Expires: 2026-12-31T23:59:59.000Z -Encryption: https://gitlab.com/maa-framework/.gpg Preferred-Languages: en -Canonical: https://gitlab.com/maa-framework/6-the-foundation/aletheia/-/raw/main/.well-known/security.txt -Policy: https://gitlab.com/maa-framework/6-the-foundation/aletheia/-/blob/main/SECURITY.md +Canonical: https://raw.githubusercontent.com/hyperpolymath/maa-framework/main/aletheia/.well-known/security.txt +Policy: https://github.com/hyperpolymath/maa-framework/blob/main/aletheia/SECURITY.md Hiring: https://maa-framework.org/careers # Acknowledgments diff --git a/aletheia/CLAUDE.md b/aletheia/CLAUDE.md index e105977..c82f65d 100644 --- a/aletheia/CLAUDE.md +++ b/aletheia/CLAUDE.md @@ -49,20 +49,20 @@ Aletheia maintains **zero unsafe blocks** for RSR compliance. ## Architecture Principles -### Module Layout (updated 2026-07-27 — this section was stale) +### Module Layout (updated 2026-09-21 — re-measured after the CLI rebuild) > **This file used to say "all core logic lives in `src/main.rs` (~950 lines)" and > "don't split into modules unless >1000 lines". Both were out of date.** The crate has -> been split for some time. Measured 2026-07-27: +> been split for some time. Measured 2026-09-21 (`wc -l`): | File | Lines | Contents | |---|---|---| -| `src/main.rs` | 121 | CLI entry, arg parsing, `verify_repository`, exit policy | -| `src/checks.rs` | 316 | `check_documentation`, `check_spdx_headers`, `check_workflow_pins`, `check_path_security`, glob matching | -| `src/config.rs` | 243 | `.aletheia.toml` loading, hand-rolled TOML parse | -| `src/output.rs` | 226 | human / JSON / SARIF report printing, date+time formatting | -| `src/types.rs` | 89 | `ComplianceLevel`, `CheckResult`, `ComplianceReport`, … | -| **total** | **995** | | +| `src/main.rs` | 422 | CLI entry, arg parsing, init-hook installer, exit policy | +| `src/checks.rs` | 1932 | 26-check inventory, `Scanner` walks, symlink sweep, pure predicates | +| `src/config.rs` | 538 | `.aletheia.toml` (level / checks / ignore), hand-rolled TOML | +| `src/output.rs` | 626 | human / JSON / SARIF / HTML / badge / quiet / verbose, exit codes | +| `src/types.rs` | 246 | `ComplianceLevel`, `CheckResult`, `ComplianceReport`, scoring | +| **total src** | **3764** | (+ 1236 lines in `tests/integration_tests.rs`) | The single-file rationale still applies *in spirit* — prefer few, auditable files over deep abstraction — but do not "restore" the single-file layout, and do not treat 1000 @@ -77,27 +77,25 @@ lines as a threshold that has not yet been crossed. - Don't create abstractions prematurely - Don't add utility files for one-off functions -### Known gap: the CLI is unfinished (issues #124 / #125) +### Resolved 2026-09-21: the CLI rebuild (was: issues #124 / #125) -`main.rs` parses only `` plus `--json` / `--sarif`, and wires **three** -checks. `tests/integration_tests.rs` is 806 lines / 32 tests describing a much larger -tool (16 Bronze checks plus Silver, `--help`, `--version`, `--verbose`, `--badge`, -`--init-hook`, `--format=`, HTML output). **2 pass, 27 fail.** +The CLI now implements the RSR v2 offline file-presence subset: 26 checks +(13 Bronze, 12 Silver, 1 Gold) across `src/{types,config,checks,output,main}.rs`, +TOML config, and human/JSON/SARIF/HTML/badge/quiet/verbose output with exit +codes 0–4. **66 unit + 45 integration tests pass; clippy `-D warnings` is a +blocking gate** (`rust-ci.yml` + `just lint`). -Two things to know before touching it: +Two lessons from the rebuild, still binding: -1. **Those tests assert on stdout substrings**, e.g. - `assert!(stdout.contains("Bronze-level RSR compliance: ACHIEVED"))`. They pin the - *wording* of the report and say nothing about what the checks must verify — so they - can be satisfied by checks that verify nothing. Treat them as a UI contract, not a - specification. -2. **A definition of RSR conformance already exists** elsewhere in the estate (hypatia's - `rsr-conformance` oracle). Writing checks to satisfy these strings risks creating a - second, divergent definition. Resolve the source-of-truth question first. +1. **The integration tests assert on stdout substrings** — a UI contract, not a + specification. Every check cites its upstream criterion (see `SSOT_PROVENANCE` + in `src/checks.rs`); never write a check to match a string. +2. **Hypatia's `rsr-conformance` oracle is normative.** Aletheia evaluates only + the offline subset and labels itself non-normative. Do not grow a second, + divergent definition of RSR here. -Most of the clippy findings in #125 are dead code that exists *because* those modules -are unwired. **Do not silence them with `#![allow(dead_code)]`** — the root Rust CI -header forbids it, and that dead code is the specification of the missing feature. +The #125 findings were wired into real code, not silenced. **Do not silence +future ones with `#![allow(dead_code)]`** — the root Rust CI header forbids it. ### Type Safety First @@ -311,7 +309,7 @@ aletheia/ │ ├── output.rs # human/JSON/SARIF (226) │ └── types.rs # core types (89) ├── tests/ -│ └── integration_tests.rs # 32 tests — 2 pass, 27 fail (issue #124) +│ └── integration_tests.rs # 45 tests, all passing (#124 closed 2026-09-21) ├── benches/ # Performance benchmarks ├── examples/ # Usage examples ├── fuzz/ # Fuzzing infrastructure @@ -417,7 +415,7 @@ For questions about this document or Aletheia development: --- -**Last Updated**: 2026-07-27 +**Last Updated**: 2026-09-21 **Version**: 1.2 *"Alētheia is not just absence of falsehood, but active unconcealment of truth."* diff --git a/aletheia/PROJECT_STATUS.adoc b/aletheia/PROJECT_STATUS.adoc index 05ecab6..04759ec 100644 --- a/aletheia/PROJECT_STATUS.adoc +++ b/aletheia/PROJECT_STATUS.adoc @@ -1,7 +1,7 @@ == Aletheia Project Status -*Version*: 1.0.0 *Status*: Stable / Maintenance *Last Updated*: -2026-02-05 +*Version*: 1.9.0 *Status*: Stable / Maintenance *Last Updated*: +2026-09-21 === Project Overview @@ -14,13 +14,13 @@ reversible operating systems (r-Minix). ==== Core Implementation (100% Complete) * *Main Application* (`+src/main.rs+`) -** ~950 lines of safe Rust code +** ~3,800 lines of safe Rust code (measured 2026-09-21) ** Zero dependencies ** Zero unsafe blocks -** 10 unit tests + 18 integration tests (28 total) +** 66 unit tests + 45 integration tests (111 total, 100% pass) ** 100% test pass rate -** Bronze-level RSR verification logic -** CLI with human, JSON, quiet, verbose output modes +** RSR v2 offline-subset verification (26 checks: 13 Bronze, 12 Silver, 1 Gold) +** CLI with human, JSON, SARIF, HTML, badge, quiet, verbose output + `.aletheia.toml` config ** Symlink detection and security warnings ** Verification timestamps @@ -50,10 +50,9 @@ Architecture, specifications, FAQ, quick start * `+Cargo.toml+` - Zero dependencies, MSRV 1.80 * `+Cargo.lock+` - Reproducible builds -* `+justfile+` / `+Justfile+` - Build automation -* `+flake.nix+` - Nix reproducible builds -* `+.gitlab-ci.yml+` - GitLab CI/CD pipeline -* `+.github/workflows/+` - 23 GitHub Actions workflows +* `+Justfile+` - Crate recipes (the root Justfile is canonical, wired to CI) +* `+.gitlab-ci.yml+` - Present for the GitLab mirror; the live gate is the root `+rust-ci.yml+` +* `+.github/workflows/+` - 16 files, ALL INERT (live gates live at the repo root) ==== Source Structure (100% Complete) @@ -66,33 +65,30 @@ Architecture, specifications, FAQ, quick start === RSR Bronze Compliance -*Self-Verification Result*: 16/16 checks passed (100%) - -.... -Documentation: 7/7 -Well-Known: 4/4 -Build System: 3/3 -Source: 2/2 -.... +*Self-Verification Result* (measured 2026-09-21): Bronze-level RSR +compliance ACHIEVED — 13/13 Bronze checks in the v2 offline subset pass +(23/26 overall; the 3 gaps are Silver: `+.tool-versions+`, +`+0-AI-MANIFEST.a2ml+`, `+rsr-profile.a2ml+`, which live at the repo +root, not in the vendored crate tree). === Code Metrics [cols=",",options="header",] |=== |Metric |Value -|Lines of Rust (main) |~950 -|Lines of Rust (tests) |~490 +|Lines of Rust (main, measured) |~3,800 +|Lines of Rust (tests, measured) |~1,240 |Dependencies |0 |Unsafe Blocks |0 -|Unit Tests |10 -|Integration Tests |18 +|Unit Tests |66 +|Integration Tests |45 |Clippy Warnings |0 |Format Issues |0 |=== === Security Posture -* *Attack Surface*: Minimal (~950 lines) +* *Attack Surface*: Minimal (~3,800 lines, zero dependencies, `#![forbid(unsafe_code)]`) * *Supply Chain Risk*: None (zero dependencies) * *Memory Safety*: 100% (Rust ownership, zero unsafe) * *Network Access*: None (offline-first) @@ -102,22 +98,22 @@ Source: 2/2 ==== Short-term [arabic] -. Tag v1.0.0 release +. Tag a release from the current tree (crate is at 1.9.0, untagged) . Publish to crates.io (optional) -==== Medium-term (v1.1.0+) +*Shipped 2026-09-21 (were medium-term):* SARIF output, `+.aletheia.toml+` +configuration, Silver-level RSR checks. + +==== Open [arabic] -. SARIF output format for CI/CD integration -. `+.aletheia.toml+` configuration file . Custom check definitions -. Silver-level RSR checks ''''' *Status Summary*: *Stable* -Aletheia v1.0.0 is feature-complete, well-tested, fully documented, and -achieves 100% Bronze-level RSR compliance. +Aletheia v1.9.0 is feature-complete, well-tested, fully documented, and +achieves Bronze-level RSR compliance on itself. _"`Aletheia: Unconcealing the truth in repository standards.`"_ diff --git a/aletheia/QUICK_REFERENCE.adoc b/aletheia/QUICK_REFERENCE.adoc index 93e873b..6bbe6b4 100644 --- a/aletheia/QUICK_REFERENCE.adoc +++ b/aletheia/QUICK_REFERENCE.adoc @@ -1,86 +1,109 @@ == Aletheia Quick Reference Card -One-page reference for Aletheia RSR compliance verification. +One-page reference for Aletheia RSR compliance verification (v1.9.0, +2026-09-21). Aletheia evaluates the *offline file-presence subset* of the +RSR v2 criteria (26 checks); the hypatia `rsr-conformance` oracle stays +normative. Criteria source: `hyperpolymath/standards` +`0-canon/rsr/rsr-criteria-v2.a2ml`. === 🚀 Installation [source,bash] ---- -# Quick install -curl -sSf https://gitlab.com/.../install.sh | bash +# Build from source (aletheia is vendored in maa-framework) +git clone https://github.com/hyperpolymath/maa-framework.git +cd maa-framework/aletheia && cargo build --release +# binary: target/release/aletheia (zero dependencies, MSRV 1.80) -# Or build from source -git clone https://gitlab.com/maa-framework/6-the-foundation/aletheia.git -cd aletheia && cargo build --release - -# Or use Cargo -cargo install --git https://gitlab.com/maa-framework/6-the-foundation/aletheia.git +# Or run the installer (same steps, installs to ~/.local/bin) +bash scripts/install.sh ---- === 💻 Usage [source,bash] ---- -# Verify current directory +# Verify current directory (human report, Bronze gate) aletheia -# or -cargo run -# Verify specific repository +# Verify a specific repository aletheia /path/to/repo -# or -cargo run -- /path/to/repo -# In Docker -docker run -v $(pwd):/repo aletheia:0.1.0 ----- +# Machine-readable output +aletheia --format json /path/to/repo +aletheia --format sarif . > results.sarif +aletheia --badge . > badge.svg # SVG compliance badge +aletheia -q . # quiet: prints only PASS or FAIL +aletheia -v . # verbose: suggestions, tiers, SSOT note + +# Config file (default: /.aletheia.toml) +aletheia --config my.toml . -=== ✅ RSR Bronze Requirements Checklist +# Install a pre-commit hook that runs aletheia +aletheia init-hook /path/to/repo +---- -==== Documentation (7 files) +=== ✅ The 26 checks (RSR v2 offline subset) -* [ ] `+README.md+` - Project overview -* [ ] `+LICENSE.txt+` - Open source license -* [ ] `+SECURITY.md+` - Security policy -* [ ] `+CONTRIBUTING.md+` - Contribution guide -* [ ] `+CODE_OF_CONDUCT.md+` - Community standards -* [ ] `+MAINTAINERS.md+` - Governance -* [ ] `+CHANGELOG.md+` - Version history +==== Bronze (13 — the gate) -==== .well-known (3 files) +* [ ] `scm-github` — `.github/` directory +* [ ] `justfile` — Justfile with real recipes +* [ ] `no-makefile` — no Makefile / Dockerfile +* [ ] `readme` — README.adoc (README.md accepted) +* [ ] `license-file` — LICENSE file (not LICENSE.txt) +* [ ] `security-policy` — SECURITY policy (root or .github/) +* [ ] `gitignore` — .gitignore + .gitattributes +* [ ] `spdx-headers` — SPDX headers on source files +* [ ] `no-secrets` — no committed secrets / key material +* [ ] `language-policy` — no banned languages +* [ ] `no-node-runtime` — no package.json runtime deps (Bun lockfile carves out) +* [ ] `ci-present` — CI pipeline under .github/workflows/ +* [ ] `licence-class` — LICENSE names a recognized identifier -* [ ] `+.well-known/security.txt+` - RFC 9116 security contact -* [ ] `+.well-known/ai.txt+` - AI policy -* [ ] `+.well-known/humans.txt+` - Attribution +==== Silver (12 — reported, non-fatal at the default gate) -==== Build System (3 files) +* [ ] `editorconfig`, `tool-versions` +* [ ] `coc` — CODE_OF_CONDUCT (root or .github/) +* [ ] `contributing` — CONTRIBUTING (root or .github/) +* [ ] `changelog` — CHANGELOG.adoc (or .md) +* [ ] `wellknown-core` — .well-known/{security.txt,ai.txt,humans.txt} +* [ ] `ai-manifest` — 0-AI-MANIFEST.a2ml +* [ ] `rsr-profile` — .machine_readable/rsr-profile.a2ml +* [ ] `sha-pinned` — actions pinned (full SHA, or tag covered by actions.lock) +* [ ] `hypatia-scan` — hypatia-scan.yml workflow +* [ ] `governance-wf` — governance.yml workflow +* [ ] `reuse` — LICENSES/ with full texts -* [ ] `+justfile+` - Build automation -* [ ] `+flake.nix+` - Nix reproducible builds -* [ ] `+.gitlab-ci.yml+` or `+.github/workflows/+` - CI/CD +==== Gold (1) -==== Source Structure (2 directories) +* [ ] `no-silent-skip` — no `|| echo SKIP` silent-green recipes -* [ ] `+src/+` - Source code -* [ ] `+tests/+` or `+test/+` - Tests +Plus automatic critical warnings for symlinks escaping the repo root. === 📊 Understanding Output .... -🔍 Aletheia - RSR Compliance Verification Report -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Aletheia - RSR Compliance Verification Report Repository: /path/to/repo +Verified: 2026-09-21T20:05:37Z + +[Documentation] + ✅ README.adoc (or README.md) [Bronze] + ✅ LICENSE file (not LICENSE.txt) [Bronze] + ❌ SECURITY policy (root or .github/) [Bronze] -📋 Documentation - ✅ README.md [Bronze] # Passes - ❌ LICENSE.txt [Bronze] # Fails +Score: 25/26 checks passed (96.2%) +Bronze-level RSR compliance: NOT MET # Exit code 1 -Score: 8/16 checks passed (50.0%) -⚠️ Bronze-level RSR compliance: NOT MET # Exit code 1 +Fix Suggestions + [security-policy] Add SECURITY.md with disclosure contact + response timeline; v2 2.1.3. .... -*Exit Codes*: - `+0+` - All Bronze checks pass (compliant) - `+1+` - One -or more Bronze checks fail (not compliant) +*Exit Codes*: `0` — required tier achieved; `1` — tier not met; `2` — +critical warning (escaping symlink); `3` — usage/path/config error; +`4` — invalid arguments. Gate tier via `[aletheia] level` in config +(default `bronze`). === 🛠️ Quick Fixes @@ -88,10 +111,11 @@ or more Bronze checks fail (not compliant) [source,bash] ---- -# Create all required docs at once -touch README.md LICENSE.txt SECURITY.md \ - CONTRIBUTING.md CODE_OF_CONDUCT.md \ - MAINTAINERS.md CHANGELOG.md +# .adoc is primary, .md accepted; LICENSE (never LICENSE.txt) +touch README.adoc LICENSE SECURITY.md \ + CONTRIBUTING.md CODE_OF_CONDUCT.md CHANGELOG.adoc +touch .gitignore .gitattributes +mkdir -p LICENSES # full licence texts, REUSE style ---- ==== Missing .well-known @@ -101,7 +125,7 @@ touch README.md LICENSE.txt SECURITY.md \ mkdir -p .well-known cat > .well-known/security.txt << 'EOF' Contact: mailto:security@example.org -Expires: 2026-12-31T23:59:59.000Z +Expires: 2027-12-31T23:59:59.000Z EOF touch .well-known/ai.txt .well-known/humans.txt @@ -111,114 +135,54 @@ touch .well-known/ai.txt .well-known/humans.txt [source,bash] ---- -# Create Justfile +# Capital-J Justfile with real recipes (stubs fail the check) cat > Justfile << 'EOF' build: - cargo build + cargo build --locked + test: - cargo test + cargo test --locked EOF -# Create basic flake.nix -echo '{}' > flake.nix - -# Create basic CI -touch .gitlab-ci.yml # or .github/workflows/ci.yml +# CI lives at .github/workflows/ (repo root only) +mkdir -p .github/workflows ---- -==== Missing Source Structure +==== Missing Source/Machine-Readable [source,bash] ---- -mkdir -p src tests -mv *.rs src/ # Move source files +mkdir -p src +touch 0-AI-MANIFEST.a2ml +mkdir -p .machine_readable && touch .machine_readable/rsr-profile.a2ml ---- -=== 🔧 Build Automation (Justfile) +=== 🔧 Config (.aletheia.toml) -[source,bash] ----- -just # List all commands -just build # Build project -just test # Run tests -just check # Run all checks -just validate # Verify RSR compliance -just clean # Clean build artifacts +[source,toml] ---- +[aletheia] +level = "bronze" # gate tier: bronze | silver | gold -=== 🐳 Docker Commands +[checks] +# Disable by check id, category, or tier alias: +# wellknown-core = false +# Machine-Readable = false +# silver = false -[source,bash] ----- -# Build image -docker build -t aletheia:0.1.0 . - -# Verify current directory -docker run -v $(pwd):/repo aletheia:0.1.0 - -# Verify specific path -docker run -v /path/to/repo:/repo aletheia:0.1.0 +[ignore] +files = ["CHANGELOG.adoc"] # glob patterns, repo-relative ---- === 🔄 CI/CD Integration -==== GitLab CI - -[source,yaml] ----- -rsr-compliance: - script: - - cargo install --git https://gitlab.com/.../aletheia.git - - aletheia ----- - -==== GitHub Actions - [source,yaml] ---- -- run: cargo install --git https://gitlab.com/.../aletheia.git -- run: aletheia ----- - -=== 📝 Quick Templates - -==== Minimal README.md - -[source,markdown] ----- -# Project Name - -Description - -## Usage -\`\`\`bash -command -\`\`\` - -## License -See [LICENSE.txt](LICENSE.txt) ----- - -==== Minimal SECURITY.md - -[source,markdown] ----- -# Security Policy - -Report vulnerabilities to: security@example.org - -See [.well-known/security.txt](.well-known/security.txt) ----- - -==== Minimal Justfile - -[source,makefile] ----- -build: - cargo build - -test: - cargo test +# No published image or crate: build from the vendored tree. +- uses: actions/checkout@v7.0.1 +- run: cargo build --locked --release + working-directory: aletheia +- run: ./aletheia/target/release/aletheia -q . ---- === 🆘 Troubleshooting @@ -226,11 +190,13 @@ test: [width="100%",cols="48%,52%",options="header",] |=== |Problem |Solution -|Path not found |Use absolute path: `+aletheia /full/path+` +|Path not found |Use an absolute path: `+aletheia /full/path+` |Permission denied |Check file permissions: `+ls -la+` -|Wrong directory |Run from repo root +|Wrong directory |Run from the repo root, or pass the path + +|Exit code 2 |An escaping symlink was found — inspect before overriding |Missing tool |Install Rust: `+curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \| sh+` @@ -238,25 +204,27 @@ test: === 📚 More Information -* *Full Docs*: See `+docs/+` directory -* *Quick Start*: `+docs/QUICK_START.md+` -* *FAQ*: `+docs/FAQ.md+` -* *Migration*: `+docs/MIGRATION-GUIDE.md+` -* *Architecture*: `+docs/ARCHITECTURE.md+` +* *AI guide*: `+CLAUDE.md+` (setup, gates, module layout) +* *Status*: `+PROJECT_STATUS.adoc+` +* *Architecture*: `+docs/ARCHITECTURE.adoc+` +* *Quick Start*: `+docs/QUICK_START.adoc+` +* *FAQ*: `+docs/FAQ.adoc+` +* *Silver level*: `+docs/SILVER-LEVEL.adoc+` === 🔗 Links * *Repository*: -https://gitlab.com/maa-framework/6-the-foundation/aletheia -* *Issues*: https://gitlab.com/…/aletheia/-/issues -* *Security*: See `+SECURITY.md+` +https://github.com/hyperpolymath/maa-framework/tree/main/aletheia +* *Issues*: https://github.com/hyperpolymath/maa-framework/issues +* *Security*: See `+SECURITY.md+` and `+.well-known/security.txt+` === 📞 Contact -* *Email*: maintainers@maa-framework.org * *Security*: security@maa-framework.org ''''' -*Version*: 0.1.0 | *RSR Level*: Bronze | *License*: MIT OR -Palimpsest-0.8 +*Version*: 1.9.0 | *RSR Level*: Bronze (self-verified) + Silver checks | +*License*: PMPL-1.0-or-later (code) + CC-BY-SA-4.0 (docs), see LICENSE — +under review, see the `aletheia-licence` open question in the root +STATE.a2ml diff --git a/aletheia/conformance/RSR-CONFORMANCE.adoc b/aletheia/conformance/RSR-CONFORMANCE.adoc index 062e473..0488f35 100644 --- a/aletheia/conformance/RSR-CONFORMANCE.adoc +++ b/aletheia/conformance/RSR-CONFORMANCE.adoc @@ -1,3 +1,14 @@ +[NOTE] +==== +2026-09-21: the record below dates from 2025-12-26 and describes the *v1* +check shape (16 checks: LICENSE.txt, lowercase justfile, flake.nix), +which the estate has since retired. Current state: aletheia implements +the RSR v2 offline file-presence subset (26 checks: 13 Bronze, 12 Silver, +1 Gold — see `SSOT_PROVENANCE` in `src/checks.rs`) and achieves +Bronze-level compliance on itself. The hypatia `rsr-conformance` oracle +is normative. This file is retained as a historical record. +==== + == RSR Conformance Report https://github.com/hyperpolymath/rhodium-standard-repositories[image:https://img.shields.io/badge/RSR-Bronze-cd7f32[RSR diff --git a/aletheia/docs/RSR-SPECIFICATION.adoc b/aletheia/docs/RSR-SPECIFICATION.adoc index d696c5a..55b2e5b 100644 --- a/aletheia/docs/RSR-SPECIFICATION.adoc +++ b/aletheia/docs/RSR-SPECIFICATION.adoc @@ -1,3 +1,14 @@ +[WARNING] +==== +Superseded. This v0.1.0 specification predates the estate's RSR v2 +criteria and no longer defines anything: the normative criteria text is +`hyperpolymath/standards` `0-canon/rsr/rsr-criteria-v2.a2ml`, the +normative evaluator is the hypatia `rsr-conformance` oracle, and +aletheia's implemented subset is defined by the inventory table in +`src/checks.rs` (see `SSOT_PROVENANCE`). Do not extend this document — +it is retained for archaeology only. +==== + == RSR (Rhodium Standard Repository) Specification === Version 0.1.0 diff --git a/aletheia/examples/simple_verification.rs b/aletheia/examples/simple_verification.rs index bf8b81e..928a36b 100644 --- a/aletheia/examples/simple_verification.rs +++ b/aletheia/examples/simple_verification.rs @@ -20,23 +20,25 @@ fn main() { println!("Example 2: Verify specific repository"); println!("Run: cargo run -- /path/to/repository\n"); - // Example 3: Expected output + // Example 3: Expected output (RSR v2 shape — README.adoc primary, + // LICENSE not LICENSE.txt, 26 checks in the offline subset) println!("Example 3: Expected output format"); println!( " -🔍 Aletheia - RSR Compliance Verification Report -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Aletheia - RSR Compliance Verification Report Repository: /path/to/repository +Verified: 2026-09-21T20:05:37Z -📋 Documentation - ✅ README.md [Bronze] - ✅ LICENSE.txt [Bronze] - ✅ SECURITY.md [Bronze] - ❌ CONTRIBUTING.md [Bronze] +[Documentation] + ✅ README.adoc (or README.md) [Bronze] + ✅ LICENSE file (not LICENSE.txt) [Bronze] + ✅ SECURITY policy (root or .github/) [Bronze] + ❌ CONTRIBUTING (root or .github/) [Silver] ... -Score: 14/16 checks passed (87.5%) -⚠️ Bronze-level RSR compliance: NOT MET +Score: 25/26 checks passed (96.2%) +Bronze-level RSR compliance: ACHIEVED +Silver-level RSR compliance: NOT MET " ); diff --git a/aletheia/scripts/create-template.sh b/aletheia/scripts/create-template.sh index 9ca2f4d..0009f38 100755 --- a/aletheia/scripts/create-template.sh +++ b/aletheia/scripts/create-template.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 # Script to create RSR Bronze-compliant project template # Usage: ./create-template.sh diff --git a/aletheia/scripts/install.sh b/aletheia/scripts/install.sh index b216340..129b232 100755 --- a/aletheia/scripts/install.sh +++ b/aletheia/scripts/install.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 # Aletheia installation script # This script installs Aletheia on Unix-like systems @@ -11,7 +12,7 @@ YELLOW='\033[1;33m' NC='\033[0m' # No Color # Configuration -REPO_URL="https://gitlab.com/maa-framework/6-the-foundation/aletheia.git" +REPO_URL="https://github.com/hyperpolymath/maa-framework.git" INSTALL_DIR="${HOME}/.local/bin" TEMP_DIR=$(mktemp -d) @@ -48,8 +49,8 @@ check_dependencies() { clone_repository() { log_info "Cloning Aletheia repository..." cd "$TEMP_DIR" - git clone "$REPO_URL" aletheia - cd aletheia + git clone "$REPO_URL" maa-framework + cd maa-framework/aletheia } build_binary() { @@ -126,7 +127,7 @@ main() { echo " 1. Run 'aletheia' in any repository to verify RSR compliance" echo " 2. Run 'aletheia /path/to/repo' to verify a specific repository" echo " 3. Read the documentation at:" - echo " https://gitlab.com/maa-framework/6-the-foundation/aletheia" + echo " https://github.com/hyperpolymath/maa-framework/tree/main/aletheia" echo "" } diff --git a/aletheia/src/checks.rs b/aletheia/src/checks.rs index 60872af..80770fa 100644 --- a/aletheia/src/checks.rs +++ b/aletheia/src/checks.rs @@ -5,14 +5,261 @@ //! This module implements the deterministic checks used by Aletheia to //! audit repository state. It performs physical filesystem analysis to //! validate documentation, build system files, and security configurations. +//! +//! SOURCE OF TRUTH: every check below cites the upstream RSR criterion +//! it evaluates (see [`SSOT_PROVENANCE`]). Aletheia covers the *offline +//! file-presence subset* — criteria whose verdict can be computed from +//! the working tree with zero dependencies. Everything else (Scorecard, +//! secret-scanner depth, content carve-outs, containers, proofs, …) is +//! explicitly out of scope and deferred to the hypatia oracle; see +//! [`LOCAL_SUBSET_NOTE`]. Aletheia is non-normative by design. +use std::cell::Cell; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; +use crate::config::{Config, IgnoreConfig}; use crate::types::*; -/// ALGORITHM: Implements a recursive glob pattern matcher for path filtering. -/// Supports '*' (zero or more chars) and '?' (exactly one char). +/// Provenance of the check inventory: the upstream single source of +/// truth this file is aligned to. When the criteria rev, update this +/// block and the inventory together — never one without the other. +pub const SSOT_PROVENANCE: &str = "RSR criteria v2.1.0-draft (2026-09-19), \ + hyperpolymath/standards 0-canon/rsr/rsr-criteria-v2.a2ml \ + (sha256 6a5aa885…1694d82a); normative oracle hypatia:rsr-conformance"; + +/// Honest scope statement, printed in verbose output so nobody mistakes +/// the local subset for the normative tier. +pub const LOCAL_SUBSET_NOTE: &str = "Local subset: offline file-presence checks only. \ + Scorecard, SBOM, containers, Guix, git hooks, signed commits, formal proofs, \ + dependency audit and content carve-outs (https-only, weak hashes, deep secret \ + scanning) are evaluated by the hypatia rsr-conformance oracle, not here."; + +/// Bound on files examined per walk. Repos beyond this get a `warning` +/// (results partial) rather than a silent pass. +const MAX_SCAN_FILES: usize = 50_000; +/// Maximum recursion depth for filesystem walks. +const MAX_SCAN_DEPTH: u32 = 16; +/// Maximum depth for the symlink security sweep. +const MAX_SYMLINK_DEPTH: u32 = 6; +/// Maximum entries examined by the symlink security sweep. +const MAX_SYMLINK_ENTRIES: usize = 20_000; +/// Content reads are capped at 64 KiB per file. +const MAX_CONTENT_BYTES: u64 = 64 * 1024; +/// SPDX detection reads the first 8 KiB (headers live in lines 1–10). +const SPDX_HEAD_BYTES: u64 = 8 * 1024; + +/// Directory names never descended into (build outputs, caches, VCS). +const SKIP_DIR_NAMES: &[&str] = &[ + ".git", + "target", + "node_modules", + "_build", + ".zig-cache", + "zig-out", + "zig-cache", + "deps", + "vendor", + "dist", + "build", + "out", + ".cache", + ".venv", + "__pycache__", + ".hypatia", + ".idea", + ".vscode", + ".tox", + ".nox", + "coverage", + ".dart_tool", + ".gradle", + "DerivedData", + "Pods", +]; + +/// Source-code extensions examined for SPDX headers. +const SPDX_EXTENSIONS: &[&str] = &[ + "rs", "sh", "bash", "zsh", "ksh", "py", "pyi", "js", "mjs", "cjs", "jsx", "ts", "mts", "cts", + "tsx", "ex", "exs", "erl", "hrl", "gleam", "res", "resi", "go", "java", "kt", "kts", "swift", + "c", "h", "cc", "hh", "cpp", "hpp", "cxx", "hxx", "cs", "fs", "fsi", "hs", "lhs", "ml", "mli", + "idr", "adb", "ads", "pl", "pm", "rb", "php", "lua", "r", "jl", "scala", "zig", "zon", "nix", + "scm", "rkt", "v", "el", "ps1", +]; + +/// Config extensions additionally scanned for committed secrets. +/// Documentation formats (md/adoc/txt) are deliberately excluded: they +/// routinely show redacted key *examples*, which a naive scan would flag. +const SECRET_SCAN_EXTENSIONS: &[&str] = &[ + "rs", "sh", "bash", "zsh", "toml", "yaml", "yml", "json", "env", "ini", "cfg", "conf", "py", + "js", "ts", "go", "java", "kt", "ex", "exs", "c", "h", "cpp", "zig", "nix", "pem", "key", + "p12", "pfx", "keystore", "jks", +]; + +/// Filenames that must never be committed (v2 4.1.2, file half). +const SECRET_FILENAMES: &[&str] = &[".env", ".env.local", "credentials.json"]; + +/// Fragment pairs concatenating into the committed-secret markers +/// (v2 4.1.2, content half): private-key block headers and unmistakable +/// token prefixes. Deliberately narrow — anything fuzzier belongs to the +/// oracle's secret scanner. +/// +/// WHY FRAGMENTS: if a full marker appeared here as one literal, this +/// scanner would flag its own source file — the definition would contain +/// the pattern it detects (self-hosting paradox). Each half is innocuous +/// alone; only their concatenation is ever matched, and that full string +/// appears nowhere in this crate. Keep it that way: never write a whole +/// marker in any scanned source file, including comments and tests (build +/// test fixtures from halves too). +const SECRET_MARKER_FRAGMENTS: &[(&str, &str)] = &[ + ("-----BEGIN RSA ", "PRIVATE KEY-----"), + ("-----BEGIN OPENSSH ", "PRIVATE KEY-----"), + ("-----BEGIN EC ", "PRIVATE KEY-----"), + ("-----BEGIN DSA ", "PRIVATE KEY-----"), + ("-----BEGIN ENCRYPTED ", "PRIVATE KEY-----"), + ("-----BEGIN ", "PRIVATE KEY-----"), + ("aws_secret_access", "_key"), + ("AK", "IA"), + ("xo", "xb-"), + ("xo", "xp-"), + ("gh", "p_"), + ("github_pa", "t_"), +]; + +/// Assemble the full matchable markers from their fragments. +fn secret_markers() -> Vec { + SECRET_MARKER_FRAGMENTS + .iter() + .map(|(head, tail)| format!("{head}{tail}")) + .collect() +} + +/// Banned build-system files (v2 1.1.3 + the Mustfile contractile, +/// which additionally bans Dockerfiles). +const BANNED_BUILD_FILES: &[&str] = &[ + "Makefile", + "makefile", + "GNUmakefile", + "Dockerfile", + "dockerfile", +]; + +/// Estate-banned language extensions (v2 5.1.1–5.1.5). `.v` is +/// content-sniffed (Coq vs V-lang) rather than blanket-banned — see +/// [`v_file_is_suspect`]. +const BANNED_LANGUAGE_EXTENSIONS: &[&str] = &[ + "py", "pyi", "ts", "tsx", "mts", "cts", "res", "resi", "go", "v", +]; + +/// Markers identifying Coq sources (which share `.v` with V-lang). +const COQ_MARKERS: &[&str] = &[ + "Definition ", + "Lemma ", + "Theorem ", + "Axiom ", + "Require ", + "Inductive ", + "Fixpoint ", + "Proposition ", + "Corollary ", + "Record ", + "Class ", + "Instance ", + "Scheme ", + "Ltac ", + "Proof.", + "Qed.", + "Admitted.", + "Section ", + "Variable ", + "Notation ", +]; + +/// Markers identifying V-lang sources. +const V_MARKERS: &[&str] = &["fn main", "\nfn ", "module main"]; + +/// Recognised licence-identifier anchors for the structural 7.1.1 check. +/// Real classification is the oracle's job (`cicd_rules/license_finding`); +/// this only asserts the LICENSE file names *something* known. Both SPDX +/// ids and spelled-out names are accepted: real LICENSE files often carry +/// only the full name (e.g. "Mozilla Public License Version 2.0" with no +/// "MPL-2.0" string anywhere). +const KNOWN_LICENSE_IDS: &[&str] = &[ + "Mozilla Public License", + "MPL-2.0", + "MIT License", + "Permission is hereby granted", + "Apache License", + "Apache-2.0", + "General Public License", + "Lesser General Public License", + "Affero General Public License", + "GPL-", + "LGPL-", + "AGPL-", + "Palimpsest", + "PMPL-1.0", + "Creative Commons", + "CC-BY-SA", + "CC0", + "BSD-2-Clause", + "BSD-3-Clause", + "Redistribution and use", + "ISC License", + "Unlicense", + "European Union Public Licence", + "EUPL-", + "Blue Oak", + "BlueOak", + "0BSD", + "zlib/libpng", +]; + +/// Outcome of one check: pass/fail plus an optional remediation hint. +/// `ignored` marks "missing but covered by an `[ignore]` pattern", which +/// passes and is labelled `(ignored)` in reports. +struct Outcome { + passed: bool, + suggestion: Option, + ignored: bool, +} + +impl Outcome { + fn pass() -> Self { + Outcome { + passed: true, + suggestion: None, + ignored: false, + } + } + + fn pass_ignored() -> Self { + Outcome { + passed: true, + suggestion: None, + ignored: true, + } + } + + fn fail(suggestion: String) -> Self { + Outcome { + passed: false, + suggestion: Some(suggestion), + ignored: false, + } + } +} + +/// Presence of a file looked up under several candidate paths. +enum Presence { + Found, + MissingIgnored, + Missing, +} + +/// ALGORITHM: Implements a glob pattern matcher for path filtering. +/// Supports `*` (zero or more chars, crossing `/`) and `?` (exactly one +/// char). Used for `[ignore]` patterns against repo-relative paths. pub fn glob_match(pattern: &str, text: &str) -> bool { let pat: Vec = pattern.chars().collect(); let txt: Vec = text.chars().collect(); @@ -48,165 +295,981 @@ pub fn check_path_security(path: &Path, repo_root: &Path) -> PathCheckResult { }; // ESCAPE DETECTION: Canonicalize and verify prefix. - let canonical_root = repo_root - .canonicalize() - .unwrap_or_else(|_| repo_root.to_path_buf()); - let resolved_target = if target.is_absolute() { + let canonical_root = repo_root.canonicalize().unwrap_or(repo_root.to_path_buf()); + let joined = if target.is_absolute() { target } else { - path.parent().expect("TODO: handle error").join(target) + match path.parent() { + Some(parent) => parent.join(target), + None => { + return PathCheckResult { + exists: true, + is_symlink: true, + ..Default::default() + } + }, + } }; - let canonical_target = resolved_target - .canonicalize() - .unwrap_or_else(|_| resolved_target); + // A dangling link cannot canonicalize; judge the joined path as-is. + // Best-effort by construction — documented, not silent. + let canonical_target = joined.canonicalize().unwrap_or(joined); PathCheckResult { exists: true, is_symlink: true, - escapes_repo: !canonical_target.starts_with(canonical_root), + escapes_repo: !canonical_target.starts_with(&canonical_root), target: Some(canonical_target), } } -/// VERIFICATION: Ensures mandatory documentation files (README, SECURITY, etc.) -/// are present in either Markdown (.md) or AsciiDoc (.adoc) format. -pub fn check_documentation(report: &mut ComplianceReport, repo_path: &Path, _ignore: &[String]) { - let docs = vec![ - ("README.md", "Project overview"), - ("SECURITY.md", "Vulnerability reporting"), - ("CONTRIBUTING.md", "Contribution guidelines"), - ("LICENSE", "Legal framework"), - ]; +/// Helper: Check if a file exists at repo_path/filename. +fn file_exists(repo_path: &Path, filename: &str) -> bool { + repo_path.join(filename).is_file() +} - for (doc, _description) in docs { - let md_path = repo_path.join(doc); - let adoc_path = repo_path.join(doc.replace(".md", ".adoc")); - let exists = md_path.is_file() || adoc_path.is_file(); - - report.add_check("Documentation", doc, exists, ComplianceLevel::Bronze); - } -} - -/// VERIFICATION: Audits source files for standard SPDX license headers. -/// Requires headers to be within the first 10 lines of the file. -pub fn check_spdx_headers(report: &mut ComplianceReport, repo_path: &Path) { - let src_path = repo_path.join("src"); - if src_path.is_dir() { - if let Ok(entries) = fs::read_dir(&src_path) { - let mut checked = 0; - let mut valid = 0; - - for entry in entries { - if let Ok(entry) = entry { - let path = entry.path(); - if path.extension().map_or(false, |ext| ext == "rs") { - checked += 1; - if let Ok(content) = fs::read_to_string(&path) { - let first_10_lines: String = - content.lines().take(10).collect::>().join("\n"); - if first_10_lines.contains("SPDX-License-Identifier") { - valid += 1; - } - } - } - } +/// Parse `.gitmodules` content into submodule paths. Submodule contents +/// belong to their upstream repo; scanning them here would attribute +/// foreign failures to this repository. +fn parse_gitmodules_content(content: &str) -> Vec { + content + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + let rest = trimmed.strip_prefix("path")?.trim(); + let value = rest.strip_prefix('=')?.trim(); + if value.is_empty() { + None + } else { + Some(value.to_string()) } + }) + .collect() +} - let all_valid = checked == 0 || checked == valid; - report.add_check( - "Security", - "SPDX headers in src/", - all_valid, - ComplianceLevel::Bronze, - ); - } +/// Read submodule paths from `.gitmodules` (absent file: no submodules). +fn submodule_paths(repo_path: &Path) -> Vec { + let path = repo_path.join(".gitmodules"); + match fs::read_to_string(&path) { + Ok(content) => parse_gitmodules_content(&content), + Err(_) => Vec::new(), } } -/// VERIFICATION: Audits CI workflows for SHA-pinned actions. -/// SECURITY: Rejects 'uses: actions/checkout@v4', requires 'uses: actions/checkout@'. -pub fn check_workflow_pins(report: &mut ComplianceReport, repo_path: &Path) { - let workflows_path = repo_path.join(".github/workflows"); - if workflows_path.is_dir() { - if let Ok(entries) = fs::read_dir(&workflows_path) { - let mut checked_files = 0; - let mut valid_files = 0; +/// Bounded file read: the first `max_bytes` bytes, lossy-decoded. +/// Returns `None` on I/O error so callers treat unreadable files as +/// "cannot verify" rather than crashing the scan. +fn read_head(path: &Path, max_bytes: u64) -> Option { + use std::io::Read; + let file = fs::File::open(path).ok()?; + let mut content = String::new(); + file.take(max_bytes).read_to_string(&mut content).ok()?; + Some(content) +} + +/// Filesystem scanner: bounded recursive walks honouring skip dirs, +/// submodule boundaries and `[ignore]` globs. Never follows symlinks. +struct Scanner<'a> { + root: &'a Path, + ignore: &'a IgnoreConfig, + submodules: &'a [String], + truncated: Cell, +} + +impl<'a> Scanner<'a> { + /// Repo-relative path with `/` separators ("" for the root itself). + fn rel(&self, path: &Path) -> String { + path.strip_prefix(self.root) + .map(|p| p.to_string_lossy().replace('\\', "/")) + .unwrap_or_default() + } - for entry in entries { - if let Ok(entry) = entry { - let path = entry.path(); - if path + fn under_submodule(&self, rel: &str) -> bool { + self.submodules + .iter() + .any(|s| rel == s || (rel.starts_with(s) && rel.as_bytes().get(s.len()) == Some(&b'/'))) + } + + fn skipped(&self, rel: &str) -> bool { + rel.is_empty() || self.under_submodule(rel) || self.ignore.is_ignored(rel) + } + + /// Recursive walk collecting files. `exts`, when `Some`, restricts + /// to those (lowercased) extensions; `None` collects every file. + fn walk_into(&self, dir: &Path, depth: u32, exts: Option<&[&str]>, out: &mut Vec) { + if depth > MAX_SCAN_DEPTH || out.len() >= MAX_SCAN_FILES { + if out.len() >= MAX_SCAN_FILES { + self.truncated.set(true); + } + return; + } + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return, + }; + for entry in entries.flatten() { + if out.len() >= MAX_SCAN_FILES { + self.truncated.set(true); + return; + } + let path = entry.path(); + let rel = self.rel(&path); + if self.skipped(&rel) { + continue; + } + let file_type = match entry.file_type() { + Ok(t) => t, + Err(_) => continue, + }; + // Never follow symlinks; the security sweep covers them. + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() { + let skip = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| SKIP_DIR_NAMES.contains(&name)); + if !skip { + self.walk_into(&path, depth + 1, exts, out); + } + } else if file_type.is_file() { + if let Some(wanted) = exts { + let ext = path .extension() - .map_or(false, |ext| ext == "yml" || ext == "yaml") - { - checked_files += 1; - if let Ok(content) = fs::read_to_string(&path) { - let has_unpinned = - content.lines().any(|line| !uses_line_is_pinned(line)); - if !has_unpinned { - valid_files += 1; - } - } + .and_then(|e| e.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if !wanted.iter().any(|w| *w == ext) { + continue; } } + out.push(path); + } + } + } + + fn walk_files(&self, exts: Option<&[&str]>) -> Vec { + let mut out = Vec::new(); + self.walk_into(self.root, 0, exts, &mut out); + out + } + + /// Look up a file under several candidate relative paths. + fn presence(&self, candidates: &[&str]) -> Presence { + let mut saw_ignored = false; + for candidate in candidates { + if file_exists(self.root, candidate) { + return Presence::Found; + } + if self.ignore.is_ignored(candidate) { + saw_ignored = true; } + } + if saw_ignored { + Presence::MissingIgnored + } else { + Presence::Missing + } + } - let all_pinned = checked_files == 0 || checked_files == valid_files; - report.add_check( - "Security", - "GitHub Actions SHA pinning", - all_pinned, - ComplianceLevel::Silver, - ); + /// Sweep for symlinks escaping the repository root. Returns + /// `(message, path)` pairs, all critical. + fn scan_symlinks(&self) -> Vec<(String, Option)> { + let mut findings = Vec::new(); + let mut seen = 0usize; + self.symlink_into(self.root, 0, &mut seen, &mut findings); + findings + } + + fn symlink_into( + &self, + dir: &Path, + depth: u32, + seen: &mut usize, + findings: &mut Vec<(String, Option)>, + ) { + if depth > MAX_SYMLINK_DEPTH || *seen >= MAX_SYMLINK_ENTRIES { + return; + } + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return, + }; + for entry in entries.flatten() { + if *seen >= MAX_SYMLINK_ENTRIES { + return; + } + *seen += 1; + let path = entry.path(); + let rel = self.rel(&path); + if self.skipped(&rel) { + continue; + } + let result = check_path_security(&path, self.root); + if !result.exists { + // TOCTOU: vanished between read_dir and stat. Skip it. + continue; + } + if result.is_symlink && result.escapes_repo { + findings.push(( + format!( + "Symlink escapes repository: {} -> {}", + rel, + result + .target + .as_ref() + .map(|t| t.display().to_string()) + .unwrap_or_else(|| "?".to_string()) + ), + Some(path.clone()), + )); + continue; + } + // Recurse into real dirs only — never through a symlink. + if !result.is_symlink && path.is_dir() { + let skip = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| SKIP_DIR_NAMES.contains(&name)); + if !skip { + self.symlink_into(&path, depth + 1, seen, findings); + } + } } } } -/// Helper: Check if a file exists at repo_path/filename -fn file_exists(repo_path: &Path, filename: &str) -> bool { - repo_path.join(filename).is_file() +// --------------------------------------------------------------------------- +// Pure content predicates (unit-tested below). +// --------------------------------------------------------------------------- + +/// A Justfile is "real" when it defines at least one recipe and is not +/// the estate stub. Recipe lines start at column 0 with a target name +/// and contain a `:`. (v2 1.1.2: "Justfile task runner present with +/// real recipes".) +fn justfile_is_real(content: &str) -> bool { + if content.contains("not configured yet") { + return false; + } + content.lines().any(|line| { + let mut chars = line.chars(); + matches!(chars.next(), Some(c) if c.is_ascii_alphanumeric() || c == '_' || c == '-') + && line.contains(':') + }) } -/// SECURITY: Decide whether one workflow line satisfies SHA pinning. -/// -/// Returns `true` for any line that is not a `uses:` line, so callers can apply -/// this with `.any(|l| !uses_line_is_pinned(l))` over a whole file. -/// -/// A `uses:` value is pinned only when the ref after the final `@` is exactly -/// 40 hexadecimal characters (a full-length Git SHA-1). `@v4`, `@main`, -/// `@master` and a bare action with no `@` at all are all unpinned. +/// Suspect V-lang iff V markers are present and no Coq markers are. +/// `.v` is shared by Coq and V; a blanket ban would false-positive every +/// proof file, so this heuristic stays narrow by design. +fn v_file_is_suspect(content: &str) -> bool { + let has_v = V_MARKERS.iter().any(|m| content.contains(m)); + let has_coq = COQ_MARKERS.iter().any(|m| content.contains(m)); + has_v && !has_coq +} + +/// `package.json` carries runtime deps iff it has a non-empty +/// `"dependencies"` object. (v2 5.1.6. `devDependencies` alone is fine.) +fn package_json_has_runtime_deps(content: &str) -> bool { + let Some(start) = content.find("\"dependencies\"") else { + return false; + }; + let rest = &content[start + "\"dependencies\"".len()..]; + let mut chars = rest.chars(); + // Expect `:`, then `{`, then something other than `}`. + for ch in chars.by_ref() { + if ch == ':' { + break; + } + if !ch.is_whitespace() { + return false; + } + } + for ch in chars.by_ref() { + if ch == '{' { + break; + } + if !ch.is_whitespace() { + return false; + } + } + for ch in chars { + if ch == '}' { + return false; + } + if !ch.is_whitespace() { + return true; + } + } + false +} + +/// A recipe line is silent-skip iff it chains `|| echo` with a skip +/// message — the literal `|| echo SKIP` shape v2 6.1.5 names. Plain +/// `|| true` (common in cleanup recipes) is out of scope. +fn silent_skip_line(line: &str) -> bool { + line.contains("|| echo") && (line.contains("SKIP") || line.contains("skip")) +} + +/// Structural licence check: the text names a known identifier. +fn license_text_ok(content: &str) -> bool { + KNOWN_LICENSE_IDS.iter().any(|id| content.contains(id)) +} + +// --------------------------------------------------------------------------- +// Checks. Each returns an Outcome; the inventory table in +// `verify_repository` binds ids, categories, items and tiers. +// Criterion refs point at rsr-criteria-v2.a2ml. +// --------------------------------------------------------------------------- + +/// 1.1.1 scm-github: repository canonical on GitHub (`.github/` present). +fn check_scm_github(scanner: &Scanner) -> Outcome { + if scanner.root.join(".github").is_dir() { + Outcome::pass() + } else { + Outcome::fail("Create .github/ (workflows live there; v2 1.1.1).".to_string()) + } +} + +/// 1.1.2 justfile: Justfile with real recipes. +fn check_justfile(scanner: &Scanner) -> Outcome { + match scanner.presence(&["Justfile", "justfile"]) { + Presence::Found => { + let content = ["Justfile", "justfile"] + .iter() + .find_map(|name| read_head(&scanner.root.join(name), MAX_CONTENT_BYTES)) + .unwrap_or_default(); + if justfile_is_real(&content) { + Outcome::pass() + } else { + Outcome::fail( + "Justfile has no real recipes (stub text or no targets); v2 1.1.2.".to_string(), + ) + } + }, + Presence::MissingIgnored => Outcome::pass_ignored(), + Presence::Missing => Outcome::fail( + "Add a Justfile with real recipes (build/test/lint at minimum); v2 1.1.2.".to_string(), + ), + } +} + +/// 1.1.3 no-makefile (+ Mustfile: no Dockerfiles). +fn check_no_makefile(scanner: &Scanner) -> Outcome { + let mut offenders = Vec::new(); + for path in scanner.walk_files(None) { + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| BANNED_BUILD_FILES.contains(&name)) + { + offenders.push(scanner.rel(&path)); + } + } + if offenders.is_empty() { + Outcome::pass() + } else { + offenders.sort(); + offenders.truncate(5); + Outcome::fail(format!( + "Remove banned build files ({}); use Justfile/Mustfile + Containerfile (v2 1.1.3).", + offenders.join(", ") + )) + } +} + +/// 2.1.1 readme-adoc: README.adoc (`.md` accepted; `.adoc` primary). +fn check_readme(scanner: &Scanner) -> Outcome { + match scanner.presence(&["README.adoc", "README.md"]) { + Presence::Found => Outcome::pass(), + Presence::MissingIgnored => Outcome::pass_ignored(), + Presence::Missing => Outcome::fail( + "Add README.adoc (README.md accepted; .adoc is primary per v2 2.1.1).".to_string(), + ), + } +} + +/// 2.1.2 license-file: LICENSE present (NOT LICENSE.txt — retired v1 shape). +fn check_license_file(scanner: &Scanner) -> Outcome { + match scanner.presence(&["LICENSE", "LICENSE.md", "LICENSE.adoc"]) { + Presence::Found => Outcome::pass(), + Presence::MissingIgnored => Outcome::pass_ignored(), + Presence::Missing => { + if file_exists(scanner.root, "LICENSE.txt") { + Outcome::fail( + "LICENSE.txt is the retired v1 shape: rename to LICENSE (+ LICENSES/ full texts); v2 2.1.2." + .to_string(), + ) + } else { + Outcome::fail("Add a LICENSE file (+ LICENSES/ full texts); v2 2.1.2.".to_string()) + } + }, + } +} + +/// 2.1.3 security-md: vulnerability-disclosure policy, root or .github/. +fn check_security_policy(scanner: &Scanner) -> Outcome { + match scanner.presence(&[ + "SECURITY.md", + "SECURITY.adoc", + ".github/SECURITY.md", + ".github/SECURITY.adoc", + ]) { + Presence::Found => Outcome::pass(), + Presence::MissingIgnored => Outcome::pass_ignored(), + Presence::Missing => Outcome::fail( + "Add SECURITY.md with disclosure contact + response timeline; v2 2.1.3.".to_string(), + ), + } +} + +/// 2.1.10 gitignore: .gitignore and .gitattributes present. +fn check_gitignore(scanner: &Scanner) -> Outcome { + let has_ignore = file_exists(scanner.root, ".gitignore"); + let has_attributes = file_exists(scanner.root, ".gitattributes"); + if has_ignore && has_attributes { + Outcome::pass() + } else if scanner.ignore.is_ignored(".gitignore") && scanner.ignore.is_ignored(".gitattributes") + { + Outcome::pass_ignored() + } else { + let mut missing = Vec::new(); + if !has_ignore { + missing.push(".gitignore"); + } + if !has_attributes { + missing.push(".gitattributes"); + } + Outcome::fail(format!("Add {}; v2 2.1.10.", missing.join(" and "))) + } +} + +/// 4.1.1 spdx-headers: SPDX headers on all scanned source files. +fn check_spdx_headers(scanner: &Scanner) -> Outcome { + let mut missing = Vec::new(); + for path in scanner.walk_files(Some(SPDX_EXTENSIONS)) { + let headed = read_head(&path, SPDX_HEAD_BYTES) + .map(|content| { + content + .lines() + .take(10) + .any(|line| line.contains("SPDX-License-Identifier")) + }) + .unwrap_or(false); + if !headed { + missing.push(scanner.rel(&path)); + } + } + if missing.is_empty() { + Outcome::pass() + } else { + missing.sort(); + let shown: Vec<&str> = missing.iter().take(5).map(String::as_str).collect(); + let more = if missing.len() > 5 { + format!(" (+{} more)", missing.len() - 5) + } else { + String::new() + }; + Outcome::fail(format!( + "Add SPDX-License-Identifier headers (first 10 lines) to: {}{}; v2 4.1.1.", + shown.join(", "), + more + )) + } +} + +/// 4.1.2 no-secrets: no committed secret files or key material. +fn check_no_secrets(scanner: &Scanner) -> Outcome { + let mut offenders = Vec::new(); + for path in scanner.walk_files(None) { + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| SECRET_FILENAMES.contains(&name)) + { + offenders.push(scanner.rel(&path)); + } + } + let markers = secret_markers(); + for path in scanner.walk_files(Some(SECRET_SCAN_EXTENSIONS)) { + if let Some(content) = read_head(&path, MAX_CONTENT_BYTES) { + if markers.iter().any(|m| content.contains(m)) { + offenders.push(scanner.rel(&path)); + } + } + } + if offenders.is_empty() { + Outcome::pass() + } else { + offenders.sort(); + offenders.dedup(); + offenders.truncate(5); + Outcome::fail(format!( + "Possible committed secrets in: {}. Remove + rotate; v2 4.1.2.", + offenders.join(", ") + )) + } +} + +/// 5.1.1–5.1.5 language bans: no Python/TS/ReScript/Go/V sources. +fn check_language_policy(scanner: &Scanner) -> Outcome { + let mut offenders = Vec::new(); + for path in scanner.walk_files(Some(BANNED_LANGUAGE_EXTENSIONS)) { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if ext == "v" { + // `.v` is shared with Coq: only flag likely V-lang. + let suspect = read_head(&path, MAX_CONTENT_BYTES) + .is_some_and(|content| v_file_is_suspect(&content)); + if suspect { + offenders.push(scanner.rel(&path)); + } + continue; + } + offenders.push(scanner.rel(&path)); + } + if offenders.is_empty() { + Outcome::pass() + } else { + offenders.sort(); + offenders.truncate(5); + Outcome::fail(format!( + "Banned-language sources present: {}. See estate language policy (v2 5.1.x); carve-outs are granted via hypatia, not here.", + offenders.join(", ") + )) + } +} + +/// 5.1.6 no-node-runtime: no npm-style runtime dependencies. /// -/// Exempt (not pinnable, so treated as pinned): -/// - local actions and local reusable workflows — `./…` -/// - `docker://` image references, which use a different digest syntax +/// Tier-1 Bun carve-out (estate LANGUAGE-POLICY §1, enforced by +/// governance): runtime deps accompanied by a Bun lockfile pass. +fn check_no_node_runtime(scanner: &Scanner) -> Outcome { + let mut offenders = Vec::new(); + for path in scanner.walk_files(None) { + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "package.json") + { + if let Some(content) = read_head(&path, MAX_CONTENT_BYTES) { + if package_json_has_runtime_deps(&content) && !has_bun_lockfile(&path) { + offenders.push(scanner.rel(&path)); + } + } + } + } + if offenders.is_empty() { + Outcome::pass() + } else { + Outcome::fail(format!( + "package.json with runtime dependencies and no Bun lockfile: {}. Use the estate runtime policy (v2 5.1.6).", + offenders.join(", ") + )) + } +} + +/// A Bun lockfile next to package.json (either generation: `bun.lock` +/// text or legacy `bun.lockb` binary). +fn has_bun_lockfile(package_json: &Path) -> bool { + package_json + .parent() + .is_some_and(|dir| dir.join("bun.lock").is_file() || dir.join("bun.lockb").is_file()) +} + +/// 6.1.1 ci-present: CI pipeline with at least one workflow. +fn check_ci_present(scanner: &Scanner) -> Outcome { + let workflows = scanner.root.join(".github/workflows"); + if workflows.is_dir() { + let has_workflow = fs::read_dir(&workflows).is_ok_and(|entries| { + entries.flatten().any(|entry| { + entry + .path() + .extension() + .is_some_and(|ext| ext == "yml" || ext == "yaml") + }) + }); + if has_workflow { + return Outcome::pass(); + } + } + if scanner.ignore.is_ignored(".github/workflows") { + Outcome::pass_ignored() + } else { + Outcome::fail("Add at least one workflow under .github/workflows/; v2 6.1.1.".to_string()) + } +} + +/// 7.1.1 licence-classified (structural half): LICENSE names a known id. +fn check_licence_class(scanner: &Scanner) -> Outcome { + let content = ["LICENSE", "LICENSE.md", "LICENSE.adoc", "LICENSE.txt"] + .iter() + .find_map(|name| read_head(&scanner.root.join(name), MAX_CONTENT_BYTES)); + match content { + None => Outcome::fail("No LICENSE file to classify; v2 7.1.1.".to_string()), + Some(text) if license_text_ok(&text) => Outcome::pass(), + Some(_) => Outcome::fail( + "LICENSE names no recognised identifier; use an estate-classified licence (v2 7.1.1)." + .to_string(), + ), + } +} + +/// 1.1.4 editorconfig. +fn check_editorconfig(scanner: &Scanner) -> Outcome { + match scanner.presence(&[".editorconfig"]) { + Presence::Found => Outcome::pass(), + Presence::MissingIgnored => Outcome::pass_ignored(), + Presence::Missing => Outcome::fail("Add .editorconfig; v2 1.1.4.".to_string()), + } +} + +/// 1.2.4 tool-versions. +fn check_tool_versions(scanner: &Scanner) -> Outcome { + match scanner.presence(&[".tool-versions"]) { + Presence::Found => Outcome::pass(), + Presence::MissingIgnored => Outcome::pass_ignored(), + Presence::Missing => { + Outcome::fail("Add .tool-versions pinning toolchain versions; v2 1.2.4.".to_string()) + }, + } +} + +/// 2.1.4 coc-md, root or .github/. +fn check_coc(scanner: &Scanner) -> Outcome { + match scanner.presence(&[ + "CODE_OF_CONDUCT.md", + "CODE_OF_CONDUCT.adoc", + ".github/CODE_OF_CONDUCT.md", + ".github/CODE_OF_CONDUCT.adoc", + ]) { + Presence::Found => Outcome::pass(), + Presence::MissingIgnored => Outcome::pass_ignored(), + Presence::Missing => Outcome::fail("Add CODE_OF_CONDUCT.md; v2 2.1.4.".to_string()), + } +} + +/// 2.1.5 contributing-md, root or .github/. +fn check_contributing(scanner: &Scanner) -> Outcome { + match scanner.presence(&[ + "CONTRIBUTING.md", + "CONTRIBUTING.adoc", + ".github/CONTRIBUTING.md", + ".github/CONTRIBUTING.adoc", + ]) { + Presence::Found => Outcome::pass(), + Presence::MissingIgnored => Outcome::pass_ignored(), + Presence::Missing => Outcome::fail("Add CONTRIBUTING.md; v2 2.1.5.".to_string()), + } +} + +/// 2.1.6 changelog: CHANGELOG.adoc or .md. +fn check_changelog(scanner: &Scanner) -> Outcome { + match scanner.presence(&["CHANGELOG.adoc", "CHANGELOG.md"]) { + Presence::Found => Outcome::pass(), + Presence::MissingIgnored => Outcome::pass_ignored(), + Presence::Missing => { + Outcome::fail("Add CHANGELOG.adoc (Keep a Changelog); v2 2.1.6.".to_string()) + }, + } +} + +/// 2.2.1 wellknown-core: security.txt + ai.txt + humans.txt. +fn check_wellknown_core(scanner: &Scanner) -> Outcome { + let files = ["security.txt", "ai.txt", "humans.txt"]; + let missing: Vec<&str> = files + .iter() + .filter(|name| !file_exists(scanner.root, &format!(".well-known/{name}"))) + .copied() + .collect(); + if missing.is_empty() { + Outcome::pass() + } else if scanner.ignore.is_ignored(".well-known") { + Outcome::pass_ignored() + } else { + Outcome::fail(format!( + "Add .well-known/{}; v2 2.2.1.", + missing.join(", .well-known/") + )) + } +} + +/// 2.3.1 ai-manifest: agent front door (`.deed` accepted mid-migration). +fn check_ai_manifest(scanner: &Scanner) -> Outcome { + match scanner.presence(&["0-AI-MANIFEST.a2ml", "0-AI-MANIFEST.deed"]) { + Presence::Found => Outcome::pass(), + Presence::MissingIgnored => Outcome::pass_ignored(), + Presence::Missing => { + Outcome::fail("Add 0-AI-MANIFEST.a2ml agent front door; v2 2.3.1.".to_string()) + }, + } +} + +/// 3.2.2 rsr-profile: capability declaration driving the applicable set. +fn check_rsr_profile(scanner: &Scanner) -> Outcome { + match scanner.presence(&[ + ".machine_readable/rsr-profile.a2ml", + ".machine_readable/rsr-profile.deed", + ]) { + Presence::Found => Outcome::pass(), + Presence::MissingIgnored => Outcome::pass_ignored(), + Presence::Missing => Outcome::fail( + "Add .machine_readable/rsr-profile.a2ml declaring capabilities; v2 3.2.2.".to_string(), + ), + } +} + +/// SECURITY: Extract the normalized `uses:` value from one workflow line. /// -/// A trailing `# v4` provenance comment is ignored, so -/// `uses: actions/checkout@3d3c42e5… # v7.0.1` is correctly seen as pinned. -fn uses_line_is_pinned(line: &str) -> bool { +/// Returns `None` for non-`uses:` lines and empty values (nothing to +/// judge — callers treat those as pinned). Strips trailing provenance +/// comments (`# v7.0.1`) and surrounding quotes. +fn parse_uses_value(line: &str) -> Option { let trimmed = line.trim(); // Accept both `uses:` and list form `- uses:`. - let rest = match trimmed + let rest = trimmed .strip_prefix("uses:") - .or_else(|| trimmed.strip_prefix("- uses:")) - { - Some(r) => r, - None => return true, // not a uses: line — nothing to judge - }; - - // Strip the trailing provenance comment, then surrounding quotes. + .or_else(|| trimmed.strip_prefix("- uses:"))?; let value = rest.split('#').next().unwrap_or("").trim(); let value = value.trim_matches(|c| c == '"' || c == '\''); - if value.is_empty() { - return true; + None + } else { + Some(value.to_string()) } +} + +/// SECURITY: Decide whether a `uses:` value is pinned. +/// +/// A value is pinned when the ref after the final `@` is exactly 40 +/// hexadecimal characters (a full-length Git SHA-1) — or when it is +/// covered by the repo's `actions.lock` (`locked`: the locked refs for +/// this workflow file; empty when there is no lockfile, in which case +/// only full SHAs pass). +/// +/// Lock matching follows the estate convention (governance-reusable: +/// tags in YAML plus a verified lockfile are the authoritative +/// resolution; inline SHAs alongside a lock are forbidden because they +/// break the lock tooling). A lock entry covers the exact `repo@ref` and +/// any sub-path action under it (`github/codeql-action@vX` covers +/// `github/codeql-action/init@vX`). +/// +/// Exempt (treated as pinned): +/// - local actions and reusable workflows — `./…` +/// - `docker://` image references (different digest syntax) +/// - `actions/github-script` (governance parity exemption) +fn uses_value_is_pinned(value: &str, locked: &[String]) -> bool { if value.starts_with("./") || value.starts_with(".\\") || value.starts_with("docker://") { return true; } + if value == "actions/github-script" || value.starts_with("actions/github-script@") { + return true; + } + let Some((repo, git_ref)) = value.rsplit_once('@') else { + return false; // no ref at all — unpinned + }; + if git_ref.len() == 40 && git_ref.chars().all(|c| c.is_ascii_hexdigit()) { + return true; + } + // Lock coverage: same ref on the same repo (or a sub-path of it). + locked.iter().any(|entry| { + let Some((locked_repo, locked_ref)) = entry.rsplit_once('@') else { + return false; + }; + locked_ref == git_ref + && (repo == locked_repo + || (repo.starts_with(locked_repo) + && repo.as_bytes().get(locked_repo.len()) == Some(&b'/'))) + }) +} + +/// SECURITY: Decide whether one workflow line satisfies SHA pinning. +/// +/// Strict form (no lockfile): only full 40-hex SHAs pass. Unit-test +/// entry point — production uses the lock-aware verdict in +/// [`uses_value_is_pinned`]. +#[cfg(test)] +fn uses_line_is_pinned(line: &str) -> bool { + match parse_uses_value(line) { + None => true, // not a uses: line — nothing to judge + Some(value) => uses_value_is_pinned(&value, &[]), + } +} + +/// Extract the locked action refs for one workflow file from an +/// `actions.lock` document. Minimal line parser for the known shape +/// (`workflows:` map of file → ref list). Unknown shapes yield no refs — +/// a lockfile this parser cannot read falls back to the strict SHA +/// test rather than silently passing everything. +fn locked_refs_for(lock_content: &str, rel_path: &str) -> Vec { + let mut refs = Vec::new(); + let mut in_workflows = false; + let mut current: Option<&str> = None; + for line in lock_content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if !line.starts_with([' ', '\t']) { + // Top-level key: entering or leaving the workflows map. + in_workflows = trimmed == "workflows:"; + current = None; + continue; + } + if !in_workflows { + continue; + } + // File entries look like `'.github/workflows/ci.yml':`. + if let Some(path) = trimmed + .strip_suffix(':') + .map(str::trim) + .and_then(|quoted| quoted.strip_prefix('\'')) + .and_then(|quoted| quoted.strip_suffix('\'')) + { + current = Some(path); + continue; + } + // Ref entries look like `- 'actions/checkout@v7.0.1'`. + if current == Some(rel_path) { + if let Some(entry) = trimmed + .strip_prefix('-') + .map(str::trim) + .and_then(|quoted| quoted.strip_prefix('\'')) + .and_then(|quoted| quoted.strip_suffix('\'')) + { + refs.push(entry.to_string()); + } + } + } + refs +} + +/// 4.1.3 sha-pinned: GitHub Actions SHA pinning. +/// +/// A `uses:` ref passes with a full 40-char SHA, or with a tag covered +/// by `.github/workflows/actions.lock` for that file (the estate +/// mechanism: tags in YAML plus a verified lockfile; governance forbids +/// inline SHAs alongside a lock because they break the lock tooling). +/// Repos without a lockfile are held to strict SHAs — mirroring the +/// governance-reusable no-lock branch. +fn check_workflow_pins(scanner: &Scanner) -> Outcome { + let workflows_path = scanner.root.join(".github/workflows"); + if !workflows_path.is_dir() { + // No workflows: ci-present already fails at Bronze; stay silent + // here rather than double-reporting the same gap. + return Outcome::pass(); + } + let lock = read_head( + &scanner.root.join(".github/workflows/actions.lock"), + MAX_CONTENT_BYTES, + ); + let entries = match fs::read_dir(&workflows_path) { + Ok(entries) => entries, + Err(_) => return Outcome::pass(), + }; + let mut unpinned_files = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if !path + .extension() + .is_some_and(|ext| ext == "yml" || ext == "yaml") + { + continue; + } + let rel = scanner.rel(&path); + let locked = lock + .as_ref() + .map(|content| locked_refs_for(content, &rel)) + .unwrap_or_default(); + if let Some(content) = read_head(&path, MAX_CONTENT_BYTES) { + let unpinned = content.lines().any(|line| match parse_uses_value(line) { + None => false, + Some(value) => !uses_value_is_pinned(&value, &locked), + }); + if unpinned { + unpinned_files.push(rel); + } + } + } + if unpinned_files.is_empty() { + Outcome::pass() + } else { + unpinned_files.sort(); + unpinned_files.truncate(5); + Outcome::fail(format!( + "Pin actions to full 40-char SHAs (or cover the tag in actions.lock) in: {}; v2 4.1.3.", + unpinned_files.join(", ") + )) + } +} + +/// 6.1.2 hypatia-scan: self-scan workflow wired. +fn check_hypatia_scan(scanner: &Scanner) -> Outcome { + match scanner.presence(&[".github/workflows/hypatia-scan.yml"]) { + Presence::Found => Outcome::pass(), + Presence::MissingIgnored => Outcome::pass_ignored(), + Presence::Missing => Outcome::fail( + "Wire .github/workflows/hypatia-scan.yml self-scan; v2 6.1.2.".to_string(), + ), + } +} + +/// 6.1.3 governance-wf: estate-policy enforcement workflow. +fn check_governance_wf(scanner: &Scanner) -> Outcome { + match scanner.presence(&[".github/workflows/governance.yml"]) { + Presence::Found => Outcome::pass(), + Presence::MissingIgnored => Outcome::pass_ignored(), + Presence::Missing => Outcome::fail( + "Wire .github/workflows/governance.yml estate-policy enforcement; v2 6.1.3." + .to_string(), + ), + } +} + +/// 7.1.2 reuse-compliant: LICENSES/ dir with full texts. +fn check_reuse(scanner: &Scanner) -> Outcome { + let dir = scanner.root.join("LICENSES"); + let has_texts = + dir.is_dir() && fs::read_dir(&dir).is_ok_and(|entries| entries.flatten().next().is_some()); + if has_texts { + Outcome::pass() + } else if scanner.ignore.is_ignored("LICENSES") { + Outcome::pass_ignored() + } else { + Outcome::fail("Add LICENSES/ with full licence texts (REUSE style); v2 7.1.2.".to_string()) + } +} - match value.rsplit_once('@') { - Some((_, git_ref)) => git_ref.len() == 40 && git_ref.chars().all(|c| c.is_ascii_hexdigit()), - None => false, // no ref at all — unpinned +/// 6.1.5 no-silent-skip (Gold): no `|| echo SKIP` silent-green recipes. +fn check_no_silent_skip(scanner: &Scanner) -> Outcome { + let mut offenders = Vec::new(); + for path in scanner.walk_files(None) { + let is_recipe = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "Justfile" || name == "justfile"); + let is_script = path.extension().is_some_and(|ext| { + ext == "just" || ext == "sh" || ext == "bash" || ext == "yml" || ext == "yaml" + }); + if !(is_recipe || is_script) { + continue; + } + if let Some(content) = read_head(&path, MAX_CONTENT_BYTES) { + if content.lines().any(silent_skip_line) { + offenders.push(scanner.rel(&path)); + } + } + } + if offenders.is_empty() { + Outcome::pass() + } else { + offenders.sort(); + Outcome::fail(format!( + "Silent-skip pattern (|| echo SKIP) in: {}. Fail loudly instead; v2 6.1.5.", + offenders.join(", ") + )) } } @@ -215,9 +1278,13 @@ fn glob_match_recursive(pattern: &[char], text: &[char], pi: usize, ti: usize) - if pi >= pattern.len() && ti >= text.len() { true } else if pi >= pattern.len() || ti >= text.len() { + // A trailing `*` also matches the empty remainder ("a*" ~ "a"). + if pi < pattern.len() && ti >= text.len() && pattern[pi..].iter().all(|c| *c == '*') { + return true; + } false } else if pattern[pi] == '*' { - // '*' can match zero or more characters + // '*' matches zero or more characters (crossing `/`). glob_match_recursive(pattern, text, pi + 1, ti) || glob_match_recursive(pattern, text, pi, ti + 1) } else if pattern[pi] == '?' || pattern[pi] == text[ti] { @@ -228,6 +1295,264 @@ fn glob_match_recursive(pattern: &[char], text: &[char], pi: usize, ti: usize) - } } +/// One inventory row: stable id, category, item label, required tier, check. +type InventoryRow = ( + &'static str, + &'static str, + &'static str, + ComplianceLevel, + fn(&Scanner) -> Outcome, +); + +/// Record one evaluated check on the report, labelling `(ignored)` when +/// the pass comes from an `[ignore]` pattern rather than presence. +fn emit( + report: &mut ComplianceReport, + id: &str, + category: &str, + item: &str, + level: ComplianceLevel, + outcome: Outcome, +) { + let item = if outcome.ignored { + format!("{item} (ignored)") + } else { + item.to_string() + }; + report.add_check( + id, + category, + &item, + outcome.passed, + level, + outcome.suggestion, + ); +} + +/// VERIFY: run the local file-presence subset against a repository. +/// +/// Checks disabled via `[checks]` are *skipped* (not recorded), so the +/// score denominator is always "checks actually evaluated". Symlink +/// escapes become `critical` warnings; scan truncation becomes a +/// `warning` — partial results are labelled, never silent. +pub fn verify_repository(repo_path: &Path, config: &Config) -> ComplianceReport { + let mut report = ComplianceReport::new(repo_path.to_path_buf()); + let submodules = submodule_paths(repo_path); + let scanner = Scanner { + root: repo_path, + ignore: &config.ignore, + submodules: &submodules, + truncated: Cell::new(false), + }; + + // (id, category, item, tier, check). Display order follows this table. + let inventory: &[InventoryRow] = &[ + ( + "scm-github", + "Infrastructure", + ".github/ directory", + ComplianceLevel::Bronze, + check_scm_github, + ), + ( + "justfile", + "Build System", + "Justfile with real recipes", + ComplianceLevel::Bronze, + check_justfile, + ), + ( + "no-makefile", + "Build System", + "No Makefile / Dockerfile", + ComplianceLevel::Bronze, + check_no_makefile, + ), + ( + "readme", + "Documentation", + "README.adoc (or README.md)", + ComplianceLevel::Bronze, + check_readme, + ), + ( + "license-file", + "Documentation", + "LICENSE file (not LICENSE.txt)", + ComplianceLevel::Bronze, + check_license_file, + ), + ( + "security-policy", + "Documentation", + "SECURITY policy (root or .github/)", + ComplianceLevel::Bronze, + check_security_policy, + ), + ( + "gitignore", + "Documentation", + ".gitignore + .gitattributes", + ComplianceLevel::Bronze, + check_gitignore, + ), + ( + "spdx-headers", + "Security", + "SPDX license headers", + ComplianceLevel::Bronze, + check_spdx_headers, + ), + ( + "no-secrets", + "Security", + "No secrets committed", + ComplianceLevel::Bronze, + check_no_secrets, + ), + ( + "language-policy", + "Language Policy", + "No banned languages", + ComplianceLevel::Bronze, + check_language_policy, + ), + ( + "no-node-runtime", + "Language Policy", + "No Node/npm runtime deps", + ComplianceLevel::Bronze, + check_no_node_runtime, + ), + ( + "ci-present", + "CI/CD", + "CI pipeline (.github/workflows/)", + ComplianceLevel::Bronze, + check_ci_present, + ), + ( + "licence-class", + "Licensing", + "LICENSE carries recognized identifier", + ComplianceLevel::Bronze, + check_licence_class, + ), + ( + "editorconfig", + "Build System", + ".editorconfig", + ComplianceLevel::Silver, + check_editorconfig, + ), + ( + "tool-versions", + "Build System", + ".tool-versions", + ComplianceLevel::Silver, + check_tool_versions, + ), + ( + "coc", + "Documentation", + "CODE_OF_CONDUCT (root or .github/)", + ComplianceLevel::Silver, + check_coc, + ), + ( + "contributing", + "Documentation", + "CONTRIBUTING (root or .github/)", + ComplianceLevel::Silver, + check_contributing, + ), + ( + "changelog", + "Documentation", + "CHANGELOG.adoc (or .md)", + ComplianceLevel::Silver, + check_changelog, + ), + ( + "wellknown-core", + "Well-Known", + ".well-known core files", + ComplianceLevel::Silver, + check_wellknown_core, + ), + ( + "ai-manifest", + "Machine-Readable", + "0-AI-MANIFEST.a2ml", + ComplianceLevel::Silver, + check_ai_manifest, + ), + ( + "rsr-profile", + "Machine-Readable", + "rsr-profile.a2ml", + ComplianceLevel::Silver, + check_rsr_profile, + ), + ( + "sha-pinned", + "Security", + "GitHub Actions SHA pinning", + ComplianceLevel::Silver, + check_workflow_pins, + ), + ( + "hypatia-scan", + "CI/CD", + "hypatia-scan.yml workflow", + ComplianceLevel::Silver, + check_hypatia_scan, + ), + ( + "governance-wf", + "CI/CD", + "governance.yml workflow", + ComplianceLevel::Silver, + check_governance_wf, + ), + ( + "reuse", + "Licensing", + "LICENSES/ with full texts", + ComplianceLevel::Silver, + check_reuse, + ), + ( + "no-silent-skip", + "CI/CD", + "No silent-skip in recipes", + ComplianceLevel::Gold, + check_no_silent_skip, + ), + ]; + + for (id, category, item, level, run) in inventory { + if config.checks.enabled(id) { + let outcome = run(&scanner); + emit(&mut report, id, category, item, *level, outcome); + } + } + + for (message, path) in scanner.scan_symlinks() { + report.add_warning("critical", message, path); + } + + if scanner.truncated.get() { + report.add_warning( + "warning", + format!("Scan truncated at {MAX_SCAN_FILES} files; results are partial."), + None, + ); + } + + report +} + #[cfg(test)] mod tests { use super::*; @@ -235,8 +1560,8 @@ mod tests { #[test] fn test_glob_match_literal() { - assert!(glob_match("foo.rs", "foo.rs")); - assert!(!glob_match("foo.rs", "bar.rs")); + assert!(glob_match("main.rs", "main.rs")); + assert!(!glob_match("main.rs", "lib.rs")); } #[test] @@ -246,19 +1571,25 @@ mod tests { assert!(!glob_match("*.rs", "main.toml")); } + #[test] + fn test_glob_match_star_crosses_dirs_and_trailing_star() { + assert!(glob_match("*.log", "nested/debug.log")); + assert!(glob_match("fixtures/**", "fixtures/input/data.bin")); + assert!(glob_match("prefix*", "prefix")); + assert!(glob_match("src/*", "src/main.rs")); + } + #[test] fn test_glob_match_question() { - assert!(glob_match("foo?.rs", "foo1.rs")); - assert!(glob_match("foo?.rs", "foox.rs")); - assert!(!glob_match("foo?.rs", "foo.rs")); + assert!(glob_match("file?.rs", "file1.rs")); + assert!(glob_match("file?.rs", "filex.rs")); + assert!(!glob_match("file?.rs", "file.rs")); } #[test] fn test_glob_match_complex() { assert!(glob_match("*.rs", "test.rs")); assert!(glob_match("src/*.rs", "src/main.rs")); - // Note: simple glob_match doesn't handle nested paths like we expect - // This is OK for basic matching. More complex patterns would need enhancement. } #[test] @@ -273,6 +1604,7 @@ mod tests { #[test] fn test_check_result_creation() { let check = CheckResult { + id: "readme".to_string(), category: "Documentation".to_string(), item: "README.md".to_string(), passed: true, @@ -280,6 +1612,7 @@ mod tests { suggestion: None, }; + assert_eq!(check.id, "readme"); assert_eq!(check.category, "Documentation"); assert_eq!(check.item, "README.md"); assert!(check.passed); @@ -313,9 +1646,17 @@ mod tests { let path = PathBuf::from("/tmp/test-repo"); let mut report = ComplianceReport::new(path); - report.add_check("Documentation", "README.md", true, ComplianceLevel::Bronze); + report.add_check( + "readme", + "Documentation", + "README.md", + true, + ComplianceLevel::Bronze, + None, + ); assert_eq!(report.checks.len(), 1); + assert_eq!(report.checks[0].id, "readme"); assert_eq!(report.checks[0].category, "Documentation"); assert_eq!(report.checks[0].item, "README.md"); assert!(report.checks[0].passed); @@ -332,6 +1673,52 @@ mod tests { assert_eq!(report.warnings[0].level, "warning"); } + #[test] + fn test_compliant_at_builds_upward() { + let mut report = ComplianceReport::new(PathBuf::from("/tmp/x")); + report.add_check("a", "C", "a", true, ComplianceLevel::Bronze, None); + report.add_check("b", "C", "b", false, ComplianceLevel::Silver, None); + assert!(report.compliant_at(ComplianceLevel::Bronze)); + assert!(!report.compliant_at(ComplianceLevel::Silver)); + assert_eq!(report.score_pct(), 50.0); + } + + #[test] + fn test_tier_counts_covers_all_levels() { + let report = ComplianceReport::new(PathBuf::from("/tmp/x")); + let counts = report.tier_counts(); + assert_eq!(counts.len(), 4); + assert_eq!(counts[3].0, ComplianceLevel::Platinum); + assert_eq!(report.score_pct(), 100.0); + } + + #[test] + fn test_compliance_level_vocabulary() { + assert_eq!( + ComplianceLevel::from_v2_tier("bronze"), + Some(ComplianceLevel::Bronze) + ); + assert_eq!( + ComplianceLevel::from_v2_tier("SILVER"), + Some(ComplianceLevel::Silver) + ); + assert_eq!( + ComplianceLevel::from_v2_tier("gold"), + Some(ComplianceLevel::Gold) + ); + assert_eq!( + ComplianceLevel::from_v2_tier("rhodium"), + Some(ComplianceLevel::Platinum) + ); + assert_eq!( + ComplianceLevel::from_v2_tier("platinum"), + Some(ComplianceLevel::Platinum) + ); + assert_eq!(ComplianceLevel::from_v2_tier("copper"), None); + assert_eq!(ComplianceLevel::Bronze.name(), "Bronze"); + assert!(ComplianceLevel::Bronze.rank() < ComplianceLevel::Platinum.rank()); + } + #[test] fn test_file_exists() { // Anchor on CARGO_MANIFEST_DIR rather than the process working directory: @@ -394,6 +1781,149 @@ mod tests { assert!(uses_line_is_pinned(" uses: docker://alpine:3.20")); } + #[test] + fn test_secret_markers_assemble() { + let markers = secret_markers(); + assert_eq!(markers.len(), SECRET_MARKER_FRAGMENTS.len()); + // Expectations assembled from halves too: a whole marker + // literal here would trip the scanner on its own source. + let rsa_block = format!("{}{}", "-----BEGIN RSA ", "PRIVATE KEY-----"); + assert!(markers.contains(&rsa_block)); + let aws_prefix = format!("{}{}", "AK", "IA"); + assert!(markers.contains(&aws_prefix)); + assert!(markers.iter().all(|m| !m.is_empty())); + } + + #[test] + fn test_parse_uses_value() { + assert_eq!( + parse_uses_value(" - uses: actions/checkout@v4 # v4"), + Some("actions/checkout@v4".to_string()) + ); + assert_eq!( + parse_uses_value("uses: \"./local/action\""), + Some("./local/action".to_string()) + ); + assert_eq!(parse_uses_value(" - name: Checkout"), None); + assert_eq!(parse_uses_value(""), None); + assert_eq!(parse_uses_value(" uses: "), None); + } + + #[test] + fn test_uses_value_is_pinned_with_lock() { + let locked = vec![ + "actions/checkout@v7.0.1".to_string(), + "github/codeql-action@v4.38.0".to_string(), + ]; + // Full SHA passes with or without a lock. + assert!(uses_value_is_pinned( + "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + &[] + )); + // Locked tag passes; unlocked tag fails. + assert!(uses_value_is_pinned("actions/checkout@v7.0.1", &locked)); + assert!(!uses_value_is_pinned("actions/checkout@v7.0.1", &[])); + assert!(!uses_value_is_pinned("actions/checkout@v4", &locked)); + // Repo-level lock entry covers sub-path actions at the same ref. + assert!(uses_value_is_pinned( + "github/codeql-action/init@v4.38.0", + &locked + )); + assert!(!uses_value_is_pinned( + "github/codeql-action/init@v4.37.0", + &locked + )); + // A different repo sharing the prefix must not match. + assert!(!uses_value_is_pinned( + "github/codeql-action-evil@v4.38.0", + &locked + )); + // No ref at all is unpinned even with a lock present. + assert!(!uses_value_is_pinned("actions/checkout", &locked)); + // Exemptions hold regardless of lock state. + assert!(uses_value_is_pinned("./.github/actions/setup", &[])); + assert!(uses_value_is_pinned("docker://alpine:3.20", &[])); + assert!(uses_value_is_pinned("actions/github-script@v7", &[])); + } + + #[test] + fn test_locked_refs_for() { + let lock = "version: 'v0.0.2'\nworkflows:\n '.github/workflows/ci.yml':\n - 'actions/checkout@v7.0.1'\n - 'actions/cache@v6.1.0'\n '.github/workflows/empty.yml': []\ndependencies:\n 'actions/checkout@v7.0.1':\n ref: 'v7.0.1'\n"; + assert_eq!( + locked_refs_for(lock, ".github/workflows/ci.yml"), + vec!["actions/checkout@v7.0.1", "actions/cache@v6.1.0"] + ); + assert!(locked_refs_for(lock, ".github/workflows/empty.yml").is_empty()); + assert!(locked_refs_for(lock, ".github/workflows/other.yml").is_empty()); + assert!(locked_refs_for("not a lockfile\n", ".github/workflows/ci.yml").is_empty()); + assert!(locked_refs_for("", ".github/workflows/ci.yml").is_empty()); + } + + #[test] + fn test_justfile_is_real() { + assert!(justfile_is_real("build:\n cargo build\n")); + assert!(justfile_is_real("# comment\n\ntest target:\n echo ok\n")); + assert!(!justfile_is_real("")); + assert!(!justfile_is_real("# only a comment\n")); + assert!(!justfile_is_real( + "build:\n @echo \"Build not configured yet\"\n" + )); + // Indented lines alone are not recipes. + assert!(!justfile_is_real(" cargo build --release\n")); + } + + #[test] + fn test_v_file_is_suspect() { + let coq = "Require Import List.\nDefinition ident (x : nat) := x.\n"; + assert!(!v_file_is_suspect(coq)); + let vlang = "module main\n\nfn main() {\n\tprintln('hi')\n}\n"; + assert!(v_file_is_suspect(vlang)); + // No markers either way: not evidence of V. + assert!(!v_file_is_suspect("// empty\n")); + } + + #[test] + fn test_package_json_has_runtime_deps() { + assert!(package_json_has_runtime_deps( + r#"{"dependencies": {"left-pad": "^1.0.0"}}"# + )); + assert!(package_json_has_runtime_deps( + "{\"dependencies\" :\n{\n\"x\": \"1\"\n}\n}" + )); + assert!(!package_json_has_runtime_deps( + r#"{"devDependencies": {"vitest": "^1.0.0"}}"# + )); + assert!(!package_json_has_runtime_deps(r#"{"dependencies": {}}"#)); + assert!(!package_json_has_runtime_deps(r#"{"name": "x"}"#)); + } + + #[test] + fn test_silent_skip_line() { + assert!("test -f x || echo SKIP".lines().any(silent_skip_line)); + assert!("run || echo skipping optional step" + .lines() + .any(silent_skip_line)); + assert!(!"rm -rf tmp || true".lines().any(silent_skip_line)); + assert!(!"echo done".lines().any(silent_skip_line)); + } + + #[test] + fn test_license_text_ok() { + assert!(license_text_ok("Mozilla Public License Version 2.0")); + assert!(license_text_ok( + "SPDX-License-Identifier: PMPL-1.0-or-later" + )); + assert!(!license_text_ok("All rights reserved. No licence given.")); + } + + #[test] + fn test_parse_gitmodules_content() { + let content = "[submodule \"absolute-zero\"]\n\tpath = absolute-zero\n\turl = https://example.test/x.git\n"; + assert_eq!(parse_gitmodules_content(content), vec!["absolute-zero"]); + assert!(parse_gitmodules_content("").is_empty()); + assert!(parse_gitmodules_content("path =\n").is_empty()); + } + #[test] fn test_compliance_level_equality() { assert_eq!(ComplianceLevel::Bronze, ComplianceLevel::Bronze); diff --git a/aletheia/src/config.rs b/aletheia/src/config.rs index 7325f93..597f777 100644 --- a/aletheia/src/config.rs +++ b/aletheia/src/config.rs @@ -7,26 +7,96 @@ //! //! DESIGN MANDATE: To satisfy RSR Bronze compliance (Air-Gapped/Self-Contained), //! this parser avoids external crates like `serde` or `toml-rs`. +//! +//! SUPPORTED SHAPE (all sections optional; missing file means defaults): +//! +//! ```toml +//! [aletheia] +//! level = "bronze" # bronze | silver | gold | platinum +//! +//! [checks] +//! readme = true # per-check toggles, keyed by check id +//! changelog = false # (see checks.rs inventory for ids) +//! +//! [ignore] +//! files = ["fixtures/**", "*.log"] # glob patterns, matched against +//! # repo-relative paths (`*`, `?`) +//! ``` +//! +//! For backwards compatibility, top-level `level = "..."` and flat +//! `documentation` / `spdx_headers` keys are still honoured. use std::collections::HashMap; use std::fs; use std::path::Path; -/// TOML value types. -#[derive(Debug, Clone)] +/// Maximum `.aletheia.toml` size ingested (1 MiB). A hostile config must +/// not be able to exhaust memory. +const MAX_CONFIG_BYTES: u64 = 1024 * 1024; + +/// TOML value types supported by the minimal parser. +#[derive(Debug, Clone, PartialEq)] pub enum TomlValue { String(String), Boolean(bool), Integer(i64), + Array(Vec), } -/// IGNORE PATTERNS: Paths to exclude from audit. +impl TomlValue { + /// Interpret the value as a boolean toggle. Strings accept the + /// `true`/`false` spellings; anything else falls back to `default`. + pub fn as_bool(&self, default: bool) -> bool { + match self { + TomlValue::Boolean(b) => *b, + TomlValue::String(s) if s.eq_ignore_ascii_case("true") => true, + TomlValue::String(s) if s.eq_ignore_ascii_case("false") => false, + TomlValue::Integer(i) => *i != 0, + _ => default, + } + } + + /// Interpret the value as a string. + pub fn as_string(&self) -> Option<&str> { + match self { + TomlValue::String(s) => Some(s), + _ => None, + } + } + + /// Interpret the value as a string array (for `[ignore] files`). + /// A lone string is treated as a single-element array. + pub fn as_array(&self) -> Vec { + match self { + TomlValue::Array(items) => items.clone(), + TomlValue::String(s) => vec![s.clone()], + _ => Vec::new(), + } + } +} + +/// IGNORE PATTERNS: glob patterns for paths to exclude from the audit. +/// Matched against repo-relative paths with `/` separators (see +/// `checks::glob_match`). `*` crosses directory boundaries. #[derive(Debug, Clone, Default)] pub struct IgnoreConfig { pub patterns: Vec, } -/// COMPLIANCE CONFIG: Toggles specific verification modules. +impl IgnoreConfig { + /// Whether a repo-relative path is ignored by any pattern. + pub fn is_ignored(&self, rel_path: &str) -> bool { + self.patterns + .iter() + .any(|pat| crate::checks::glob_match(pat, rel_path)) + } +} + +/// COMPLIANCE CONFIG: toggles for specific verification checks. +/// +/// The five named fields are the historical category toggles; `extra` +/// carries per-check-id toggles (`readme = false`, …). [`ChecksConfig::enabled`] +/// resolves a check id through both, defaulting to enabled. #[derive(Debug, Clone)] pub struct ChecksConfig { pub documentation: bool, @@ -34,6 +104,7 @@ pub struct ChecksConfig { pub build_system: bool, pub spdx_headers: bool, pub workflow_pins: bool, + pub extra: HashMap, } impl Default for ChecksConfig { @@ -44,14 +115,52 @@ impl Default for ChecksConfig { build_system: true, spdx_headers: true, workflow_pins: true, + extra: HashMap::new(), + } + } +} + +impl ChecksConfig { + /// Resolve whether the check with this stable id is enabled. + /// + /// A per-id entry in `[checks]` wins; otherwise the owning category + /// toggle applies; unknown ids default to enabled (a typo must not + /// silently disable a check — the id simply never matches). + pub fn enabled(&self, check_id: &str) -> bool { + if let Some(toggle) = self.extra.get(check_id) { + return *toggle; + } + match check_id { + "readme" | "license-file" | "security-policy" | "gitignore" | "coc" + | "contributing" | "changelog" => self.documentation, + "wellknown-core" => self.well_known, + "justfile" | "no-makefile" | "editorconfig" | "tool-versions" => self.build_system, + "spdx-headers" => self.spdx_headers, + "sha-pinned" => self.workflow_pins, + _ => true, + } + } + + /// Set a toggle from a `[checks]` key. Known category aliases update + /// the named field; anything else becomes a per-id entry. + fn set(&mut self, key: &str, value: bool) { + match key { + "documentation" => self.documentation = value, + "well_known" | "well-known" | "wellknown" => self.well_known = value, + "build_system" | "build-system" | "buildsystem" => self.build_system = value, + "spdx_headers" | "spdx-headers" | "spdx" => self.spdx_headers = value, + "workflow_pins" | "workflow-pins" | "workflowpins" => self.workflow_pins = value, + other => { + self.extra.insert(other.to_string(), value); + }, } } } -/// CONFIGURATION: The top-level policy record. +/// CONFIGURATION: the top-level policy record. #[derive(Debug, Clone)] pub struct Config { - pub level: String, // Target RSR tier (bronze, silver, gold) + pub level: String, // Target RSR tier (bronze, silver, gold, platinum) pub checks: ChecksConfig, pub ignore: IgnoreConfig, // Path patterns to exclude from audit } @@ -67,81 +176,146 @@ impl Default for Config { } impl Config { - /// LOADER: Reconciles the physical `.aletheia.toml` file with - /// the hardcoded system defaults. + /// LOADER: reconciles the physical `.aletheia.toml` file with + /// the hardcoded system defaults. Missing or unreadable file means + /// defaults — a policy file must never be load-bearing for a scan. pub fn load_config(repo_path: &Path) -> Config { - let config_path = repo_path.join(".aletheia.toml"); + Self::load_from_file(&repo_path.join(".aletheia.toml")) + } + /// Load policy from an explicit path (`--config`). Same fallback + /// semantics as [`Config::load_config`]. + pub fn load_from_file(config_path: &Path) -> Config { if config_path.is_file() { - if let Ok(file) = fs::File::open(&config_path) { + if let Ok(file) = fs::File::open(config_path) { use std::io::Read; let mut content = String::new(); - // Cap the read at 1 MiB so a hostile config cannot exhaust memory. - if file.take(1024 * 1024).read_to_string(&mut content).is_ok() { + // Cap the read so a hostile config cannot exhaust memory. + if file + .take(MAX_CONFIG_BYTES) + .read_to_string(&mut content) + .is_ok() + { return Self::parse_from_string(&content); } } } - Config::default() } /// Parse configuration from a TOML string. + /// + /// Section-aware keys (`[aletheia]`, `[checks]`, `[ignore]`) are read + /// via [`parse_toml`]; the historical flat keys (`level`, + /// `documentation`, `spdx_headers` at top level) are honoured too. fn parse_from_string(content: &str) -> Config { let mut config = Config::default(); + let sections = parse_toml(content); - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; + // Top-level (flat) keys, for backwards compatibility. + if let Some(top) = sections.get("") { + if let Some(level) = top.get("level").and_then(TomlValue::as_string) { + config.level = level.to_string(); } - - if trimmed.starts_with("level") { - if let Some(value) = trimmed.split('=').nth(1) { - config.level = value.trim().trim_matches('"').to_string(); - } - } else if trimmed.starts_with("documentation") { - if let Some(value) = trimmed.split('=').nth(1) { - config.checks.documentation = value.trim().parse().unwrap_or(true); - } - } else if trimmed.starts_with("spdx_headers") { - if let Some(value) = trimmed.split('=').nth(1) { - config.checks.spdx_headers = value.trim().parse().unwrap_or(true); + for key in ["documentation", "spdx_headers"] { + if let Some(value) = top.get(key) { + config.checks.set(key, value.as_bool(true)); } } } + // [aletheia] section. + if let Some(section) = sections.get("aletheia") { + if let Some(level) = section.get("level").and_then(TomlValue::as_string) { + config.level = level.to_string(); + } + } + + // [checks] section: every key is a toggle. + if let Some(section) = sections.get("checks") { + for (key, value) in section { + config.checks.set(key, value.as_bool(true)); + } + } + + // [ignore] section: `files = [...]` (or a lone string). + if let Some(section) = sections.get("ignore") { + if let Some(files) = section.get("files") { + config.ignore.patterns = files.as_array(); + } + } + config } } -/// PARSER: A line-based state machine for TOML key-value pairs. -fn parse_toml(content: &str) -> HashMap> { +/// Parse a single scalar value (string, bool or integer). +fn parse_scalar(value_str: &str) -> TomlValue { + let value_str = value_str.trim(); + let quoted = |quote: char| { + value_str.len() >= 2 && value_str.starts_with(quote) && value_str.ends_with(quote) + }; + if quoted('"') || quoted('\'') { + TomlValue::String(value_str[1..value_str.len() - 1].to_string()) + } else if let Ok(b) = value_str.parse::() { + TomlValue::Boolean(b) + } else if let Ok(i) = value_str.parse::() { + TomlValue::Integer(i) + } else { + TomlValue::String(value_str.to_string()) + } +} + +/// Parse an inline string array: `["a", "b"]`. Elements must be quoted; +/// unquoted elements are taken literally (TOML-strictness is deliberately +/// not the goal — resilience is). +fn parse_inline_array(value_str: &str) -> Vec { + let inner = value_str.trim(); + let inner = inner + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .unwrap_or(inner); + inner + .split(',') + .map(|item| { + let item = item.trim().trim_matches('"').trim_matches('\''); + item.to_string() + }) + .filter(|item| !item.is_empty()) + .collect() +} + +/// PARSER: a line-based state machine for TOML key-value pairs. +/// +/// Understands `[section]` headers, `key = value` pairs, `#` comments, +/// quoted strings, booleans, integers and single-line string arrays. +/// Keys before any section header land in the `""` section. A `#` inside +/// a quoted string does not start a comment. +pub fn parse_toml(content: &str) -> HashMap> { let mut sections: HashMap> = HashMap::new(); + sections.insert(String::new(), HashMap::new()); let mut current_section = String::new(); for line in content.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { + let trimmed = strip_comment(line).trim(); + if trimmed.is_empty() { continue; } if trimmed.starts_with('[') && trimmed.ends_with(']') { - current_section = trimmed[1..trimmed.len() - 1].to_string(); - sections.insert(current_section.clone(), HashMap::new()); + current_section = trimmed[1..trimmed.len() - 1].trim().to_string(); + sections.entry(current_section.clone()).or_default(); } else if let Some(eq_idx) = trimmed.find('=') { let key = trimmed[..eq_idx].trim().to_string(); let value_str = trimmed[eq_idx + 1..].trim(); - let value = if value_str.starts_with('"') && value_str.ends_with('"') { - TomlValue::String(value_str[1..value_str.len() - 1].to_string()) - } else if let Ok(b) = value_str.parse::() { - TomlValue::Boolean(b) - } else if let Ok(i) = value_str.parse::() { - TomlValue::Integer(i) + if key.is_empty() || value_str.is_empty() { + continue; + } + let value = if value_str.starts_with('[') { + TomlValue::Array(parse_inline_array(value_str)) } else { - TomlValue::String(value_str.to_string()) + parse_scalar(value_str) }; - if let Some(section) = sections.get_mut(¤t_section) { section.insert(key, value); } @@ -151,6 +325,21 @@ fn parse_toml(content: &str) -> HashMap> { sections } +/// Strip a trailing `#` comment, honouring single/double quotes. +fn strip_comment(line: &str) -> &str { + let mut in_single = false; + let mut in_double = false; + for (idx, ch) in line.char_indices() { + match ch { + '\'' if !in_double => in_single = !in_single, + '"' if !in_single => in_double = !in_double, + '#' if !in_single && !in_double => return &line[..idx], + _ => {}, + } + } + line +} + #[cfg(test)] mod tests { use super::*; @@ -189,12 +378,74 @@ documentation = true assert!(config.checks.documentation); } + #[test] + fn test_config_sections() { + let toml = r#" +[aletheia] +level = "silver" + +[checks] +readme = false +spdx_headers = false + +[ignore] +files = ["fixtures/**", "*.log"] +"#; + let config = Config::parse_from_string(toml); + assert_eq!(config.level, "silver"); + assert!(!config.checks.enabled("readme")); + assert!(config.checks.enabled("license-file")); + assert!(!config.checks.enabled("spdx-headers")); + assert_eq!(config.ignore.patterns, vec!["fixtures/**", "*.log"]); + } + + #[test] + fn test_config_category_toggle_disables_members() { + let toml = "[checks]\ndocumentation = false\n"; + let config = Config::parse_from_string(toml); + assert!(!config.checks.enabled("readme")); + assert!(!config.checks.enabled("changelog")); + assert!(config.checks.enabled("spdx-headers")); + } + + #[test] + fn test_config_per_id_overrides_category() { + let toml = "[checks]\ndocumentation = false\nreadme = true\n"; + let config = Config::parse_from_string(toml); + assert!(config.checks.enabled("readme")); + assert!(!config.checks.enabled("changelog")); + } + + #[test] + fn test_config_unknown_check_id_defaults_enabled() { + let config = Config::default(); + assert!(config.checks.enabled("no-such-check")); + } + + #[test] + fn test_config_hash_inside_string_is_not_comment() { + let toml = "[ignore]\nfiles = [\"releases/#42/**\"]\n"; + let config = Config::parse_from_string(toml); + assert_eq!(config.ignore.patterns, vec!["releases/#42/**"]); + } + #[test] fn test_ignore_config_default() { let ignore = IgnoreConfig::default(); assert!(ignore.patterns.is_empty()); } + #[test] + fn test_ignore_is_ignored() { + let ignore = IgnoreConfig { + patterns: vec!["*.log".to_string(), "fixtures/**".to_string()], + }; + assert!(ignore.is_ignored("debug.log")); + assert!(ignore.is_ignored("nested/debug.log")); + assert!(ignore.is_ignored("fixtures/input/data.bin")); + assert!(!ignore.is_ignored("src/main.rs")); + } + #[test] fn test_checks_config_default() { let checks = ChecksConfig::default(); @@ -229,6 +480,28 @@ documentation = true } } + #[test] + fn test_toml_value_array() { + let value = TomlValue::Array(vec!["a".to_string(), "b".to_string()]); + assert_eq!(value.as_array(), vec!["a".to_string(), "b".to_string()]); + assert_eq!( + TomlValue::String("solo".to_string()).as_array(), + vec!["solo"] + ); + assert!(TomlValue::Boolean(true).as_array().is_empty()); + } + + #[test] + fn test_toml_value_as_bool() { + assert!(TomlValue::Boolean(true).as_bool(false)); + assert!(!TomlValue::Boolean(false).as_bool(true)); + assert!(TomlValue::String("true".to_string()).as_bool(false)); + assert!(!TomlValue::String("FALSE".to_string()).as_bool(true)); + assert!(TomlValue::Integer(1).as_bool(false)); + assert!(!TomlValue::Integer(0).as_bool(true)); + assert!(TomlValue::Array(vec![]).as_bool(true)); + } + #[test] fn test_parse_toml_basic() { let toml = r#" @@ -240,4 +513,26 @@ enabled = true let parsed = parse_toml(toml); assert!(parsed.contains_key("project")); } + + #[test] + fn test_parse_toml_types_and_arrays() { + let toml = "[s]\ncount = 7\nname = \"x\"\nflag = false\nfiles = [\"a\", \"b\"]\n"; + let parsed = parse_toml(toml); + let section = &parsed["s"]; + assert_eq!(section["count"], TomlValue::Integer(7)); + assert_eq!(section["name"], TomlValue::String("x".to_string())); + assert_eq!(section["flag"], TomlValue::Boolean(false)); + assert_eq!( + section["files"], + TomlValue::Array(vec!["a".to_string(), "b".to_string()]) + ); + } + + #[test] + fn test_parse_toml_empty_array_and_skipped_lines() { + let toml = "[s]\nfiles = []\n= oops\nlonely\nkey =\n"; + let parsed = parse_toml(toml); + assert_eq!(parsed["s"]["files"], TomlValue::Array(vec![])); + assert_eq!(parsed["s"].len(), 1); + } } diff --git a/aletheia/src/main.rs b/aletheia/src/main.rs index fa52995..d5d0106 100644 --- a/aletheia/src/main.rs +++ b/aletheia/src/main.rs @@ -12,6 +12,11 @@ //! 2. Totality: Checks for Idris totality markers. //! 3. Air-Gapped Readiness: Ensures no network dependencies in core logic. //! 4. Provenance: Validates SPDX headers and license compliance. +//! +//! SCOPE HONESTY: the checks wired here are the offline file-presence +//! subset of the upstream RSR criteria (see `checks::SSOT_PROVENANCE`). +//! The normative tier verdict always comes from the hypatia +//! `rsr-conformance` oracle; Aletheia is the fast local pre-check. #![forbid(unsafe_code)] mod checks; @@ -19,7 +24,7 @@ mod config; mod output; mod types; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process; use output::exit_codes; @@ -28,38 +33,231 @@ use types::*; /// CLI options. #[derive(Debug)] struct Options { - repo_path: std::path::PathBuf, + repo_path: PathBuf, format: OutputFormat, + verbose: bool, + quiet: bool, + config_path: Option, + init_hook: bool, } /// Output format selection. -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq, Clone, Copy)] enum OutputFormat { Human, Json, Sarif, + Html, + Badge, } -/// Parse command-line arguments. -fn parse_args() -> Result { - let args: Vec = std::env::args().collect(); - - if args.len() < 2 { - return Err("Usage: aletheia [--json|--sarif]".to_string()); +impl OutputFormat { + fn from_name(name: &str) -> Option { + match name.to_ascii_lowercase().as_str() { + "human" | "text" => Some(OutputFormat::Human), + "json" => Some(OutputFormat::Json), + "sarif" => Some(OutputFormat::Sarif), + "html" => Some(OutputFormat::Html), + "badge" | "svg" => Some(OutputFormat::Badge), + _ => None, + } } +} + +/// Parse command-line arguments (pure: testable without the process). +/// +/// Accepted shape: +/// `aletheia [repo-path] [--format X | --format=X | --json | --sarif | +/// --html | --badge] [-v|--verbose] [-q|--quiet] [--config PATH] +/// [--init-hook] [init-hook [path]] [--help] [--version]` +/// The positional path defaults to `.`; `--` ends flag parsing. +fn parse_args_from(argv: &[String]) -> Result { + let mut repo_path: Option = None; + let mut format = OutputFormat::Human; + let mut verbose = false; + let mut quiet = false; + let mut config_path: Option = None; + let mut init_hook = false; + let mut help = false; + let mut version = false; + let mut positional_done = false; + + let mut args = argv.iter().peekable(); + // Skip argv[0]. + args.next(); - let repo_path = std::path::PathBuf::from(&args[1]); - let format = if args.len() > 2 { - match args[2].as_str() { - "--json" => OutputFormat::Json, - "--sarif" => OutputFormat::Sarif, - _ => OutputFormat::Human, + while let Some(arg) = args.next() { + if !positional_done && arg == "--" { + positional_done = true; + continue; } - } else { - OutputFormat::Human - }; + if !positional_done && arg.starts_with('-') && arg.len() > 1 { + if arg == "--help" || arg == "-h" { + help = true; + } else if arg == "--version" || arg == "-V" { + version = true; + } else if arg == "--verbose" || arg == "-v" { + verbose = true; + } else if arg == "--quiet" || arg == "-q" { + quiet = true; + } else if arg == "--json" { + format = OutputFormat::Json; + } else if arg == "--sarif" { + format = OutputFormat::Sarif; + } else if arg == "--html" { + format = OutputFormat::Html; + } else if arg == "--badge" { + format = OutputFormat::Badge; + } else if arg == "--init-hook" { + init_hook = true; + } else if arg == "--format" { + let value = args.next().ok_or_else(|| { + "Missing value for --format (expected human|json|sarif|html|badge)".to_string() + })?; + format = OutputFormat::from_name(value) + .ok_or_else(|| format!("Unknown --format '{value}'"))?; + } else if let Some(value) = arg.strip_prefix("--format=") { + if value.is_empty() { + return Err("Missing value for --format=X".to_string()); + } + format = OutputFormat::from_name(value) + .ok_or_else(|| format!("Unknown --format '{value}'"))?; + } else if arg == "--config" { + let value = args.next().ok_or_else(|| { + "Missing value for --config (expected a file path)".to_string() + })?; + config_path = Some(PathBuf::from(value)); + } else if let Some(value) = arg.strip_prefix("--config=") { + if value.is_empty() { + return Err("Missing value for --config=X".to_string()); + } + config_path = Some(PathBuf::from(value)); + } else { + return Err(format!( + "Unknown option '{arg}'. Run `aletheia --help` for usage." + )); + } + continue; + } + if !positional_done && (arg == "init-hook" && repo_path.is_none() && !init_hook) { + init_hook = true; + continue; + } + if repo_path.is_some() { + return Err(format!("Unexpected extra argument '{arg}'.")); + } + positional_done = true; + repo_path = Some(PathBuf::from(arg)); + } + + if help { + print_help(); + process::exit(exit_codes::SUCCESS); + } + if version { + println!("aletheia {}", output::VERSION); + process::exit(exit_codes::SUCCESS); + } + + Ok(Options { + repo_path: repo_path.unwrap_or_else(|| PathBuf::from(".")), + format, + verbose, + quiet, + config_path, + init_hook, + }) +} - Ok(Options { repo_path, format }) +/// Parse command-line arguments from the process environment. +fn parse_args() -> Result { + let argv: Vec = std::env::args().collect(); + parse_args_from(&argv) +} + +/// USAGE text: the CLI contract. +fn print_help() { + println!("aletheia {} - RSR compliance verification", output::VERSION); + println!(); + println!("USAGE:"); + println!(" aletheia [OPTIONS] [repo-path]"); + println!(" aletheia init-hook [repo-path]"); + println!(); + println!("ARGS:"); + println!(" [repo-path] Repository to verify (default: current directory)"); + println!(); + println!("OPTIONS:"); + println!(" -h, --help Print this help and exit"); + println!(" -V, --version Print version and exit"); + println!(" --format FORMAT Output format: human, json, sarif, html, badge"); + println!(" --format=FORMAT Same, equals syntax (e.g. --format=json)"); + println!(" --json Shorthand for --format json"); + println!(" --sarif Shorthand for --format sarif"); + println!(" --html Shorthand for --format html"); + println!(" --badge Print an SVG compliance badge and exit"); + println!(" -v, --verbose Verbose human output (suggestions, tiers, exit reason)"); + println!(" -q, --quiet Print only PASS or FAIL"); + println!(" --config PATH Use PATH instead of /.aletheia.toml"); + println!(" --init-hook Install a pre-commit hook instead of verifying"); + println!(); + println!("EXIT CODES:"); + println!(" 0 Required tier achieved, no critical warnings"); + println!(" 1 Required-tier checks failed (see Fix Suggestions)"); + println!(" 2 Critical security warnings (e.g. symlink escapes repo)"); + println!(" 3 Invalid repository path"); + println!(" 4 Invalid arguments"); + println!(); + println!("EXAMPLES:"); + println!(" aletheia Verify the current directory"); + println!(" aletheia /path/to/repo Verify a specific repository"); + println!(" aletheia --format json . Machine-readable report"); + println!(" aletheia --format=sarif . SARIF for GitHub code scanning"); + println!(" aletheia -q . && echo shipped Quiet gate for scripts"); + println!(" aletheia init-hook . Install the pre-commit hook"); + println!(); + println!("CONFIG:"); + println!(" Reads /.aletheia.toml ([aletheia] level, [checks] toggles,"); + println!(" [ignore] files globs). Absent file means defaults (tier: bronze)."); +} + +/// Install a pre-commit hook that gates commits on Bronze compliance. +/// Returns the exit code for the process. +fn run_init_hook(repo_path: &Path) -> i32 { + if !repo_path.is_dir() { + eprintln!("Error: Invalid repository path."); + return exit_codes::INVALID_PATH; + } + let git_dir = repo_path.join(".git"); + if !git_dir.is_dir() { + eprintln!( + "Error: {} is not a git repository (no .git/).", + repo_path.display() + ); + return exit_codes::INVALID_PATH; + } + let hooks_dir = git_dir.join("hooks"); + if let Err(err) = std::fs::create_dir_all(&hooks_dir) { + eprintln!("Error: cannot create hooks dir: {err}"); + return exit_codes::INVALID_PATH; + } + let hook_path = hooks_dir.join("pre-commit"); + let hook_body = "#!/bin/sh\n# Installed by `aletheia init-hook`: gate commits on RSR Bronze.\n\ + aletheia --quiet .\nstatus=$?\nif [ \"$status\" -ne 0 ]; then\n echo \"aletheia: Bronze compliance NOT MET - commit blocked\" >&2\n exit 1\nfi\n"; + if let Err(err) = std::fs::write(&hook_path, hook_body) { + eprintln!("Error: cannot write pre-commit hook: {err}"); + return exit_codes::INVALID_PATH; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o755); + if let Err(err) = std::fs::set_permissions(&hook_path, perms) { + eprintln!("Error: cannot chmod pre-commit hook: {err}"); + return exit_codes::INVALID_PATH; + } + } + println!("Installed pre-commit hook at {}", hook_path.display()); + exit_codes::SUCCESS } /// ENTRY POINT: Handles CLI orchestration and reporting. @@ -67,55 +265,158 @@ fn main() { // 1. ARGUMENT PARSING let options = match parse_args() { Ok(opts) => opts, - Err(e) => { - eprintln!("Error: {}", e); - process::exit(1); + Err(message) => { + eprintln!("Error: {message}"); + process::exit(exit_codes::INVALID_ARGS); }, }; - // 2. ENVIRONMENT VALIDATION - if !options.repo_path.exists() || !options.repo_path.is_dir() { - eprintln!("Error: Invalid repository path."); - process::exit(1); + // 2a. INIT-HOOK MODE (verifies nothing; installs the git hook). + if options.init_hook { + process::exit(run_init_hook(&options.repo_path)); + } + + // 2b. ENVIRONMENT VALIDATION + if !options.repo_path.is_dir() { + eprintln!( + "Error: repository path '{}' does not exist or is not a directory.", + options.repo_path.display() + ); + process::exit(exit_codes::INVALID_PATH); } // 3. AUDIT EXECUTION - // Loads policy from .aletheia.toml and runs the verification suite. - let config = config::Config::load_config(&options.repo_path); - let report = verify_repository(&options.repo_path, &config); + let config = match &options.config_path { + Some(path) => config::Config::load_from_file(path), + None => config::Config::load_config(&options.repo_path), + }; + let level = match ComplianceLevel::from_v2_tier(&config.level) { + Some(level) => level, + None => { + eprintln!( + "Warning: unknown level '{}' in config; falling back to bronze.", + config.level + ); + ComplianceLevel::Bronze + }, + }; + let report = checks::verify_repository(&options.repo_path, &config); // 4. RESULT DISPATCH - // Outputs results in the requested format (Human, JSON, SARIF). - match options.format { - OutputFormat::Json => output::print_json_report(&report), - OutputFormat::Human => output::print_report(&report), - OutputFormat::Sarif => output::print_sarif_report(&report), - } - - // 5. EXIT POLICY - let exit_code = if report - .checks - .iter() - .any(|c| !c.passed && c.required_for == ComplianceLevel::Bronze) - { - exit_codes::COMPLIANCE_FAILED - } else if report.warnings.iter().any(|w| w.level == "critical") { - exit_codes::SECURITY_WARNING + if options.quiet { + output::print_quiet(&report, level); } else { - exit_codes::SUCCESS - }; + match options.format { + OutputFormat::Human => output::print_report(&report, level, options.verbose), + OutputFormat::Json => output::print_json_report(&report, level), + OutputFormat::Sarif => output::print_sarif_report(&report), + OutputFormat::Html => output::print_html_report(&report, level), + OutputFormat::Badge => output::print_badge(&report, level), + } + } - process::exit(exit_code); + // 5. EXIT POLICY (shared with the verbose explainer). + process::exit(output::exit_code_for(&report, level)); } -/// Verify repository against RSR standards. -fn verify_repository(repo_path: &Path, _config: &config::Config) -> ComplianceReport { - let mut report = ComplianceReport::new(repo_path.to_path_buf()); +#[cfg(test)] +mod cli_tests { + use super::*; + + fn argv(words: &[&str]) -> Vec { + words.iter().map(ToString::to_string).collect() + } + + #[test] + fn test_parse_defaults_to_human_and_cwd() { + let options = parse_args_from(&argv(&["aletheia"])).expect("bare invocation parses"); + assert_eq!(options.repo_path, PathBuf::from(".")); + assert_eq!(options.format, OutputFormat::Human); + assert!(!options.verbose); + assert!(!options.quiet); + assert!(options.config_path.is_none()); + assert!(!options.init_hook); + } + + #[test] + fn test_parse_positional_path() { + let options = + parse_args_from(&argv(&["aletheia", "/tmp/repo"])).expect("positional parses"); + assert_eq!(options.repo_path, PathBuf::from("/tmp/repo")); + } + + #[test] + fn test_parse_format_forms() { + for words in [ + vec!["aletheia", "--format", "json"], + vec!["aletheia", "--format=json"], + vec!["aletheia", "--json"], + ] { + let options = parse_args_from(&argv(&words)).expect("json form parses"); + assert_eq!(options.format, OutputFormat::Json); + } + let options = + parse_args_from(&argv(&["aletheia", "--format", "sarif"])).expect("sarif parses"); + assert_eq!(options.format, OutputFormat::Sarif); + let options = parse_args_from(&argv(&["aletheia", "--format=html"])).expect("html parses"); + assert_eq!(options.format, OutputFormat::Html); + let options = parse_args_from(&argv(&["aletheia", "--badge"])).expect("badge parses"); + assert_eq!(options.format, OutputFormat::Badge); + } - // Run checks - checks::check_documentation(&mut report, repo_path, &[]); - checks::check_spdx_headers(&mut report, repo_path); - checks::check_workflow_pins(&mut report, repo_path); + #[test] + fn test_parse_flags_and_config() { + let options = parse_args_from(&argv(&[ + "aletheia", + "-v", + "--config", + "custom.toml", + "/tmp/repo", + ])) + .expect("flags parse"); + assert!(options.verbose); + assert_eq!(options.config_path, Some(PathBuf::from("custom.toml"))); + assert_eq!(options.repo_path, PathBuf::from("/tmp/repo")); + + let options = parse_args_from(&argv(&["aletheia", "-q"])).expect("quiet parses"); + assert!(options.quiet); + let options = parse_args_from(&argv(&["aletheia", "--config=other.toml"])) + .expect("equals config parses"); + assert_eq!(options.config_path, Some(PathBuf::from("other.toml"))); + } - report + #[test] + fn test_parse_init_hook_forms() { + let options = + parse_args_from(&argv(&["aletheia", "init-hook", "/tmp/repo"])).expect("subcommand"); + assert!(options.init_hook); + assert_eq!(options.repo_path, PathBuf::from("/tmp/repo")); + let options = parse_args_from(&argv(&["aletheia", "--init-hook"])).expect("flag form"); + assert!(options.init_hook); + } + + #[test] + fn test_parse_rejects_unknown_options() { + assert!(parse_args_from(&argv(&["aletheia", "--invalid-option"])).is_err()); + assert!(parse_args_from(&argv(&["aletheia", "--format", "yaml"])).is_err()); + assert!(parse_args_from(&argv(&["aletheia", "--format="])).is_err()); + assert!(parse_args_from(&argv(&["aletheia", "--format"])).is_err()); + assert!(parse_args_from(&argv(&["aletheia", "--config"])).is_err()); + assert!(parse_args_from(&argv(&["aletheia", "a", "b"])).is_err()); + } + + #[test] + fn test_parse_double_dash_ends_flags() { + let options = + parse_args_from(&argv(&["aletheia", "--", "--not-a-flag"])).expect("separator works"); + assert_eq!(options.repo_path, PathBuf::from("--not-a-flag")); + } + + #[test] + fn test_output_format_names() { + assert_eq!(OutputFormat::from_name("JSON"), Some(OutputFormat::Json)); + assert_eq!(OutputFormat::from_name("text"), Some(OutputFormat::Human)); + assert_eq!(OutputFormat::from_name("svg"), Some(OutputFormat::Badge)); + assert_eq!(OutputFormat::from_name("yaml"), None); + } } diff --git a/aletheia/src/output.rs b/aletheia/src/output.rs index e30c331..fb7f725 100644 --- a/aletheia/src/output.rs +++ b/aletheia/src/output.rs @@ -3,13 +3,14 @@ //! Aletheia Output and Reporting Engine. //! //! This module implements the presentation layer for compliance audits. -//! It provides multiple serialization formats (JSON, SARIF, HTML) and -//! a high-fidelity human-readable CLI report. +//! It provides multiple serialization formats (JSON, SARIF, HTML, badge) +//! and a high-fidelity human-readable CLI report. //! //! ZERO-DEPENDENCY DESIGN: To maintain RSR Bronze compliance, this module //! implements its own timestamp formatting and string escaping rather than //! pulling in external crates like `chrono` or `serde_json`. +use crate::checks::{LOCAL_SUBSET_NOTE, SSOT_PROVENANCE}; use crate::types::*; use std::time::SystemTime; @@ -18,9 +19,11 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// EXIT STRATEGY: Standardized exit codes for CI/CD integration. pub mod exit_codes { - pub const SUCCESS: i32 = 0; // Bronze compliance achieved. - pub const COMPLIANCE_FAILED: i32 = 1; // Mandatory checks failed. + pub const SUCCESS: i32 = 0; // Required tier achieved, no critical warnings. + pub const COMPLIANCE_FAILED: i32 = 1; // Required checks failed. pub const SECURITY_WARNING: i32 = 2; // Critical security issues (e.g. symlink escape). + pub const INVALID_PATH: i32 = 3; // Repository path missing or not a directory. + pub const INVALID_ARGS: i32 = 4; // Unrecognised flags or malformed arguments. } /// ALGORITHM: Manual timestamp formatter. @@ -36,10 +39,7 @@ pub fn format_timestamp(time: SystemTime) -> String { let (year, month, day) = calculate_date(days_since_epoch); let (hour, minute, second) = calculate_time(seconds_today); - format!( - "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", - year, month, day, hour, minute, second - ) + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") }, Err(_) => "2026-02-21T00:00:00Z".to_string(), } @@ -93,32 +93,83 @@ fn is_leap_year(year: u32) -> bool { (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) } -/// REPORTING: Standard CLI output. -/// Prints a formatted summary using ASCII symbols and emoji. -/// Includes: -/// - Categorized check results (Documentation, Build System, etc.) -/// - Specific fix suggestions for failed items. -/// - Prominent security warnings for critical issues. -pub fn print_report(report: &ComplianceReport) { - println!("Aletheia - RSR Compliance Verification Report"); - println!("Repository: {}", report.repository_path.display()); - println!(); +/// Escape a string for JSON embedding (minimal, hand-rolled). +fn json_escape(text: &str) -> String { + let mut out = String::with_capacity(text.len() + 2); + for ch in text.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out +} - let mut category_results: std::collections::HashMap> = - std::collections::HashMap::new(); +/// Escape a string for HTML embedding (minimal, hand-rolled). +fn html_escape(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} +/// Group checks by category, preserving first-seen category order so +/// reports are deterministic across runs and platforms. +fn group_by_category(report: &ComplianceReport) -> Vec<(String, Vec<&CheckResult>)> { + let mut groups: Vec<(String, Vec<&CheckResult>)> = Vec::new(); for check in &report.checks { - category_results - .entry(check.category.clone()) - .or_insert_with(Vec::new) - .push(check); + match groups.iter_mut().find(|(cat, _)| *cat == check.category) { + Some((_, items)) => items.push(check), + None => groups.push((check.category.clone(), vec![check])), + } + } + groups +} + +/// REPORTING: quiet mode. A single machine-greppable word. +pub fn print_quiet(report: &ComplianceReport, level: ComplianceLevel) { + if report.compliant_at(level) && !report.has_critical_warnings() { + println!("PASS"); + } else { + println!("FAIL"); + } +} + +/// REPORTING: Standard CLI output. +/// Prints a formatted summary using ✅/❌ markers and per-tier verdicts. +/// Includes categorized check results, fix suggestions for failed items, +/// prominent security warnings, score and verification timestamp. +/// Verbose mode adds version, per-tier counts, inline suggestions, +/// exit-code explanation and the local-subset honesty note. +pub fn print_report(report: &ComplianceReport, level: ComplianceLevel, verbose: bool) { + if verbose { + println!("Aletheia - RSR Compliance Verification Report (Verbose)"); + } else { + println!("Aletheia - RSR Compliance Verification Report"); + } + println!("Repository: {}", report.repository_path.display()); + println!("Verified: {}", format_timestamp(report.verified_at)); + if verbose { + println!("Version: {VERSION}"); + println!("Required tier: {}", level.name()); } + println!(); - for (category, checks) in category_results { - println!("[{}]", category); + for (category, checks) in group_by_category(report) { + println!("[{category}]"); for check in checks { - let status = if check.passed { "[OK]" } else { "[FAIL]" }; - println!(" {} {}", status, check.item); + let status = if check.passed { "✅" } else { "❌" }; + println!(" {status} {} [{}]", check.item, check.required_for.name()); + if verbose { + if let Some(suggestion) = &check.suggestion { + println!(" 💡 {suggestion}"); + } + } } println!(); } @@ -126,64 +177,364 @@ pub fn print_report(report: &ComplianceReport) { if !report.warnings.is_empty() { println!("Warnings:"); for warning in &report.warnings { - println!(" [{}] {}", warning.level, warning.message); + match &warning.path { + Some(path) => println!( + " [{}] {} ({})", + warning.level, + warning.message, + path.display() + ), + None => println!(" [{}] {}", warning.level, warning.message), + } + } + println!(); + } + + let passed = report.passed_count(); + let total = report.total_count(); + println!( + "Score: {passed}/{total} checks passed ({:.1}%)", + report.score_pct() + ); + + let bronze = if report.compliant_at(ComplianceLevel::Bronze) { + "ACHIEVED" + } else { + "NOT MET" + }; + println!("Bronze-level RSR compliance: {bronze}"); + let silver = if report.compliant_at(ComplianceLevel::Silver) { + "ACHIEVED" + } else { + "NOT MET" + }; + println!("Silver-level RSR compliance: {silver}"); + + if verbose { + println!(); + println!("Per-tier results:"); + for (tier, tier_passed, tier_total) in report.tier_counts() { + println!(" {}: {tier_passed}/{tier_total}", tier.name()); + } + println!(); + println!( + "Exit code: {} ({})", + exit_code_for(report, level), + exit_reason(report, level) + ); + println!(); + println!("Aligned to: {SSOT_PROVENANCE}"); + println!("Note: {LOCAL_SUBSET_NOTE}"); + } else { + let failing = report.failing_checks(); + if !failing.is_empty() { + println!(); + println!("Fix Suggestions:"); + for check in failing { + if let Some(suggestion) = &check.suggestion { + println!(" - {}: {suggestion}", check.item); + } + } } } } +/// EXIT POLICY shared by the CLI and the verbose explainer: critical +/// security warnings outrank compliance failures (both are non-zero). +pub fn exit_code_for(report: &ComplianceReport, level: ComplianceLevel) -> i32 { + if report.has_critical_warnings() { + exit_codes::SECURITY_WARNING + } else if !report.compliant_at(level) { + exit_codes::COMPLIANCE_FAILED + } else { + exit_codes::SUCCESS + } +} + +/// Human-readable reason for [`exit_code_for`]. +fn exit_reason(report: &ComplianceReport, level: ComplianceLevel) -> &'static str { + if report.has_critical_warnings() { + "critical security warnings present" + } else if !report.compliant_at(level) { + "required-tier checks failed" + } else { + "required tier achieved" + } +} + /// SERIALIZATION: JSON Output. -/// Generates a machine-readable JSON object representing the audit. -/// Implemented using manual string construction to ensure zero-dependency builds. -pub fn print_json_report(report: &ComplianceReport) { +/// Machine-readable object representing the audit, hand-rolled to keep +/// zero-dependency builds. +pub fn print_json_report(report: &ComplianceReport, level: ComplianceLevel) { println!("{{"); - println!(" \"version\": \"{}\",", VERSION); + println!(" \"version\": \"{VERSION}\","); println!( " \"repository\": \"{}\",", - report.repository_path.display() + json_escape(&report.repository_path.display().to_string()) ); println!( - " \"timestamp\": \"{}\",", + " \"verified_at\": \"{}\",", format_timestamp(report.verified_at) ); + println!(" \"required_tier\": \"{}\",", level.name().to_lowercase()); + println!( + " \"score\": {{\"passed\": {}, \"total\": {}, \"percent\": {:.1}}},", + report.passed_count(), + report.total_count(), + report.score_pct() + ); + println!( + " \"bronze_compliant\": {},", + report.compliant_at(ComplianceLevel::Bronze) + ); + println!( + " \"silver_compliant\": {},", + report.compliant_at(ComplianceLevel::Silver) + ); + println!(" \"tiers\": ["); + let tiers = report.tier_counts(); + for (i, (tier, passed, total)) in tiers.iter().enumerate() { + println!( + " {{\"tier\": \"{}\", \"passed\": {passed}, \"total\": {total}}}{}", + tier.name().to_lowercase(), + if i + 1 < tiers.len() { "," } else { "" } + ); + } + println!(" ],"); println!(" \"checks\": ["); - for (i, check) in report.checks.iter().enumerate() { println!(" {{"); - println!(" \"category\": \"{}\",", check.category); - println!(" \"item\": \"{}\",", check.item); - println!(" \"passed\": {}", check.passed); + println!(" \"id\": \"{}\",", json_escape(&check.id)); + println!(" \"category\": \"{}\",", json_escape(&check.category)); + println!(" \"item\": \"{}\",", json_escape(&check.item)); + println!(" \"passed\": {},", check.passed); + println!( + " \"required_for\": \"{}\"{}", + check.required_for.name().to_lowercase(), + match &check.suggestion { + Some(suggestion) => + format!(",\n \"suggestion\": \"{}\"", json_escape(suggestion)), + None => String::new(), + } + ); println!( " }}{}", - if i < report.checks.len() - 1 { "," } else { "" } + if i + 1 < report.checks.len() { "," } else { "" } ); } - println!(" ],"); - println!(" \"warnings\": []"); + println!(" \"warnings\": ["); + for (i, warning) in report.warnings.iter().enumerate() { + println!(" {{"); + println!(" \"level\": \"{}\",", json_escape(&warning.level)); + println!( + " \"message\": \"{}\"{}", + json_escape(&warning.message), + match &warning.path { + Some(path) => format!( + ",\n \"path\": \"{}\"", + json_escape(&path.display().to_string()) + ), + None => String::new(), + } + ); + println!( + " }}{}", + if i + 1 < report.warnings.len() { + "," + } else { + "" + } + ); + } + println!(" ]"); println!("}}"); } /// SERIALIZATION: SARIF (Static Analysis Results Interchange Format). -/// Enables native integration with GitHub Code Scanning and other security tools. -pub fn print_sarif_report(_report: &ComplianceReport) { +/// Enables native integration with GitHub Code Scanning and other tools. +/// Every check becomes a rule (`rsr/`) with a pass/fail result. +pub fn print_sarif_report(report: &ComplianceReport) { println!("{{"); + println!(" \"$schema\": \"https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json\","); println!(" \"version\": \"2.1.0\","); println!(" \"runs\": [{{"); println!(" \"tool\": {{"); println!(" \"driver\": {{"); - println!(" \"name\": \"Aletheia\","); - println!(" \"version\": \"{}\",", VERSION); - println!(" \"informationUri\": \"https://github.com/hyperpolymath/aletheia\""); + println!(" \"name\": \"aletheia\","); + println!(" \"version\": \"{VERSION}\","); + println!(" \"informationUri\": \"https://github.com/hyperpolymath/aletheia\","); + println!(" \"rules\": ["); + for (i, check) in report.checks.iter().enumerate() { + println!(" {{"); + println!(" \"id\": \"rsr/{}\",", json_escape(&check.id)); + println!( + " \"name\": \"{}\",", + json_escape( + &check + .item + .replace(|c: char| !c.is_ascii_alphanumeric(), "_") + ) + ); + println!( + " \"shortDescription\": {{\"text\": \"{}\"}},", + json_escape(&format!("{}: {}", check.category, check.item)) + ); + println!( + " \"properties\": {{\"requiredFor\": \"{}\"}}", + check.required_for.name().to_lowercase() + ); + println!( + " }}{}", + if i + 1 < report.checks.len() { "," } else { "" } + ); + } + println!(" ]"); println!(" }}"); println!(" }},"); - println!(" \"results\": []"); + println!(" \"results\": ["); + for (i, check) in report.checks.iter().enumerate() { + let (kind, level) = if check.passed { + ("pass", "none") + } else { + ("fail", "error") + }; + println!(" {{"); + println!(" \"ruleId\": \"rsr/{}\",", json_escape(&check.id)); + println!(" \"kind\": \"{kind}\","); + println!(" \"level\": \"{level}\","); + println!( + " \"message\": {{\"text\": \"{}\"}}", + json_escape(&match &check.suggestion { + Some(suggestion) if !check.passed => suggestion.clone(), + _ => format!("{}: {}", check.category, check.item), + }) + ); + println!( + " }}{}", + if i + 1 < report.checks.len() { "," } else { "" } + ); + } + println!(" ]"); println!(" }}]"); println!("}}"); } +/// SERIALIZATION: HTML report with embedded CSS (no external assets — +/// the report must render identically offline and air-gapped). +pub fn print_html_report(report: &ComplianceReport, level: ComplianceLevel) { + let compliant = report.compliant_at(level); + let verdict = if compliant { "ACHIEVED" } else { "NOT MET" }; + let verdict_class = if compliant { "pass" } else { "fail" }; + println!(""); + println!(""); + println!(""); + println!(""); + println!("Aletheia Compliance Report"); + println!(""); + println!(""); + println!(""); + println!("

Aletheia Compliance Report

"); + println!( + "

Repository: {}

", + html_escape(&report.repository_path.display().to_string()) + ); + println!("

Verified: {}

", format_timestamp(report.verified_at)); + println!( + "

Score: {}/{} checks passed ({:.1}%)

", + report.passed_count(), + report.total_count(), + report.score_pct() + ); + println!("

Bronze-level RSR compliance: {verdict}

"); + for (category, checks) in group_by_category(report) { + println!("

{}

", html_escape(&category)); + println!(""); + for check in checks { + let (mark, class) = if check.passed { + ("✅", "pass") + } else { + ("❌", "fail") + }; + println!( + "", + html_escape(&check.item), + check.required_for.name() + ); + } + println!("
StatusCheckTier
{mark}{}{}
"); + } + if !report.warnings.is_empty() { + println!("

Warnings

"); + println!("
    "); + for warning in &report.warnings { + println!( + "
  • [{}] {}
  • ", + html_escape(&warning.level), + html_escape(&warning.message) + ); + } + println!("
"); + } + println!(""); + println!(""); +} + +/// SVG BADGE: shields-style `RSR compliance | passing/failing` badge. +pub fn print_badge(report: &ComplianceReport, level: ComplianceLevel) { + let passing = report.compliant_at(level) && !report.has_critical_warnings(); + let (status, color) = if passing { + ("passing", "#97ca00") + } else { + ("failing", "#e05d44") + }; + println!(""); + println!(" "); + println!(" "); + println!(" "); + println!(" "); + println!(" "); + println!(" "); + println!(" "); + println!( + " " + ); + println!(" RSR compliance"); + println!(" {status}"); + println!(" "); + println!(""); +} + #[cfg(test)] mod tests { use super::*; + use std::path::PathBuf; + + fn sample_report() -> ComplianceReport { + let mut report = ComplianceReport::new(PathBuf::from("/tmp/sample")); + report.add_check( + "readme", + "Documentation", + "README.adoc (or README.md)", + true, + ComplianceLevel::Bronze, + None, + ); + report.add_check( + "changelog", + "Documentation", + "CHANGELOG.adoc (or .md)", + false, + ComplianceLevel::Silver, + Some("Add CHANGELOG.adoc; v2 2.1.6.".to_string()), + ); + report + } #[test] fn test_is_leap_year() { @@ -212,6 +563,13 @@ mod tests { assert!(timestamp.contains("1970")); } + #[test] + fn test_format_timestamp_shape() { + // 2026-01-01T00:00:00Z == 1767225600. + let time = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1767225600); + assert_eq!(format_timestamp(time), "2026-01-01T00:00:00Z"); + } + #[test] fn test_version_constant() { assert!(!VERSION.is_empty()); @@ -222,5 +580,47 @@ mod tests { assert_eq!(exit_codes::SUCCESS, 0); assert_eq!(exit_codes::COMPLIANCE_FAILED, 1); assert_eq!(exit_codes::SECURITY_WARNING, 2); + assert_eq!(exit_codes::INVALID_PATH, 3); + assert_eq!(exit_codes::INVALID_ARGS, 4); + } + + #[test] + fn test_exit_code_for_policy() { + let report = sample_report(); + assert_eq!( + exit_code_for(&report, ComplianceLevel::Bronze), + exit_codes::SUCCESS + ); + assert_eq!( + exit_code_for(&report, ComplianceLevel::Silver), + exit_codes::COMPLIANCE_FAILED + ); + let mut critical = sample_report(); + critical.add_warning("critical", "escape".to_string(), None); + // Critical warnings outrank compliance failures. + assert_eq!( + exit_code_for(&critical, ComplianceLevel::Bronze), + exit_codes::SECURITY_WARNING + ); + } + + #[test] + fn test_group_by_category_is_stable() { + let report = sample_report(); + let groups = group_by_category(&report); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].0, "Documentation"); + assert_eq!(groups[0].1.len(), 2); + } + + #[test] + fn test_json_escape() { + assert_eq!(json_escape("a\"b\\c"), "a\\\"b\\\\c"); + assert_eq!(json_escape("line\ntab\t"), "line\\ntab\\t"); + } + + #[test] + fn test_html_escape() { + assert_eq!(html_escape("&\""), "<a>&""); } } diff --git a/aletheia/src/types.rs b/aletheia/src/types.rs index ccc398a..ac1825b 100644 --- a/aletheia/src/types.rs +++ b/aletheia/src/types.rs @@ -5,15 +5,32 @@ //! This module defines the formal data structures used throughout the //! compliance verification engine. It establishes the schema for //! audit results, security warnings, and RSR compliance tiers. +//! +//! TIER VOCABULARY: [`ComplianceLevel`] follows the estate-wide RSR tier +//! ladder (Bronze → Silver → Gold → Platinum/Rhodium). The *normative* +//! definition of what each tier requires lives upstream in +//! `hyperpolymath/standards` (`0-canon/rsr/rsr-criteria-v2.a2ml`) and is +//! scored by the hypatia `rsr-conformance` oracle. Aletheia evaluates a +//! documented *local file-presence subset* of those criteria — see +//! `checks::SSOT_PROVENANCE`. It is a non-normative convenience checker +//! in the same class as the rhodium-pipeline rsr-certifier (criteria +//! file `[oracle]` section: "MAY consume this file but is not normative"). use std::path::PathBuf; use std::time::SystemTime; -/// COMPLIANCE TIERS: Authoritative RSR assurance levels. -/// - **Bronze**: Basic documentation and security baselines. -/// - **Silver**: Advanced content validation and workflow pinning. -/// - **Gold/Platinum**: Reserved for deep formal verification. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// COMPLIANCE TIERS: RSR assurance levels, lowest to highest. +/// +/// - **Bronze**: foundational documentation, security and build baselines. +/// - **Silver**: enhanced quality (provenance, pinning, machine-readable substrate). +/// - **Gold**: production readiness (release hygiene, hardened enforcement). +/// - **Platinum**: estate-rhodium exemplary tier (proofs, succession, signed history). +/// +/// All four variants are constructed: [`ComplianceLevel::all`] feeds the +/// per-tier summary, and [`ComplianceLevel::from_v2_tier`] maps the +/// upstream tier vocabulary (including `rhodium`, the v2 name for this +/// tier) onto it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ComplianceLevel { Bronze, Silver, @@ -21,17 +38,77 @@ pub enum ComplianceLevel { Platinum, } -/// CHECK RESULT: The outcome of a single deterministic verification item. +impl ComplianceLevel { + /// All levels, lowest to highest. Used by the per-tier score summary + /// so every tier is always reported, even with zero local checks. + pub fn all() -> [ComplianceLevel; 4] { + [ + ComplianceLevel::Bronze, + ComplianceLevel::Silver, + ComplianceLevel::Gold, + ComplianceLevel::Platinum, + ] + } + + /// Severity rank: higher tiers build on lower ones. + pub fn rank(self) -> u8 { + match self { + ComplianceLevel::Bronze => 0, + ComplianceLevel::Silver => 1, + ComplianceLevel::Gold => 2, + ComplianceLevel::Platinum => 3, + } + } + + /// Short display name, matching the RSR badge vocabulary. + pub fn name(self) -> &'static str { + match self { + ComplianceLevel::Bronze => "Bronze", + ComplianceLevel::Silver => "Silver", + ComplianceLevel::Gold => "Gold", + ComplianceLevel::Platinum => "Platinum", + } + } + + /// Map the upstream criteria-file tier vocabulary onto this enum. + /// + /// Accepts `bronze`, `silver`, `gold`, `platinum` and `rhodium` (the + /// v2 name for the exemplary tier). Case-insensitive. Returns `None` + /// for anything else so callers can fall back honestly instead of + /// guessing. + pub fn from_v2_tier(tier: &str) -> Option { + if tier.eq_ignore_ascii_case("bronze") { + Some(ComplianceLevel::Bronze) + } else if tier.eq_ignore_ascii_case("silver") { + Some(ComplianceLevel::Silver) + } else if tier.eq_ignore_ascii_case("gold") { + Some(ComplianceLevel::Gold) + } else if tier.eq_ignore_ascii_case("platinum") || tier.eq_ignore_ascii_case("rhodium") { + Some(ComplianceLevel::Platinum) + } else { + None + } + } +} + +/// CHECK RESULT: the outcome of a single deterministic verification item. +/// +/// `id` is a stable machine key (e.g. `readme`, `sha-pinned`) used by +/// `[checks]` config toggles, JSON output and SARIF `rsr/` rule ids. +/// `suggestion` carries the remediation hint shown in verbose output and +/// the "Fix Suggestions" section; it is `Some` exactly when `passed` is +/// false (a passing check has nothing to remediate). #[derive(Debug, Clone)] pub struct CheckResult { - pub category: String, // e.g. "Documentation", "Build System" - pub item: String, // e.g. "README.md" + pub id: String, + pub category: String, // e.g. "Documentation", "Security" + pub item: String, // e.g. "README.adoc (or README.md)" pub passed: bool, pub required_for: ComplianceLevel, - pub suggestion: Option, // Remediation hint for tutors/developers. + pub suggestion: Option, } -/// PATH SECURITY CHECK RESULT: Validates symlink safety. +/// PATH SECURITY CHECK RESULT: validates symlink safety. #[derive(Debug, Clone, Default)] pub struct PathCheckResult { pub exists: bool, @@ -40,7 +117,10 @@ pub struct PathCheckResult { pub target: Option, } -/// SECURITY WARNING: Detailed information about a security concern. +/// SECURITY WARNING: detailed information about a security concern. +/// +/// `path` is `Some` whenever the warning concerns a specific filesystem +/// entry (e.g. an escaping symlink) so reports can point at it. #[derive(Debug, Clone)] pub struct SecurityWarning { pub level: String, @@ -48,7 +128,7 @@ pub struct SecurityWarning { pub path: Option, } -/// AUDIT REPORT: The consolidated results of a repository audit. +/// AUDIT REPORT: the consolidated results of a repository audit. pub struct ComplianceReport { pub repository_path: PathBuf, pub verified_at: SystemTime, @@ -68,13 +148,25 @@ impl ComplianceReport { } /// Add a check result to the report. - pub fn add_check(&mut self, category: &str, item: &str, passed: bool, level: ComplianceLevel) { + /// + /// `suggestion` should be `Some` when (and only when) `passed` is + /// false; see [`CheckResult::suggestion`]. + pub fn add_check( + &mut self, + id: &str, + category: &str, + item: &str, + passed: bool, + level: ComplianceLevel, + suggestion: Option, + ) { self.checks.push(CheckResult { + id: id.to_string(), category: category.to_string(), item: item.to_string(), passed, required_for: level, - suggestion: None, + suggestion, }); } @@ -86,4 +178,69 @@ impl ComplianceReport { path, }); } + + /// Number of passing checks. + pub fn passed_count(&self) -> usize { + self.checks.iter().filter(|c| c.passed).count() + } + + /// Total number of checks evaluated. + pub fn total_count(&self) -> usize { + self.checks.len() + } + + /// Score as a percentage of evaluated checks passed. + /// + /// Informational only: the exit policy is strict per-tier (see + /// [`ComplianceReport::compliant_at`]), not score-based, because the + /// local subset cannot reproduce the oracle's capability-gated + /// weighting. An empty report scores 100.0 (nothing failed). + pub fn score_pct(&self) -> f64 { + if self.checks.is_empty() { + 100.0 + } else { + self.passed_count() as f64 / self.checks.len() as f64 * 100.0 + } + } + + /// Per-tier `(level, passed, total)` triples, lowest tier first. + pub fn tier_counts(&self) -> Vec<(ComplianceLevel, usize, usize)> { + ComplianceLevel::all() + .iter() + .map(|level| { + let total = self + .checks + .iter() + .filter(|c| c.required_for == *level) + .count(); + let passed = self + .checks + .iter() + .filter(|c| c.required_for == *level && c.passed) + .count(); + (*level, passed, total) + }) + .collect() + } + + /// Strict tier compliance: every check required at or below `level` + /// must pass. Tiers build on each other, so Silver compliance implies + /// Bronze compliance. + pub fn compliant_at(&self, level: ComplianceLevel) -> bool { + let max_rank = level.rank(); + !self + .checks + .iter() + .any(|c| c.required_for.rank() <= max_rank && !c.passed) + } + + /// Whether any `critical`-level security warning was recorded. + pub fn has_critical_warnings(&self) -> bool { + self.warnings.iter().any(|w| w.level == "critical") + } + + /// Failing checks, for suggestions and SARIF results. + pub fn failing_checks(&self) -> Vec<&CheckResult> { + self.checks.iter().filter(|c| !c.passed).collect() + } } diff --git a/aletheia/tests/integration_tests.rs b/aletheia/tests/integration_tests.rs index 1168c1d..f073685 100644 --- a/aletheia/tests/integration_tests.rs +++ b/aletheia/tests/integration_tests.rs @@ -1,16 +1,28 @@ // SPDX-License-Identifier: MPL-2.0 // Copyright (c) Jonathan D.A. Jewell -//! Integration tests for Aletheia RSR compliance verification +//! Integration tests for Aletheia RSR compliance verification. //! -//! These tests verify the complete end-to-end functionality of Aletheia. +//! End-to-end coverage of the CLI surface: fixtures below encode the RSR +//! v2 file shape ( criteria `hyperpolymath/standards` +//! `0-canon/rsr/rsr-criteria-v2.a2ml`), NOT the retired 2025 v1 shape +//! (LICENSE.txt / justfile / flake.nix / .gitlab-ci.yml). Where v1 and +//! v2 conflict, a dedicated test pins the v2 behaviour. +//! +//! The binary under test is resolved via `CARGO_BIN_EXE_aletheia` (the +//! already-built binary), never via a nested `cargo run`. use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; -/// Helper to create a temporary test repository +/// The already-built binary under test. +fn aletheia() -> Command { + Command::new(env!("CARGO_BIN_EXE_aletheia")) +} + +/// Helper to create a temporary test repository. fn create_test_repo(name: &str) -> PathBuf { - let test_dir = std::env::temp_dir().join(format!("aletheia_test_{}", name)); + let test_dir = std::env::temp_dir().join(format!("aletheia_test_{name}")); // Clean up if it exists if test_dir.exists() { @@ -21,7 +33,7 @@ fn create_test_repo(name: &str) -> PathBuf { test_dir } -/// Helper to create a file in the test repo +/// Helper to create a file in the test repo. fn create_file(base: &Path, path: &str, content: &str) { let file_path = base.join(path); @@ -33,66 +45,84 @@ fn create_file(base: &Path, path: &str, content: &str) { fs::write(file_path, content).expect("Failed to create file"); } -/// Create a fully compliant test repository (Bronze + Silver) +const PINNED_WORKFLOW: &str = "name: CI\non: [push]\njobs:\n t:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1\n"; + +/// Create a fully compliant test repository (Bronze + Silver + Gold). fn create_fully_compliant_repo(name: &str) -> PathBuf { let repo = create_test_repo(name); + // Infrastructure / CI (Bronze + Silver) + create_file(&repo, ".github/workflows/ci.yml", PINNED_WORKFLOW); + create_file(&repo, ".github/workflows/hypatia-scan.yml", PINNED_WORKFLOW); + create_file(&repo, ".github/workflows/governance.yml", PINNED_WORKFLOW); + + // Build system (Bronze + Silver) + create_file( + &repo, + "Justfile", + "build:\n\tcargo build\n\ntest:\n\tcargo test\n", + ); + create_file(&repo, ".editorconfig", "root = true\n"); + create_file(&repo, ".tool-versions", "rust 1.80\n"); + // Documentation (Bronze) - create_file(&repo, "README.md", "# Test Project"); - create_file(&repo, "LICENSE.txt", "MIT License"); - create_file(&repo, "SECURITY.md", "# Security Policy"); + create_file(&repo, "README.adoc", "= Test Project\n"); + create_file(&repo, "LICENSE", "Mozilla Public License Version 2.0\n"); create_file( &repo, - "CONTRIBUTING.md", - "# Contributing\n\n## How to Contribute\n\nGetting started with development.\n\n## Pull Request Process\n\nPlease submit a pull request.\n\n## Setup\n\nRun the setup script.\n\nMore details on how to get involved.\n", + "LICENSES/MPL-2.0.txt", + "Mozilla Public License Version 2.0\n", ); - create_file(&repo, "CODE_OF_CONDUCT.md", "# Code of Conduct"); - create_file(&repo, "MAINTAINERS.md", "# Maintainers"); - create_file(&repo, "CHANGELOG.md", "# Changelog"); + create_file(&repo, "SECURITY.md", "# Security Policy\n"); + create_file(&repo, ".gitignore", "target/\n"); + create_file(&repo, ".gitattributes", "*.rs text eol=lf\n"); + + // Documentation (Silver) + create_file(&repo, "CODE_OF_CONDUCT.md", "# Code of Conduct\n"); + create_file(&repo, "CONTRIBUTING.md", "# Contributing\n"); + create_file(&repo, "CHANGELOG.adoc", "== Changelog\n"); - // .well-known (Bronze) + // Well-known (Silver) create_file( &repo, ".well-known/security.txt", - "Contact: security@example.org", + "Contact: security@example.org\n", ); - create_file(&repo, ".well-known/ai.txt", "# AI Policy"); - create_file(&repo, ".well-known/humans.txt", "# Humans"); + create_file(&repo, ".well-known/ai.txt", "# AI Policy\n"); + create_file(&repo, ".well-known/humans.txt", "# Humans\n"); - // Build system (Bronze) - create_file(&repo, "justfile", "build:\n\techo 'building'"); - create_file(&repo, "flake.nix", "{}"); - create_file(&repo, ".gitlab-ci.yml", "test:\n script: echo 'test'"); + // Machine-readable (Silver) + create_file(&repo, "0-AI-MANIFEST.a2ml", "# agent front door\n"); + create_file( + &repo, + ".machine_readable/rsr-profile.a2ml", + "# capabilities\n", + ); - // Source structure (Bronze) + // Source with SPDX header (Bronze) create_file( &repo, "src/main.rs", - "// SPDX-License-Identifier: MPL-2.0\nfn main() {}", + "// SPDX-License-Identifier: MPL-2.0\nfn main() {}\n", ); - create_file(&repo, "tests/test.rs", "#[test] fn test() {}"); - - // Silver checks - create_file(&repo, ".editorconfig", "root = true\n"); repo } -/// Test verification of a fully compliant repository +/// Test verification of a fully compliant repository. #[test] fn test_fully_compliant_repository() { let repo = create_fully_compliant_repo("compliant"); - // Run aletheia on the test repository - let output = Command::new("cargo") - .args(["run", "--", repo.to_str().unwrap()]) + let output = aletheia() + .arg(repo.to_str().unwrap()) .output() .expect("Failed to run aletheia"); - // Should exit with success (Bronze compliance) assert!( output.status.success(), - "Fully compliant repository should pass verification" + "Fully compliant repository should pass verification: {}", + String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); @@ -100,28 +130,29 @@ fn test_fully_compliant_repository() { stdout.contains("Bronze-level RSR compliance: ACHIEVED"), "Should achieve Bronze compliance" ); + assert!( + stdout.contains("Silver-level RSR compliance: ACHIEVED"), + "Should achieve Silver compliance" + ); + assert!(stdout.contains("Score:"), "Should show score"); - // Clean up fs::remove_dir_all(repo).ok(); } -/// Test verification of a partially compliant repository +/// Test verification of a partially compliant repository. #[test] fn test_partially_compliant_repository() { let repo = create_test_repo("partial"); - // Create only some required files create_file(&repo, "README.md", "# Test Project"); - create_file(&repo, "LICENSE.txt", "MIT License"); + create_file(&repo, "LICENSE", "Mozilla Public License Version 2.0"); create_file(&repo, "src/main.rs", "fn main() {}"); - // Run aletheia on the test repository - let output = Command::new("cargo") - .args(["run", "--", repo.to_str().unwrap()]) + let output = aletheia() + .arg(repo.to_str().unwrap()) .output() .expect("Failed to run aletheia"); - // Should exit with failure assert!( !output.status.success(), "Partially compliant repository should fail verification" @@ -133,22 +164,19 @@ fn test_partially_compliant_repository() { "Should not achieve Bronze compliance" ); - // Clean up fs::remove_dir_all(repo).ok(); } -/// Test verification of empty repository +/// Test verification of empty repository. #[test] fn test_empty_repository() { let repo = create_test_repo("empty"); - // Run aletheia on empty repository - let output = Command::new("cargo") - .args(["run", "--", repo.to_str().unwrap()]) + let output = aletheia() + .arg(repo.to_str().unwrap()) .output() .expect("Failed to run aletheia"); - // Should exit with failure assert!( !output.status.success(), "Empty repository should fail verification" @@ -160,20 +188,19 @@ fn test_empty_repository() { "Should not meet Bronze compliance" ); - // Clean up fs::remove_dir_all(repo).ok(); } -/// Test handling of non-existent path +/// Test handling of non-existent path. #[test] fn test_nonexistent_path() { - let output = Command::new("cargo") - .args(["run", "--", "/nonexistent/path/that/does/not/exist"]) + let output = aletheia() + .arg("/nonexistent/path/that/does/not/exist") .output() .expect("Failed to run aletheia"); - // Should exit with error assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(3)); let stderr = String::from_utf8_lossy(&output.stderr); assert!( @@ -182,40 +209,33 @@ fn test_nonexistent_path() { ); } -/// Test self-verification (Aletheia verifying itself) +/// Test self-verification (Aletheia verifying itself). #[test] fn test_self_verification() { - let output = Command::new("cargo") - .args(["run"]) + let output = aletheia() + .current_dir(env!("CARGO_MANIFEST_DIR")) .output() .expect("Failed to run aletheia self-verification"); let stdout = String::from_utf8_lossy(&output.stdout); - // Bronze compliance should always pass for aletheia itself + // Bronze compliance must always pass for aletheia itself. assert!( stdout.contains("Bronze-level RSR compliance: ACHIEVED"), - "Aletheia should achieve Bronze compliance on itself" - ); - - // Should have Bronze checks (16) plus Silver checks - assert!( - stdout.contains("Content Validation"), - "Should include Content Validation section" + "Aletheia should achieve Bronze compliance on itself:\n{stdout}" ); } -/// Test output format consistency +/// Test output format consistency. #[test] fn test_output_format() { - let output = Command::new("cargo") - .args(["run"]) + let output = aletheia() + .current_dir(env!("CARGO_MANIFEST_DIR")) .output() .expect("Failed to run aletheia"); let stdout = String::from_utf8_lossy(&output.stdout); - // Check for expected output sections assert!( stdout.contains("Aletheia - RSR Compliance Verification Report"), "Should have report header" @@ -224,6 +244,7 @@ fn test_output_format() { stdout.contains("Repository:"), "Should show repository path" ); + assert!(stdout.contains("Verified:"), "Should show timestamp"); assert!( stdout.contains("Documentation"), "Should have Documentation section" @@ -236,14 +257,7 @@ fn test_output_format() { stdout.contains("Build System"), "Should have Build System section" ); - assert!( - stdout.contains("Source Structure"), - "Should have Source Structure section" - ); - assert!( - stdout.contains("Content Validation"), - "Should have Content Validation section" - ); + assert!(stdout.contains("Security"), "Should have Security section"); assert!(stdout.contains("Score:"), "Should show score"); assert!( stdout.contains("Bronze-level RSR compliance:"), @@ -251,51 +265,13 @@ fn test_output_format() { ); } -/// Test that tests directory can be named 'test' or 'tests' -#[test] -fn test_alternate_test_directory_names() { - // Test with 'tests' directory - let repo1 = create_test_repo("with_tests"); - create_file(&repo1, "src/main.rs", "fn main() {}"); - create_file(&repo1, "tests/test.rs", "#[test] fn test() {}"); - - let output1 = Command::new("cargo") - .args(["run", "--", repo1.to_str().unwrap()]) - .output() - .expect("Failed to run aletheia"); - - let stdout1 = String::from_utf8_lossy(&output1.stdout); - assert!( - stdout1.contains("✅ tests/ directory"), - "Should accept 'tests' directory" - ); - - // Test with 'test' directory - let repo2 = create_test_repo("with_test"); - create_file(&repo2, "src/main.rs", "fn main() {}"); - create_file(&repo2, "test/test.rs", "#[test] fn test() {}"); - - let output2 = Command::new("cargo") - .args(["run", "--", repo2.to_str().unwrap()]) - .output() - .expect("Failed to run aletheia"); - - let stdout2 = String::from_utf8_lossy(&output2.stdout); - assert!( - stdout2.contains("✅ tests/ directory"), - "Should accept 'test' directory" - ); - - // Clean up - fs::remove_dir_all(repo1).ok(); - fs::remove_dir_all(repo2).ok(); -} - -/// Test JSON output format +/// Test JSON output format. #[test] fn test_json_output() { - let output = Command::new("cargo") - .args(["run", "--", "--format", "json"]) + let repo = create_fully_compliant_repo("json_out"); + let output = aletheia() + .args(["--format", "json"]) + .arg(repo.to_str().unwrap()) .output() .expect("Failed to run aletheia with JSON format"); @@ -303,7 +279,6 @@ fn test_json_output() { let stdout = String::from_utf8_lossy(&output.stdout); - // Verify JSON structure assert!(stdout.contains("\"version\":"), "Should have version field"); assert!( stdout.contains("\"repository\":"), @@ -323,13 +298,17 @@ fn test_json_output() { stdout.contains("\"warnings\":"), "Should have warnings array" ); + + fs::remove_dir_all(repo).ok(); } -/// Test SARIF output format +/// Test SARIF output format. #[test] fn test_sarif_output() { - let output = Command::new("cargo") - .args(["run", "--", "--format", "sarif"]) + let repo = create_fully_compliant_repo("sarif_out"); + let output = aletheia() + .args(["--format", "sarif"]) + .arg(repo.to_str().unwrap()) .output() .expect("Failed to run aletheia with SARIF format"); @@ -337,7 +316,6 @@ fn test_sarif_output() { let stdout = String::from_utf8_lossy(&output.stdout); - // Verify SARIF structure assert!( stdout.contains("\"version\": \"2.1.0\""), "Should have SARIF version" @@ -352,13 +330,17 @@ fn test_sarif_output() { assert!(stdout.contains("\"results\":"), "Should have results"); assert!(stdout.contains("\"ruleId\":"), "Results should have ruleId"); assert!(stdout.contains("rsr/"), "Rule IDs should use rsr/ prefix"); + + fs::remove_dir_all(repo).ok(); } -/// Test quiet mode output +/// Test quiet mode output (pass). #[test] fn test_quiet_mode() { - let output = Command::new("cargo") - .args(["run", "--", "-q"]) + let repo = create_fully_compliant_repo("quiet_pass"); + let output = aletheia() + .arg("-q") + .arg(repo.to_str().unwrap()) .output() .expect("Failed to run aletheia in quiet mode"); @@ -366,13 +348,34 @@ fn test_quiet_mode() { let stdout = String::from_utf8_lossy(&output.stdout); assert_eq!(stdout.trim(), "PASS", "Quiet mode should only output PASS"); + + fs::remove_dir_all(repo).ok(); +} + +/// Test quiet mode output (fail). +#[test] +fn test_quiet_mode_fail() { + let repo = create_test_repo("quiet_fail"); + let output = aletheia() + .arg("-q") + .arg(repo.to_str().unwrap()) + .output() + .expect("Failed to run aletheia in quiet mode"); + + assert!(!output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout.trim(), "FAIL", "Quiet mode should only output FAIL"); + + fs::remove_dir_all(repo).ok(); } -/// Test verbose mode output +/// Test verbose mode output. #[test] fn test_verbose_mode() { - let output = Command::new("cargo") - .args(["run", "--", "-v"]) + let repo = create_fully_compliant_repo("verbose"); + let output = aletheia() + .arg("-v") + .arg(repo.to_str().unwrap()) .output() .expect("Failed to run aletheia in verbose mode"); @@ -388,13 +391,19 @@ fn test_verbose_mode() { stdout.contains("Exit code:"), "Should show exit code explanation" ); + assert!( + stdout.contains("Per-tier results:"), + "Should show per-tier counts" + ); + + fs::remove_dir_all(repo).ok(); } -/// Test version flag +/// Test version flag. #[test] fn test_version_flag() { - let output = Command::new("cargo") - .args(["run", "--", "--version"]) + let output = aletheia() + .arg("--version") .output() .expect("Failed to run aletheia with --version"); @@ -404,11 +413,11 @@ fn test_version_flag() { assert!(stdout.contains("aletheia"), "Should show program name"); } -/// Test help flag +/// Test help flag. #[test] fn test_help_flag() { - let output = Command::new("cargo") - .args(["run", "--", "--help"]) + let output = aletheia() + .arg("--help") .output() .expect("Failed to run aletheia with --help"); @@ -422,20 +431,18 @@ fn test_help_flag() { assert!(stdout.contains("sarif"), "Should mention SARIF format"); } -/// Test exit codes for non-compliant repository +/// Test exit codes for non-compliant repository. #[test] fn test_exit_code_compliance_failed() { let repo = create_test_repo("exit_code_fail"); - // Create minimal non-compliant repo create_file(&repo, "README.md", "# Test"); - let output = Command::new("cargo") - .args(["run", "--", repo.to_str().unwrap()]) + let output = aletheia() + .arg(repo.to_str().unwrap()) .output() .expect("Failed to run aletheia"); - // Exit code 1 = compliance failed assert_eq!( output.status.code(), Some(1), @@ -445,15 +452,14 @@ fn test_exit_code_compliance_failed() { fs::remove_dir_all(repo).ok(); } -/// Test exit code for invalid path +/// Test exit code for invalid path. #[test] fn test_exit_code_invalid_path() { - let output = Command::new("cargo") - .args(["run", "--", "/nonexistent/path/12345"]) + let output = aletheia() + .arg("/nonexistent/path/12345") .output() .expect("Failed to run aletheia"); - // Exit code 3 = invalid path assert_eq!( output.status.code(), Some(3), @@ -461,15 +467,14 @@ fn test_exit_code_invalid_path() { ); } -/// Test exit code for invalid arguments +/// Test exit code for invalid arguments. #[test] fn test_exit_code_invalid_args() { - let output = Command::new("cargo") - .args(["run", "--", "--invalid-option"]) + let output = aletheia() + .arg("--invalid-option") .output() .expect("Failed to run aletheia"); - // Exit code 4 = invalid arguments assert_eq!( output.status.code(), Some(4), @@ -477,11 +482,13 @@ fn test_exit_code_invalid_args() { ); } -/// Test combined short format flag +/// Test combined format flag. #[test] fn test_format_equals_syntax() { - let output = Command::new("cargo") - .args(["run", "--", "--format=json"]) + let repo = create_fully_compliant_repo("fmt_eq"); + let output = aletheia() + .arg("--format=json") + .arg(repo.to_str().unwrap()) .output() .expect("Failed to run aletheia with --format=json"); @@ -489,37 +496,60 @@ fn test_format_equals_syntax() { let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.starts_with('{'), "Should output JSON"); + + fs::remove_dir_all(repo).ok(); } -/// Test README.adoc alternative +/// Test README.md accepted as README.adoc alternative. #[test] -fn test_readme_adoc_alternative() { - let repo = create_test_repo("readme_adoc"); +fn test_readme_md_alternative() { + let repo = create_fully_compliant_repo("readme_md"); + fs::remove_file(repo.join("README.adoc")).ok(); + create_file(&repo, "README.md", "# Test Project"); - // Create with README.adoc instead of README.md - create_file(&repo, "README.adoc", "= Test Project"); - create_file(&repo, "LICENSE.txt", "MIT"); - create_file(&repo, "src/main.rs", "fn main() {}"); + let output = aletheia() + .arg(repo.to_str().unwrap()) + .output() + .expect("Failed to run aletheia"); - let output = Command::new("cargo") - .args(["run", "--", repo.to_str().unwrap()]) + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("✅ README.adoc (or README.md)"), + "Should accept README.md as README.adoc alternative" + ); + + fs::remove_dir_all(repo).ok(); +} + +/// Test LICENSE.txt alone is the retired v1 shape (v2 wants LICENSE). +#[test] +fn test_license_txt_is_retired_shape() { + let repo = create_test_repo("license_txt"); + create_file(&repo, "LICENSE.txt", "MIT License"); + + let output = aletheia() + .arg(repo.to_str().unwrap()) .output() .expect("Failed to run aletheia"); let stdout = String::from_utf8_lossy(&output.stdout); assert!( - stdout.contains("✅ README.md"), - "Should accept README.adoc as README.md alternative" + stdout.contains("❌ LICENSE file (not LICENSE.txt)"), + "LICENSE.txt alone must fail the v2 license-file check" + ); + assert!( + stdout.contains("retired v1 shape"), + "Should explain the v1 retirement" ); fs::remove_dir_all(repo).ok(); } -/// Test timestamp is present in output +/// Test timestamp is present in output. #[test] fn test_timestamp_in_output() { - let output = Command::new("cargo") - .args(["run"]) + let output = aletheia() + .current_dir(env!("CARGO_MANIFEST_DIR")) .output() .expect("Failed to run aletheia"); @@ -528,55 +558,101 @@ fn test_timestamp_in_output() { stdout.contains("Verified:"), "Should show verification timestamp" ); - // Check ISO 8601 format (contains T and Z) assert!( stdout.contains('T') && stdout.contains('Z'), "Timestamp should be in ISO 8601 format" ); } -/// Test .aletheia.toml configuration file +/// Test .aletheia.toml disabling checks skips them. #[test] fn test_config_file() { let repo = create_fully_compliant_repo("config_test"); - // Create config that disables Silver checks create_file( &repo, ".aletheia.toml", - r#" -[aletheia] -level = "bronze" - -[checks] -editorconfig = false -spdx-headers = false -workflow-pins = false -contributing-content = false -"#, + "[checks]\nchangelog = false\nwellknown-core = false\n", ); - let output = Command::new("cargo") - .args(["run", "--", repo.to_str().unwrap()]) + let output = aletheia() + .arg(repo.to_str().unwrap()) .output() .expect("Failed to run aletheia with config"); assert!( output.status.success(), - "Should succeed with config disabling Silver checks" + "Should succeed with checks disabled via config" ); let stdout = String::from_utf8_lossy(&output.stdout); - // Should NOT contain Content Validation section when all Silver checks disabled + // Disabled checks are skipped, not recorded. + assert!( + !stdout.contains("CHANGELOG.adoc (or .md)"), + "Disabled changelog check should not be reported" + ); + assert!( + !stdout.contains(".well-known core files"), + "Disabled well-known check should not be reported" + ); + + fs::remove_dir_all(repo).ok(); +} + +/// Test level=silver makes Silver failures fatal. +#[test] +fn test_config_level_silver() { + let repo = create_fully_compliant_repo("level_silver"); + // Break a Silver-only check. + fs::remove_file(repo.join("CHANGELOG.adoc")).ok(); + create_file(&repo, ".aletheia.toml", "[aletheia]\nlevel = \"silver\"\n"); + + let output = aletheia() + .arg(repo.to_str().unwrap()) + .output() + .expect("Failed to run aletheia"); + + assert_eq!( + output.status.code(), + Some(1), + "Silver failure with level=silver should exit 1" + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("Bronze-level RSR compliance: ACHIEVED"), + "Bronze should still hold" + ); assert!( - !stdout.contains("Content Validation"), - "Should not show Content Validation when Silver checks disabled" + stdout.contains("Silver-level RSR compliance: NOT MET"), + "Silver should fail" ); fs::remove_dir_all(repo).ok(); } -/// Test SPDX header detection +/// Test unknown level falls back to bronze with a warning. +#[test] +fn test_config_unknown_level_falls_back() { + let repo = create_fully_compliant_repo("level_unknown"); + create_file(&repo, ".aletheia.toml", "level = \"copper\"\n"); + + let output = aletheia() + .arg(repo.to_str().unwrap()) + .output() + .expect("Failed to run aletheia"); + + assert!(output.status.success(), "Should fall back to bronze"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("unknown level"), + "Should warn about the unknown level" + ); + + fs::remove_dir_all(repo).ok(); +} + +/// Test SPDX header detection names the offending file. #[test] fn test_spdx_header_detection() { let repo = create_test_repo("spdx_test"); @@ -587,31 +663,33 @@ fn test_spdx_header_detection() { "src/main.rs", "// SPDX-License-Identifier: MPL-2.0\nfn main() {}", ); - create_file( - &repo, - "src/lib.rs", - "// SPDX-License-Identifier: MPL-2.0\npub fn hello() {}", - ); + create_file(&repo, "src/lib.rs", "pub fn hello() {}"); - let output = Command::new("cargo") - .args(["run", "--", repo.to_str().unwrap()]) + let output = aletheia() + .arg(repo.to_str().unwrap()) .output() .expect("Failed to run aletheia"); let stdout = String::from_utf8_lossy(&output.stdout); assert!( - stdout.contains("SPDX license headers"), - "Should check SPDX headers" + stdout.contains("❌ SPDX license headers"), + "Should fail SPDX check for headerless file" + ); + assert!( + stdout.contains("src/lib.rs"), + "Suggestion should name the offending file" ); fs::remove_dir_all(repo).ok(); } -/// Test --format=sarif syntax +/// Test --format=sarif syntax. #[test] fn test_sarif_format_equals_syntax() { - let output = Command::new("cargo") - .args(["run", "--", "--format=sarif"]) + let repo = create_fully_compliant_repo("sarif_eq"); + let output = aletheia() + .arg("--format=sarif") + .arg(repo.to_str().unwrap()) .output() .expect("Failed to run aletheia with --format=sarif"); @@ -625,13 +703,17 @@ fn test_sarif_format_equals_syntax() { stdout.contains("sarif-schema-2.1.0"), "Should reference SARIF schema" ); + + fs::remove_dir_all(repo).ok(); } -/// Test HTML output format +/// Test HTML output format. #[test] fn test_html_output() { - let output = Command::new("cargo") - .args(["run", "--", "--format", "html"]) + let repo = create_fully_compliant_repo("html_out"); + let output = aletheia() + .args(["--format", "html"]) + .arg(repo.to_str().unwrap()) .output() .expect("Failed to run aletheia with HTML format"); @@ -648,13 +730,17 @@ fn test_html_output() { stdout.contains("Bronze-level RSR compliance"), "Should show compliance status" ); + + fs::remove_dir_all(repo).ok(); } -/// Test --format=html syntax +/// Test --format=html syntax. #[test] fn test_html_format_equals_syntax() { - let output = Command::new("cargo") - .args(["run", "--", "--format=html"]) + let repo = create_fully_compliant_repo("html_eq"); + let output = aletheia() + .arg("--format=html") + .arg(repo.to_str().unwrap()) .output() .expect("Failed to run aletheia with --format=html"); @@ -662,13 +748,17 @@ fn test_html_format_equals_syntax() { let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains(""), "Should output HTML"); + + fs::remove_dir_all(repo).ok(); } -/// Test SVG badge output +/// Test SVG badge output (passing). #[test] fn test_badge_output() { - let output = Command::new("cargo") - .args(["run", "--", "--badge"]) + let repo = create_fully_compliant_repo("badge_pass"); + let output = aletheia() + .arg("--badge") + .arg(repo.to_str().unwrap()) .output() .expect("Failed to run aletheia with --badge"); @@ -680,22 +770,38 @@ fn test_badge_output() { stdout.contains("RSR compliance"), "Should have RSR compliance label" ); - assert!( - stdout.contains("passing"), - "Should show passing status (aletheia passes Bronze)" - ); + assert!(stdout.contains("passing"), "Should show passing status"); + + fs::remove_dir_all(repo).ok(); } -/// Test fix suggestions in verbose mode +/// Test SVG badge output (failing). +#[test] +fn test_badge_failing() { + let repo = create_test_repo("badge_fail"); + let output = aletheia() + .arg("--badge") + .arg(repo.to_str().unwrap()) + .output() + .expect("Failed to run aletheia with --badge"); + + assert!(!output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("failing"), "Should show failing status"); + + fs::remove_dir_all(repo).ok(); +} + +/// Test fix suggestions in verbose mode. #[test] fn test_fix_suggestions_verbose() { let repo = create_test_repo("fix_suggestions"); - // Create minimal repo (will fail many checks) create_file(&repo, "README.md", "# Test"); - let output = Command::new("cargo") - .args(["run", "--", "-v", repo.to_str().unwrap()]) + let output = aletheia() + .arg("-v") + .arg(repo.to_str().unwrap()) .output() .expect("Failed to run aletheia verbose"); @@ -708,15 +814,15 @@ fn test_fix_suggestions_verbose() { fs::remove_dir_all(repo).ok(); } -/// Test fix suggestions in normal mode +/// Test fix suggestions in normal mode. #[test] fn test_fix_suggestions_normal() { let repo = create_test_repo("fix_suggestions_normal"); create_file(&repo, "README.md", "# Test"); - let output = Command::new("cargo") - .args(["run", "--", repo.to_str().unwrap()]) + let output = aletheia() + .arg(repo.to_str().unwrap()) .output() .expect("Failed to run aletheia"); @@ -729,27 +835,23 @@ fn test_fix_suggestions_normal() { fs::remove_dir_all(repo).ok(); } -/// Test ignore patterns in config +/// Test ignore patterns in config. #[test] fn test_ignore_patterns() { let repo = create_fully_compliant_repo("ignore_patterns"); - // Create config that ignores flake.nix create_file( &repo, ".aletheia.toml", - r#" -[ignore] -files = ["flake.nix", ".gitlab-ci.yml"] -"#, + "[ignore]\nfiles = [\"CHANGELOG.adoc\", \".tool-versions\"]\n", ); - // Delete the files that are being ignored - fs::remove_file(repo.join("flake.nix")).ok(); - fs::remove_file(repo.join(".gitlab-ci.yml")).ok(); + // Delete the files that are being ignored. + fs::remove_file(repo.join("CHANGELOG.adoc")).ok(); + fs::remove_file(repo.join(".tool-versions")).ok(); - let output = Command::new("cargo") - .args(["run", "--", repo.to_str().unwrap()]) + let output = aletheia() + .arg(repo.to_str().unwrap()) .output() .expect("Failed to run aletheia with ignore patterns"); @@ -767,20 +869,241 @@ files = ["flake.nix", ".gitlab-ci.yml"] fs::remove_dir_all(repo).ok(); } -/// Test init-hook subcommand +/// Test unpinned workflows fail Silver but keep Bronze green. +#[test] +fn test_sha_pinning_is_silver() { + let repo = create_fully_compliant_repo("sha_pin"); + create_file( + &repo, + ".github/workflows/ci.yml", + "name: CI\non: [push]\njobs:\n t:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n", + ); + + let output = aletheia() + .arg(repo.to_str().unwrap()) + .output() + .expect("Failed to run aletheia"); + + // Silver failure must not fail a Bronze gate. + assert!( + output.status.success(), + "Unpinned workflow is Silver, not Bronze" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("❌ GitHub Actions SHA pinning"), + "Should flag the unpinned workflow" + ); + + fs::remove_dir_all(repo).ok(); +} + +/// Test banned languages fail Bronze. +#[test] +fn test_banned_language() { + let repo = create_fully_compliant_repo("banned_lang"); + create_file(&repo, "scripts/helper.py", "print('hi')\n"); + + let output = aletheia() + .arg(repo.to_str().unwrap()) + .output() + .expect("Failed to run aletheia"); + + assert_eq!( + output.status.code(), + Some(1), + "Banned language is a Bronze failure" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("❌ No banned languages"), + "Should flag the banned language" + ); + + fs::remove_dir_all(repo).ok(); +} + +/// Test Coq `.v` files are not mistaken for V-lang. +#[test] +fn test_coq_v_not_flagged() { + let repo = create_fully_compliant_repo("coq_v"); + create_file( + &repo, + "proofs/Lemma.v", + "(* SPDX-License-Identifier: MPL-2.0 *)\nRequire Import List.\nLemma trivial_one : 1 = 1.\nProof. reflexivity. Qed.\n", + ); + + let output = aletheia() + .arg(repo.to_str().unwrap()) + .output() + .expect("Failed to run aletheia"); + + assert!( + output.status.success(), + "Coq sources must not trip the V-lang ban" + ); + + fs::remove_dir_all(repo).ok(); +} + +/// Test silent-skip detection is Gold (non-fatal at Bronze). +#[test] +fn test_no_silent_skip_is_gold() { + let repo = create_fully_compliant_repo("silent_skip"); + create_file( + &repo, + "Justfile", + "test:\n\tcargo test || echo SKIP tests unavailable\n", + ); + + let output = aletheia() + .arg(repo.to_str().unwrap()) + .output() + .expect("Failed to run aletheia"); + + assert!(output.status.success(), "Silent-skip is Gold, not Bronze"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("❌ No silent-skip in recipes"), + "Should flag the silent-skip pattern" + ); + + fs::remove_dir_all(repo).ok(); +} + +/// Test submodule contents are attributed to upstream, not this repo. +#[test] +fn test_submodule_skipped() { + let repo = create_fully_compliant_repo("submodule"); + create_file( + &repo, + ".gitmodules", + "[submodule \"vendor\"]\n\tpath = vendor\n\turl = https://example.test/vendor.git\n", + ); + // Banned content inside the submodule must not fail the outer scan. + create_file(&repo, "vendor/evil.py", "print('hi')\n"); + create_file(&repo, "vendor/Makefile", "all:\n\techo hi\n"); + + let output = aletheia() + .arg(repo.to_str().unwrap()) + .output() + .expect("Failed to run aletheia"); + + assert!( + output.status.success(), + "Submodule contents must be skipped: {}", + String::from_utf8_lossy(&output.stdout) + ); + + fs::remove_dir_all(repo).ok(); +} + +/// Test committed secret filenames fail Bronze. +#[test] +fn test_secret_filename() { + let repo = create_fully_compliant_repo("secret_name"); + create_file(&repo, ".env", "SMTP_PASSWORD=hunter2\n"); + + let output = aletheia() + .arg(repo.to_str().unwrap()) + .output() + .expect("Failed to run aletheia"); + + assert_eq!( + output.status.code(), + Some(1), + "Committed .env is a Bronze failure" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("❌ No secrets committed"), + "Should flag the committed secret file" + ); + + fs::remove_dir_all(repo).ok(); +} + +/// Test committed key material fails Bronze. +#[test] +fn test_secret_content() { + let repo = create_fully_compliant_repo("secret_content"); + // Built from halves: a whole marker literal here would trip the + // scanner on its own test source (see checks.rs fragment comment). + let key_block = format!( + "{}{}\nZmFrZXlhdHl6eQ==\n", + "-----BEGIN RSA ", "PRIVATE KEY-----" + ); + create_file(&repo, "deploy/key.pem", &key_block); + + let output = aletheia() + .arg(repo.to_str().unwrap()) + .output() + .expect("Failed to run aletheia"); + + assert_eq!( + output.status.code(), + Some(1), + "Committed key material is a Bronze failure" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("❌ No secrets committed"), + "Should flag the committed key material" + ); + + fs::remove_dir_all(repo).ok(); +} + +/// Test symlink escapes are critical (exit 2). +#[test] +#[cfg(unix)] +fn test_symlink_escape() { + let outside = create_test_repo("symlink_outside"); + create_file(&outside, "secret.txt", "outside\n"); + let repo = create_fully_compliant_repo("symlink_repo"); + + std::os::unix::fs::symlink(outside.join("secret.txt"), repo.join("leak")) + .expect("Failed to create symlink"); + + let output = aletheia() + .arg(repo.to_str().unwrap()) + .output() + .expect("Failed to run aletheia"); + + assert_eq!( + output.status.code(), + Some(2), + "Escaping symlink should exit 2" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("escapes repository"), + "Should report the escaping symlink" + ); + + fs::remove_dir_all(repo).ok(); + fs::remove_dir_all(outside).ok(); +} + +/// Test init-hook subcommand. #[test] fn test_init_hook() { let repo = create_test_repo("init_hook"); - // Initialize a git repo - std::process::Command::new("git") + // Initialize a git repo (skip if git is unavailable). + let init = Command::new("git") .args(["init"]) .current_dir(&repo) - .output() - .expect("Failed to init git repo"); + .output(); + if init.is_err() || !init.map(|o| o.status.success()).unwrap_or(false) { + eprintln!("skipping test_init_hook: git unavailable"); + fs::remove_dir_all(repo).ok(); + return; + } - let output = Command::new("cargo") - .args(["run", "--", "init-hook", repo.to_str().unwrap()]) + let output = aletheia() + .args(["init-hook"]) + .arg(repo.to_str().unwrap()) .output() .expect("Failed to run aletheia init-hook"); @@ -792,7 +1115,6 @@ fn test_init_hook() { "Should confirm hook installation" ); - // Verify the hook file exists let hook_path = repo.join(".git").join("hooks").join("pre-commit"); assert!(hook_path.exists(), "Pre-commit hook file should exist"); @@ -804,3 +1126,111 @@ fn test_init_hook() { fs::remove_dir_all(repo).ok(); } + +/// Test init-hook without a git repository fails honestly. +#[test] +fn test_init_hook_no_git() { + let repo = create_test_repo("init_hook_no_git"); + + let output = aletheia() + .args(["init-hook"]) + .arg(repo.to_str().unwrap()) + .output() + .expect("Failed to run aletheia init-hook"); + + assert_eq!( + output.status.code(), + Some(3), + "init-hook without .git should exit 3" + ); + + fs::remove_dir_all(repo).ok(); +} + +/// Test tags covered by actions.lock pass (estate mechanism: tags in YAML +/// plus a verified lockfile; governance forbids inline SHAs with a lock). +#[test] +fn test_tag_with_lockfile_passes() { + let repo = create_fully_compliant_repo("tag_locked"); + create_file( + &repo, + ".github/workflows/ci.yml", + "name: CI\non: [push]\njobs:\n t:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v7.0.1\n", + ); + create_file( + &repo, + ".github/workflows/actions.lock", + "version: 'v0.0.2'\nworkflows:\n '.github/workflows/ci.yml':\n - 'actions/checkout@v7.0.1'\n", + ); + let output = aletheia() + .arg(repo.to_str().unwrap()) + .output() + .expect("Failed to run aletheia"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("\u{2705} GitHub Actions SHA pinning"), + "Locked tag should pass: {stdout}" + ); + fs::remove_dir_all(repo).ok(); +} + +/// Test tags without lock coverage fail (strict SHAs, no-lock branch). +#[test] +fn test_tag_without_lockfile_fails() { + let repo = create_fully_compliant_repo("tag_unlocked"); + create_file( + &repo, + ".github/workflows/ci.yml", + "name: CI\non: [push]\njobs:\n t:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v7.0.1\n", + ); + let output = aletheia() + .arg(repo.to_str().unwrap()) + .output() + .expect("Failed to run aletheia"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("\u{274c} GitHub Actions SHA pinning"), + "Unlocked tag should fail: {stdout}" + ); + assert!( + stdout.contains("actions.lock"), + "Hint should name the lock: {stdout}" + ); + fs::remove_dir_all(repo).ok(); +} + +/// Test tier-1 Bun carve-out: runtime deps with a Bun lockfile pass. +#[test] +fn test_bun_lockfile_carveout() { + let repo = create_fully_compliant_repo("bun_ok"); + create_file(&repo, "package.json", "{\"dependencies\": {\"x\": \"1\"}}"); + create_file(&repo, "bun.lock", "# bun lockfile"); + let output = aletheia() + .arg(repo.to_str().unwrap()) + .output() + .expect("Failed to run aletheia"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("\u{2705} No Node/npm runtime deps"), + "Bun lockfile should carve out: {stdout}" + ); + fs::remove_dir_all(repo).ok(); + + let bare = create_fully_compliant_repo("bun_missing"); + create_file(&bare, "package.json", "{\"dependencies\": {\"x\": \"1\"}}"); + let output = aletheia() + .arg(bare.to_str().unwrap()) + .output() + .expect("Failed to run aletheia"); + assert_eq!( + output.status.code(), + Some(1), + "Runtime deps without a Bun lockfile are a Bronze failure" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("\u{274c} No Node/npm runtime deps"), + "Should flag the unlocked runtime deps: {stdout}" + ); + fs::remove_dir_all(bare).ok(); +}