AtlatestRepositoryfolio

folio / tree / testsabotage-f1a.sh

1#!/usr/bin/env bash
2#
3# F1a sabotage -- proof that the gate can go RED, one named signature per leg.
4#
5# A gate reported only green has not been shown to work; it has been shown to be
6# quiet. Each leg below plants a REAL DEFECT IN THE PRODUCT (not in an
7# assertion, except the one leg that says so) and requires a SPECIFIC failure
8# signature, not merely a non-zero exit. Exit codes lie: 126 is a script that is
9# not executable, 127 is a binary that is not there, and both look like a
10# convincing red while running no assertions at all.
12# RESTORE IS UNCONDITIONAL, NOT REACHED. Originals are copied aside up front and
13# put back from an `EXIT INT TERM` trap, then verified BY SHA256 rather than by
14# the exit status of `cp`. A driver whose undo is a step that must be reached
15# leaves the tree corrupted the moment `set -e` fires between damage and repair.
16# `git checkout -- <path>` is deliberately not used: it restores from the last
17# COMMIT, so on an uncommitted tree it silently discards real work along with
18# the sabotage, and exits 0 either way.
19set -uo pipefail
21repo_dir="$(cd "$(dirname "$0")/.." && pwd)"
22cd "$repo_dir"
24sigil="${SIGIL_F1A_COMPILER:-$repo_dir/../task-sigil-language-readers/build/dev/bin/sigil}"
25redirects="${SIGIL_F1A_REDIRECTS:-$repo_dir/dev-redirects.sgl}"
26export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$(mktemp -d)}"
27# A DIRECT script run has no `--redirects` flag and reads the environment.
28# Without this, S5's probe dies with "Missing dependencies" -- which IS a
29# non-zero exit, and a driver that accepted "exited non-zero" as proof of a red
30# would have scored that leg as a PASS while the probe never ran a single check.
31# That is precisely why every leg below demands a NAMED SIGNATURE as well.
32export SIGIL_REDIRECTS="$redirects"
34targets=(
35 src/folio/doc/model.sgl
36 src/folio/doc/publication.sgl
37 src/folio/doc/traversal.sgl
38 src/folio/doc/render/plain.sgl
39 src/folio/doc/render/debug.sgl
40 test/test-folio-doc-model.sgl
42backup_dir=$(mktemp -d)
43declare -A original_hash
44for t in "${targets[@]}"; do
45 mkdir -p "$backup_dir/$(dirname "$t")"
46 cp "$t" "$backup_dir/$t"
47 original_hash["$t"]=$(sha256sum "$t" | cut -d' ' -f1)
48done
50restore_all() {
51 local bad=0
52 for t in "${targets[@]}"; do
53 cp "$backup_dir/$t" "$t"
54 local now
55 now=$(sha256sum "$t" | cut -d' ' -f1)
56 if [ "$now" != "${original_hash[$t]}" ]; then
57 echo "RESTORE FAILED for $t (${original_hash[$t]} != $now)" >&2
58 bad=1
59 fi
60 done
61 [ "$bad" = "0" ] || { echo "TREE MAY BE DIRTY -- CHECK BEFORE COMMITTING" >&2; exit 9; }
63trap restore_all EXIT INT TERM
65# changed <target> -- a leg must PROVE it changed the file it claims to have
66# sabotaged. A `sed` that matches nothing exits 0, and a leg that quietly
67# edited nothing is indistinguishable from a gate that failed to notice a real
68# defect. Legs that PLANT a new string still grep for it; this is for the ones
69# that REMOVE one, where there is no new text to look for.
70changed() {
71 local t="$1" now
72 now=$(sha256sum "$t" | cut -d' ' -f1)
73 test "$now" != "${original_hash[$t]}" || {
74 echo "SABOTAGE DID NOT CHANGE $t" >&2; exit 1; }
77legs_run=0
78legs_red=0
80# $1 human name, $2 signature that must appear, $3 command
81expect_red() {
82 local name="$1" signature="$2" cmd="$3"
83 legs_run=$((legs_run + 1))
84 local log; log=$(mktemp)
85 # The surrounding script runs WITHOUT errexit on purpose (a leg going red must
86 # not end the run). An earlier version restored it with a bare `set -e` here,
87 # which silently switched errexit ON for everything after the first leg.
88 eval "$cmd" > "$log" 2>&1
89 local status=$?
90 if [ "$status" = "0" ]; then
91 echo " [$name] DID NOT GO RED - the gate does not catch this" >&2
92 tail -15 "$log" >&2
93 return 1
94 fi
95 # The runner colours its output, so strip escapes before matching a signature
96 # -- the gate already learned this the hard way on its summary line.
97 local clean="$log.clean"
98 sed 's/\x1b\[[0-9;]*m//g' "$log" > "$clean"
99 if ! signature_in_failure "$clean" "$signature"; then
100 echo " [$name] went red, but NOT for the expected reason." >&2
101 echo " expected signature: $signature" >&2
102 grep -nE '^[[:space:]]*✗ ' "$clean" >&2 || tail -20 "$clean" >&2
103 return 1
104 fi
105 legs_red=$((legs_red + 1))
106 echo " [$name] RED as required, signature matched: $signature"
107 return 0
111# signature_in_failure <ansi-stripped-log> <signature>
113# THE ATTRIBUTION CHECK ABOVE COULD NOT FIRE FOR ANY SUITE LEG UNTIL 2026-08-22.
114# `sigil test` ECHOES EVERY TEST'S NAME, passing ones included, so matching an
115# arm's name anywhere in the log succeeds on the line reporting that arm PASSING.
116# Measured against a 36/36 GREEN suite log: all eight suite-arm signatures this
117# driver uses appear in it, each on a `✓` line. So "went red, but NOT for the
118# expected reason" was unreachable -- a red from ANY arm scored as a red at the
119# expected one, and every leg's attribution was decorative. This is the same
120# defect `test/sabotage-sd-p3.sh` in sigil-doc names in its own header.
122# The discriminator is deliberately generous: anything that looks like a runner
123# log at all gets the strict `✗` anchor. Being wrong in that direction costs a
124# loud failure; being wrong the other way restores the vacuous check.
125signature_in_failure() {
126 local clean="$1" signature="$2"
127 if grep -qE '^[[:space:]]*[✓✗] ' "$clean" \
128 || grep -qE 'Tests:[[:space:]]+[0-9]+ passed' "$clean"; then
129 grep -E '^[[:space:]]*✗ ' "$clean" | grep -qF -- "$signature"
130 else
131 # Not a runner log: the publication probe or the gate itself, whose
132 # signatures are strings ONLY the failing path prints -- ` FAIL: <label>`
133 # (the probe emits nothing at all on the passing branch) and
134 # `F1A GATE RED: <reason>`. Verified: a green probe log contains zero
135 # occurrences of S5's signature.
136 grep -qF -- "$signature" "$clean"
137 fi
140# CONTROL FOR THE ANCHOR. It replaced a check that could not fire, so it does not
141# get to be trusted on its shape. Synthetic logs, no compiler, three directions.
142_anchor_ctl=$(mktemp -d)
143printf ' ✓ an arm named exactly like the signature <1ms\nTests: 1 passed (1 total)\n' \
144 >"$_anchor_ctl/green"
145printf ' ✗ an arm named exactly like the signature\nTests: 0 passed, 1 failed (1 total)\n' \
146 >"$_anchor_ctl/red"
147printf ' FAIL: a probe label\n' >"$_anchor_ctl/probe"
148if signature_in_failure "$_anchor_ctl/green" "an arm named exactly like the signature"; then
149 echo "ANCHOR CONTROL FAILED: a signature on a PASSING runner line was accepted" >&2; exit 1; fi
150if ! signature_in_failure "$_anchor_ctl/red" "an arm named exactly like the signature"; then
151 echo "ANCHOR CONTROL FAILED: a signature on a FAILING runner line was rejected" >&2; exit 1; fi
152if ! signature_in_failure "$_anchor_ctl/probe" "FAIL: a probe label"; then
153 echo "ANCHOR CONTROL FAILED: a probe's failure-only string was rejected" >&2; exit 1; fi
154rm -rf "$_anchor_ctl"
155echo "attribution anchor armed: refused on a ✓ line, accepted on a ✗ line, probe strings still matched"
157run_suite() { "$sigil" -L build/dev/lib test --redirects "$redirects" test/test-folio-doc-model.sgl; }
158run_probe() { "$sigil" -L build/dev/lib -L src -L test test/f1a-publication-probe.sgl; }
160echo "=== F1a sabotage: $(git rev-parse HEAD) ==="
162# --- S1 PRODUCT: defensive ownership removed from one accessor --------------
163echo "S1: note-children stops copying its list spine"
164sed -i 's/(define (note-children x) (copy-list (note-record-children x)))/(define (note-children x) (note-record-children x))/' \
165 src/folio/doc/model.sgl
166grep -q '(define (note-children x) (note-record-children x))' src/folio/doc/model.sgl \
167 || { echo "S1 patch did not apply" >&2; exit 1; }
168expect_red "S1 ownership" "aggregate ownership is mutation-proof" "run_suite" || true
169restore_all
171# --- S2 PRODUCT: `next` re-enters the state vocabulary ----------------------
172echo "S2: 'next' is admitted as a task status"
173sed -i "s/(define task-statuses '(inbox ready active blocked done cancelled))/(define task-statuses '(inbox ready next active blocked done cancelled))/" \
174 src/folio/doc/model.sgl
175grep -q "inbox ready next active" src/folio/doc/model.sgl || { echo "S2 patch did not apply" >&2; exit 1; }
176expect_red "S2 D6 ready-not-next" "ready is a status, never a priority" "run_suite" || true
177restore_all
179# --- S3 PRODUCT: the flat node-id grammar is loosened to admit `/` ----------
180# This is the exact thing D12 says never to do.
181echo "S3: valid-node-id? starts accepting a slash"
182sed -i 's|(or (and (char>=? c #\\a) (char<=? c #\\z))|(or (char=? c #\\/) (and (char>=? c #\\a) (char<=? c #\\z))|' \
183 src/folio/doc/model.sgl
184grep -q 'char=? c #\\/' src/folio/doc/model.sgl || { echo "S3 patch did not apply" >&2; exit 1; }
185expect_red "S3 D12 two grammars" "node ids stay flat and never admit a slash" "run_suite" || true
186restore_all
188# --- S4 PRODUCT: phase order uniqueness stops being enforced ----------------
189echo "S4: phase order no longer has to increase"
190sed -i 's/(if (and previous (<= order previous))/(if (and previous #f (<= order previous))/' \
191 src/folio/doc/model.sgl
192grep -q 'and previous #f' src/folio/doc/model.sgl || { echo "S4 patch did not apply" >&2; exit 1; }
193expect_red "S4 ordered container" "phase order must be positive, unique and strictly increasing" "run_suite" || true
194restore_all
196# --- S5 PRODUCT: the unresolved query marker silently disappears ------------
197# The whole failure this requirement exists to prevent: rendering still works,
198# still validates, and simply says less than the source did.
199echo "S5: query lowering emits ordinary text instead of an unresolved marker"
200sed -i 's|((query? n) (para (unresolved-query-text n) (query-location n)))|((query? n) (para "results" (query-location n)))|' \
201 src/folio/doc/publication.sgl
202grep -q '(para "results"' src/folio/doc/publication.sgl || { echo "S5 patch did not apply" >&2; exit 1; }
203expect_red "S5 publication fallback" "FAIL: plain marks the unresolved query" "run_probe" || true
204restore_all
206# --- S6 PRODUCT: backend vocabulary in the semantic core --------------------
207# S6 RUNS THE REAL GATE. It previously re-implemented arm 5's grep inline and
208# printed its own "signature matched" string, which meant deleting arm 5 from
209# gate-f1a.sh entirely would still have shown this leg RED. It proved that grep
210# can find a word that was just appended to a file -- a check validating itself.
211# Running the gate is slower; a leg that cannot fail is worth nothing at any
212# speed. The signature is now arm 5's own refusal message, which only
213# gate-f1a.sh can emit.
214echo "S6: SQLite vocabulary planted in the semantic core"
215echo ';; index-backed by sqlite for speed' >> src/folio/doc/model.sgl
216grep -q 'sqlite for speed' src/folio/doc/model.sgl || { echo "S6 patch did not apply" >&2; exit 1; }
217expect_red "S6 leakage" "backend/runtime vocabulary leaked into the semantic core" \
218 "bash test/gate-f1a.sh" || true
219restore_all
221# --- S7 ASSERTION LAYER: the canonical datum literal is wrong ---------------
222# Deliberately the ONLY assertion-layer leg. Assertion sabotage proves the
223# harness notices when the harness changes; it says nothing about whether the
224# suite is executing the real product. The NINE product-layer legs in this driver
225# are what establish that, which is why they outnumber this one nine to one.
226echo "S7: the expected canonical datum is altered (assertion layer)"
227sed -i 's/(status ready) (priority low) (tags) (due #f) (children) source)/(status ready) (priority high) (tags) (due #f) (children) source)/' \
228 test/test-folio-doc-model.sgl
229grep -q '(priority high) (tags) (due #f)' test/test-folio-doc-model.sgl \
230 || { echo "S7 patch did not apply" >&2; exit 1; }
231expect_red "S7 canonical datum" "canonical datum of the note root is exact" "run_suite" || true
232restore_all
234# --- S8 PRODUCT: `update-id` becomes a constant #f in the TRAVERSAL ----------
235# THE LEG THIS TICKET EXISTS FOR. Until 2026-08-22 `traversal.sgl` had no
236# sabotage leg in ANY driver in this family, while `gate-f1a.sh` arm 8 READS it
237# -- slicing each record's datum branch out of it to check field totality. An
238# input the gate consumes and cannot notice is wrong is the worst combination
239# there is, and it is not hypothetical: review planted exactly this defect and
240# got 35/35 GREEN from a broken product. The corpus's only update carried #f
241# anyway, and no assertion pinned the id BY VALUE in the rendered views.
243# The suite was fixed at the time (see the "a declared update id reaches the
244# datum, the plain text and the lowering" arm, and the `u-f0review` id the
245# pressure manuscript now carries). NOTHING KEPT IT FIXED. This leg does.
247# Both call sites go, not one: `folio-events` and `folio->datum` each read the
248# id, and replacing only one leaves the other instrument still telling the
249# truth -- which is how a two-instrument design hides a single-instrument break.
250echo "S8: update-id is replaced by a constant #f throughout the traversal"
251sed -i 's/(update-id n)/#f/g' src/folio/doc/traversal.sgl
252test "$(grep -c '(update-id n)' src/folio/doc/traversal.sgl || true)" = "0" \
253 || { echo "S8 patch did not apply" >&2; exit 1; }
254changed src/folio/doc/traversal.sgl
255expect_red "S8 traversal update identity" \
256 "a declared update id reaches the datum, the plain text and the lowering" "run_suite" || true
257restore_all
259# --- S9 PRODUCT: statuses stop being visible words in the plain rendering ----
260# `render/plain.sgl`'s own header said, in the tree, that no sabotage leg
261# targeted it and that the gap was filed rather than papered over. This is that
262# leg. The rule the renderer is written to is SEMANTIC FACTS BECOME VISIBLE
263# WORDS, and the failure it guards is silent: a rendering that dropped statuses
264# is still valid output that simply says less than the source did.
266# The BINDING stays and only the BEHAVIOUR goes, so this is a behaviour test
267# rather than a compile error -- a leg that fails to compile proves nothing
268# about what the suite can see.
269echo "S9: render/plain's status-of stops producing a status word"
270sed -i 's/^\( *\)(define (status-of symbol).*$/\1(define (status-of symbol) (begin symbol ""))/' \
271 src/folio/doc/render/plain.sgl
272grep -q '(define (status-of symbol) (begin symbol ""))' src/folio/doc/render/plain.sgl \
273 || { echo "S9 patch did not apply" >&2; exit 1; }
274changed src/folio/doc/render/plain.sgl
275expect_red "S9 plain statuses" \
276 "every work item status survives folio rendering and the lowering" "run_suite" || true
277restore_all
279# --- S10 PRODUCT: the debug view becomes a second serialization -------------
280# `render/debug.sgl` is eleven lines and is deliberately an ALIAS for
281# `folio->string`. That makes it look like a file too small to be worth a leg,
282# which is precisely the argument that leaves a file uncovered: the defect it
283# guards against is somebody "improving" the debug view into a second
284# formatter, and a second serialization nobody gates is the output people read
285# while reasoning about the canonical one.
286echo "S10: folio->debug stops being folio->string"
287sed -i 's/(folio->string root)/(begin root "")/' src/folio/doc/render/debug.sgl
288grep -q '(begin root "")' src/folio/doc/render/debug.sgl \
289 || { echo "S10 patch did not apply" >&2; exit 1; }
290changed src/folio/doc/render/debug.sgl
291expect_red "S10 debug divergence" \
292 "debug rendering is the canonical string, not a second serialization" "run_suite" || true
293restore_all
296echo
297echo "F1A_SABOTAGE legs=$legs_run red=$legs_red"
298if [ "$legs_run" != "$legs_red" ]; then
299 echo "SABOTAGE INCOMPLETE: $((legs_run - legs_red)) leg(s) failed to go red" >&2
300 exit 1
301fi
302echo "F1A_SABOTAGE_COMPLETE all legs red"