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.
- 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 +44 -4
- package/templates/pi/extensions/epic.ts +113 -0
- 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 +2 -2
- package/templates/pi/prompts/epic-parallel.md +54 -0
- 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/epic-parallel.sh +95 -0
- package/templates/pi/scripts/epic-registry.sh +138 -0
- 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
|
@@ -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
|