guardian-framework 0.1.147 → 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.
@@ -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
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================================
3
+ # validate-guards.sh — make gating vs advisory explicit (ADR-010)
4
+ # ============================================================================
5
+ # A validator that cannot fail is documentation, not a control. This is the one
6
+ # place that answers "which checks can stop a merge?".
7
+ #
8
+ # * every root .pi/scripts/validate-*.sh MUST be registered in .pi/guards.tsv
9
+ # * every row must be well-formed and point at a file that exists
10
+ # * a duplicate or phantom registration is rejected
11
+ #
12
+ # Usage:
13
+ # bash .pi/scripts/validate-guards.sh # registry integrity (advisory)
14
+ # bash .pi/scripts/validate-guards.sh --list # print the classified table
15
+ # bash .pi/scripts/validate-guards.sh --run # run the gating set (run_here=yes)
16
+ # bash .pi/scripts/validate-guards.sh --self-test # prove the check bites
17
+ # ============================================================================
18
+ set -uo pipefail
19
+
20
+ # Resolve the project root: the nearest ancestor of this script that looks like
21
+ # a project (has .git or package.json). Works whether the script is invoked from
22
+ # the canonical tree (templates/pi/scripts) or the shipped copy (.pi/scripts).
23
+ resolve_root() {
24
+ local d
25
+ d="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
26
+ while [ "$d" != "/" ]; do
27
+ if [ -e "$d/.git" ] || [ -e "$d/package.json" ]; then
28
+ echo "$d"
29
+ return
30
+ fi
31
+ d="$(dirname "$d")"
32
+ done
33
+ echo "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
34
+ }
35
+ ROOT="$(resolve_root)"
36
+ REG="$ROOT/.pi/guards.tsv"
37
+
38
+ MODE="${1:-check}"
39
+
40
+ # --self-test must work in any checkout (it builds its own throwaway roots), so
41
+ # the registry requirement applies to the real check only.
42
+ if [ "$MODE" != "--self-test" ] && [ ! -f "$REG" ]; then
43
+ echo "guard registry missing: .pi/guards.tsv" >&2
44
+ echo " run: guardian update --force (recreates project-owned template files)" >&2
45
+ exit 2
46
+ fi
47
+
48
+ registry_check() {
49
+ # $1 = registry path, $2 = root, $3 = mode (check|list)
50
+ python3 - "$1" "$2" "$3" <<'PY'
51
+ import os, re, sys
52
+
53
+ reg, root, mode = sys.argv[1:4]
54
+ rows, seen, errors = [], set(), []
55
+
56
+ for lineno, line in enumerate(open(reg, encoding="utf-8"), 1):
57
+ if line.startswith("#") or not line.strip():
58
+ continue
59
+ parts = line.rstrip("\n").split("\t")
60
+ if len(parts) < 6:
61
+ errors.append(f"{lineno}: malformed row (need 6 tab-separated fields)")
62
+ continue
63
+ guard, cls, tier, run_here, owner, notes = parts[:6]
64
+ if tier not in ("gating", "advisory"):
65
+ errors.append(f"{lineno}: bad tier {tier!r} (want gating|advisory)")
66
+ if run_here not in ("yes", "no"):
67
+ errors.append(f"{lineno}: bad run_here {run_here!r} (want yes|no)")
68
+ if not os.path.exists(os.path.join(root, guard)):
69
+ errors.append(f"{lineno}: registered guard does not exist: {guard}")
70
+ if guard in seen:
71
+ errors.append(f"{lineno}: duplicate registration: {guard}")
72
+ seen.add(guard)
73
+ rows.append((guard, cls, tier, run_here, owner, notes))
74
+
75
+ scripts_dir = os.path.join(root, ".pi/scripts")
76
+ on_disk = sorted(
77
+ f".pi/scripts/{n}"
78
+ for n in (os.listdir(scripts_dir) if os.path.isdir(scripts_dir) else [])
79
+ if re.fullmatch(r"validate-.*\.sh", n)
80
+ )
81
+ unregistered = [p for p in on_disk if p not in seen]
82
+
83
+ if errors:
84
+ print("guard registry invalid — .pi/guards.tsv:", file=sys.stderr)
85
+ for e in errors:
86
+ print(f" {e}", file=sys.stderr)
87
+ sys.exit(2)
88
+
89
+ if unregistered:
90
+ print("unclassified guard(s) — add them to .pi/guards.tsv:", file=sys.stderr)
91
+ for p in unregistered:
92
+ print(f" {p}", file=sys.stderr)
93
+ print(" every guard declares gating|advisory, run_here and an owner.", file=sys.stderr)
94
+ sys.exit(2)
95
+
96
+ if mode == "list":
97
+ gating = [r for r in rows if r[2] == "gating"]
98
+ advisory = [r for r in rows if r[2] == "advisory"]
99
+ print("GATING (a non-zero exit fails a mandatory path):")
100
+ for g, c, _t, run_here, o, _n in gating:
101
+ where = "run here" if run_here == "yes" else "gates elsewhere"
102
+ print(f" {c:<18} {g:<48} [{where}] owner={o}")
103
+ print("\nADVISORY (informational; does not stop a merge):")
104
+ for g, c, _t, _r, o, _n in advisory:
105
+ print(f" {c:<18} {g:<48} owner={o}")
106
+ print(f"\n{len(on_disk)} validator script(s) on disk, all classified "
107
+ f"({len(gating)} gating, {len(advisory)} advisory).")
108
+ sys.exit(0)
109
+
110
+ print(f"guard registry OK — {len(on_disk)} validator(s), all classified "
111
+ f"({sum(1 for r in rows if r[2]=='gating')} gating).")
112
+ PY
113
+ }
114
+
115
+ case "$MODE" in
116
+ --self-test)
117
+ # Prove the checks bite, in throwaway roots under a temp dir.
118
+ TD="$(mktemp -d "${TMPDIR:-/tmp}/guards-selftest.XXXXXX")"
119
+ ok=0
120
+ fail=0
121
+ bump() { if [ "$1" -eq 0 ]; then ok=$((ok + 1)); else fail=$((fail + 1)); fi; }
122
+
123
+ # 1. a guard on disk that is not registered must be rejected
124
+ mkdir -p "$TD/case1/.pi/scripts"
125
+ printf '#!/usr/bin/env bash\nexit 0\n' >"$TD/case1/.pi/scripts/validate-planted.sh"
126
+ { echo "# g\tclass\ttier\trun_here\towner\tnotes"; } >"$TD/case1/.pi/guards.tsv"
127
+ registry_check "$TD/case1/.pi/guards.tsv" "$TD/case1" check >/dev/null 2>&1
128
+ [ $? -ne 0 ]
129
+ bump $?
130
+
131
+ # 2. a bad tier must be rejected
132
+ mkdir -p "$TD/case2/.pi/scripts"
133
+ printf '#!/usr/bin/env bash\nexit 0\n' >"$TD/case2/.pi/scripts/validate-x.sh"
134
+ printf '.pi/scripts/validate-x.sh\tclass\twrong-tier\tno\towner\tnote\n' >"$TD/case2/.pi/guards.tsv"
135
+ out2="$(registry_check "$TD/case2/.pi/guards.tsv" "$TD/case2" check 2>&1)"
136
+ case "$out2" in *"bad tier"*) bump 0 ;; *) bump 1 ;; esac
137
+
138
+ # 3. a fully classified registry must pass
139
+ mkdir -p "$TD/case3/.pi/scripts"
140
+ printf '#!/usr/bin/env bash\nexit 0\n' >"$TD/case3/.pi/scripts/validate-y.sh"
141
+ printf '.pi/scripts/validate-y.sh\tclass\tgating\tyes\towner\tnote\n' >"$TD/case3/.pi/guards.tsv"
142
+ registry_check "$TD/case3/.pi/guards.tsv" "$TD/case3" check >/dev/null 2>&1
143
+ bump $?
144
+
145
+ echo "self-test: $ok ok, $fail failed"
146
+ [ "$fail" -eq 0 ]
147
+ ;;
148
+ --run)
149
+ registry_check "$REG" "$ROOT" check || exit 1
150
+ echo "── gating guards ──"
151
+ rc=0
152
+ while IFS=$'\t' read -r guard _cls tier run_here _owner _notes; do
153
+ case "$guard" in \#* | "") continue ;; esac
154
+ if [ "$tier" = "gating" ] && [ "$run_here" = "yes" ]; then
155
+ echo "▶ $guard"
156
+ if ! bash "$ROOT/$guard"; then
157
+ rc=1
158
+ echo "✗ $guard FAILED"
159
+ fi
160
+ fi
161
+ done <"$REG"
162
+ [ "$rc" -eq 0 ] && echo "✅ all gating guards passed"
163
+ exit "$rc"
164
+ ;;
165
+ --list)
166
+ registry_check "$REG" "$ROOT" list
167
+ ;;
168
+ *)
169
+ registry_check "$REG" "$ROOT" check
170
+ ;;
171
+ esac
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================================
3
+ # validate-script-drift.sh — one source of truth for mirrored .pi/ trees (ADR-010)
4
+ # ============================================================================
5
+ # `.pi/` is copied into sub-projects / crates, so the same command name can end
6
+ # up as a *different program* depending on which tree invoked it. This guard
7
+ # fails when a file mirrored across trees differs AND is not a recorded,
8
+ # reviewed difference.
9
+ #
10
+ # Trees are the immediate subdirectories that carry a `.pi` directory, plus the
11
+ # repo root. Differences are recorded in `.pi/script-drift-allow.tsv`:
12
+ #
13
+ # pattern<TAB>trees<TAB>reason
14
+ #
15
+ # A pattern present below means the difference is known; the reason says whether
16
+ # it is by design, inert, or drift deliberately not reconciled.
17
+ #
18
+ # Usage:
19
+ # bash .pi/scripts/validate-script-drift.sh # check (gating)
20
+ # bash .pi/scripts/validate-script-drift.sh --list # show recorded + drifted
21
+ # bash .pi/scripts/validate-script-drift.sh --self-test
22
+ # ============================================================================
23
+ set -uo pipefail
24
+
25
+ # Resolve the project root: the nearest ancestor of this script that looks like
26
+ # a project (has .git or package.json). Works whether the script is invoked from
27
+ # the canonical tree (templates/pi/scripts) or the shipped copy (.pi/scripts).
28
+ resolve_root() {
29
+ local d
30
+ d="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
31
+ while [ "$d" != "/" ]; do
32
+ if [ -e "$d/.git" ] || [ -e "$d/package.json" ]; then
33
+ echo "$d"
34
+ return
35
+ fi
36
+ d="$(dirname "$d")"
37
+ done
38
+ echo "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
39
+ }
40
+ ROOT="$(resolve_root)"
41
+ ALLOW="$ROOT/.pi/script-drift-allow.tsv"
42
+ MODE="${1:-check}"
43
+
44
+ drift_check() {
45
+ # $1 = root, $2 = allowlist path, $3 = mode, $4... = subtrees ("." is root)
46
+ python3 - "$@" <<'PY'
47
+ import fnmatch, hashlib, os, sys
48
+
49
+ root, allow_path, mode = sys.argv[1:4]
50
+ # Each remaining argument is "label=dir"; `dir` contains a scripts/ subtree.
51
+ pairs = []
52
+ for a in sys.argv[4:]:
53
+ label, _, d = a.partition("=")
54
+ if d:
55
+ pairs.append((label, d))
56
+ if not pairs:
57
+ pairs = [(".", os.path.join(root, ".pi"))]
58
+
59
+ records = []
60
+ if os.path.exists(allow_path):
61
+ for lineno, line in enumerate(open(allow_path, encoding="utf-8"), 1):
62
+ line = line.rstrip("\n")
63
+ if not line.strip() or line.startswith("#"):
64
+ continue
65
+ parts = line.split("\t")
66
+ if len(parts) < 3:
67
+ print(f"{allow_path}:{lineno}: malformed row "
68
+ f"(need pattern<TAB>trees<TAB>reason)", file=sys.stderr)
69
+ sys.exit(2)
70
+ records.append((parts[0], parts[1], "\t".join(parts[2:])))
71
+
72
+ def recorded(rel):
73
+ for pat, _trees, reason in records:
74
+ if fnmatch.fnmatch(rel, pat):
75
+ return reason
76
+ return None
77
+
78
+ contents = {}
79
+ for label, d in pairs:
80
+ d = os.path.normpath(os.path.join(d, "scripts"))
81
+ if not os.path.isdir(d):
82
+ continue
83
+ for dirpath, _dirnames, files in os.walk(d):
84
+ for f in files:
85
+ p = os.path.join(dirpath, f)
86
+ rel = os.path.relpath(p, d)
87
+ h = hashlib.sha256(open(p, "rb").read()).hexdigest()
88
+ contents.setdefault(rel, {})[label] = h
89
+
90
+ mirrored = {r: d for r, d in contents.items() if len(d) >= 2}
91
+ drifted = {r: d for r, d in mirrored.items() if len(set(d.values())) > 1}
92
+ allowed = {r: d for r, d in drifted.items() if recorded(r) is not None}
93
+ bad = {r: d for r, d in drifted.items() if recorded(r) is None}
94
+
95
+ if mode == "list":
96
+ print(f"mirrored files checked: {len(mirrored)}")
97
+ print(f"recorded differences (allowed): {len(allowed)}")
98
+ for r in sorted(allowed):
99
+ print(f" {r}")
100
+ print(f" trees: {','.join(sorted(allowed[r]))}")
101
+ print(f" why: {recorded(r)}")
102
+ print(f"unrecorded differences: {len(bad)}")
103
+ for r in sorted(bad):
104
+ print(f" {r}")
105
+ for t in sorted(bad[r]):
106
+ print(f" {t:<10} {bad[r][t][:16]}")
107
+ sys.exit(0)
108
+
109
+ if not mirrored:
110
+ print("script drift: nothing mirrored — no trees to compare (OK)")
111
+ sys.exit(0)
112
+
113
+ print(f"script drift: {len(mirrored)} mirrored files, "
114
+ f"{len(allowed)} recorded difference(s), {len(bad)} unrecorded")
115
+
116
+ if bad:
117
+ print("", file=sys.stderr)
118
+ print("Unintentional drift — mirrored files differ and are not recorded in "
119
+ ".pi/script-drift-allow.tsv:", file=sys.stderr)
120
+ for r in sorted(bad):
121
+ print(f" {r}", file=sys.stderr)
122
+ for t in sorted(bad[r]):
123
+ print(f" {t:<10} {bad[r][t][:16]}", file=sys.stderr)
124
+ print(" -> reconcile to one implementation, or record the "
125
+ "difference with a reason", file=sys.stderr)
126
+ sys.exit(1)
127
+
128
+ print("OK — every unrecorded mirrored file is byte-identical across trees.")
129
+ PY
130
+ }
131
+
132
+ # Trees compared by default:
133
+ # 1. the repo root;
134
+ # 2. every immediate subdirectory that carries .pi/scripts (crate layout);
135
+ # 3. any mirror pair declared in .pi/mirrors.tsv (e.g. templates/pi -> .pi).
136
+ # Declaring a mirror is how a framework repo compares its canonical tree with
137
+ # the copy it ships in.
138
+ # Directories compared. Each entry is a directory that CONTAINS a `scripts/`
139
+ # subtree; files are compared by path relative to that `scripts/` dir.
140
+ # * the repo root (.pi/scripts is the root .pi/ -> its scripts dir)
141
+ # * every immediate subdirectory carrying .pi/scripts (crate layout)
142
+ # * any extra tree declared in .pi/mirrors.tsv (one path per line, e.g.
143
+ # templates/pi — how a framework compares its canonical tree with the copy
144
+ # it ships inside the repo).
145
+ tree_dirs() {
146
+ echo "$ROOT/.pi"
147
+ for d in "$ROOT"/*/; do
148
+ [ -d "$d.pi/scripts" ] && echo "${d%/}/.pi"
149
+ done
150
+ local mirrors="$ROOT/.pi/mirrors.tsv"
151
+ if [ -f "$mirrors" ]; then
152
+ while IFS= read -r line; do
153
+ case "$line" in \#*|"") continue ;; esac
154
+ line="$(echo "$line" | tr -d '[:space:]')"
155
+ [ -n "$line" ] && [ -d "$ROOT/$line/scripts" ] && echo "$ROOT/$line"
156
+ done <"$mirrors"
157
+ fi
158
+ }
159
+
160
+ # label=dir arguments for the real comparison; label is the display name.
161
+ tree_args() {
162
+ local out=""
163
+ for d in $(tree_dirs); do
164
+ local rel="${d#"$ROOT"/}"
165
+ out="$out ${rel}=$d"
166
+ done
167
+ echo "$out"
168
+ }
169
+
170
+ case "$MODE" in
171
+ --self-test)
172
+ TD="$(mktemp -d "${TMPDIR:-/tmp}/drift-selftest.XXXXXX")"
173
+ ok=0
174
+ fail=0
175
+ bump() {
176
+ if [ "$1" -eq 0 ]; then ok=$((ok + 1)); else fail=$((fail + 1)); echo " x case $2" >&2; fi
177
+ }
178
+
179
+ # 1. identical copies -> pass
180
+ mkdir -p "$TD/case1/pi/scripts" "$TD/case1/crate/pi/scripts"
181
+ printf 'echo a\n' >"$TD/case1/pi/scripts/tool.sh"
182
+ cp "$TD/case1/pi/scripts/tool.sh" "$TD/case1/crate/pi/scripts/tool.sh"
183
+ : >"$TD/case1/allow.tsv"
184
+ drift_check "$TD/case1" "$TD/case1/allow.tsv" check "pi=$TD/case1/pi" "crate=$TD/case1/crate/pi" >/dev/null 2>&1
185
+ bump $? 1
186
+
187
+ # 2. divergent copies, unrecorded -> fail
188
+ mkdir -p "$TD/case2/pi/scripts" "$TD/case2/crate/pi/scripts"
189
+ printf 'echo a\n' >"$TD/case2/pi/scripts/tool.sh"
190
+ printf 'echo b\n' >"$TD/case2/crate/pi/scripts/tool.sh"
191
+ : >"$TD/case2/allow.tsv"
192
+ drift_check "$TD/case2" "$TD/case2/allow.tsv" check "pi=$TD/case2/pi" "crate=$TD/case2/crate/pi" >/dev/null 2>&1
193
+ [ $? -ne 0 ]
194
+ bump $? 2
195
+
196
+ # 3. divergent copies, recorded -> pass
197
+ printf 'tool.sh\tpi,crate\tby design\n' >"$TD/case2/allow.tsv"
198
+ drift_check "$TD/case2" "$TD/case2/allow.tsv" check "pi=$TD/case2/pi" "crate=$TD/case2/crate/pi" >/dev/null 2>&1
199
+ bump $? 3
200
+
201
+ echo "self-test: $ok ok, $fail failed"
202
+ [ "$fail" -eq 0 ]
203
+ ;;
204
+ --list | list)
205
+ # shellcheck disable=SC2046
206
+ drift_check "$ROOT" "$ALLOW" list $(tree_args)
207
+ ;;
208
+ *)
209
+ [ -f "$ALLOW" ] || printf '' >"$ALLOW"
210
+ # shellcheck disable=SC2046
211
+ drift_check "$ROOT" "$ALLOW" check $(tree_args)
212
+ ;;
213
+ esac
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================================
3
+ # validate-shell-exit-status.sh — a passing path must not exit non-zero
4
+ # ============================================================================
5
+ # The bug class: an arithmetic command used as a *statement*.
6
+ #
7
+ # set -e; x=0; x=$((x + 1)); echo survived
8
+ # bash 3.2 (macOS) → exit 0, prints "survived"
9
+ # bash 4.4 / 5.x → exit 1, prints nothing
10
+ #
11
+ # Two shapes are caught:
12
+ # 1. a standalone arithmetic command in a script that enables errexit
13
+ # 2. the same command as the tail of a one-line function body — there the
14
+ # *function* returns 1, which errexit treats as a failed command on every
15
+ # shell: `log_pass() { echo ok; PASS=$((PASS + 1)); }` ends the script on macOS too.
16
+ #
17
+ # The fix is the assignment form, `X=$((X + 1))`, which always returns 0 and
18
+ # does not swallow a real failure (unlike `x=$((x + 1)) || true`).
19
+ #
20
+ # Usage: bash .pi/scripts/validate-shell-exit-status.sh [root] [--self-test]
21
+ # ============================================================================
22
+ set -uo pipefail
23
+
24
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
25
+ # Resolve the project root: the nearest ancestor of this script that looks like
26
+ # a project (has .git or package.json). Works whether the script is invoked from
27
+ # the canonical tree (templates/pi/scripts) or the shipped copy (.pi/scripts).
28
+ resolve_root() {
29
+ local d
30
+ d="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
31
+ while [ "$d" != "/" ]; do
32
+ if [ -e "$d/.git" ] || [ -e "$d/package.json" ]; then
33
+ echo "$d"
34
+ return
35
+ fi
36
+ d="$(dirname "$d")"
37
+ done
38
+ echo "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
39
+ }
40
+ RESOLVED_ROOT="$(resolve_root)"
41
+ ROOT="${1:-$RESOLVED_ROOT}"
42
+
43
+ [ "$ROOT" = "--self-test" ] && ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
44
+ SELFTEST=0
45
+ for a in "$@"; do [ "$a" = "--self-test" ] && SELFTEST=1; done
46
+
47
+ ARITH='\(\([[:space:]]*[A-Za-z_][A-Za-z0-9_]*[[:space:]]*(\+\+|--)[[:space:]]*\)\)'
48
+ STANDALONE_RE="^[[:space:]]*${ARITH}[[:space:]]*;?[[:space:]]*(#.*)?$"
49
+ FUNC_TAIL_RE="^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*[[:space:]]*\(\)[[:space:]]*\{.*;[[:space:]]*${ARITH}[[:space:]]*;[[:space:]]*\}[[:space:]]*(#.*)?$"
50
+ ERREXIT_RE='^[[:space:]]*set[[:space:]]+-[a-zA-Z]*e|set[[:space:]]+-o[[:space:]]+errexit'
51
+
52
+ scan() {
53
+ # $1 = root to scan
54
+ local root="$1"
55
+ local offenders=0
56
+ while IFS= read -r f; do
57
+ case "$f" in */node_modules/* | */target/* | */.git/*) continue ;; esac
58
+ # Only files that enable errexit are in scope.
59
+ grep -qE "$ERREXIT_RE" "$f" 2>/dev/null || continue
60
+ local n=0
61
+ while IFS= read -r line; do
62
+ n=$((n + 1))
63
+ if [[ "$line" =~ $STANDALONE_RE ]] || [[ "$line" =~ $FUNC_TAIL_RE ]]; then
64
+ if [ "$offenders" -eq 0 ]; then
65
+ echo "Arithmetic command used as a statement (returns 1 when the value is 0):" >&2
66
+ fi
67
+ offenders=$((offenders + 1))
68
+ echo " ${f#"$root"/}:$n: $(echo "$line" | sed 's/^[[:space:]]*//')" >&2
69
+ fi
70
+ done <"$f"
71
+ done < <(find "$root" -name '*.sh' -not -path '*/node_modules/*' -not -path '*/target/*' -not -path '*/.git/*' 2>/dev/null)
72
+
73
+ if [ "$offenders" -gt 0 ]; then
74
+ echo "" >&2
75
+ echo " Use the assignment form, which always returns 0:" >&2
76
+ echo " X=\$((X + 1)) (not ((X++)) and not ((X++)) || true)" >&2
77
+ return 1
78
+ fi
79
+ return 0
80
+ }
81
+
82
+ if [ "$SELFTEST" -eq 1 ]; then
83
+ TD="$(mktemp -d "${TMPDIR:-/tmp}/exits-selftest.XXXXXX")"
84
+ ok=0
85
+ fail=0
86
+ bump() { if [ "$1" -eq 0 ]; then ok=$((ok + 1)); else fail=$((fail + 1)); fi; }
87
+
88
+ # 1. standalone arithmetic command -> fail
89
+ mkdir -p "$TD/case1"
90
+ printf '#!/usr/bin/env bash\nset -e\nx=0\n((x++))\necho survived\n' >"$TD/case1/bad.sh"
91
+ scan "$TD/case1" >/dev/null 2>&1
92
+ [ $? -ne 0 ]
93
+ bump $?
94
+
95
+ # 2. function tail -> fail
96
+ mkdir -p "$TD/case2"
97
+ printf '#!/usr/bin/env bash\nset -euo pipefail\nPASS=0\nlog_pass() { echo ok; ((PASS++)); }\n' >"$TD/case2/bad.sh"
98
+ scan "$TD/case2" >/dev/null 2>&1
99
+ [ $? -ne 0 ]
100
+ bump $?
101
+
102
+ # 3. assignment form -> pass
103
+ mkdir -p "$TD/case3"
104
+ printf '#!/usr/bin/env bash\nset -e\nx=0\nx=$((x + 1))\necho survived\n' >"$TD/case3/good.sh"
105
+ scan "$TD/case3" >/dev/null 2>&1
106
+ bump $?
107
+
108
+ # 4. no errexit -> out of scope, pass
109
+ mkdir -p "$TD/case4"
110
+ printf '#!/usr/bin/env bash\nx=0\n((x++))\necho ok\n' >"$TD/case4/loose.sh"
111
+ scan "$TD/case4" >/dev/null 2>&1
112
+ bump $?
113
+
114
+ echo "self-test: $ok ok, $fail failed"
115
+ [ "$fail" -eq 0 ]
116
+ exit $?
117
+ fi
118
+
119
+ echo "── shell exit-status scan: $ROOT ──"
120
+ scan "$ROOT" && echo "✅ no arithmetic-statement exit hazards"