diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index bc65a5e05..2b0a1d52b 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -156,9 +156,9 @@ for the canonical statement. | TypeScript | AffineScript | RS/TS/JS → AffineScript → typed-wasm. | | ReScript | AffineScript | RS/TS/JS → AffineScript → typed-wasm. | | **ReScript** | AffineScript | Banned in new code as of 2026-04-30. Existing `.res` files migrate to `.affine` directly (do not pass through ReScript). | -| **Deno** | Bun | **Being removed.** Owner ruling 2026-08-26: *"deno is to go and bun is the way we are going, put it first everywhere unless not possible and explain why if not."* Existing Deno projects must migrate to Bun; where Bun genuinely cannot be used, the reason must be documented in the repo. Assessment of all 30 remaining `deno.json` locations: #658. | +| **Deno** | Bun | **Banned 2026-09-22.** Owner ruling: *"deno is over, we're prioritising bun, and using bunx."* `deno.json` task definitions must be ported to `package.json` scripts. Shrink-only ledger: `.machine_readable/deno-allow.txt`. | | Node.js | Bun | Bun is Node-compatible; run the code, drop the runtime. | -| npm | Bun | npm is tier 4 — *permitted, never preferred*, not banned. `package-lock.json` must still not be tracked (standards#67). | +| npm | Bun | npm is tier 3 — *permitted, never preferred*, not banned. `package-lock.json` must still not be tracked (standards#67). | | yarn | Bun | yarn is not in the tier list at all. | | Go | Rust/SPARK | | | **Python** | AffineScript/Rust/SPARK/Julia | Fully banned, no exceptions (SaltStack exception removed 2026-01-03) | diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 531cdb6b8..22a54454f 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -53,10 +53,46 @@ run_validator() { return 0 } +# run_validator (above) is FAIL-OPEN by design: a missing validator script +# warns and returns 0. That is tolerable for advisory checks and intolerable +# for a secrets gate, where "the validator is absent" and "no secrets found" +# produce exactly the same silence. This sibling fails CLOSED. +run_validator_required() { + local label="$1" script="$2" scope="$3" + local target_files="" + [ "$scope" = "staged" ] && target_files=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null || true) + [ -z "$target_files" ] && [ "$scope" = "staged" ] && return 0 + if [ ! -f "$HOOK_DIR/$script" ]; then + echo -e "${RED}[pre-commit] ($label) REQUIRED validator '$script' is missing — refusing to pass a check that does not exist.${NC}" >&2 + echo " Restore it from https://github.com/hyperpolymath/standards/tree/main/.githooks" >&2 + ERRORS=$((ERRORS + 1)) + return 1 + fi + if [ ! -x "$HOOK_DIR/$script" ]; then + # A hook committed 0644 passes every local run that invokes it via `bash` + # and dies in CI at exit 126. Catch the mode here, where it is cheap. + echo -e "${YELLOW}[pre-commit] ($label) '$script' is not executable (mode should be 100755); running via bash anyway.${NC}" >&2 + fi + echo -e "${BLUE}[pre-commit]${NC} Running ${label}..." + if ! INPUT_PATH="$REPO_ROOT" INPUT_STAGED_FILES="$target_files" bash "$HOOK_DIR/$script"; then + ERRORS=$((ERRORS + 1)) + return 1 + fi + return 0 +} + echo -e "${BLUE}========================================${NC}" echo -e "${BLUE}Hyperpolymath Pre-commit Checks${NC}" echo -e "${BLUE}========================================${NC}" +# Secrets first: it is the cheapest check that prevents the most expensive +# mistake, and a leaked credential is unrecoverable once pushed. +run_validator_required "Staged secret scan (gitleaks)" "validate-gitleaks.sh" "staged" + +# Ecosystem-scoped lint + format. Read-only; confined to the ecosystems this +# repo actually has staged. +run_validator_required "Ecosystem lint + format" "validate-lint-format.sh" "staged" + # Language Policy default_validator '\.(ts|tsx)$' "TypeScript files not allowed. Use AffineScript instead." default_validator '\.go$' "Go files not allowed. Use Rust instead." diff --git a/.githooks/validate-gitleaks.sh b/.githooks/validate-gitleaks.sh new file mode 100755 index 000000000..e87d6616d --- /dev/null +++ b/.githooks/validate-gitleaks.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Hyperpolymath Estate — staged-secret scan (pre-commit) +# Source: https://github.com/hyperpolymath/standards +# +# Scans ONLY what is staged, so it runs in the time a commit can afford. +# +# FAILS CLOSED. If gitleaks is not installed this exits non-zero and prints the +# install line. It never returns 0 on "could not scan": "validator absent" and +# "no secrets found" produce exactly the same silence, and a gate that reports +# success having examined nothing is worse than no gate at all — it is a gate +# somebody trusts. +# +# --redact is NOT optional. --verbose without it prints the discovered secret +# to the terminal and into scrollback, CI logs and any `script`/tmux capture, +# which turns a near-miss into a second disclosure. + +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +REPO_ROOT="${INPUT_PATH:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +cd "$REPO_ROOT" + +# Print the denominator. A scan whose input size is never shown cannot be +# told apart from a scan of nothing. +STAGED_COUNT="$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -c . || true)" +echo "[gitleaks] staged files in scope: ${STAGED_COUNT}" + +if [ "${STAGED_COUNT}" -eq 0 ]; then + echo -e "${YELLOW}[gitleaks] nothing staged; no scan performed (this is not a pass).${NC}" + exit 0 +fi + +if ! command -v gitleaks >/dev/null 2>&1; then + echo -e "${RED}[gitleaks] gitleaks is NOT installed — refusing to let a commit through unscanned.${NC}" >&2 + echo "" >&2 + echo " Install one of:" >&2 + echo " brew install gitleaks" >&2 + echo " go install github.com/zricethezav/gitleaks/v8@latest" >&2 + echo " https://github.com/gitleaks/gitleaks/releases (pinned binary, verify sha256)" >&2 + echo "" >&2 + echo " Deliberate override for this one commit: git commit --no-verify" >&2 + exit 1 +fi + +# Gitleaks moved staged scanning between major versions: 8.x exposes +# `protect --staged`, and newer releases expose `git --staged` while hiding +# `protect`. PROBE for the subcommand instead of assuming either one. +# +# ⚠ Do not hard-code `gitleaks git` here. MEASURED 2026-09-22 on the installed +# binary, whose subcommands are exactly: completion, detect, help, protect, +# version. `gitleaks git` exits 1 as an unknown command — and because the +# failure branch below treats ANY non-zero exit as a finding, that reports +# "SECRET DETECTED" and refuses every commit while having scanned NOTHING. +# A gate that has scanned nothing must never be able to look like either a +# pass or a finding. +if gitleaks git --help >/dev/null 2>&1; then + GITLEAKS_STAGED=(gitleaks git --staged --verbose --redact) +elif gitleaks protect --help >/dev/null 2>&1; then + GITLEAKS_STAGED=(gitleaks protect --staged --verbose --redact) +else + echo -e "${RED}[gitleaks] installed gitleaks exposes neither 'git --staged' nor" >&2 + echo -e " 'protect --staged'. Refusing to report a pass from a scan that cannot run.${NC}" >&2 + exit 1 +fi + +if "${GITLEAKS_STAGED[@]}"; then + echo -e "${GREEN}[gitleaks] no secrets detected in ${STAGED_COUNT} staged file(s).${NC}" + exit 0 +fi + +echo "" >&2 +echo -e "${RED}[gitleaks] SECRET DETECTED in staged changes — commit refused.${NC}" >&2 +echo " Values above are redacted; the rule id and location are not." >&2 +echo "" >&2 +echo " If this is a real credential: rotate it FIRST, then unstage." >&2 +echo " If it is a false positive: add a scoped allow rule to .gitleaks.toml," >&2 +echo " or append the fingerprint to .gitleaksignore — never a blanket skip." >&2 +exit 1 diff --git a/.githooks/validate-lint-format.sh b/.githooks/validate-lint-format.sh new file mode 100755 index 000000000..9ab72d02c --- /dev/null +++ b/.githooks/validate-lint-format.sh @@ -0,0 +1,273 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Hyperpolymath Estate — ecosystem-scoped lint + format check (pre-commit) +# Source: https://github.com/hyperpolymath/standards +# +# CONFINEMENT IS THE POINT. Each check runs only when THIS repo has staged +# files of that kind. A Rust check must not fire in a ReScript repo, and a V +# toolchain must never be summoned to lint a Coq development. +# +# READ-ONLY. Every command here reports; none rewrites a tracked file. +# `cargo fmt --check`, `nickel format --check`, `v fmt -verify`. The +# bare/`-w`/`--fix` forms are banned in this file. +# +# TWO FAILURE POLICIES, and the difference is deliberate: +# +# * TOOLCHAIN MISSING, ecosystem toolchain (cargo, nickel, v, bun) → +# FAIL CLOSED. You cannot have authored a .rs without cargo, so "cargo not +# found" means a broken environment, not an exempt one. Skipping there +# would report success having examined nothing. +# +# * DENO is neither: it is BANNED, so no Deno toolchain is ever summoned. +# Its section refuses rather than checks — see it below. +# +# * OPTIONAL LINTER MISSING (hlint, fourmolu) → reported as an explicit +# SKIP line, not as a pass, and CI remains the authority. These are add-ons +# rather than the toolchain, so their absence is an ordinary state of a +# working machine and must not block every commit on every machine. +# +# Override for one commit: git commit --no-verify +# Skip the slow Rust clippy pass: ESTATE_HOOK_SKIP_SLOW=1 (prints a loud +# warning — the gate did NOT run, and that is not the same as passing). + +set -euo pipefail + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' + +REPO_ROOT="${INPUT_PATH:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +cd "$REPO_ROOT" + +STAGED="${INPUT_STAGED_FILES:-$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null || true)}" + +ERRORS=0 +LEDGER=() + +note() { echo -e "${BLUE}[lint]${NC} $*"; } +warn() { echo -e "${YELLOW}[lint] $*${NC}" >&2; } +fail() { echo -e "${RED}[lint] $*${NC}" >&2; ERRORS=$((ERRORS + 1)); } + +# Staged files matching an extended regex, one per line. +staged_matching() { + [ -z "$STAGED" ] && return 0 + echo "$STAGED" | grep -E "$1" || true +} + +# Files ADDED by this commit, as distinct from modified. A language ban is a ban +# on NEW sources: grandfathered files must still lint, but a newly added one is +# exactly the regression the ban exists to stop, so the two populations need +# different verdicts. A warning cannot stop a commit; only `fail` can. +# NOTE: reads git directly — INPUT_STAGED_FILES carries names but not statuses. +added_matching() { + git diff --cached --name-only --diff-filter=A 2>/dev/null | grep -E "$1" || true +} + +# Does this repo TRACK a marker file? Distinct from "is a file staged": +# `v.mod` is what makes a `.v` a V source rather than a Coq proof script. +tracks() { + git ls-files -- "$@" | grep -q . 2>/dev/null +} + +require_tool() { + local tool="$1" eco="$2" hint="$3" + if ! command -v "$tool" >/dev/null 2>&1; then + fail "${eco}: '${tool}' is not installed, so the ${eco} gate could not run. Refusing to pass a check that examined nothing. Install: ${hint}" + return 1 + fi + return 0 +} + +# ── Rust ──────────────────────────────────────────────────────────────── +RS="$(staged_matching '\.rs$|(^|/)Cargo\.toml$')" +# The ecosystem gate is the MANIFEST, not the extension. A stray `.rs` staged in +# a ReScript tree with no Cargo.toml would otherwise run `cargo fmt --all`, which +# exits non-zero with "error: could not find `Cargo.toml`" — a RED gate for a +# reason that has nothing to do with the code. Confinement means the Rust gate +# does not fire in a non-Rust repo at all. +# `tracks` reads the INDEX, so a Cargo.toml added in THIS commit does count. +if [ -n "$RS" ] && tracks 'Cargo.toml' '*/Cargo.toml'; then + note "Rust: $(echo "$RS" | grep -c .) staged file(s)" + if require_tool cargo Rust "https://rustup.rs"; then + cargo fmt --all --check || fail "Rust: sources are not formatted (cargo fmt --check)" + if [ "${ESTATE_HOOK_SKIP_SLOW:-0}" = "1" ]; then + warn "Rust: clippy SKIPPED via ESTATE_HOOK_SKIP_SLOW=1. A skip is not a pass — CI will still run it." + LEDGER+=("rust-clippy: SKIPPED (ESTATE_HOOK_SKIP_SLOW)") + else + cargo clippy --all-targets -- -D warnings || fail "Rust: clippy reported warnings (treated as errors)" + fi + fi +else + LEDGER+=("rust: not staged, or repo tracks no Cargo.toml") +fi + +# ── Nickel ────────────────────────────────────────────────────────────── +NCL="$(staged_matching '\.ncl$')" +if [ -n "$NCL" ]; then + note "Nickel: $(echo "$NCL" | grep -c .) staged file(s)" + if require_tool nickel Nickel "https://github.com/tweag/nickel/releases"; then + while IFS= read -r f; do + [ -z "$f" ] && continue + nickel format --check "$f" || fail "Nickel: $f is not formatted" + # Only these generated artefacts may be missing at commit time. Matching + # every "could not find import" instead would let a TYPO in a new import + # pass the gate as though it were a generated file. + NCL_GENERATED_IMPORTS='claude-md-data\.json' + # A Nickel file may `import` a build-time artefact that is GITIGNORED and + # generated — here machine-readable/arrival-pack/arrival-pack.ncl imports + # claude-md-data.json, which extract.sh produces during generate.sh. A + # pre-commit hook cannot run the repo's build, so typechecking such a file + # is PERMANENTLY red: a gate no edit can satisfy. Report the skip loudly + # and name the file — a skip is not a pass. Every other typecheck error + # still fails, so this narrows the gate rather than disabling it. + if tc_out="$(nickel typecheck "$f" 2>&1)"; then + : + elif printf '%s' "$tc_out" | grep -q 'could not find import' \ + && printf '%s' "$tc_out" | grep -qE "$NCL_GENERATED_IMPORTS"; then + warn "Nickel: $f NOT typechecked — unresolved GENERATED import. A SKIP, not a pass." + else + printf '%s\n' "$tc_out" >&2 + fail "Nickel: $f failed typecheck" + fi + done <<< "$NCL" + fi +else + LEDGER+=("nickel: not staged") +fi + +# ── Deno (BANNED — a REFUSAL, not a lint) ─────────────────────────────── +# +# Owner ruling: "deno is over, we're prioritising bun, and using bunx." +# +# This section used to install nothing and run `deno fmt --check` / `deno lint` +# over staged JS in a tree that tracked a deno.json, behind a `warn`. That +# lint-checked — and thereby blessed — the very thing the warning called +# banned, and it summoned a banned toolchain to do it. +# +# It now refuses. The question it asks is deliberately NARROWER than CI's: +# +# * CI asks "does this repository still carry Deno debt?", and answers it +# against the central shrink-only ledger +# `.machine_readable/deno-allow.txt` in `standards`. +# * This hook CANNOT ask that — it runs inside a caller's checkout and has +# no access to that ledger — and must not fake an answer. It asks instead +# "is THIS COMMIT ADDING Deno debt?", which is answerable right here. +# +# STAGED is `--diff-filter=ACM`, so a DELETED deno.json never appears: paying +# the debt down is never blocked, only growing it. That is the same +# shrink-only direction the central ledger enforces, reached without one. +DENO_CFG="$(staged_matching '(^|/)deno\.jsonc?$')" +DENO_TRACKED="$(git ls-files -- 'deno.json' '*/deno.json' 'deno.jsonc' '*/deno.jsonc' | grep -c . || true)" +if [ -n "$DENO_CFG" ]; then + fail "Deno is BANNED (owner ruling 2026-08-26 — Bun is the estate runtime). This commit ADDS or MODIFIES $(echo "$DENO_CFG" | grep -c .) Deno config file(s):" + while IFS= read -r cfg; do [ -n "$cfg" ] && echo " - $cfg" >&2; done <<< "$DENO_CFG" + echo " Port the deno.json 'tasks' map to package.json scripts run by" >&2 + echo " 'bun run' — 'bun run' does NOT read a deno.json tasks map — and" >&2 + echo " delete the config. Deleting a deno.json is never blocked here." >&2 + echo " A genuinely grandfathered repository is exempted CENTRALLY, in" >&2 + echo " .machine_readable/deno-allow.txt in hyperpolymath/standards, not" >&2 + echo " by a local bypass." >&2 +else + LEDGER+=("deno: no deno.json(c) added or modified (repo tracks ${DENO_TRACKED}; the ledgered CI gate is the authority on existing debt)") +fi + +# ⚠ Dropping the Deno lint leaves staged `.js`/`.jsx`/`.mjs`/`.cjs` with NO +# pre-commit gate: the estate has no pinned bun lint/format gate to fall +# through to. Declared here rather than left silent — an unchecked file type +# that nobody names reads as a checked one. +JS_SRC="$(staged_matching '\.(js|jsx|mjs|cjs)$')" +if [ -n "$JS_SRC" ]; then + LEDGER+=("javascript: $(echo "$JS_SRC" | grep -c .) staged file(s) NOT checked — no pinned bun lint/format gate exists. A skip, not a pass.") +fi + +# ── ReScript (BANNED for new code; migrates to AffineScript) ──────────── +RES="$(staged_matching '\.resi?$')" +# Same confinement as Rust: `bunx rescript` needs a project manifest to resolve +# sources. Without one it fails for a reason unrelated to the staged file. +if [ -n "$RES" ] && tracks 'rescript.json' '*/rescript.json' 'bsconfig.json' '*/bsconfig.json'; then + warn "ReScript is BANNED for new code (owner ruling 2026-04-30). Existing .res migrates to .affine — AffineScript, not TypeScript." + note "ReScript: $(echo "$RES" | grep -c .) staged file(s)" + RES_ADDED="$(added_matching '\.resi?$')" + if [ -n "$RES_ADDED" ]; then + fail "ReScript is BANNED for new code: $(echo "$RES_ADDED" | grep -c .) NEWLY ADDED .res/.resi file(s). Migrate to AffineScript (.affine). Modified grandfathered files are still linted below." + fi + if require_tool bunx ReScript "https://bun.sh"; then + if bunx rescript format --help 2>&1 | grep -q -- '-check'; then + bunx rescript format -all -check || fail "ReScript: sources are not formatted" + else + warn "ReScript: the installed CLI has no 'format -check'; the format gate did NOT run. This is a skip, not a pass." + LEDGER+=("rescript-format: SKIPPED (CLI lacks -check)") + fi + fi +else + LEDGER+=("rescript: not staged, or repo tracks no rescript.json/bsconfig.json") +fi + +# ── V (BANNED; Zig migration completed 2026-05-28) ────────────────────── +V_SRC="$(staged_matching '\.v$')" +if [ -n "$V_SRC" ]; then + if tracks 'v.mod' '*/v.mod'; then + warn "V-lang is BANNED (owner ruling 2026-04-10; estate migration to Zig COMPLETED 2026-05-28). A v.mod here means a carve-out or a regression — confirm which." + note "V: $(echo "$V_SRC" | grep -c .) staged file(s)" + V_ADDED="$(added_matching '\.v$')" + if [ -n "$V_ADDED" ]; then + fail "V is BANNED: $(echo "$V_ADDED" | grep -c .) NEWLY ADDED .v file(s). The estate completed its Zig migration on 2026-05-28. Modified carve-out files are still linted below." + fi + if require_tool v V "https://github.com/vlang/v/releases"; then + # ⚠ MEASURED on v 0.5.2: `v fmt -verify ` prints its findings and + # then EXITS 0 — a fake green. Only the DIRECTORY form exits 1. Do not + # rewrite this as a per-file loop. + v fmt -verify . || fail "V: sources are not formatted (v fmt -verify)" + v vet . || fail "V: v vet reported problems" + fi + else + # `.v` is shared with Coq proof scripts and Verilog. Without a v.mod this + # is NOT V source, and summoning a V toolchain for it would be wrong. + note "V: .v staged but no v.mod tracked — treating as Coq/Verilog, not V. No V check run." + LEDGER+=("v: .v staged without v.mod (Coq/Verilog — correctly not checked)") + fi +else + LEDGER+=("v: not staged") +fi + +# ── Haskell ───────────────────────────────────────────────────────────── +HS="$(staged_matching '\.hs$')" +if [ -n "$HS" ] && tracks '*.cabal' 'stack.yaml' '*/stack.yaml'; then + note "Haskell: $(echo "$HS" | grep -c .) staged file(s)" + RAN_ANY=0 + if command -v fourmolu >/dev/null 2>&1; then + # shellcheck disable=SC2086 + fourmolu --mode check $HS || fail "Haskell: sources are not formatted (fourmolu --mode check)" + RAN_ANY=1 + fi + if command -v hlint >/dev/null 2>&1; then + # shellcheck disable=SC2086 + hlint $HS || fail "Haskell: hlint reported problems" + RAN_ANY=1 + fi + if [ "$RAN_ANY" -eq 0 ]; then + # Optional linters, not the toolchain — see the header. Reported as a + # skip so it cannot be mistaken for a clean result. + warn "Haskell: neither fourmolu nor hlint is installed, so NO Haskell gate ran. This is a skip, not a pass — CI still checks with -Wall -Werror." + LEDGER+=("haskell: SKIPPED (no fourmolu/hlint installed)") + fi +else + LEDGER+=("haskell: not staged or repo has no cabal/stack manifest") +fi + +# ── Ledger ────────────────────────────────────────────────────────────── +# Make every non-run legible. An absent check must never read as a satisfied +# one just because the terminal stayed quiet. +if [ "${#LEDGER[@]}" -gt 0 ]; then + echo "" + echo -e "${BLUE}[lint] gates that did not run:${NC}" + for l in "${LEDGER[@]}"; do echo " - $l"; done +fi + +if [ "$ERRORS" -gt 0 ]; then + echo -e "${RED}[lint] FAILED with ${ERRORS} error(s).${NC}" >&2 + echo " Fix locally with the ecosystem's own formatter, then re-stage." >&2 + echo " Deliberate override for this one commit: git commit --no-verify" >&2 + exit 1 +fi + +echo -e "${GREEN}[lint] ecosystem lint + format checks passed.${NC}" +exit 0 diff --git a/.githooks/validate-sha-pins.sh b/.githooks/validate-sha-pins.sh index 6e7e84381..63c4266b7 100755 --- a/.githooks/validate-sha-pins.sh +++ b/.githooks/validate-sha-pins.sh @@ -44,11 +44,18 @@ is_vendored() { case "$1" in *"${VENDORED_MARK}"*) return 0 ;; *) return 1 ;; es # this repo's own composite actions and `docker://` refs are container images, not # actions -- neither is modelled by actions.lock, which keys actions only. UNPINNED_FILTER() { - grep -nE '^[[:space:]]*(-[[:space:]]*)?uses:[[:space:]]+[A-Za-z0-9]' \ - | grep -vE 'uses:[[:space:]]+[./]' \ - | grep -vE 'uses:[[:space:]]+docker://' \ - | grep -vE 'uses:[[:space:]]+[^[:space:]@]+@[0-9a-f]{40}([^0-9a-f]|$)' \ - || true + # `$/...` is NOT valid `uses:` syntax (GitHub Actions has no such thing) — + # `gh actions-lock` REWRITE MODE once invented `uses: $/.github/actions/...` + # and every workflow carrying it died at startup. The alnum-first selector + # below would silently skip such lines, so they are flagged explicitly: + # waving that corruption through is the exact failure this gate exists to + # catch. (Zero matches tree-wide today; this arm is purely prospective.) + { grep -nE '^[[:space:]]*(-[[:space:]]*)?uses:[[:space:]]+[A-Za-z0-9]' \ + | grep -vE 'uses:[[:space:]]+[./]' \ + | grep -vE 'uses:[[:space:]]+docker://' \ + | grep -vE 'uses:[[:space:]]+[^[:space:]@]+@[0-9a-f]{40}([^0-9a-f]|$)' \ + || true; + grep -nE 'uses:[[:space:]]+\$/' || true; } } validate_file() { diff --git a/.githooks/validate-spdx.sh b/.githooks/validate-spdx.sh index 53a194b98..54c33adcf 100755 --- a/.githooks/validate-spdx.sh +++ b/.githooks/validate-spdx.sh @@ -6,6 +6,7 @@ set -euo pipefail SCAN_PATH="${INPUT_PATH:-.}" STAGED_FILES="${INPUT_STAGED_FILES:-}" ERRORS=0 +CHECKED=0 # The single authority for "does this path need an SPDX header". # @@ -25,10 +26,16 @@ ERRORS=0 # # Keep ONE list. If a mode ever needs a different rule, that is a new function # with a name saying so, never a second copy of these patterns. +# +# ⚠ *.json is NOT here and must never be re-added. JSON has no comment syntax +# at all, so a JSON file cannot carry an inline SPDX header in any form. The +# 58 tracked .json files were therefore failing a check no edit could satisfy. +# REUSE covers them with a sidecar `.license`, which is a different +# check and belongs in a differently-named function. is_source_file() { case "$1" in *.rs|*.res|*.js|*.ts|*.sh|*.bash|*.zig|*.ex|*.exs|*.gleam|\ - *.ml|*.mli|*.adb|*.ads|*.ncl|*.toml|*.json|*.yaml|*.yml) return 0 ;; + *.ml|*.mli|*.adb|*.ads|*.ncl|*.toml|*.yaml|*.yml|*.scm) return 0 ;; *) return 1 ;; esac } @@ -48,13 +55,40 @@ for file in $FILES_TO_CHECK; do [ -f "$file" ] || continue is_source_file "$file" || continue - # Check for SPDX header in first 10 lines - if ! head -10 "$file" | grep -qE '^# SPDX-License-Identifier:'; then + CHECKED=$((CHECKED + 1)) + + # Check for an SPDX header in the first 10 lines, in ANY of the comment + # syntaxes the extension list above actually admits. + # + # ⚠ This used to test `^# SPDX-License-Identifier:` alone, which is a + # question most of the listed languages cannot answer: Rust, JS, TS, Zig, + # ReScript and Gleam comment with `//`, OCaml with `(* *)`, Ada with `--`. + # Measured on this repository at the time of the fix, 39 files ALREADY + # carried a correct SPDX header in their own syntax and were being reported + # as violations — .zig 15/15, .ml 6/6, .js 11/15, .ads 1/1, .adb 1/2, .rs + # 5/41. The gate was not merely impossible for them, it was inverted. + # + # Still a HEADER check, deliberately: the marker must open the line. A bare + # `SPDX-License-Identifier:` anywhere in the first 10 lines would match + # prose, and a licence mentioned in a docstring is not a licence grant. + # The expression itself is validated, not just the marker: an empty + # identifier or trailing junk (`MPL-2.0; copyright`) passes a prefix + # check but fails SPDX tooling. Require a non-empty expression (bare id + # or OR/AND/WITH compound) occupying the rest of the line, with only + # the applicable comment terminator (`*)`, `*/`) after it. Copyright + # data belongs on its own SPDX-FileCopyrightText line. `;` is Scheme's + # comment marker (*.scm). + if ! head -10 "$file" | grep -qE '^[[:space:]]*(#|//|--|;+|\(\*|/\*|\*)[[:space:]]*SPDX-License-Identifier:[[:space:]]*[A-Za-z0-9][A-Za-z0-9_.+:-]*([[:space:]]+(OR|AND|WITH)[[:space:]]+[A-Za-z0-9][A-Za-z0-9_.+:-]*)*[[:space:]]*(\*\)|\*/)?[[:space:]]*$'; then echo "[validate-spdx] ERROR: $file missing SPDX header" >&2 ERRORS=$((ERRORS + 1)) fi done -[ $ERRORS -gt 0 ] && exit 1 -echo "[validate-spdx] ✅ All source files have SPDX headers" +# Always print the denominator: "0 errors" out of 0 files examined is a +# vacuous pass, and it must not read the same as a real one. +if [ $ERRORS -gt 0 ]; then + echo "[validate-spdx] $ERRORS of $CHECKED source files are missing an SPDX header" >&2 + exit 1 +fi +echo "[validate-spdx] ✅ All $CHECKED source files have SPDX headers" exit 0 diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 76b8042d0..189a2f36f 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -31,6 +31,10 @@ workflows: '.github/workflows/check-suite-monitor.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3' + '.github/workflows/ci-pipeline.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'haskell-actions/setup@6037f33647c3f17758a2356c80fc4a53d7e0685d' + - 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' '.github/workflows/codeql-reusable.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'github/codeql-action@1c5b675653bb5c22dbe9b12b556ec555138e09fd' @@ -52,6 +56,8 @@ workflows: - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124' '.github/workflows/elixir-ci.yml': [] + '.github/workflows/github-backup-mirror-reusable.yml': + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/governance-reusable.yml': - 'actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' @@ -248,6 +254,11 @@ dependencies: commit: 'sha1-2d1146689b8cda280b9bc96326124645441f03bc' owner_id: 67707773 repo_id: 421101922 + 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6': + ref: 'v2.2.0' + commit: 'sha1-0c5077e51419868618aeaa5fe8019c62421857d6' + owner_id: 108928776 + repo_id: 512644635 'peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697': ref: 'v4.0.1' commit: 'sha1-28959ce8df70de7be546dd1250a005dd32156697' diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml new file mode 100644 index 000000000..d831065c7 --- /dev/null +++ b/.github/workflows/ci-pipeline.yml @@ -0,0 +1,758 @@ +# SPDX-License-Identifier: MPL-2.0 +# +# CI Pipeline (reusable) — secrets, SAST, per-ecosystem lint/format, coverage. +# +# Consumed by both @hyperpolymath and @metadatastician repos through a thin +# caller. Everything here is READ-ONLY verification: no `--fix`, no bare `fmt`, +# no command that rewrites a tracked file. A CI run must never author a diff. +# +# Three design decisions worth reading before editing: +# +# 1. DETECTION IS PER-REPO, AND THE DENOMINATOR IS PRINTED. +# Each ecosystem job is gated on files this repo actually tracks. A repo +# with no recognised ecosystem FAILS `detect` rather than sailing through +# green having checked nothing — a gate pointed at zero files is not a +# pass. `detect` prints the count it matched per ecosystem, so the +# denominator is always visible, never inferred. +# +# 2. RUST DELEGATES; IT IS NOT REIMPLEMENTED. +# `rust-ci-reusable.yml` already runs `cargo check --locked`, +# `cargo fmt --all -- --check` and `cargo clippy --locked -D warnings`, +# and already does coverage via cargo-llvm-cov against a ratchet floor, +# writing to $GITHUB_STEP_SUMMARY with no external service. Duplicating it +# here would create a second, drifting copy. Nested reusable refs are +# fully qualified (never `./`) because `./` resolution inside a nested +# reusable called from another repo is not worth a startup failure. +# +# 3. SAST FINDINGS ARE ADVISORY; SAST VACUITY IS FATAL. +# Owner standing ruling: a NEW scanner finding does not block a merge, it +# becomes an issue with acceptance criteria. So semgrep findings are +# reported, not thrown — unless `sast_blocking: true`. What IS fatal, and +# unconditionally so, is semgrep scanning ZERO files: `p/default` has thin +# Rust coverage, and a 0-file scan would otherwise report a cheerful green +# having examined nothing at all. +# +# Language-policy note: ReScript, V-lang and Deno are BANNED for new code +# (.claude/CLAUDE.md, owner rulings 2026-04-30 / 2026-04-10 / 2026-08-26). +# The ReScript and V jobs serve grandfathered trees still in migration and emit +# a ::warning:: saying so — a migration courtesy, not a licence. +# +# DENO IS DIFFERENT, as of 2026-09-22. Its job no longer lints; it REFUSES, +# and the only way past it is the central shrink-only ledger +# `.machine_readable/deno-allow.txt` in `standards`. A ::warning:: cannot fail +# a job, so the old shape lint-checked — and thereby blessed — the very thing +# it called banned. + +name: CI Pipeline (reusable) + +on: + workflow_call: + inputs: + runs-on: + description: Runner label for every job in this pipeline. + type: string + default: ubuntu-latest + enable_sast: + description: Run the semgrep SAST job. + type: boolean + default: true + sast_blocking: + description: >- + Fail the build on semgrep findings. Default false per the owner's + standing ruling that a new scanner finding is an issue, not a + blocker. The zero-files-scanned check is fatal regardless. + type: boolean + default: false + enable_coverage: + description: Pass through to rust-ci-reusable.yml. + type: boolean + default: true + coverage_floor: + description: >- + Minimum coverage percentage. Ratchet upward, never lower. + type: string + default: '0' + fail_on_no_ecosystem: + description: >- + Fail `detect` when no known ecosystem is found. Leave true: this is + the guard against a pipeline that passes by matching nothing. + type: boolean + default: true + +permissions: + contents: read + +jobs: + # ─────────────────────────────────────────────────────────────────────── + # detect — the denominator + # ─────────────────────────────────────────────────────────────────────── + detect: + name: Detect ecosystems + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 5 + permissions: + contents: read + outputs: + has_rust: ${{ steps.scan.outputs.has_rust }} + has_nickel: ${{ steps.scan.outputs.has_nickel }} + has_rescript: ${{ steps.scan.outputs.has_rescript }} + has_v: ${{ steps.scan.outputs.has_v }} + has_haskell: ${{ steps.scan.outputs.has_haskell }} + has_deno: ${{ steps.scan.outputs.has_deno }} + # The Deno gate REFUSES and names what it found, so it needs the + # count itself, not merely the boolean. + n_deno: ${{ steps.scan.outputs.n_deno }} + total: ${{ steps.scan.outputs.total }} + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Scan tracked files for ecosystem markers + id: scan + shell: bash + run: | + set -euo pipefail + + # `git ls-files` lists TRACKED files only. `find` would descend into + # vendored trees (node_modules/, deps/, target/) and report an + # ecosystem this repo does not own. + count() { + git ls-files -z -- "$@" | tr -cd '\0' | wc -c + } + + N_RUST=$(count 'Cargo.toml' '*/Cargo.toml') + N_NICKEL=$(count '*.ncl') + N_RESCRIPT=$(count 'rescript.json' '*/rescript.json' 'bsconfig.json' '*/bsconfig.json' '*.res') + # V is gated on `v.mod` ALONE. The `.v` extension is shared with Coq + # proof scripts and Verilog sources, so globbing `*.v` would install + # a V toolchain to lint a Coq development. + N_V=$(count 'v.mod' '*/v.mod') + N_HASKELL=$(count '*.cabal' 'stack.yaml' '*/stack.yaml' '*.hs') + N_DENO=$(count 'deno.json' '*/deno.json' 'deno.jsonc' '*/deno.jsonc') + + TOTAL=$(( N_RUST + N_NICKEL + N_RESCRIPT + N_V + N_HASKELL + N_DENO )) + + bool() { [ "$1" -gt 0 ] && echo true || echo false; } + + { + echo "has_rust=$(bool "$N_RUST")" + echo "has_nickel=$(bool "$N_NICKEL")" + echo "has_rescript=$(bool "$N_RESCRIPT")" + echo "has_v=$(bool "$N_V")" + echo "has_haskell=$(bool "$N_HASKELL")" + echo "has_deno=$(bool "$N_DENO")" + echo "n_deno=$N_DENO" + echo "total=$TOTAL" + } >> "$GITHUB_OUTPUT" + + # Print the denominator. A gate that never shows what it measured + # cannot be distinguished from a gate that measured nothing. + { + echo "### Ecosystem detection" + echo "" + echo "| Ecosystem | Marker | Tracked files matched |" + echo "|---|---|---|" + echo "| Rust | \`Cargo.toml\` | ${N_RUST} |" + echo "| Nickel | \`*.ncl\` | ${N_NICKEL} |" + echo "| ReScript | \`rescript.json\`, \`bsconfig.json\`, \`*.res\` | ${N_RESCRIPT} |" + echo "| V | \`v.mod\` only | ${N_V} |" + echo "| Haskell | \`*.cabal\`, \`stack.yaml\`, \`*.hs\` | ${N_HASKELL} |" + echo "| Deno | \`deno.json(c)\` | ${N_DENO} |" + echo "| **Total** | | **${TOTAL}** |" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + + if [ "$TOTAL" -eq 0 ]; then + if [ "${{ inputs.fail_on_no_ecosystem }}" = "true" ]; then + echo "::error::No known ecosystem detected (denominator = 0). Every lint and format gate would be vacuous, so this pipeline refuses to report success. Add the ecosystem's marker file, or call this workflow with fail_on_no_ecosystem: false and say why." + echo "**REFUSED: denominator is zero — no ecosystem matched, so nothing could be checked.**" >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + echo "::warning::No known ecosystem detected; fail_on_no_ecosystem is false, so continuing with nothing to check." + fi + + # ─────────────────────────────────────────────────────────────────────── + # Job 1a — secret scanning (delegated: pinned, sha256-verified gitleaks) + # ─────────────────────────────────────────────────────────────────────── + secret-scan: + name: Secret scanning + # Deliberately NOT gated on `detect`: a repo with no recognised ecosystem + # can still be leaking credentials. + uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@571cc734cd69fb846032ec77a662aa8ee4fc32cd + with: + runs-on: ${{ inputs.runs-on }} + + # ─────────────────────────────────────────────────────────────────────── + # Job 1b — SAST (semgrep OSS rulesets, no account, no telemetry) + # ─────────────────────────────────────────────────────────────────────── + sast: + name: SAST (semgrep) + if: ${{ inputs.enable_sast }} + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 20 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Run semgrep (p/default + p/security-audit) + id: semgrep + shell: bash + env: + # Digest resolved from the Docker Hub registry, not copied from a + # tag. A tag is mutable; this is the image or nothing. + SEMGREP_IMAGE: semgrep/semgrep@sha256:10301f060aacf84078f9704fb1ba3a321df4ac46b009fd29c1c66880d1db8e77 + run: | + set -uo pipefail + + # Run through `docker run` rather than a job-level `container:` so + # that checkout and the jq post-processing happen on the runner, + # where jq is preinstalled. The semgrep image's own tooling is not + # relied upon for anything but semgrep itself. + # + # --metrics off: OSS rulesets, no account, nothing phoned home. + # A config that fails to resolve makes semgrep exit non-zero, so + # "the ruleset did not download" cannot masquerade as "clean". + set +e + docker run --rm \ + -v "${PWD}:/src" -w /src \ + "$SEMGREP_IMAGE" \ + semgrep scan \ + --config p/default \ + --config p/security-audit \ + --metrics off \ + --disable-version-check \ + --json --output /src/semgrep.json \ + 2> "$RUNNER_TEMP/semgrep.err" + SEMGREP_RC=$? + set -e + + echo "semgrep exit code: $SEMGREP_RC" + echo "--- semgrep stderr (rule loading and scan totals) ---" + cat "$RUNNER_TEMP/semgrep.err" || true + echo "-----------------------------------------------------" + + if [ ! -s semgrep.json ]; then + echo "::error::semgrep produced no JSON report (exit $SEMGREP_RC). Treating as a scan failure, not as a clean result." + echo "### SAST (semgrep): FAILED — no report produced" >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + # A report can exist and still be PARTIAL: semgrep exits non-zero on + # rule-loading and scan errors while still writing what it managed to + # collect. Reading only the JSON would report that truncated scan as a + # clean tree. The exit code is the only signal that separates them. + if [ "$SEMGREP_RC" -ne 0 ]; then + echo "::error::semgrep exited $SEMGREP_RC. A partial report is not a clean result." + echo "### SAST (semgrep): FAILED — scanner exited $SEMGREP_RC" >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + SCANNED=$(jq '(.paths.scanned // []) | length' semgrep.json) + FINDINGS=$(jq '(.results // []) | length' semgrep.json) + RULES_FIRED=$(jq '[(.results // [])[].check_id] | unique | length' semgrep.json) + ERRORS=$(jq '(.errors // []) | length' semgrep.json) + + { + echo "### SAST (semgrep)" + echo "" + echo "| Measure | Value |" + echo "|---|---|" + echo "| Files scanned | ${SCANNED} |" + echo "| Findings | ${FINDINGS} |" + echo "| Distinct rules fired | ${RULES_FIRED} |" + echo "| Scan errors | ${ERRORS} |" + echo "| Rulesets | \`p/default\`, \`p/security-audit\` |" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + + # THE LOAD-BEARING ASSERTION. Zero files scanned means every rule in + # both rulesets was applied to nothing, which produces exactly the + # same green tick as a genuinely clean tree. + if [ "$SCANNED" -eq 0 ]; then + echo "::error::semgrep scanned 0 files. Zero findings over zero files is not a clean result — it is no result. Failing rather than reporting a vacuous pass." + echo "**REFUSED: 0 files scanned — a vacuous pass, not a clean tree.**" >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + if [ "$FINDINGS" -gt 0 ]; then + { + echo "| Severity | Rule | File | Line |" + echo "|---|---|---|---|" + jq -r '(.results // [])[:100][] + | "| \(.extra.severity // "INFO") | `\(.check_id)` | `\(.path)` | \(.start.line) |"' \ + semgrep.json + echo "" + } >> "$GITHUB_STEP_SUMMARY" + + if [ "${{ inputs.sast_blocking }}" = "true" ]; then + echo "::error::semgrep reported ${FINDINGS} finding(s) and sast_blocking is true." + exit 1 + fi + # Advisory by owner ruling: a new scanner finding is an issue with + # acceptance criteria, not a merge blocker. The findings are in the + # step summary above, so this is visible rather than swallowed. + echo "::warning::semgrep reported ${FINDINGS} finding(s). Advisory by standing ruling — raise an issue with acceptance criteria. Set sast_blocking: true to gate on these." + fi + + # ─────────────────────────────────────────────────────────────────────── + # Job 2/3 — Rust: lint, format, build, test, coverage (delegated) + # ─────────────────────────────────────────────────────────────────────── + rust: + name: Rust + needs: detect + if: ${{ needs.detect.outputs.has_rust == 'true' }} + uses: hyperpolymath/standards/.github/workflows/rust-ci-reusable.yml@571cc734cd69fb846032ec77a662aa8ee4fc32cd + with: + runs-on: ${{ inputs.runs-on }} + enable_coverage: ${{ inputs.enable_coverage }} + coverage_floor: ${{ inputs.coverage_floor }} + # Read-only. `-D warnings` promotes every warning to an error, which is + # the "fail on warnings" the spec asked for. + clippy_args: '--all-targets -- -D warnings' + + # ─────────────────────────────────────────────────────────────────────── + # Job 2 — Nickel + # ─────────────────────────────────────────────────────────────────────── + nickel: + name: Nickel + needs: detect + if: ${{ needs.detect.outputs.has_nickel == 'true' }} + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install Nickel (pinned, sha256-verified) + shell: bash + env: + NICKEL_VERSION: '1.18.0' + # Computed locally from the release asset, not transcribed from a + # web page. Same discipline as secret-scanner-reusable.yml's gitleaks + # pin: a download whose bytes are not checked is not a pin. + NICKEL_SHA256: '9cba4dd65ae9915ec61f73033aafcff307a377665a83fd8f530df086763318cb' + run: | + set -euo pipefail + URL="https://github.com/tweag/nickel/releases/download/${NICKEL_VERSION}/nickel-x86_64-linux" + curl -sSfL --proto '=https' --proto-redir '=https' --tlsv1.2 --retry 3 -o "$RUNNER_TEMP/nickel" "$URL" + echo "${NICKEL_SHA256} ${RUNNER_TEMP}/nickel" | sha256sum -c - + chmod +x "$RUNNER_TEMP/nickel" + echo "$RUNNER_TEMP" >> "$GITHUB_PATH" + + - name: Nickel format check + typecheck + shell: bash + run: | + set -euo pipefail + mapfile -d '' FILES < <(git ls-files -z -- '*.ncl') + echo "Nickel files: ${#FILES[@]}" + if [ "${#FILES[@]}" -eq 0 ]; then + echo "::error::detect said Nickel was present but zero .ncl files are tracked here. Refusing to report a pass over an empty set." + exit 1 + fi + FAILED=0 + for f in "${FILES[@]}"; do + # --check is read-only: it reports, it does not rewrite the file. + nickel format --check "$f" || { echo "::error file=$f::not formatted (nickel format --check)"; FAILED=1; } + nickel typecheck "$f" || { echo "::error file=$f::failed nickel typecheck"; FAILED=1; } + done + { + echo "### Nickel" + echo "" + echo "- files checked: **${#FILES[@]}**" + echo "- result: $( [ "$FAILED" -eq 0 ] && echo 'pass' || echo '**FAIL**' )" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + exit "$FAILED" + + # ─────────────────────────────────────────────────────────────────────── + # Job 2 — Deno REFUSAL gate (BANNED; shrink-only ledger exemption) + # ─────────────────────────────────────────────────────────────────────── + # + # Owner ruling: "deno is over, we're prioritising bun, and using bunx." + # This job used to install a pinned Deno binary and run `deno lint` / + # `deno fmt --check`, announcing the ban with a `::warning::`. A + # `::warning::` CANNOT fail a job, so that job asserted the opposite of what + # it said: it lint-checked, and thereby blessed, the very thing it called + # banned. It now REFUSES, and the only way past it is the central + # shrink-only ledger `.machine_readable/deno-allow.txt` in `standards`. + # + # Both outcomes print their denominator. A gate that only ever says yes + # proves nothing, and an exemption that reports nothing is a silent pass. + deno: + name: Deno (banned) + needs: detect + if: ${{ needs.detect.outputs.has_deno == 'true' }} + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # The ledger is CENTRAL: one file in `standards`, not a per-repo opt-out, + # so the estate's Deno debt is countable from one place and the ratchet + # can forbid its growth. Pinned to an immutable commit for the same + # reason as the governance helpers — `github.workflow_sha` resolves to + # the CALLER's commit, and following `main` would let an edit in + # `standards` change the verdict of every already-pinned caller with no + # review in their repositories. + - name: Checkout the pinned Standards Deno ledger + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: hyperpolymath/standards + # 938264d8 is the commit that introduced the ledger. ⚠ BUMP THIS + # whenever .machine_readable/deno-allow.txt changes, or the gate goes + # on judging callers against a stale list. A stale list is SAFE in + # one direction only: a repository that migrated off Deno stops + # tracking deno.json, so the job does not run at all and its lingering + # exemption never fires. A repository that is ADDED to the ledger + # stays red here until this pin moves — which is the correct default, + # because the addition needs a Ratchet-exception trailer anyway. + ref: 938264d8b0bbc3ca079e3d02efff5c1d423d0329 + path: .standards-deno-ledger + sparse-checkout: | + .machine_readable/deno-allow.txt + sparse-checkout-cone-mode: false + # Not fatal here: the next step names precisely what was missing and + # then FAILS CLOSED. A fetch failure must never read as an exemption. + continue-on-error: true + + - name: Refuse Deno unless this repository is ledgered + shell: bash + env: + LEDGER: .standards-deno-ledger/.machine_readable/deno-allow.txt + SLUG: ${{ github.repository }} + N_DENO: ${{ needs.detect.outputs.n_deno }} + run: | + # LEDGER, SLUG and N_DENO come from the step `env:` map above. + set -euo pipefail + + # FAIL CLOSED. "Could not read the ledger" is not "is exempt". + if [ ! -f "$LEDGER" ]; then + echo "::error::Deno gate: the central ledger could not be read ($LEDGER). This is NOT an exemption — the gate refuses rather than guess. Check the pinned ref of the 'Checkout the pinned Standards Deno ledger' step." + { + echo "### Deno (banned)" + echo "" + echo "**REFUSED: the exemption ledger could not be read.** A gate that cannot" + echo "check its exemption list must not pass; this is not an exemption." + echo "" + } >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + # Strip comments and blanks; this is also the ledger's own denominator. + ALLOWED="$(grep -vE '^[[:space:]]*(#|$)' "$LEDGER" || true)" + N_ALLOWED="$(printf '%s' "$ALLOWED" | grep -c . || true)" + + # What this repository actually tracks, named rather than counted, so + # the migration has a list to work from. + CONFIGS="$(git ls-files -- 'deno.json' '*/deno.json' 'deno.jsonc' '*/deno.jsonc')" + + echo "Deno debt: ${N_ALLOWED} ledgered repositories estate-wide." + echo "This repository (${SLUG}) tracks ${N_DENO} Deno config file(s):" + printf '%s\n' "$CONFIGS" | sed 's/^/ - /' + + if printf '%s\n' "$ALLOWED" | grep -Fxq "$SLUG"; then + echo "::notice::${SLUG} is on the Deno exemption ledger (${N_ALLOWED} repositories). Deno remains BANNED: this is grandfathered debt, not a licence. Port the deno.json 'tasks' map to package.json scripts run by 'bun run', delete the config, then delete this repository's line from .machine_readable/deno-allow.txt." + { + echo "### Deno (banned) — ledgered debt" + echo "" + echo "| | |" + echo "|---|---|" + echo "| Repository | \`${SLUG}\` |" + echo "| Deno configs tracked here | **${N_DENO}** |" + echo "| Ledgered repositories estate-wide | **${N_ALLOWED}** |" + echo "| Verdict | exempt (shrink-only ledger) |" + echo "" + echo "Tracked configs:" + echo "" + # Backticks below are literal markdown, not command substitution. + # shellcheck disable=SC2016 + printf '%s\n' "$CONFIGS" | sed 's/^/- `/; s/$/`/' + echo "" + echo "_Exempt is not approved._ Deno is banned estate-wide. The ledger is" + echo "SHRINK-ONLY: \`scripts/check-exemption-ratchet.sh\` fails any commit that" + echo "adds a line to it without a \`Ratchet-exception\` trailer naming the file." + echo "" + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + echo "::error::Deno is BANNED (owner ruling: 'deno is over, we're prioritising bun, and using bunx'). ${SLUG} tracks ${N_DENO} Deno config file(s) and is NOT on the exemption ledger (${N_ALLOWED} repositories listed). Migrate to Bun: port the deno.json 'tasks' map to package.json scripts run by 'bun run' — 'bun run' does not read a deno.json tasks map — and delete the config." + { + echo "### Deno (banned) — REFUSED" + echo "" + echo "| | |" + echo "|---|---|" + echo "| Repository | \`${SLUG}\` |" + echo "| Deno configs tracked here | **${N_DENO}** |" + echo "| Ledgered repositories estate-wide | **${N_ALLOWED}** |" + echo "| Verdict | **REFUSED — not ledgered** |" + echo "" + echo "Tracked configs:" + echo "" + # Backticks below are literal markdown, not command substitution. + # shellcheck disable=SC2016 + printf '%s\n' "$CONFIGS" | sed 's/^/- `/; s/$/`/' + echo "" + echo "Deno is banned estate-wide. Migrate to Bun, or — if this is genuinely" + echo "grandfathered debt — add \`${SLUG}\` to" + echo "\`.machine_readable/deno-allow.txt\` in \`hyperpolymath/standards\` with a" + echo "\`Ratchet-exception: .machine_readable/deno-allow.txt — \` commit" + echo "trailer, and bump this workflow's ledger pin." + echo "" + } >> "$GITHUB_STEP_SUMMARY" + exit 1 + # ─────────────────────────────────────────────────────────────────────── + # Job 2 — ReScript (grandfathered; banned for new code) + # ─────────────────────────────────────────────────────────────────────── + rescript: + name: ReScript + needs: detect + if: ${{ needs.detect.outputs.has_rescript == 'true' }} + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 20 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Policy notice + run: | + echo "::warning::ReScript is BANNED for new code (owner ruling 2026-04-30). Existing .res migrates to .affine directly — AffineScript, not TypeScript. This job serves a grandfathered tree mid-migration." + + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + + - name: Install dependencies + shell: bash + run: | + set -euo pipefail + # An npm lifecycle script is arbitrary code execution at CI + # privilege, so scripts stay off. MEASURED 2026-09-22 against the + # registry: rescript 11.1.4 declares postinstall + # 'node scripts/rescript_postinstall.js', but 12.3.1 declares NO + # postinstall at all -- it ships the compiler as per-platform + # optionalDependencies. So the flag only costs anything on the + # superseded major, and ReScript is a banned language here in any + # case: this job exists to lint grandfathered sources, not to make + # legacy installs convenient. + # + # --frozen-lockfile is the other half: without it bun may resolve a + # floating range, and CI would then be testing a compiler the repo + # does not pin. + bun install --frozen-lockfile --ignore-scripts + + # A missing binary after that install must be an ERROR, not a + # silent fall-through to a build step that cannot run. A skip is + # not a pass. + if ! bunx --no-install rescript -h >/dev/null 2>&1; then + echo "::error::rescript is not present after a --frozen-lockfile --ignore-scripts install. Upgrade to rescript >= 12, which needs no lifecycle script, or add the compiler to the lockfile. Lifecycle scripts are deliberately not enabled in this workflow." + exit 1 + fi + + - name: ReScript build (warnings are errors) + format check + shell: bash + run: | + set -euo pipefail + FAILED=0 + + # A compile IS the lint for ReScript; -warn-error +a promotes every + # warning. This writes to lib/, which is compiler output and not a + # tracked source file. + bunx --no-install rescript build -with-deps -warn-error +a || FAILED=1 + + # `rescript format` gained -check at different points in different + # majors. Probe for the capability rather than assume it: a gate that + # fails because the flag does not exist tells you nothing about the + # code, and one that silently passes tells you less. + if bunx --no-install rescript format --help 2>&1 | grep -q -- '-check'; then + bunx --no-install rescript format -all -check || { echo "::error::ReScript sources are not formatted"; FAILED=1; } + FMT='checked' + else + echo "::warning::The installed rescript CLI has no 'format -check'; the format gate did NOT run. This is a skip, not a pass." + FMT='**SKIPPED — CLI lacks -check**' + fi + + { + echo "### ReScript" + echo "" + echo "- \`rescript build -warn-error +a\`: $( [ "$FAILED" -eq 0 ] && echo 'pass' || echo '**FAIL**' )" + echo "- format check: ${FMT}" + echo "- policy: **banned for new code**, migrates to AffineScript" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + exit "$FAILED" + + # ─────────────────────────────────────────────────────────────────────── + # Job 2 — V (grandfathered; banned, migration to Zig completed 2026-05-28) + # ─────────────────────────────────────────────────────────────────────── + v-lang: + name: V + needs: detect + if: ${{ needs.detect.outputs.has_v == 'true' }} + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 20 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Policy notice + run: | + echo "::warning::V-lang is BANNED (owner ruling 2026-04-10; estate migration to Zig COMPLETED 2026-05-28 across 16 PRs). A v.mod here means either a carve-out (v-cartridge / v-adapter / v-bindings / v-client, asdf-vlang, archived polystack/) or a regression. Confirm which." + + - name: Install V (pinned, sha256-verified) + shell: bash + env: + V_VERSION: '0.5.2' + # Computed locally from the release archive. + V_SHA256: '86caf9e70c3342d48ef19eb4f6c47b709f18c90ae86255520d5c29df6b482e23' + run: | + set -euo pipefail + URL="https://github.com/vlang/v/releases/download/${V_VERSION}/v_linux.zip" + curl -sSfL --proto '=https' --proto-redir '=https' --tlsv1.2 --retry 3 -o "$RUNNER_TEMP/v_linux.zip" "$URL" + echo "${V_SHA256} ${RUNNER_TEMP}/v_linux.zip" | sha256sum -c - + unzip -q "$RUNNER_TEMP/v_linux.zip" -d "$RUNNER_TEMP" + echo "$RUNNER_TEMP/v" >> "$GITHUB_PATH" + + - name: V vet + format verify + shell: bash + run: | + set -euo pipefail + FAILED=0 + # ⚠ MEASURED on v 0.5.2: `v fmt -verify ` prints + # " is not vfmt'ed / Encountered a total of: 1 formatting + # errors." and then EXITS 0 — a fake green. Only the DIRECTORY form + # exits 1. Do not "simplify" this into a per-file loop: it would go + # on printing the same errors and stop failing on them. + # `v fmt -w` is the destructive form and must never appear in CI. + v fmt -verify . || { echo "::error::V sources are not formatted (v fmt -verify)"; FAILED=1; } + v vet . || { echo "::error::v vet reported problems"; FAILED=1; } + { + echo "### V" + echo "" + echo "- \`v fmt -verify\` + \`v vet\`: $( [ "$FAILED" -eq 0 ] && echo 'pass' || echo '**FAIL**' )" + echo "- policy: **banned**; migration to Zig completed 2026-05-28" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + exit "$FAILED" + + # ─────────────────────────────────────────────────────────────────────── + # Job 2 — Haskell + # ─────────────────────────────────────────────────────────────────────── + haskell: + name: Haskell + needs: detect + if: ${{ needs.detect.outputs.has_haskell == 'true' }} + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install GHC and Cabal + uses: haskell-actions/setup@6037f33647c3f17758a2356c80fc4a53d7e0685d # v2.12.0 + with: + enable-stack: false + + - name: Build with warnings as errors + shell: bash + run: | + set -euo pipefail + # Haskell's lint IS the compiler here. hlint/fourmolu would each need + # a `cabal install` costing several minutes, and neither is pinned + # anywhere in the estate — an unpinned toolchain install is a worse + # trade than a narrower gate. -Wall -Werror is the spec's "fail on + # warnings" and is read-only with respect to tracked files. + cabal update + cabal build all --ghc-options='-Wall -Werror' + { + echo "### Haskell" + echo "" + echo "- \`cabal build all --ghc-options='-Wall -Werror'\`: pass" + echo "- note: format gate not run — no pinned fourmolu/hlint in the estate" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + + # ─────────────────────────────────────────────────────────────────────── + # report — make every skip legible + # ─────────────────────────────────────────────────────────────────────── + report: + name: Pipeline report + needs: [detect, secret-scan, sast, rust, nickel, deno, rescript, v-lang, haskell] + if: ${{ always() }} + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Write skip ledger + shell: bash + env: + R_DETECT: ${{ needs.detect.result }} + R_SECRET: ${{ needs.secret-scan.result }} + R_SAST: ${{ needs.sast.result }} + R_RUST: ${{ needs.rust.result }} + R_NICKEL: ${{ needs.nickel.result }} + R_DENO: ${{ needs.deno.result }} + R_RESCRIPT: ${{ needs.rescript.result }} + R_V: ${{ needs.v-lang.result }} + R_HASKELL: ${{ needs.haskell.result }} + H_RUST: ${{ needs.detect.outputs.has_rust }} + H_NICKEL: ${{ needs.detect.outputs.has_nickel }} + H_DENO: ${{ needs.detect.outputs.has_deno }} + H_RESCRIPT: ${{ needs.detect.outputs.has_rescript }} + H_V: ${{ needs.detect.outputs.has_v }} + H_HASKELL: ${{ needs.detect.outputs.has_haskell }} + run: | + set -euo pipefail + + # A skipped job renders as a grey tick that reads, at a glance, like + # a pass. Spell out which gates did NOT run and why, so nobody reads + # an absent check as a satisfied one. + row() { + local name="$1" result="$2" reason="$3" + case "$result" in + skipped) echo "| ${name} | skipped | ${reason} |" ;; + success) echo "| ${name} | pass | ran |" ;; + failure) echo "| ${name} | **FAIL** | ran |" ;; + *) echo "| ${name} | ${result} | ran |" ;; + esac + } + + { + echo "### Pipeline report" + echo "" + echo "| Gate | Result | Note |" + echo "|---|---|---|" + row "Detect" "$R_DETECT" "n/a" + row "Secret scanning" "$R_SECRET" "n/a" + row "SAST (semgrep)" "$R_SAST" "disabled via enable_sast: false" + row "Rust" "$R_RUST" "no Cargo.toml tracked (has_rust=${H_RUST})" + row "Nickel" "$R_NICKEL" "no *.ncl tracked (has_nickel=${H_NICKEL})" + row "Deno" "$R_DENO" "BANNED — refusal gate; skipped means no deno.json(c) tracked (has_deno=${H_DENO})" + row "ReScript" "$R_RESCRIPT" "no rescript.json/bsconfig.json/*.res tracked (has_rescript=${H_RESCRIPT})" + row "V" "$R_V" "no v.mod tracked (has_v=${H_V})" + row "Haskell" "$R_HASKELL" "no *.cabal/stack.yaml/*.hs tracked (has_haskell=${H_HASKELL})" + echo "" + echo "_A skipped gate examined nothing. It is not a pass._" + echo "" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Fail if any gate failed + if: ${{ contains(needs.*.result, 'failure') }} + run: | + echo "::error::At least one pipeline gate failed. See the report table in the step summary." + exit 1 diff --git a/.github/workflows/github-backup-mirror-reusable.yml b/.github/workflows/github-backup-mirror-reusable.yml new file mode 100644 index 000000000..2ada4e256 --- /dev/null +++ b/.github/workflows/github-backup-mirror-reusable.yml @@ -0,0 +1,324 @@ +# SPDX-License-Identifier: MPL-2.0 +# This workflow is managed by gh actions-lock. +# github-backup-mirror-reusable.yml — Reusable GitHub→GitHub backup mirror. +# +# `mirror-reusable.yml` covers seven NON-GitHub forges (GitLab, Bitbucket, +# Codeberg, SourceHut, Disroot, Gitea, Radicle). None of them mirror GitHub to +# GitHub, which is what a `metadatastician` → `hyperpolymath` backup needs. +# This reusable fills exactly that gap and copies the house idiom job-for-job. +# +# THREE DELIBERATE DEPARTURES FROM THE NAIVE `git clone --mirror` PATTERN +# +# 1. No `--mirror`, on either end. `git push --mirror` makes the destination +# match the source EXACTLY, which means it DELETES destination refs that the +# source does not have. Against a destination that is not already a copy of +# the source, that is data loss, not a backup. We push one branch. +# +# 2. No `|| true`. The familiar snippet needs it because `git clone --mirror` +# drags `refs/pull/*/head` and `refs/pull/*/merge` along, and GitHub then +# rejects those with "deny updating a hidden ref" — a non-zero exit even on a +# completely successful push. Not cloning `refs/pull/*` removes the reason to +# suppress the exit code, so the real failure signal survives. Advisory +# behaviour comes from job-level `continue-on-error`, as in mirror-reusable. +# +# 3. The destination is PROVEN SAFE BEFORE the push, never after. See the +# "Resolve destination and prove it is safe" step. +# +# ⚠ `continue-on-error: true` means a refusal below shows a GREEN tick and sends +# no notification. Every refusal therefore writes BOTH `::error::` AND a line +# to $GITHUB_STEP_SUMMARY, or the guard would be findable only by opening the +# log of a run that looks like it passed. +# +# Caller example (wrapper): +# # SPDX-License-Identifier: MPL-2.0 +# name: GitHub Backup Mirror +# on: +# push: +# branches: [main] +# workflow_dispatch: +# permissions: +# contents: read +# jobs: +# backup: +# uses: hyperpolymath/standards/.github/workflows/github-backup-mirror-reusable.yml@ +# secrets: inherit +# +# Required repo configuration: +# vars.BACKUP_MIRROR_ENABLED = 'true' (opt in; off by default) +# vars.BACKUP_MIRROR_ORG (destination owner) +# vars.BACKUP_MIRROR_AUTOCREATE = 'true' (optional; see below) +# secrets.BACKUP_MIRROR_TOKEN (classic PAT) +# +# ⚠ BACKUP_MIRROR_TOKEN needs `repo` AND `workflow` scope. Every repo in this +# estate contains `.github/workflows/`, and GitHub rejects a push carrying +# workflow files outright when the token lacks `workflow` scope. A token with +# only `repo` fails at push time with a message that does not obviously say +# "scope", so this is worth getting right before the first run. + +name: GitHub Backup Mirror (reusable) + +on: + workflow_call: + inputs: + runs-on: + description: Runner label for the mirror job + type: string + required: false + default: ubuntu-latest + destination-branch: + description: Branch name to write on the destination + type: string + required: false + default: main + secrets: + BACKUP_MIRROR_TOKEN: + required: false + +permissions: + contents: read + +jobs: + mirror-github: + name: Mirror to GitHub backup + timeout-minutes: 20 + runs-on: ${{ inputs.runs-on }} + if: vars.BACKUP_MIRROR_ENABLED == 'true' + # Advisory mirror: a backup failure must not redden an otherwise good build. + # The safety checks below still fail closed — they refuse to push. + continue-on-error: true + # Map the secret to env so step `if:`s can gate on its presence: the + # `secrets` context is NOT available in `if:` (using it is an + # "Unrecognized named-value: 'secrets'" startup failure that kills the whole + # workflow before any job runs — `instant-sync.yml` in this very repo still + # carries that bug). `env` IS available in step `if:`, and secrets are valid + # in job-level `env`. + env: + MIRROR_TOKEN: ${{ secrets.BACKUP_MIRROR_TOKEN }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # persist-credentials: false is LOAD-BEARING, not hygiene. + # actions/checkout writes the job GITHUB_TOKEN into the local git + # config as `http.https://github.com/.extraheader`. That header + # applies to EVERY github.com remote, including the backup + # destination — where that token has no write access. An + # extraheader beats credentials in a remote URL, so leaving it in + # place makes the push below fail with a 403 that reads like a + # problem with the PAT. Do not re-enable without unsetting it. + persist-credentials: false + # Full history: a shallow clone cannot answer "is the destination an + # ancestor of what I am about to push?", which is the whole safety + # argument below. + fetch-depth: 0 + + - name: Skipped (BACKUP_MIRROR_TOKEN not configured) + if: ${{ env.MIRROR_TOKEN == '' }} + run: | + set -euo pipefail + echo "::notice::BACKUP_MIRROR_ENABLED=true but secrets.BACKUP_MIRROR_TOKEN is empty. Skipping GitHub backup mirror." + { + echo "### GitHub backup mirror: skipped" + echo "" + echo "\`BACKUP_MIRROR_ENABLED\` is \`true\` but \`BACKUP_MIRROR_TOKEN\` is empty." + echo "A skip is not a pass: nothing was mirrored." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Resolve destination and prove it is safe + id: resolve + if: ${{ env.MIRROR_TOKEN != '' }} + env: + MIRROR_ORG: ${{ vars.BACKUP_MIRROR_ORG || vars.MIRROR_ORG }} + REPO_NAME: ${{ github.event.repository.name }} + SRC_OWNER: ${{ github.repository_owner }} + AUTOCREATE: ${{ vars.BACKUP_MIRROR_AUTOCREATE }} + DEST_BRANCH: ${{ inputs.destination-branch }} + GH_TOKEN: ${{ secrets.BACKUP_MIRROR_TOKEN }} + run: | + set -euo pipefail + + refuse() { + # A refusal under continue-on-error is invisible unless it is + # written in both places. Say it twice, deliberately. + echo "::error::$1" + { + echo "### GitHub backup mirror: REFUSED" + echo "" + echo "$1" + } >> "$GITHUB_STEP_SUMMARY" + exit 1 + } + + if [ -z "$MIRROR_ORG" ]; then + refuse "No destination owner. Set vars.BACKUP_MIRROR_ORG (or vars.MIRROR_ORG)." + fi + + # ── Hard denylist ──────────────────────────────────────────────── + # `.github` is the community-health repo, and the two estate copies + # are UNRELATED repositories that merely share a name: + # hyperpolymath/.github and metadatastician/.github differ in size, + # content and creation date, with no common history. Mirroring by + # name would overwrite one with the other and destroy real content. + # This is not configurable, because no configuration makes it safe. + case "$REPO_NAME" in + .github) + refuse "Refusing to mirror '.github': the estate copies are unrelated repositories sharing a name. Mirroring by name would destroy the destination." + ;; + esac + + if [ "$MIRROR_ORG" = "$SRC_OWNER" ]; then + refuse "Destination owner equals source owner ($SRC_OWNER); that would mirror a repository onto itself." + fi + + DEST_SLUG="${MIRROR_ORG}/${REPO_NAME}" + echo "Destination: $DEST_SLUG (branch $DEST_BRANCH)" + + # ── Does the destination exist? ────────────────────────────────── + # Discriminate on the HTTP STATUS, not on whether the command + # failed. GitHub deliberately returns 404 for a private repository + # you are not allowed to see, so "absent" and "exists but forbidden" + # are the SAME failure from `gh repo view`. Reading a 403 as + # "absent" would send us down the create path against a repository + # that already exists. `gh api -i` prints the status line, which is + # the only thing that tells them apart. + gh api -i "repos/${DEST_SLUG}" > "$RUNNER_TEMP/dest.http" 2>&1 || true + CODE="$(awk 'NR==1 {print $2; exit}' "$RUNNER_TEMP/dest.http")" + + if [[ ! "$CODE" =~ ^[0-9]{3}$ ]]; then + refuse "Could not parse an HTTP status from the destination probe for $DEST_SLUG. Refusing to guess whether it exists." + fi + + case "$CODE" in + 200) DEST_EXISTS=true ;; + 404) DEST_EXISTS=false ;; + 401|403) + refuse "Destination probe for $DEST_SLUG returned HTTP $CODE. The token is missing, expired, or lacks scope on the destination owner. A 403 is NOT an absent repository: not creating, not pushing." + ;; + *) + refuse "Destination probe for $DEST_SLUG returned HTTP $CODE. Unexpected; refusing to act on an unclassified response." + ;; + esac + echo "destination $DEST_SLUG probe: HTTP $CODE" + + if [ "$DEST_EXISTS" = false ]; then + if [ "$AUTOCREATE" != "true" ]; then + # Creating repositories is a side effect nobody asked for. Across + # this estate 46 of 49 source repos have no same-named + # destination, so a default-on autocreate would silently spawn 46 + # repositories on the first run. Opt in deliberately or not at all. + refuse "Destination $DEST_SLUG does not exist and vars.BACKUP_MIRROR_AUTOCREATE is not 'true'. Create it manually, or set BACKUP_MIRROR_AUTOCREATE=true to allow this workflow to create it private." + fi + echo "::notice::Creating private destination $DEST_SLUG" + gh repo create "$DEST_SLUG" --private \ + --description "Backup mirror of ${SRC_OWNER}/${REPO_NAME}. Managed by github-backup-mirror-reusable.yml." + # A freshly created repo is empty. Empty is SAFE, not divergent: + # there is nothing there to lose. + echo "expected_dest_head=" >> "$GITHUB_OUTPUT" + echo "safe_reason=destination created empty" >> "$GITHUB_OUTPUT" + echo "dest_slug=$DEST_SLUG" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # ── Destination exists: prove we are not about to clobber it ───── + # Embed the token in the fetch URL. `secrets.BACKUP_MIRROR_TOKEN` + # arrives through the secrets context, so Actions masks it in logs; + # a base64 extraheader would NOT be masked, which is why the plain + # form is the safer one here. + DEST_URL="https://x-access-token:${MIRROR_TOKEN}@github.com/${DEST_SLUG}.git" + git remote add backup "$DEST_URL" 2>/dev/null || git remote set-url backup "$DEST_URL" + + # `git fetch` returns non-zero for BOTH "no such branch" and "the + # fetch failed" (transport, auth, server). Reading that one code as + # absence lets any outage bypass the ancestry proof below and fall + # straight through to a force push. `ls-remote` separates them: + # rc != 0 is a real failure, empty output is a genuinely absent branch. + if ! LS_OUT="$(git ls-remote --heads backup "refs/heads/${DEST_BRANCH}" 2>&1)"; then + refuse "Cannot reach $DEST_SLUG to check for '$DEST_BRANCH'. A failed lookup is NOT proof the branch is absent, so this run refuses rather than force-pushing over a destination it could not read." + fi + + if [ -z "$LS_OUT" ]; then + echo "::notice::Destination $DEST_SLUG has no '$DEST_BRANCH' branch yet; nothing to overwrite." + echo "expected_dest_head=" >> "$GITHUB_OUTPUT" + echo "safe_reason=destination has no $DEST_BRANCH branch" >> "$GITHUB_OUTPUT" + echo "dest_slug=$DEST_SLUG" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # The branch DOES exist, so a fetch failure here is a real failure. + if ! git fetch --no-tags backup "refs/heads/${DEST_BRANCH}:refs/remotes/backup/${DEST_BRANCH}"; then + refuse "Destination branch '$DEST_BRANCH' exists on $DEST_SLUG but could not be fetched. Refusing to push over a destination whose state could not be read." + fi + + # An ancestry proof over a TRUNCATED history is vacuous. With a + # shallow clone `git merge-base --is-ancestor` can answer "no" + # purely because the common ancestor was never fetched, and it can + # answer "yes" only within the fetched window. checkout above asks + # for fetch-depth: 0, but a caller override or a grafted repo would + # silently defeat that — so assert it rather than assume it. + if [ "$(git rev-parse --is-shallow-repository)" != "false" ]; then + refuse "Repository is shallow; an ancestry proof over a truncated history proves nothing. Set fetch-depth: 0 on the checkout. Not pushing." + fi + + DEST_HEAD="$(git rev-parse "refs/remotes/backup/${DEST_BRANCH}")" + SRC_HEAD="$(git rev-parse HEAD)" + echo "Destination HEAD: $DEST_HEAD" + echo "Source HEAD: $SRC_HEAD" + + if [ "$DEST_HEAD" = "$SRC_HEAD" ]; then + echo "expected_dest_head=$DEST_HEAD" >> "$GITHUB_OUTPUT" + echo "safe_reason=already up to date" >> "$GITHUB_OUTPUT" + echo "dest_slug=$DEST_SLUG" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # The ONLY other safe case: everything the destination has is already + # contained in what we are about to push. Anything else means the + # destination holds commits that a force-push would destroy. + if ! git merge-base --is-ancestor "$DEST_HEAD" "$SRC_HEAD"; then + AHEAD="$(git rev-list --count "^${SRC_HEAD}" "${DEST_HEAD}" 2>/dev/null || echo '?')" + refuse "Destination $DEST_SLUG has DIVERGED: $DEST_HEAD is not an ancestor of $SRC_HEAD ($AHEAD commit(s) exist only on the destination). Refusing to force-push; reconcile the histories by hand. This is the case the naive 'git push --mirror || true' pattern silently destroys." + fi + + echo "expected_dest_head=$DEST_HEAD" >> "$GITHUB_OUTPUT" + echo "safe_reason=destination is a strict ancestor (fast-forward)" >> "$GITHUB_OUTPUT" + echo "dest_slug=$DEST_SLUG" >> "$GITHUB_OUTPUT" + + - name: Push to backup mirror + if: ${{ env.MIRROR_TOKEN != '' && steps.resolve.outputs.dest_slug != '' }} + env: + DEST_SLUG: ${{ steps.resolve.outputs.dest_slug }} + SAFE_REASON: ${{ steps.resolve.outputs.safe_reason }} + EXPECTED_DEST_HEAD: ${{ steps.resolve.outputs.expected_dest_head }} + DEST_BRANCH: ${{ inputs.destination-branch }} + run: | + set -euo pipefail + DEST_URL="https://x-access-token:${MIRROR_TOKEN}@github.com/${DEST_SLUG}.git" + git remote add backup "$DEST_URL" 2>/dev/null || git remote set-url backup "$DEST_URL" + + # HEAD:refs/heads/ rather than a bare branch name: the local + # branch is not guaranteed to be checked out under every trigger, and + # this form pushes exactly the commit that was verified above. + # The ancestry proof was taken in an EARLIER step. The destination can + # advance in the window between that step and this one, and a bare + # `--force` would destroy whatever arrived in it. The lease binds this + # push to the exact SHA that was verified: if the destination moved, + # the push is rejected instead of overwriting an unseen commit. + if [ -z "${EXPECTED_DEST_HEAD:-}" ]; then + # Nothing existed to verify, so creating the branch needs no force. + # If it appeared in the meantime this push fails, which is correct. + git push backup "HEAD:refs/heads/${DEST_BRANCH}" + else + git push \ + --force-with-lease="refs/heads/${DEST_BRANCH}:${EXPECTED_DEST_HEAD}" \ + backup "HEAD:refs/heads/${DEST_BRANCH}" + fi + + { + echo "### GitHub backup mirror: pushed" + echo "" + echo "| Field | Value |" + echo "|---|---|" + echo "| Destination | \`${DEST_SLUG}\` |" + echo "| Branch | \`${DEST_BRANCH}\` |" + echo "| Commit | \`${GITHUB_SHA}\` |" + echo "| Safety | ${SAFE_REASON} |" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/governance-reusable.yml b/.github/workflows/governance-reusable.yml index a9e9081ab..ed8b71ca6 100644 --- a/.github/workflows/governance-reusable.yml +++ b/.github/workflows/governance-reusable.yml @@ -339,29 +339,28 @@ jobs: # Language Policy; SaltStack exception removed 2026-01-03). The # previous in-line `python3 << PYEOF` heredoc made this very gate a # self-referential violation — same structural class as the CSA001 - # self-loop fixed in hypatia#328. Eradicated by porting the logic - # to a Deno script that lives in this standards repo. + # self-loop fixed in hypatia#328. Eradicated by porting the logic to + # a script that lives in this standards repo. # # Implementation note: a reusable workflow only auto-checks-out its # YAML, not sibling files in its repo. So we explicitly check out # this repo into `.standards-checkout/`, then run the script from - # there. We pin to `main` because `github.workflow_sha` resolves to - # the caller repo's commit SHA (not standards'), which makes the - # fetch fail with exit 128 ("No commit found for SHA"). Caller - # repos already pin the reusable's YAML by SHA, so the bounded - # drift is just whatever's on standards/main between the reusable - # version and the script version — acceptable since scripts here - # are read-only governance checks. - - name: Set up Deno - uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5 - with: - deno-version: v2.x - - - name: Check out standards repo for shared scripts + # there. + # + # The checkout is pinned to an immutable standards commit, not `main`. + # `github.workflow_sha` cannot serve: it resolves to the caller repo's + # commit SHA (not standards'), so the fetch fails with exit 128 + # ("No commit found for SHA"). Hence a literal SHA, bumped in the same + # PR as any helper change. scripts/tests/governance-reusable-contract- + # test.sh asserts exactly this with `ref: [0-9a-f]{40}`: following + # moving `main` would let one standards push silently change the + # behaviour of every already-pinned caller, with no review in their + # repos. + - name: Checkout the pinned Standards policy helpers uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: main + ref: 317101e03b8fe642589498f4bdb84541ab466062 path: .standards-checkout # Sparse-checkout only the scripts dir to keep this fast. sparse-checkout: | @@ -370,45 +369,21 @@ jobs: - name: Check for TypeScript # Read-only execution; never writes outside the runner workspace. - # `--no-lock` so an empty / stale / missing `deno.lock` doesn't fail - # `deno run` before the file-walker even starts — the script does not - # import anything, so the lockfile is irrelevant to its execution. - # See standards#294. # - # Runs the AffineScript-compiled `.deno.js` (source of truth: - # `scripts/check-ts-allowlist.affine`). The .ts archetype is kept - # alongside for the regression suite (`scripts/tests/check-ts- - # allowlist-test.sh`) and for parallel-validation during the - # TS→AffineScript migration (standards#239 / #241). Retirement of - # the .ts is a separate follow-up after the dual-target window. - run: deno run --allow-read --no-lock .standards-checkout/scripts/check-ts-allowlist.deno.js - - - name: check-ts-allowlist source/compile drift (informational) - # Non-blocking — informational until the AffineScript compiler - # output is hash-pinned per compiler version. The compiler header - # currently stamps "Generated by AffineScript compiler" which is - # a moving target as the codegen evolves, so spurious diff = - # "compiler bumped" vs real diff = "someone edited .affine - # without recompiling". Promotion to blocking is gated on a - # compiler-version pin landing (see standards#312). - continue-on-error: true - run: | - if ! command -v affinescript >/dev/null 2>&1; then - echo "::notice::affinescript compiler unavailable on runner — skipping drift check" - exit 0 - fi - tmp="$(mktemp /tmp/check-ts-allowlist-drift.XXXXXX.deno.js)" - if ! affinescript compile --deno-esm -o "$tmp" .standards-checkout/scripts/check-ts-allowlist.affine; then - echo "::warning::affinescript compile failed — drift check skipped" - rm -f "$tmp" - exit 0 - fi - if diff -u .standards-checkout/scripts/check-ts-allowlist.deno.js "$tmp"; then - echo "✅ check-ts-allowlist .affine source and .deno.js compiled output are in sync" - else - echo "::warning::check-ts-allowlist.deno.js drifted from check-ts-allowlist.affine — re-run \`just check-ts-allowlist-drift\` locally and recommit the .deno.js" - fi - rm -f "$tmp" + # Runs `scripts/check-ts-allowlist.sh`, replacing the + # AffineScript-compiled `check-ts-allowlist.deno.js`. That artefact + # existed only to be fed to `deno run`, which forced a + # `denoland/setup-deno` install onto a REQUIRED context in every + # estate repo. The shell implementation is behaviourally equivalent + # (scripts/tests/check-ts-allowlist-test.sh, 18/18) and needs no + # runtime installed at all. Estate policy retires Deno in favour of + # bun — see LANGUAGE-POLICY.adoc §1. + # + # The source/compile drift check that used to sit here was removed + # with the .deno.js it compared against: `affinescript compile` is + # unavailable on the runner, so the step could only ever emit + # `::warning::`, which cannot fail a job — a vacuous gate. + run: bash .standards-checkout/scripts/check-ts-allowlist.sh # Shared escape hatch for the banned-language-file checks below. # Honours three exemption mechanisms (see @@ -1099,22 +1074,25 @@ jobs: # governance jobs on every PR estate-wide. github.sha resolves to the # same merge commit but is always fetchable. ref: ${{ github.sha }} - - name: Checkout standards for the duplicate-key check + - name: Checkout the pinned Standards policy helpers uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: main + ref: 317101e03b8fe642589498f4bdb84541ab466062 path: .standards-dupkey sparse-checkout: | scripts/check-workflow-duplicate-keys.sh scripts/update-actions-lock.sh + scripts/check-actions-lock-gate.sh + .machine_readable/lock-allow.txt sparse-checkout-cone-mode: false - # ⚠ Not fatal if the file is absent. This checkout is pinned to - # standards@main, so during a rename of the script the fetch finds - # nothing — the new name does not exist on main until the renaming pull - # request merges. Without this, the RENAME ITSELF fails the linter, on - # the one pull request that cannot possibly be at fault. See the - # fallback in the next step. + # ⚠ Not fatal if a file is absent, and the next step names precisely + # which one was missing. Formerly this followed `main`, so a rename of + # any helper failed the linter on the one pull request that could not + # possibly be at fault — the new name does not exist on main until that + # very PR merges. An immutable pin removes the race entirely: the names + # at this SHA are fixed. The self-lint fallback in the next step still + # covers standards' own tree, where a rename lands before the bump. continue-on-error: true - name: Duplicate YAML keys in workflows @@ -1157,6 +1135,32 @@ jobs: exit 1 fi cp "$LOCK_SCRIPT" "$RUNNER_TEMP/update-actions-lock.sh" + # The actions-lock GATE and its exemption ledger, same idiom: preserve + # them before this sparse standards checkout leaves the workspace. + GATE_SCRIPT=".standards-dupkey/scripts/check-actions-lock-gate.sh" + if [ ! -f "$GATE_SCRIPT" ] && [ -f scripts/check-actions-lock-gate.sh ]; then + GATE_SCRIPT="scripts/check-actions-lock-gate.sh" + echo "Using this repository's own actions-lock gate (standards self-lint)." + fi + if [ ! -f "$GATE_SCRIPT" ]; then + echo "::error::actions-lock gate not found — neither fetched from the" \ + "pinned standards helpers nor present locally." + exit 1 + fi + cp "$GATE_SCRIPT" "$RUNNER_TEMP/check-actions-lock-gate.sh" + LEDGER=".standards-dupkey/.machine_readable/lock-allow.txt" + if [ ! -f "$LEDGER" ] && [ -f .machine_readable/lock-allow.txt ]; then + LEDGER=".machine_readable/lock-allow.txt" + fi + # A MISSING ledger must not silently exempt everyone, nor silently + # exempt no one. Stage an empty file and let the gate step print the + # denominator it actually read — a skip is not a pass. + if [ -f "$LEDGER" ]; then + cp "$LEDGER" "$RUNNER_TEMP/lock-allow.txt" + else + echo "::warning::actions-lock exemption ledger not found; treating it as EMPTY (no repo is exempt)." + : > "$RUNNER_TEMP/lock-allow.txt" + fi rm -rf .standards-dupkey bash "$RUNNER_TEMP/dupkeys.sh" .github/workflows @@ -1192,34 +1196,66 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | + set -uo pipefail + # ONE implementation of the lock/pin predicate, not two. + # + # This step used to carry its own inline copy of the logic. That copy + # matched `^[[:space:]]+uses:` — with no `-?` — so it never saw + # `- uses: foo@v1`, the commonest step form, and its green was + # therefore partly vacuous. scripts/check-actions-lock-gate.sh uses + # `^[[:space:]]+-?[[:space:]]*uses:` and is the tested implementation + # (scripts/tests/check-actions-lock-gate-test.sh, 11/11). + # + # actions.lock is the authoritative immutable resolution for both + # direct actions and their transitive dependencies. Do not also + # rewrite direct refs to raw SHAs: gh actions-lock omits refs that + # no tag or branch contains, and GitHub then rejects the workflow + # at startup. Measured in oikosbot PR #78 on 2026-08-29: five + # previously executable workflows became startup_failure after the + # redundant direct-SHA conversion; restoring their locked version + # refs made GitHub's native resolver accept them again. if [ -f .github/workflows/actions.lock ]; then - # actions.lock is the authoritative immutable resolution for both - # direct actions and their transitive dependencies. Do not also - # rewrite direct refs to raw SHAs: gh actions-lock omits refs that - # no tag or branch contains, and GitHub then rejects the workflow - # at startup. Measured in oikosbot PR #78 on 2026-08-29: five - # previously executable workflows became startup_failure after the - # redundant direct-SHA conversion; restoring their locked version - # refs made GitHub's native resolver accept them again. gh extension install github/gh-actions-lock - bash "$RUNNER_TEMP/update-actions-lock.sh" --verify-local - echo "Immutable direct and transitive lockfile coverage verified" - else - unpinned=$(grep -rnE --include='*.yml' --include='*.yaml' \ - "^[[:space:]]+uses:" .github/workflows/ | \ - grep -v "@[a-f0-9]\{40\}" | \ - grep -v "uses: \./\|uses: docker://\|uses: actions/github-script\|uses: hyperpolymath/standards/" || true) - if [ -n "$unpinned" ]; then - echo "ERROR: no .github/workflows/actions.lock in THIS TREE, and these refs are not SHA-pinned." - echo " Prefer \`gh actions-lock\` — it also locks the transitive dependencies" - echo " of composite actions, which an inline SHA cannot express." - echo " Do NOT do both: gh actions-lock refuses a ref no tag or branch contains," - echo " so inline pinning REMOVES actions from the lockfile." - echo "$unpinned" - exit 1 - fi - echo "All actions are SHA-pinned" fi + + # The gate delegates lockfile verification to the authoritative + # verifier. Both were staged into RUNNER_TEMP by the duplicate-key + # step, before its sparse standards checkout left the workspace. + export ACTIONS_LOCK_VERIFIER="$RUNNER_TEMP/update-actions-lock.sh" + bash "$RUNNER_TEMP/check-actions-lock-gate.sh" + rc=$? + + # Shrink-only exemption ledger (.machine_readable/lock-allow.txt in + # standards, guarded by scripts/check-exemption-ratchet.sh so it can + # never grow without a `Ratchet-exception` trailer). + # + # Why a ledger and not a later date: the gate compares `date -u +%F` + # against ENFORCE_ACTIONS_LOCK_FROM at RUNTIME, inside an artefact + # that callers have already pinned by SHA. A date therefore fires + # simultaneously across every bumped caller with no human action and + # cannot be moved without re-bumping every pin. Measured 2026-09-22 + # (complete n=368 census of governance callers, `gh api` on default + # branches): 200 carry actions.lock, 163 do not, 5 have no workflows + # directory. The ledger makes that 163 an explicit, shrinking debt + # instead of a cliff. + total=$(grep -cve '^[[:space:]]*$' -e '^[[:space:]]*#' "$RUNNER_TEMP/lock-allow.txt" || true) + : "${total:=0}" + # The ledger excuses missing-lock debt ONLY (gate exit 3: lockless, + # every ref pinned, grace window closed). Exit 1 is a LIVE + # VIOLATION — unpinned refs or a verifier-rejected lock — and exit + # 2 is infrastructure failure; neither is ledgerable, so `-ne 0` + # here would wave real findings through with the debt. + if [ "$rc" -eq 3 ] && grep -qxF "$GITHUB_REPOSITORY" "$RUNNER_TEMP/lock-allow.txt"; then + echo "::notice::actions-lock debt is LEDGERED for $GITHUB_REPOSITORY (missing-lock only, gate exit 3)." + echo "lock debt: $GITHUB_REPOSITORY is 1 of $total ledgered repositories." + echo "This exemption is shrink-only. Run scripts/update-actions-lock.sh, commit" + echo "the lockfile, and delete this repository's line from" + echo ".machine_readable/lock-allow.txt in hyperpolymath/standards." + exit 0 + fi + # Never a silent pass: state the denominator even when clean. + echo "lock debt: $total ledgered repositories; $GITHUB_REPOSITORY is NOT among them." + exit "$rc" # The step above proves a pin has the right SHAPE. It cannot prove the # SHA EXISTS — a fabricated 40-hex string passes it. Measured 2026-07-28: # 112 of 613 unique estate pins (18%) do not resolve, in 876 committed @@ -1229,7 +1265,10 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: hyperpolymath/standards - ref: main + # Immutable pin, for the same reason as the policy-helper checkout + # above: following `main` lets one standards push change the + # behaviour of every already-pinned caller with no review. + ref: 317101e03b8fe642589498f4bdb84541ab466062 path: .standards-checkout persist-credentials: false sparse-checkout: | diff --git a/.machine_readable/REGISTRY.a2ml b/.machine_readable/REGISTRY.a2ml index fe7c8bb5c..167b2b0e2 100644 --- a/.machine_readable/REGISTRY.a2ml +++ b/.machine_readable/REGISTRY.a2ml @@ -36,7 +36,7 @@ name = "Hyperpolymath Estate Constitution" stream = "governance" home = "0-canon/constitution/" canonical_doc = "0-canon/constitution/README.adoc" -source_hash = "sha256:7fbd1015a184d33391957e73f6348c7409b6298e15ef9c8bcd26bf1a6fd7d739" +source_hash = "sha256:e0f2c790f01b05bd331748918f197e7df0273ec0aa745908a109db3b2113bca7" route = "the highest estate-level rules, authority precedence, assurance, contribution, exceptions, and known tensions" [[spec]] @@ -45,7 +45,7 @@ name = "K9 Self-Validating Components" stream = "foundation" home = "1-formats/k9/" canonical_doc = "1-formats/k9/README.adoc" -source_hash = "sha256:780c4a516609fe2c8a61c615ef9f8a97c630218813385530ad4c1a36a61908ef" +source_hash = "sha256:536014a24a3928eafa441726511566705a4605877bc15866b3a91fb80d727b0f" route = "the K9 specification, security analysis and adoption guidance (implementations live in hyperpolymath/k9-ecosystem)" [[spec]] @@ -54,7 +54,7 @@ name = "Contractiles (Must/Trust/Dust/Intend)" stream = "foundation" home = "1-formats/contractiles/" canonical_doc = "1-formats/contractiles/README.adoc" -source_hash = "sha256:196b51cb7efbe7c9dc775513e45e46d5b88404dd8c0cf50391a466cba3b53a34" +source_hash = "sha256:b3bedbed23c8c79a9a94b059e09ff5865f8bbf198a82d90384290e8f501504d5" route = "policy-enforcement primitives the K9 layer is built from" [[spec]] @@ -63,7 +63,7 @@ name = "META.a2ml spec" stream = "foundation" home = "1-formats/a2ml/meta/" canonical_doc = "1-formats/a2ml/meta/README.adoc" -source_hash = "sha256:a058855d1c8019ccf1814a9386ba406b7e8df3698fd17c9fd342a5134f6a2eb0" +source_hash = "sha256:16ff64a7aeca0f213fa4e98dbe9e43e579259c93b8ab98a60e228c49aaab31e2" route = "architecture decisions / governance metadata format" [[spec]] @@ -72,7 +72,7 @@ name = "STATE.a2ml spec" stream = "foundation" home = "1-formats/a2ml/state/" canonical_doc = "1-formats/a2ml/state/README.adoc" -source_hash = "sha256:a67908e04098d40d957fe067042410bf195ef48dd2d18e7426b66b3ed1a01f33" +source_hash = "sha256:90de2e78ee3875bf7ca9b1e239a4bfd56b465ce5f07b3ca691d720704a8eb4ee" route = "project-state metadata format (drives this registry's topology)" [[spec]] @@ -81,7 +81,7 @@ name = "ECOSYSTEM.a2ml spec" stream = "foundation" home = "1-formats/a2ml/ecosystem/" canonical_doc = "1-formats/a2ml/ecosystem/README.adoc" -source_hash = "sha256:fd8d8eae614d7a6c89dee84a874e4a5ba6f94db4b5e3f675154252632b01788d" +source_hash = "sha256:599daba5759987a132568e27eabe7596d4b6f29baa2a33556198e21c47ffbc1f" route = "ecosystem-positioning metadata format" [[spec]] @@ -90,7 +90,7 @@ name = "AGENTIC.a2ml spec" stream = "foundation" home = "1-formats/a2ml/agentic/" canonical_doc = "1-formats/a2ml/agentic/README.adoc" -source_hash = "sha256:025e72fc7cbddeeb4e92b3d1aedd74e812f164644dd9ecd26010e4ff26a74c3a" +source_hash = "sha256:b86efb14f342b7d152e31f098be8929c0b9ca02efe61d901f749ec999409e299" route = "AI-agent operational gating / entropy budgets" [[spec]] @@ -99,7 +99,7 @@ name = "NEUROSYM.a2ml spec" stream = "foundation" home = "1-formats/a2ml/neurosym/" canonical_doc = "1-formats/a2ml/neurosym/README.adoc" -source_hash = "sha256:753f11a288d6402296a6dc2fc69c0cca986e164d6319b7b789e159995816ec61" +source_hash = "sha256:11362cc1e57ad7fdd29e4f2a57316c7df7f4d51bff4963d607dd560810318e0b" route = "symbolic semantics / proof obligations" [[spec]] @@ -108,7 +108,7 @@ name = "PLAYBOOK.a2ml spec" stream = "foundation" home = "1-formats/a2ml/playbook/" canonical_doc = "1-formats/a2ml/playbook/README.adoc" -source_hash = "sha256:ae559e69331afa37ddf14d71d92bddb41613964765420a2fe930234c13e71074" +source_hash = "sha256:13956749d0074922bdcc7e7fc4ba3708a6cf24052612be6408533741a0b4215e" route = "executable operational runbooks" [[spec]] @@ -117,7 +117,7 @@ name = "ANCHOR.a2ml spec" stream = "foundation" home = "1-formats/a2ml/anchor/" canonical_doc = "1-formats/a2ml/anchor/README.adoc" -source_hash = "sha256:d33c7ff6c44dc734eb8fd05bd5c7491176da20237b0a5d8212df25471e6b2765" +source_hash = "sha256:52b6aaa64eb11858abff115f21268f97bb862a9b169a171504c08b0170684e46" route = "project-recalibration intervention format" [[spec]] @@ -126,7 +126,7 @@ name = "0-AI Gatekeeper Protocol" stream = "protocol" home = "2-protocols/0-ai-gatekeeper/" canonical_doc = "2-protocols/0-ai-gatekeeper/README.adoc" -source_hash = "sha256:41f60acfb75bc32b0a3fc13cf3642f2e3f553bb4b7ddc9911b1e23d17e205ef3" +source_hash = "sha256:09632266a1822b2583b3b96f831a540d41e3827cb93f5acb213585f90d8275e8" route = "the AI-agent entry/gating protocol behind 0-AI-MANIFEST" [[spec]] @@ -135,7 +135,7 @@ name = "K9 Coordination Protocol" stream = "protocol" home = "2-protocols/k9-coordination/" canonical_doc = "2-protocols/k9-coordination/README.adoc" -source_hash = "sha256:7fe64bde6943757dc0cb60d9e7b37429770bf02bdd9c8db91bee4af50739a99d" +source_hash = "sha256:548def6808dc22da192758da17bbadacdd0d3e43130035801a2e4b0a6ca02aed" route = "multi-agent coordination on top of K9" [[spec]] @@ -144,7 +144,7 @@ name = "AVOW Protocol" stream = "protocol" home = "2-protocols/avow/" canonical_doc = "2-protocols/avow/BINDING.adoc" -source_hash = "sha256:ed5b1c9415c137348313f6d8a65d0d6eb320abf2f64e76bb25e108d7a21a3b5b" +source_hash = "sha256:41c7f3653ab96f56f72a02c9c3a99e34007915912f03ff3f5e0eced689382045" route = "consent-attested messaging / origin attribution" [[spec]] @@ -153,7 +153,7 @@ name = "AXEL Protocol" stream = "protocol" home = "2-protocols/axel/" canonical_doc = "2-protocols/axel/README.adoc" -source_hash = "sha256:03ce83b73eb01290dda2cac2fd837e4cdc68412c842375e29331aa1e4be0a57b" +source_hash = "sha256:3074c2eb863fe42ff4e26fc85c2520bb40da2853af90b50a1908e111a8908db2" route = "age-gating + explicit-content enforcement" [[spec]] @@ -162,7 +162,7 @@ name = "Overlay Protocol" stream = "protocol" home = "2-protocols/overlay/" canonical_doc = "2-protocols/overlay/.machine_readable/descriptiles/ECOSYSTEM.a2ml" -source_hash = "sha256:7bde0638703825f37abc83ee98160e4cc2e72298b13ff50b8cf3632cb5444a15" +source_hash = "sha256:ca720852bf060e6879c04b547af262874a0f1cbeedf4cfeeeb48b8e1b16f252e" route = "layered overlay composition spec" [[spec]] @@ -171,7 +171,7 @@ name = "ARG — Adoption Readiness Grades" stream = "readiness" home = "adoption-readiness-grades/" canonical_doc = "adoption-readiness-grades/README.adoc" -source_hash = "sha256:89999392cd908835ea7aa0e8ccbdefbdeacab7bc012c4f040aadc811f9caf587" +source_hash = "sha256:2550506e1c8836a1a7045b91196b92a8b28e222fdedab6003c2f34b2e3eec695" route = "per-language adoption-maturity profile templates" [[spec]] @@ -189,7 +189,7 @@ name = "CRG — Component Readiness Grades" stream = "readiness" home = "component-readiness-grades/" canonical_doc = "component-readiness-grades/README.adoc" -source_hash = "sha256:89466c6d58d3e159fd1cba5ff17b115f4ca476c492bb7157ba70ca81fcaed8fd" +source_hash = "sha256:fd47af106dddd99a9424e1ecbe5888e6e707cc46e0ddd88ea8228785b4e19ebe" route = "the X..A grading system for components" [[spec]] @@ -198,7 +198,7 @@ name = "TRG — Toolchain Readiness Grades" stream = "readiness" home = "toolchain-readiness-grades/" canonical_doc = "toolchain-readiness-grades/README.adoc" -source_hash = "sha256:d134340774dd73435ebc428758541433eafaef9599bfedf51946bbfda0b4482c" +source_hash = "sha256:c3d9161a193c3a588559ecf990555bf0c79a72f6fccff6143c5ec2a25f3fb2e9" route = "per-toolchain readiness profile templates" [[spec]] @@ -207,7 +207,7 @@ name = "RSR — Rhodium Standard Repositories" stream = "governance" home = "rhodium-standard-repositories/" canonical_doc = "rhodium-standard-repositories/README.adoc" -source_hash = "sha256:35b2a2d8a9b4e33d7f73c663f0d811d054a16e8601f62a20c9ec54114cd43b54" +source_hash = "sha256:343ee9bb809ea0920cbe064ac26d6889c91d38e476813b41dd279504729d373a" route = "the repository-compliance standard every repo is graded against" [[spec]] @@ -216,7 +216,7 @@ name = "Session Management Standards" stream = "governance" home = "3-practice/session-management-standards/" canonical_doc = "3-practice/session-management-standards/README.adoc" -source_hash = "sha256:f97ff391eea3fc80a4ab0b94031cf4ad9a373f8699ca99f0f0daecfb968ac148" +source_hash = "sha256:beea95b19ff9565abf30d34b758bfb669c5140fc5eef5046a494eafe20a7a91f" route = "continuity / verify / handover protocols" [[spec]] @@ -225,7 +225,7 @@ name = "DYADT — Did-You-Actually-Do-That" stream = "governance" home = "1-formats/sub-specs/did-you-actually-do-that/" canonical_doc = "1-formats/sub-specs/did-you-actually-do-that/README.adoc" -source_hash = "sha256:453bf00d0dfac71576b5e7b4068fb8987abc3337d4bc3bf75c081e0332ae1dff" +source_hash = "sha256:66930b4a3aec27a03a2e62bee3263f602ebadda9e0185043d133a59bce08c45d" route = "post-action agent-claim verification (Tier 4 accountability)" [[spec]] @@ -234,7 +234,7 @@ name = "ENSAID Config" stream = "governance" home = "1-formats/sub-specs/ensaid-config/" canonical_doc = "1-formats/sub-specs/ensaid-config/README.adoc" -source_hash = "sha256:c56e9784c957b9bd29704d41cbf7d88b1495789e0ad1c628de42abe900d319ef" +source_hash = "sha256:9941b04a8a96dd1414c28c9dc9ba50fe4178aa6feb057675ea8a6d5615d85131" route = "the ensaid configuration standard" [[spec]] @@ -243,7 +243,7 @@ name = "Accessibility Standard" stream = "governance" home = "3-practice/accessibility/" canonical_doc = "3-practice/accessibility/STANDARD.a2ml" -source_hash = "sha256:73898902f539078297c9c1d952e403dc62ddcb39c1d0282442fdf4c22bea330b" +source_hash = "sha256:849090c4b436106e300e3ad4c77e74cdc0d2ac3ea51c111a3bce8288bca84eed" route = "estate accessibility requirements" [[spec]] @@ -252,7 +252,7 @@ name = "Publication Pre-Flight" stream = "governance" home = "3-practice/publication-pre-flight/" canonical_doc = "3-practice/publication-pre-flight/HOL-SUITABILITY-CHECKLIST.adoc" -source_hash = "sha256:86e93a00784d646d99dcaf412efc3d647a02ff7ac2e38cc1f94c1d6bc775c188" +source_hash = "sha256:05abe62e69eb2dd6eb85a4f362d90d8e178bd7adbef1b3617e01b4c792ccdb3c" route = "submission gate (HOL + Zenodo checklists)" [[spec]] @@ -261,7 +261,7 @@ name = "Release Pre-Flight (V1 Gate)" stream = "governance" home = "3-practice/release-pre-flight/" canonical_doc = "3-practice/release-pre-flight/V1-GATE.adoc" -source_hash = "sha256:f9dc04e36e6638518ccc9d72518ef5ed9f1187da1fe1044e6d1394bfecf86000" +source_hash = "sha256:5ddef2b20cb2c6fccfb2174893db316e7cb3b8f94f12e0e48294c76c6d6417aa" route = "hard v1.0.0 audit requirements" [[spec]] @@ -270,7 +270,7 @@ name = "Standards Hypatia Rules" stream = "integration" home = "hypatia-rules/" canonical_doc = "hypatia-rules/README.adoc" -source_hash = "sha256:60bc34212bbc8e8b5a673f9dd3c3ee5a77300f069ec266b0a4d13060f46e2356" +source_hash = "sha256:b78a413b26148b04b2de9485d31514bd2ba6461832bb741c48010a58518afa29" route = "the dogfooding rules that scan THIS repo (incl. drift detection)" [[spec]] @@ -279,7 +279,7 @@ name = "A2ML Templates" stream = "integration" home = "1-formats/templates/" canonical_doc = "1-formats/templates/STATE.a2ml.v2.spec.adoc" -source_hash = "sha256:16fc9e6b38b1bf06a3fdc1069127af42985562381f3ef126d0dcd0370f774783" +source_hash = "sha256:5dbe5d5bef5e084631af8523ba210980b8a21e3969f382b6f0a52ca791609a6a" route = "copy-in templates for the 7 A2ML files" [[spec]] diff --git a/.machine_readable/deno-allow.txt b/.machine_readable/deno-allow.txt new file mode 100644 index 000000000..a48f4423b --- /dev/null +++ b/.machine_readable/deno-allow.txt @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# deno-allow.txt — SHRINK-ONLY exemption ledger for the Deno refusal gate +# (the `deno` job of .github/workflows/ci-pipeline.yml). +# +# Owner ruling: "deno is over, we're prioritising bun, and using bunx." +# Deno is BANNED. A repository that still tracks `deno.json`/`deno.jsonc` goes +# RED unless its `owner/name` slug is listed below; a listed repository gets a +# `::notice::` naming its debt and the job passes. Both paths print the +# denominator, so an exemption is never a silent pass. +# +# One `owner/name` slug per line, sorted. The gate compares +# `${{ github.repository }}` — a SLUG, not a path. Local checkout paths are not +# a valid key: the estate's ~240 linked worktrees check the same repository out +# many times over. +# +# ⚠ SHRINK-ONLY. Registered in LEDGERS=() in scripts/check-exemption-ratchet.sh, +# which line-counts this file against BASE_REF and FAILS on growth unless a +# commit in the range carries a trailer naming this exact path: +# +# Ratchet-exception: .machine_readable/deno-allow.txt — +# +# To leave the ledger: delete the repository's `deno.json`/`deno.jsonc`, port +# the `tasks` map to `package.json` scripts run by `bun run`, then delete its +# line here. ⚠ `bun run` does NOT read a `deno.json` `tasks` map; that port is +# the substantive part of the migration, not the config deletion. +# +# ⚠ A repository TRANSFER changes its slug (measured: `MaridIR.jl` moved +# hyperpolymath → metadatastician). Transferring a ledgered repository turns it +# red until the new slug is added — which is growth, and therefore needs the +# trailer above. +# +# ── Seed provenance ─────────────────────────────────────────────────────────── +# Measured 2026-09-22. COMPLETE CENSUS, not a sample: all 458 non-archived +# repositories owned by the two accounts (409 hyperpolymath, 49 metadatastician, +# enumerated with `gh repo list --limit 2000 --no-archived`), each +# probed on its DEFAULT BRANCH with +# gh api repos//git/trees/?recursive=1 +# and matched against the regex `(^|/)deno\.jsonc?$` at ANY depth. +# +# Result: 11 carry a Deno config, 446 do not, 1 has no commits +# (metadatastician/proglanging-languages), 0 probe errors. +# +# Two controls, because a census that cannot fail is not a measurement: +# * per-repo denominator — each probe recorded its tree entry count, so a +# failed probe reports ERR and can never be mistaken for "no Deno". This +# caught a total probe failure (458/458 ERR) that would otherwise have been +# published as "zero Deno estate-wide". +# * truncation — GitHub silently truncates a recursive tree at ~100k entries. +# `hyperpolymath/julia-ecosystem` came back `"truncated": true` (69117 +# entries), so its result was DISCARDED and re-measured with a blobless +# clone (`--filter=tree:0 --depth 1`): 57611 blobs, zero matches. Every +# other tree reported `"truncated": false`. +# +# ⚠ Do NOT regenerate this list from the local working estate. The plan this +# implements cited 69 repositories, derived from tracked `deno.json` files in +# local checkouts. That figure is WRONG and is withdrawn: the local worktrees +# are stale (last fetch 2026-09-15) while the default branches had already +# removed these configs — e.g. hyperpolymath/coq-jr carries `deno.json` on local +# `main` and not on `origin/main`, removed by `44f956e refactor: eradicate +# TypeScript and NPM/Deno configs (#80)`. Only default-branch content is a valid +# proxy for what CI executes. +# +# ⚠ `deno.jsonc` matched ZERO repositories. The detection glob still covers it +# anyway: this is a REFUSAL gate, and a gate that checks only one of the two +# spellings of the thing it bans is exactly the vacuous class it exists to stop. +hyperpolymath/ambientops +hyperpolymath/civic-connect +hyperpolymath/excel-economic-numbers-tool +hyperpolymath/proven +hyperpolymath/social-media-tools +metadatastician/burble +metadatastician/gossamer +metadatastician/selur +metadatastician/stapeln +metadatastician/svalinn +metadatastician/vordr diff --git a/.machine_readable/lock-allow.txt b/.machine_readable/lock-allow.txt new file mode 100644 index 000000000..a8589e930 --- /dev/null +++ b/.machine_readable/lock-allow.txt @@ -0,0 +1,203 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# lock-allow.txt — SHRINK-ONLY exemption ledger for the actions-lock gate +# (scripts/check-actions-lock-gate.sh, wired in the `workflow-lint` job of +# .github/workflows/governance-reusable.yml). +# +# One `owner/name` slug per line. A listed repository whose gate exits non-zero +# gets a `::notice::` naming its debt and the job passes; an UNLISTED repository +# goes red. Both paths print the denominator, so an exemption is never a silent +# pass. +# +# ⚠ SHRINK-ONLY. Registered in LEDGERS=() in scripts/check-exemption-ratchet.sh, +# which line-counts this file against BASE_REF and FAILS on growth unless a +# commit in the range carries a trailer naming this exact path: +# +# Ratchet-exception: .machine_readable/lock-allow.txt — +# +# To leave the ledger: run scripts/update-actions-lock.sh in the repository, +# commit .github/workflows/actions.lock, then delete its line here. +# +# ── Seed provenance ─────────────────────────────────────────────────────────── +# Measured 2026-09-22. COMPLETE CENSUS, not a sample: 368 distinct repositories +# whose tracked workflows call governance-reusable.yml (347 hyperpolymath, +# 21 metadatastician), each probed on its DEFAULT BRANCH with +# gh api repos//contents/.github/workflows/actions.lock +# Result: 200 carry a lockfile, 163 do not, 5 have no workflows directory. +# +# The 163 below are exactly the lockless set. They are the gate's NEW blast +# radius and nothing more: repositories that DO carry a lockfile were already +# verified by the previous inline check, so their verdict is unchanged, and a +# date-based grace window was rejected because the gate reads `date -u +%F` at +# RUNTIME inside an artefact callers have already pinned by SHA — it would fire +# across every bumped caller at once and could not be moved without re-bumping +# all 368 pins. +# +# ⚠ Do not regenerate this list from a local `grep` over the working estate. +# The same question returned 44, 132 and 359 from local checkouts, and 356 local +# copies read `@main` while every default branch was SHA-pinned. Only default +# branch content is a valid proxy for what CI executes. +hyperpolymath/0patch-lsa-sentinel +hyperpolymath/a2ml +hyperpolymath/AcceleratorGate.jl +hyperpolymath/accessibility-everywhere +hyperpolymath/achievements-lab +hyperpolymath/aerie +hyperpolymath/aggregate-library +hyperpolymath/ai-cli-lab +hyperpolymath/ambientops +hyperpolymath/anvomidav +hyperpolymath/anytype +hyperpolymath/avow-protocol +hyperpolymath/Axiology.jl +hyperpolymath/Axiom.jl +hyperpolymath/befunge93-vault-cracker +hyperpolymath/bofig +hyperpolymath/boinc-boinc +hyperpolymath/BowtieRisk.jl +hyperpolymath/branch-newspaper +hyperpolymath/candy-crash +hyperpolymath/Causals.jl +hyperpolymath/cccp +hyperpolymath/checky-monkey +hyperpolymath/chimichanga +hyperpolymath/civic-connect +hyperpolymath/Cladistics.jl +hyperpolymath/claude-gecko-browser-extension +hyperpolymath/cloudflare-dns-terraform +hyperpolymath/cloudguard-cli +hyperpolymath/cloudguard-server +hyperpolymath/cloud-sync-tuner +hyperpolymath/conative-gating +hyperpolymath/contractiles-a2-lab +hyperpolymath/cyo +hyperpolymath/developer-ecosystem +hyperpolymath/dictask +hyperpolymath/dicti0nary-attack +hyperpolymath/docmatrix +hyperpolymath/docudactyl +hyperpolymath/dotfiles +hyperpolymath/echobox +hyperpolymath/EchoTypes.jl +hyperpolymath/email-octad-experiment +hyperpolymath/empty-linter +hyperpolymath/ensaid-spec +hyperpolymath/error-lang +hyperpolymath/excel-economic-numbers-tool +hyperpolymath/Exnovation.jl +hyperpolymath/explicit-trust-plane +hyperpolymath/feedback-o-tron +hyperpolymath/ffmpeg-ffi +hyperpolymath/filesoup +hyperpolymath/fireflag +hyperpolymath/FirmwareAudit.jl +hyperpolymath/flatracoon +hyperpolymath/formatrix-docs +hyperpolymath/frayed-knot-toolkit +hyperpolymath/git-reticulator +hyperpolymath/grim-repo +hyperpolymath/HackenbushGames.jl +hyperpolymath/HardwareResilience.jl +hyperpolymath/hesiod-dns-map +hyperpolymath/HOL +hyperpolymath/homebrew-tap +hyperpolymath/hpm-crypto-rsr +hyperpolymath/http-capability-gateway +hyperpolymath/hyperpolymath.github.io +hyperpolymath/Hyperpolymath.jl +hyperpolymath/hyperpolymath-sovereign-registry +hyperpolymath/i-human +hyperpolymath/im-docs +hyperpolymath/infrastructure-automation +hyperpolymath/intsoc-transactor +hyperpolymath/InvestigativeJournalism.jl +hyperpolymath/ipv6-only +hyperpolymath/ipv6-site-enforcer +hyperpolymath/jaffascript +hyperpolymath/julia-ecosystem +hyperpolymath/JuliaPackage-Reuse-Audit.jl +hyperpolymath/laminar +hyperpolymath/launch-scaffolder +hyperpolymath/lcb-website +hyperpolymath/live-files +hyperpolymath/lol +hyperpolymath/LowLevel.jl +hyperpolymath/MacroPower.jl +hyperpolymath/manifesto +hyperpolymath/megadog +hyperpolymath/metadata-grammar +hyperpolymath/misinformation-defence-platform +hyperpolymath/modshells +hyperpolymath/multiterm +hyperpolymath/nafa-app +hyperpolymath/network-dashboard +hyperpolymath/neural-foundations +hyperpolymath/nextgen-language-evangeliser +hyperpolymath/not-so-serious-software +hyperpolymath/ochrance +hyperpolymath/ochrance-framework +hyperpolymath/palimpsest-license +hyperpolymath/panll +hyperpolymath/phantom-metal-taste +hyperpolymath/php-aegis +hyperpolymath/phronesis +hyperpolymath/pimcore-fortress +hyperpolymath/plasma-parser-writer +hyperpolymath/polyglot-formalisms-elixir +hyperpolymath/polyglot-formalisms-gleam +hyperpolymath/PolyglotFormalisms.jl +hyperpolymath/poly-observability-mcp +hyperpolymath/polysafe-gitfixer +hyperpolymath/polystack +hyperpolymath/pow-the-game +hyperpolymath/preference-injector +hyperpolymath/project-wharf +hyperpolymath/proven +hyperpolymath/pseudoscript +hyperpolymath/QuantumCircuit.jl +hyperpolymath/rattlescript +hyperpolymath/raze-tui +hyperpolymath/refugia +hyperpolymath/rescript +hyperpolymath/resource-record-fluctuator +hyperpolymath/robodog-defensive-systems-lab +hyperpolymath/rrecord-verity +hyperpolymath/safe-brute-force +hyperpolymath/sanctify-php +hyperpolymath/sdp-hkdf-deployment +hyperpolymath/ShellIntegration.jl +hyperpolymath/SiliconCore.jl +hyperpolymath/SMTLib.jl +hyperpolymath/snapcreate +hyperpolymath/social-media-polygraph +hyperpolymath/SoftwareSovereign.jl +hyperpolymath/somethings-fishy +hyperpolymath/ssg-collection +hyperpolymath/supernorma +hyperpolymath/tangle +hyperpolymath/tentacles-agentic-syllabus +hyperpolymath/thejeffparadox +hyperpolymath/thunderbird-template-reloaded +hyperpolymath/TradeUnionism.jl +hyperpolymath/tree-navigator +hyperpolymath/twingate-helm-deploy +hyperpolymath/typell +hyperpolymath/unified-dataset-vocab +hyperpolymath/universal-extension-format +hyperpolymath/universal-language-server-plugin +hyperpolymath/universal-project-manager +hyperpolymath/valence-shell +hyperpolymath/vcl-ut +hyperpolymath/verisimiser +hyperpolymath/vext +hyperpolymath/ViableSystems.jl +hyperpolymath/volumod +hyperpolymath/voyage-enterprise-decision-system +hyperpolymath/wokelang +hyperpolymath/ZeroProb.jl +hyperpolymath/zerostep +hyperpolymath/zerotier-k8s-link +metadatastician/boj-server-mk2 +metadatastician/project-ovine +metadatastician/sr71-blackglider diff --git a/0-canon/rsr/LANGUAGE-POLICY.adoc b/0-canon/rsr/LANGUAGE-POLICY.adoc index 9eb695130..51bd0ba5d 100644 --- a/0-canon/rsr/LANGUAGE-POLICY.adoc +++ b/0-canon/rsr/LANGUAGE-POLICY.adoc @@ -3,15 +3,18 @@ = RSR Language Policy :author: Jonathan D.A. Jewell (hyperpolymath) -:revnumber: 1.5.0 -:revdate: 2026-08-31 +:revnumber: 1.6.0 +:revdate: 2026-09-22 :toc: left :icons: font :source-repo: https://github.com/hyperpolymath/cccp [NOTE] ==== -*Version Status*: v1.5.0 — retires the telegram-bot, `affinescript-deno-test/` +*Version Status*: v1.6.0 — corrects the inverted JavaScript-ecosystem policy: +Bun is tier 1 and Deno is banned (owner ruling 2026-09-22). This document had +frozen the 2026-04-10 order in which Deno was first and *Bun was banned*. +v1.5.0 retired the telegram-bot, `affinescript-deno-test/` and `tsconfig.json` carve-outs; reframes `affinescript-cli/` as the permanent npm front door. v1.4.0 codified the ReScript/npm/Unnecessarily-JS Layer-1 policy. v1.3.0 codified the TS → AffineScript migration state. v1.2.2 added @@ -67,9 +70,12 @@ NOTE: For full rationale and migration guides, see the link:{source-repo}[CCCP r | Idris2 is the only formal-verification language. ATS2 is rejected. See the `proven` repo for the canonical library status. -| *Deno* -| Runtime & package management -| Replaces Node/npm/Bun +| *Bun* +| JS runtime & package management (tier 1) +| Default for all new JavaScript-ecosystem work; `bunx ` for one-off + tooling. Uses an npm-compatible `package.json` plus `bun.lock` — both are + expected, not anti-patterns. Replaces Node/npm/Deno. Deno occupied this + row until 2026-09-22 and is now banned outright — see §Banned Languages. | *Gleam* | Backend services @@ -191,7 +197,7 @@ NOTE: For full rationale and migration guides, see the link:{source-repo}[CCCP r ReScript-to-TS first). | *Node.js* -| Deno +| Bun | Insecure by default. Detection via hypatia `cicd_rules/nodejs_detected` (matches `package-lock.json`) with `path_allow_prefixes` covering nine carve-out classes (six original @@ -224,12 +230,16 @@ NOTE: For full rationale and migration guides, see the link:{source-repo}[CCCP r handful of heavier Vite/Vitest/Jest/Express stacks). Tracker: `project_estate_npm_to_deno_2026_05_28.md`. -| *npm/Bun/pnpm/yarn* -| Deno -| Supply chain risks. Same six carve-outs as Node.js above (the - detection rule keys on `package-lock.json` which is npm's lockfile - format; other lockfiles like `pnpm-lock.yaml` are caught by the - same path-allow logic where present). +| *npm/yarn* +| Bun +| Supply chain risks. npm is tier 3 — *permitted, never preferred* — rather + than absolutely banned, but `package-lock.json` must still not be tracked + (standards#67). Same six carve-outs as Node.js above (the detection rule + keys on `package-lock.json` which is npm's lockfile format; other + lockfiles like `pnpm-lock.yaml` are caught by the same path-allow logic + where present). *Bun was struck from this row on 2026-09-22*: it is tier 1 + and sits in §Allowed Languages. pnpm is tier 2, permitted only where an + upstream toolchain needs a `node_modules` layout. | *JavaScript (Unnecessarily-)* | AffineScript @@ -288,13 +298,6 @@ NOTE: For full rationale and migration guides, see the link:{source-repo}[CCCP r | Tauri/Dioxus | Facebook/Meta lock-in -| *Bun* -| Deno, then pnpm (as Node fallback only) -| Added 2026-04-10. Previously tentatively allowed as a Deno fallback; ditched - on 2026-04-10 in favour of pnpm for forced-Node cases. User's framing: - "okay, let's dirch bun for pnpm if needed, so deno thebn fall back to pnpm". - Bun is too young and moves too fast for policy-level adoption. - | *V-lang* | Zig | Added 2026-04-10. Migration **COMPLETED 2026-05-28** across 16 @@ -375,7 +378,7 @@ Podman where Guix is not installable. documented as such (never a blanket mirror). | *JS deps* -| Deno (deno.json imports) +| Bun (`package.json` + `bun.lock`) |=== == Migration Priority @@ -408,11 +411,13 @@ When encountering banned languages: == JavaScript / Node Ecosystem Policy -Ratified 2026-04-10. When the work involves the JavaScript or Node ecosystem, -the reach order is: +Ratified 2026-04-10; reach order *superseded 2026-09-22* (Bun replaces Deno at +the head, and Deno is banned). When the work involves the JavaScript or Node +ecosystem, the reach order is: -1. **Deno first, always.** Deno with `npm:` specifiers covers most forced-Node - package needs without actually having a Node project. +1. **Bun first, always.** Bun is npm-compatible, so it covers forced-Node + package needs without actually having a Node project. Use `bunx ` + for one-off tooling. 2. **pnpm as the Node-runtime fallback.** When a project *must* be Node-runtime (legacy integration, deployment target demands it), use @@ -420,13 +425,15 @@ the reach order is: real security improvement over npm, its content-addressable store saves disk, its workspaces are sane, and it's compatible with the npm registry. -3. **No other JavaScript runtimes or package managers.** Explicitly banned: - * `npm` — unsafe defaults, phantom dependencies, slow +3. **npm last.** Tier 3 — permitted, never preferred; reaching for it is a + noted decision, not a default. `package-lock.json` must not be tracked. + +4. **No other JavaScript runtimes or package managers.** Explicitly banned: * `yarn` (classic + berry) — classic is abandoned, berry/PnP has compatibility pain - * `Bun` — too young, moves too fast (ditched 2026-04-10 after brief - consideration) - * `Node.js` as a standalone runtime — replaced by Deno + * `Deno` — banned 2026-09-22 by owner ruling (see §Banned Languages). + Migrate Deno to Bun; never the reverse. + * `Node.js` as a standalone runtime — replaced by Bun == Interface & Architecture Law (NORMATIVE) @@ -575,6 +582,46 @@ not prose alone. == Amendments +=== v1.6.0 — 2026-09-22 (Jonathan D.A. Jewell) + +Corrected an inverted JavaScript-ecosystem policy. This document had frozen +the 2026-04-10 reach order, in which Deno was first and **Bun was banned** +("too young, moves too fast for policy-level adoption"). Two later rulings +had already reversed that and were recorded only in the root +`LANGUAGE-POLICY.adoc` and `.claude/CLAUDE.md`, never here: 2026-07-29 made +Bun tier 1, and 2026-09-22 banned Deno. The owner ruling, verbatim: + +[quote] +____ +should be no deno, it's deprecated in standards, and rsr-template-repo, and +we are all bun and bunx now +____ + +This matters because RSR is a *seed*. `scripts/check-package-policy.sh` names +`rhodium-standard-repositories/spec/` as its canonical source, so every repo +graded against RSR inherited a spec instructing it to migrate off the estate's +tier-1 runtime and onto a banned one — the same inversion the root +`.claude/CLAUDE.md` corrected on 2026-08-07, left uncorrected in the seed. + +Six normative sites corrected: (1) §Allowed Languages — the `*Deno*` row +("Replaces Node/npm/Bun") becomes the `*Bun*` tier-1 row; there had been no +Bun row at all; (2) §Banned Languages `*Node.js*` — replacement Deno → Bun; +(3) §Banned Languages `*npm/Bun/pnpm/yarn*` → `*npm/yarn*`, replacement +Deno → Bun, with npm restated as tier 4 (permitted, never preferred); +(4) the `*Bun*` banned row is **withdrawn and removed** rather than struck +through — a struck row in a policy table is ambiguous to the agents that read +it, which is exactly what codacy raised on #655; (5) §Package Management +`*JS deps*` — Deno imports → `package.json` + `bun.lock`; (6) §JavaScript / +Node Ecosystem Policy — the ratified reach order now runs Bun → pnpm → npm, +with Deno moved into the banned list. + +Deliberately left as history rather than treated as residue: the dated +`✅ Done 2026-05-31` migration record in §Migration Priority, the +`project_estate_npm_to_deno_2026_05_28.md` tracker filenames, and the +`*/bindings/{deno,ts,typescript}/` and `**/.deno/**` carve-out globs — those +name interop targets and compiled output, not a runtime choice, and the +matching hypatia rules still key on them. + === v1.5.0 — 2026-08-31 (Jonathan D.A. Jewell) Retired three dead carve-outs, verified no-op estate-wide against every diff --git a/3-practice/LANGUAGE-POLICY.adoc b/3-practice/LANGUAGE-POLICY.adoc index 80c25f84c..2d8131b2f 100644 --- a/3-practice/LANGUAGE-POLICY.adoc +++ b/3-practice/LANGUAGE-POLICY.adoc @@ -26,18 +26,15 @@ Ordered preference. Reach for the first one that can do the job. | Default for all new work. Native TypeScript execution, no build step, built-in test runner and bundler. -| 2 | *Deno* -| *BEING REMOVED* (owner ruling 2026-08-26, #655): existing Deno projects - must migrate to Bun; where Bun genuinely cannot be used, the reason must - be documented. Ranked second only while the estate migration is in - flight — not a safe harbour. +| 2 | *pnpm* +| Second. Only where an upstream toolchain requires a node_modules layout. -| 3 | *pnpm* -| Third. Only where an upstream toolchain requires a node_modules layout. - -| 4 | *npm* +| 3 | *npm* | Last resort. Permitted, never preferred. Reaching for npm should be a deliberate, noted decision — not a default. + +| — | *Deno* +| *BANNED.* Removed from the ordering 2026-09-22 — see §1.3. |=== === 1.1 What this replaced @@ -96,6 +93,54 @@ The correct remedy for a gate that cannot fail is to *repair or replace the gate*, and to verify it both ways: that it passes on clean input and fails on a deliberately planted violation. Not to withdraw the policy. +=== 1.3 Deno + +RULED 2026-09-22 by the owner. *Deno is banned.* The ruling, verbatim: + +[quote] +____ +deno is over, we're prioritising bun, and using bunx +____ + +This *supersedes* the grandfathering clause that stood in the §1 table from +2026-07-29 to 2026-09-22 ("Existing Deno projects are grandfathered and need +not migrate; prefer it over pnpm/npm when Bun genuinely cannot be used"). +Deno now holds no position in the ordering at all: where Bun cannot be used, +the fallback is *pnpm*, then npm. + +==== How this is enforced + +Not by prose. Three gates, and each prints its denominator: + +`.github/workflows/ci-pipeline.yml`, job `deno`:: A repository that tracks +`deno.json` or `deno.jsonc` goes *RED* unless its `owner/name` slug appears in +`.machine_readable/deno-allow.txt` in this repository. Until 2026-09-22 this +job installed a pinned Deno binary and ran `deno lint` / `deno fmt --check` +behind a `::warning::` — and a `::warning::` cannot fail a job, so it +lint-checked, and thereby blessed, the very thing it called banned. + +`.machine_readable/deno-allow.txt`:: A *shrink-only* exemption ledger, seeded +2026-09-22 from a complete 458-repository census of both accounts' default +branches: *11 repositories* still carry a Deno config. Registered in +`LEDGERS=()` in `scripts/check-exemption-ratchet.sh`, so adding a line needs a +`Ratchet-exception: .machine_readable/deno-allow.txt — ` commit trailer. +Exempt is not approved: it is measured, named, countable debt. + +`.githooks/validate-lint-format.sh`:: Refuses any commit that *adds or +modifies* a `deno.json`/`deno.jsonc`. It deliberately asks a narrower question +than CI, because it runs in a caller's checkout and cannot read the central +ledger — and a hook that guessed at an exemption would be worse than one that +admits its scope. Deletions are never blocked, so migrating away is never +obstructed. + +==== What migration actually costs + +The config file is the easy part. `bun run` does *not* read a `deno.json` +`tasks` map; those task definitions must be ported to `package.json` scripts. +Measured 2026-09-22 across the 23 tracked config files in the 11 ledgered +repositories: *95* task definitions to port. That is the substantive work, and +the reason this is a ledger rather than a flag day. + == 2. Package management RULED 2026-05-18. Two tiers, in order: @@ -162,9 +207,16 @@ Not permitted for new work anywhere in the estate: * Go, Java/Kotlin, Swift * Makefiles (use `just`) +Banned *runtimes* are listed separately because the ban is on the runtime, not +the language it executes: + +* Deno — banned 2026-09-22, see §1.3. JavaScript itself is not banned; it runs + on Bun. + CAUTION: Bans must be enforced by gates that can actually fail. Several "blocker" workflows in this estate have been structurally incapable of failing — -see §1.2. When adding a gate, verify it *both* ways: that it passes on clean +see §1.2, and §1.3 for a worked example: a job that announced a ban with a +`::warning::`, which cannot fail a job, while lint-checking the banned thing. When adding a gate, verify it *both* ways: that it passes on clean input *and* fails on a deliberately planted violation. == 4. Amending this document diff --git a/docs/JS-RUNTIME-POLICY.adoc b/docs/JS-RUNTIME-POLICY.adoc index b64cc4bf3..8eb808e45 100644 --- a/docs/JS-RUNTIME-POLICY.adoc +++ b/docs/JS-RUNTIME-POLICY.adoc @@ -41,20 +41,17 @@ Tool selection MUST follow this hierarchy: built-in test runner and bundler. | 2 -| *Deno* -| *BEING REMOVED* (owner ruling 2026-08-26, #655): existing Deno projects -must migrate to Bun; where Bun genuinely cannot be used, the reason must be -documented. Ranked second only while the estate migration is in flight — -not a safe harbour. - -| 3 | *pnpm* -| Third. Only where an upstream toolchain requires a `node_modules` layout. +| Second. Only where an upstream toolchain requires a `node_modules` layout. -| 4 +| 3 | *npm* | Last resort. Permitted, never preferred. Reaching for npm should be a deliberate, noted decision — not a default. + +| — +| *Deno* +| *BANNED* 2026-09-22. Holds no position in the ordering. |=== [IMPORTANT] @@ -63,6 +60,19 @@ deliberate, noted decision — not a default. the exact inverse of `3-practice/LANGUAGE-POLICY.adoc` §1, which has been the ruling since 2026-07-29. The ordering above now matches it. `3-practice/LANGUAGE-POLICY.adoc` is authoritative; if the two ever disagree again, that one wins. + +*Deno banned 2026-09-22.* Owner ruling: "deno is over, we're prioritising bun, +and using bunx". Deno is removed from the ordering entirely; where Bun cannot +be used the fallback is pnpm, then npm. The grandfathering clause that stood in +row 2 from 2026-08-07 to 2026-09-22 is *withdrawn* and replaced by a +shrink-only exemption ledger, `.machine_readable/deno-allow.txt`, seeded from a +complete 458-repository census of both accounts' default branches: 11 +repositories still carry a Deno config. `LANGUAGE-POLICY.adoc` §1.3 is the +authoritative statement and records how it is enforced. + +⚠ The NOTE at the head of this document still describes an *npm → Deno* +migration as the estate's direction. That migration's direction is now +reversed; the text is retained as history, not as instruction. ==== == Hard Rules (enforced by governance-reusable.yml) @@ -88,7 +98,7 @@ The following entries MUST be present in every repo's `.gitignore` (carried from rsr-template-repo and v3-templater via template propagation): ---- -# npm-avoidant (standards#67): estate JS-runtime policy is Bun>Deno>pnpm>npm. +# npm-avoidant (standards#67): estate JS-runtime policy is Bun>pnpm>npm. # npm lockfiles must never be committed estate-wide. package-lock.json **/package-lock.json diff --git a/rhodium-standard-repositories/CLAUDE.md b/rhodium-standard-repositories/CLAUDE.md index ac276ffcb..5f7aac441 100644 --- a/rhodium-standard-repositories/CLAUDE.md +++ b/rhodium-standard-repositories/CLAUDE.md @@ -85,7 +85,7 @@ The Citadel is where RSR meets CCCP—the actual implementation pattern that emb ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────┐ │ Haskell │ │ Nickel Config │ │ Podman Compose │ │ Registry │ │ (Infra as Code)│ │ (Elixir, Ada, │ -│ (Validation) │ │ │ │ Rust, ReScript) │ +│ (Validation) │ │ │ │ Rust, AffineScript)│ └─────────┬───────┘ └─────────┬───────┘ └──────────┬──────────┘ │ │ │ ▼ ▼ ▼ @@ -93,13 +93,13 @@ The Citadel is where RSR meets CCCP—the actual implementation pattern that emb │ 🚀 POST-JAVASCRIPT STACK (Podman Orchestration) │ │ │ │ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ Frontend: ReScript → WASM (OCaml soundness) │ │ +│ │ Frontend: AffineScript → typed-wasm (affine/linear) │ │ │ └────────────────────────────────┬─────────────────────────────────┘ │ │ │ │ │ ┌──────────────────────────────────────────────────────────────────┐ │ -│ │ Router: CADRE (ReScript + Deno + CRDTs) │ │ -│ │ - OCaml type safety │ │ -│ │ - Deno security perimeters │ │ +│ │ Router: CADRE (AffineScript + Bun + CRDTs) │ │ +│ │ - Affine/linear type safety │ │ +│ │ - Sandboxed at the typed-wasm boundary │ │ │ │ - Conflict-free distributed state │ │ │ └────────────────────────────────┬─────────────────────────────────┘ │ │ │ │ @@ -146,15 +146,15 @@ The Citadel is where RSR meets CCCP—the actual implementation pattern that emb #### Robot Vacuum Cleaner (RVC) - **Automated repository tidying and optimization** -- Only Python allowed in RSR repos (grudgingly, will be eliminated) +- Rust or Elixir only. Python is FULLY banned - its "SaltStack only" exception was removed 2026-01-03 - Triggered by Git hooks (pre-commit, pre-push) - Operates during offline work, before push - Preventive maintenance—keeps repos clean without manual intervention #### CADRE Router - **Replaces traditional HTTP servers (including Bandit)** -- ReScript compilation (OCaml → JS, 10-100x faster than TypeScript) -- Deno runtime (explicit, granular, auditable permissions) +- AffineScript compilation (affine/linear types → typed-wasm) +- Bun runtime (tier 1; `bunx ` for one-off tooling) - CRDTs for conflict-free distributed state - No databases + locks + cache invalidation complexity @@ -180,7 +180,7 @@ A repository is Rhodium Standard compliant when it meets the following comprehen - ✅ Git hooks triggering local automation - ✅ **RVC** for automated tidying - ✅ **SaltRover** for offline-first repository management -- ✅ Salt states for configuration management (temporary—migrating away from Python) +- ✅ Nickel configs + Bash for configuration management (the Salt/Python exception was removed 2026-01-03) ### 2. Documentation Standards @@ -231,13 +231,13 @@ repository-root/ ### 3. Security Architecture (10+ Dimensions) #### Type Safety -- ✅ **ReScript** (OCaml soundness) for frontend +- ✅ **AffineScript** (affine/linear types, compiles to typed-wasm) for frontend - ✅ **Rust** for systems programming - ✅ **Ada + SPARK** for safety-critical paths - ✅ **Elixir** (Erlang VM) for fault-tolerant services - ✅ **Haskell** for pure functional validation -- ❌ **No TypeScript** (unsound gradual typing) -- ❌ **No Python** (except SaltStack, temporary) +- ❌ **No TypeScript** (unsound gradual typing) - use AffineScript. Not a fallback tier. +- ❌ **No Python** (fully banned; the SaltStack exception was removed 2026-01-03) - ❌ **No JavaScript** (actively being eliminated) #### Memory Safety @@ -251,13 +251,17 @@ repository-root/ - ✅ No distributed locking - ✅ No cache invalidation issues - ✅ Offline-first by design -- ✅ Deno KV for persistent CRDT storage +- ✅ An embedded KV store for persistent CRDT state (Bun ships a built-in SQLite driver) #### Process Security -- ✅ **Deno permissions model**: Explicit, granular, auditable - - No file access by default - - No network access by default - - No environment variable access by default +- ✅ **Capability boundaries enforced OUTSIDE the runtime**: no file, network or + environment access beyond what the deployment grants + - ⚠ This replaced the Deno permission model when Deno was banned (2026-09-22). + Bun is the estate runtime and has **no granular permission flags**, so the + boundary cannot live in the runtime any more. Do not read "Bun" as a + drop-in for `--allow-net` / `--allow-read`; it has no such thing. + - The boundary is therefore the typed-wasm sandbox (AffineScript compiles to + it), the rootless Podman container, and the SDP network perimeter below. - ✅ Podman rootless containers - ✅ **Software-Defined Perimeter (SDP)** for network access - ✅ Zero Trust architecture @@ -346,6 +350,7 @@ repository-root/ ### 5. Web Standards & Protocols #### .well-known/ Directory + ``` .well-known/ ├── security.txt # Security contact, PGP keys @@ -370,6 +375,7 @@ repository-root/ - ✅ Certificate transparency monitoring #### HTTP Security Headers (Mandatory) + ```http Content-Security-Policy: default-src 'self'; script-src 'none' X-Frame-Options: DENY @@ -465,7 +471,7 @@ Cross-Origin-Resource-Policy: same-origin - **Rationale**: Architectural integrity > open contribution here **🧠 Perimeter 2: Expert Extensions (Trusted Contributors)** -- **Languages**: Rust, Nickel, Bash, controlled Python +- **Languages**: Rust, Nickel, Bash - **Scope**: Protocol extensions, shell plugins, compliance validators - **Contribution**: Apply via issue template → review → merge under `extensions/` or `emit/` - **Requirements**: Unit tests, docs, examples, SPDX headers @@ -507,20 +513,46 @@ This is **graduated trust without gatekeeping**—everyone can contribute, but s ## Language Policy +> **Updated 2026-09-22.** This section is the seed copy of the estate language +> policy; its canonical source is `spec/LANGUAGE-POLICY.adoc` (rev 1.6.0) and its +> machine-readable twin is `spec.scm/language-policy.scm` (MODULE-VERSION 2.0.0). +> Where they disagree, the `.adoc` wins and this file is the defect. Four bans +> ratified after this document was written were missing from it entirely, so it +> was still recommending ReScript and Deno as destinations: +> +> | Banned | On | Replacement | +> |---|---|---| +> | Python (incl. the "SaltStack only" exception) | 2026-01-03 | AffineScript / Rust / Julia | +> | ReScript | 2026-04-30 | AffineScript (directly - not via ReScript) | +> | TypeScript | 2026-08-27 | AffineScript. Not a fallback tier. | +> | Deno | 2026-09-22 | Bun (tier 1; `bunx` for one-off tooling) | + ### Prohibited Languages +❌ **ReScript**: Banned 2026-04-30 +- Replace with: AffineScript. Migrate `.res` directly to `.affine` - do not route new work through ReScript. + +❌ **TypeScript**: Banned 2026-08-27 +- Replace with: AffineScript. Owner ruling: *"no typescript ... that should not exist at all."* +- It is **not** a fallback tier. Carve-outs are limited to `**/*.d.ts`, `**/bindings/ts/**` and `**/vscode/**`. + +❌ **Deno**: Banned 2026-09-22 +- Replace with: Bun. Owner ruling: *"deno is over, we're prioritising bun, and using bunx."* +- Bun is Node-compatible and reads `package.json` + `bun.lock`. ⚠ `bun run` does **not** read a `deno.json` `tasks` map - those task definitions must be ported to `package.json` scripts (invoked via `bun run`), per LANGUAGE-POLICY §1. + ❌ **JavaScript**: Actively being eliminated -- Replace with: ReScript → WASM, Deno (TypeScript if unavoidable) +- Replace with: AffineScript → typed-wasm, run under Bun. TypeScript is NOT a fallback. - Build tools: Use Rust alternatives (rspack, turbopack) - npm scripts: Replace with Justfile commands -❌ **Python**: Only in SaltStack (temporary) +❌ **Python**: Fully banned (the SaltStack exception was removed 2026-01-03) - RVC rewrite in progress: Target is Rust or Elixir - SaltStack replacement: Nickel configs → Bash scripts directly ### Approved Languages -✅ **ReScript** (OCaml soundness) - Frontend, type-safe web +✅ **AffineScript** (affine/linear types) - Frontend, compiles to typed-wasm +✅ **Bun** - JS runtime & package management (tier 1). `package.json` + `bun.lock`; `bunx ` for one-off tooling ✅ **Rust** - Systems programming, memory safety ✅ **Julia** - Scientific computing, CLI tools, high-performance ✅ **Ada + SPARK** - Safety-critical, formal verification @@ -580,7 +612,7 @@ This is **graduated trust without gatekeeping**—everyone can contribute, but s 4. **Test thoroughly** - Offline mode - Concurrent operations (CRDT conflicts) - - Security boundaries (Deno permissions) + - Security boundaries (container + typed-wasm sandbox) 5. **Document the fix** - Update 3-practice/SECURITY.md if vulnerability @@ -600,7 +632,7 @@ This is **graduated trust without gatekeeping**—everyone can contribute, but s - Offline-first considerations 3. **Then dive into specific details** - - Type safety guarantees (ReScript/Rust/Ada) + - Type safety guarantees (AffineScript/Rust/Ada) - CRDT operations if applicable - Supervision tree structure if Elixir @@ -627,9 +659,9 @@ This is **graduated trust without gatekeeping**—everyone can contribute, but s - Offline-first violations 3. **Suggest improvements for clarity** - - Type annotations (ReScript/Rust/Haskell) + - Type annotations (AffineScript/Rust/Haskell) - Error handling (Elixir supervision, Rust Result) - - Security boundaries (Deno permissions) + - Security boundaries (container + typed-wasm sandbox) 4. **Verify documentation is complete** - DocGementer compliance @@ -724,26 +756,29 @@ just check-offline # Offline-first capability ### Migrating from JavaScript/Python -#### JavaScript → ReScript/Rust +#### JavaScript → AffineScript/Rust + ```bash # 1. Identify JS files fd -e js -e jsx -# 2. For frontend: Convert to ReScript -# (Provides OCaml type safety, 10-100x faster compilation than TS) +# 2. For frontend: Convert to AffineScript +# (Affine/linear types; compiles to typed-wasm) -# 3. For Node scripts: Convert to Deno or Justfile tasks -# Deno provides secure-by-default runtime +# 3. For Node scripts: Convert to Bun or Justfile tasks +# Bun is Node-compatible: run the code, drop the runtime # 4. For build tools: Replace with Rust alternatives # webpack → rspack # esbuild → turbopack + ``` -#### Python → Rust/Elixir/Nickel +#### Python → AffineScript/Rust/Elixir/Julia/Nickel + ```bash -# 1. Identify Python files (exclude Salt states temporarily) -fd -e py | grep -v salt +# 1. Identify Python files (no exclusions - Python is fully banned) +fd -e py # 2. For scripts: Convert to Nickel or Bash # Nickel for configuration/validation @@ -754,6 +789,7 @@ fd -e py | grep -v salt # 4. For performance-critical: Convert to Rust # Memory safety, no GC pauses + ``` ### Implementing CRDT State @@ -774,18 +810,25 @@ defmodule MyApp.CRDTServer do end ``` -### Setting Up Deno Permissions - -```typescript -// CADRE router with explicit permissions -// deno run --allow-net=:8000 --allow-read=/public server.ts - -import { serve } from "https://deno.land/std/http/server.ts"; - -// No file access except /public -// No network access except port 8000 -// No environment variable access -// All explicit, auditable +### Setting Up Runtime Capability Boundaries + +```javascript +// CADRE router under Bun. +// bun run server.js +// +// ⚠ Bun has NO per-process permission flags. There is no `--allow-net` +// equivalent, so the boundary is declared by the deployment, not the command: +// - bind only the port you serve on +// - mount only the paths you read (rootless Podman, read-only volumes) +// - pass only the environment variables you need +// Application logic that must be sandboxed compiles to typed-wasm instead. + +Bun.serve({ + port: 8000, + fetch(req) { + return new Response("ok"); + }, +}); ``` ### Writing SPARK Proofs (Ada) @@ -813,8 +856,8 @@ end Process_Data; ### Technologies - **Nickel**: https://nickel-lang.org/ -- **ReScript**: https://rescript-lang.org/ -- **Deno**: https://deno.land/ +- **AffineScript** (canonical location TBD — this guide is never-GitHub, and no public GitLab project exists yet, so no link until one is established) +- **Bun**: https://bun.sh/ - **CRDTs**: https://crdt.tech/ - **SPARK**: https://www.adacore.com/about-spark - **Chainguard Wolfi**: https://chainguard.dev/unchained/introducing-wolfi-the-first-linux-un-distro @@ -835,7 +878,7 @@ end Process_Data; 2. **Offline-First**: Intermittent connectivity never blocks work 3. **Formally Verified**: Correctness is care, use SPARK/Coq where critical 4. **Community Over Ego**: TPCF graduated trust model -5. **Post-JavaScript**: Eliminate JS/Python, use ReScript/Rust/Elixir/Ada/Haskell +5. **Post-JavaScript**: Eliminate JS/Python, use AffineScript/Rust/Elixir/Ada/Haskell 6. **Holistic Lifecycle**: Consider upstream dependencies to downstream human impact 7. **Maximum Principal Reduction**: Only necessary processing, minimal exposure 8. **Mutually Assured Accountability**: MAA framework embedded in architecture @@ -851,14 +894,14 @@ end Process_Data; - ✅ Which TPCF perimeter does this affect? ### Never Do -- ❌ Add JavaScript/Python without explicit justification +- ❌ Add Python, TypeScript, ReScript or Deno at all - all four are banned outright - ❌ Use Docker (always Podman) - ❌ Use GitHub (always GitLab) - ❌ Add dependencies without vendoring/pinning - ❌ Create online-only features - ❌ Skip SPDX headers - ❌ Ignore accessibility -- ❌ Bypass Deno permissions +- ❌ Bypass the sandbox or capability boundary ### When in Doubt - Ask for clarification (don't assume) diff --git a/rhodium-standard-repositories/satellites/palimpsest-license/RESEARCH/story.scm b/rhodium-standard-repositories/satellites/palimpsest-license/RESEARCH/story.scm index 02ed7293a..6c37508c0 100644 --- a/rhodium-standard-repositories/satellites/palimpsest-license/RESEARCH/story.scm +++ b/rhodium-standard-repositories/satellites/palimpsest-license/RESEARCH/story.scm @@ -1,3 +1,5 @@ +;; SPDX-License-Identifier: MPL-2.0 +;; SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) ;;; ================================================== ;;; story.scm — A Voyage of Discovery ;;; ================================================== @@ -393,4 +395,4 @@ ;;; ================================================== ;;; END story.scm -;;; ================================================== +;;; ================================================== \ No newline at end of file diff --git a/rhodium-standard-repositories/satellites/palimpsest-license/story.scm b/rhodium-standard-repositories/satellites/palimpsest-license/story.scm index 02ed7293a..6c37508c0 100644 --- a/rhodium-standard-repositories/satellites/palimpsest-license/story.scm +++ b/rhodium-standard-repositories/satellites/palimpsest-license/story.scm @@ -1,3 +1,5 @@ +;; SPDX-License-Identifier: MPL-2.0 +;; SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) ;;; ================================================== ;;; story.scm — A Voyage of Discovery ;;; ================================================== @@ -393,4 +395,4 @@ ;;; ================================================== ;;; END story.scm -;;; ================================================== +;;; ================================================== \ No newline at end of file diff --git a/rhodium-standard-repositories/spec.scm/language-policy.scm b/rhodium-standard-repositories/spec.scm/language-policy.scm index 34202484e..3d72a1ea4 100644 --- a/rhodium-standard-repositories/spec.scm/language-policy.scm +++ b/rhodium-standard-repositories/spec.scm/language-policy.scm @@ -1,11 +1,49 @@ ;; SPDX-License-Identifier: MPL-2.0 -;; SPDX-FileCopyrightText: 2024-2025 hyperpolymath +;; SPDX-FileCopyrightText: 2024-2026 hyperpolymath ;; ;; RSR Language Policy - Machine Readable ;; Derived from CCCP (Campaign for Cooler Coding and Programming) ;; -;; VERSION: 1.0.0 (FROZEN 2025-12-27) -;; Language allowed/banned lists are immutable in v1.x. +;; MODULE-VERSION: 2.0.0 (2026-09-22) - UNFROZEN from 1.0.0 +;; +;; THREE different version numbers meet in this file. Conflating them is what +;; let it drift, so they are named here explicitly: +;; 1. MODULE-VERSION (this line, 2.0.0) - the version of THIS module's +;; allowed/banned lists. A ban is a MAJOR bump. +;; 2. spec/LANGUAGE-POLICY.adoc :revnumber: (1.6.0, 2026-09-22) - the +;; canonical human-readable policy document this file mirrors. +;; 3. (spec-version) below returns the RSR SPECIFICATION version from +;; (rsr version) - 1.0.0, status `frozen`. That is a DIFFERENT artefact +;; with its own freeze and is deliberately NOT touched here. +;; +;; v1.0.0 of this module was frozen 2025-12-27 with the note "Language +;; allowed/banned lists are immutable in v1.x". That freeze outlived the policy +;; it protected: four bans were ruled after it and none could be recorded here, +;; so this file drifted into asserting the OPPOSITE of ratified policy -- +;; `(bun . ((replacement . deno)))` while spec/LANGUAGE-POLICY.adoc SS1 ranks +;; Bun tier 1 and bans Deno outright. A machine-readable twin that contradicts +;; its own spec is worse than no twin, because tooling trusts it silently. +;; +;; Concretely, the freeze also hid a live defect: `.ts` was claimed by BOTH +;; the allowed `deno` entry and the banned `typescript` entry, and because +;; file-extension-language searches allowed FIRST, it resolved `.ts` to `deno` +;; -- silently blessing a banned extension. See the warning on that function. +;; +;; The lists are therefore versioned, not frozen. +;; +;; Ratified changes since 1.0.0, all now reflected below: +;; 2026-01-03 Python's "SaltStack only" exception REMOVED (fully banned). +;; 2026-04-10 V-lang banned -> Zig. ATS2 banned -> Idris2 / Rust-SPARK. +;; "Rust" redefined estate-wide as "Rust/SPARK". +;; 2026-04-30 ReScript banned -> AffineScript (directly; not via ReScript). +;; 2026-05-28 Zig made the estate default for APIs/FFIs/gateways/client SDKs. +;; 2026-08-27 TypeScript banned -> AffineScript ("no typescript ... that +;; should not exist at all"). It is not a fallback tier. +;; 2026-09-22 Deno banned -> Bun. Bun is tier 1 ("deno is over, we're +;; prioritising bun, and using bunx"). +;; +;; CANONICAL SOURCE: spec/LANGUAGE-POLICY.adoc. This file MUST mirror it. +;; Where they disagree, the .adoc wins and this file is the defect. (define-module (rsr language-policy) #:use-module (rsr version) @@ -18,52 +56,103 @@ ;; Allowed languages with use cases (define allowed-languages - '((rescript . ((extensions . (".res" ".resi")) - (use-case . "Primary application code") - (compiles-to . "javascript"))) - (rust . ((extensions . (".rs")) - (use-case . "Systems, performance, WASM") - (preferred-for . ("cli" "wasm")))) - (deno . ((extensions . (".ts" ".js")) ; only via Deno runtime - (use-case . "Runtime & package management") - (replaces . ("node" "npm" "bun")))) - (gleam . ((extensions . (".gleam")) - (use-case . "Backend services") - (targets . ("beam" "javascript")))) - (ocaml . ((extensions . (".ml" ".mli")) - (use-case . "Compilers, formal methods"))) - (ada . ((extensions . (".adb" ".ads")) - (use-case . "Safety-critical systems") - (verification . "spark"))) - (julia . ((extensions . (".jl")) - (use-case . "Data processing, batch scripts"))) - (guile . ((extensions . (".scm")) - (use-case . "State/meta files") - (required-for . ("STATE.scm" "META.scm" "ECOSYSTEM.scm")))) - (nickel . ((extensions . (".ncl")) - (use-case . "Configuration language"))) - (bash . ((extensions . (".sh" ".bash")) - (use-case . "Scripts, automation") - (note . "Keep minimal"))))) + '((affinescript . ((extensions . (".affine")) + (use-case . "Primary application code") + (compiles-to . "typed-wasm") + (note . "RS/TS/JS -> AffineScript -> typed-wasm"))) + (bun . ((extensions . ()) + (use-case . "JS runtime & package management (tier 1)") + (replaces . ("node" "npm" "deno")) + (manifest . ("package.json" "bun.lock")) + (note . "bunx for one-off tooling"))) + (pnpm . ((extensions . ()) + (use-case . "JS package management (tier 2)") + (tier . 2) + (preferred-instead . bun) + (rationale . "Only where an upstream toolchain requires a node_modules layout") + (note . "Tier 2: strict node_modules, content-addressable store; second reach after Bun."))) + (npm . ((extensions . ()) + (use-case . "JS package management of last resort (tier 3)") + (tier . 3) + (preferred-instead . bun) + (rationale . "Supply chain risks") + (note . "Tier 3: permitted, never preferred - NOT banned. Order is bun (1) -> pnpm (2) -> npm (3) since the 2026-09-22 Deno ban vacated rank 2. It lives here, not in banned-languages, because `language-allowed?` answers from THIS list: a tier-3 fallback listed as banned reads as prohibited to every consumer. package-lock.json must still not be tracked (standards#67)."))) + (rust . ((extensions . (".rs")) + (use-case . "Systems, performance, WASM, CLI, safety-critical") + (preferred-for . ("cli" "wasm")) + (verification . "spark") + (note . "\"Rust\" always means Rust/SPARK (2026-04-10)"))) + (zig . ((extensions . (".zig")) + (use-case . "APIs, FFIs, gateways, client SDKs (estate default)") + (note . "Default since 2026-05-28; Idris2 owns ABIs"))) + (idris2 . ((extensions . (".idr")) + (use-case . "Formal verification (primary, ABI-style proofs)"))) + (agda . ((extensions . (".agda")) + (use-case . "Formal verification (foundational)") + (note . "Constructive only - no postulates in load-bearing tracks"))) + (gleam . ((extensions . (".gleam")) + (use-case . "Backend services") + (targets . ("beam" "javascript")))) + (elixir . ((extensions . (".ex" ".exs")) + (use-case . "Backend services, distributed systems"))) + (haskell . ((extensions . (".hs")) + (use-case . "Type-heavy tools, registry validation"))) + (ocaml . ((extensions . (".ml" ".mli")) + (use-case . "AffineScript compiler, formal methods"))) + (ada . ((extensions . (".adb" ".ads")) + (use-case . "Safety-critical systems (legacy)") + (verification . "spark") + (note . "Rust/SPARK absorbs new work; no new pure-Ada projects"))) + (julia . ((extensions . (".jl")) + (use-case . "Data processing, batch scripts"))) + (guile . ((extensions . (".scm")) + (use-case . "State/meta files, package manifests") + (required-for . ("STATE.scm" "META.scm" "ECOSYSTEM.scm")) + (note . "Guix is primary package management (guix.scm)"))) + (nickel . ((extensions . (".ncl")) + (use-case . "Configuration language"))) + (javascript . ((extensions . (".js")) + (use-case . "Only where AffineScript cannot reach") + (note . "Transitional. MCP/LSP glue, VSCode host, npm front door. Prefer .affine."))) + (bash . ((extensions . (".sh" ".bash")) + (use-case . "Scripts, automation") + (note . "Keep minimal"))))) ;; Banned languages with replacements (define banned-languages '((typescript . ((extensions . (".ts" ".tsx")) - (replacement . rescript) - (rationale . "Unsound gradual typing"))) - (nodejs . ((replacement . deno) - (rationale . "Insecure by default"))) - (npm . ((replacement . deno) - (rationale . "Supply chain risks"))) - (bun . ((replacement . deno) - (rationale . "Supply chain risks"))) + (replacement . affinescript) + (rationale . "Unsound gradual typing; AffineScript governs") + (banned-on . "2026-08-27") + (carve-outs . ("**/*.d.ts" "**/bindings/ts/**" "**/vscode/**")))) + (rescript . ((extensions . (".res" ".resi")) + (replacement . affinescript) + (rationale . "Superseded; migrate .res directly to .affine") + (banned-on . "2026-04-30"))) + (deno . ((extensions . ()) + (replacement . bun) + (rationale . "Owner ruling: \"deno is over, we're prioritising bun, and using bunx\"") + (banned-on . "2026-09-22") + (note . "Shrink-only ledger: .machine_readable/deno-allow.txt in standards"))) + (nodejs . ((replacement . bun) + (rationale . "Bun is Node-compatible; run the code, drop the runtime"))) + (yarn . ((replacement . bun) + (rationale . "Not in the tier list at all"))) + (vlang . ((extensions . (".v")) + (replacement . zig) + (rationale . "Banned 2026-04-10; migration completed 2026-05-28") + (carve-outs . ("v-cartridge/" "v-adapter/" "v-bindings/" "v-client/")) + (note . "WARNING .v is shared with Coq proof scripts and Verilog - check before flagging"))) + (ats2 . ((extensions . (".dats" ".sats")) + (replacement . (idris2 rust)) + (rationale . "Rejected in favour of Idris2 and Rust/SPARK"))) (go . ((extensions . (".go")) (replacement . rust) (rationale . "Error handling, generics"))) (python . ((extensions . (".py")) - (replacement . (rescript rust)) + (replacement . (affinescript rust julia)) (rationale . "No static types") - (exception . "SaltStack only"))) + (note . "FULLY banned - the \"SaltStack only\" exception was removed 2026-01-03"))) (java . ((extensions . (".java")) (replacement . rust) (rationale . "JVM overhead"))) @@ -72,7 +161,15 @@ (rationale . "Platform lock-in"))) (swift . ((extensions . (".swift")) (replacement . (tauri dioxus)) - (rationale . "Platform lock-in"))))) + (rationale . "Platform lock-in"))) + (react-native . ((replacement . (tauri dioxus)) + (rationale . "Google/Meta platform lock-in"))) + (dart . ((extensions . (".dart")) + (replacement . (tauri dioxus)) + (rationale . "Flutter/Dart - Google lock-in"))) + (make . ((extensions . ("Makefile" "makefile" ".mk")) + (replacement . (mustfile justfile)) + (rationale . "Replaced by Mustfile/justfile estate-wide"))))) ;; Check if a language is allowed (define (language-allowed? lang) @@ -86,6 +183,10 @@ #f))) ;; Map file extension to language +;; +;; WARNING: allowed-languages is searched FIRST, so an extension claimed by both +;; lists resolves to the allowed entry. No extension is currently duplicated +;; across the two lists; keep it that way, or this silently blesses a banned one. (define (file-extension-language ext) (let loop ((langs (append allowed-languages banned-languages))) (if (null? langs) @@ -98,6 +199,8 @@ lang-name (loop (cdr langs))))))) -;; Return spec version for this module +;; Return the RSR SPECIFICATION version from (rsr version) -- NOT this module's +;; own MODULE-VERSION (see header). The two are independent artefacts; the +;; spec version is 1.0.0 / frozen, this module is 2.0.0 / unfrozen. (define (spec-version) (version-string)) diff --git a/scripts/check-actions-lock-gate.sh b/scripts/check-actions-lock-gate.sh index dcea7c581..7000509f7 100755 --- a/scripts/check-actions-lock-gate.sh +++ b/scripts/check-actions-lock-gate.sh @@ -12,11 +12,20 @@ # lockfile absent, → RED: an unpinned `uses:` is a violation today, lock or # unpinned refs no lock. # lockfile absent, → grace window: `::warning` + "NOT YET ENFORCED" and exit -# all SHA-pinned 0 until ENFORCE_ACTIONS_LOCK_FROM; `::error` + exit 1 +# all SHA-pinned 0 until ENFORCE_ACTIONS_LOCK_FROM; `::error` + exit 3 # from that date. The sweep (spec §10 step 5) lands the # lockfiles before the date; the date makes the gate # real without red-flooding 300 repos on day one. # +# Exit contract (consumed by governance-reusable.yml's ledger exemption): +# 0 = pass (verified lock, or lockless+pinned inside the grace window) +# 1 = LIVE VIOLATION (unpinned refs, or verifier-rejected lock) — never exempt +# 2 = infrastructure failure (no workflows dir, no verifier) — never exempt +# 3 = missing-lock debt ONLY (lockless, every ref pinned, grace window +# closed) — the single state the shrink-only ledger may excuse. +# Collapsing 3 into 1 would let the ledger wave unpinned refs and corrupt +# locks through with the debt it was built to excuse. +# # Test seams (used by scripts/tests/check-actions-lock-gate-test.sh): # LOCK_TODAY override today's date (YYYY-MM-DD) # ENFORCE_ACTIONS_LOCK_FROM override the cutoff (default 2026-10-01) @@ -70,5 +79,5 @@ if [[ "$TODAY" < "$ENFORCE_FROM" ]]; then exit 0 fi -echo "::error::actions-lock gate: no $WF_DIR/actions.lock and the grace window closed on $ENFORCE_FROM (today is $TODAY). Run scripts/update-actions-lock.sh and commit the lockfile." -exit 1 +echo "::error::actions-lock gate: no $WF_DIR/actions.lock and the grace window closed on $ENFORCE_FROM (today is $TODAY). MISSING-LOCK DEBT (exit 3): run scripts/update-actions-lock.sh and commit the lockfile." +exit 3 diff --git a/scripts/check-exemption-ratchet.sh b/scripts/check-exemption-ratchet.sh index 1efb984a3..e2230c879 100755 --- a/scripts/check-exemption-ratchet.sh +++ b/scripts/check-exemption-ratchet.sh @@ -129,6 +129,8 @@ LEDGERS=( ".hypatia-ignore" ".gitleaks.toml" ".machine_readable/root-allow.txt" + ".machine_readable/lock-allow.txt" + ".machine_readable/deno-allow.txt" ) echo "Exemption ratchet — comparing against ${BASE_REF}" diff --git a/scripts/tests/check-actions-lock-gate-test.sh b/scripts/tests/check-actions-lock-gate-test.sh index 8e8584d45..4aa48ddeb 100755 --- a/scripts/tests/check-actions-lock-gate-test.sh +++ b/scripts/tests/check-actions-lock-gate-test.sh @@ -59,8 +59,8 @@ echo "=== no lockfile ===" d=$(mkwf c pinned) assert "no lock, pinned, before cutoff → 0 NOT YET ENFORCED" 0 "NOT YET ENFORCED" env LOCK_TODAY="$BEFORE" bash "$GATE" "$d" assert "no lock, pinned, before cutoff emits ::warning" 0 "::warning::" env LOCK_TODAY="$BEFORE" bash "$GATE" "$d" -assert "no lock, pinned, on cutoff → 1" 1 "grace window closed" env LOCK_TODAY="$AFTER" bash "$GATE" "$d" -assert "no lock, pinned, custom cutoff honoured → 1" 1 "::error::" env LOCK_TODAY="2026-09-03" ENFORCE_ACTIONS_LOCK_FROM="2026-09-02" bash "$GATE" "$d" +assert "no lock, pinned, on cutoff → 3 (ledgerable debt)" 3 "MISSING-LOCK DEBT" env LOCK_TODAY="$AFTER" bash "$GATE" "$d" +assert "no lock, pinned, custom cutoff honoured → 3" 3 "::error::" env LOCK_TODAY="2026-09-03" ENFORCE_ACTIONS_LOCK_FROM="2026-09-02" bash "$GATE" "$d" d=$(mkwf e unpinned) assert "no lock, unpinned, before cutoff → 1 (no grace for unpinned)" 1 "not SHA-pinned" env LOCK_TODAY="$BEFORE" bash "$GATE" "$d" assert "no lock, unpinned, after cutoff → 1" 1 "not SHA-pinned" env LOCK_TODAY="$AFTER" bash "$GATE" "$d" diff --git a/scripts/tests/governance-reusable-contract-test.sh b/scripts/tests/governance-reusable-contract-test.sh index ff0a91dab..af0562819 100644 --- a/scripts/tests/governance-reusable-contract-test.sh +++ b/scripts/tests/governance-reusable-contract-test.sh @@ -15,7 +15,13 @@ fail() { exit 1 } -helper_checkout="$(grep -F -A 18 -- '- name: Checkout the pinned Standards policy helpers' "$GOVERNANCE")" +# `set -e` + a command substitution is a silence trap: when the grep matches +# nothing it exits 1 and the assignment terminates the script BEFORE `fail()` +# can name the missing assertion — a contract test that cannot say why it +# failed is the same vacuous class it exists to catch. Capture, then assert. +helper_checkout="$(grep -F -A 18 -- '- name: Checkout the pinned Standards policy helpers' "$GOVERNANCE" || true)" +[ -n "$helper_checkout" ] || + fail "governance workflow has no step named 'Checkout the pinned Standards policy helpers'" # GitHub expression is an asserted literal. # shellcheck disable=SC2016 printf '%s\n' "$helper_checkout" | grep -Eq 'ref: [0-9a-f]{40}$' || diff --git a/scripts/tests/validate-spdx-test.sh b/scripts/tests/validate-spdx-test.sh index 76949b6eb..6498c1055 100755 --- a/scripts/tests/validate-spdx-test.sh +++ b/scripts/tests/validate-spdx-test.sh @@ -68,5 +68,22 @@ cp "$T/README.md" "$T/parity/README.md" cp "$T/good.sh" "$T/parity/good.sh" ck_scan "PARITY: full-scan mode ignores the same non-source files" 0 "parity" +# RSR COVERAGE: .scm files carry `;;`-style headers and must be checked +# (previously the extension list skipped them, so a staged RSR seed file +# passed without its required header). Empty identifiers and trailing junk +# must fail: they pass a prefix check but fail SPDX tooling. +printf ';; SPDX-License-Identifier: MPL-2.0\n(display 1)\n' > "$T/good.scm" +printf ';;; SPDX-License-Identifier: MPL-2.0\n;;; banner\n' > "$T/banner.scm" +printf '(display 1)\n' > "$T/bad.scm" +printf ';; SPDX-License-Identifier:\n(display 1)\n' > "$T/empty.scm" +printf '# SPDX-License-Identifier: MPL-2.0; copyright me\necho\n' > "$T/junk.sh" +printf '(* SPDX-License-Identifier: MIT *)\n' > "$T/good.ml" +ck "scm with ;; header must PASS" 0 "good.scm" +ck "scm with ;;; banner header must PASS" 0 "banner.scm" +ck "headerless .scm must FAIL" 1 "bad.scm" +ck "empty SPDX identifier must FAIL" 1 "empty.scm" +ck "trailing junk after expression must FAIL" 1 "junk.sh" +ck "OCaml (* *) terminator must PASS" 0 "good.ml" + printf '\n%s passed, %s failed\n' "$pass" "$fail" [ "$fail" -eq 0 ] || exit 1