guardian-framework 0.1.159 → 0.1.160
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/extensions/architect.ts +70 -1
- package/templates/pi/preflight_report.json +1 -1
- package/templates/pi/scripts/epic-fanout.sh +74 -44
- package/templates/pi/scripts/epic-parallel.sh +21 -28
- package/templates/pi/scripts/freeze.sh +471 -0
package/dist/cli.js
CHANGED
|
@@ -3206,7 +3206,7 @@ __export(exports_package, {
|
|
|
3206
3206
|
bin: () => bin,
|
|
3207
3207
|
author: () => author
|
|
3208
3208
|
});
|
|
3209
|
-
var name = "guardian-framework", version = "0.1.
|
|
3209
|
+
var name = "guardian-framework", version = "0.1.160", description = "Token-optimized agentic framework scaffolder with pi-first architecture", type = "module", main = "dist/exports.js", exports, types = "dist/exports.d.ts", bin, files, engines, scripts, publishConfig, pi, repository, homepage = "https://github.com/arman-jalili/guardian-framework#readme", bugs, dependencies, devDependencies, keywords, author = "Arman Wolkensteiner-Jalili", license = "MIT", package_default;
|
|
3210
3210
|
var init_package = __esm(() => {
|
|
3211
3211
|
exports = {
|
|
3212
3212
|
".": {
|
package/dist/exports.js
CHANGED
|
@@ -1991,7 +1991,7 @@ __export(exports_package, {
|
|
|
1991
1991
|
bin: () => bin,
|
|
1992
1992
|
author: () => author
|
|
1993
1993
|
});
|
|
1994
|
-
var name = "guardian-framework", version = "0.1.
|
|
1994
|
+
var name = "guardian-framework", version = "0.1.160", description = "Token-optimized agentic framework scaffolder with pi-first architecture", type = "module", main = "dist/exports.js", exports, types = "dist/exports.d.ts", bin, files, engines, scripts, publishConfig, pi, repository, homepage = "https://github.com/arman-jalili/guardian-framework#readme", bugs, dependencies, devDependencies, keywords, author = "Arman Wolkensteiner-Jalili", license = "MIT", package_default;
|
|
1995
1995
|
var init_package = __esm(() => {
|
|
1996
1996
|
exports = {
|
|
1997
1997
|
".": {
|
package/package.json
CHANGED
|
@@ -507,7 +507,7 @@ export default function architectExtension(pi: ExtensionAPI) {
|
|
|
507
507
|
const tokens = raw ? parseArgs(raw) : [];
|
|
508
508
|
if (tokens.length === 0) {
|
|
509
509
|
ctx.ui.notify(
|
|
510
|
-
"Usage: /architect [--epic Name] [--tracking-issue N] [--tdd] [--pr-per epic|issue] [--parallel|--worktree] | --roadmap | --phase \"Phase 1\" | --phase-status | --phase-done <N> | --phase-module-done <N> \"Module\" | status | next-epic | abort",
|
|
510
|
+
"Usage: /architect [--epic Name] [--tracking-issue N] [--tdd] [--pr-per epic|issue] [--parallel|--worktree] | --freeze [--module M] [--record --commit SHA] | --roadmap | --phase \"Phase 1\" | --phase-status | --phase-done <N> | --phase-module-done <N> \"Module\" | status | next-epic | abort",
|
|
511
511
|
"info",
|
|
512
512
|
);
|
|
513
513
|
return;
|
|
@@ -872,6 +872,75 @@ export default function architectExtension(pi: ExtensionAPI) {
|
|
|
872
872
|
return;
|
|
873
873
|
}
|
|
874
874
|
|
|
875
|
+
// ── Freeze phase (spec v1) ────────────────────────────────────
|
|
876
|
+
// `/architect --freeze [--module M]` works ONLY the contract freeze for
|
|
877
|
+
// one module, then stops. `/architect --freeze --record` records the
|
|
878
|
+
// evidence after you merge. Phase B (`--epic --parallel`) runs only
|
|
879
|
+
// modules that pass `freeze.sh verify`.
|
|
880
|
+
if (tokens.includes("--freeze")) {
|
|
881
|
+
const freezeSh = join(ctx.cwd, ".pi", "scripts", "freeze.sh");
|
|
882
|
+
if (!existsSync(freezeSh)) {
|
|
883
|
+
ctx.ui.notify("freeze.sh not found — run `guardian update`.", "error");
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
const modFlag = findFlag(tokens, "--module");
|
|
887
|
+
|
|
888
|
+
if (tokens.includes("--record")) {
|
|
889
|
+
if (!modFlag) {
|
|
890
|
+
ctx.ui.notify("Usage: /architect --freeze --record --module <name> [--commit <sha>]", "error");
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
const shaFlag = findFlag(tokens, "--commit");
|
|
894
|
+
const cmd = `bash .pi/scripts/freeze.sh record --module "${modFlag}"${shaFlag ? ` --commit ${shaFlag}` : ""}`;
|
|
895
|
+
const res = runScript(ctx.cwd, cmd);
|
|
896
|
+
ctx.ui.notify(
|
|
897
|
+
String(res.stdout ?? "").trim().split("\n").slice(-14).join("\n"),
|
|
898
|
+
res.exitCode === 0 ? "success" : "error",
|
|
899
|
+
);
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Which module? explicit, else the next eligible one in order.
|
|
904
|
+
let target = modFlag ?? "";
|
|
905
|
+
if (!target) {
|
|
906
|
+
const nx = runScript(ctx.cwd, "bash .pi/scripts/freeze.sh next");
|
|
907
|
+
target = String(nx.stdout ?? "").trim().split("\n").pop() ?? "";
|
|
908
|
+
if (nx.exitCode !== 0 || !target || target.startsWith("no eligible")) {
|
|
909
|
+
ctx.ui.notify(
|
|
910
|
+
String(nx.stdout ?? "").trim() || "No module is eligible to freeze (dependencies unfrozen).",
|
|
911
|
+
"error",
|
|
912
|
+
);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Freeze = create the epic + its issue set ONCE, then stop.
|
|
918
|
+
// (Declared here: the shared consts are defined further down.)
|
|
919
|
+
const freezeTracking = findFlag(tokens, "--tracking-issue");
|
|
920
|
+
const freezeTdd = tokens.includes("--tdd");
|
|
921
|
+
const state = await manager.startEpic(ctx, target, freezeTracking, freezeTdd);
|
|
922
|
+
if (!state?.slices?.length) {
|
|
923
|
+
ctx.ui.notify(`Could not start a freeze for "${target}" — check .pi/architecture/modules/.`, "error");
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
ctx.ui.notify(
|
|
927
|
+
[
|
|
928
|
+
`FREEZE PHASE — ${target}`,
|
|
929
|
+
"",
|
|
930
|
+
"Work ONLY the contract-freeze issue. Do not implement.",
|
|
931
|
+
"Deliver: contracts (docs/contracts/…), frozen signatures, and behaviour",
|
|
932
|
+
"tests that FAIL for the right reason (NotImplementedError, not ImportError).",
|
|
933
|
+
"",
|
|
934
|
+
"Then, after you merge the contract-freeze PR:",
|
|
935
|
+
` /architect --freeze --record --module "${target}" --commit <merge-sha>`,
|
|
936
|
+
"",
|
|
937
|
+
"Until it records and verifies, Phase B refuses this module.",
|
|
938
|
+
].join("\n"),
|
|
939
|
+
"info",
|
|
940
|
+
);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
|
|
875
944
|
const epicName = findFlag(tokens, "--epic");
|
|
876
945
|
const trackingIssueId = findFlag(tokens, "--tracking-issue");
|
|
877
946
|
// --pr-per epic (default) | issue — one PR for the whole epic, or the
|
|
@@ -34,8 +34,8 @@ FORCE_FREEZE=0
|
|
|
34
34
|
DRY_RUN=0
|
|
35
35
|
NO_SPAWN=0
|
|
36
36
|
SPAWN=0
|
|
37
|
-
ASSUME_FROZEN=0
|
|
38
37
|
ALLOW_REMOTE=0
|
|
38
|
+
STOP=0
|
|
39
39
|
BASE="main"
|
|
40
40
|
TIMEOUT_MS=0
|
|
41
41
|
|
|
@@ -48,7 +48,12 @@ while [ $# -gt 0 ]; do
|
|
|
48
48
|
--dry-run) DRY_RUN=1; shift ;;
|
|
49
49
|
--no-spawn) NO_SPAWN=1; shift ;;
|
|
50
50
|
--spawn) SPAWN=1; shift ;;
|
|
51
|
-
--assume-frozen)
|
|
51
|
+
--assume-frozen)
|
|
52
|
+
echo "⛔ --assume-frozen no longer exists: there is no bypass for the freeze gate." >&2
|
|
53
|
+
echo " Freeze the module properly: /architect --freeze --module <m>" >&2
|
|
54
|
+
exit 2
|
|
55
|
+
;;
|
|
56
|
+
--stop) STOP=1; shift ;;
|
|
52
57
|
--allow-remote-issues) ALLOW_REMOTE=1; shift ;;
|
|
53
58
|
--base) BASE="${2:-main}"; shift 2 ;;
|
|
54
59
|
--timeout-ms) TIMEOUT_MS="${2:-0}"; shift 2 ;;
|
|
@@ -86,6 +91,39 @@ if [ -z "$NODE_BIN" ]; then
|
|
|
86
91
|
exit 1
|
|
87
92
|
fi
|
|
88
93
|
|
|
94
|
+
# ── Stop: kill every spawned agent recorded in the registry ─────────────────
|
|
95
|
+
if [ "$STOP" -eq 1 ]; then
|
|
96
|
+
PIDS="$(python3 - "$COMMON_ABS/guardian/epics.json" <<'PY' 2>/dev/null
|
|
97
|
+
import json, sys
|
|
98
|
+
try:
|
|
99
|
+
d = json.load(open(sys.argv[1], encoding="utf-8"))
|
|
100
|
+
except Exception:
|
|
101
|
+
raise SystemExit
|
|
102
|
+
for mod, e in (d.get("epics") or {}).items():
|
|
103
|
+
if isinstance(e, dict) and e.get("pid"):
|
|
104
|
+
print(f"{e['pid']} {mod}")
|
|
105
|
+
PY
|
|
106
|
+
)"
|
|
107
|
+
if [ -z "$PIDS" ]; then
|
|
108
|
+
echo "No agents recorded in the registry — nothing to stop."
|
|
109
|
+
exit 0
|
|
110
|
+
fi
|
|
111
|
+
echo "Stopping spawned agents:"
|
|
112
|
+
while read -r pid mod; do
|
|
113
|
+
[ -z "$pid" ] && continue
|
|
114
|
+
if kill "$pid" 2>/dev/null; then
|
|
115
|
+
echo " stopped $mod (pid $pid)"
|
|
116
|
+
bash "$REGISTRY" set "$mod" "status=stopped" "pid=" >/dev/null 2>&1
|
|
117
|
+
else
|
|
118
|
+
echo " $mod (pid $pid) not running"
|
|
119
|
+
bash "$REGISTRY" set "$mod" "status=stopped" "pid=" >/dev/null 2>&1
|
|
120
|
+
fi
|
|
121
|
+
done <<<"$PIDS"
|
|
122
|
+
echo ""
|
|
123
|
+
echo "Note: each agent's own pi child is terminated with its runner."
|
|
124
|
+
exit 0
|
|
125
|
+
fi
|
|
126
|
+
|
|
89
127
|
# ── Resolve the module list ─────────────────────────────────────────────────
|
|
90
128
|
# Explicit list wins. Otherwise take roadmap order, skipping implemented modules.
|
|
91
129
|
SELECTED="$(
|
|
@@ -193,6 +231,39 @@ if [ "$SPAWN" -eq 1 ] && [ "$MAX" -le 0 ]; then
|
|
|
193
231
|
fi
|
|
194
232
|
fi
|
|
195
233
|
|
|
234
|
+
# ── Freeze gate (spec v1): Phase B only runs FROZEN modules ──────────────────
|
|
235
|
+
# A module is frozen when freeze.sh verify passes: contracts merged, signatures
|
|
236
|
+
# present, behaviour tests RED for the right reason, ACs referenced, deps frozen.
|
|
237
|
+
# There is no bypass: this is the one decision a human makes.
|
|
238
|
+
FREEZE_SH="$ROOT/.pi/scripts/freeze.sh"
|
|
239
|
+
if [ ! -f "$FREEZE_SH" ]; then
|
|
240
|
+
echo "⛔ missing .pi/scripts/freeze.sh — run guardian update" >&2
|
|
241
|
+
exit 1
|
|
242
|
+
fi
|
|
243
|
+
|
|
244
|
+
UNFROZEN=""
|
|
245
|
+
for m in ${MODULES[@]+"${MODULES[@]}"}; do
|
|
246
|
+
if ! bash "$FREEZE_SH" verify --module "$m" >/tmp/guardian-freeze-$$.log 2>&1; then
|
|
247
|
+
UNFROZEN="$UNFROZEN$m
|
|
248
|
+
"
|
|
249
|
+
fi
|
|
250
|
+
done
|
|
251
|
+
|
|
252
|
+
if [ -n "$UNFROZEN" ]; then
|
|
253
|
+
echo "⛔ Not frozen — Phase B only runs frozen modules:"
|
|
254
|
+
printf '%s' "$UNFROZEN" | sed 's/^/ - /'
|
|
255
|
+
echo ""
|
|
256
|
+
echo " Freeze first (one module at a time, in dependency order):"
|
|
257
|
+
echo " /architect --freeze --module <m>"
|
|
258
|
+
echo " then merge the contract-freeze PR and record it:"
|
|
259
|
+
echo " bash .pi/scripts/freeze.sh record --module <m> --commit <sha>"
|
|
260
|
+
echo ""
|
|
261
|
+
echo " Detail for the first module:"
|
|
262
|
+
bash "$FREEZE_SH" verify --module "$(printf '%s' "$UNFROZEN" | head -1)"
|
|
263
|
+
exit 2
|
|
264
|
+
fi
|
|
265
|
+
rm -f /tmp/guardian-freeze-$$.log
|
|
266
|
+
|
|
196
267
|
# ── Pre-flight: the spawned agents inherit THIS project's .pi/ ──────────────
|
|
197
268
|
# Without extensions, /architect is not a command: the prompt falls through to
|
|
198
269
|
# the model, which then improvises (expensive and wrong). Fail before spawning.
|
|
@@ -421,45 +492,6 @@ PY
|
|
|
421
492
|
exit 0
|
|
422
493
|
fi
|
|
423
494
|
|
|
424
|
-
# ── Contract-freeze gate (the module itself) ────────────────────────────────
|
|
425
|
-
# Freeze is a human act: it asserts the module's public contracts are reviewed
|
|
426
|
-
# and merged. This script must never assert that for you — doing so is what let
|
|
427
|
-
# a fan-out start six agents on unreviewed contracts.
|
|
428
|
-
if [ "$ASSUME_FROZEN" -eq 0 ]; then
|
|
429
|
-
UNFROZEN="$(python3 - "$COMMON_ABS/guardian/epics.json" ${MODULES[@]+"${MODULES[@]}"} <<'PY'
|
|
430
|
-
import json, re, sys
|
|
431
|
-
|
|
432
|
-
path, batch = sys.argv[1], sys.argv[2:]
|
|
433
|
-
frozen = set()
|
|
434
|
-
try:
|
|
435
|
-
for mod, entry in (json.load(open(path, encoding="utf-8")).get("epics") or {}).items():
|
|
436
|
-
if isinstance(entry, dict) and entry.get("contractFrozen"):
|
|
437
|
-
frozen.add(re.sub(r"[^a-z0-9]+", "-", mod.lower()).strip("-"))
|
|
438
|
-
except Exception:
|
|
439
|
-
pass
|
|
440
|
-
|
|
441
|
-
missing = []
|
|
442
|
-
for m in batch:
|
|
443
|
-
slug = re.sub(r"[^a-z0-9]+", "-", m.lower()).strip("-")
|
|
444
|
-
if slug not in frozen:
|
|
445
|
-
missing.append(m)
|
|
446
|
-
print("\n".join(missing))
|
|
447
|
-
PY
|
|
448
|
-
)"
|
|
449
|
-
if [ -n "$UNFROZEN" ]; then
|
|
450
|
-
echo "⛔ Contracts are not frozen for:"
|
|
451
|
-
echo "$UNFROZEN" | sed 's/^/ - /'
|
|
452
|
-
echo ""
|
|
453
|
-
echo " Freeze is YOUR assertion that the module's public contracts are"
|
|
454
|
-
echo " reviewed and merged. Review the module doc, merge its contract"
|
|
455
|
-
echo " freeze, then record it:"
|
|
456
|
-
echo " bash .pi/scripts/epic-registry.sh freeze \"<Module>\" --frozen"
|
|
457
|
-
echo ""
|
|
458
|
-
echo " Bypass only if you are certain: --assume-frozen"
|
|
459
|
-
exit 2
|
|
460
|
-
fi
|
|
461
|
-
fi
|
|
462
|
-
|
|
463
495
|
# ── Freeze, create worktrees, spawn ─────────────────────────────────────────
|
|
464
496
|
mkdir -p "$LOGDIR"
|
|
465
497
|
STARTED=()
|
|
@@ -474,9 +506,7 @@ for m in ${MODULES[@]+"${MODULES[@]}"}; do
|
|
|
474
506
|
|
|
475
507
|
|
|
476
508
|
# 2. worktree + epic branch (gated on the freeze we just recorded)
|
|
477
|
-
|
|
478
|
-
[ "$ASSUME_FROZEN" -eq 1 ] && WT_ARGS+=(--assume-frozen)
|
|
479
|
-
WT_OUT="$(bash "$PARALLEL" "${WT_ARGS[@]}" 2>&1)"
|
|
509
|
+
WT_OUT="$(bash "$PARALLEL" "$m" --base "$BASE" 2>&1)"
|
|
480
510
|
WT_RC=$?
|
|
481
511
|
if [ "$WT_RC" -ne 0 ]; then
|
|
482
512
|
echo " ✗ worktree failed (exit $WT_RC)"
|
|
@@ -13,10 +13,9 @@ set -uo pipefail
|
|
|
13
13
|
|
|
14
14
|
MODULE="${1:-}"
|
|
15
15
|
shift || true
|
|
16
|
-
BASE="main"; DIR=""
|
|
16
|
+
BASE="main"; DIR=""
|
|
17
17
|
while [ $# -gt 0 ]; do
|
|
18
18
|
case "$1" in
|
|
19
|
-
--assume-frozen) ASSUME_FROZEN=1; shift ;;
|
|
20
19
|
--base) BASE="${2:-main}"; shift 2 ;;
|
|
21
20
|
--dir) DIR="${2:-}"; shift 2 ;;
|
|
22
21
|
*) shift ;;
|
|
@@ -44,32 +43,26 @@ echo " branch: $BRANCH"
|
|
|
44
43
|
echo " worktree: $DIR"
|
|
45
44
|
echo ""
|
|
46
45
|
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
echo "⛔
|
|
66
|
-
|
|
67
|
-
echo " Parallel epics must not redefine shared contracts. Freeze first:"
|
|
68
|
-
echo " 1. run the epic's Contract Freeze issue and merge it to $BASE"
|
|
69
|
-
echo " 2. record it: bash .pi/scripts/epic-registry.sh freeze \"$MODULE\" --frozen"
|
|
70
|
-
echo ""
|
|
71
|
-
echo " (single-epic work does not need this gate)"
|
|
72
|
-
exit 2
|
|
46
|
+
# The project dir (owner of .pi/) is NOT necessarily the git top level: a
|
|
47
|
+
# monorepo keeps the app in <repo>/backend with .pi/ inside it.
|
|
48
|
+
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
49
|
+
FREEZE_SH="$PROJECT_DIR/.pi/scripts/freeze.sh"
|
|
50
|
+
if [ -f "$FREEZE_SH" ]; then
|
|
51
|
+
if ! bash "$FREEZE_SH" verify --module "$MODULE" >/dev/null 2>&1; then
|
|
52
|
+
echo "⛔ Blocked: '$MODULE' is not frozen (freeze.sh verify failed)."
|
|
53
|
+
echo ""
|
|
54
|
+
echo " Freeze it first:"
|
|
55
|
+
echo " /architect --freeze --module \"$MODULE\""
|
|
56
|
+
echo " then merge the contract-freeze PR and record it:"
|
|
57
|
+
echo " bash .pi/scripts/freeze.sh record --module \"$MODULE\" --commit <sha>"
|
|
58
|
+
echo ""
|
|
59
|
+
echo " Detail:"
|
|
60
|
+
bash "$FREEZE_SH" verify --module "$MODULE" 2>&1 | sed 's/^/ /'
|
|
61
|
+
exit 2
|
|
62
|
+
fi
|
|
63
|
+
else
|
|
64
|
+
echo "⛔ missing .pi/scripts/freeze.sh — run guardian update" >&2
|
|
65
|
+
exit 1
|
|
73
66
|
fi
|
|
74
67
|
|
|
75
68
|
# ── Worktree creation ───────────────────────────────────────────────────────
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ============================================================================
|
|
3
|
+
# freeze.sh — the contract-freeze state machine (spec v1)
|
|
4
|
+
# ============================================================================
|
|
5
|
+
# "Frozen" is not a flag somebody sets. It is evidence that can be re-checked:
|
|
6
|
+
#
|
|
7
|
+
# frozen = contracts merged on the base branch
|
|
8
|
+
# + frozen signatures in source
|
|
9
|
+
# + behaviour tests that are RED for the right reason
|
|
10
|
+
# + every test names an AC that exists in the module doc
|
|
11
|
+
# + no structural-only tests (isinstance / dataclass / hasattr)
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# freeze.sh verify --module <m> [--root DIR] [--base main] [--json]
|
|
15
|
+
# freeze.sh record --module <m> [--root DIR] [--base main] [--commit SHA]
|
|
16
|
+
# freeze.sh status [--root DIR]
|
|
17
|
+
# freeze.sh list [--root DIR]
|
|
18
|
+
# freeze.sh next [--root DIR] # next module eligible to freeze, in order
|
|
19
|
+
#
|
|
20
|
+
# Exit: 0 pass · 1 verification failed · 2 usage/state error
|
|
21
|
+
# ============================================================================
|
|
22
|
+
set -uo pipefail
|
|
23
|
+
|
|
24
|
+
CMD="${1:-}"; shift || true
|
|
25
|
+
MODULE=""; ROOT_OPT=""; BASE="main"; JSON_OUT=0; COMMIT=""
|
|
26
|
+
|
|
27
|
+
while [ $# -gt 0 ]; do
|
|
28
|
+
case "$1" in
|
|
29
|
+
--module) MODULE="${2:-}"; shift 2 ;;
|
|
30
|
+
--root) ROOT_OPT="${2:-}"; shift 2 ;;
|
|
31
|
+
--base) BASE="${2:-main}"; shift 2 ;;
|
|
32
|
+
--commit) COMMIT="${2:-}"; shift 2 ;;
|
|
33
|
+
--json) JSON_OUT=1; shift ;;
|
|
34
|
+
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
|
35
|
+
esac
|
|
36
|
+
done
|
|
37
|
+
|
|
38
|
+
# Project root: the directory that owns .pi/ (this script lives in <root>/.pi/scripts).
|
|
39
|
+
PROJECT_DIR="$(
|
|
40
|
+
d="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
41
|
+
while [ "$d" != "/" ]; do
|
|
42
|
+
if [ -d "$d/.pi" ]; then echo "$d"; exit 0; fi
|
|
43
|
+
d="$(dirname "$d")"
|
|
44
|
+
done
|
|
45
|
+
echo "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
46
|
+
)"
|
|
47
|
+
ROOT="${ROOT_OPT:-$PROJECT_DIR}"
|
|
48
|
+
GIT_TOP="$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null || echo "$ROOT")"
|
|
49
|
+
COMMON_DIR="$(cd "$(git -C "$ROOT" rev-parse --git-common-dir 2>/dev/null || echo "$GIT_TOP/.git")" 2>/dev/null && pwd || echo "$GIT_TOP/.git")"
|
|
50
|
+
STATE="$COMMON_DIR/guardian/epics.json"
|
|
51
|
+
|
|
52
|
+
usage() {
|
|
53
|
+
sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
case "$CMD" in
|
|
57
|
+
verify | record | status | list | next) ;;
|
|
58
|
+
*) usage; exit 2 ;;
|
|
59
|
+
esac
|
|
60
|
+
|
|
61
|
+
exec python3 - "$CMD" "$ROOT" "$GIT_TOP" "$STATE" "$MODULE" "$BASE" "$JSON_OUT" "$COMMIT" <<'PY'
|
|
62
|
+
import json, os, re, subprocess, sys
|
|
63
|
+
from datetime import datetime, timezone
|
|
64
|
+
|
|
65
|
+
cmd, root, git_top, state_path, module, base, json_out, commit = sys.argv[1:9]
|
|
66
|
+
json_out = json_out == "1"
|
|
67
|
+
|
|
68
|
+
MODS = os.path.join(root, ".pi", "architecture", "modules")
|
|
69
|
+
|
|
70
|
+
def slugify(s):
|
|
71
|
+
return re.sub(r"[^a-z0-9]+", "-", (s or "").lower()).strip("-")
|
|
72
|
+
|
|
73
|
+
def read(path):
|
|
74
|
+
try:
|
|
75
|
+
return open(path, encoding="utf-8", errors="ignore").read()
|
|
76
|
+
except OSError:
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
def module_doc(slug):
|
|
80
|
+
p = os.path.join(MODS, f"{slug}.md")
|
|
81
|
+
return p if os.path.exists(p) else None
|
|
82
|
+
|
|
83
|
+
def git(*args):
|
|
84
|
+
try:
|
|
85
|
+
return subprocess.run(["git", "-C", git_top] + list(args),
|
|
86
|
+
capture_output=True, text=True, timeout=60).stdout.strip()
|
|
87
|
+
except Exception:
|
|
88
|
+
return ""
|
|
89
|
+
|
|
90
|
+
def load_state():
|
|
91
|
+
try:
|
|
92
|
+
return json.load(open(state_path, encoding="utf-8"))
|
|
93
|
+
except Exception:
|
|
94
|
+
return {"version": 1, "epics": {}}
|
|
95
|
+
|
|
96
|
+
def save_state(state):
|
|
97
|
+
os.makedirs(os.path.dirname(state_path), exist_ok=True)
|
|
98
|
+
json.dump(state, open(state_path, "w", encoding="utf-8"), indent=2)
|
|
99
|
+
|
|
100
|
+
def entry(state, slug):
|
|
101
|
+
e = (state.get("epics") or {}).get(slug)
|
|
102
|
+
return e if isinstance(e, dict) else {}
|
|
103
|
+
|
|
104
|
+
def set_entry(doc, slug, **fields):
|
|
105
|
+
"""Write one module entry. First arg is the state DOCUMENT, not a field."""
|
|
106
|
+
doc.setdefault("epics", {})
|
|
107
|
+
e = doc["epics"].get(slug)
|
|
108
|
+
if not isinstance(e, dict):
|
|
109
|
+
e = {}
|
|
110
|
+
e["module"] = slug
|
|
111
|
+
e.update(fields)
|
|
112
|
+
doc["epics"][slug] = e
|
|
113
|
+
return e
|
|
114
|
+
|
|
115
|
+
# ── dependency helpers ──────────────────────────────────────────────────────
|
|
116
|
+
def deps_of(slug):
|
|
117
|
+
p = module_doc(slug)
|
|
118
|
+
if not p:
|
|
119
|
+
return []
|
|
120
|
+
lines = read(p).splitlines()
|
|
121
|
+
scope, sec = list(lines), None
|
|
122
|
+
for i, ln in enumerate(lines):
|
|
123
|
+
if re.match(r"^##\s+Dependencies\b", ln, re.I):
|
|
124
|
+
sec = i
|
|
125
|
+
break
|
|
126
|
+
if sec is not None:
|
|
127
|
+
scope = []
|
|
128
|
+
for ln in lines[sec + 1:]:
|
|
129
|
+
if re.match(r"^##\s+", ln):
|
|
130
|
+
break
|
|
131
|
+
scope.append(ln)
|
|
132
|
+
block, in_deps = [], False
|
|
133
|
+
for ln in scope:
|
|
134
|
+
label = ln.strip()
|
|
135
|
+
m1 = re.match(r"^#{2,4}\s*Depends On\b(.*)$", label, re.I)
|
|
136
|
+
m2 = re.match(r"^\*\*Depends On:?\*\*(.*)$", label, re.I)
|
|
137
|
+
if m1 or m2:
|
|
138
|
+
in_deps = True
|
|
139
|
+
rest = (m1.group(1) if m1 else m2.group(1)).strip()
|
|
140
|
+
if rest:
|
|
141
|
+
block.append(rest)
|
|
142
|
+
continue
|
|
143
|
+
if in_deps and re.match(r"^#{2,4}\s", label):
|
|
144
|
+
in_deps = False
|
|
145
|
+
continue
|
|
146
|
+
if in_deps:
|
|
147
|
+
block.append(label)
|
|
148
|
+
joined = " ".join(block)
|
|
149
|
+
if not block or re.search(r"\bnone\b", joined, re.I):
|
|
150
|
+
return []
|
|
151
|
+
found = []
|
|
152
|
+
for ln in block:
|
|
153
|
+
if ln.startswith("|"):
|
|
154
|
+
cells = [c.strip() for c in ln.strip("|").split("|")]
|
|
155
|
+
if cells:
|
|
156
|
+
nm = cells[0].replace(chr(96), "").replace("*", "").strip()
|
|
157
|
+
if nm and nm.lower() not in ("module", "modules") and not set(nm) <= set("-: "):
|
|
158
|
+
found.append(slugify(nm))
|
|
159
|
+
elif ln.startswith("- "):
|
|
160
|
+
nm = ln[2:].split("\u2014")[0].strip().strip(chr(96) + "* ")
|
|
161
|
+
if nm and nm.lower() != "none":
|
|
162
|
+
found.append(slugify(nm))
|
|
163
|
+
else:
|
|
164
|
+
# inline list: "**Depends On:** Billing, Authority"
|
|
165
|
+
for part in re.split(r"[,;]", ln):
|
|
166
|
+
nm = part.strip().strip(chr(96) + "* ").rstrip(".")
|
|
167
|
+
if nm and not nm.lower().startswith("none") and len(nm) < 60:
|
|
168
|
+
found.append(slugify(nm))
|
|
169
|
+
return found
|
|
170
|
+
|
|
171
|
+
def state_of(state, slug):
|
|
172
|
+
e = entry(state, slug)
|
|
173
|
+
return e.get("state") or ("frozen" if e.get("contractFrozen") else "unfrozen")
|
|
174
|
+
|
|
175
|
+
# ── the verification ────────────────────────────────────────────────────────
|
|
176
|
+
STRUCTURAL_ONLY = re.compile(
|
|
177
|
+
r"\b(isinstance|issubclass|dataclasses\.is_dataclass|is_dataclass|hasattr|"
|
|
178
|
+
r"inspect\.isclass|inspect\.isfunction|inspect\.getmembers|type\(|__mro__|"
|
|
179
|
+
r"is_property|isinstance\(.*Property\))\b"
|
|
180
|
+
)
|
|
181
|
+
# A suite is behavioural when it ENFORCES A RULE: it expects a raise, or it
|
|
182
|
+
# asserts something that is not knowledge about the *shape* of the code.
|
|
183
|
+
# Everything below is shape: subclassing, dataclass internals, member names,
|
|
184
|
+
# signatures, annotations, enum defaults. 199 green assertions of that kind over
|
|
185
|
+
# NotImplementedError bodies is exactly what this check exists to catch.
|
|
186
|
+
RAISES = re.compile(r"(pytest\.raises|assertRaises|toThrow|should_panic|expect_err)", re.I)
|
|
187
|
+
SHAPE_ASSERT = re.compile(
|
|
188
|
+
r"(isinstance|issubclass|hasattr|inspect\.|__dataclass|__annotations__|__mro__|"
|
|
189
|
+
r"_model_kind|signature|dataclasses\.|_fields|__name__|__qualname__|"
|
|
190
|
+
r"\bnames\b|annotations\[|startswith\(\s*[\"']set_|__dict__|dir\()"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def classify(code):
|
|
194
|
+
"""Return 'behaviour' | 'structural' | 'none'."""
|
|
195
|
+
asserts = [l for l in code.splitlines() if re.match(r"\s*assert\b", l)]
|
|
196
|
+
if RAISES.search(code):
|
|
197
|
+
return "behaviour"
|
|
198
|
+
if not asserts:
|
|
199
|
+
return "none"
|
|
200
|
+
substantive = [a for a in asserts if not SHAPE_ASSERT.search(a)]
|
|
201
|
+
return "behaviour" if substantive else "structural"
|
|
202
|
+
|
|
203
|
+
AC_ID = re.compile(r"\b[A-Z]{1,3}-\d{1,3}\b")
|
|
204
|
+
|
|
205
|
+
def find_tests(slug):
|
|
206
|
+
"""Test files for the module. `tests/` wins; `src/` is a fallback only for
|
|
207
|
+
projects that colocate tests, so source files are never counted as suites."""
|
|
208
|
+
for base_dir in ("tests", "src"):
|
|
209
|
+
hits = []
|
|
210
|
+
base_path = os.path.join(root, base_dir)
|
|
211
|
+
if not os.path.isdir(base_path):
|
|
212
|
+
continue
|
|
213
|
+
for dp, _dn, fs in os.walk(base_path):
|
|
214
|
+
for f in fs:
|
|
215
|
+
if not re.search(r"\.(py|ts|rs|go|java)$", f):
|
|
216
|
+
continue
|
|
217
|
+
low = os.path.join(dp, f).lower()
|
|
218
|
+
if slug in low or slug.replace("-", "") in low.replace("-", ""):
|
|
219
|
+
hits.append(os.path.join(dp, f))
|
|
220
|
+
if hits:
|
|
221
|
+
return sorted(set(hits))
|
|
222
|
+
return []
|
|
223
|
+
|
|
224
|
+
def analyse_tests(slug, tests):
|
|
225
|
+
"""Return (total, behaviour, structural_only, ac_named, details)."""
|
|
226
|
+
total = behaviour = struct_only = ac_named = 0
|
|
227
|
+
details = []
|
|
228
|
+
for f in tests:
|
|
229
|
+
src = read(f)
|
|
230
|
+
if not src.strip():
|
|
231
|
+
continue
|
|
232
|
+
code = "\n".join(l for l in src.splitlines() if not l.strip().startswith("#"))
|
|
233
|
+
kind = classify(code)
|
|
234
|
+
body_struct = kind == "structural"
|
|
235
|
+
body_behav = kind == "behaviour"
|
|
236
|
+
acs = sorted(set(AC_ID.findall(src)))
|
|
237
|
+
# count test functions/entries crudely but honestly
|
|
238
|
+
n_tests = len(re.findall(r"^\s*(?:async\s+)?def test_|^\s*it\(|\bfn\s+test_|@Test", code, re.M)) or 1
|
|
239
|
+
total += n_tests
|
|
240
|
+
if body_behav:
|
|
241
|
+
behaviour += 1
|
|
242
|
+
elif body_struct:
|
|
243
|
+
struct_only += 1
|
|
244
|
+
details.append(f"{os.path.relpath(f, root)}: structural assertions only")
|
|
245
|
+
if acs:
|
|
246
|
+
ac_named += 1
|
|
247
|
+
return total, behaviour, struct_only, ac_named, details
|
|
248
|
+
|
|
249
|
+
def run_python_tests(tests):
|
|
250
|
+
"""Return (status, reason, sample) where status: red_ok|red_wrong|green|unknown."""
|
|
251
|
+
dirs = sorted({os.path.dirname(f) for f in tests})
|
|
252
|
+
if not dirs:
|
|
253
|
+
return "unknown", "no tests found", ""
|
|
254
|
+
venv = os.path.join(root, ".venv", "bin", "python")
|
|
255
|
+
py = venv if os.path.exists(venv) else sys.executable
|
|
256
|
+
try:
|
|
257
|
+
proc = subprocess.run([py, "-m", "pytest", *dirs, "-q", "--no-header", "-x", "--tb=line"],
|
|
258
|
+
cwd=root, capture_output=True, text=True, timeout=900)
|
|
259
|
+
except Exception as e:
|
|
260
|
+
return "unknown", f"could not run pytest: {e}", ""
|
|
261
|
+
out = (proc.stdout or "") + (proc.stderr or "")
|
|
262
|
+
if proc.returncode == 0:
|
|
263
|
+
return "green", "tests pass while the module is supposedly unimplemented", out[-800:]
|
|
264
|
+
if re.search(r"(ImportError|ModuleNotFoundError|SyntaxError|Error while importing|collection error)", out):
|
|
265
|
+
return "red_wrong", "tests fail on import/syntax, not on behaviour", out[-800:]
|
|
266
|
+
if re.search(r"NotImplementedError|AssertionError|assert", out):
|
|
267
|
+
return "red_ok", "tests fail on missing behaviour", out[-800:]
|
|
268
|
+
return "red_wrong", "unrecognised failure reason", out[-800:]
|
|
269
|
+
|
|
270
|
+
def verify(slug):
|
|
271
|
+
checks = []
|
|
272
|
+
ok_all = True
|
|
273
|
+
|
|
274
|
+
doc = module_doc(slug)
|
|
275
|
+
checks.append(("module doc", bool(doc), doc and os.path.relpath(doc, root) or f"missing .pi/architecture/modules/{slug}.md"))
|
|
276
|
+
ok_all &= bool(doc)
|
|
277
|
+
doctext = read(doc) if doc else ""
|
|
278
|
+
|
|
279
|
+
# 1. contract artifacts exist and are referenced by the module doc
|
|
280
|
+
contracts = []
|
|
281
|
+
cdir = os.path.join(root, "docs", "contracts")
|
|
282
|
+
if os.path.isdir(cdir):
|
|
283
|
+
for f in sorted(os.listdir(cdir)):
|
|
284
|
+
if slug in f.lower() or slug.replace("-", "") in f.lower().replace("-", ""):
|
|
285
|
+
contracts.append(os.path.join("docs", "contracts", f))
|
|
286
|
+
referenced = [c for c in contracts if os.path.basename(c) in doctext or c in doctext]
|
|
287
|
+
ok_contracts = bool(contracts) and bool(referenced)
|
|
288
|
+
checks.append(("contracts", ok_contracts,
|
|
289
|
+
f"{len(contracts)} file(s), {len(referenced)} referenced by the module doc"
|
|
290
|
+
if contracts else "no docs/contracts/* for this module"))
|
|
291
|
+
ok_all &= ok_contracts
|
|
292
|
+
|
|
293
|
+
# 2. frozen signatures in source
|
|
294
|
+
src_dirs = [d for d in (os.path.join(root, "src", slug),
|
|
295
|
+
os.path.join(root, "src", slug.replace("-", "_"))) if os.path.isdir(d)]
|
|
296
|
+
sig_files = []
|
|
297
|
+
for d in src_dirs:
|
|
298
|
+
for dp, _dn, fs in os.walk(d):
|
|
299
|
+
sig_files += [os.path.join(dp, f) for f in fs if re.search(r"\.(py|ts|java|rs|go)$", f)]
|
|
300
|
+
ok_sigs = bool(sig_files)
|
|
301
|
+
checks.append(("frozen signatures", ok_sigs, f"{len(sig_files)} source file(s)" if ok_sigs else f"no src/{slug}/"))
|
|
302
|
+
ok_all &= ok_sigs
|
|
303
|
+
|
|
304
|
+
# 3. tests exist, are behaviour (not structural-only), and name ACs
|
|
305
|
+
tests = find_tests(slug)
|
|
306
|
+
total, behaviour, struct_only, ac_named, tdetails = analyse_tests(slug, tests)
|
|
307
|
+
ok_tests = bool(tests)
|
|
308
|
+
checks.append(("contract tests", ok_tests, f"{len(tests)} file(s)" if tests else "no tests referencing the module"))
|
|
309
|
+
ok_all &= ok_tests
|
|
310
|
+
|
|
311
|
+
# Advisory only: a syntactic shape-vs-behaviour classifier is always
|
|
312
|
+
# arguable. The objective gate is the RED check below — tests that are GREEN
|
|
313
|
+
# over NotImplementedError bodies are structural by definition, whatever the
|
|
314
|
+
# assertions look like.
|
|
315
|
+
checks.append(("assertion mix", True,
|
|
316
|
+
f"~{behaviour} substantive / {struct_only} shape-only suite(s) (advisory)"))
|
|
317
|
+
|
|
318
|
+
doc_acs = set(AC_ID.findall(doctext))
|
|
319
|
+
ok_acs = ok_tests and ac_named > 0 and bool(doc_acs)
|
|
320
|
+
checks.append(("AC references", ok_acs,
|
|
321
|
+
f"{ac_named} suite(s) name an AC; module doc has {len(doc_acs)} AC id(s)"
|
|
322
|
+
if ok_acs else f"tests name ACs: {ac_named}; module doc AC ids: {len(doc_acs)}"))
|
|
323
|
+
ok_all &= ok_acs
|
|
324
|
+
|
|
325
|
+
# 4. RED for the right reason
|
|
326
|
+
status, reason, sample = run_python_tests(tests) if tests else ("unknown", "no tests", "")
|
|
327
|
+
ok_red = status == "red_ok"
|
|
328
|
+
if status == "green":
|
|
329
|
+
reason = ("tests are GREEN while the module is unimplemented — they assert shape, "
|
|
330
|
+
"not rules; a freeze needs failing behaviour tests")
|
|
331
|
+
checks.append(("red state", ok_red, f"{status}: {reason}"))
|
|
332
|
+
ok_all &= ok_red
|
|
333
|
+
|
|
334
|
+
# 5. dependencies frozen/implemented
|
|
335
|
+
bad_deps = []
|
|
336
|
+
st = load_state()
|
|
337
|
+
for d in deps_of(slug):
|
|
338
|
+
s = state_of(st, d)
|
|
339
|
+
if s not in ("frozen", "implemented"):
|
|
340
|
+
bad_deps.append(f"{d} ({s})")
|
|
341
|
+
ok_deps = not bad_deps
|
|
342
|
+
checks.append(("dependencies", ok_deps, "all frozen/implemented" if ok_deps else ", ".join(bad_deps)))
|
|
343
|
+
ok_all &= ok_deps
|
|
344
|
+
|
|
345
|
+
return ok_all, checks, sample, {"contracts": contracts, "tests": [os.path.relpath(t, root) for t in tests]}
|
|
346
|
+
|
|
347
|
+
def emit(ok, checks, slug, extra=None, sample=""):
|
|
348
|
+
if json_out:
|
|
349
|
+
print(json.dumps({"module": slug, "ok": ok,
|
|
350
|
+
"checks": [{"name": n, "ok": bool(o), "detail": d} for n, o, d in checks],
|
|
351
|
+
**(extra or {})}, indent=2))
|
|
352
|
+
return
|
|
353
|
+
head = "PASS" if ok else "FAIL"
|
|
354
|
+
print(f"freeze verify: {slug} — {head}")
|
|
355
|
+
for n, o, d in checks:
|
|
356
|
+
print(f" [{'ok' if o else 'XX'}] {n:<20} {d}")
|
|
357
|
+
if sample and not ok:
|
|
358
|
+
print("\n failure sample:")
|
|
359
|
+
for line in sample.strip().splitlines()[-6:]:
|
|
360
|
+
print(f" {line}")
|
|
361
|
+
|
|
362
|
+
def roadmap_order():
|
|
363
|
+
"""Modules in roadmap order, Phase 0 skipped, from the roadmap Modules tables."""
|
|
364
|
+
rp = os.path.join(root, ".pi", "architecture", "implementation-roadmap.md")
|
|
365
|
+
order, phase, in_modules = [], None, False
|
|
366
|
+
for line in read(rp).splitlines():
|
|
367
|
+
st = line.strip()
|
|
368
|
+
if st.startswith("## "):
|
|
369
|
+
phase, in_modules = st[3:].strip(), False
|
|
370
|
+
continue
|
|
371
|
+
if st.startswith("### "):
|
|
372
|
+
in_modules = st[4:].strip().lower().startswith("modules")
|
|
373
|
+
continue
|
|
374
|
+
if not in_modules or not st.startswith("|"):
|
|
375
|
+
continue
|
|
376
|
+
cells = [c.strip() for c in st.strip("|").split("|")]
|
|
377
|
+
if len(cells) < 2:
|
|
378
|
+
continue
|
|
379
|
+
name = cells[0].replace(chr(96), "").replace("*", "").strip()
|
|
380
|
+
if not name or name.lower() in ("module", "modules") or set(name) <= set("-: "):
|
|
381
|
+
continue
|
|
382
|
+
if phase and phase.lower().startswith("phase 0"):
|
|
383
|
+
continue
|
|
384
|
+
doc = cells[-1].replace(chr(96), "").strip()
|
|
385
|
+
slug = os.path.basename(doc)[:-3] if doc.endswith(".md") else slugify(name)
|
|
386
|
+
if slug not in order:
|
|
387
|
+
order.append(slug)
|
|
388
|
+
if not order and os.path.isdir(MODS):
|
|
389
|
+
order = sorted(f[:-3] for f in os.listdir(MODS) if f != "module-template.md")
|
|
390
|
+
return order
|
|
391
|
+
|
|
392
|
+
if cmd == "next":
|
|
393
|
+
st = load_state()
|
|
394
|
+
for m in roadmap_order():
|
|
395
|
+
if state_of(st, m) in ("frozen", "implemented"):
|
|
396
|
+
continue
|
|
397
|
+
deps = deps_of(m)
|
|
398
|
+
bad = [f"{d} ({state_of(st, d)})" for d in deps if state_of(st, d) not in ("frozen", "implemented")]
|
|
399
|
+
if bad:
|
|
400
|
+
continue # not eligible yet; a later module never leapfrogs
|
|
401
|
+
print(m)
|
|
402
|
+
raise SystemExit(0)
|
|
403
|
+
print("no eligible module: every module is frozen/implemented or blocked by an unfrozen dependency")
|
|
404
|
+
raise SystemExit(1)
|
|
405
|
+
|
|
406
|
+
# ── commands ────────────────────────────────────────────────────────────────
|
|
407
|
+
if cmd == "list":
|
|
408
|
+
st = load_state()
|
|
409
|
+
mods = sorted(f[:-3] for f in os.listdir(MODS)) if os.path.isdir(MODS) else []
|
|
410
|
+
if not mods:
|
|
411
|
+
print("no modules found")
|
|
412
|
+
raise SystemExit(0)
|
|
413
|
+
print(f"{'module':<28} {'state':<14} evidence")
|
|
414
|
+
for m in mods:
|
|
415
|
+
if m == "module-template":
|
|
416
|
+
continue
|
|
417
|
+
e = entry(st, m)
|
|
418
|
+
ev = (e.get("evidence") or {}).get("commit", "")
|
|
419
|
+
print(f"{m:<28} {state_of(st, m):<14} {ev}")
|
|
420
|
+
raise SystemExit(0)
|
|
421
|
+
|
|
422
|
+
if cmd == "status":
|
|
423
|
+
if not module:
|
|
424
|
+
print("usage: freeze.sh status --module <m>", file=sys.stderr)
|
|
425
|
+
raise SystemExit(2)
|
|
426
|
+
slug = slugify(module)
|
|
427
|
+
st = load_state()
|
|
428
|
+
e = entry(st, slug)
|
|
429
|
+
print(f"module: {slug}\nstate: {state_of(st, slug)}")
|
|
430
|
+
if e.get("evidence"):
|
|
431
|
+
print("evidence:", json.dumps(e["evidence"]))
|
|
432
|
+
raise SystemExit(0)
|
|
433
|
+
|
|
434
|
+
if not module:
|
|
435
|
+
print("usage: freeze.sh verify|record --module <m>", file=sys.stderr)
|
|
436
|
+
raise SystemExit(2)
|
|
437
|
+
|
|
438
|
+
slug = slugify(module)
|
|
439
|
+
|
|
440
|
+
if cmd == "verify":
|
|
441
|
+
ok, checks, sample, extra = verify(slug)
|
|
442
|
+
emit(ok, checks, slug, extra, sample)
|
|
443
|
+
raise SystemExit(0 if ok else 1)
|
|
444
|
+
|
|
445
|
+
# record: only with passing verification AND a real commit on the base branch
|
|
446
|
+
ok, checks, sample, extra = verify(slug)
|
|
447
|
+
if not ok:
|
|
448
|
+
emit(ok, checks, slug, extra, sample)
|
|
449
|
+
print("\nRefusing to record: the freeze does not verify.", file=sys.stderr)
|
|
450
|
+
raise SystemExit(1)
|
|
451
|
+
|
|
452
|
+
sha = commit or git("rev-parse", "HEAD")
|
|
453
|
+
if not sha:
|
|
454
|
+
print("cannot resolve a commit to record", file=sys.stderr)
|
|
455
|
+
raise SystemExit(2)
|
|
456
|
+
on_base = git("merge-base", "--is-ancestor", sha, base)
|
|
457
|
+
is_ancestor = subprocess.run(["git", "-C", git_top, "merge-base", "--is-ancestor", sha, base]).returncode == 0
|
|
458
|
+
if not is_ancestor:
|
|
459
|
+
print(f"Refusing to record: {sha[:8]} is not on '{base}'. Merge the contract freeze first.", file=sys.stderr)
|
|
460
|
+
raise SystemExit(1)
|
|
461
|
+
|
|
462
|
+
st = load_state()
|
|
463
|
+
set_entry(st, slug, state="frozen", contractFrozen=True,
|
|
464
|
+
evidence={"commit": sha, "branch": base,
|
|
465
|
+
"contracts": extra.get("contracts", []),
|
|
466
|
+
"tests": extra.get("tests", []),
|
|
467
|
+
"at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")})
|
|
468
|
+
save_state(st)
|
|
469
|
+
print(f"recorded: {slug} frozen at {sha[:8]} on {base}")
|
|
470
|
+
raise SystemExit(0)
|
|
471
|
+
PY
|