guardian-framework 0.1.146 → 0.1.148

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/cli.js +1 -1
  2. package/dist/exports.js +1 -1
  3. package/package.json +1 -1
  4. package/templates/pi/architecture/wiring-claims.tsv +13 -0
  5. package/templates/pi/coverage-floors.tsv +12 -0
  6. package/templates/pi/extensions/architect.ts +44 -4
  7. package/templates/pi/extensions/epic.ts +113 -0
  8. package/templates/pi/extensions/pipeline.ts +7 -1
  9. package/templates/pi/guards.tsv +33 -0
  10. package/templates/pi/mirrors.tsv +9 -0
  11. package/templates/pi/preflight_report.json +2 -2
  12. package/templates/pi/prompts/epic-parallel.md +54 -0
  13. package/templates/pi/script-drift-allow.tsv +13 -0
  14. package/templates/pi/scripts/ci/check_architecture_conformance.sh +6 -6
  15. package/templates/pi/scripts/ci/run_hardening_stages.sh +7 -7
  16. package/templates/pi/scripts/ci/run_preflight.sh +6 -6
  17. package/templates/pi/scripts/ci/stage_docs_policy.sh +3 -3
  18. package/templates/pi/scripts/ci/stage_lint.sh +2 -2
  19. package/templates/pi/scripts/ci/stage_remaining.sh +3 -3
  20. package/templates/pi/scripts/ci/stage_static_analysis.sh +4 -4
  21. package/templates/pi/scripts/ci/stage_unit.sh +2 -2
  22. package/templates/pi/scripts/ci/validate_agent_output.sh +11 -11
  23. package/templates/pi/scripts/epic-parallel.sh +95 -0
  24. package/templates/pi/scripts/epic-registry.sh +138 -0
  25. package/templates/pi/scripts/languages/python/validate-canonical.sh +3 -3
  26. package/templates/pi/scripts/validate-coverage-floors.sh +174 -0
  27. package/templates/pi/scripts/validate-guards.sh +171 -0
  28. package/templates/pi/scripts/validate-script-drift.sh +213 -0
  29. package/templates/pi/scripts/validate-shell-exit-status.sh +120 -0
  30. package/templates/pi/scripts/validate-wiring-claims.sh +228 -0
@@ -14,8 +14,8 @@ FAIL=0
14
14
  PASS=0
15
15
  COVERAGE_PCT=0
16
16
 
17
- log_pass() { echo " ✓ PASS: $1"; ((PASS++)); }
18
- log_fail() { echo " ✗ FAIL: $1 — $2"; ((FAIL++)); }
17
+ log_pass() { echo " ✓ PASS: $1"; PASS=$((PASS + 1)); }
18
+ log_fail() { echo " ✗ FAIL: $1 — $2"; FAIL=$((FAIL + 1)); }
19
19
 
20
20
  echo " Running unit tests..."
21
21
 
@@ -48,13 +48,13 @@ check_section_present() {
48
48
  local section="$1"
49
49
  if echo "$CONTENT" | grep -qi "$section"; then
50
50
  RESULTS+=("{\"check\": \"$section\", \"status\": \"pass\"}")
51
- ((CHECKS_PASSED++))
51
+ CHECKS_PASSED=$((CHECKS_PASSED + 1))
52
52
  if [[ "$JSON" == "false" ]]; then
53
53
  echo -e " ${GREEN}✓${NC} Section present: $section"
54
54
  fi
55
55
  else
56
56
  RESULTS+=("{\"check\": \"$section\", \"status\": \"fail\", \"message\": \"Section '$section' not found\"}")
57
- ((CHECKS_FAILED++))
57
+ CHECKS_FAILED=$((CHECKS_FAILED + 1))
58
58
  if [[ "$JSON" == "false" ]]; then
59
59
  echo -e " ${RED}✗${NC} Missing section: $section"
60
60
  fi
@@ -73,20 +73,20 @@ check_no_contradictions() {
73
73
  must_count=$(echo "$CONTENT" | grep -ci "${topic}.*must " || echo 0)
74
74
  must_not_count=$(echo "$CONTENT" | grep -ci "${topic}.*must not\|${topic}.*must not " || echo 0)
75
75
  if [[ $must_count -gt 0 && $must_not_count -gt 0 ]]; then
76
- ((contradictions++))
76
+ contradictions=$((contradictions + 1))
77
77
  fi
78
78
  fi
79
79
  done < <(echo "$CONTENT" | grep -E "^\s*[-*]\s+\*\*" || true)
80
80
 
81
81
  if [[ $contradictions -eq 0 ]]; then
82
82
  RESULTS+=("{\"check\": \"no_contradictions\", \"status\": \"pass\"}")
83
- ((CHECKS_PASSED++))
83
+ CHECKS_PASSED=$((CHECKS_PASSED + 1))
84
84
  if [[ "$JSON" == "false" ]]; then
85
85
  echo -e " ${GREEN}✓${NC} No internal contradictions"
86
86
  fi
87
87
  else
88
88
  RESULTS+=("{\"check\": \"no_contradictions\", \"status\": \"fail\", \"message\": \"Found $contradictions contradictions\"}")
89
- ((CHECKS_FAILED++))
89
+ CHECKS_FAILED=$((CHECKS_FAILED + 1))
90
90
  if [[ "$JSON" == "false" ]]; then
91
91
  echo -e " ${RED}✗${NC} Found $contradictions contradictions"
92
92
  fi
@@ -100,13 +100,13 @@ check_decision_consistency() {
100
100
 
101
101
  if [[ $decisions -eq 0 ]] || [[ $evidence -gt 0 ]]; then
102
102
  RESULTS+=("{\"check\": \"decision_consistency\", \"status\": \"pass\"}")
103
- ((CHECKS_PASSED++))
103
+ CHECKS_PASSED=$((CHECKS_PASSED + 1))
104
104
  if [[ "$JSON" == "false" ]]; then
105
105
  echo -e " ${GREEN}✓${NC} Decision consistency"
106
106
  fi
107
107
  else
108
108
  RESULTS+=("{\"check\": \"decision_consistency\", \"status\": \"fail\", \"message\": \"Decisions without supporting evidence\"}")
109
- ((CHECKS_FAILED++))
109
+ CHECKS_FAILED=$((CHECKS_FAILED + 1))
110
110
  if [[ "$JSON" == "false" ]]; then
111
111
  echo -e " ${RED}✗${NC} Decisions without supporting evidence"
112
112
  fi
@@ -120,13 +120,13 @@ check_acceptance_criteria() {
120
120
 
121
121
  if [[ $has_criteria -gt 0 ]]; then
122
122
  RESULTS+=("{\"check\": \"acceptance_criteria\", \"status\": \"pass\"}")
123
- ((CHECKS_PASSED++))
123
+ CHECKS_PASSED=$((CHECKS_PASSED + 1))
124
124
  if [[ "$JSON" == "false" ]]; then
125
125
  echo -e " ${GREEN}✓${NC} Acceptance criteria present"
126
126
  fi
127
127
  else
128
128
  RESULTS+=("{\"check\": \"acceptance_criteria\", \"status\": \"fail\", \"message\": \"No acceptance criteria found\"}")
129
- ((CHECKS_FAILED++))
129
+ CHECKS_FAILED=$((CHECKS_FAILED + 1))
130
130
  if [[ "$JSON" == "false" ]]; then
131
131
  echo -e " ${RED}✗${NC} No acceptance criteria found"
132
132
  fi
@@ -138,13 +138,13 @@ check_canonical_references() {
138
138
 
139
139
  if [[ $has_refs -gt 0 ]]; then
140
140
  RESULTS+=("{\"check\": \"canonical_references\", \"status\": \"pass\"}")
141
- ((CHECKS_PASSED++))
141
+ CHECKS_PASSED=$((CHECKS_PASSED + 1))
142
142
  if [[ "$JSON" == "false" ]]; then
143
143
  echo -e " ${GREEN}✓${NC} Canonical references present"
144
144
  fi
145
145
  else
146
146
  RESULTS+=("{\"check\": \"canonical_references\", \"status\": \"fail\", \"message\": \"No canonical references found\"}")
147
- ((CHECKS_FAILED++))
147
+ CHECKS_FAILED=$((CHECKS_FAILED + 1))
148
148
  if [[ "$JSON" == "false" ]]; then
149
149
  echo -e " ${RED}✗${NC} No canonical references found"
150
150
  fi
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================================
3
+ # epic-parallel.sh — start an epic in its own git worktree (parallel epics)
4
+ # ============================================================================
5
+ # Parallel epics work when (a) each module is isolated in its own worktree,
6
+ # (b) there is one epic branch per module, and (c) the module's shared
7
+ # contracts are FROZEN (merged) so the parallel work cannot redefine them.
8
+ #
9
+ # Run as: bash .pi/scripts/epic-parallel.sh <module> [--base main] [--dir <path>]
10
+ # Exit: 0 worktree ready · 2 blocked by contract freeze · 1 error
11
+ # ============================================================================
12
+ set -uo pipefail
13
+
14
+ MODULE="${1:-}"
15
+ shift || true
16
+ BASE="main"; DIR=""
17
+ while [ $# -gt 0 ]; do
18
+ case "$1" in
19
+ --base) BASE="${2:-main}"; shift 2 ;;
20
+ --dir) DIR="${2:-}"; shift 2 ;;
21
+ *) shift ;;
22
+ esac
23
+ done
24
+
25
+ if [ -z "$MODULE" ]; then
26
+ echo "usage: epic-parallel.sh <module> [--base main] [--dir <path>]" >&2
27
+ exit 1
28
+ fi
29
+
30
+ slug() { echo "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//'; }
31
+ BRANCH="epic/$(slug "$MODULE")"
32
+
33
+ ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || { echo "not a git repo" >&2; exit 1; }
34
+ REG="$(git rev-parse --git-common-dir 2>/dev/null || echo "$ROOT/.git")"
35
+ [ -d "$REG" ] || REG="$ROOT/.git"
36
+ REPO_NAME="$(basename "$ROOT")"
37
+ [ -z "$DIR" ] && DIR="$(dirname "$ROOT")/${REPO_NAME}-$(slug "$MODULE")"
38
+
39
+ echo "============================================"
40
+ echo " Parallel epic: $MODULE"
41
+ echo "============================================"
42
+ echo " branch: $BRANCH"
43
+ echo " worktree: $DIR"
44
+ echo ""
45
+
46
+ # ── Contract-freeze gate ────────────────────────────────────────────────────
47
+ # An epic may only run in parallel once its shared contracts are frozen. The
48
+ # registry is the source of truth (set by the contract-freeze issue / a human).
49
+ REG_FILE="$REG/guardian/epics.json"
50
+ FROZEN="unknown"
51
+ if [ -f "$REG_FILE" ]; then
52
+ FROZEN=$(python3 - "$REG_FILE" "$MODULE" <<'PY' 2>/dev/null || echo unknown
53
+ import json,sys
54
+ d=json.load(open(sys.argv[1]))
55
+ e=d.get("epics",{}).get(sys.argv[2],{})
56
+ print("true" if e.get("contractFrozen") else ("false" if e else "unknown"))
57
+ PY
58
+ )
59
+ fi
60
+
61
+ if [ "$FROZEN" != "true" ]; then
62
+ # NOTE: block here (before worktree creation) and exit 2 — a non-zero,
63
+ # distinguishable code so callers/agents cannot mistake it for success.
64
+ echo "⛔ Blocked: contracts for '$MODULE' are not frozen (registry: $FROZEN)."
65
+ echo ""
66
+ echo " Parallel epics must not redefine shared contracts. Freeze first:"
67
+ echo " 1. run the epic's Contract Freeze issue and merge it to $BASE"
68
+ echo " 2. record it: bash .pi/scripts/epic-registry.sh freeze \"$MODULE\" --frozen"
69
+ echo ""
70
+ echo " (single-epic work does not need this gate)"
71
+ exit 2
72
+ fi
73
+
74
+ # ── Worktree creation ───────────────────────────────────────────────────────
75
+ if [ -e "$DIR" ]; then
76
+ echo "worktree path already exists: $DIR"
77
+ elif git show-ref --verify --quiet "refs/heads/$BRANCH"; then
78
+ echo "branch exists — attaching worktree"
79
+ git worktree add "$DIR" "$BRANCH" || exit 1
80
+ else
81
+ echo "creating branch + worktree from origin/$BASE"
82
+ git fetch origin "$BASE" --quiet 2>/dev/null || true
83
+ git worktree add -b "$BRANCH" "$DIR" "origin/$BASE" 2>/dev/null || \
84
+ git worktree add -b "$BRANCH" "$DIR" "$BASE" || exit 1
85
+ fi
86
+
87
+ # ── Register in the shared registry ─────────────────────────────────────────
88
+ if [ -x "$ROOT/.pi/scripts/epic-registry.sh" ]; then
89
+ bash "$ROOT/.pi/scripts/epic-registry.sh" register "$MODULE" --branch "$BRANCH" --worktree "$DIR" --status running
90
+ fi
91
+
92
+ echo ""
93
+ echo "✅ Worktree ready."
94
+ echo " Next: cd \"$DIR\" && /architect --epic \"$MODULE\""
95
+ echo " The epic runs in epic mode: one branch, local CI per issue, one PR at the end."
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================================
3
+ # epic-registry.sh — project-level epic registry shared across git worktrees
4
+ # ============================================================================
5
+ # Worktrees each have their own .pi/ directory, so epic state is invisible
6
+ # across parallel /architect sessions. The registry lives in the git COMMON
7
+ # dir (shared by all worktrees) and records: module, branch, worktree path,
8
+ # status, contract-freeze state and the shared dependencies the module needs.
9
+ #
10
+ # Run as:
11
+ # epic-registry.sh register <module> [--branch B] [--worktree P] [--status S]
12
+ # epic-registry.sh status <module> <status>
13
+ # epic-registry.sh freeze <module> [--frozen|--pending]
14
+ # epic-registry.sh list
15
+ # epic-registry.sh get <module>
16
+ # epic-registry.sh path # registry file path
17
+ # ============================================================================
18
+ set -uo pipefail
19
+
20
+ common_dir() {
21
+ git rev-parse --git-common-dir 2>/dev/null || echo ".git"
22
+ }
23
+ COMMON="$(common_dir)"
24
+ [ -d "$COMMON" ] || COMMON=".git"
25
+ # Absolute path: the registry must be addressable from any worktree/cwd.
26
+ COMMON_ABS="$(cd "$COMMON" 2>/dev/null && pwd || echo "$COMMON")"
27
+ REG="$COMMON_ABS/guardian/epics.json"
28
+ mkdir -p "$(dirname "$REG")" 2>/dev/null || true
29
+
30
+ now() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
31
+
32
+ ensure() {
33
+ [ -f "$REG" ] || echo '{"version":1,"epics":{}}' > "$REG"
34
+ }
35
+
36
+ read_epic() {
37
+ local mod="$1"
38
+ python3 - "$REG" "$mod" <<'PY' 2>/dev/null || echo "{}"
39
+ import json,sys
40
+ path,mod=sys.argv[1],sys.argv[2]
41
+ try:
42
+ d=json.load(open(path))
43
+ except Exception:
44
+ d={"version":1,"epics":{}}
45
+ print(json.dumps(d.get("epics",{}).get(mod,{})))
46
+ PY
47
+ }
48
+
49
+ write_epic() {
50
+ local mod="$1"; shift
51
+ python3 - "$REG" "$mod" "$@" <<'PY'
52
+ import json,sys,os
53
+ path,mod=sys.argv[1],sys.argv[2]
54
+ pairs=sys.argv[3:]
55
+ try:
56
+ d=json.load(open(path))
57
+ except Exception:
58
+ d={"version":1,"epics":{}}
59
+ d.setdefault("epics",{})
60
+ e=d["epics"].get(mod,{})
61
+ for p in pairs:
62
+ k,_,v=p.partition("=")
63
+ if v in ("true","false"):
64
+ e[k]=v=="true"
65
+ else:
66
+ e[k]=v
67
+ e.setdefault("module",mod)
68
+ d["epics"][mod]=e
69
+ os.makedirs(os.path.dirname(path),exist_ok=True)
70
+ json.dump(d,open(path,"w"),indent=2)
71
+ print(json.dumps(e))
72
+ PY
73
+ }
74
+
75
+ cmd="${1:-}"; shift || true
76
+ case "$cmd" in
77
+ path) ensure; echo "$REG" ;;
78
+ register)
79
+ MOD="${1:?usage: register <module> [--branch B] [--worktree P]}"; shift || true
80
+ BRANCH=""; WT=""; STATUS="running"
81
+ while [ $# -gt 0 ]; do
82
+ case "$1" in
83
+ --branch) BRANCH="${2:-}"; shift 2 ;;
84
+ --worktree) WT="${2:-}"; shift 2 ;;
85
+ --status) STATUS="${2:-}"; shift 2 ;;
86
+ *) shift ;;
87
+ esac
88
+ done
89
+ [ -z "$BRANCH" ] && BRANCH="epic/$(echo "$MOD" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//')"
90
+ [ -z "$WT" ] && WT="$(cd "$(dirname "$0")/../.." 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null || pwd)"
91
+ FNOW="$(now)"
92
+ # Preserve an already-frozen contract: re-registering (e.g. a worktree
93
+ # restarted) must never silently un-freeze a module.
94
+ PRESENT=$(read_epic "$MOD")
95
+ KEEP_FROZEN=$(python3 - "$PRESENT" <<'PY' 2>/dev/null || echo ""
96
+ import json,sys
97
+ try:
98
+ e=json.loads(sys.argv[1])
99
+ except Exception:
100
+ e={}
101
+ print("true" if e.get("contractFrozen") else "false")
102
+ PY
103
+ )
104
+ [ -z "$KEEP_FROZEN" ] && KEEP_FROZEN="false"
105
+ write_epic "$MOD" "branch=$BRANCH" "worktree=$WT" "status=$STATUS" "registeredAt=$FNOW" "updatedAt=$FNOW" "contractFrozen=$KEEP_FROZEN" >/dev/null
106
+ echo "registered $MOD -> $BRANCH ($WT)"
107
+ ;;
108
+ status)
109
+ MOD="${1:?usage: status <module> <status>}"; ST="${2:?}"
110
+ write_epic "$MOD" "status=$ST" "updatedAt=$(now)" >/dev/null
111
+ echo "$MOD: $ST"
112
+ ;;
113
+ freeze)
114
+ MOD="${1:?usage: freeze <module> [--frozen|--pending]}"; shift || true
115
+ V="true"; [ "${1:-}" = "--pending" ] && V="false"
116
+ write_epic "$MOD" "contractFrozen=$V" "updatedAt=$(now)" >/dev/null
117
+ echo "$MOD: contractFrozen=$V"
118
+ ;;
119
+ list|get)
120
+ MOD="${1:-}"
121
+ ensure
122
+ python3 - "$REG" "$cmd" "$MOD" <<'PY'
123
+ import json,sys
124
+ path,mode,mod=sys.argv[1],sys.argv[2],sys.argv[3]
125
+ d=json.load(open(path))
126
+ epics=d.get("epics",{})
127
+ if mode=="get":
128
+ print(json.dumps(epics.get(mod,{}),indent=2))
129
+ else:
130
+ if not epics: print("(no epics registered)")
131
+ for m,e in sorted(epics.items()):
132
+ print(f"{m:32} {e.get('status','?'):12} frozen={'yes' if e.get('contractFrozen') else 'no ':3} branch={e.get('branch','?')} worktree={e.get('worktree','?')}")
133
+ PY
134
+ ;;
135
+ *)
136
+ echo "usage: epic-registry.sh register|status|freeze|list|get|path ..." >&2
137
+ exit 1 ;;
138
+ esac
@@ -55,7 +55,7 @@ if [ -d ".pi/architecture/modules" ]; then
55
55
  if [ -n "$impl_path" ]; then
56
56
  if [ ! -f "$impl_path" ]; then
57
57
  warn "Architecture references $impl_path but file does not exist"
58
- ((MISSING++))
58
+ MISSING=$((MISSING + 1))
59
59
  fi
60
60
  fi
61
61
  done < <(grep -oE '`[a-z_/]+\.py`' "$module_file" 2>/dev/null || true)
@@ -76,9 +76,9 @@ INIT_DOCS=0
76
76
  INIT_WITHOUT_DOCS=0
77
77
  for init_file in $(find "$SRC_DIR" -name "__init__.py" 2>/dev/null | head -20); do
78
78
  if grep -qE '"""|"""' "$init_file" 2>/dev/null; then
79
- ((INIT_DOCS++))
79
+ INIT_DOCS=$((INIT_DOCS + 1))
80
80
  else
81
- ((INIT_WITHOUT_DOCS++))
81
+ INIT_WITHOUT_DOCS=$((INIT_WITHOUT_DOCS + 1))
82
82
  fi
83
83
  done
84
84
  if [ "$INIT_DOCS" -gt 0 ]; then
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================================
3
+ # validate-coverage-floors.sh — a coverage floor may not be lowered (ADR-010)
4
+ # ============================================================================
5
+ # `.pi/coverage-floors.tsv` is a ratchet: raise a floor when a layer improves,
6
+ # never lower one to make a build pass. A rule that only lives in prose is not
7
+ # a control, so this script makes it executable.
8
+ #
9
+ # Two failure modes, both named:
10
+ #
11
+ # 1. a floor DECREASED relative to the base ref — the ratchet moved backwards
12
+ # and the layer's real coverage is now unguarded below where it had reached;
13
+ # 2. a layer DISAPPEARED — a removed row stops gating, and a missing row reads
14
+ # as "no floor" rather than "this layer lost its floor".
15
+ #
16
+ # Table (tab-separated): layer<TAB>floor<TAB>measured<TAB>target<TAB>note
17
+ # floor the value that gates (ratchet; never decreases)
18
+ # measured last measured line/region coverage (informational)
19
+ # target the criterion from the roadmap (gap printed when unmet)
20
+ #
21
+ # Usage:
22
+ # bash .pi/scripts/validate-coverage-floors.sh # vs the base ref
23
+ # bash .pi/scripts/validate-coverage-floors.sh --base main
24
+ # bash .pi/scripts/validate-coverage-floors.sh --self-test
25
+ # ============================================================================
26
+ set -uo pipefail
27
+
28
+ # Resolve the project root: the nearest ancestor of this script that looks like
29
+ # a project (has .git or package.json). Works whether the script is invoked from
30
+ # the canonical tree (templates/pi/scripts) or the shipped copy (.pi/scripts).
31
+ resolve_root() {
32
+ local d
33
+ d="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
34
+ while [ "$d" != "/" ]; do
35
+ if [ -e "$d/.git" ] || [ -e "$d/package.json" ]; then
36
+ echo "$d"
37
+ return
38
+ fi
39
+ d="$(dirname "$d")"
40
+ done
41
+ echo "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
42
+ }
43
+ ROOT="$(resolve_root)"
44
+ TABLE_REL=".pi/coverage-floors.tsv"
45
+ TABLE="$ROOT/$TABLE_REL"
46
+ BASE=""
47
+ MODE="check"
48
+
49
+ while [ $# -gt 0 ]; do
50
+ case "$1" in
51
+ --base) BASE="${2:-}"; shift 2 ;;
52
+ --self-test) MODE="self-test"; shift ;;
53
+ --base-file) MODE="base-file"; BASE="${2:-}"; shift 2 ;;
54
+ --new-file) NEWFILE="${2:-}"; shift 2 ;;
55
+ *) shift ;;
56
+ esac
57
+ done
58
+
59
+ compare() {
60
+ # $1 = base table file, $2 = new table file
61
+ python3 - "$1" "$2" <<'PY'
62
+ import sys
63
+
64
+ def parse(path):
65
+ rows = {}
66
+ for lineno, line in enumerate(open(path, encoding="utf-8"), 1):
67
+ if line.startswith("#") or not line.strip():
68
+ continue
69
+ parts = line.rstrip("\n").split("\t")
70
+ if len(parts) < 2:
71
+ print(f"{path}:{lineno}: malformed row (need layer<TAB>floor...)", file=sys.stderr)
72
+ sys.exit(2)
73
+ layer, floor = parts[0], parts[1]
74
+ # An uncommented header row is the column name, not a floor.
75
+ if layer.strip().lower() == "layer":
76
+ continue
77
+ try:
78
+ rows[layer] = float(floor)
79
+ except ValueError:
80
+ print(f"{path}:{lineno}: floor is not a number: {floor!r}", file=sys.stderr)
81
+ sys.exit(2)
82
+ return rows
83
+
84
+ base = parse(sys.argv[1])
85
+ new = parse(sys.argv[2])
86
+ problems = []
87
+
88
+ for layer, b in sorted(base.items()):
89
+ if layer not in new:
90
+ problems.append(f"layer '{layer}' disappeared — its floor ({b:g}) no longer gates")
91
+ elif new[layer] < b - 1e-9:
92
+ problems.append(f"layer '{layer}' floor decreased {b:g} -> {new[layer]:g} "
93
+ f"(a floor is a ratchet; never lower it)")
94
+
95
+ if problems:
96
+ print("coverage floors regressed:", file=sys.stderr)
97
+ for p in problems:
98
+ print(f" {p}", file=sys.stderr)
99
+ sys.exit(1)
100
+
101
+ raised = [f"{l} {base[l]:g}->{new[l]:g}" for l in base if l in new and new[l] > base[l] + 1e-9]
102
+ print(f"coverage floors OK — {len(new)} layer(s), no floor lowered"
103
+ + (f"; raised: {', '.join(raised)}" if raised else ""))
104
+ PY
105
+ }
106
+
107
+ if [ "$MODE" = "--self-test" ] || [ "$MODE" = "self-test" ]; then
108
+ TD="$(mktemp -d "${TMPDIR:-/tmp}/floors-selftest.XXXXXX")"
109
+ ok=0
110
+ fail=0
111
+ bump() { if [ "$1" -eq 0 ]; then ok=$((ok + 1)); else fail=$((fail + 1)); fi; }
112
+
113
+ tbl() { printf 'layer\tfloor\tmeasured\ttarget\n%s\n' "$2" >"$TD/$1.tsv"; }
114
+
115
+ # 1. unchanged -> pass
116
+ tbl base 'domain 90 92 95'
117
+ tbl same 'domain 90 92 95'
118
+ compare "$TD/base.tsv" "$TD/same.tsv" >/dev/null 2>&1
119
+ bump $?
120
+
121
+ # 2. raised -> pass
122
+ tbl raised 'domain 95 96 95'
123
+ compare "$TD/base.tsv" "$TD/raised.tsv" >/dev/null 2>&1
124
+ bump $?
125
+
126
+ # 3. lowered -> fail
127
+ tbl lowered 'domain 80 92 95'
128
+ compare "$TD/base.tsv" "$TD/lowered.tsv" >/dev/null 2>&1
129
+ [ $? -ne 0 ]
130
+ bump $?
131
+
132
+ # 4. layer removed -> fail
133
+ tbl removed 'application 90 91 90'
134
+ compare "$TD/base.tsv" "$TD/removed.tsv" >/dev/null 2>&1
135
+ [ $? -ne 0 ]
136
+ bump $?
137
+
138
+ echo "self-test: $ok ok, $fail failed"
139
+ [ "$fail" -eq 0 ]
140
+ exit $?
141
+ fi
142
+
143
+ if [ "$MODE" != "--self-test" ] && [ "$MODE" != "self-test" ] && [ ! -f "$TABLE" ]; then
144
+ echo "no coverage floors table ($TABLE_REL) — nothing to ratchet (OK)"
145
+ exit 0
146
+ fi
147
+
148
+ if [ -n "$BASE" ]; then
149
+ git rev-parse --verify --quiet "$BASE" >/dev/null || {
150
+ echo "unknown base ref: $BASE" >&2
151
+ exit 2
152
+ }
153
+ BASE_COPY="$(mktemp "${TMPDIR:-/tmp}/floors-base.XXXXXX")"
154
+ if ! git show "$BASE:$TABLE_REL" >"$BASE_COPY" 2>/dev/null; then
155
+ echo "no $TABLE_REL at $BASE — first ratchet commit (OK)"
156
+ exit 0
157
+ fi
158
+ compare "$BASE_COPY" "$TABLE"
159
+ else
160
+ # Default: compare against the merge base with the default branch when known.
161
+ DEFAULT_BRANCH="$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||')"
162
+ [ -z "$DEFAULT_BRANCH" ] && DEFAULT_BRANCH="main"
163
+ MB="$(git merge-base HEAD "origin/$DEFAULT_BRANCH" 2>/dev/null || git merge-base HEAD "$DEFAULT_BRANCH" 2>/dev/null || true)"
164
+ if [ -z "$MB" ]; then
165
+ echo "no base commit available — floors unchanged in this checkout (OK)"
166
+ exit 0
167
+ fi
168
+ BASE_COPY="$(mktemp "${TMPDIR:-/tmp}/floors-base.XXXXXX")"
169
+ if ! git show "$MB:$TABLE_REL" >"$BASE_COPY" 2>/dev/null; then
170
+ echo "no $TABLE_REL at merge base — first ratchet commit (OK)"
171
+ exit 0
172
+ fi
173
+ compare "$BASE_COPY" "$TABLE"
174
+ fi