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.
- package/dist/cli.js +1 -1
- package/dist/exports.js +1 -1
- package/package.json +1 -1
- package/templates/pi/architecture/wiring-claims.tsv +13 -0
- package/templates/pi/coverage-floors.tsv +12 -0
- package/templates/pi/extensions/architect.ts +3 -3
- package/templates/pi/extensions/pipeline.ts +7 -1
- package/templates/pi/guards.tsv +33 -0
- package/templates/pi/mirrors.tsv +9 -0
- package/templates/pi/preflight_report.json +1 -1
- package/templates/pi/script-drift-allow.tsv +13 -0
- package/templates/pi/scripts/ci/check_architecture_conformance.sh +6 -6
- package/templates/pi/scripts/ci/run_hardening_stages.sh +7 -7
- package/templates/pi/scripts/ci/run_preflight.sh +6 -6
- package/templates/pi/scripts/ci/stage_docs_policy.sh +3 -3
- package/templates/pi/scripts/ci/stage_lint.sh +2 -2
- package/templates/pi/scripts/ci/stage_remaining.sh +3 -3
- package/templates/pi/scripts/ci/stage_static_analysis.sh +4 -4
- package/templates/pi/scripts/ci/stage_unit.sh +2 -2
- package/templates/pi/scripts/ci/validate_agent_output.sh +11 -11
- package/templates/pi/scripts/languages/python/validate-canonical.sh +3 -3
- package/templates/pi/scripts/validate-coverage-floors.sh +174 -0
- package/templates/pi/scripts/validate-guards.sh +171 -0
- package/templates/pi/scripts/validate-script-drift.sh +213 -0
- package/templates/pi/scripts/validate-shell-exit-status.sh +120 -0
- package/templates/pi/scripts/validate-wiring-claims.sh +228 -0
|
@@ -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"
|