Files
home-network-k3s-impl/ci/touched-requirements.sh
T
jakman85andClaude Opus 4.8 27bc02471c ci: exclude comment-only prose from touched-requirements scan; gate.yml -> make -C ci gate
HAND-OFF 1 (make -C ci gate parity).

touched-requirements.sh gained a third scoping rule: after stripping the diff
marker, whole-line YAML/shell `#` comments are dropped BEFORE the R-ID grep. A
requirement cited only in a code-file comment is prose (a REFERENCE), not a
declaration -- the same logic rule 2 already applies to `*.md`. This unblocks the
self-referential trap where editing gate.yml's header (which cites R-TEST-06, a
known-uncovered ID enforced by the spec-immutability STAGE, not a Chainsaw test)
re-attributed R-TEST-06 so the coverage `--require` check false-blocked the merge.
Conservative: only whole-line comments are dropped; an R-ID on a real code/spec
line is still detected, and authoritative declarations never live in a bare comment.

gate.yml now invokes the aggregate `make -C ci gate` (ci/gate.sh) instead of the
individual stage scripts, so CI runs byte-for-byte the same gate a human runs in the
devcontainer (R-TEST-02) and applies the R-TEST-03 decision. A BLOCKED gate exits
non-zero and fails the job. The cluster-gated L2/L3 stage SKIPS honestly (no
CLUSTER_AVAILABLE). spec-immutability is retained as an explicit early UI step.

Adds ci/tests/touched-requirements.test.sh (+ `make -C ci test`) asserting rule 3:
comment-only R-IDs (added or removed) are not attributed while real-line R-IDs are.
ci/lib.sh REPO_ROOT is now env-overridable so tests can run the real script against a
throwaway git repo (default unchanged).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-31 13:54:29 +00:00

150 lines
8.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# ci/touched-requirements.sh — compute the requirement subset a change "touches".
#
# R-TEST-03 gates a merge on the conformance subset tagged with the requirement
# IDs the change touches. This script derives that subset from the diff so the
# conformance runner can be filtered to it (conformance.sh --requirements ...).
#
# Output (stdout): newline-separated, sorted, unique R-* IDs. Nothing else goes
# to stdout, so callers can `mapfile`/`$(...)` it directly.
#
# SOURCE OF TRUTH (reconciled, PM 2026-08-16): the conformance requirement TAGS are
# authoritative. Each Chainsaw test declares the requirements it verifies via
# `conformance.home.k3s/<R-ID>` labels; the coverage report (conformance/_artifacts/
# coverage/coverage.json) is generated from those tags. This script's job is only to
# select which tagged subset a change TOUCHES, then hand those IDs to
# `conformance.sh --requirements ...` which runs exactly the tests carrying those tags.
#
# Detection = union of:
# 1. R-* tokens on the ADDED/REMOVED LINES of the diff, in non-Markdown files —
# including the authoritative `conformance.home.k3s/R-*` tags on changed tests, and
# the R-IDs that policies/ServiceDefinitions self-declare (docs/14 + ADR-0011).
# Untouched lines and prose are NOT scanned; see changed_diff() for why.
# 2. A coarse path->requirement fallback for changes that don't self-declare IDs.
# The coarse map (2) NEVER overrides an explicit tag; it only widens coverage for
# untagged changes so nothing silently escapes the gate.
set -euo pipefail
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "${CI_DIR}/lib.sh"
# Base ref to diff against. In Gitea/GitHub Actions PR runs this is the target
# branch (main). Locally, default to main; override with BASE_REF.
: "${BASE_REF:=}"
resolve_base() {
if [ -n "$BASE_REF" ]; then echo "$BASE_REF"; return; fi
if [ -n "${GITHUB_BASE_REF:-}" ]; then echo "origin/${GITHUB_BASE_REF}"; return; fi
if git -C "$REPO_ROOT" rev-parse --verify -q origin/main >/dev/null; then echo "origin/main"; return; fi
if git -C "$REPO_ROOT" rev-parse --verify -q main >/dev/null; then echo "main"; return; fi
echo "HEAD~1" # last resort
}
changed_files() {
local base; base="$(resolve_base)"
# Three-dot: changes on this branch since it diverged from base.
if git -C "$REPO_ROOT" diff --name-only "${base}...HEAD" 2>/dev/null; then return; fi
git -C "$REPO_ROOT" diff --name-only "${base}" 2>/dev/null || true
}
# The ADDED/REMOVED lines of the change, for the R-ID content scan — excluding
# documentation.
#
# THREE scoping rules, all learned from the gate blocking merges it should not have:
#
# 1. Scan the DIFF, not whole file contents. Grepping whole files meant a change
# inherited every requirement those files had EVER mentioned, on lines the author
# never touched. Since some of those are legitimately uncovered, `--require-all`
# then failed and BLOCKED the merge over unrelated prose.
#
# 2. Skip `*.md`. A requirement ID in prose is a REFERENCE, not a declaration —
# explaining a requirement is not the same as changing behaviour against it.
# Without this, the decision log cannot record which requirements a change
# concerned without thereby claiming to have touched them, and the CI-fix commit
# that explains a mis-attribution re-triggers the very mis-attribution it fixes.
# Nothing escapes: authoritative declarations (`conformance.home.k3s/R-*` tags,
# Kyverno policies, ServiceDefinitions) all live in YAML, and the doc subtrees
# that matter (conformance/, schemas/, catalog/, clusters/, ci/) are covered by
# the path->requirement fallback regardless of file type.
#
# 3. Skip COMMENT-ONLY lines in non-Markdown files — rule 2's logic, applied to the
# prose that lives INSIDE code files. A requirement ID cited in a YAML/shell `#`
# comment is documentation, not a declaration, exactly like an ID in a `*.md` file.
# Rule 2 drops whole Markdown files, but a `.gitea/workflows/*.yml` or `ci/*.sh`
# HEADER that explains a requirement would otherwise re-attribute it. Concretely:
# gate.yml's header cites R-TEST-06 (enforced by the spec-immutability STAGE, not by
# any Chainsaw test, so a known-uncovered ID); before this rule, editing that comment
# re-attributed R-TEST-06 and the coverage `--require` check false-blocked the merge —
# the same self-referential trap rule 2 fixed for `*.md`. Applied in main()'s content
# scan (see there): after the diff marker is stripped, a line whose first non-blank
# character is `#` is dropped BEFORE the R-ID grep. Deliberately conservative — only
# WHOLE-LINE comments are dropped; an R-ID on a real code/spec line is still detected
# (authoritative declarations — test tags, policies, ServiceDefinitions — are never a
# bare comment), so this only narrows PROSE, never a declaration.
#
# Removed lines count: deleting a test's requirement tag, or a policy's self-declared
# ID, touches that requirement just as much as adding one.
# `:(exclude)*.md` drops Markdown from the content scan (rule 2 above).
# `:(exclude)ci/tests/` drops this dir's OWN unit-test fixtures: those files embed example
# R-IDs as DATA (asserting the scan's behaviour), never as declarations — the same
# "reference, not declaration" logic as rule 2. Their gate coverage is unaffected: any
# ci/ change (ci/tests/ included) still attributes R-GITOPS-01/R-TEST-02/R-TEST-03 via the
# path->requirement map below. NOTE: this excludes ci/tests/ only — conformance/tests/
# carry AUTHORITATIVE `conformance.home.k3s/R-*` labels and are deliberately still scanned.
# Three-dot to match changed_files(): changes on this branch since it diverged from base.
changed_diff() {
local base; base="$(resolve_base)"
local -a excl=(':(exclude)*.md' ':(exclude)ci/tests/')
if git -C "$REPO_ROOT" diff -U0 "${base}...HEAD" -- . "${excl[@]}" 2>/dev/null; then return; fi
git -C "$REPO_ROOT" diff -U0 "${base}" -- . "${excl[@]}" 2>/dev/null || true
}
# Coarse path -> requirement map. Deliberately conservative; extend as subtrees
# fill in. Left side is a path prefix (relative to repo root).
path_requirements() {
case "$1" in
catalog/*) echo "R-GITOPS-02" ;;
schemas/*) echo "R-GITOPS-02" ;;
platform/kyverno/*) echo "R-TEST-01 R-NET-01 R-SEC-01" ;;
platform/cilium/*) echo "R-NET-01 R-NET-02 R-NET-05" ;;
platform/longhorn/*) echo "R-STATE-01 R-STATE-02" ;;
platform/cert-manager/*) echo "R-TLS-01 R-TLS-02" ;;
platform/externaldns/*) echo "R-DNS-02" ;;
platform/velero/*) echo "R-STATE-03" ;;
platform/monitoring/*) echo "R-OBS-01 R-OBS-02" ;;
platform/image-automation/*) echo "R-UPD-01 R-UPD-02" ;;
conformance/*) echo "R-TEST-01 R-TEST-02 R-TEST-03 R-TEST-04" ;;
clusters/*) echo "R-GITOPS-01 R-ENV-02" ;;
.gitea/*|ci/*) echo "R-GITOPS-01 R-TEST-02 R-TEST-03" ;;
*) : ;;
esac
}
main() {
local files reqs=""
files="$(changed_files)"
[ -n "$files" ] || { log "no changed files vs base; empty touched-requirement set"; exit 0; }
while IFS= read -r f; do
[ -n "$f" ] || continue
reqs+=" $(path_requirements "$f")"
done <<< "$files"
# Self-declared R-IDs on the ADDED/REMOVED lines of the change (see changed_diff).
# `^[+-]` keeps hunk headers (`@@`) and the `+++`/`---` file headers out of scope.
# `sed 's/^[+-]//'` then strips the single-char diff marker (safe: -U0 emits one), so
# what remains is the SOURCE line, and `grep -vE '^[[:space:]]*#'` drops whole-line
# YAML/shell comments (rule 3 in changed_diff's header) BEFORE the R-ID grep — a
# requirement cited only in a `#` comment is prose, not a declaration.
reqs+=" $(changed_diff \
| grep -E '^[+-]' \
| grep -vE '^(\+\+\+|---)' \
| sed -E 's/^[+-]//' \
| grep -vE '^[[:space:]]*#' \
| grep -hoE 'R-[A-Z]+-[0-9]+' \
| tr '\n' ' ' || true)"
# Normalize: split, unique, sort. Emit to stdout.
printf '%s\n' $reqs | sed '/^$/d' | sort -u
}
main "$@"