diff --git a/.github/workflows/container-build.yml b/.github/workflows/container-build.yml new file mode 100644 index 0000000..c3950f5 --- /dev/null +++ b/.github/workflows/container-build.yml @@ -0,0 +1,57 @@ +# SPDX-License-Identifier: MPL-2.0 +name: container build +on: + pull_request: + paths: + - 'container/**' + - 'build/just/container.just' + - '.github/workflows/container-build.yml' + push: + tags: ['v*'] + workflow_dispatch: + +# Scope + cancel superseded runs (estate guardrail). +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + container: + # OFF by default — costs nothing in derived repos. A repo opts in by setting + # the repository/organisation variable CONTAINER_CI=true. When enabled it + # runs on OWNED self-hosted runners (no metered GitHub Actions minutes); + # override the labels with the CONTAINER_RUNNER variable (a JSON array). + if: vars.CONTAINER_CI == 'true' + runs-on: ${{ fromJSON(vars.CONTAINER_RUNNER || '["self-hosted","owned","container"]') }} + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Tooling check + run: | + command -v just >/dev/null 2>&1 || { echo "::error::just not found on this runner"; exit 1; } + # The container recipes auto-detect the engine (podman | nerdctl | docker). + for e in podman nerdctl docker; do command -v "$e" >/dev/null 2>&1 && { echo "engine: $e"; break; }; done + + - name: Build image + run: just container-build + + - name: Verify compose configuration + run: just container-verify + + - name: Scan image (trivy, best-effort) + run: | + if command -v trivy >/dev/null 2>&1; then + trivy image --severity HIGH,CRITICAL --exit-code 0 "${{ github.event.repository.name }}:latest" || true + else + echo "trivy not installed on this runner — skipping image scan" + fi + + - name: Sign & verify .ctp bundle (tags only) + if: startsWith(github.ref, 'refs/tags/v') + run: just container-sign # cerro-torre: build + pack + Ed25519 sign + verify diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml new file mode 100644 index 0000000..8946075 --- /dev/null +++ b/.github/workflows/dependabot-automerge.yml @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# dependabot-automerge.yml — enable GitHub's native auto-merge on +# Dependabot pull requests that match a declared severity / ecosystem +# policy. Pairs with `.github/dependabot.yml`'s +# `open-pull-requests-limit: 0` + security-only pattern (see the +# cargo block there). +# +# What this does: +# - Triggers on every Dependabot PR. +# - Reads the PR's update-type metadata via the dependabot/fetch-metadata +# action (no free-text parsing). +# - Requires CI to be green before merge (GitHub's auto-merge enforces +# required status checks). +# - Gates merge behind a severity+ecosystem policy table. Default is +# low+medium security updates only. +# +# Why auto-merge on GitHub (not via a bot like rhodibot) is the right +# layer: GitHub enforces branch protection + required checks natively, +# and the PR author is already `dependabot[bot]`. Rhodibot doesn't need +# to know anything about ecosystems — GitHub handles the merge mechanics +# once we approve. +# +# Threat model: +# - A compromised upstream package with a bogus security advisory +# could propose a malicious version bump. Mitigation: require at +# least one non-automated reviewer for HIGH+CRITICAL severity +# (done below — we explicitly refuse to auto-approve those). +# - A compromised Dependabot itself is an Akerlof claim-grounder +# problem. Not in scope here; track under +# `project_claim_grounders_dual_use_akerlof.md`. +# +# Dogfooding: this workflow template is itself subject to the same +# Dependabot config via the github-actions ecosystem block, so SHA +# bumps for dependabot/fetch-metadata flow through the same path. + +name: Dependabot Auto-Merge +on: + pull_request: + types: [opened, reopened, synchronize] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false +permissions: + contents: write # needed to enable auto-merge + pull-requests: write # needed to approve + # NB: keep narrow — do NOT add secrets: read or id-token: write here. +jobs: + automerge: + # Only run for PRs actually authored by Dependabot. + if: github.actor == 'dependabot[bot]' && github.event.pull_request.user.login == 'dependabot[bot]' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Fetch Dependabot metadata + id: meta + uses: dependabot/fetch-metadata@dbb049abf0d677abbd7f7eee0375145b417fdd34 # v2.2.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + # --- Policy gate ------------------------------------------------------- + # Outputs from fetch-metadata we care about: + # update-type → version-update:semver-{patch,minor,major} + # dependency-type → direct:{development,production} | indirect + # alert-state → AUTO_DISMISSED | DISMISSED | FIXED | OPEN + # ghsa-id → GHSA-... if this is a security PR + # --- Policy ------------------------------------------------------------- + # AUTO-APPROVE + AUTO-MERGE when: + # 1. This is a SECURITY update (ghsa-id present), AND + # 2. Update is patch or minor, AND + # 3. Severity ≤ moderate (Dependabot doesn't expose severity + # directly in fetch-metadata; infer from the absence of + # HIGH/CRITICAL labels added by Dependabot). + # Otherwise: do nothing. Human reviews HIGH+CRITICAL security + # updates and all non-security bumps. + - name: Decide policy outcome + id: policy + env: + GHSA_ID: ${{ steps.meta.outputs.ghsa-id }} + UPDATE_TYPE: ${{ steps.meta.outputs.update-type }} + PR_LABELS: ${{ toJson(github.event.pull_request.labels.*.name) }} + run: | + set -euo pipefail + + is_security=false + [ -n "$GHSA_ID" ] && is_security=true + + # Owner policy (deliberate: velocity over caution). Auto-merge EVERY + # Dependabot update — patch, minor AND major, security or routine. + # Safe because GitHub's auto-merge only COMPLETES once the required + # checks (secret-scanner, codeql, hypatia-scan, openssf-compliance, + # build/test) are green: a bump that breaks fails CI and the PR stays + # OPEN with an email ("oi, it broke") instead of landing on a red + # main; a bump that passes lands within minutes with no chasing. + # This is preferred over making Dependabot a ruleset BYPASS actor, + # which would let bumps skip those very checks (no gate, no signal). + echo "action=automerge" >> "$GITHUB_OUTPUT" + echo "security=$is_security" >> "$GITHUB_OUTPUT" + echo "update_type=$UPDATE_TYPE" >> "$GITHUB_OUTPUT" + echo "ghsa=$GHSA_ID" >> "$GITHUB_OUTPUT" + - name: Approve PR (if policy allows) + if: steps.policy.outputs.action == 'automerge' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ github.event.pull_request.html_url }} + run: | + gh pr review --approve "$PR_URL" \ + --body "Auto-approving Dependabot security update (${{ steps.policy.outputs.ghsa }}, ${{ steps.policy.outputs.update_type }}). Policy: low/moderate security patches/minors only." + - name: Enable auto-merge (if policy allows) + if: steps.policy.outputs.action == 'automerge' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ github.event.pull_request.html_url }} + run: | + gh pr merge --auto --squash "$PR_URL" + - name: Write decision to step summary + env: + ACTION: ${{ steps.policy.outputs.action }} + IS_SECURITY: ${{ steps.policy.outputs.security }} + UPDATE_TYPE: ${{ steps.policy.outputs.update_type }} + GHSA: ${{ steps.policy.outputs.ghsa }} + run: | + { + echo "## Dependabot Auto-Merge Decision" + echo "" + echo "| Field | Value |" + echo "|-------|-------|" + echo "| Policy action | \`$ACTION\` |" + echo "| Security update | \`$IS_SECURITY\` |" + echo "| Update type | \`$UPDATE_TYPE\` |" + echo "| GHSA ID | \`${GHSA:-n/a}\` |" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/dogfood-gate.yml b/.github/workflows/dogfood-gate.yml new file mode 100644 index 0000000..fac6439 --- /dev/null +++ b/.github/workflows/dogfood-gate.yml @@ -0,0 +1,375 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# dogfood-gate.yml — Hyperpolymath Dogfooding Quality Gate +# Validates that the repo uses hyperpolymath's own formats and tools. +# Companion to static-analysis-gate.yml (security) — this is for format compliance. +name: Dogfood Gate + +on: + pull_request: + branches: ['**'] + push: + branches: [main, master] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read + +jobs: + # --------------------------------------------------------------------------- + # Job 1: A2ML manifest validation + # --------------------------------------------------------------------------- + a2ml-validate: + name: Validate A2ML manifests + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Check for A2ML files + id: detect + run: | + COUNT=$(find . -name '*.a2ml' -not -path './.git/*' | wc -l) + echo "count=$COUNT" >> "$GITHUB_OUTPUT" + if [ "$COUNT" -eq 0 ]; then + echo "::warning::No .a2ml manifest files found. Every RSR repo should have 0-AI-MANIFEST.a2ml" + fi + + - name: Validate A2ML manifests + if: steps.detect.outputs.count > 0 + uses: hyperpolymath/a2ml-validate-action@cb3c1e298169dc5ac2b42e257068b0fb5920cd5e # main + with: + path: '.' + strict: 'false' + + - name: Write summary + run: | + A2ML_COUNT="${{ steps.detect.outputs.count }}" + if [ "$A2ML_COUNT" -eq 0 ]; then + cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" + ## A2ML Validation + + :warning: **No .a2ml files found.** Every RSR-compliant repo should have at least `0-AI-MANIFEST.a2ml`. + + Create one with: `a2mliser init` or copy from [rsr-template-repo](https://github.com/hyperpolymath/rsr-template-repo). + EOF + else + echo "## A2ML Validation" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Scanned **${A2ML_COUNT}** .a2ml file(s). See step output for details." >> "$GITHUB_STEP_SUMMARY" + fi + + # --------------------------------------------------------------------------- + # Job 2: K9 contract validation + # --------------------------------------------------------------------------- + k9-validate: + name: Validate K9 contracts + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Check for K9 files + id: detect + run: | + COUNT=$(find . \( -name '*.k9' -o -name '*.k9.ncl' \) -not -path './.git/*' | wc -l) + CONFIG_COUNT=$(find . \( -name '*.toml' -o -name '*.yaml' -o -name '*.yml' -o -name '*.json' \) \ + -not -path './.git/*' -not -path './node_modules/*' -not -path './.deno/*' \ + -not -name 'package-lock.json' -not -name 'Cargo.lock' -not -name 'deno.lock' | wc -l) + echo "k9_count=$COUNT" >> "$GITHUB_OUTPUT" + echo "config_count=$CONFIG_COUNT" >> "$GITHUB_OUTPUT" + if [ "$COUNT" -eq 0 ] && [ "$CONFIG_COUNT" -gt 0 ]; then + echo "::warning::Found $CONFIG_COUNT config files but no K9 contracts. Run k9iser to generate contracts." + fi + + - name: Validate K9 contracts + if: steps.detect.outputs.k9_count > 0 + uses: hyperpolymath/k9-validate-action@236f0035cc159051c8dd5dc7cd8af1e8cf961462 # main + with: + path: '.' + strict: 'false' + + - name: Write summary + run: | + K9_COUNT="${{ steps.detect.outputs.k9_count }}" + CFG_COUNT="${{ steps.detect.outputs.config_count }}" + if [ "$K9_COUNT" -eq 0 ]; then + cat <<'EOF' >> "$GITHUB_STEP_SUMMARY" + ## K9 Contract Validation + + :warning: **No K9 contract files found.** Repos with configuration files should have K9 contracts. + + Generate contracts with: `k9iser generate .` + EOF + else + echo "## K9 Contract Validation" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Validated **${K9_COUNT}** K9 contract(s) against **${CFG_COUNT}** config file(s)." >> "$GITHUB_STEP_SUMMARY" + fi + + # --------------------------------------------------------------------------- + # Job 3: Empty-linter — invisible character detection + # --------------------------------------------------------------------------- + empty-lint: + name: Empty-linter (invisible characters) + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Scan for invisible characters + id: lint + run: | + # Inline invisible character detection (from empty-linter's core patterns). + # Checks for: zero-width spaces, zero-width joiners, BOM, soft hyphens, + # non-breaking spaces, null bytes, and other invisible Unicode in source files. + set +e + PATTERNS='\xc2\xa0|\xe2\x80\x8b|\xe2\x80\x8c|\xe2\x80\x8d|\xef\xbb\xbf|\xc2\xad|\xe2\x80\x8e|\xe2\x80\x8f|\xe2\x80\xaa|\xe2\x80\xab|\xe2\x80\xac|\xe2\x80\xad|\xe2\x80\xae|\x00' + find "$GITHUB_WORKSPACE" \ + -not -path '*/.git/*' -not -path '*/node_modules/*' \ + -not -path '*/.deno/*' -not -path '*/target/*' \ + -not -path '*/_build/*' -not -path '*/deps/*' \ + -not -path '*/external_corpora/*' -not -path '*/.lake/*' \ + -type f \( -name '*.rs' -o -name '*.ex' -o -name '*.exs' -o -name '*.res' \ + -o -name '*.js' -o -name '*.ts' -o -name '*.json' -o -name '*.toml' \ + -o -name '*.yml' -o -name '*.yaml' -o -name '*.md' -o -name '*.adoc' \ + -o -name '*.idr' -o -name '*.zig' -o -name '*.v' -o -name '*.jl' \ + -o -name '*.gleam' -o -name '*.hs' -o -name '*.ml' -o -name '*.sh' \) \ + -exec grep -Prl "$PATTERNS" {} \; > /tmp/empty-lint-results.txt 2>/dev/null + EL_EXIT=$? + set -e + + FINDINGS=$(wc -l < /tmp/empty-lint-results.txt 2>/dev/null || echo 0) + echo "findings=$FINDINGS" >> "$GITHUB_OUTPUT" + echo "exit_code=$EL_EXIT" >> "$GITHUB_OUTPUT" + echo "ready=true" >> "$GITHUB_OUTPUT" + + # Emit annotations for each file with invisible chars + while IFS= read -r filepath; do + [ -z "$filepath" ] && continue + REL_PATH="${filepath#$GITHUB_WORKSPACE/}" + echo "::warning file=${REL_PATH}::Invisible Unicode characters detected (zero-width space, BOM, NBSP, etc.)" + done < /tmp/empty-lint-results.txt + + - name: Write summary + run: | + if [ "${{ steps.lint.outputs.ready }}" = "true" ]; then + FINDINGS="${{ steps.lint.outputs.findings }}" + if [ "$FINDINGS" -gt 0 ] 2>/dev/null; then + echo "## Empty-Linter Results" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Found **${FINDINGS}** invisible character issue(s). See annotations above." >> "$GITHUB_STEP_SUMMARY" + else + echo "## Empty-Linter Results" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo ":white_check_mark: No invisible character issues found." >> "$GITHUB_STEP_SUMMARY" + fi + else + echo "## Empty-Linter" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Skipped: empty-linter not available." >> "$GITHUB_STEP_SUMMARY" + fi + + # --------------------------------------------------------------------------- + # Job 4: Groove manifest check (for repos that should expose services) + # --------------------------------------------------------------------------- + groove-check: + name: Groove manifest check + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Check for Groove manifest + id: groove + run: | + # Check for static or dynamic Groove endpoints + HAS_MANIFEST="false" + HAS_GROOVE_CODE="false" + + if [ -f ".well-known/groove/manifest.json" ]; then + HAS_MANIFEST="true" + # Validate the manifest JSON + if ! jq empty .well-known/groove/manifest.json 2>/dev/null; then + echo "::error file=.well-known/groove/manifest.json::Invalid JSON in Groove manifest" + else + SVC_ID=$(jq -r '.service_id // "unknown"' .well-known/groove/manifest.json) + echo "service_id=$SVC_ID" >> "$GITHUB_OUTPUT" + fi + fi + + # Check for Groove endpoint code (Rust, Elixir, Zig, V) + if grep -rl 'well-known/groove' --include='*.rs' --include='*.ex' --include='*.zig' --include='*.v' --include='*.res' . 2>/dev/null | head -1 | grep -q .; then + HAS_GROOVE_CODE="true" + fi + + # Check if this repo likely serves HTTP (has server/listener code) + HAS_SERVER="false" + if grep -rl 'TcpListener\|Bandit\|Plug.Cowboy\|httpz\|vweb\|axum::serve\|actix_web' --include='*.rs' --include='*.ex' --include='*.zig' --include='*.v' . 2>/dev/null | head -1 | grep -q .; then + HAS_SERVER="true" + fi + + echo "has_manifest=$HAS_MANIFEST" >> "$GITHUB_OUTPUT" + echo "has_groove_code=$HAS_GROOVE_CODE" >> "$GITHUB_OUTPUT" + echo "has_server=$HAS_SERVER" >> "$GITHUB_OUTPUT" + + if [ "$HAS_SERVER" = "true" ] && [ "$HAS_MANIFEST" = "false" ] && [ "$HAS_GROOVE_CODE" = "false" ]; then + echo "::warning::This repo has server code but no Groove endpoint. Add .well-known/groove/manifest.json for service discovery." + fi + + - name: Write summary + run: | + echo "## Groove Protocol Check" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Check | Status |" >> "$GITHUB_STEP_SUMMARY" + echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY" + echo "| Static manifest (.well-known/groove/manifest.json) | ${{ steps.groove.outputs.has_manifest }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| Groove endpoint in code | ${{ steps.groove.outputs.has_groove_code }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| Has HTTP server code | ${{ steps.groove.outputs.has_server }} |" >> "$GITHUB_STEP_SUMMARY" + + # --------------------------------------------------------------------------- + # Job 5: eclexiaiser manifest validation + # --------------------------------------------------------------------------- + eclexiaiser-validate: + name: Validate eclexiaiser manifest + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Check and validate eclexiaiser manifest + id: eclex + run: | + if [ ! -f "eclexiaiser.toml" ]; then + # Check if repo has a Containerfile — if so, recommend eclexiaiser + if [ -f "Containerfile" ]; then + echo "::warning::Containerfile present but no eclexiaiser.toml. Run \`eclexiaiser init\` to scaffold energy/carbon budgets." + fi + echo "has_manifest=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "has_manifest=true" >> "$GITHUB_OUTPUT" + + # Validate eclexiaiser.toml structure (bash + grep; NO Python per estate policy). + # Structural presence checks only — deep schema validation is eclexiaiser's own job. + err=0 + grep -qE '^[[:space:]]*\[project\]' eclexiaiser.toml || { echo "::error file=eclexiaiser.toml::[project] section is required"; err=1; } + grep -qE '^[[:space:]]*name[[:space:]]*=[[:space:]]*"[^"]+"' eclexiaiser.toml || { echo "::error file=eclexiaiser.toml::a non-empty name is required"; err=1; } + grep -qE '^[[:space:]]*\[\[functions\]\]' eclexiaiser.toml || { echo "::error file=eclexiaiser.toml::at least one [[functions]] entry is required"; err=1; } + if [ "$err" -ne 0 ]; then + exit 1 + fi + fns=$(grep -cE '^[[:space:]]*\[\[functions\]\]' eclexiaiser.toml) + echo "Valid: eclexiaiser.toml structure present (${fns} function block(s))" + + - name: Write summary + run: | + if [ "${{ steps.eclex.outputs.has_manifest }}" = "true" ]; then + echo "## Eclexiaiser Manifest" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo ":white_check_mark: **eclexiaiser.toml** present and valid." >> "$GITHUB_STEP_SUMMARY" + else + echo "## Eclexiaiser Manifest" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo ":ballot_box_with_check: No eclexiaiser.toml. Add one with \`eclexiaiser init\` for energy/carbon tracking." >> "$GITHUB_STEP_SUMMARY" + fi + + # --------------------------------------------------------------------------- + # Job 6: Dogfooding summary + # --------------------------------------------------------------------------- + dogfood-summary: + name: Dogfooding compliance summary + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: [a2ml-validate, k9-validate, empty-lint, groove-check, eclexiaiser-validate] + if: always() + + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Generate dogfooding scorecard + run: | + SCORE=0 + MAX=6 + + # A2ML manifest present? + if find . -name '*.a2ml' -not -path './.git/*' | head -1 | grep -q .; then + SCORE=$((SCORE + 1)) + A2ML_STATUS=":white_check_mark:" + else + A2ML_STATUS=":x:" + fi + + # K9 contracts present? + if find . \( -name '*.k9' -o -name '*.k9.ncl' \) -not -path './.git/*' | head -1 | grep -q .; then + SCORE=$((SCORE + 1)) + K9_STATUS=":white_check_mark:" + else + K9_STATUS=":x:" + fi + + # .editorconfig present? + if [ -f ".editorconfig" ]; then + SCORE=$((SCORE + 1)) + EC_STATUS=":white_check_mark:" + else + EC_STATUS=":x:" + fi + + # Groove manifest or code? + if [ -f ".well-known/groove/manifest.json" ] || grep -rl 'well-known/groove' --include='*.rs' --include='*.ex' --include='*.zig' . 2>/dev/null | head -1 | grep -q .; then + SCORE=$((SCORE + 1)) + GROOVE_STATUS=":white_check_mark:" + else + GROOVE_STATUS=":ballot_box_with_check:" + fi + + # VeriSimDB integration? + if grep -rl 'verisimdb\|VeriSimDB' --include='*.toml' --include='*.yaml' --include='*.yml' --include='*.json' --include='*.rs' --include='*.ex' . 2>/dev/null | head -1 | grep -q .; then + SCORE=$((SCORE + 1)) + VSDB_STATUS=":white_check_mark:" + else + VSDB_STATUS=":ballot_box_with_check:" + fi + + # eclexiaiser energy tracking? + if [ -f "eclexiaiser.toml" ]; then + SCORE=$((SCORE + 1)) + ECLEX_STATUS=":white_check_mark:" + else + ECLEX_STATUS=":ballot_box_with_check:" + fi + + cat <> "$GITHUB_STEP_SUMMARY" + ## Dogfooding Scorecard + + **Score: ${SCORE}/${MAX}** + + | Tool/Format | Status | Notes | + |-------------|--------|-------| + | A2ML manifest (0-AI-MANIFEST.a2ml) | ${A2ML_STATUS} | Required for all RSR repos | + | K9 contracts | ${K9_STATUS} | Required for repos with config files | + | .editorconfig | ${EC_STATUS} | Required for all repos | + | Groove endpoint | ${GROOVE_STATUS} | Required for service repos | + | VeriSimDB integration | ${VSDB_STATUS} | Required for stateful repos | + | eclexiaiser | ${ECLEX_STATUS} | Energy/carbon budgets for container services | + + --- + *Generated by the [Dogfood Gate](https://github.com/hyperpolymath/rsr-template-repo) workflow.* + *Dogfooding is guinea pig fooding — we test our tools on ourselves.* + EOF diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..8c59aee --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,205 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# RSR Standard E2E + Aspect + Benchmark Workflow Template +# +# Covers ALL merge requirement test categories: +# - E2E (end-to-end pipeline tests) +# - Aspect (cross-cutting concern validation) +# - Benchmarks (performance regression detection) +# - Readiness (Component Readiness Grade: D/C/B) +# +# INSTRUCTIONS: Uncomment and customise the section matching your stack. +# Delete sections that don't apply. See examples in each job. + +name: E2E + Aspect + Bench +on: + push: + branches: [main, master, develop] + paths: + - 'src/**' + - 'src/interface/ffi/**' + - 'tests/**' + - '.github/workflows/e2e.yml' + pull_request: + branches: [main, master] + paths: + - 'src/**' + - 'src/interface/ffi/**' + - 'tests/**' + workflow_dispatch: +permissions: read-all +concurrency: + group: e2e-${{ github.ref }} + cancel-in-progress: true +jobs: + # ─── Default: template smoke (always runs — not a silent no-op) ───── + # Runs the template's own E2E harness (tests/e2e.sh). On the bare template + # this is a clean pass (no stack-specific checks enabled yet); downstream + # repos either extend tests/e2e.sh or uncomment a stack block below. + e2e: + name: E2E — template smoke + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Run E2E harness + run: | + if [ -f tests/e2e.sh ]; then + bash tests/e2e.sh + else + echo "::notice::no tests/e2e.sh present — nothing to run" + fi + +# ─── End-to-End Tests (downstream stack-specific examples) ───────── +# Uncomment ONE of the following e2e job blocks matching your stack. + +## === RUST E2E === +# e2e: +# name: E2E — Full Pipeline +# runs-on: ubuntu-latest +# timeout-minutes: 15 +# steps: +# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 +# - uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable +# - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 +# - run: cargo build --release +# - run: bash tests/e2e.sh +# # OR: cargo test --test end_to_end -- --nocapture + +## === ZIG FFI E2E === +# e2e: +# name: E2E — FFI Pipeline +# runs-on: ubuntu-latest +# timeout-minutes: 15 +# steps: +# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 +# - uses: goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406 # v2.2.1 +# with: +# version: 0.15.1 +# - run: cd ffi/zig && zig build test +# - run: bash tests/e2e.sh + +## === ELIXIR E2E === +# e2e: +# name: E2E — Full Pipeline +# runs-on: ubuntu-latest +# timeout-minutes: 15 +# steps: +# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 +# - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0 +# with: +# otp-version: '27.0' +# elixir-version: '1.17' +# - run: mix deps.get && mix compile --warnings-as-errors +# - run: mix test test/integration/e2e_test.exs --trace + +## === DENO/RESCRIPT E2E === +# e2e: +# name: E2E — Full Pipeline +# runs-on: ubuntu-latest +# timeout-minutes: 15 +# steps: +# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 +# - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 +# with: +# deno-version: v2.x +# - run: deno install --node-modules-dir=auto +# - run: deno task res:build # ReScript compile +# - run: deno test tests/e2e/ + +## === PLAYWRIGHT (Browser E2E) === +# e2e-playwright: +# name: Playwright — ${{ matrix.project }} +# runs-on: ubuntu-latest +# timeout-minutes: 20 +# strategy: +# fail-fast: false +# matrix: +# project: [chromium-1080p, firefox-1080p, webkit-1080p] +# steps: +# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 +# - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 +# with: +# deno-version: v2.x +# - run: deno install --node-modules-dir=auto +# - run: npx playwright install --with-deps +# - run: npx playwright test --project=${{ matrix.project }} +# - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 +# if: failure() +# with: +# name: playwright-traces-${{ matrix.project }} +# path: test-results/**/trace.zip +# retention-days: 7 + +## === HASKELL E2E === +# e2e: +# name: E2E — Full Pipeline +# runs-on: ubuntu-latest +# timeout-minutes: 15 +# steps: +# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 +# - uses: haskell-actions/setup@cd0d9bdd65b20557f41bea4dbe43d0b5fbbfe553 # v2.11.0 +# with: +# ghc-version: '9.6' +# cabal-version: '3.10' +# - run: cabal build all +# - run: bash tests/integration-test.sh + +# ─── Aspect Tests ────────────────────────────────────────────────── +# Cross-cutting concerns: thread safety, ABI contracts, SPDX, dangerous patterns +# Uncomment and customise: + +# aspect-tests: +# name: Aspect — Architectural Invariants +# runs-on: ubuntu-latest +# timeout-minutes: 10 +# steps: +# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 +# - run: bash tests/aspect_tests.sh + +# ─── Benchmarks ──────────────────────────────────────────────────── +# Performance regression detection. Uncomment matching stack: + +## === RUST BENCH === +# benchmarks: +# name: Bench — Performance Regression +# runs-on: ubuntu-latest +# timeout-minutes: 15 +# steps: +# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 +# - uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable +# - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 +# - run: cargo bench 2>&1 | tee /tmp/bench-results.txt +# - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 +# if: always() +# with: +# name: benchmark-results +# path: /tmp/bench-results.txt +# retention-days: 30 + +## === ZIG BENCH === +# benchmarks: +# name: Bench — Performance Regression +# runs-on: ubuntu-latest +# timeout-minutes: 15 +# steps: +# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 +# - uses: goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406 # v2.2.1 +# with: +# version: 0.15.1 +# - run: cd ffi/zig && zig build bench + +# ─── Readiness (CRG) ────────────────────────────────────────────── +# Component Readiness Grade: D (runs) → C (correct) → B (edge cases) + +# readiness: +# name: Readiness — Grade D/C/B +# runs-on: ubuntu-latest +# timeout-minutes: 10 +# steps: +# - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 +# - uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7 # stable +# - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 +# - run: cargo test --test readiness -- --nocapture diff --git a/.github/workflows/estate-rules.yml b/.github/workflows/estate-rules.yml new file mode 100644 index 0000000..b0c4726 --- /dev/null +++ b/.github/workflows/estate-rules.yml @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Estate Rules — enforces hyperpolymath estate-wide conventions: +# * root shape (allowlist of permitted root entries) +# * AsciiDoc-by-default (no .md files under docs/) +# * V-lang is banned (no V-lang scaffolding or references; Zig is the FFI default) +# +# Each rule is enforced by an executable check script under scripts/. Failures +# are surfaced as workflow errors so the rule can't silently regress. + +name: Estate Rules +on: + push: + branches: [main] + pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read +jobs: + estate-rules: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Root shape allowlist + run: bash scripts/check-root-shape.sh . + - name: AsciiDoc by default (no .md under docs/) + run: bash scripts/check-no-md-in-docs.sh . + - name: No V-lang references + run: bash scripts/check-no-vlang.sh . diff --git a/.github/workflows/guix-policy.yml b/.github/workflows/guix-policy.yml new file mode 100644 index 0000000..837c8d2 --- /dev/null +++ b/.github/workflows/guix-policy.yml @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: MPL-2.0 +name: Guix Package Policy +on: + push: + branches: [main, master] + pull_request: + +# Estate guardrail: scope push to default branches so a PR fires once (not +# push+PR), and cancel superseded runs. Safe — read-only PR-triggered check. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Enforce Guix-only package policy + run: | + # Guix is the sole package manager estate-wide. Nix is BANNED. + HAS_GUIX=$(find . -path ./.git -prune -o \( -name "*.scm" -o -name ".guix-channel" -o -name "guix.scm" \) -print 2>/dev/null | head -1) + HAS_NIX=$(find . -path ./.git -prune -o -name "*.nix" -print 2>/dev/null | head -1) + + # Hard-fail on any Nix file — Nix is banned, migrate to Guix. + if [ -n "$HAS_NIX" ]; then + echo "::error::Nix is banned estate-wide (Guix only). Remove flake.nix/*.nix and use guix.scm: $HAS_NIX" + exit 1 + fi + + # Warn on non-reproducible lock files; prefer Guix manifests. + NEW_LOCKS=$(git diff --name-only --diff-filter=A HEAD~1 2>/dev/null | grep -E 'package-lock\.json|yarn\.lock|Gemfile\.lock|Pipfile\.lock|poetry\.lock|cargo\.lock' || true) + if [ -n "$NEW_LOCKS" ]; then + echo "⚠️ Lock files detected. Prefer Guix manifests for reproducibility." + fi + + if [ -n "$HAS_GUIX" ]; then + echo "✅ Guix package management detected" + else + echo "ℹ️ Consider adding guix.scm / .guix-channel for reproducible builds" + fi + + echo "✅ Guix package policy check passed" diff --git a/.github/workflows/instant-sync.yml b/.github/workflows/instant-sync.yml new file mode 100644 index 0000000..b9465f7 --- /dev/null +++ b/.github/workflows/instant-sync.yml @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: MPL-2.0 +# Instant Forge Sync - Triggers propagation to all forges on push/release +name: Instant Sync +on: + push: + branches: [main, master] + release: + types: [published] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false +permissions: + contents: read +jobs: + dispatch: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Gate on secret presence + id: gate + env: + FARM_DISPATCH_TOKEN: ${{ secrets.FARM_DISPATCH_TOKEN }} + run: | + if [ -n "${FARM_DISPATCH_TOKEN}" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::notice::FARM_DISPATCH_TOKEN not set — skipping forge propagation (expected on forks)." + fi + - name: Trigger Propagation + if: steps.gate.outputs.present == 'true' + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v3 + with: + token: ${{ secrets.FARM_DISPATCH_TOKEN }} + repository: hyperpolymath/.git-private-farm + event-type: propagate + client-payload: |- + { + "repo": "${{ github.event.repository.name }}", + "ref": "${{ github.ref }}", + "sha": "${{ github.sha }}", + "forges": "" + } + - name: Confirm + env: + REPO_NAME: ${{ github.event.repository.name }} + run: echo "::notice::Propagation triggered for ${REPO_NAME}" diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml new file mode 100644 index 0000000..9b11b60 --- /dev/null +++ b/.github/workflows/mirror.yml @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: MPL-2.0 +name: Mirror to Git Forges +on: + push: + branches: [main] + workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false +permissions: + contents: read +jobs: + mirror: + uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@09e7023d24682621bea4e11965a1ef5e87d86c3b + timeout-minutes: 10 + secrets: inherit diff --git a/.github/workflows/npm-bun-blocker.yml b/.github/workflows/npm-bun-blocker.yml new file mode 100644 index 0000000..153b39d --- /dev/null +++ b/.github/workflows/npm-bun-blocker.yml @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: MPL-2.0 +name: NPM/Bun Blocker +on: + push: + branches: [main, master] + pull_request: + +# Estate guardrail: scope push to default branches so a PR fires once (not +# push+PR), and cancel superseded runs. Safe — read-only PR-triggered check. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Block npm/bun + run: | + if [ -f "package-lock.json" ] || [ -f "bun.lockb" ] || [ -f ".npmrc" ]; then + echo "❌ npm/bun artifacts detected. Use Deno instead." + exit 1 + fi + echo "✅ No npm/bun violations" diff --git a/.github/workflows/openssf-compliance.yml b/.github/workflows/openssf-compliance.yml new file mode 100644 index 0000000..703c264 --- /dev/null +++ b/.github/workflows/openssf-compliance.yml @@ -0,0 +1,117 @@ +# SPDX-License-Identifier: MPL-2.0 +# OpenSSF Best Practices compliance gate — blocks PRs and pushes that lack +# required files or still contain unfilled placeholder tokens. +name: OpenSSF Compliance +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read +jobs: + openssf-compliance: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - name: Check SECURITY.md exists and has substance + run: | + SECFILE="" + [ -f "SECURITY.md" ] && SECFILE="SECURITY.md" + [ -f "SECURITY.adoc" ] && SECFILE="SECURITY.adoc" + [ -f ".github/SECURITY.md" ] && SECFILE=".github/SECURITY.md" + + if [ -z "$SECFILE" ]; then + echo "::error::SECURITY.md (or SECURITY.adoc) is required for OpenSSF Best Practices" + exit 1 + fi + + LINES=$(wc -l < "$SECFILE") + if [ "$LINES" -lt 10 ]; then + echo "::error::$SECFILE has only $LINES lines — must have >10 lines of substantive content" + exit 1 + fi + echo "SECURITY file: OK ($SECFILE, $LINES lines)" + - name: Check LICENSE exists + run: | + if [ ! -f "LICENSE" ] && [ ! -f "LICENSE.txt" ] && [ ! -f "LICENSE.md" ]; then + echo "::error::LICENSE file is required for OpenSSF Best Practices" + exit 1 + fi + echo "LICENSE: OK" + - name: Check CONTRIBUTING exists + run: | + if [ ! -f "CONTRIBUTING.md" ] && [ ! -f "CONTRIBUTING.adoc" ] \ + && [ ! -f ".github/CONTRIBUTING.md" ] && [ ! -f ".github/CONTRIBUTING.adoc" ]; then + echo "::error::CONTRIBUTING file is required for OpenSSF Best Practices" + exit 1 + fi + echo "CONTRIBUTING: OK" + - name: Check README exists + run: | + if [ ! -f "README.md" ] && [ ! -f "README.adoc" ] && [ ! -f "README.rst" ] && [ ! -f "README.txt" ] && [ ! -f "README" ]; then + echo "::error::README file is required for OpenSSF Best Practices" + exit 1 + fi + echo "README: OK" + - name: Check .machine_readable directory and STATE.a2ml + run: | + if [ ! -d ".machine_readable" ]; then + echo "::error::.machine_readable/ directory is required" + exit 1 + fi + + if [ ! -f ".machine_readable/descriptiles/STATE.a2ml" ]; then + echo "::error::.machine_readable/descriptiles/STATE.a2ml is required" + exit 1 + fi + echo ".machine_readable/descriptiles/STATE.a2ml: OK" + - name: Check CHANGELOG exists + run: | + if [ ! -f "CHANGELOG.md" ] && [ ! -f "CHANGELOG.adoc" ] && [ ! -f "CHANGES.md" ]; then + echo "::error::CHANGELOG.md is required for OpenSSF Best Practices" + exit 1 + fi + echo "CHANGELOG: OK" + - name: Check no unfilled placeholder tokens in required files + run: | + ERRORS=0 + REQUIRED_FILES="" + + # Collect all required files that exist + for f in SECURITY.md SECURITY.adoc .github/SECURITY.md LICENSE LICENSE.txt \ + CONTRIBUTING.md CONTRIBUTING.adoc README.md README.adoc \ + .machine_readable/descriptiles/STATE.a2ml .machine_readable/descriptiles/META.a2ml \ + .machine_readable/descriptiles/ECOSYSTEM.a2ml CHANGELOG.md CHANGELOG.adoc; do + [ -f "$f" ] && REQUIRED_FILES="$REQUIRED_FILES $f" + done + + for f in $REQUIRED_FILES; do + # Match {{ANYTHING}} placeholder tokens + PLACEHOLDERS=$(grep -cE '\{\{[A-Z_]+\}\}' "$f" 2>/dev/null || true) + if [ "$PLACEHOLDERS" -gt 0 ]; then + echo "::error::$f contains $PLACEHOLDERS unfilled {{PLACEHOLDER}} tokens" + grep -nE '\{\{[A-Z_]+\}\}' "$f" | head -5 + ERRORS=$((ERRORS + 1)) + fi + done + + if [ "$ERRORS" -gt 0 ]; then + echo "" + echo "::error::$ERRORS file(s) still contain placeholder tokens — run 'just init' to fill them" + exit 1 + fi + echo "Placeholder check: OK (no unfilled tokens in required files)" + - name: Summary + run: | + echo "=== OpenSSF Best Practices Compliance: PASS ===" + echo "All required files present and placeholder-free." diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..3d90bda --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,90 @@ +# SPDX-License-Identifier: MPL-2.0 +# GitHub Pages via casket-ssg (hyperpolymath's pure-Haskell static site generator). +# Replaces the orphan one-off Pages deployment with a reproducible build. +name: GitHub Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Serialise Pages deploys; never cancel an in-flight deploy. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + timeout-minutes: 20 + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Checkout casket-ssg + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: hyperpolymath/casket-ssg + path: .casket-ssg + + - name: Setup GHCup + uses: haskell-actions/setup@cd0d9bdd65b20557f41bea4dbe43d0b5fbbfe553 # v2.11.0 + with: + ghc-version: '9.8.2' + cabal-version: '3.10' + + - name: Cache Cabal + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4 + with: + path: | + ~/.cabal/packages + ~/.cabal/store + .casket-ssg/dist-newstyle + key: ${{ runner.os }}-casket-${{ hashFiles('.casket-ssg/casket-ssg.cabal') }} + + - name: Build casket-ssg + working-directory: .casket-ssg + run: cabal build + + - name: Build site + run: | + mkdir -p site _site + # Seed site/index.md from README if the author hasn't provided one. + if [ ! -f site/index.md ]; then + { + echo "---" + echo "title: $(basename "$PWD")" + echo "date: $(date +%Y-%m-%d)" + echo "---" + if [ -f README.adoc ]; then cat README.adoc + elif [ -f README.md ]; then cat README.md + else printf '\n# %s\n\nDocumentation coming soon.\n' "$(basename "$PWD")" + fi + } > site/index.md + fi + cd .casket-ssg && cabal run casket-ssg -- build ../site ../_site + + - name: Setup Pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + + - name: Upload artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v3 + with: + path: '_site' + + deploy: + timeout-minutes: 20 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..89e67f6 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: MPL-2.0 +name: Code Quality +on: + push: + branches: [main, master] + pull_request: + +# Estate guardrail: scope push to default branches so a PR fires once (not +# push+PR), and cancel superseded runs. Safe — read-only PR-triggered check. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + +permissions: + contents: read +jobs: + lint: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Check file permissions + run: | + find . -type f -perm /111 -name "*.sh" | head -10 || true + - name: Check for secrets + uses: trufflesecurity/trufflehog@6c05c4a00b91aa542267d8e32a8254774799d68d # v3.93.3 + with: + path: ./ + base: ${{ github.event.pull_request.base.sha || github.event.before }} + head: ${{ github.sha }} + continue-on-error: true + - name: Check TODO/FIXME + run: | + echo "=== TODOs ===" + grep -rn "TODO\|FIXME\|HACK\|XXX" --include="*.rs" --include="*.res" --include="*.py" --include="*.ex" . | head -20 || echo "None found" + - name: Check for large files + run: | + find . -type f -size +1M -not -path "./.git/*" | head -10 || echo "No large files" + - name: EditorConfig check + uses: editorconfig-checker/action-editorconfig-checker@4b6cd6190d435e7e084fb35e36a096e98506f7b9 # v2.1.0 + continue-on-error: true + docs: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Check documentation + run: | + MISSING="" + [ ! -f "README.md" ] && [ ! -f "README.adoc" ] && MISSING="$MISSING README" + [ ! -f "LICENSE" ] && [ ! -f "LICENSE.txt" ] && [ ! -f "LICENSE.md" ] && MISSING="$MISSING LICENSE" + [ ! -f "CONTRIBUTING.md" ] && [ ! -f "CONTRIBUTING.adoc" ] && [ ! -f ".github/CONTRIBUTING.md" ] && [ ! -f ".github/CONTRIBUTING.adoc" ] && MISSING="$MISSING CONTRIBUTING" + + if [ -n "$MISSING" ]; then + echo "::warning::Missing docs:$MISSING" + else + echo "✅ Core documentation present" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..641a4b9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,158 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Release workflow — triggered by version tags (v*). +# Builds artifacts, generates changelog via git-cliff, creates a GitHub Release, +# and produces GitHub native build-provenance attestations (OIDC + Sigstore). +name: Release +on: + push: + tags: + - 'v*' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false +permissions: + contents: read +jobs: + build: + name: Build Artifacts + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Detect project type and build + id: build + run: | + # Auto-detect build system from project files. + # Order matters: more specific markers checked first. + if [ -f "mix.exs" ]; then + echo "::notice::Detected Elixir/Gleam project (mix.exs)" + echo "build_type=mix" >> "$GITHUB_OUTPUT" + mix local.hex --force --if-missing + mix local.rebar --force --if-missing + mix deps.get --only prod + MIX_ENV=prod mix release + elif [ -f "Cargo.toml" ]; then + echo "::notice::Detected Rust project (Cargo.toml)" + echo "build_type=cargo" >> "$GITHUB_OUTPUT" + cargo build --release + elif [ -f "build.zig" ]; then + echo "::notice::Detected Zig project (build.zig)" + echo "build_type=zig" >> "$GITHUB_OUTPUT" + zig build -Doptimize=ReleaseSafe + elif [ -f "deno.json" ] || [ -f "deno.jsonc" ]; then + echo "::notice::Detected Deno project (deno.json)" + echo "build_type=deno" >> "$GITHUB_OUTPUT" + deno task build + elif [ -f "gossamer.conf.json" ]; then + echo "::notice::Detected Gossamer project (gossamer.conf.json)" + echo "build_type=gossamer" >> "$GITHUB_OUTPUT" + gossamer build + elif [ -f "gleam.toml" ]; then + echo "::notice::Detected Gleam project (gleam.toml)" + echo "build_type=gleam" >> "$GITHUB_OUTPUT" + gleam build + elif [ -f "rebar.config" ]; then + echo "::notice::Detected Erlang/Rebar project (rebar.config)" + echo "build_type=rebar" >> "$GITHUB_OUTPUT" + rebar3 as prod release + elif [ -f "Justfile" ] || [ -f "justfile" ]; then + echo "::notice::Detected Justfile — running 'just build'" + echo "build_type=just" >> "$GITHUB_OUTPUT" + just build + else + echo "::error::No recognised build system found." + echo "Expected one of: mix.exs, Cargo.toml, build.zig, deno.json, gossamer.conf.json, gleam.toml, rebar.config, Justfile" + exit 1 + fi + # TODO: Upload build artifacts if needed (pin actions/upload-artifact + # to a full commit SHA when enabling, per the SHA-pin policy): + # - uses: actions/upload-artifact@ # vX.Y.Z + # with: + # name: release-artifacts + # path: target/release/ + changelog: + name: Generate Changelog + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + outputs: + changelog: ${{ steps.cliff.outputs.content }} + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + - name: Extract version from tag + id: version + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + - name: Install git-cliff + run: | + curl -sSfL https://github.com/orhun/git-cliff/releases/latest/download/git-cliff-$(uname -m)-unknown-linux-gnu.tar.gz \ + | tar -xz --strip-components=1 -C /usr/local/bin/ git-cliff-*/git-cliff + - name: Generate changelog for this release + id: cliff + run: | + # Generate changelog for the current tag only + CHANGELOG=$(git cliff --latest --strip header) + # Write to output using delimiter to handle multiline + { + echo "content<> "$GITHUB_OUTPUT" + - name: Update full CHANGELOG.md + run: | + git cliff --output CHANGELOG.md + - name: Upload updated CHANGELOG.md + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: changelog + path: CHANGELOG.md + retention-days: 5 + release: + name: Create GitHub Release + needs: [build, changelog] + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + id-token: write # mint the OIDC token attestation provenance is signed with + attestations: write # write the build-provenance attestation (the "claim") + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # TODO: Download build artifacts if uploading to the release (pin + # actions/download-artifact to a full commit SHA when enabling): + # - uses: actions/download-artifact@ # vX.Y.Z + # with: + # name: release-artifacts + # path: artifacts/ + - name: Create GitHub Release + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 + with: + body: ${{ needs.changelog.outputs.changelog }} + draft: false + prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }} + generate_release_notes: false + # TODO: Add artifact files to the release + # files: | + # artifacts/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # GitHub native artifact attestation (build provenance). Generates a + # signed, verifiable claim binding each released artifact to this build + # (commit, workflow, runner) via OIDC + Sigstore — verify with + # `gh attest verify --repo ${{ github.repository }}`. + # Replaces the older SLSA-generator job; native attestations need no + # separate isolated workflow. + # TODO: point subject-path at the artifacts this release actually ships + # (must match the `files:` uploaded above, e.g. artifacts/*). + - name: Attest build provenance + if: ${{ hashFiles('artifacts/*') != '' }} # skip until real artifacts are wired + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 + with: + subject-path: 'artifacts/*' diff --git a/.github/workflows/rhodibot.yml b/.github/workflows/rhodibot.yml new file mode 100644 index 0000000..8b114f2 --- /dev/null +++ b/.github/workflows/rhodibot.yml @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: MPL-2.0 +# rhodibot.yml — RSR compliance CANARY (report-only) +# +# Rhodibot does NOT mutate this repository. It never deletes, renames, +# rewrites SPDX headers, creates files, or opens PRs. Instead it DETECTS +# what an auto-fixer would have changed and reports it. +# +# Design intent (owner): if rhodibot "feels the desire to edit" — i.e. it +# detects something it considers non-compliant — that is itself a MAJOR +# WARNING. Either the repo has drifted, OR rhodibot's own rules have +# diverged from the normative style it is meant to enforce. Both warrant +# a human look, so the canary FAILS the run when it finds would-mutate +# drift. Dangerous-pattern hits are advisory warnings only. +# +# Licence note: SPDX/licence drift is reported for MANUAL, owner-only +# correction. Rhodibot must never edit a licence header (estate directive). + +name: "\U0001F916 Rhodibot — RSR Compliance Canary" +on: + schedule: + - cron: '0 6 * * 1' # Every Monday at 06:00 UTC + workflow_dispatch: # Manual trigger + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read +jobs: + canary: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 1 + - name: Rhodibot — detect drift (no mutations) + run: | + set -uo pipefail + DRIFT=0 + warn() { echo "::warning title=Rhodibot canary::$*"; DRIFT=$((DRIFT+1)); } + note() { echo "::warning title=Rhodibot advisory::$*"; } + + echo "## 🤖 Rhodibot canary — report only (no edits made)" >> "$GITHUB_STEP_SUMMARY" + + # --- would-DELETE: banned files --- + for f in AI.djot NEXT_STEPS.md TODO.md NOTES.md TASKS.md; do + [ -f "$f" ] && warn "banned file present: $f (an auto-fixer would delete it)" + done + # would-DELETE: stale snapshots + for f in *-STATUS-*.md *-COMPLETION-*.md *-COMPLETE.md *-VERIFIED-*.md; do + [ -f "$f" ] && warn "stale snapshot present: $f (would be deleted)" + done + # would-RENAME: legacy manifest name + if [ -f "AI.a2ml" ] && [ ! -f "0-AI-MANIFEST.a2ml" ]; then + warn "AI.a2ml present without 0-AI-MANIFEST.a2ml (would be renamed)" + fi + # would-DELETE: duplicate community files + [ -f "CONTRIBUTING.md" ] && [ -f "CONTRIBUTING.adoc" ] && warn "duplicate CONTRIBUTING.md + CONTRIBUTING.adoc (one would be removed)" + if [ -f "README.md" ] && [ -f "README.adoc" ] && [ "$(wc -l < README.md)" -lt 5 ]; then + warn "stub README.md alongside README.adoc (would be removed)" + fi + # SPDX drift — MANUAL owner-only fix, never auto-edited + for dotfile in .gitignore .gitattributes .editorconfig; do + if [ -f "$dotfile" ] && grep -q "AGPL-3.0" "$dotfile" 2>/dev/null; then + warn "$dotfile carries an AGPL-3.0 SPDX header; estate policy is MPL-2.0 — fix MANUALLY (owner-only, never auto-edited)" + fi + done + # would-CREATE: missing required files + [ -f "SECURITY.md" ] || [ -f ".github/SECURITY.md" ] || warn "no SECURITY.md (would be created)" + [ -f "CONTRIBUTING.md" ] || [ -f ".github/CONTRIBUTING.md" ] || warn "no CONTRIBUTING.md (would be created)" + + # --- unfixable compliance gaps (also drift) --- + [ -f "0-AI-MANIFEST.a2ml" ] || [ -f "AI.a2ml" ] || warn "missing AI manifest (0-AI-MANIFEST.a2ml)" + [ -f "LICENSE" ] || [ -f "LICENSE.md" ] || [ -f "LICENSE.txt" ] || warn "missing LICENSE file" + [ -f "README.adoc" ] || [ -f "README.md" ] || warn "missing README" + + # --- advisory only: dangerous verification-bypass patterns --- + for pattern in believe_me assert_total Admitted sorry unsafeCoerce Obj.magic; do + count=$(grep -rl "$pattern" --include='*.idr' --include='*.v' --include='*.lean' --include='*.hs' --include='*.ml' --include='*.res' . 2>/dev/null | grep -v node_modules | wc -l || echo 0) + [ "$count" -gt 0 ] && note "verification-bypass pattern '$pattern' in $count file(s) (advisory)" + done + + echo "" >> "$GITHUB_STEP_SUMMARY" + if [ "$DRIFT" -gt 0 ]; then + echo "🔴 **Canary tripped: $DRIFT would-mutate finding(s).** Either the repo drifted or rhodibot's rules diverged from the norm — investigate (no edits were made)." >> "$GITHUB_STEP_SUMMARY" + echo "::error title=Rhodibot canary::$DRIFT would-mutate finding(s) detected — rhodibot wants to edit. Investigate; nothing was changed." + exit 1 + fi + echo "✅ Canary clean — rhodibot has no desire to edit. Repository matches the norm." >> "$GITHUB_STEP_SUMMARY" + echo "✅ Rhodibot canary clean — no drift, no mutations." diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml new file mode 100644 index 0000000..c675d0f --- /dev/null +++ b/.github/workflows/rust-ci.yml @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: MPL-2.0 +# Rust CI — thin wrapper calling the shared estate reusable in +# hyperpolymath/standards. Configure once, propagate everywhere. +# See: docs/CI-REUSABLE-WORKFLOWS.adoc in standards. +name: Rust CI +on: + push: + branches: [main, master] + pull_request: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read +jobs: + rust-ci: + uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@09e7023d24682621bea4e11965a1ef5e87d86c3b diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index d713d06..ea9b892 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -15,5 +15,13 @@ permissions: jobs: scan: + # The reusable's gitleaks job requests pull-requests: write (PR + # summary comments) and actions: read. A called workflow cannot + # exceed the caller's grant - without these, every run dies at + # startup_failure. See standards#472. + permissions: + contents: read + pull-requests: write + actions: read uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@d135b05bfc647d0c0fbfedc7e80f37ea50f49236 - secrets: inherit + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/security-policy.yml b/.github/workflows/security-policy.yml new file mode 100644 index 0000000..deafd84 --- /dev/null +++ b/.github/workflows/security-policy.yml @@ -0,0 +1,53 @@ +# SPDX-License-Identifier: MPL-2.0 +name: Security Policy +on: + push: + branches: [main, master] + pull_request: + +# Estate guardrail: scope push to default branches so a PR fires once (not +# push+PR), and cancel superseded runs. Safe — read-only PR-triggered check. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Security checks + run: | + FAILED=false + + # Block MD5/SHA1 for security (allow for checksums/caching) + WEAK_CRYPTO=$(grep -rE 'md5\(|sha1\(' --include="*.py" --include="*.rb" --include="*.js" --include="*.ts" --include="*.go" --include="*.rs" . 2>/dev/null | grep -v 'checksum\|cache\|test\|spec' | head -5 || true) + if [ -n "$WEAK_CRYPTO" ]; then + echo "⚠️ Weak crypto (MD5/SHA1) detected. Use SHA256+ for security:" + echo "$WEAK_CRYPTO" + fi + + # Block HTTP URLs (except localhost) + HTTP_URLS=$(grep -rE 'http://[^l][^o][^c]' --include="*.py" --include="*.js" --include="*.ts" --include="*.go" --include="*.rs" --include="*.yaml" --include="*.yml" . 2>/dev/null | grep -v 'localhost\|127.0.0.1\|example\|test\|spec' | head -5 || true) + if [ -n "$HTTP_URLS" ]; then + echo "⚠️ HTTP URLs found. Use HTTPS:" + echo "$HTTP_URLS" + fi + + # Block hardcoded secrets patterns + SECRETS=$(grep -rEi '(api_key|apikey|secret_key|password)\s*[=:]\s*["\x27][A-Za-z0-9+/=]{20,}' --include="*.py" --include="*.js" --include="*.ts" --include="*.go" --include="*.rs" --include="*.env" . 2>/dev/null | grep -v 'example\|sample\|test\|mock\|placeholder' | head -3 || true) + if [ -n "$SECRETS" ]; then + echo "❌ Potential hardcoded secrets detected!" + FAILED=true + fi + + if [ "$FAILED" = true ]; then + exit 1 + fi + + echo "✅ Security policy check passed" diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml new file mode 100644 index 0000000..c27d4ca --- /dev/null +++ b/.github/workflows/sonarqube.yml @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: MPL-2.0 +# SonarQube Cloud (SonarCloud) static analysis. Analysis scope + exclusions live +# in sonar-project.properties. Requires the SONAR_TOKEN repository secret +# (Settings -> Secrets and variables -> Actions) and a SonarCloud project: +# https://sonarcloud.io/project/overview?id=hyperpolymath_rsr-template-repo +# Mirrors the boj-server arrangement. +name: SonarQube +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read +jobs: + sonarqube: + name: SonarQube + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 # full history for accurate new-code detection + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@7006c4492b2e0ee0f816d36501671557c97f5995 # v8.1.0 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/static-analysis-gate.yml b/.github/workflows/static-analysis-gate.yml new file mode 100644 index 0000000..3b6ecc0 --- /dev/null +++ b/.github/workflows/static-analysis-gate.yml @@ -0,0 +1,411 @@ +# SPDX-License-Identifier: MPL-2.0 +# Static Analysis Gate — Required by branch protection rules. +# Runs panic-attack and hypatia, deposits findings for gitbot-fleet learning. +name: Static Analysis Gate +on: + pull_request: + branches: ['**'] + push: + branches: [main, master] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read +jobs: + # --------------------------------------------------------------------------- + # Job 1: panic-attack assail + # --------------------------------------------------------------------------- + panic-attack-assail: + name: panic-attack assail + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + - name: Install panic-attack (if available) + id: install + run: | + # Try to fetch the latest release binary from the org + PA_URL="https://github.com/hyperpolymath/panic-attack/releases/latest/download/panic-attack-linux-x86_64" + if curl -fsSL --head "$PA_URL" >/dev/null 2>&1; then + curl -fsSL -o /usr/local/bin/panic-attack "$PA_URL" + chmod +x /usr/local/bin/panic-attack + echo "installed=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::panic-attack binary not available — skipping assail" + echo "installed=false" >> "$GITHUB_OUTPUT" + fi + - name: Run panic-attack assail + id: assail + if: steps.install.outputs.installed == 'true' + run: | + set +e + panic-attack assail --format json . > panic-attack-findings.json 2>&1 + PA_EXIT=$? + set -e + + if [ ! -s panic-attack-findings.json ]; then + echo "[]" > panic-attack-findings.json + fi + + # Parse finding counts + TOTAL=$(jq '. | length' panic-attack-findings.json 2>/dev/null || echo 0) + CRITICAL=$(jq '[.[] | select(.severity == "critical")] | length' panic-attack-findings.json 2>/dev/null || echo 0) + HIGH=$(jq '[.[] | select(.severity == "high")] | length' panic-attack-findings.json 2>/dev/null || echo 0) + MEDIUM=$(jq '[.[] | select(.severity == "medium")] | length' panic-attack-findings.json 2>/dev/null || echo 0) + LOW=$(jq '[.[] | select(.severity == "low")] | length' panic-attack-findings.json 2>/dev/null || echo 0) + + echo "total=$TOTAL" >> "$GITHUB_OUTPUT" + echo "critical=$CRITICAL" >> "$GITHUB_OUTPUT" + echo "high=$HIGH" >> "$GITHUB_OUTPUT" + echo "medium=$MEDIUM" >> "$GITHUB_OUTPUT" + echo "low=$LOW" >> "$GITHUB_OUTPUT" + echo "exit_code=$PA_EXIT" >> "$GITHUB_OUTPUT" + - name: Emit check annotations + if: steps.install.outputs.installed == 'true' + run: | + # Convert JSON findings into GitHub Actions annotations + jq -r '.[] | select(.file != null) | + if .severity == "critical" then + "::error file=\(.file),line=\(.line // 1)::[panic-attack] \(.message)" + elif .severity == "high" then + "::error file=\(.file),line=\(.line // 1)::[panic-attack] \(.message)" + else + "::warning file=\(.file),line=\(.line // 1)::[panic-attack] \(.message)" + end + ' panic-attack-findings.json || true + - name: Write step summary + if: steps.install.outputs.installed == 'true' + run: | + cat <> "$GITHUB_STEP_SUMMARY" + ## panic-attack assail Results + + | Severity | Count | + |----------|-------| + | Critical | ${{ steps.assail.outputs.critical }} | + | High | ${{ steps.assail.outputs.high }} | + | Medium | ${{ steps.assail.outputs.medium }} | + | Low | ${{ steps.assail.outputs.low }} | + | **Total**| ${{ steps.assail.outputs.total }} | + EOF + - name: Create stub findings (when panic-attack unavailable) + if: steps.install.outputs.installed != 'true' + run: | + echo "[]" > panic-attack-findings.json + echo "## panic-attack assail" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Skipped: panic-attack not available in this environment." >> "$GITHUB_STEP_SUMMARY" + - name: Upload panic-attack findings + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: panic-attack-findings + path: panic-attack-findings.json + retention-days: 90 + - name: Fail on critical findings + if: steps.install.outputs.installed == 'true' && steps.assail.outputs.critical > 0 + run: | + echo "::error::panic-attack found ${{ steps.assail.outputs.critical }} critical issue(s) — blocking merge" + exit 1 + # --------------------------------------------------------------------------- + # Job 2: hypatia-scan + # + # NOTE — this is NOT a duplicate of the standalone `hypatia-scan.yml`. + # * THIS job runs Hypatia inside the gate and hands its findings to the + # `deposit-findings` job below, which feeds the gitbot-fleet LEARNING + # pipeline (artifact -> fleet). It is a REQUIRED status check. + # * `hypatia-scan.yml` runs Hypatia standalone as the repo's security scan, + # independent of the fleet-learning deposit. + # Same tool, two different consumers — keep both. See .github/workflows/README.adoc. + # --------------------------------------------------------------------------- + hypatia-scan: + name: Hypatia neurosymbolic scan + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + - name: Setup Elixir for Hypatia scanner + id: beam + continue-on-error: true + uses: erlef/setup-beam@e6d7c94229049569db56a7ad5a540c051a010af9 # v1.18.2 + with: + elixir-version: '1.19.4' + otp-version: '28.3' + - name: Clone and build Hypatia + id: build + continue-on-error: true + run: | + git clone https://github.com/hyperpolymath/hypatia.git "$HOME/hypatia" 2>/dev/null || true + if [ -f "$HOME/hypatia/mix.exs" ]; then + cd "$HOME/hypatia" + # Build escript if neither hypatia nor hypatia-v2 exists + if [ ! -f hypatia ] && [ ! -f hypatia-v2 ]; then + mix deps.get + mix escript.build + fi + echo "ready=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::Hypatia scanner not available — skipping scan" + echo "ready=false" >> "$GITHUB_OUTPUT" + fi + - name: Run Hypatia scan + id: scan + if: steps.build.outputs.ready == 'true' + run: | + set +e + HYPATIA_FORMAT=json "$HOME/hypatia/hypatia-cli.sh" scan . > hypatia-findings.json 2>&1 + HYP_EXIT=$? + set -e + + if [ ! -s hypatia-findings.json ] || ! jq empty hypatia-findings.json 2>/dev/null; then + echo "[]" > hypatia-findings.json + fi + + TOTAL=$(jq '. | length' hypatia-findings.json 2>/dev/null || echo 0) + CRITICAL=$(jq '[.[] | select(.severity == "critical")] | length' hypatia-findings.json 2>/dev/null || echo 0) + HIGH=$(jq '[.[] | select(.severity == "high")] | length' hypatia-findings.json 2>/dev/null || echo 0) + MEDIUM=$(jq '[.[] | select(.severity == "medium")] | length' hypatia-findings.json 2>/dev/null || echo 0) + LOW=$(jq '[.[] | select(.severity == "low")] | length' hypatia-findings.json 2>/dev/null || echo 0) + + echo "total=$TOTAL" >> "$GITHUB_OUTPUT" + echo "critical=$CRITICAL" >> "$GITHUB_OUTPUT" + echo "high=$HIGH" >> "$GITHUB_OUTPUT" + echo "medium=$MEDIUM" >> "$GITHUB_OUTPUT" + echo "low=$LOW" >> "$GITHUB_OUTPUT" + - name: Emit check annotations + if: steps.build.outputs.ready == 'true' + run: | + jq -r '.[] | select(.file != null) | + if .severity == "critical" then + "::error file=\(.file),line=\(.line // 1)::[hypatia] \(.message)" + elif .severity == "high" then + "::error file=\(.file),line=\(.line // 1)::[hypatia] \(.message)" + else + "::warning file=\(.file),line=\(.line // 1)::[hypatia] \(.message)" + end + ' hypatia-findings.json || true + - name: Write step summary + if: steps.build.outputs.ready == 'true' + run: | + cat <> "$GITHUB_STEP_SUMMARY" + ## Hypatia Scan Results + + | Severity | Count | + |----------|-------| + | Critical | ${{ steps.scan.outputs.critical }} | + | High | ${{ steps.scan.outputs.high }} | + | Medium | ${{ steps.scan.outputs.medium }} | + | Low | ${{ steps.scan.outputs.low }} | + | **Total**| ${{ steps.scan.outputs.total }} | + EOF + - name: Create stub findings (when Hypatia unavailable) + if: steps.build.outputs.ready != 'true' + run: | + echo "[]" > hypatia-findings.json + echo "## Hypatia Scan" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Skipped: Hypatia scanner not available in this environment." >> "$GITHUB_STEP_SUMMARY" + - name: Upload hypatia findings + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: hypatia-findings + path: hypatia-findings.json + retention-days: 90 + - name: Fail on critical security findings + if: steps.build.outputs.ready == 'true' && steps.scan.outputs.critical > 0 + run: | + echo "::error::Hypatia found ${{ steps.scan.outputs.critical }} critical security issue(s) — blocking merge" + exit 1 + # --------------------------------------------------------------------------- + # Job 3: patch-bridge triage (CVE contextual assessment) + # --------------------------------------------------------------------------- + patch-bridge-triage: + name: Patch Bridge CVE triage + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + - name: Install panic-attack (if available) + id: install + run: | + PA_URL="https://github.com/hyperpolymath/panic-attack/releases/latest/download/panic-attack-linux-x86_64" + if curl -fsSL --head "$PA_URL" >/dev/null 2>&1; then + curl -fsSL -o /usr/local/bin/panic-attack "$PA_URL" + chmod +x /usr/local/bin/panic-attack + echo "installed=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::panic-attack binary not available — skipping Patch Bridge" + echo "installed=false" >> "$GITHUB_OUTPUT" + fi + - name: Run Patch Bridge triage + id: triage + if: steps.install.outputs.installed == 'true' + run: | + set +e + panic-attack bridge triage --format json . > bridge-report.json 2>&1 + PB_EXIT=$? + set -e + + if [ ! -s bridge-report.json ] || ! jq empty bridge-report.json 2>/dev/null; then + echo '{"cves":[],"mitigated":0,"unmitigable":0,"concatenative":0,"informational":0}' > bridge-report.json + fi + + UNMITIGABLE=$(jq '.unmitigable // 0' bridge-report.json) + MITIGATED=$(jq '.mitigated // 0' bridge-report.json) + CONCATENATIVE=$(jq '.concatenative // 0' bridge-report.json) + INFORMATIONAL=$(jq '.informational // 0' bridge-report.json) + + echo "unmitigable=$UNMITIGABLE" >> "$GITHUB_OUTPUT" + echo "mitigated=$MITIGATED" >> "$GITHUB_OUTPUT" + echo "concatenative=$CONCATENATIVE" >> "$GITHUB_OUTPUT" + echo "informational=$INFORMATIONAL" >> "$GITHUB_OUTPUT" + - name: Write step summary + if: steps.install.outputs.installed == 'true' + run: | + cat <> "$GITHUB_STEP_SUMMARY" + ## Patch Bridge CVE Triage + + | Classification | Count | + |----------------|-------| + | Unmitigable | ${{ steps.triage.outputs.unmitigable }} | + | Mitigated | ${{ steps.triage.outputs.mitigated }} | + | Concatenative | ${{ steps.triage.outputs.concatenative }} | + | Informational | ${{ steps.triage.outputs.informational }} | + + Unmitigable CVEs require dependency replacement or rearchitecture. + Mitigated CVEs have active controls with soundness proofs. + Concatenative risks are CVE combinations that multiply severity. + EOF + - name: Create stub report (when unavailable) + if: steps.install.outputs.installed != 'true' + run: | + echo '{"cves":[],"mitigated":0,"unmitigable":0,"concatenative":0,"informational":0}' > bridge-report.json + echo "## Patch Bridge CVE Triage" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Skipped: panic-attack not available in this environment." >> "$GITHUB_STEP_SUMMARY" + - name: Upload bridge report + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: bridge-report + path: bridge-report.json + retention-days: 90 + - name: Fail on unmitigable CVEs in critical paths + if: steps.install.outputs.installed == 'true' && steps.triage.outputs.unmitigable > 0 + run: | + echo "::warning::Patch Bridge found ${{ steps.triage.outputs.unmitigable }} unmitigable CVE(s) — review required" + # Warning only, not blocking. Unmitigable means the developer needs + # to make an architectural decision, not that the PR is wrong. + # --------------------------------------------------------------------------- + # Job 4: deposit-findings (combines + archives for gitbot-fleet) + # --------------------------------------------------------------------------- + deposit-findings: + name: Deposit findings for gitbot-fleet + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: [panic-attack-assail, hypatia-scan, patch-bridge-triage] + if: always() + steps: + - name: Download panic-attack findings + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4 + with: + name: panic-attack-findings + path: findings/ + - name: Download hypatia findings + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4 + with: + name: hypatia-findings + path: findings/ + - name: Download bridge report + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4 + with: + name: bridge-report + path: findings/ + - name: Combine findings into unified report + id: combine + run: | + PA_FILE="findings/panic-attack-findings.json" + HYP_FILE="findings/hypatia-findings.json" + + # Ensure both files exist and are valid JSON arrays + for f in "$PA_FILE" "$HYP_FILE"; do + if [ ! -s "$f" ] || ! jq empty "$f" 2>/dev/null; then + echo "[]" > "$f" + fi + done + + # Tag each finding with its source scanner + jq '[.[] | . + {"scanner": "panic-attack"}]' "$PA_FILE" > /tmp/pa-tagged.json + jq '[.[] | . + {"scanner": "hypatia"}]' "$HYP_FILE" > /tmp/hyp-tagged.json + + # Read bridge report (CVE triage, not findings array) + BRIDGE_FILE="findings/bridge-report.json" + if [ ! -s "$BRIDGE_FILE" ] || ! jq empty "$BRIDGE_FILE" 2>/dev/null; then + echo '{"cves":[],"mitigated":0,"unmitigable":0,"concatenative":0,"informational":0}' > "$BRIDGE_FILE" + fi + + # Build unified report envelope + jq -n \ + --arg repo "${{ github.repository }}" \ + --arg sha "${{ github.sha }}" \ + --arg ref "${{ github.ref }}" \ + --arg run_id "${{ github.run_id }}" \ + --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --slurpfile pa /tmp/pa-tagged.json \ + --slurpfile hyp /tmp/hyp-tagged.json \ + --slurpfile bridge "$BRIDGE_FILE" \ + '{ + schema_version: "1.1.0", + repository: $repo, + commit_sha: $sha, + ref: $ref, + run_id: $run_id, + timestamp: $ts, + findings: ($pa[0] + $hyp[0]), + patch_bridge: $bridge[0] + }' > findings/unified-findings.json + + TOTAL=$(jq '.findings | length' findings/unified-findings.json) + CRITICAL=$(jq '[.findings[] | select(.severity == "critical")] | length' findings/unified-findings.json) + HIGH=$(jq '[.findings[] | select(.severity == "high")] | length' findings/unified-findings.json) + MEDIUM=$(jq '[.findings[] | select(.severity == "medium")] | length' findings/unified-findings.json) + LOW=$(jq '[.findings[] | select(.severity == "low")] | length' findings/unified-findings.json) + + echo "total=$TOTAL" >> "$GITHUB_OUTPUT" + echo "critical=$CRITICAL" >> "$GITHUB_OUTPUT" + echo "high=$HIGH" >> "$GITHUB_OUTPUT" + echo "medium=$MEDIUM" >> "$GITHUB_OUTPUT" + echo "low=$LOW" >> "$GITHUB_OUTPUT" + - name: Upload unified findings (fleet scanner picks these up) + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: unified-findings + path: findings/unified-findings.json + retention-days: 90 + - name: Write deposit summary + run: | + cat <> "$GITHUB_STEP_SUMMARY" + ## Unified Findings Deposit + + **Repository:** ${{ github.repository }} + **Commit:** \`${{ github.sha }}\` + **Deposited at:** $(date -u +"%Y-%m-%d %H:%M:%S UTC") + + | Severity | Count | + |----------|-------| + | Critical | ${{ steps.combine.outputs.critical }} | + | High | ${{ steps.combine.outputs.high }} | + | Medium | ${{ steps.combine.outputs.medium }} | + | Low | ${{ steps.combine.outputs.low }} | + | **Total**| ${{ steps.combine.outputs.total }} | + + Findings saved as \`unified-findings\` artifact. + The gitbot-fleet scanner will ingest these on its next pass. + EOF diff --git a/.github/workflows/ts-blocker.yml b/.github/workflows/ts-blocker.yml new file mode 100644 index 0000000..9c6e204 --- /dev/null +++ b/.github/workflows/ts-blocker.yml @@ -0,0 +1,35 @@ +# SPDX-License-Identifier: MPL-2.0 +name: TypeScript/JavaScript Blocker +on: + push: + branches: [main, master] + pull_request: + +# Estate guardrail: scope push to default branches so a PR fires once (not +# push+PR), and cancel superseded runs. Safe — read-only PR-triggered check. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Block new TypeScript/JavaScript + run: | + NEW_TS=$(git diff --name-only --diff-filter=A HEAD~1 2>/dev/null | grep -E '\.(ts|tsx)$' | grep -v '\.gen\.' || true) + NEW_JS=$(git diff --name-only --diff-filter=A HEAD~1 2>/dev/null | grep -E '\.(js|jsx)$' | grep -v '\.res\.js$' | grep -v '\.gen\.' | grep -v 'node_modules' || true) + + if [ -n "$NEW_TS" ] || [ -n "$NEW_JS" ]; then + echo "❌ New TS/JS files detected. Use ReScript instead." + [ -n "$NEW_TS" ] && echo "$NEW_TS" + [ -n "$NEW_JS" ] && echo "$NEW_JS" + exit 1 + fi + echo "✅ ReScript policy enforced" diff --git a/.github/workflows/wellknown-enforcement.yml b/.github/workflows/wellknown-enforcement.yml new file mode 100644 index 0000000..6a6c5e9 --- /dev/null +++ b/.github/workflows/wellknown-enforcement.yml @@ -0,0 +1,94 @@ +# SPDX-License-Identifier: MPL-2.0 +name: Well-Known Standards (RFC 9116 + RSR) +on: + push: + branches: [main, master] + paths: + - '.well-known/**' + - 'security.txt' + pull_request: + paths: + - '.well-known/**' + schedule: + # Weekly expiry check (Mondays 09:00 UTC) + - cron: '0 9 * * 1' + workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: RFC 9116 security.txt validation + run: | + SECTXT="" + [ -f ".well-known/security.txt" ] && SECTXT=".well-known/security.txt" + [ -f "security.txt" ] && SECTXT="security.txt" + + if [ -z "$SECTXT" ]; then + echo "::error::No security.txt found — required for OpenSSF Best Practices. See https://github.com/hyperpolymath/well-known-ecosystem" + exit 1 + fi + + # Required: Contact + grep -q "^Contact:" "$SECTXT" || { echo "::error::Missing Contact field"; exit 1; } + + # Required: Expires + if ! grep -q "^Expires:" "$SECTXT"; then + echo "::error::Missing Expires field" + exit 1 + fi + + # Check expiry + EXPIRES=$(grep "^Expires:" "$SECTXT" | cut -d: -f2- | tr -d ' ' | head -1) + if date -d "$EXPIRES" > /dev/null 2>&1; then + DAYS=$(( ($(date -d "$EXPIRES" +%s) - $(date +%s)) / 86400 )) + if [ $DAYS -lt 0 ]; then + echo "::error::security.txt EXPIRED" + exit 1 + elif [ $DAYS -lt 30 ]; then + echo "::warning::security.txt expires in $DAYS days" + else + echo "✅ security.txt valid ($DAYS days)" + fi + fi + - name: RSR well-known compliance + run: | + MISSING="" + [ ! -f ".well-known/security.txt" ] && [ ! -f "security.txt" ] && MISSING="$MISSING security.txt" + [ ! -f ".well-known/ai.txt" ] && MISSING="$MISSING ai.txt" + [ ! -f ".well-known/humans.txt" ] && MISSING="$MISSING humans.txt" + + if [ -n "$MISSING" ]; then + echo "::warning::Missing RSR recommended files:$MISSING" + echo "Reference: https://github.com/hyperpolymath/well-known-ecosystem/.well-known/" + else + echo "✅ RSR well-known compliant" + fi + - name: Mixed content check + run: | + MIXED=$(grep -rE 'src="http://|href="http://' --include="*.html" --include="*.htm" . 2>/dev/null | grep -vE 'localhost|127\.0\.0\.1|example\.com' | head -5 || true) + if [ -n "$MIXED" ]; then + echo "::error::Mixed content (HTTP in HTML)" + echo "$MIXED" + exit 1 + fi + echo "✅ No mixed content" + - name: DNS security records check + if: hashFiles('CNAME') != '' + run: | + DOMAIN=$(cat CNAME 2>/dev/null | tr -d '\n') + if [ -n "$DOMAIN" ]; then + echo "Checking DNS for $DOMAIN..." + # CAA record + dig +short CAA "$DOMAIN" | grep -q "issue" && echo "✅ CAA record" || echo "::warning::No CAA record" + # DNSSEC + dig +dnssec +short "$DOMAIN" | grep -q "RRSIG" && echo "✅ DNSSEC" || echo "::warning::No DNSSEC" + fi diff --git a/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml new file mode 100644 index 0000000..e82b6a3 --- /dev/null +++ b/.github/workflows/workflow-linter.yml @@ -0,0 +1,160 @@ +# SPDX-License-Identifier: MPL-2.0 +# workflow-linter.yml - Validates GitHub workflows against RSR security standards +# This workflow can be copied to other repos for consistent enforcement +name: Workflow Security Linter + +on: + push: + paths: + - '.github/workflows/**' + pull_request: + paths: + - '.github/workflows/**' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read + +jobs: + lint-workflows: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check SPDX Headers + run: | + echo "=== Checking SPDX License Headers ===" + failed=0 + for file in .github/workflows/*.yml .github/workflows/*.yaml; do + [ -f "$file" ] || continue + if ! head -1 "$file" | grep -q "^# SPDX-License-Identifier:"; then + echo "ERROR: $file missing SPDX header" + failed=1 + fi + done + if [ $failed -eq 1 ]; then + echo "Add '# SPDX-License-Identifier: MPL-2.0' as first line" + exit 1 + fi + echo "All workflows have SPDX headers" + + - name: Check Permissions Declaration + run: | + echo "=== Checking Permissions ===" + failed=0 + for file in .github/workflows/*.yml .github/workflows/*.yaml; do + [ -f "$file" ] || continue + if ! grep -q "^permissions:" "$file"; then + echo "ERROR: $file missing top-level 'permissions:' declaration" + failed=1 + fi + done + if [ $failed -eq 1 ]; then + echo "Add a top-level 'permissions:' block (e.g. contents: read)" + exit 1 + fi + echo "All workflows have permissions declared" + + - name: Check SHA-Pinned Actions + run: | + echo "=== Checking Action Pinning ===" + # Find any uses: lines that don't have @SHA format + # Pattern: uses: owner/repo@<40-char-hex> + unpinned=$(grep -rnE "^[[:space:]]+uses:" .github/workflows/ | \ + grep -v "@[a-f0-9]\{40\}" | \ + grep -v "uses: \./\|uses: docker://\|uses: actions/github-script" || true) + + if [ -n "$unpinned" ]; then + echo "ERROR: Found unpinned actions:" + echo "$unpinned" + echo "" + echo "Replace version tags with SHA pins, e.g.:" + echo " uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.1" + exit 1 + fi + echo "All actions are SHA-pinned" + + - name: Check for Duplicate Workflows + run: | + echo "=== Checking for Duplicates ===" + # Known duplicate patterns + if [ -f .github/workflows/codeql.yml ] && [ -f .github/workflows/codeql-analysis.yml ]; then + echo "ERROR: Duplicate CodeQL workflows found" + echo "Delete codeql-analysis.yml (keep codeql.yml)" + exit 1 + fi + if [ -f .github/workflows/rust.yml ] && [ -f .github/workflows/rust-ci.yml ]; then + echo "WARNING: Potential duplicate Rust workflows" + echo "Consider consolidating rust.yml and rust-ci.yml" + fi + echo "No critical duplicates found" + + - name: Check CodeQL Language Matrix + run: | + echo "=== Checking CodeQL Configuration ===" + if [ ! -f .github/workflows/codeql.yml ]; then + echo "No CodeQL workflow found (optional)" + exit 0 + fi + + # Detect repo languages + has_js=$(find . -name "*.js" -o -name "*.ts" -o -name "*.jsx" -o -name "*.tsx" -path "*/src/*" -o -path "*/lib/*" 2>/dev/null | head -1) + has_py=$(find . -name "*.py" -path "*/src/*" -o -path "*/lib/*" 2>/dev/null | head -1) + has_go=$(find . -name "*.go" -path "*/src/*" -o -path "*/cmd/*" -o -path "*/pkg/*" 2>/dev/null | head -1) + has_rs=$(find . -name "*.rs" -path "*/src/*" 2>/dev/null | head -1) + has_java=$(find . -name "*.java" -path "*/src/*" 2>/dev/null | head -1) + has_rb=$(find . -name "*.rb" -path "*/lib/*" -o -path "*/app/*" 2>/dev/null | head -1) + + echo "Detected languages:" + [ -n "$has_js" ] && echo " - javascript-typescript" + [ -n "$has_py" ] && echo " - python" + [ -n "$has_go" ] && echo " - go" + [ -n "$has_rs" ] && echo " - rust (note: CodeQL rust is limited)" + [ -n "$has_java" ] && echo " - java-kotlin" + [ -n "$has_rb" ] && echo " - ruby" + + # Check for over-reach + if grep -q "language:.*'go'" .github/workflows/codeql.yml && [ -z "$has_go" ]; then + echo "WARNING: CodeQL configured for Go but no Go files found" + fi + if grep -q "language:.*'python'" .github/workflows/codeql.yml && [ -z "$has_py" ]; then + echo "WARNING: CodeQL configured for Python but no Python files found" + fi + if grep -q "language:.*'java'" .github/workflows/codeql.yml && [ -z "$has_java" ]; then + echo "WARNING: CodeQL configured for Java but no Java files found" + fi + if grep -q "language:.*'ruby'" .github/workflows/codeql.yml && [ -z "$has_rb" ]; then + echo "WARNING: CodeQL configured for Ruby but no Ruby files found" + fi + + echo "CodeQL check complete" + + - name: Check Secrets Guards + run: | + echo "=== Checking Secrets Usage ===" + # Look for secrets without conditional guards in mirror workflows + if [ -f .github/workflows/mirror.yml ]; then + if grep -q "secrets\." .github/workflows/mirror.yml; then + if ! grep -q "if:.*vars\." .github/workflows/mirror.yml; then + echo "WARNING: mirror.yml uses secrets without vars guard" + echo "Add 'if: vars.FEATURE_ENABLED == true' to jobs" + fi + fi + fi + echo "Secrets check complete" + + - name: Summary + run: | + echo "" + echo "=== Workflow Linter Summary ===" + echo "All critical checks passed." + echo "" + echo "For more info, see: robot-repo-bot/ERROR-CATALOG.scm" diff --git a/.machine_readable/descriptiles/STATE.a2ml b/.machine_readable/descriptiles/STATE.a2ml new file mode 100644 index 0000000..9679315 --- /dev/null +++ b/.machine_readable/descriptiles/STATE.a2ml @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# STATE.a2ml — Project state checkpoint (META-TEMPLATE) +# +# This is the STATE file for rsr-template-repo itself. +# When consumed by a new project, replace REPLACED tokens +# and customize sections below for the target project. + +[metadata] +project = "rsr-template-repo" +version = "0.2.0" +last-updated = "2026-02-28" +status = "active" # active | paused | archived + +[project-context] +name = "rsr-template-repo" +purpose = "Canonical RSR-compliant repository template providing scaffolding for all hyperpolymath projects — including CI/CD, AI manifests, ABI/FFI standards, container ecosystem, and governance infrastructure." +completion-percentage = 95 + +[position] +phase = "maintenance" # design | implementation | testing | maintenance | archived +maturity = "production" # experimental | alpha | beta | production | lts + +[route-to-mvp] +milestones = [ + { name = "Phase 0: Core scaffolding (justfile, CI/CD, .machine_readable)", completion = 100 }, + { name = "Phase 1: ABI/FFI standard (Idris2/Zig templates)", completion = 100 }, + { name = "Phase 1b: AI Gatekeeper Protocol (0-AI-MANIFEST.a2ml)", completion = 100 }, + { name = "Phase 1c: TOPOLOGY.md standard and guide", completion = 100 }, + { name = "Phase 1d: Maintenance gate (axes, checklist, approach)", completion = 100 }, + { name = "Phase 1e: Trustfile / contractiles", completion = 100 }, + { name = "Phase 2: Container ecosystem templates (stapeln)", completion = 100 }, + { name = "Phase 3: Multi-forge sync hardening", completion = 0 }, + { name = "Phase 4: Guix reproducible shells", completion = 50 }, +] + +[blockers-and-issues] +# No active blockers + +[critical-next-actions] +actions = [ + "Container templates complete — test with `just container-init`", + "Validate container templates across wolfi-base and static Chainguard images", + "Harden multi-forge sync for GitLab/Bitbucket mirroring edge cases", + "Expand Guix development shell templates", +] + +[maintenance-status] +last-run-utc = "never" +last-report = "docs/reports/maintenance/latest.json" +last-result = "unknown" # unknown | pass | warn | fail +open-warnings = 0 +open-failures = 0 + +[ecosystem] +part-of = ["RSR Framework", "stapeln ecosystem"] +depends-on = ["stapeln", "selur-compose", "cerro-torre", "svalinn", "vordr", "k9-svc"] + +# --------------------------------------------------------------------------- +# NOTE FOR CONSUMERS: When using this template to create a new repo, reset +# the fields above to your project's values and replace all REPLACED +# tokens. The milestones above describe the TEMPLATE's evolution, not yours. +# --------------------------------------------------------------------------- diff --git a/.machine_readable/root-allow.txt b/.machine_readable/root-allow.txt new file mode 100644 index 0000000..e8ec204 --- /dev/null +++ b/.machine_readable/root-allow.txt @@ -0,0 +1,88 @@ +# Canonical root allowlist for RSR-templated repositories. +# +# Lists every entry permitted at the repository root. +# Anything tracked at root that is not in this list is drift and +# must be moved into the appropriate subdirectory (or added here +# with a justification comment). +# +# Used by: scripts/check-root-shape.sh +# Authority: TEMPLATE-STANDARDS-AUDIT.adoc, "Proposed Final Directory Map" +# +# Format: one entry per line; '#' starts a comment; trailing '/' marks a directory. +# Blank lines and comment-only lines are ignored. + +# ─── Authority files (template-mandated) ───────────────────────────────────── +README.adoc +AUDIT.adoc +EXPLAINME.adoc +AFFIRMATION.adoc # dated/signed honesty snapshot (README/EXPLAINME/AFFIRMATION trio) +0-AI-MANIFEST.a2ml # thin pointer to .machine_readable/0.1-AI-MANIFEST.a2ml +GOVERNANCE.adoc # governance model (validator accepts root or docs/governance/) +MAINTAINERS.adoc # maintainer roster +CONTRIBUTING.md # Accepted at root OR .github/ — CI (openssf/quality/rhodibot) now checks both; .github/ is the canonical estate location (org-inherited). Allow-listed if present at root. +SECURITY.md # Accepted at root OR .github/ — see CONTRIBUTING.md note above; security-policy contractile now accepts the .github/ copy. +LICENSE +LICENSES/ # REUSE licence texts (MPL-2.0.txt + CC-BY-SA-4.0.txt) for the dual-licence model (code MPL-2.0 / docs CC-BY-SA-4.0) +CHANGELOG.md +CITATION.cff # citation metadata (surfaced at root by #96) + +# ─── Build entry points (must live at root for their tooling) ──────────────── +Justfile # delegates phases to build/just/*.just +coordination.k9 # repo-local session binding (template-mandated) +abi.ipkg # Idris2 package for the ABI seam; sourcedir=src/interface (estate canon: root-level *-abi.ipkg). Single case-consistent src/interface/Abi/ dir. Typecheck: `idris2 --typecheck abi.ipkg`. + +# ─── Conventional dotfiles (tool-required at root) ─────────────────────────── +.editorconfig +.envrc +.gitattributes +.gitignore +.tool-versions +CLAUDE.md # AI session instructions, generated by the arrival-pack from .machine_readable/ a2ml and read by agents at repo root (do not hand-edit; edit the a2ml source) +.hypatia-ignore # repo-scoped Hypatia scanner exemptions; the scanner reads it from the repo root +sonar-project.properties # SonarCloud analysis config; the SonarQube scan action reads it from the repo root + +# ─── Directories ───────────────────────────────────────────────────────────── +.devcontainer/ # VS Code dev container spec; tool-required at root +.git/ +.github/ # CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, workflows/ +.machine_readable/ # AI manifests, contractiles, custom-format configs +.well-known/ +build/ # contractile.just, setup.sh, guix.scm, .guix-channel, Containerfile +ci/ # .gitlab-ci.yml, .pre-commit-config.yaml (root shims if tools require) +docs/ # onboarding/, status/, governance/, ... +docs-template/ # template-only: scaffolding docs copied into new repos +machine-readable-design/ # template-only: design rationale for .machine_readable/ layout +session/ # dispatch.sh, custom-checks.k9, local-hooks.sh + +# Source / test / artifact trees (project-specific but conventional) +src/ +tests/ +benches/ +examples/ +features/ +scripts/ +verification/ +container/ # may host Containerfile if not at build/ + +# ─── Tolerated pending follow-up (re-evaluate when item lands) ─────────────── +.gitlab-ci.yml # TODO: relocate to ci/.gitlab-ci.yml after GitLab project-setting update +.pre-commit-config.yaml # TODO: relocate to ci/.pre-commit-config.yaml after invocation pattern decided +affinescript/ # AffineScript source subtree consumed by this template +tools/ # TODO: consolidate with scripts/ or document the split (pending decision) +CODE_OF_CONDUCT.md # standard +contractile.just # standard +contractiles # standard +gleam.toml # standard +guix.scm # standard +llm-warmup-dev.md # standard +llm-warmup-user.md # standard +manifest.toml # standard +QUICKSTART-DEV.adoc # standard +QUICKSTART-MAINTAINER.adoc # standard +QUICKSTART-USER.adoc # standard +README.md # standard +setup.sh # standard +stapeln.toml # standard +test # standard +TEST-NEEDS.md # standard +TOPOLOGY.md # standard diff --git a/scripts/check-no-md-in-docs.sh b/scripts/check-no-md-in-docs.sh new file mode 100644 index 0000000..102a6b8 --- /dev/null +++ b/scripts/check-no-md-in-docs.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# check-no-md-in-docs.sh — enforce "AsciiDoc by default for general docs". +# +# Estate rule: .adoc for general docs (TOPOLOGY, READINESS, ROADMAP, etc.); +# .md only for files GitHub's community-health rules special-case by name +# (CONTRIBUTING, CODE_OF_CONDUCT, SECURITY, CHANGELOG, etc.) — those live at +# root or in .github/, never under docs/. +# +# Fails if any .md files exist under docs/. Add justified entries to the +# ALLOWED list below if a docs/-rooted .md is genuinely needed (rare). +# +# Exit codes: +# 0 — no .md files under docs/ (or all matches are allow-listed) +# 1 — disallowed .md files found +# 2 — usage / setup error + +set -euo pipefail + +REPO_ROOT="${1:-.}" +DOCS_DIR="$REPO_ROOT/docs" + +# Justified exceptions, relative to repo root. Empty by default. +ALLOWED=() + +if [ ! -d "$DOCS_DIR" ]; then + echo "PASS: no docs/ directory (nothing to check)" + exit 0 +fi + +mapfile -t HITS < <(find "$DOCS_DIR" -name '*.md' -type f 2>/dev/null | sort) + +EXTRAS=() +for hit in "${HITS[@]}"; do + rel="${hit#"$REPO_ROOT/"}" + skip=0 + for allowed in "${ALLOWED[@]}"; do + if [ "$rel" = "$allowed" ]; then skip=1; break; fi + done + if [ $skip -eq 0 ]; then EXTRAS+=("$rel"); fi +done + +if [ ${#EXTRAS[@]} -eq 0 ]; then + echo "PASS: no .md files under docs/ (${#HITS[@]} total found, ${#ALLOWED[@]} allow-listed)" + exit 0 +fi + +echo "FAIL: ${#EXTRAS[@]} .md files found under docs/ (estate rule: AsciiDoc by default):" >&2 +for e in "${EXTRAS[@]}"; do + echo " - $e" >&2 +done +echo "" >&2 +echo "Convert these to .adoc, or add a justified entry to the ALLOWED list" >&2 +echo "in scripts/check-no-md-in-docs.sh." >&2 +exit 1 diff --git a/scripts/check-no-vlang.sh b/scripts/check-no-vlang.sh new file mode 100644 index 0000000..f2125a3 --- /dev/null +++ b/scripts/check-no-vlang.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# check-no-vlang.sh — enforce "V-lang is banned in the estate". +# +# Estate rule: V-lang (vlang.io) is banned estate-wide (2026-04-10). It was +# migrated to Zig — the estate's default FFI/API/gateway language — via the +# `zig-unified-api-adapter`. Zig is ALLOWED and is deliberately NOT matched by +# this check; only V-lang references are treated as drift and must be removed. +# +# Searches for V-lang-specific content patterns in tracked files. The .v file +# extension is intentionally NOT used as a marker because Coq theorem files +# share that extension; this check looks at content patterns instead. +# +# Excludes: +# .git/ (vcs internals) +# affinescript/ (a separately-licensed AffineScript subtree; pattern hits +# there are not estate-managed and false-positive on `.v` mentions in +# linguistic / academic prose). +# +# Exit codes: +# 0 — no V-lang references found +# 1 — V-lang references found (treat as drift) +# 2 — usage / setup error + +set -euo pipefail + +REPO_ROOT="${1:-.}" + +# Patterns that uniquely indicate V-lang code, scaffolding, or naming. +# Zig is the estate FFI/API default and is deliberately NOT matched here. +# Coq's `.v` extension and the affinescript subtree are excluded by path. +PATTERNS=( + 'gen-v-connector' + 'V-TRIPLE' + 'v-triple' + 'vlang' + 'connectors/v-' +) + +PATTERN_OR=$(IFS='|'; echo "${PATTERNS[*]}") + +# Files that document the V-lang ban itself (the rule's own description +# legitimately names "V-lang", "vlang", "V-TRIPLE", etc.). Excluded by name. +DOC_EXCLUSIONS=( + "estate-rules.yml" # the workflow that calls this script + "check-no-vlang.sh" # this script itself + "PLAYBOOK.a2ml" # documents the [rsr-repo-skeleton] rules + "feedback_v_lang_banned.md" # memory entry documenting the ban + "project_zig_unified_api.md" # memory entry documenting the replacement +) + +EXCLUDE_ARGS=() +for f in "${DOC_EXCLUSIONS[@]}"; do + EXCLUDE_ARGS+=(--exclude="$f") +done + +# Build grep arguments. Use -r to recurse, -n for line numbers, -i for +# case-insensitive matching. Exclude .git, the affinescript subtree, and +# files that legitimately document the ban. +HITS=$(grep -rni -E "$PATTERN_OR" "$REPO_ROOT" \ + --exclude-dir=.git \ + --exclude-dir=affinescript \ + --exclude-dir=node_modules \ + "${EXCLUDE_ARGS[@]}" \ + 2>/dev/null || true) + +if [ -z "$HITS" ]; then + echo "PASS: no V-lang references" + exit 0 +fi + +# Count matches +LINES=$(echo "$HITS" | wc -l | tr -d ' ') + +echo "FAIL: $LINES V-lang reference(s) found (estate rule: V-lang is banned):" >&2 +echo "$HITS" | sed 's|^| |' >&2 +echo "" >&2 +echo "V-lang has been replaced by Zig (the zig-unified-api-adapter). Remove these references." >&2 +exit 1 diff --git a/scripts/check-root-shape.sh b/scripts/check-root-shape.sh new file mode 100644 index 0000000..8f75343 --- /dev/null +++ b/scripts/check-root-shape.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# check-root-shape.sh — fail when the repository root contains entries that +# are not on the canonical allowlist (.machine_readable/root-allow.txt). +# +# Companion to scripts/validate-template.sh: that script enforces required +# files; this one enforces that nothing else has crept in. +# +# Exit codes: +# 0 — root matches allowlist +# 1 — extras found at root (drift) +# 2 — usage / setup error + +set -euo pipefail + +REPO_ROOT="${1:-.}" +ALLOW_FILE="${REPO_ROOT}/.machine_readable/root-allow.txt" + +if [ ! -f "$ALLOW_FILE" ]; then + echo "ERROR: allowlist not found at $ALLOW_FILE" >&2 + exit 2 +fi + +# Build the allow set: strip comments, trailing slashes, and blank lines. +mapfile -t ALLOW < <( + sed -E 's/[[:space:]]*#.*$//' "$ALLOW_FILE" \ + | sed -E 's|/$||' \ + | awk 'NF' +) + +declare -A ALLOW_SET=() +for entry in "${ALLOW[@]}"; do + ALLOW_SET["$entry"]=1 +done + +# Enumerate everything tracked or present at the repository root. +mapfile -t ACTUAL < <( + cd "$REPO_ROOT" && \ + find . -mindepth 1 -maxdepth 1 \ + ! -name '.' \ + -printf '%f\n' \ + | sort +) + +EXTRAS=() +for entry in "${ACTUAL[@]}"; do + if [ -z "${ALLOW_SET[$entry]+x}" ]; then + EXTRAS+=("$entry") + fi +done + +if [ ${#EXTRAS[@]} -eq 0 ]; then + echo "PASS: root matches allowlist (${#ACTUAL[@]} entries, ${#ALLOW[@]} permitted)" + exit 0 +fi + +echo "FAIL: ${#EXTRAS[@]} root entries are not on the allowlist:" >&2 +for e in "${EXTRAS[@]}"; do + if [ -d "$REPO_ROOT/$e" ]; then + echo " - $e/ (directory)" >&2 + else + echo " - $e" >&2 + fi +done +echo "" >&2 +echo "Either move them into the appropriate subdirectory, or add a justified" >&2 +echo "entry to .machine_readable/root-allow.txt." >&2 +exit 1 diff --git a/scripts/invariant-path.sh b/scripts/invariant-path.sh new file mode 100755 index 0000000..3f7c05b --- /dev/null +++ b/scripts/invariant-path.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +IP_ROOT="${REPO_ROOT}/../invariant-path" + +if [[ ! -f "${IP_ROOT}/Cargo.toml" ]]; then + echo "invariant-path workspace not found at ${IP_ROOT}" >&2 + exit 1 +fi + +if [[ $# -eq 0 ]]; then + set -- scan --profile generic --file "${REPO_ROOT}/README.adoc" --artifact-uri "repo://README.adoc" +elif [[ "$1" == "scan" ]]; then + shift + has_profile="false" + for arg in "$@"; do + if [[ "$arg" == "--profile" ]]; then + has_profile="true" + break + fi + done + if [[ "${has_profile}" == "true" ]]; then + set -- scan "$@" + else + set -- scan --profile generic "$@" + fi +fi + +exec cargo run --manifest-path "${IP_ROOT}/Cargo.toml" -p invariant-path-cli -- "$@" diff --git a/scripts/validate-template.sh b/scripts/validate-template.sh new file mode 100755 index 0000000..cdc914d --- /dev/null +++ b/scripts/validate-template.sh @@ -0,0 +1,382 @@ +#!/bin/bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# RSR Template Validation Script +# Verifies that a repository follows the RSR template structure and contains all required files +# +# Exit codes: +# 0 = validation passed +# 1 = validation failed with errors +# 2 = validation failed with warnings (but can proceed) + +set -euo pipefail + +REPO_ROOT="${1:-.}" +VERBOSE="${2:-0}" +ERRORS=0 +WARNINGS=0 + +# ANSI colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Helper functions +log_error() { + echo -e "${RED}ERROR${NC}: $*" >&2 + ERRORS=$((ERRORS + 1)) +} + +log_warning() { + echo -e "${YELLOW}WARN${NC}: $*" >&2 + WARNINGS=$((WARNINGS + 1)) +} + +log_info() { + echo -e "${BLUE}INFO${NC}: $*" >&2 +} + +log_pass() { + echo -e "${GREEN}PASS${NC}: $*" >&2 +} + +check_file_exists() { + local file="$1" + local description="${2:-}" + if [ -f "$REPO_ROOT/$file" ]; then + [ "$VERBOSE" = "1" ] && log_pass "File exists: $file" + return 0 + else + log_error "Required file missing: $file ${description:+(${description})}" + return 1 + fi +} + +check_dir_exists() { + local dir="$1" + local description="${2:-}" + if [ -d "$REPO_ROOT/$dir" ]; then + [ "$VERBOSE" = "1" ] && log_pass "Directory exists: $dir" + return 0 + else + log_error "Required directory missing: $dir ${description:+(${description})}" + return 1 + fi +} + +# Case-tolerant ABI seam checks: accept the canonical case-consistent +# src/interface/Abi/ (matches `module Abi.*`) OR a lowercase src/interface/abi/ +# that some downstream repos ship. Never require BOTH (that would be a case-fold +# collision on case-insensitive filesystems). +check_abi_dir_exists() { + local description="${1:-}" + if [ -d "$REPO_ROOT/src/interface/Abi" ] || [ -d "$REPO_ROOT/src/interface/abi" ]; then + [ "$VERBOSE" = "1" ] && log_pass "Directory exists: src/interface/{Abi,abi}" + return 0 + fi + log_error "Required directory missing: src/interface/Abi ${description:+(${description})}" + return 1 +} +check_abi_file_exists() { + local fname="$1" + local description="${2:-}" + if [ -f "$REPO_ROOT/src/interface/Abi/$fname" ] || [ -f "$REPO_ROOT/src/interface/abi/$fname" ]; then + [ "$VERBOSE" = "1" ] && log_pass "File exists: src/interface/{Abi,abi}/$fname" + return 0 + fi + log_error "Required file missing: src/interface/Abi/$fname ${description:+(${description})}" + return 1 +} + +has_spdx_header() { + local file="$1" + if head -10 "$file" | grep -q "SPDX-License-Identifier"; then + return 0 + fi + return 1 +} + +has_placeholder() { + local file="$1" + if grep -q "{{REPO\|{{OWNER\|{{FORGE\|{{PROJECT\|{{project\|{{AUTHOR" "$file" 2>/dev/null; then + return 0 + fi + return 1 +} + +#============================================================================== +# VALIDATION PHASE 1: CORE STRUCTURE +#============================================================================== + +echo "" +log_info "Phase 1: Core repository structure" +echo "" + +# Root files +check_file_exists "0-AI-MANIFEST.a2ml" "AI manifest (universal entry point)" +check_file_exists "README.adoc" "High-level pitch" +check_file_exists "EXPLAINME.adoc" "Developer deep-dive" +check_file_exists "LICENSE" "License file" +check_file_exists "Justfile" "Task runner" +check_file_exists "AUDIT.adoc" "Release audit gate" + +# Directories +check_dir_exists ".machine_readable" "Machine-readable metadata" +check_dir_exists ".github" "GitHub community metadata" +check_abi_dir_exists "Idris2 ABI definitions" +check_dir_exists "src/interface/ffi" "Zig FFI implementation" +check_dir_exists "src/interface/generated/abi" "Generated C headers" +check_dir_exists "docs" "Documentation" + +#============================================================================== +# VALIDATION PHASE 2: MACHINE-READABLE METADATA +#============================================================================== + +echo "" +log_info "Phase 2: Machine-readable metadata (.machine_readable/)" +echo "" + +check_file_exists ".machine_readable/descriptiles/STATE.a2ml" "Project state" +check_file_exists ".machine_readable/descriptiles/META.a2ml" "Architecture decisions" +check_file_exists ".machine_readable/descriptiles/ECOSYSTEM.a2ml" "Ecosystem position" +check_file_exists ".machine_readable/descriptiles/anchors/ANCHOR.a2ml" "Semantic boundary anchor" +check_file_exists ".machine_readable/policies/MAINTENANCE-AXES.a2ml" "Maintenance axes" + +#============================================================================== +# VALIDATION PHASE 3: REQUIRED WORKFLOWS (17 minimum) +#============================================================================== + +echo "" +log_info "Phase 3: GitHub Actions workflows" +echo "" + +REQUIRED_WORKFLOWS=( + "hypatia-scan.yml" + "codeql.yml" + "scorecard.yml" + "quality.yml" + "mirror.yml" + "instant-sync.yml" + "guix-policy.yml" + "security-policy.yml" + "wellknown-enforcement.yml" + "workflow-linter.yml" + "npm-bun-blocker.yml" + "ts-blocker.yml" + "secret-scanner.yml" +) + +# Check required workflows +for workflow in "${REQUIRED_WORKFLOWS[@]}"; do + if [ -f "$REPO_ROOT/.github/workflows/$workflow" ]; then + [ "$VERBOSE" = "1" ] && log_pass "Workflow found: $workflow" + else + log_error "Required workflow missing: $workflow" + fi +done + +# Verify all workflows have SPDX headers and proper structure +WORKFLOW_FILES=$(find "$REPO_ROOT/.github/workflows" -name "*.yml" -type f 2>/dev/null || true) +WORKFLOW_COUNT=$(echo "$WORKFLOW_FILES" | grep -c "." || true) + +if [ "$WORKFLOW_COUNT" -ge 15 ]; then + log_pass "Found $WORKFLOW_COUNT workflows (>= 15 expected)" +else + log_warning "Found only $WORKFLOW_COUNT workflows (expected >= 15)" +fi + +# Spot-check workflow files for issues +while IFS= read -r workflow_file; do + if [ -z "$workflow_file" ]; then continue; fi + + # Check for SPDX header (optional in YAML workflows, but best practice) + if ! head -5 "$workflow_file" | grep -q "SPDX-License-Identifier"; then + log_warning "Workflow missing SPDX header: $(basename "$workflow_file")" + fi + + # Check for proper YAML structure + if ! grep -q "^name:" "$workflow_file"; then + log_error "Workflow missing 'name' field: $(basename "$workflow_file")" + fi +done <<< "$WORKFLOW_FILES" + +#============================================================================== +# VALIDATION PHASE 4: ABI/FFI SOURCE FILES +#============================================================================== + +echo "" +log_info "Phase 4: Idris2 ABI and Zig FFI source files" +echo "" + +# Idris2 ABI files +check_abi_file_exists "Types.idr" "Core type definitions" +check_abi_file_exists "Layout.idr" "Memory layout specifications" +check_abi_file_exists "Foreign.idr" "FFI foreign declarations" + +# Zig FFI files +check_file_exists "src/interface/ffi/build.zig" "Zig build configuration" +check_file_exists "src/interface/ffi/src/main.zig" "Zig implementation" +check_file_exists "src/interface/ffi/test/integration_test.zig" "Integration tests" + +#============================================================================== +# VALIDATION PHASE 5: PLACEHOLDER TOKENS +#============================================================================== + +echo "" +log_info "Phase 5: Placeholder token replacement (skipped in template repo)" +echo "" + +# Note: Template repo is allowed to have placeholders +# For derived repos, we'd check that placeholders are replaced +if [ "$(basename "$REPO_ROOT")" = "rsr-template-repo" ]; then + log_pass "Skipping placeholder check for template repo" +else + # Check that key files don't have unresolved placeholders + for file in "$REPO_ROOT/README.adoc" "$REPO_ROOT/Justfile" "$REPO_ROOT/.machine_readable/descriptiles/STATE.a2ml"; do + if [ -f "$file" ]; then + if has_placeholder "$file"; then + log_warning "File contains unresolved placeholders: $(basename "$file")" + fi + fi + done +fi + +#============================================================================== +# VALIDATION PHASE 6: SPDX LICENSE HEADERS +#============================================================================== + +echo "" +log_info "Phase 6: SPDX License Headers" +echo "" + +# Check source files for SPDX headers (excluding build artifacts) +SOURCE_FILES=$(find "$REPO_ROOT/src" -type f \( -name "*.idr" -o -name "*.zig" \) \ + ! -path "*/.zig-cache/*" ! -path "*/zig-cache/*" 2>/dev/null || true) +SOURCE_COUNT=$(echo "$SOURCE_FILES" | grep -c "." || true) +SPDX_COUNT=0 + +while IFS= read -r src_file; do + if [ -z "$src_file" ]; then continue; fi + if has_spdx_header "$src_file"; then + SPDX_COUNT=$((SPDX_COUNT + 1)) + else + log_warning "Source file missing SPDX header: $(basename "$src_file")" + fi +done <<< "$SOURCE_FILES" + +if [ "$SOURCE_COUNT" -gt 0 ]; then + PERCENT=$((SPDX_COUNT * 100 / SOURCE_COUNT)) + log_pass "SPDX headers: $SPDX_COUNT/$SOURCE_COUNT ($PERCENT%)" + if [ "$PERCENT" -lt 100 ]; then + log_warning "Not all source files have SPDX headers" + fi +fi + +#============================================================================== +# VALIDATION PHASE 7: BUILD VERIFICATION +#============================================================================== + +echo "" +log_info "Phase 7: Build system verification" +echo "" + +# Check Zig build +if [ -f "$REPO_ROOT/src/interface/ffi/build.zig" ]; then + if command -v zig &> /dev/null; then + cd "$REPO_ROOT/src/interface/ffi" + if zig build 2>&1 | grep -q "error"; then + log_error "Zig build failed" + else + log_pass "Zig build successful" + fi + cd - > /dev/null + else + log_warning "Zig compiler not found - skipping Zig build check" + fi +else + log_error "Zig build.zig not found" +fi + +# Check Idris2. Prefer a REAL typecheck via the package (abi.ipkg sets the +# sourcedir so the `module Abi.*` namespace resolves); this catches namespace / +# path / import breakage that a bare per-file `idris2 --check` masks as a +# tolerated "module name does not match file name" warning. +if command -v idris2 &> /dev/null; then + if [ -f "$REPO_ROOT/abi.ipkg" ]; then + if (cd "$REPO_ROOT" && idris2 --typecheck abi.ipkg) > /dev/null 2>&1; then + log_pass "Idris2 ABI typechecks (abi.ipkg)" + else + log_error "Idris2 ABI does NOT typecheck (abi.ipkg)" + fi + else + # No package: fall back to a best-effort per-file syntax check (warns on + # the expected namespace/path mismatch). Look in either case of the dir. + IDS_FILES=$(find "$REPO_ROOT/src/interface/Abi" "$REPO_ROOT/src/interface/abi" -name "*.idr" -type f 2>/dev/null || true) + while IFS= read -r ids_file; do + if [ -z "$ids_file" ]; then continue; fi + if ! idris2 --check "$ids_file" 2>&1 | grep -q "Error"; then + log_pass "Idris2 syntax OK: $(basename "$ids_file")" + else + log_warning "Idris2 syntax issue: $(basename "$ids_file")" + fi + done <<< "$IDS_FILES" + fi +else + log_warning "Idris2 compiler not found - skipping Idris2 syntax checks" +fi + +#============================================================================== +# VALIDATION PHASE 8: DOCUMENTATION +#============================================================================== + +echo "" +log_info "Phase 8: Documentation requirements" +echo "" + +check_file_exists "docs/developer/ABI-FFI-README.adoc" "ABI/FFI documentation" +# TOPOLOGY may live at root or under docs/architecture/, .md or .adoc +if [ -f "$REPO_ROOT/TOPOLOGY.adoc" ] || [ -f "$REPO_ROOT/TOPOLOGY.md" ] || \ + [ -f "$REPO_ROOT/docs/architecture/TOPOLOGY.adoc" ] || [ -f "$REPO_ROOT/docs/architecture/TOPOLOGY.md" ]; then + [ "$VERBOSE" = "1" ] && log_pass "Architecture topology found" +else + log_error "Required file missing: TOPOLOGY (root or docs/architecture/, .adoc or .md)" +fi +# CONTRIBUTING.md may live at root or in .github/ (GitHub auto-discovers either) +if [ -f "$REPO_ROOT/CONTRIBUTING.md" ] || [ -f "$REPO_ROOT/.github/CONTRIBUTING.md" ]; then + [ "$VERBOSE" = "1" ] && log_pass "Contribution guide found" +else + log_error "Required file missing: CONTRIBUTING.md (root or .github/)" +fi + +# Governance can be at root or in docs/governance/ +if [ -f "$REPO_ROOT/GOVERNANCE.adoc" ] || [ -f "$REPO_ROOT/GOVERNANCE.md" ] || [ -d "$REPO_ROOT/docs/governance" ]; then + [ "$VERBOSE" = "1" ] && log_pass "Governance files found" +else + log_warning "Governance documentation not found" +fi + +#============================================================================== +# VALIDATION SUMMARY +#============================================================================== + +echo "" +echo "═══════════════════════════════════════════════════════════════════════════════" +echo "VALIDATION SUMMARY" +echo "═══════════════════════════════════════════════════════════════════════════════" +echo "" +echo -e "Errors: ${RED}${ERRORS}${NC}" +echo -e "Warnings: ${YELLOW}${WARNINGS}${NC}" +echo "" + +if [ "$ERRORS" -eq 0 ]; then + echo -e "${GREEN}✓ Validation PASSED${NC}" + [ "$WARNINGS" -gt 0 ] && echo -e " (with $WARNINGS warnings)" + exit 0 +else + echo -e "${RED}✗ Validation FAILED${NC}" + echo " Please fix the errors above." + exit 1 +fi diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..fa6029c --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,5 @@ +sonar.projectKey=a2ml_gleam +sonar.organization=hyperpolymath +sonar.sources=src +sonar.tests=test +sonar.language=gleam diff --git a/src/a2ml_gleam/parser.gleam b/src/a2ml_gleam/parser.gleam index 51ef184..0262b7c 100644 --- a/src/a2ml_gleam/parser.gleam +++ b/src/a2ml_gleam/parser.gleam @@ -7,10 +7,6 @@ // A2ML syntax is similar to Markdown with extensions for directives (@) // and attestation blocks (!attest). -import gleam/list -import gleam/result -import gleam/string - import a2ml_gleam/types.{ type Attestation, type Block, type Directive, type Document, type Inline, type Manifest, type TrustLevel, Attestation, AttestationBlock, Automated, @@ -18,6 +14,9 @@ import a2ml_gleam/types.{ Link, ListBlock, Manifest, Paragraph, Reviewed, Strong, Text, ThematicBreak, Unverified, Verified, } +import gleam/list +import gleam/result +import gleam/string /// Error type for parse failures. pub type ParseError { @@ -119,8 +118,7 @@ fn parse_blocks(lines: List(String), acc: List(Block)) -> List(Block) { "" -> parse_blocks(rest, acc) // Thematic break: three or more dashes. - "---" | "----" | "-----" -> - parse_blocks(rest, [ThematicBreak, ..acc]) + "---" | "----" | "-----" -> parse_blocks(rest, [ThematicBreak, ..acc]) _ -> { case string.starts_with(trimmed, "```") { @@ -200,7 +198,8 @@ fn parse_blocks(lines: List(String), acc: List(Block)) -> List(Block) { True -> { let #(list_lines, remaining) = collect_list_block(lines, []) - let items = parse_list_items(list_lines, False) + let items = + parse_list_items(list_lines, False) parse_blocks(remaining, [ ListBlock(ordered: False, items: items), ..acc @@ -392,10 +391,7 @@ fn is_all_digits(s: String) -> Bool { } /// Parse list items from collected list lines. -fn parse_list_items( - lines: List(String), - _ordered: Bool, -) -> List(List(Block)) { +fn parse_list_items(lines: List(String), _ordered: Bool) -> List(List(Block)) { // Split on lines that start a new item. split_list_items(lines, []) |> list.map(fn(item_text) { @@ -566,10 +562,7 @@ fn parse_inlines_acc(input: String, acc: List(Inline)) -> List(Inline) { let rest = string.drop_start(input, 1) case string.split_once(rest, "`") { Ok(#(code, after)) -> - parse_inlines_acc(after, [ - types.Code(value: code), - ..acc - ]) + parse_inlines_acc(after, [types.Code(value: code), ..acc]) Error(_) -> { let #(text, remaining) = take_until_special(input, "") parse_inlines_acc(remaining, [Text(text), ..acc]) @@ -597,8 +590,7 @@ fn parse_inlines_acc(input: String, acc: List(Inline)) -> List(Inline) { } } Error(_) -> { - let #(text, remaining) = - take_until_special(input, "") + let #(text, remaining) = take_until_special(input, "") parse_inlines_acc(remaining, [Text(text), ..acc]) } } diff --git a/src/a2ml_gleam/renderer.gleam b/src/a2ml_gleam/renderer.gleam index d8d8d17..d285e42 100644 --- a/src/a2ml_gleam/renderer.gleam +++ b/src/a2ml_gleam/renderer.gleam @@ -6,16 +6,15 @@ // Produces A2ML-formatted output from a parsed Document structure, // suitable for round-tripping through parse -> modify -> render. -import gleam/int -import gleam/list -import gleam/string - import a2ml_gleam/types.{ type Attestation, type Block, type Document, type Inline, type TrustLevel, AttestationBlock, Automated, BlockQuote, CodeBlock, DirectiveBlock, Emphasis, Heading, Link, ListBlock, Paragraph, Reviewed, Strong, Text, ThematicBreak, Unverified, Verified, } +import gleam/int +import gleam/list +import gleam/string /// Render a Document to A2ML-formatted text. /// diff --git a/test/a2ml_gleam_aspect_test.gleam b/test/a2ml_gleam_aspect_test.gleam index 69efbf2..45e0140 100644 --- a/test/a2ml_gleam_aspect_test.gleam +++ b/test/a2ml_gleam_aspect_test.gleam @@ -7,11 +7,11 @@ // performance, and resilience. These complement the unit and contract tests // by validating behavioural aspects that cut across the whole API surface. -import gleam/list -import gleam/string import a2ml_gleam/parser import a2ml_gleam/renderer import a2ml_gleam/types.{Verified} +import gleam/list +import gleam/string // --------------------------------------------------------------------------- // Aspect: Security — empty and whitespace inputs are handled gracefully @@ -59,14 +59,14 @@ pub fn aspect_correctness_attestation_roundtrip_test() { let assert Ok(doc1) = parser.parse(input) let assert [a] = doc1.attestations - assert a.identity == "Jonathan D.A. Jewell" - assert a.trust_level == Verified + let assert True = a.identity == "Jonathan D.A. Jewell" + let assert True = a.trust_level == Verified let rendered = renderer.render(doc1) let assert Ok(doc2) = parser.parse(rendered) let assert [a2] = doc2.attestations - assert a2.identity == a.identity - assert a2.trust_level == a.trust_level + let assert True = a2.identity == a.identity + let assert True = a2.trust_level == a.trust_level } pub fn aspect_correctness_multiple_attestations_roundtrip_test() { @@ -74,11 +74,11 @@ pub fn aspect_correctness_multiple_attestations_roundtrip_test() { "# Two Attestors\n\n!attest\n identity: Alice\n role: author\n trust-level: reviewed\n\n!attest\n identity: Bob\n role: reviewer\n trust-level: reviewed" let assert Ok(doc1) = parser.parse(input) - assert list.length(doc1.attestations) == 2 + let assert True = list.length(doc1.attestations) == 2 let rendered = renderer.render(doc1) let assert Ok(doc2) = parser.parse(rendered) - assert list.length(doc2.attestations) == 2 + let assert True = list.length(doc2.attestations) == 2 } pub fn aspect_correctness_directive_value_survives_roundtrip_test() { @@ -88,7 +88,7 @@ pub fn aspect_correctness_directive_value_survives_roundtrip_test() { let assert Ok(doc2) = parser.parse(rendered) let manifest1 = parser.extract_manifest(doc1) let manifest2 = parser.extract_manifest(doc2) - assert manifest1.version == manifest2.version + let assert True = manifest1.version == manifest2.version } // --------------------------------------------------------------------------- @@ -111,7 +111,7 @@ pub fn aspect_performance_render_100_identical_test() { list.range(from: 1, to: 100) |> list.each(fn(_) { let out = renderer.render(doc) - assert out != "" + let assert True = out != "" }) } @@ -128,7 +128,8 @@ pub fn aspect_resilience_directive_only_test() { } pub fn aspect_resilience_attestation_only_test() { - let input = "!attest\n identity: Bot\n role: scanner\n trust-level: automated" + let input = + "!attest\n identity: Bot\n role: scanner\n trust-level: automated" let result = parser.parse(input) case result { Ok(_) -> Nil diff --git a/test/a2ml_gleam_bench_test.gleam b/test/a2ml_gleam_bench_test.gleam index 1705293..902395f 100644 --- a/test/a2ml_gleam_bench_test.gleam +++ b/test/a2ml_gleam_bench_test.gleam @@ -8,10 +8,10 @@ // verify that bulk operations complete without error rather than asserting // wall-clock bounds (which would be flaky in CI). -import gleam/list import a2ml_gleam/parser import a2ml_gleam/renderer import a2ml_gleam/types.{Automated, Reviewed, Unverified, Verified} +import gleam/list // --------------------------------------------------------------------------- // Benchmark: parse 500 documents without error @@ -39,7 +39,7 @@ pub fn bench_render_500_test() { list.range(from: 1, to: 500) |> list.each(fn(_) { let out = renderer.render(doc) - assert out != "" + let assert True = out != "" }) } diff --git a/test/a2ml_gleam_contract_test.gleam b/test/a2ml_gleam_contract_test.gleam index 598ffd4..b08eb41 100644 --- a/test/a2ml_gleam_contract_test.gleam +++ b/test/a2ml_gleam_contract_test.gleam @@ -6,10 +6,10 @@ // Tests the behavioural contracts that the API must uphold regardless of input. // Each test validates a named invariant. -import gleam/string import a2ml_gleam/parser import a2ml_gleam/renderer import a2ml_gleam/types.{Automated, Reviewed, Unverified, Verified} +import gleam/string // --------------------------------------------------------------------------- // INVARIANT: parse of empty string returns Error(EmptyInput) @@ -70,8 +70,7 @@ pub fn invariant_parse_trust_level_error_for_unknown_test() { parser.parse_trust_level("invalid") let assert Error(parser.UnknownTrustLevel(_)) = parser.parse_trust_level("none") - let assert Error(parser.UnknownTrustLevel(_)) = - parser.parse_trust_level("") + let assert Error(parser.UnknownTrustLevel(_)) = parser.parse_trust_level("") } // --------------------------------------------------------------------------- @@ -81,7 +80,7 @@ pub fn invariant_parse_trust_level_error_for_unknown_test() { pub fn invariant_render_returns_non_empty_string_test() { let assert Ok(doc) = parser.parse("# Render Contract\n\n@version 1.0") let output = renderer.render(doc) - assert output != "" + let assert True = output != "" } // --------------------------------------------------------------------------- @@ -89,10 +88,10 @@ pub fn invariant_render_returns_non_empty_string_test() { // --------------------------------------------------------------------------- pub fn invariant_render_trust_level_stable_test() { - assert renderer.render_trust_level(Unverified) == "unverified" - assert renderer.render_trust_level(Automated) == "automated" - assert renderer.render_trust_level(Reviewed) == "reviewed" - assert renderer.render_trust_level(Verified) == "verified" + let assert True = renderer.render_trust_level(Unverified) == "unverified" + let assert True = renderer.render_trust_level(Automated) == "automated" + let assert True = renderer.render_trust_level(Reviewed) == "reviewed" + let assert True = renderer.render_trust_level(Verified) == "verified" } // --------------------------------------------------------------------------- @@ -101,7 +100,7 @@ pub fn invariant_render_trust_level_stable_test() { pub fn invariant_attestations_always_list_test() { let assert Ok(doc) = parser.parse("# No Attestations\n\n@version 1.0") - assert doc.attestations == [] + let assert True = doc.attestations == [] } // --------------------------------------------------------------------------- @@ -111,13 +110,13 @@ pub fn invariant_attestations_always_list_test() { pub fn invariant_manifest_version_present_test() { let assert Ok(doc) = parser.parse("# Manifest\n\n@version 3.0") let manifest = parser.extract_manifest(doc) - assert manifest.version == Ok("3.0") + let assert True = manifest.version == Ok("3.0") } pub fn invariant_manifest_version_absent_test() { let assert Ok(doc) = parser.parse("# No Version") let manifest = parser.extract_manifest(doc) - assert manifest.version == Error(Nil) + let assert True = manifest.version == Error(Nil) } // --------------------------------------------------------------------------- @@ -127,5 +126,5 @@ pub fn invariant_manifest_version_absent_test() { pub fn invariant_render_title_in_output_test() { let assert Ok(doc) = parser.parse("# My Title\n\n@version 1.0") let output = renderer.render(doc) - assert string.contains(output, "# My Title") + let assert True = string.contains(output, "# My Title") } diff --git a/test/a2ml_gleam_property_test.gleam b/test/a2ml_gleam_property_test.gleam index eb31424..d50d61e 100644 --- a/test/a2ml_gleam_property_test.gleam +++ b/test/a2ml_gleam_property_test.gleam @@ -6,10 +6,10 @@ // Validates determinism, idempotency, and structural invariants across // a range of inputs without relying on external property-testing libraries. -import gleam/list import a2ml_gleam/parser import a2ml_gleam/renderer import a2ml_gleam/types.{Automated, Reviewed, Unverified, Verified} +import gleam/list // --------------------------------------------------------------------------- // Property: parse is deterministic — same input always produces same output @@ -20,7 +20,7 @@ pub fn parse_is_deterministic_test() { let result1 = parser.parse(input) let result2 = parser.parse(input) - assert result1 == result2 + let assert True = result1 == result2 } // --------------------------------------------------------------------------- @@ -35,7 +35,7 @@ pub fn parse_deterministic_20_calls_test() { list.range(from: 1, to: 20) |> list.each(fn(_) { let result = parser.parse(input) - assert result == first + let assert True = result == first }) } @@ -49,7 +49,7 @@ pub fn render_is_deterministic_test() { let assert Ok(doc) = parser.parse(input) let out1 = renderer.render(doc) let out2 = renderer.render(doc) - assert out1 == out2 + let assert True = out1 == out2 } // --------------------------------------------------------------------------- @@ -62,10 +62,10 @@ pub fn trust_level_strings_roundtrip_test() { let assert Ok(Reviewed) = parser.parse_trust_level("reviewed") let assert Ok(Verified) = parser.parse_trust_level("verified") - assert renderer.render_trust_level(Unverified) == "unverified" - assert renderer.render_trust_level(Automated) == "automated" - assert renderer.render_trust_level(Reviewed) == "reviewed" - assert renderer.render_trust_level(Verified) == "verified" + let assert True = renderer.render_trust_level(Unverified) == "unverified" + let assert True = renderer.render_trust_level(Automated) == "automated" + let assert True = renderer.render_trust_level(Reviewed) == "reviewed" + let assert True = renderer.render_trust_level(Verified) == "verified" } // --------------------------------------------------------------------------- @@ -80,7 +80,7 @@ pub fn roundtrip_preserves_title_test() { let assert Ok(doc1) = parser.parse(input) let rendered = renderer.render(doc1) let assert Ok(doc2) = parser.parse(rendered) - assert doc1.title == doc2.title + let assert True = doc1.title == doc2.title }) } @@ -90,8 +90,8 @@ pub fn roundtrip_preserves_title_test() { pub fn invalid_trust_levels_never_ok_test() { let invalid = [ - "none", "all", "safe", "high", "low", "admin", - "unknown", "", "trusted", "reviewed1", "aut0mated", + "none", "all", "safe", "high", "low", "admin", "unknown", "", "trusted", + "reviewed1", "aut0mated", ] list.each(invalid, fn(name) { @@ -109,9 +109,9 @@ pub fn directives_count_preserved_roundtrip_test() { "# Multi-Directive\n\n@version 1.5\n\n@author Alice\n\n@license MPL-2.0" let assert Ok(doc1) = parser.parse(input) - assert list.length(doc1.directives) == 3 + let assert True = list.length(doc1.directives) == 3 let rendered = renderer.render(doc1) let assert Ok(doc2) = parser.parse(rendered) - assert list.length(doc1.directives) == list.length(doc2.directives) + let assert True = list.length(doc1.directives) == list.length(doc2.directives) } diff --git a/test/a2ml_gleam_test.gleam b/test/a2ml_gleam_test.gleam index 8d5944a..0e452a7 100644 --- a/test/a2ml_gleam_test.gleam +++ b/test/a2ml_gleam_test.gleam @@ -4,8 +4,8 @@ import a2ml_gleam/parser import a2ml_gleam/renderer import a2ml_gleam/types.{ - Attestation, AttestationBlock, Automated, DirectiveBlock, Directive, - Heading, Paragraph, Reviewed, Text, Unverified, Verified, + Attestation, AttestationBlock, Automated, Directive, DirectiveBlock, Heading, + Paragraph, Reviewed, Text, Unverified, Verified, } import gleeunit @@ -19,12 +19,12 @@ pub fn main() -> Nil { pub fn parse_empty_input_test() { let result = parser.parse("") - assert result == Error(parser.EmptyInput) + let assert True = result == Error(parser.EmptyInput) } pub fn parse_title_only_test() { let assert Ok(doc) = parser.parse("# Hello A2ML") - assert doc.title == Ok("Hello A2ML") + let assert True = doc.title == Ok("Hello A2ML") } pub fn parse_heading_levels_test() { @@ -41,8 +41,13 @@ pub fn parse_paragraph_test() { pub fn parse_directive_test() { let assert Ok(doc) = parser.parse("@version 1.0") - let assert [DirectiveBlock(directive: Directive(name: "version", value: "1.0", attributes: []))] = - doc.blocks + let assert [ + DirectiveBlock(directive: Directive( + name: "version", + value: "1.0", + attributes: [], + )), + ] = doc.blocks let assert [Directive(name: "version", value: "1.0", attributes: [])] = doc.directives } @@ -61,16 +66,16 @@ pub fn parse_attestation_test() { "!attest\n identity: Alice\n role: reviewer\n trust-level: reviewed" let assert Ok(doc) = parser.parse(input) let assert [AttestationBlock(attestation: a)] = doc.blocks - assert a.identity == "Alice" - assert a.role == "reviewer" - assert a.trust_level == Reviewed + let assert True = a.identity == "Alice" + let assert True = a.role == "reviewer" + let assert True = a.trust_level == Reviewed } pub fn parse_full_document_test() { let input = "# Test Doc\n\n@version 2.0\n\nA paragraph here.\n\n!attest\n identity: Bob\n role: author\n trust-level: verified" let assert Ok(doc) = parser.parse(input) - assert doc.title == Ok("Test Doc") + let assert True = doc.title == Ok("Test Doc") let assert [Directive(name: "version", ..)] = doc.directives let assert [Attestation(identity: "Bob", ..)] = doc.attestations } @@ -80,10 +85,10 @@ pub fn parse_full_document_test() { // --------------------------------------------------------------------------- pub fn render_trust_level_test() { - assert renderer.render_trust_level(Unverified) == "unverified" - assert renderer.render_trust_level(Automated) == "automated" - assert renderer.render_trust_level(Reviewed) == "reviewed" - assert renderer.render_trust_level(Verified) == "verified" + let assert True = renderer.render_trust_level(Unverified) == "unverified" + let assert True = renderer.render_trust_level(Automated) == "automated" + let assert True = renderer.render_trust_level(Reviewed) == "reviewed" + let assert True = renderer.render_trust_level(Verified) == "verified" } pub fn render_attestation_test() { @@ -96,7 +101,8 @@ pub fn render_attestation_test() { note: Error(Nil), ) let rendered = renderer.render_attestation(a) - assert rendered + let assert True = + rendered == "!attest\n identity: Claude\n role: agent\n trust-level: automated\n timestamp: 2026-03-16T12:00:00Z" } @@ -106,7 +112,7 @@ pub fn render_roundtrip_test() { let output = renderer.render(doc) // Re-parse the output to verify structural equivalence. let assert Ok(doc2) = parser.parse(output) - assert doc.title == doc2.title + let assert True = doc.title == doc2.title } // --------------------------------------------------------------------------- @@ -117,6 +123,6 @@ pub fn extract_manifest_test() { let input = "# Manifest Test\n\n@version 3.0\n\n@author Alice" let assert Ok(doc) = parser.parse(input) let manifest = parser.extract_manifest(doc) - assert manifest.version == Ok("3.0") - assert manifest.title == Ok("Manifest Test") + let assert True = manifest.version == Ok("3.0") + let assert True = manifest.title == Ok("Manifest Test") }