AtlatestRepositorysigil-system

sigil-system / tree / scriptsgate-system-grant-migration

1#!/bin/sh
2# gate-system-grant-migration - the gate for rebuilding
3# (sigil system grant) as a profile over (sigil grant).
4#
5# Outcomes, four distinct things:
6# MIGRATION COMPLETE PASS (0) every check ran and passed
7# MIGRATION COMPLETE FAIL (1) a check ran and failed
8# MIGRATION TIMED-OUT (2) a bounded step did not finish
9# MIGRATION SETUP-FAILED (3) the subject was never established
11# A MIGRATION'S FAILURE MODE IS SILENT DIVERGENCE, so the load-bearing
12# arm is a DIFFERENTIAL: the migrated grant-check must agree with the
13# pre-migration one decision for decision. The agreement count is
14# printed and asserted non-zero, because "they agreed on nothing because
15# neither ran" and "they agreed on everything" are otherwise the same
16# colour.
18# Scratch goes to /tmp. Nothing is written under the repo except build
19# output.
21set -u
23REPO="$(cd "$(dirname "$0")/.." && pwd)"
24SCRATCH="$(mktemp -d /tmp/gate-sysgrant.XXXXXX)"
25FIXTURE="$SCRATCH/fixture"
26TIMEOUT_S="${GATE_SYSGRANT_TIMEOUT:-900}"
28SIGIL="${GATE_SYSGRANT_SIGIL:-/home/daviwil/Projects/Code/sigil/task-sigil-grant-p1-algebra/build/dev/bin/sigil}"
29REDIRECTS="${GATE_SYSGRANT_REDIRECTS:-/tmp/grant-dev-redirects.sgl}"
30PROBE="${GATE_SYSGRANT_PROBE:-$REPO/scripts/grant-differential-probe.sgl}"
31MODULE="$REPO/src/sigil/system/grant.sgl"
32BASE_REF="${GATE_SYSGRANT_BASE:-944b328}"
34say() { echo "$@"; }
35rule() { say "------------------------------------------------------------"; }
37doubt() {
38 say ""
39 say "BEFORE READING THIS AS A PRODUCT DEFECT: suspect this gate."
40 say " Check that the sigil it used ($SIGIL) is the one you think, that"
41 say " the redirect actually applied (step 2 proves it BEHAVIOURALLY --"
42 say " a clean build proves nothing here, Sigil is late-binding), and"
43 say " that the decision counts below are non-zero."
46dump_counts() {
47 if [ -s "$SCRATCH/migrated.txt" ] || [ -s "$SCRATCH/baseline.txt" ]; then
48 say ""
49 say "--- instrument state at exit ---"
50 say " baseline decision lines: $(grep -c '^CHK|' "$SCRATCH/baseline.txt" 2>/dev/null || true)"
51 say " migrated decision lines: $(grep -c '^CHK|' "$SCRATCH/migrated.txt" 2>/dev/null || true)"
52 say "--- end ---"
53 fi
56setup_failed() {
57 rule; say "SETUP-FAILED: $*"
58 say "Nothing was tested. This is NOT a pass and NOT a failure."
59 dump_counts; doubt
60 say "Logs kept at: $SCRATCH"
61 say "MIGRATION SETUP-FAILED"; exit 3
64red() {
65 rule; say "FAILED: $*"
66 dump_counts; doubt
67 say "Logs kept at: $SCRATCH"
68 say "MIGRATION COMPLETE FAIL"; exit 1
71timed_out() {
72 rule; say "TIMED-OUT after ${TIMEOUT_S}s: $*"
73 say ""
74 say "CHECKS THAT DID NOT RUN:"
75 say " - the differential (baseline vs migrated decisions)"
76 say " - the existing test-system suite"
77 dump_counts; doubt
78 say "Logs kept at: $SCRATCH"
79 say "MIGRATION TIMED-OUT"; exit 2
82restore_module() {
83 git -C "$REPO" checkout HEAD -- src/sigil/system/grant.sgl 2>/dev/null
85trap 'restore_module' EXIT HUP INT TERM
87for t in awk grep sed timeout git sha256sum diff; do
88 command -v "$t" >/dev/null 2>&1 || setup_failed "required tool '$t' is not on PATH"
89done
90[ -x "$SIGIL" ] || setup_failed "no sigil binary at $SIGIL"
91[ -f "$REDIRECTS" ] || setup_failed "no redirect file at $REDIRECTS -- (sigil grant) is unreleased, so the build needs one"
92[ -f "$PROBE" ] || setup_failed "no differential probe at $PROBE"
93[ -f "$MODULE" ] || setup_failed "module source missing: $MODULE"
95# The module must be COMMITTED before this gate runs: the differential
96# checks the base version out over it and restores with `git checkout
97# HEAD --`, which restores to the LAST COMMIT. An uncommitted module
98# would be destroyed by that restore, and its absence afterwards looks
99# exactly like a clean restore.
100[ -z "$(git -C "$REPO" status --porcelain src/sigil/system/grant.sgl)" ] || setup_failed \
101 "src/sigil/system/grant.sgl has uncommitted changes. This gate swaps it against $BASE_REF and restores with 'git checkout HEAD --', which would revert your work rather than the swap. Commit first."
103rule
104say "GATE gate-system-grant-migration"
105say " repo: $REPO"
106say " sigil: $SIGIL"
107say " version: $("$SIGIL" --version 2>&1 | head -1)"
108say " redirects: $REDIRECTS"
109say " base ref: $BASE_REF ($(git -C "$REPO" rev-parse --short "$BASE_REF"))"
110rule
112say "STEP 1/5 fixture"
113mkdir -p "$FIXTURE/allow" "$FIXTURE/allowed" "$FIXTURE/a,b" "$SCRATCH/outside"
114ln -sfn "$SCRATCH/outside" "$FIXTURE/link-out"
115[ -d "$FIXTURE/a,b" ] || setup_failed "could not create the comma-bearing fixture directory"
116[ -L "$FIXTURE/link-out" ] || setup_failed "could not create the escaping symlink"
117say " root: $FIXTURE (allow/ allowed/ 'a,b'/ link-out -> outside/)"
119# PROVENANCE, behaviourally and from BOTH sides. The redirect is not
120# picked up automatically and has been seen not to apply, silently,
121# with the build still compiling clean -- Sigil is late-binding, so a
122# COMPILE line and a zero exit say nothing about which (sigil grant) is
123# in force. Two-sided so a merely permissive module cannot pass.
124say "STEP 2/5 provenance of (sigil grant)"
125P1="$("$SIGIL" eval '(begin (import (sigil grant)) (display (grant->specs (grant-from-specs (list "fs:rw:/x/a,b")))))' 2>&1)"
126case "$P1" in
127 *"fs:rw:/x/a,b"*) say " accepts a comma-bearing fs path: ok" ;;
128 *) setup_failed "the (sigil grant) in force REFUSES a comma-bearing fs path, so it predates the fix. Got: $P1" ;;
129esac
130P2="$("$SIGIL" eval '(begin (import (sigil grant)) (display (guard (e (#t "REFUSED")) (make-grant (list (list (quote exec) (quote allowlist) (list "a,b")))))))' 2>&1)"
131case "$P2" in
132 *REFUSED*) say " still refuses a comma in an exec command: ok" ;;
133 *) setup_failed "the (sigil grant) in force ACCEPTS a comma in an exec command; it is not the module under test. Got: $P2" ;;
134esac
136say "STEP 3/5 differential vs $BASE_REF"
137MINE_HASH="$(sha256sum "$MODULE" | cut -d' ' -f1)"
138say " migrated module sha256: $MINE_HASH"
140# `sigil eval` has NO --redirects and resolves the RELEASED dependency,
141# so it can see neither the redirected (sigil grant) nor this package's
142# own source. `sigil test <file>` is the package-aware runner that can.
143run_probe() {
144 GRANT_DIFF_ROOT="$FIXTURE" timeout "$TIMEOUT_S" \
145 "$SIGIL" test --redirects "$REDIRECTS" "$PROBE" > "$1.raw" 2>&1
146 rc=$?
147 [ "$rc" -ne 124 ] || timed_out "the differential probe did not finish"
148 # Keep ONLY the probe's own lines, and say why rather than filtering
149 # silently: the runner's chrome carries per-run durations and temp
150 # paths, which differ between two runs of the SAME code and would
151 # manufacture a divergence. The probe's prefixes are exactly the
152 # observable decisions. The surviving count is asserted non-zero
153 # below, so this filter cannot quietly empty the comparison.
154 sed 's/\x1b\[[0-9;]*m//g' "$1.raw" \
155 | grep -E '^(ADD|CHK|LOG|DECISIONS|GRANTS-PRED|PRINCIPAL)' > "$1"
156 return "$rc"
159run_probe "$SCRATCH/migrated.txt" || {
160 say "--- probe output tail ---"; tail -20 "$SCRATCH/migrated.txt"
161 red "the differential probe failed against the MIGRATED module"
164git -C "$REPO" checkout "$BASE_REF" -- src/sigil/system/grant.sgl || setup_failed "could not check out the base module"
165BASE_HASH="$(sha256sum "$MODULE" | cut -d' ' -f1)"
166say " baseline module sha256: $BASE_HASH"
167[ "$BASE_HASH" != "$MINE_HASH" ] || setup_failed \
168 "the baseline and migrated modules are the SAME FILE. The differential would compare a thing to itself and agree for the wrong reason."
170run_probe "$SCRATCH/baseline.txt" || {
171 say "--- probe output tail ---"; tail -20 "$SCRATCH/baseline.txt"
172 restore_module
173 red "the differential probe failed against the BASELINE module"
176restore_module
177NOW_HASH="$(sha256sum "$MODULE" | cut -d' ' -f1)"
178[ "$NOW_HASH" = "$MINE_HASH" ] || red \
179 "the migrated module was NOT restored after the differential (hash $NOW_HASH, expected $MINE_HASH)"
180say " module restored, verified against its pre-swap hash"
182MIG_N="$(grep -c '^CHK|' "$SCRATCH/migrated.txt")"
183BASE_N="$(grep -c '^CHK|' "$SCRATCH/baseline.txt")"
184say " decisions compared: migrated $MIG_N / baseline $BASE_N"
185[ "$MIG_N" -gt 0 ] || red "the migrated probe produced ZERO decisions; the differential compared nothing"
186[ "$BASE_N" -gt 0 ] || red "the baseline probe produced ZERO decisions; the differential compared nothing"
187[ "$MIG_N" -eq "$BASE_N" ] || red "the two probes produced different numbers of decisions ($MIG_N vs $BASE_N)"
189# A migration may INTEND to change a decision. Byte-identity would then
190# force either a failing gate or a corpus with the interesting case
191# quietly removed, and the second is how a differential comes to agree
192# for the wrong reason. So intended divergences are declared BY SHAPE,
193# checked FOR DIRECTION, and counted.
195# The one declared here: the old engine granted the EMPTY command.
196# `exec:allowlist:git,` split to ("git" "") and the "" entry became a
197# permission. Nothing could ever match it (argv[0] is never ""), so it
198# was unreachable authority, and the migration drops it. Every such
199# difference must be a NARROWING, baseline #t becoming migrated #f.
200# A widening of the same shape is a defect and still reds.
201diff -u "$SCRATCH/baseline.txt" "$SCRATCH/migrated.txt" > "$SCRATCH/diff.txt" 2>&1
202grep -E '^[-+]CHK\|' "$SCRATCH/diff.txt" > "$SCRATCH/diffchk.txt" 2>/dev/null || true
204# Anything differing that is NOT the declared shape.
205UNEXPECTED_DIFF="$(grep -E '^[-+]CHK\|' "$SCRATCH/diff.txt" 2>/dev/null \
206 | grep -vE '^-CHK\|[^|]*\|exec\|""\|#t$' \
207 | grep -vE '^\+CHK\|[^|]*\|exec\|""\|#f$' || true)"
208if [ -n "$UNEXPECTED_DIFF" ]; then
209 say "--- UNDECLARED divergence (baseline vs migrated) ---"
210 echo "$UNEXPECTED_DIFF" | head -40
211 say "--- full diff head ---"
212 head -40 "$SCRATCH/diff.txt"
213 red "the migrated engine DISAGREES with the pre-migration engine outside the declared narrowing"
214fi
216NARROWED="$(grep -cE '^\+CHK\|[^|]*\|exec\|""\|#f$' "$SCRATCH/diff.txt" 2>/dev/null || true)"
217NARROWED="${NARROWED:-0}"
218WIDENED="$(grep -cE '^\+CHK\|[^|]*\|exec\|""\|#t$' "$SCRATCH/diff.txt" 2>/dev/null || true)"
219WIDENED="${WIDENED:-0}"
220[ "$WIDENED" -eq 0 ] || red "a declared-shape divergence WIDENED authority ($WIDENED lines). The allowance covers narrowing only."
221AGREED=$((MIG_N - NARROWED))
222say " decisions agreeing: $AGREED of $MIG_N"
223say " declared narrowings (exec \"\" was granted, now denied): $NARROWED, widenings: 0"
225# A differential that agrees is meaningful only if it COULD disagree, and
226# the thing that decides now is the CLASSIFIER above, not `diff`. When a
227# check is repaired by adding a step, the new step is an instrument too
228# and arrives with the same defect rate and none of the scrutiny. So both
229# get a control.
231# Control 1: `diff` itself can see a difference at all.
232CTL="$SCRATCH/control.txt"
233sed 's/^CHK|pty|/CHK|PLANTED|/' "$SCRATCH/migrated.txt" > "$CTL"
234if diff -q "$SCRATCH/baseline.txt" "$CTL" >/dev/null 2>&1; then
235 red "the differential's comparison is BLIND: a deliberately altered transcript still compared equal"
236fi
237say " comparison positive control: an altered transcript IS detected"
239# Control 2, and this is the one that matters now: an UNDECLARED
240# divergence must be REJECTED by the classifier. Without this, a
241# classifier whose patterns were too broad would wave everything through
242# and the gate would report agreement over a transcript full of changes.
243CTL2="$SCRATCH/control-undeclared.txt"
244sed '0,/^CHK|[^|]*|fs-read|/s//CHK|PLANTED-UNDECLARED|fs-read|/' "$SCRATCH/migrated.txt" > "$CTL2"
245if [ "$(diff -u "$SCRATCH/baseline.txt" "$CTL2" 2>/dev/null | grep -cE '^[-+]CHK\|')" -eq 0 ]; then
246 setup_failed "the undeclared-divergence control did not land: the planted line produced no diff"
247fi
248CTL2_LEAK="$(diff -u "$SCRATCH/baseline.txt" "$CTL2" 2>/dev/null | grep -E '^[-+]CHK\|' \
249 | grep -vE '^-CHK\|[^|]*\|exec\|""\|#t$' \
250 | grep -vE '^\+CHK\|[^|]*\|exec\|""\|#f$' || true)"
251[ -n "$CTL2_LEAK" ] || red \
252 "the classifier is TOO BROAD: a deliberately planted UNDECLARED divergence was accepted as declared. Every difference would pass."
253say " classifier positive control: an undeclared divergence IS rejected"
255say "STEP 4/5 existing test-system suite"
256timeout "$TIMEOUT_S" "$SIGIL" test --redirects "$REDIRECTS" > "$SCRATCH/suite.log" 2>&1
257SRC=$?
258[ "$SRC" -ne 124 ] || timed_out "the test-system suite did not finish"
259SUM="$(sed 's/\x1b\[[0-9;]*m//g' "$SCRATCH/suite.log" | grep -E '^ Tests:' | tail -1)"
260[ -n "$SUM" ] || red "the suite printed no 'Tests:' summary line"
261PASSED="$(echo "$SUM" | sed -E 's/.*Tests:[[:space:]]*([0-9]+) passed.*/\1/')"
262TOTAL="$(echo "$SUM" | sed -E 's/.*\(([0-9]+) total\).*/\1/')"
263say " $PASSED passed of $TOTAL"
264[ "$TOTAL" -gt 0 ] || red "the suite reported 0 tests"
266# The known pre-existing failure is NAMED, not tolerated by count. It is
267# a missing runtime primitive unrelated to grants, and it was MEASURED
268# on the base module rather than assumed.
269KNOWN="process sessions optionally merge stderr into captured output"
270FAILED=$((TOTAL - PASSED))
271if [ "$FAILED" -gt 0 ]; then
272 # Take the names from the runner's "Failed tests:" block ONLY.
273 # An earlier version matched `^ <any char> ` and so swallowed every
274 # PASSING line too (they are " <checkmark> name"), reporting 37
275 # passes as unexpected failures. Anchoring on the block is what makes
276 # this a list of failures rather than a list of lines.
277 UNEXPECTED="$(sed 's/\x1b\[[0-9;]*m//g' "$SCRATCH/suite.log" \
278 | awk '/^ Failed tests:/{f=1;next} f&&/^━|^$/{f=0} f{print}' \
279 | sed 's/^[[:space:]]*•[[:space:]]*//' \
280 | grep -v "$KNOWN")"
281 [ -z "$UNEXPECTED" ] || { say "--- unexpected failures ---"; echo "$UNEXPECTED"; red "$FAILED failing, and not all are the known pre-existing one"; }
282 say " $FAILED failing, all of them the known pre-existing:"
283 say " '$KNOWN'"
284 say " (measured on the BASE module too -- it fails identically there)"
285fi
287say "STEP 5/5 structural"
288CTLHITS="$(grep -c 'grant-check' "$MODULE")"
289[ "$CTLHITS" -gt 0 ] || setup_failed "structural grep is blind: 0 hits for a token that IS in $MODULE"
290say " grep positive control: $CTLHITS hits"
292UNDER="$(grep -c 'path-under?' "$MODULE")"
293[ "$UNDER" -eq 0 ] || red "path-under? is still present ($UNDER refs). It was to be DELETED, not adapted -- if the algebra does not cover it, say so rather than keeping a second implementation."
294say " path-under? deleted: ok"
296for keep in realpath canonical-target; do
297 n="$(grep -c "$keep" "$MODULE")"
298 [ "$n" -gt 0 ] || red "$keep is gone from the profile. Canonicalisation is this module's reason to exist; the algebra is pure and cannot do it."
299done
300say " realpath + canonical-target retained: ok"
302# Extract the export block into a list of bare symbols and compare by
303# EXACT STRING. An earlier version pattern-matched `^ <sym>$` and
304# reported `grant-decisions` missing when it is plainly there — the last
305# export carries the closing paren (`grant-decisions)`). A false red,
306# caused by matching layout instead of content; symbols containing `?`
307# and `!` also make a regex the wrong tool here.
308# Range runs from `(export` to `(begin`. NOT to the first blank line:
309# the export block is grouped with blank lines between sections, so a
310# blank-line terminator captured 4 of 8 symbols and reported the rest
311# missing — a second false red from the same habit of matching layout.
312awk '/^ \(export/{f=1} /^ \(begin/{f=0} f' "$MODULE" \
313 | sed -e 's/;;.*//' -e 's/[()]//g' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \
314 | grep -v '^$' | grep -v '^export$' > "$SCRATCH/exports.txt"
315NEXP="$(wc -l < "$SCRATCH/exports.txt")"
316[ "$NEXP" -gt 0 ] || setup_failed "could not extract any export names from $MODULE"
317say " exports extracted: $NEXP"
318for sym in make-grants grant-allow-all grants? grants-principal grant-add! grant-check grant-assert! grant-decisions; do
319 grep -qxF "$sym" "$SCRATCH/exports.txt" || red "export '$sym' is missing from the module's export list"
320done
321say " all 8 public exports present: ok"
322# And nothing NEW is exported: the API is unchanged in both directions,
323# so a consumer cannot start depending on something this migration
324# happened to expose.
325[ "$NEXP" -eq 8 ] || red "the module exports $NEXP symbols, not 8. The public API was to be UNCHANGED; an addition is as much a change as a removal."
326say " exactly 8, nothing added: ok"
328# ------------------------------------------------------------
329# LANDING STATUS. Reported, not asserted: this gate answers "is the
330# migration CORRECT", and correctness is independent of whether the
331# dependency has been released. But the blocker must not be something a
332# reader has to remember, so the gate states it every run and derives it
333# rather than reciting it.
334# ------------------------------------------------------------
335rule
336say "LANDING STATUS"
337FLOOR="$(grep -E '^[[:space:]]*sigil:' "$REPO/package.sgl" | head -1 | sed -E 's/.*sigil:[[:space:]]*"([^"]*)".*/\1/')"
338say " package.sgl declares sigil: \"$FLOOR\""
339MONO="/home/daviwil/Projects/Code/sigil/sigil"
340if [ -d "$MONO/.git" ]; then
341 GRANT_COMMIT="$(git -C "$MONO" log --format=%H -1 -- packages/sigil-stdlib/src/sigil/grant.sgl 2>/dev/null)"
342 if [ -n "$GRANT_COMMIT" ]; then
343 TAGS="$(git -C "$MONO" tag --contains "$GRANT_COMMIT" 2>/dev/null | head -3)"
344 if [ -n "$TAGS" ]; then
345 say " (sigil grant) is RELEASED in: $(echo "$TAGS" | tr '\n' ' ')"
346 # Does the declared floor actually name one of those releases? The
347 # question is not "has it shipped" but "does THIS manifest resolve
348 # it", and those came apart for most of this branch's life.
349 # THE `sigil:` FIELD IS A MINIMUM WITH NO UPPER BOUND, not an exact
350 # pin: e530e14 established that a caret is a range while a bare
351 # version here is a floor. An earlier version of this check
352 # compared floor to tag by STRING EQUALITY and reported "0.21" as
353 # not naming v0.21.0, which is false, since 0.21 means >= 0.21.0.
354 # It failed CLOSED, so it never licensed a bad merge; it would
355 # simply have sent someone to edit a manifest already correct.
356 # AND THE COMPARISON RUNS THE OTHER WAY. `sigil:` is a MINIMUM, so
357 # any runtime at or above it is permitted. To GUARANTEE the module
358 # is present, the floor must be at least the release that
359 # introduced it. Asking "does the floor permit some release that
360 # has it" is the inverted question and answers YES for a floor of
361 # 0.20, which permits 0.20.3, which has no grant.sgl. That is a
362 # false green in the dangerous direction and a control caught it.
363 #
364 # Normalise the floor to three components first: "0.21" and
365 # "0.21.0" are the same version, and a text sort disagrees.
366 EARLIEST="$(echo "$TAGS" | sed 's/^v//' | sort -V | head -1)"
367 NFLOOR="$FLOOR"
368 case "$NFLOOR" in
369 *.*.*) ;;
370 *.*) NFLOOR="$NFLOOR.0" ;;
371 *) NFLOOR="$NFLOOR.0.0" ;;
372 esac
373 SATISFIED=no
374 [ "$(printf '%s\n%s\n' "$NFLOOR" "$EARLIEST" | sort -V | head -1)" = "$EARLIEST" ] && SATISFIED=yes
375 say " earliest release with it: $EARLIEST; declared floor normalises to $NFLOOR"
376 if [ "$SATISFIED" = yes ]; then
377 say " => LANDABLE. The declared floor names a release containing (sigil grant)."
378 else
379 say " => NOT LANDABLE YET. The floor \"$FLOOR\" does not name a release that"
380 say " contains (sigil grant). Set it to one of the releases above."
381 fi
382 else
383 say " (sigil grant) landed in monorepo $(git -C "$MONO" rev-parse --short "$GRANT_COMMIT") and is in NO TAG."
384 say " => NOT LANDABLE YET. Consumers resolve caret ranges against TAGS, so"
385 say " no floor written today can resolve it. This branch is complete"
386 say " EXCEPT the sigil: field, and that field is the only thing gating"
387 say " the merge. Do not SHA-pin to route around it."
388 fi
389 fi
390fi
392rule
393say "Logs kept at: $SCRATCH"
394say "MIGRATION COMPLETE PASS"
395exit 0