#!/usr/bin/env bash # The delta-inventory guard (#200, enforcing #197 D3's standing constraint). # # Forge-specific behaviour lives in the files docs/UPSTREAM-SYNC.md names, # and nowhere else. # # WHY. This tree carries upstream's content plus a forge delta, forever — the # sync is recurring and upstream is read-only (#197 D3). What makes that # tractable is not luck: the 0.6.0 sync cost 18 conflict hunks across 10 files # because the delta sits in six files upstream never touches. Scatter # `forge_detect` into a seventh and every future sync pays for it, in a place # nobody chose. # # So this fails on the PR that scatters it, rather than on the sync after next # — the same reason changelog-monotonic guards a property no single tree can # be asked about. # # IT RUNS OFFLINE, AND IT DOES NOT PASS BY ABSENCE. Tests must not need the # network, and a guard that goes green when it cannot see its input is the # blind-sweep shape this repo keeps writing issues about. So the inventory is # read from the document and the tree is scanned for forge markers — both local # — and a missing or empty `.upstream-ref` is a REFUSAL, not a skip. set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=test/harness.sh . "$ROOT/test/harness.sh" DOC="$ROOT/docs/UPSTREAM-SYNC.md" REF="$ROOT/.upstream-ref" # --- the recorded upstream ref ------------------------------------------------ check "the sync document exists" 0 "" test -f "$DOC" check "the upstream ref is recorded" 0 "" test -f "$REF" # THE OBJECT IS MANDATORY. An earlier head accepted "UNVERIFIABLE-HERE" when # upstream's commit was not in the local store, which contradicted this issue's # own "must not pass by absence" and made the check decorative in exactly the # environment it matters in (@codex-reviewer-andresmgsl, #200 review). # # "Runs offline" means the TEST reads local evidence, not that CI may omit the # evidence and pass. ci.yml fetches the recorded object before running the # suite; if it is missing here, that is a refusal. recorded_ref() { grep -vE '^[[:space:]]*(#|$)' "$REF" | head -n1; } ref_is_recorded() { [ -n "$(recorded_ref)" ]; } check "...and is not blank — an unrecorded ref is a refusal, not a skip" 0 "" \ ref_is_recorded # Full 40, not a short prefix: a short SHA is ambiguous across a growing repo # and cannot be compared byte-wise between the runbook, the CHANGELOG and here. ref_is_full_sha() { printf '%s' "$(recorded_ref)" | grep -qE '^[0-9a-f]{40}$'; } check "...and is a FULL 40-character SHA" 0 "" ref_is_full_sha ref_object_present() { git -C "$ROOT" cat-file -e "$(recorded_ref)^{commit}" 2>/dev/null; } check "the recorded commit is present locally — absent is a refusal, not a skip" 0 "" \ ref_object_present ref_is_ancestor() { git -C "$ROOT" merge-base --is-ancestor "$(recorded_ref)" HEAD 2>/dev/null; } check "...and is an ancestor of HEAD, so main really carries what is recorded" 0 "" \ ref_is_ancestor # --- the inventory ------------------------------------------------------------ # The document's inventory table: the first `code` span of each table row in # the "Where the forge delta lives" section. inventory() { awk '/^## Where the forge delta lives/ { on = 1; next } on && /^## / { on = 0 } on && /^\| `/ { gsub(/^\| `/, ""); sub(/`.*$/, ""); print }' "$DOC" } inventory_size() { inventory | wc -l; } inventory_is_populated() { [ "$(inventory_size)" -ge 6 ]; } check "the inventory names at least the six known delta files" 0 "" \ inventory_is_populated # Exact for a file entry, `dir/` + one path separator for a directory entry. # Prefix matching accepted `drills-old/x` for `drills/` and `lib/forge.sh.backup` # for `lib/forge.sh` (@codex-reviewer-andresmgsl, #200 review). in_inventory() { # $1 = repo-relative path local entry while IFS= read -r entry; do [ -n "$entry" ] || continue case "$entry" in */) case "$1" in "$entry"*) return 0 ;; esac ;; *) [ "$1" = "$entry" ] && return 0 ;; esac done < <(inventory) return 1 } check "a listed file is recognised" 0 "" in_inventory lib/forge.sh check "...including one listed as a directory" 0 "" in_inventory drills/0.4.1.md check "an unlisted file is not" 1 "" in_inventory lib/version.sh # --- the scan ----------------------------------------------------------------- # What "forge-specific" means, mechanically: the selector's own verbs, the # environment override that drives them, and — on a workflow, which has no # shell to call the selector from — a forge decision written inline. # # The earlier version scanned only shell under lib/ actions/ bin/ .github/scripts. # That missed three categories the inventory itself claims to govern: workflows, # .github/labels.conf and drills/. Merged main already had blind spots there — # labels.yml decides on GITHUB_SERVER_URL and declares a client, refs-guard.yml # carries a positive GitHub-only scheduling decision # (@codex-reviewer-andresmgsl, #200 review). FORGE_MARKERS='forge_detect|forge_select|forge_preflight|forge_client|CEREMONY_FORGE\b|CEREMONY_FORGE_CLIENT|GITHUB_SERVER_URL|github\.server_url' # SCAN_ROOT is a parameter so the teeth below can drive the REAL check against # a constructed tree. The previous mutation proved the predicates and never # invoked no_unlisted — the guard could have been `return 0` and still passed. scan_root() { printf '%s' "${SCAN_ROOT:-$ROOT}"; } # DISCOVERY IS DERIVED FROM THE TREE, not from a list of directories, depths # and extensions. The earlier version hand-picked five globs and therefore # could not see `actions/*/action.yml` — this repository's normal composite # structure, where a client declaration is exactly a forge decision — or a # workflow written `.yaml` rather than `.yml`. @codex-reviewer-andresmgsl # constructed both and the guard stayed 21/21 green, which is the whole # argument against maintaining a glob list. # # So: walk everything, then EXCLUDE by class, and let content classify the # rest. Excluding is safer than including because a new file type arrives # scanned rather than invisible. # # test/ the harness stubs and asserts these tokens by design # changelog.d/ prose fragments # *.md prose. `drills/` stays in the INVENTORY because its records # are forge-specific by content, but a record mentioning a # selector verb in prose is not a decision, and scanning prose # for decisions is the mistake this guard's own comment # handling exists to avoid. # # DISCOVERY IS GIT'S, NOT THE FILESYSTEM'S. An earlier head said "tracked" and # used `find`, which walks the working directory and knows nothing about the # index. That is not pedantry: `ci.yml` extracts shellcheck and actionlint # tarballs INTO the checkout before the suite runs, and any developer cache # sits there too. @codex-reviewer-andresmgsl reproduced a false red with one # untracked file. `git ls-files -z` makes "tracked" executable rather than # prose. scanned_paths() { local root; root="$(scan_root)" git -C "$root" ls-files -z 2>/dev/null \ | tr '\0' '\n' \ | grep -vE '^(test/|changelog\.d/)' \ | grep -vE '\.md$' \ | sort } forge_specific_files() { local root rel; root="$(scan_root)" while IFS= read -r rel; do [ -n "$rel" ] || continue sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$root/$rel" 2>/dev/null \ | grep -qE "$FORGE_MARKERS" && printf '%s\n' "$rel" done < <(scanned_paths) return 0 } # A file that merely CALLS the shim is not where the forge is decided — every # reconciler calls forge_preflight and that is the point of the shim. A file # that DECLARES a client is a different thing and is NOT exempt: it is a # deliberate forge-delta location and belongs in the inventory # (@codex-reviewer-andresmgsl). SHIM_CONSUMERS='actions/issueflow-reconcile/issueflow-reconcile.sh actions/labels-reconcile/labels-reconcile.sh actions/labels-scope/labels-scope.sh lib/facts.sh lib/ruling.sh lib/attention.sh .github/workflows/release.yml' declares_a_client() { sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$(scan_root)/$1" \ | grep -qE 'CEREMONY_FORGE_CLIENT[=:]' } unlisted_deciders() { local rel while IFS= read -r rel; do [ -n "$rel" ] || continue if grep -qxF "$rel" <<<"$SHIM_CONSUMERS" && ! declares_a_client "$rel"; then continue fi in_inventory "$rel" && continue printf '%s\n' "$rel" done < <(forge_specific_files) } no_unlisted() { local found found="$(unlisted_deciders)" [ -z "$found" ] || { printf 'forge-specific but not in docs/UPSTREAM-SYNC.md:\n' >&2 printf ' %s\n' "$found" >&2 return 1 } } check "every forge-deciding file is named in the inventory" 0 "" no_unlisted # --- teeth -------------------------------------------------------------------- # These drive the REAL top-level check against a constructed tree, via # SCAN_ROOT. The earlier version asserted the predicates separately and never # invoked no_unlisted — so the guard could have been replaced with `return 0` # and both "must-fail" rows would still have passed # (@codex-reviewer-andresmgsl, #200 review). A mutation test that cannot fail # when the thing it guards is deleted is the shape this repo keeps filing # issues about. TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT # A tiny GIT repository, because discovery is git's now: a fixture that is only # a directory would be invisible to `ls-files` and every must-fail below would # pass vacuously. fixture_tree() { # -> a tracked tree with no forge-deciding files local t="$TMP/tree" rm -rf "$t" mkdir -p "$t/lib" "$t/.github/workflows" printf '#!/usr/bin/env bash\necho hello\n' >"$t/lib/plain.sh" printf 'name: ci\non: [push]\n' >"$t/.github/workflows/plain.yml" git -C "$t" init -q 2>/dev/null git -C "$t" add -A 2>/dev/null printf '%s' "$t" } track() { git -C "$root" add -A 2>/dev/null; } root="$(fixture_tree)" clean_tree_passes() { SCAN_ROOT="$root" no_unlisted; } check "the guard is green on a tree with no forge decisions" 0 "" clean_tree_passes # MUST FAIL: a shell file that decides the forge, in no inventory entry. # shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold printf '%s\n' '#!/usr/bin/env bash' 'case "$(forge_detect)" in forgejo) : ;; esac' \ >"$root/lib/scattered.sh" track; scattered_shell() { SCAN_ROOT="$root" no_unlisted; } check "a new SHELL file deciding the forge fails the real guard" 1 "" scattered_shell check "...and the failure names the file" 1 "lib/scattered.sh" scattered_shell rm -f "$root/lib/scattered.sh"; track # MUST FAIL on a NON-SHELL surface too, so coverage cannot regress to the old # shell-only glob: a workflow deciding on the server URL. # shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold printf '%s\n' 'name: x' 'on: [push]' 'jobs:' ' j:' ' steps:' \ ' - run: [ "$GITHUB_SERVER_URL" = https://github.com ] || exit 0' \ >"$root/.github/workflows/scattered.yml" track; scattered_workflow() { SCAN_ROOT="$root" no_unlisted; } check "a new WORKFLOW deciding the forge fails it too" 1 "" scattered_workflow check "...naming that file" 1 ".github/workflows/scattered.yml" scattered_workflow rm -f "$root/.github/workflows/scattered.yml"; track # @codex-reviewer-andresmgsl's two reproductions, verbatim as fixtures. Both # passed the hand-picked-glob version 21/21, which is why discovery is derived # from the tree now. Deleting a discovery class must make these red. mkdir -p "$root/actions/unlisted-forge-decision" printf '%s\n' 'name: x' 'runs:' ' using: composite' ' steps:' \ ' - shell: bash' ' env:' ' CEREMONY_FORGE_CLIENT: gh' \ ' run: true' >"$root/actions/unlisted-forge-decision/action.yml" track; composite_action_seen() { SCAN_ROOT="$root" no_unlisted; } check "a forge declaration in actions/*/action.yml fails the guard" 1 \ "actions/unlisted-forge-decision/action.yml" composite_action_seen rm -rf "$root/actions/unlisted-forge-decision"; track # ...and a workflow written .yaml rather than .yml — `*.yml` was never a # complete workflow surface. # shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold printf '%s\n' 'name: x' 'on: [push]' 'jobs:' ' j:' \ " if: github.server_url == 'https://github.com'" ' steps: []' \ >"$root/.github/workflows/unlisted-forge-decision.yaml" track; yaml_workflow_seen() { SCAN_ROOT="$root" no_unlisted; } check "...and one in a .yaml workflow does too" 1 \ ".github/workflows/unlisted-forge-decision.yaml" yaml_workflow_seen rm -f "$root/.github/workflows/unlisted-forge-decision.yaml"; track check "...leaving the fixture tree green again" 0 "" clean_tree_passes # A declaration is a delta location even in a file that would otherwise read as # a shim consumer, so the consumer allow-list cannot hide one. mkdir -p "$root/lib" printf '%s\n' '#!/usr/bin/env bash' 'CEREMONY_FORGE_CLIENT=gh' >"$root/lib/facts.sh" track; declared_not_exempt() { SCAN_ROOT="$root" no_unlisted; } check "a shim consumer that DECLARES a client is not exempt" 1 "lib/facts.sh" \ declared_not_exempt rm -f "$root/lib/facts.sh"; track track check "...and the tree is green again once it is gone" 0 "" clean_tree_passes # UNTRACKED input is not source. ci.yml extracts shellcheck and actionlint # tarballs into the checkout before the suite runs, and a developer cache sits # there too; parsing either is a false red on something outside the repository # property (@codex-reviewer-andresmgsl, #200 review — reproduced with one file). printf 'CEREMONY_FORGE_CLIENT=gh\n' >"$root/local-tool-cache.txt" check "an UNTRACKED marker-bearing file is ignored" 0 "" clean_tree_passes check "...and is still ignored once it carries a decision" 0 "" clean_tree_passes track check "...but the moment it is TRACKED the guard sees it" 1 "local-tool-cache.txt" \ clean_tree_passes git -C "$root" rm -q --cached local-tool-cache.txt 2>/dev/null rm -f "$root/local-tool-cache.txt" check "...and removing it restores green" 0 "" clean_tree_passes # Path matching, both boundaries. check "a directory entry does not match a sibling with the same prefix" 1 "" \ in_inventory drills-old/0.4.1.md check "a file entry does not match a longer path" 1 "" in_inventory lib/forge.sh.backup check "...while the real ones still match" 0 "" in_inventory drills/0.4.1.md summary