AtlatestRepositoryfolio

folio / tree / testgate-f2a.sh

1#!/usr/bin/env bash
2#
3# F2a -- the gate for the passive inspector. THIS IS A TRUST BOUNDARY GATE.
4#
5# READ THIS BEFORE EDITING AN ASSERTION.
6#
7# Every count is DERIVED FROM THE ARTIFACT it describes. Adding a test, a record
8# or a node kind must not require editing a number here; if it does, the check
9# has stopped describing its subject and become a second thing to keep in sync.
11# Every ABSENCE assertion carries a POSITIVE CONTROL. That matters more here
12# than in most gates, because this phase's headline properties are BOTH
13# absences: "no author code ran" and "no forbidden module is reachable". An
14# all-empty result from four broken channels looks exactly like an all-empty
15# result from four working ones, so the controls run in the SAME ARM, on EVERY
16# run, not once when they were written.
18# THE TWO LOAD-BEARING INSTRUMENTS ARE NOT WRITTEN IN SIGIL, AND THAT IS THE
19# POINT. `test/f2a-span-verify.py` opens the manuscript in binary mode and
20# recomputes every offset with Python's own codec; `test/f2a-import-closure.py`
21# walks the module graph from the filesystem. A span checker written against
22# the descriptor, in the descriptor's language, using the descriptor's
23# arithmetic, agrees with itself no matter what the code does -- taxonomy shape
24# 13, instruments with a shared blind spot are one measurement in several hats.
26# Positive controls plant into `$(mktemp -d)` copies, never into the working
27# tree, so there is no restore step that has to be reached.
28set -euo pipefail
30repo_dir="$(cd "$(dirname "$0")/.." && pwd)"
31cd "$repo_dir"
33sigil="${SIGIL_F2A_COMPILER:-$repo_dir/../task-sigil-language-readers/build/dev/bin/sigil}"
34redirects="${SIGIL_F2A_REDIRECTS:-$repo_dir/dev-redirects.sgl}"
35sigil_doc_dir="${SIGIL_F2A_DOC_DIR:-$repo_dir/../sigil-doc}"
36stdlib_dir="${SIGIL_F2A_STDLIB_DIR:-$repo_dir/../sigil/packages/sigil-stdlib/src}"
37cache_dir="${XDG_CACHE_HOME:-$(mktemp -d)}"
38export XDG_CACHE_HOME="$cache_dir"
39# A DIRECT script run has no `--redirects` flag and reads the environment.
40export SIGIL_REDIRECTS="$redirects"
42work=$(mktemp -d)
43fail() { echo "F2A GATE RED: $*" >&2; exit 1; }
45# SHARED HELPER, DEFINED AT TOP LEVEL AND NOT INSIDE AN ARM.
47# It lived inside arm 5 and arm 6 called it. A full run was green because arm 5
48# always executes first and defines it -- an ORDER dependency nothing declared
49# and nothing checked, and the reason arm 6 had never been demonstrated able to
50# go red. Found by the sabotage driver's S4 leg, which requires a NAMED
51# signature: the leg went red with `diag_of: command not found` instead of the
52# assertion it was testing, which is a red for the wrong reason and exactly as
53# uninformative as a green for the wrong reason.
54diag_of() { # $1 fixture, $2 output path -> writes the manifest for it
55 local fx="$1" out="$2"
56 local rev="sha256:$(sha256sum "$fx" | cut -d' ' -f1)"
57 # stderr is kept OUT of the manifest. Merged in, a compiler warning became a
58 # manifest row and died inside the verifier as "unknown manifest row tag" -- a
59 # confusing red, and inconsistent with arm 3, which separates them.
60 F2A_MANIFEST_SOURCE="$fx" F2A_MANIFEST_REVISION="$rev" \
61 "$sigil" -L build/dev/lib -L src -L test test/f2a-span-manifest.sgl > "$out" 2>"$out.err"
64# ARM SELECTOR -- for the sabotage driver only.
66# The arms overlap on purpose: the suite and the manifest assert the duplicate-id
67# contract through different instruments, and both see a byte/scalar defect. That
68# redundancy is a feature in a real run and a problem in a sabotage run, because
69# whichever arm fires FIRST hides whether the arm being tested can fire at all.
70# `F2A_GATE_ONLY_ARM=<n>` runs arm 1 (the build, which every later arm needs) plus
71# arm <n>, so a leg proves the arm it names.
73# IT CANNOT BE USED TO FAKE A GREEN: a selective run prints
74# `F2A_GATE_ARM_<n>_GREEN`, never `F2A_GATE_GREEN`. The full marker is only
75# reachable when no selector is set, and every transcript is graded on that
76# marker.
77only_arm="${F2A_GATE_ONLY_ARM:-}"
78case "$only_arm" in
79 ""|1|2|3|4|5|6|7|8) : ;;
80 *) fail "F2A_GATE_ONLY_ARM=$only_arm is not an arm this gate has" ;;
81esac
82should_run() { [ -z "$only_arm" ] || [ "$only_arm" = "$1" ]; }
83plain() { sed 's/\x1b\[[0-9;]*m//g' "$1"; }
85# EVERY EXTERNAL TOOL MUST EXIST. A missing tool degrades an assertion into a
86# weaker one that cannot fail, silently.
87for tool in sha256sum grep sed awk sort comm; do
88 command -v "$tool" >/dev/null || fail "$tool is required by this gate and is not on PATH"
89done
91# PYTHON IS RESOLVED AT THE HOP, NOT ASSUMED. Measured on 2026-08-11: `python3`
92# was on PATH for the first several runs of this gate and then was NOT, mid
93# session, with no action from this worker -- `which python3` went from a hit to
94# `no python3 in (...)`. The two load-bearing instruments in this gate are
95# Python programs, so a PATH that moves underneath it turns the strongest arms
96# into "could not look". The gate refused loudly rather than skipping them,
97# which is the correct behaviour and also the reason this block exists.
99# Resolution order, and the CHOSEN RUNNER IS PRINTED IN THE HEADER: an
100# interpreter picked by fallback is an input to every result below, and an
101# unrecorded input is how two runs of "the same gate" stop being the same gate.
102if command -v python3 >/dev/null 2>&1; then
103 PY=(python3)
104 py_source="PATH"
105elif command -v guix >/dev/null 2>&1 && guix shell python -- python3 --version >/dev/null 2>&1; then
106 PY=(guix shell python -- python3)
107 py_source="guix shell python"
108else
109 fail "no python3 available, on PATH or via 'guix shell python'; the span verifier and the closure walker cannot run, and a gate that cannot run its own instruments is not a gate"
110fi
111[ -x "$sigil" ] || fail "compiler not found at $sigil"
112for f in src/folio/doc/inspect.sgl test/test-folio-doc-inspect.sgl \
113 test/f2a-span-manifest.sgl test/f2a-span-verify.py \
114 test/f2a-witness-probe.sgl test/f2a-import-closure.py \
115 test/fixtures/f2a-corpus.sgd test/fixtures/f2a-witness.sgd \
116 test/fixtures/f2a-duplicate.sgd test/fixtures/f2a-malformed.sgd \
117 test/fixtures/f2a-no-header.sgd test/fixtures/f2a-bom.sgd \
118 test/fixtures/f2a-shebang.sgd test/fixtures/f2a-reader-oom.sgd \
119 test/fixtures/f2a-argument-edges.sgd; do
120 [ -f "$f" ] || fail "$f is missing"
121done
123echo "=== F2a gate ==============================================="
124echo "repo : $repo_dir @ $(git rev-parse HEAD)"
125echo "compiler : $(plain <($sigil --version 2>&1) | tail -1)"
126# THE SIGIL-DOC SHA IS EVIDENCE, NOT DECORATION. Everything below is produced
127# through a dev redirect to a local sigil-doc WORKING TREE, so its commit is an
128# input to every result here and an uncommitted change there is an unrecorded
129# one. F1a learned this; the difference for F2a is that its evidence DOES route
130# through the parser, because this phase consumes a real parse API rather than
131# constructing nodes programmatically.
132sigil_doc_sha=$(git -C "$sigil_doc_dir" rev-parse HEAD)
133sigil_doc_tag=$(git -C "$sigil_doc_dir" describe --tags --exact-match HEAD 2>/dev/null || echo "(untagged)")
134sigil_doc_dirty=$(git -C "$sigil_doc_dir" status --porcelain | wc -l)
135echo "sigil-doc : $sigil_doc_sha $sigil_doc_tag (via dev-redirects)"
136echo "sigil-doc dirty : $sigil_doc_dirty files"
137echo "python3 : $("${PY[@]}" --version 2>&1 | tail -1) (via $py_source)"
138# EVERY RESOLVED INPUT, NOT JUST THE VERSION STRINGS. All five of these are
139# `${VAR:-default}`, so an exported SIGIL_F2A_COMPILER pointing at a different
140# binary of the same version, or a different stdlib root, produced a transcript
141# indistinguishable from a canonical run while grading different artefacts.
142echo "compiler path : $sigil"
143echo "redirects : $redirects"
144echo "sigil-doc dir : $sigil_doc_dir"
145echo "stdlib root : $stdlib_dir"
146echo "XDG_CACHE_HOME : $XDG_CACHE_HOME"
147echo "============================================================"
148if [ "$sigil_doc_dirty" != "0" ]; then
149 git -C "$sigil_doc_dir" status --porcelain >&2
150 fail "sigil-doc has $sigil_doc_dirty uncommitted file(s); evidence produced against it is not reproducible"
151fi
153# ---------------------------------------------------------------------------
154# ARM 1 -- the product builds. Asserted on the ARTIFACT, because this command
155# is usually piped and a pipeline reports its last stage.
156# ---------------------------------------------------------------------------
157echo "--- arm 1: build"
158build_log="$work/build.log"
159if ! "$sigil" build --redirects "$redirects" > "$build_log" 2>&1; then
160 tail -30 "$build_log" >&2; fail "build exited non-zero"
161fi
162plain "$build_log" | grep -q "Bundle created" || { tail -30 "$build_log" >&2; fail "build produced no bundle"; }
163modules=$(plain "$build_log" | grep -oE '^ +[0-9]+ modules' | grep -oE '[0-9]+' | head -1 || true)
164[ -n "$modules" ] || fail "build log carried no module count"
165echo "arm 1 OK: bundle built, ${modules} modules"
167# ---------------------------------------------------------------------------
168# ARM 2 -- the inspector suite, with its denominator taken from the suite file.
169# A suite that silently stopped running half its cases still prints a green
170# summary; comparing the reported count against the file's own `(test "` forms
171# is what makes green mean "all of them ran".
172# ---------------------------------------------------------------------------
173if should_run 2; then
174 echo "--- arm 2: inspector suite"
175 suite=test/test-folio-doc-inspect.sgl
176 expected_tests=$(grep -cE '^\(test "' "$suite")
177 [ "$expected_tests" -ge 25 ] || fail "suite declares only $expected_tests tests"
178 suite_log="$work/suite.log"
179 if ! "$sigil" -L build/dev/lib test --redirects "$redirects" "$suite" > "$suite_log" 2>&1; then
180 tail -40 "$suite_log" >&2; fail "inspector suite exited non-zero"
181 fi
182 reported=$(plain "$suite_log" | grep -oE 'Tests: +[0-9]+ passed' | grep -oE '[0-9]+' | head -1 || true)
183 [ -n "$reported" ] || { tail -20 "$suite_log" >&2; fail "suite printed no test count"; }
184 [ "$reported" = "$expected_tests" ] || fail "suite defines $expected_tests tests but $reported ran/passed"
185 if plain "$suite_log" | grep -qE '[1-9][0-9]* failed'; then
186 plain "$suite_log" | grep -E '[1-9][0-9]* failed' >&2
187 fail "inspector suite reported failures"
188 fi
189 # POSITIVE CONTROL for that absence: the pattern must match a real failure line.
190 printf 'Tests: 28 passed, 1 failed (29 total)\n' > "$work/failctl"
191 grep -qE '[1-9][0-9]* failed' "$work/failctl" \
192 || fail "positive control: the failure pattern does not match the runner's real failure summary"
193 echo "arm 2 OK: $reported/$expected_tests tests passed, denominator derived from $suite"
194fi
196# ---------------------------------------------------------------------------
197# ARM 3 -- SPANS, VERIFIED AGAINST THE FILE'S BYTES BY A DIFFERENT LANGUAGE.
199# The revision handed to the inspector is computed by `sha256sum` -- coreutils,
200# a completely separate implementation from anything in this repository or in
201# the sigil toolchain. That is deliberate: the revision contract is "sha256
202# over the raw file bytes", it will have at least two implementations (native
203# and WASM), and the only way to keep them honest is to check against a third.
204# ---------------------------------------------------------------------------
205if should_run 3; then
206 echo "--- arm 3: byte-exact spans"
207 corpus=test/fixtures/f2a-corpus.sgd
208 revision="sha256:$(sha256sum "$corpus" | cut -d' ' -f1)"
209 manifest="$work/corpus-manifest.txt"
210 if ! F2A_MANIFEST_SOURCE="$corpus" F2A_MANIFEST_REVISION="$revision" \
211 "$sigil" -L build/dev/lib -L src -L test test/f2a-span-manifest.sgl > "$manifest" 2>"$work/manifest.err"; then
212 tail -20 "$work/manifest.err" >&2; fail "span manifest probe exited non-zero"
213 fi
214 grep -q '^F2A_MANIFEST_DONE ' "$manifest" || fail "manifest has no trailer; it is truncated"
215 # The descriptor must have carried the revision coreutils computed, unchanged.
216 grep -qF "$revision" "$manifest" \
217 || fail "the manifest does not carry the sha256sum-computed revision"
219 verify_log="$work/verify.log"
220 if ! "${PY[@]}" test/f2a-span-verify.py "$corpus" "$manifest" > "$verify_log" 2>&1; then
221 cat "$verify_log" >&2; fail "byte verifier rejected the corpus spans"
222 fi
223 cat "$verify_log"
224 span_line=$(grep -oE 'F2A_SPAN_VERIFY nodes=[0-9]+ fields=[0-9]+ divergent=[0-9]+ divfields=[0-9]+ checks=[0-9]+ rows=[0-9]+ failed=0' "$verify_log") \
225 || { cat "$verify_log" >&2; fail "byte verifier printed no result line"; }
226 # EXACT KEY MATCH, NOT SUBSTRING. `grep -oE 'fields=[0-9]+'` also matches
227 # inside `divfields=43`, which produced a two-line value and a shell error
228 # reading "integer expression expected" -- it failed safe, but the same
229 # collision in a comparison would have compared against the wrong number.
230 field_of() { echo "$1" | tr ' ' '\n' | grep "^$2=" | cut -d= -f2; }
231 span_nodes=$(field_of "$span_line" nodes)
232 span_fields=$(field_of "$span_line" fields)
233 span_divergent=$(field_of "$span_line" divergent)
234 span_checks=$(field_of "$span_line" checks)
235 span_rows=$(field_of "$span_line" rows)
236 # The verifier already refuses when rows_verified != rows parsed. This is the
237 # denominator restated in the GATE transcript, where a later reader will look.
238 [ "$span_rows" = "$((span_nodes + span_fields))" ] \
239 || fail "verifier completed $span_rows rows but the manifest carried $((span_nodes + span_fields))"
240 [ "$span_nodes" -ge 40 ] || fail "byte verifier saw only $span_nodes nodes"
241 [ "$span_fields" -ge 20 ] || fail "byte verifier saw only $span_fields fields"
242 # NON-VACUITY, and it is not optional. Without multi-byte text before the
243 # nodes, byte offset == scalar offset everywhere and every byte-versus-scalar
244 # assertion in the verifier passes for free, however broken the arithmetic is.
245 [ "$span_divergent" -ge 10 ] \
246 || fail "only $span_divergent span(s) have byte != scalar; the corpus cannot tell byte spans from character spans"
248 # THE VERIFIER MUST BE ABLE TO GO RED, PROVEN ON THIS RUN, ON THIS INPUT.
249 if F2A_SPAN_VERIFY_SELFTEST=1 "${PY[@]}" test/f2a-span-verify.py "$corpus" "$manifest" > "$work/verify-self.log" 2>&1; then
250 cat "$work/verify-self.log" >&2
251 fail "byte verifier EXITED 0 with a span deliberately corrupted -- it cannot go red"
252 fi
253 grep -q 'failed=' "$work/verify-self.log" || grep -q 'FAIL:' "$work/verify-self.log" \
254 || fail "byte verifier self-test failed for the wrong reason"
255 echo "arm 3 OK: $span_checks byte-level checks over $span_nodes nodes and $span_fields fields; $span_divergent divergent; verifier proven able to fail"
256fi
258# ---------------------------------------------------------------------------
259# ARM 4 -- THE NO-EXECUTION WITNESS.
261# FOUR CHANNELS, chosen because they FAIL DIFFERENTLY. SD-P4 already ships a
262# witness (gate-sd-p4.sh arm 2) and it is stdout-marker only: by construction it
263# cannot see execution that does not print. A witness inherited from an upstream
264# phase covers the upstream phase's risk surface, not this one's.
266# 1. stdout markers -- blind to silent side effects
267# 2. filesystem sentinels -- blind to nothing that writes, sees the rest
268# 3. an ARGUMENT-POSITION attempt -- the shape this phase adds. Extracting
269# per-field values out of argument syntax is the only way F2a could newly
270# execute author code, and SD-P4's fixture contains no such attempt.
271# 4. the transitive import closure (arm 8) -- static, not runtime at all
273# (a) POSITIVE CONTROL FIRST, EVERY RUN. Until the same manuscript evaluated
274# through the real `#lang` path trips every tripwire, the absence assertions
275# below mean nothing whatsoever.
276# ---------------------------------------------------------------------------
277if should_run 4; then
278 echo "--- arm 4: no-execution witness"
279 witness=test/fixtures/f2a-witness.sgd
280 control_dir="$work/witness-control"
281 passive_dir="$work/witness-passive"
282 mkdir -p "$control_dir" "$passive_dir"
284 control_log="$work/witness-control.log"
285 F2A_WITNESS_DIR="$control_dir" "$sigil" -L build/dev/lib eval -f "$witness" > "$control_log" 2>&1 || true
286 # THE POSITION CLASSES ARE DERIVED FROM THE PARSER'S GRAMMAR, NOT COLLECTED BY
287 # EXHAUSTION. A manuscript offers exactly five positions at which author code
288 # can sit: block boundary, bare-prose run (non-boundary -- SD-P3's fold),
289 # argument group, brace body, and a @code raw body. The fixture now attempts
290 # execution at all five, crossed with the attempt kinds (expression,
291 # author-defined command, macro). That is what makes the set closed BY
292 # CONSTRUCTION rather than "the shapes we thought of", which is the difference
293 # between an answer and a bigger sample. The mid-prose pair was the missing
294 # one; Fable named it, and it was a witness-fixture gap rather than a product
295 # defect because the passive tree is flat and reports those nodes as ordinary
296 # siblings -- but nothing had ever tripped there under evaluation.
297 for marker in F2A-WITNESS-EXPR-RAN F2A-WITNESS-COMMAND-RAN F2A-WITNESS-ARG-RAN \
298 F2A-WITNESS-MACRO-RAN F2A-WITNESS-MIDPROSE-RAN \
299 F2A-WITNESS-MIDPROSE-COMMAND-RAN; do
300 plain "$control_log" | grep -qF "$marker" \
301 || { cat "$control_log" >&2; fail "positive control: evaluating the fixture did not print $marker -- channel 1/3 cannot fire, so its absence proves nothing"; }
302 done
303 for sentinel in expr command argument macro midprose midprose-command; do
304 [ -f "$control_dir/f2a-witness-$sentinel" ] \
305 || { ls -la "$control_dir" >&2; fail "positive control: evaluating the fixture did not write the '$sentinel' sentinel -- channel 2 cannot fire"; }
306 done
308 # (b) THE ASSERTION. Passive inspection, in a fresh process, must trip none of
309 # it -- and must PROVE it reached the dangerous path while not tripping it.
310 passive_log="$work/witness-passive.log"
311 if ! F2A_WITNESS_DIR="$passive_dir" F2A_WITNESS_FIXTURE="$witness" \
312 "$sigil" -L build/dev/lib -L src -L test test/f2a-witness-probe.sgl > "$passive_log" 2>&1; then
313 cat "$passive_log" >&2; fail "witness probe exited non-zero"
314 fi
315 probe_line=$(plain "$passive_log" | grep -oE 'F2A_WITNESS_PROBE_INSPECTED nodes=[0-9]+ commands=[0-9]+ author-expressions=[0-9]+ fields=[0-9]+') \
316 || { cat "$passive_log" >&2; fail "witness probe printed no coverage line"; }
317 for k in nodes commands author-expressions fields; do
318 v=$(echo "$probe_line" | tr ' ' '\n' | grep "^$k=" | cut -d= -f2)
319 [ "$v" -gt 0 ] || fail "witness probe reported $k=0 -- it inspected nothing, so 'no execution observed' is meaningless"
320 done
321 # THE DANGEROUS PATH WAS REACHED. Channel 3 is only a channel if the field whose
322 # value is an expression was actually extracted; a witness that observed no
323 # execution because it never looked is a control too small to fail.
324 arg_field=$(plain "$passive_log" | grep -oE '^F2A_WITNESS_PROBE_ARGUMENT_FIELD head=[a-z-]+ length=-?[0-9]+') \
325 || { cat "$passive_log" >&2; fail "witness probe printed no argument-field line -- channel 3 was never exercised"; }
326 echo "$arg_field" | grep -q 'head=begin' \
327 || fail "channel 3: the argument-position field was not read as the begin-form; got '$arg_field'"
328 arg_len=$(echo "$arg_field" | grep -oE 'length=-?[0-9]+' | grep -oE -- '-?[0-9]+')
329 [ "$arg_len" -ge 3 ] || fail "channel 3: the argument-position datum has length $arg_len; it is not the expression the fixture writes"
330 # NOTE FOR ANYONE TIGHTENING THIS: do not make the probe print the datum ITSELF.
331 # That datum contains the fixture's own stdout marker as a string literal, so
332 # echoing it would put "F2A-WITNESS-ARG-RAN" on the stream the detector below
333 # greps, and this arm would go red on a manuscript nothing executed.
334 # ...and now the absences.
335 if plain "$passive_log" | grep -q 'F2A-WITNESS-'; then
336 cat "$passive_log" >&2; fail "AUTHOR CODE EXECUTED DURING PASSIVE INSPECTION"
337 fi
338 sentinels=$(find "$passive_dir" -type f | wc -l)
339 [ "$sentinels" = "0" ] || { ls -la "$passive_dir" >&2; fail "passive inspection wrote $sentinels sentinel file(s) -- author code ran"; }
340 # A `@code` body must stay inert on the CONTROL path too -- SD-P4's finding,
341 # and the reason it is worth asserting separately: everything else in this arm
342 # requires the control to RUN author code, so this is the one place the control
343 # is checked for restraint rather than for firing.
344 #
345 # The passive side is deliberately NOT re-checked here. The blanket
346 # `grep -q 'F2A-WITNESS-'` above already refuses every marker in the passive
347 # log, including this one, so a second clause could never fire -- an assertion
348 # shadowed by an earlier one in the same arm, which is a class this gate has
349 # been bitten by three times.
350 if plain "$control_log" | grep -q 'F2A-WITNESS-CODE-BODY-RAN'; then
351 fail "a @code body was evaluated on the control path"
352 fi
353 # WHAT THIS ARM ACTUALLY ESTABLISHES, at the resolution it establishes it.
354 # Two DETECTION channels (stdout markers, filesystem sentinels), each shown
355 # able to fire in the same run by the control; plus a COVERAGE assertion that
356 # the argument-position extraction ran and read its expression as data. The
357 # third of those is not a detector -- execution in argument position is
358 # detected by the first two -- and saying "3 channels proven able to fire"
359 # overstated it by one. The static closure arm is arm 8, not this one.
360 echo "arm 4 OK: 2 detection channels proven able to fire in this run, across all 5 grammar-derived positions; inspection tripped neither; argument-position extraction exercised and read as data"
361fi
363# ---------------------------------------------------------------------------
364# ARM 5 -- DIAGNOSTICS, and the reader-pathology regression.
365# ---------------------------------------------------------------------------
366if should_run 5; then
367 echo "--- arm 5: diagnostics"
368 for pair in "f2a-malformed.sgd parse-error" "f2a-no-header.sgd no-language-header"; do
369 set -- $pair
370 fx="test/fixtures/$1"; code="$2"
371 diag_of "$fx" "$work/$1.manifest" || fail "manifest probe failed on $fx"
372 grep -qE "^DIAGNOSTIC error $code " "$work/$1.manifest" \
373 || { cat "$work/$1.manifest" >&2; fail "$fx did not produce an error diagnostic with code $code"; }
374 # A DIAGNOSTIC MUST NAME ITS FILE AND ITS POSITION. `file:?:?:` is what a
375 # positionless one renders as, and it is refused here rather than accepted as
376 # "well, there is a diagnostic".
377 grep -E "^DIAGNOSTIC error $code " "$work/$1.manifest" | grep -qE "$1:[0-9]+:[0-9]+: error: $code: " \
378 || { grep '^DIAGNOSTIC' "$work/$1.manifest" >&2; fail "$fx's diagnostic does not name its file and position"; }
379 done
380 # POSITIVE CONTROL for that rendering pattern: it must NOT match a positionless
381 # diagnostic, or "located" is not what is being asserted.
382 if printf 'x.sgd:?:?: error: parse-error: boom\n' | grep -qE "x.sgd:[0-9]+:[0-9]+: error: parse-error: "; then
383 fail "the located-diagnostic pattern accepts a positionless diagnostic; it is not checking location"
384 fi
385 # Clean documents must produce NO diagnostics -- including the two that exercise
386 # the byte/scalar divergence at the very first node.
387 for fx in f2a-bom.sgd f2a-shebang.sgd f2a-corpus.sgd; do
388 diag_of "test/fixtures/$fx" "$work/$fx.manifest" || fail "manifest probe failed on $fx"
389 grep -q '^PARSED yes' "$work/$fx.manifest" || fail "$fx did not parse"
390 if grep -q '^DIAGNOSTIC' "$work/$fx.manifest"; then
391 grep '^DIAGNOSTIC' "$work/$fx.manifest" >&2; fail "$fx is a clean manuscript but produced diagnostics"
392 fi
393 "${PY[@]}" test/f2a-span-verify.py "test/fixtures/$fx" "$work/$fx.manifest" > "$work/$fx.verify" 2>&1 \
394 || { cat "$work/$fx.verify" >&2; fail "byte verifier rejected $fx's spans"; }
395 done
396 # The BOM file's FIRST node must already diverge -- it is the smallest possible
397 # manuscript that can tell the two coordinate systems apart.
398 # REPORTED, NOT ASSERTED. The verifier itself dies when divergent < 1, so
399 # reaching this line already guarantees >= 1 and an assertion here could not
400 # fail on any input. The number is worth having in the transcript; dressing it
401 # as a check was not.
402 bom_div=$(grep -oE 'divergent=[0-9]+' "$work/f2a-bom.sgd.verify" | grep -oE '[0-9]+')
403 echo " (BOM fixture: $bom_div divergent node span(s) -- byte != scalar at the very first node)"
405 # ---- the reader pathology, t-f6e098 ---------------------------------------
406 # THIS ARM CANNOT SKIP. The fix for `(}` shipped in sigil 0.18.5; below that the
407 # reader still fatal-OOMs, so the arm asserts the state its toolchain is actually
408 # in and says which one that was. A version-gated arm that SKIPPED would report
409 # green for the same reason a missing tool does.
410 #
411 # It runs under a hard address-space cap, because without one this input eats the
412 # host. That cap is precisely the bounded worker the F2 contract amendment
413 # requires -- owned by F2b on the 2026-08-11 ruling, since a pure
414 # source->descriptor function cannot protect itself from a hostile input.
415 oom_fixture=test/fixtures/f2a-reader-oom.sgd
416 compiler_version=$(plain <($sigil --version 2>&1) | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
417 [ -n "$compiler_version" ] || fail "could not read the compiler version"
418 ver_ge() { # $1 >= $2, dotted
419 [ "$(printf '%s\n%s\n' "$2" "$1" | sort -V | head -1)" = "$2" ]
420 }
421 oom_log="$work/reader-oom.log"
422 set +e
423 ( ulimit -v 1500000; F2A_MANIFEST_SOURCE="$oom_fixture" \
424 F2A_MANIFEST_REVISION="sha256:$(sha256sum "$oom_fixture" | cut -d' ' -f1)" \
425 timeout 90 "$sigil" -L build/dev/lib -L src -L test test/f2a-span-manifest.sgl > "$oom_log" 2>&1 )
426 oom_status=$?
427 set -e
428 if ver_ge "$compiler_version" "0.18.5"; then
429 [ "$oom_status" = "0" ] || { tail -20 "$oom_log" >&2; fail "compiler $compiler_version carries the t-f6e098 fix, so the (} shape must yield a clean diagnostic; the probe exited $oom_status"; }
430 grep -qE '^DIAGNOSTIC error parse-error' "$oom_log" \
431 || { tail -20 "$oom_log" >&2; fail "the (} shape did not produce a located parse diagnostic on a fixed toolchain"; }
432 echo "arm 5 reader-pathology: compiler $compiler_version >= 0.18.5, clean diagnostic REQUIRED and observed"
433 else
434 [ "$oom_status" != "0" ] || { tail -20 "$oom_log" >&2; fail "compiler $compiler_version predates the t-f6e098 fix but the (} shape did NOT fail; this arm no longer describes its subject -- re-derive it"; }
435 grep -qiE 'out of memory|Killed' "$oom_log" \
436 || { tail -20 "$oom_log" >&2; fail "the (} shape failed on compiler $compiler_version, but not with the memory signature t-f6e098 describes"; }
437 echo "arm 5 reader-pathology: compiler $compiler_version PREDATES the 0.18.5 fix; the documented OOM is REQUIRED and was observed, contained by the ulimit. THIS IS THE PATHOLOGY, NOT A PASS: it flips to the strict assertion automatically at 0.18.5."
438 fi
439 echo "arm 5 OK: located diagnostics for parse-error and no-language-header; BOM/shebang/corpus clean and byte-verified"
440fi
442# ---------------------------------------------------------------------------
443# ARM 6 -- DUPLICATE IDS, asserted through a SECOND instrument.
445# The suite asserts the contract through the API. This arm asserts it through
446# the manifest, i.e. through the serialized descriptor a consumer actually sees,
447# so a contract that held only inside the process would not survive here.
448# ---------------------------------------------------------------------------
449if should_run 6; then
450 echo "--- arm 6: duplicate ids"
451 dup=test/fixtures/f2a-duplicate.sgd
452 diag_of "$dup" "$work/dup.manifest" || fail "manifest probe failed on $dup"
453 dup_diags=$(grep -cE '^DIAGNOSTIC error duplicate-node-id ' "$work/dup.manifest" || true)
454 # TWO, not one: every occurrence is reported and none is preferred, so the
455 # report itself does not privilege document order.
456 [ "$dup_diags" = "2" ] || { grep '^DIAGNOSTIC' "$work/dup.manifest" >&2; fail "expected 2 duplicate-node-id diagnostics, got $dup_diags"; }
457 dup_nodes=$(grep -cE '^NODE [0-9.]+ command task folio t-7681 ' "$work/dup.manifest" || true)
458 [ "$dup_nodes" = "2" ] || { fail "expected BOTH duplicate nodes in the descriptor, found $dup_nodes"; }
459 # ...and the two diagnostics are at DIFFERENT positions.
460 dup_positions=$(grep -E '^DIAGNOSTIC error duplicate-node-id ' "$work/dup.manifest" \
461 | grep -oE 'f2a-duplicate.sgd:[0-9]+:[0-9]+' | sort -u | wc -l)
462 [ "$dup_positions" = "2" ] || fail "the two duplicate diagnostics report $dup_positions distinct positions, not 2"
463 "${PY[@]}" test/f2a-span-verify.py "$dup" "$work/dup.manifest" > "$work/dup.verify" 2>&1 \
464 || { cat "$work/dup.verify" >&2; fail "byte verifier rejected the duplicate fixture's spans"; }
465 echo "arm 6 OK: both nodes present, 2 located diagnostics at 2 distinct positions, spans byte-verified"
466fi
468# ---------------------------------------------------------------------------
469# ARM 7 -- THE VOCABULARY IS DERIVED ON BOTH SIDES.
471# `(folio doc inspect)` cannot import `(folio doc model)` -- that would drag
472# `(sigil doc model)` into the passive closure, which arm 8 refuses. So the
473# vocabulary is data in both files and NOTHING IN THE PRODUCT COMPARES THEM.
474# This arm is that comparison: record names and their `(id)` fields are derived
475# from `model.sgl`'s own `define-struct` lines, the inspector's list is derived
476# from its own alist, and set equality is required IN BOTH DIRECTIONS.
477# ---------------------------------------------------------------------------
478if should_run 7; then
479 echo "--- arm 7: vocabulary derivation"
480 model=src/folio/doc/model.sgl
481 model_vocab="$work/model-vocab.txt"
482 awk '
483 /^ *\(define-struct [a-z-]+-record/ {
484 line = $0
485 match(line, /define-struct [a-z-]+-record/)
486 name = substr(line, RSTART + 14, RLENGTH - 14 - 7)
487 has_id = (line ~ /\(id\)/) ? "#t" : "#f"
488 print name " " has_id
489 }
490 ' "$model" | sort > "$model_vocab"
491 model_count=$(wc -l < "$model_vocab")
492 # `-gt 0`, not `-ge 11`, for the same reason the inspector-side floor was
493 # loosened: a floor catches a broken derivation, but a floor of 11 ALSO fires
494 # on a legitimate shrink (remove a record from the frozen model and both sides
495 # shrink together, the diff stays clean) and it fires BEFORE the diff, hiding
496 # the assertion this arm exists for.
497 [ "$model_count" -gt 0 ] || fail "derived NO records from $model; the derivation is broken, not the file"
499 inspect_vocab="$work/inspect-vocab.txt"
500 sed '/^[[:space:]]*;/d' src/folio/doc/inspect.sgl \
501 | grep -oE '\([a-z-]+ \. #[tf]\)' \
502 | sed -E 's/^\(([a-z-]+) \. (#[tf])\)$/\1 \2/' | sort > "$inspect_vocab"
503 inspect_count=$(wc -l < "$inspect_vocab")
504 # NOT a floor of 11. A floor here catches a broken derivation but ALSO refuses
505 # a legitimate shrink (remove a record from the frozen model and both sides
506 # shrink together, the diff stays clean, and the floor produces a false red) --
507 # and, measured by sabotage leg S8, it fires BEFORE the diff and hides it. The
508 # derivation must produce something; agreement is the diff's job, not a
509 # count's.
510 [ "$inspect_count" -gt 0 ] || fail "derived NO vocabulary entries from inspect.sgl -- the derivation is broken, not the file"
512 if ! diff -u "$model_vocab" "$inspect_vocab" > "$work/vocab.diff"; then
513 cat "$work/vocab.diff" >&2
514 fail "the inspector's vocabulary and the frozen model's records disagree (name or id-declaration)"
515 fi
516 # POSITIVE CONTROL, BOTH DIRECTIONS: the comparison must report a difference
517 # when one exists, in each direction, or an empty diff means nothing.
518 cp "$model_vocab" "$work/ctl-a"; echo "phantom-record #t" >> "$work/ctl-a"
519 if diff -q "$work/ctl-a" "$inspect_vocab" >/dev/null; then
520 fail "positive control: the vocabulary comparison did not notice an EXTRA model record"
521 fi
522 cp "$inspect_vocab" "$work/ctl-b"; sed -i '1d' "$work/ctl-b"
523 if diff -q "$model_vocab" "$work/ctl-b" >/dev/null; then
524 fail "positive control: the vocabulary comparison did not notice a MISSING inspector entry"
525 fi
526 # ...and the id-declaration half must be load-bearing on its own, not just the
527 # names: flipping one `#t` to `#f` has to be seen.
528 sed 's/^task #t$/task #f/' "$model_vocab" > "$work/ctl-c"
529 if diff -q "$work/ctl-c" "$inspect_vocab" >/dev/null; then
530 fail "positive control: the comparison ignores the id-declaration column"
531 fi
532 echo "arm 7 OK: $model_count records, both sides derived, set equality including id-declaration; comparison proven to see 3 kinds of drift"
533fi
535# ---------------------------------------------------------------------------
536# ARM 8 -- PASSIVITY, STATICALLY: direct allowlist AND transitive closure.
538# The direct check is F1a's arm 6 shape and it stays. The TRANSITIVE walk is
539# what F1a's review left open as informational "for F2/P0" -- and it is what
540# found the two edges that made this module reach a network stack: `(sigil
541# language)` and `(sigil error)`, both entirely innocent-looking in an import
542# list.
543# ---------------------------------------------------------------------------
544if should_run 8; then
545 echo "--- arm 8: passivity (direct allowlist + transitive closure)"
546 extract_imports() {
547 sed '/^[[:space:]]*;/d' "$1" | awk '
548 /\(import/ { collecting = 1; buf = "" }
549 collecting {
550 buf = buf " " $0
551 n = gsub(/\(/, "(", $0); m = gsub(/\)/, ")", $0)
552 depth += n - m
553 if (depth <= 0) { print buf; collecting = 0; depth = 0 }
554 }
555 ' | grep -oE '\([a-z][a-z0-9-]*( [a-z0-9-]+)+\)' | sort -u
556 }
557 allowed='^\(sigil (core|struct|syntax|math|string|list)\)$|^\(sigil doc inspect\)$'
558 imports=$(extract_imports src/folio/doc/inspect.sgl)
559 [ -n "$imports" ] || fail "no import form extracted from inspect.sgl -- the extractor is broken, not the file"
560 while IFS= read -r lib; do
561 echo "$lib" | grep -qE "$allowed" || fail "inspect.sgl imports $lib, outside the passive allowlist"
562 done <<< "$imports"
563 # POSITIVE CONTROLS on the allowlist itself.
564 for forbidden_lib in '(sigil process)' '(sigil shell)' '(sigil fs)' '(sigil io)' '(sigil error)' \
565 '(sigil language)' '(sigil crypto)' '(sigil doc model)' '(folio doc model)'; do
566 echo "$forbidden_lib" | grep -qE "$allowed" \
567 && fail "positive control: the allowlist ACCEPTS $forbidden_lib -- it is not restricting anything"
568 done
569 for permitted_lib in '(sigil core)' '(sigil doc inspect)' '(sigil syntax)'; do
570 echo "$permitted_lib" | grep -qE "$allowed" \
571 || fail "positive control: the allowlist REFUSES $permitted_lib, which the core legitimately needs"
572 done
573 # ...and the extractor must SEE a planted import, or the allowlist walk is over
574 # an empty list.
575 ctl_dir=$(mktemp -d)
576 sed 's/(import (sigil core) (sigil struct) (sigil math) (sigil string) (sigil list)/(import (sigil process) (sigil core) (sigil struct) (sigil math) (sigil string) (sigil list)/' \
577 src/folio/doc/inspect.sgl > "$ctl_dir/planted.sgl"
578 extract_imports "$ctl_dir/planted.sgl" | grep -q '(sigil process)' \
579 || fail "positive control: the import extractor did not see a planted (sigil process)"
581 closure_log="$work/closure.log"
582 if ! "${PY[@]}" test/f2a-import-closure.py '(folio doc inspect)' \
583 src "$sigil_doc_dir/src" "$stdlib_dir" > "$closure_log" 2>&1; then
584 cat "$closure_log" >&2; fail "transitive import closure is not clean"
585 fi
586 closure_line=$(grep -oE 'F2A_IMPORT_CLOSURE modules=[0-9]+ direct=[0-9]+ forbidden=0 leaves=[0-9]+' "$closure_log") \
587 || { cat "$closure_log" >&2; fail "closure walker printed no result line"; }
588 closure_modules=$(echo "$closure_line" | tr ' ' '\n' | grep '^modules=' | cut -d= -f2)
589 # The walker itself now refuses the I/O modules (they are in its FORBIDDEN
590 # dict), so this grep is a SECOND OPINION over its printed closure rather than
591 # the only thing standing between the passive core and a network stack. It
592 # depends on the walker's output format, so it gets the positive control the
593 # gate's own header demands of every absence assertion.
594 if grep -qE '^ \(sigil (fs|io|process|shell|socket)\)$' "$closure_log"; then
595 cat "$closure_log" >&2; fail "an I/O module is in the passive core's transitive closure"
596 fi
597 printf ' (sigil io)\n' > "$work/io-control"
598 grep -qE '^ \(sigil (fs|io|process|shell|socket)\)$' "$work/io-control" \
599 || fail "positive control: the I/O pattern does not match the format the walker prints; it has been silently blind"
601 # POSITIVE CONTROL FOR THE WALK ITSELF: a forbidden module TWO levels down.
602 #
603 # The previous control planted a root whose own direct import was `(folio doc
604 # model)` -- itself in FORBIDDEN -- so a walker that examined nothing but the
605 # root's import list would have passed it. It also relied on an unverified
606 # `sed -i` into model.sgl, the exact hazard the sabotage driver built
607 # `patch_file` to refuse. Now the root imports an ordinary, permitted module,
608 # and THAT module imports `(sigil process)`; the assertion names the specific
609 # violation rather than grepping for the word FORBIDDEN.
610 plant_dir=$(mktemp -d)
611 mkdir -p "$plant_dir/src/folio/doc"
612 cat > "$plant_dir/src/folio/doc/inspect-planted.sgl" <<'PLANT'
613(define-library (folio doc inspect-planted)
614 (import (folio doc planted-middle))
615 (export nothing)
616 (begin (define nothing #f)))
617PLANT
618 cat > "$plant_dir/src/folio/doc/planted-middle.sgl" <<'MID'
619(define-library (folio doc planted-middle)
620 (import (sigil core) (sigil process))
621 (export nothing-middle)
622 (begin (define nothing-middle #f)))
623MID
624 if "${PY[@]}" test/f2a-import-closure.py '(folio doc inspect-planted)' \
625 "$plant_dir/src" src "$sigil_doc_dir/src" "$stdlib_dir" > "$work/closure-ctl.log" 2>&1; then
626 cat "$work/closure-ctl.log" >&2
627 fail "positive control: the closure walker did NOT report a forbidden module reached TWO levels down"
628 fi
629 grep -q 'FORBIDDEN: (sigil process)' "$work/closure-ctl.log" \
630 || { cat "$work/closure-ctl.log" >&2; fail "positive control: the walker failed, but not by naming the planted (sigil process)"; }
631 # ...and it must have got there through the intermediary, i.e. actually recursed.
632 grep -q 'planted-middle' "$work/closure-ctl.log" \
633 || { cat "$work/closure-ctl.log" >&2; fail "positive control: the walker did not report the path it took; it may not have recursed"; }
634 rm -rf "$plant_dir" "$ctl_dir"
635 echo "arm 8 OK: direct imports inside the allowlist; transitive closure is $closure_modules modules with no I/O and no evaluation-side module; walker proven to see a violation TWO levels down, through a permitted intermediary"
636fi
638echo
639if [ -n "$only_arm" ]; then
640 echo "F2A_GATE_ARM_${only_arm}_GREEN (SELECTIVE RUN -- this is NOT a full gate and must never be quoted as one)"
641 exit 0
642fi
643echo "F2A_GATE_GREEN arms=8 tests=$reported span_checks=$span_checks span_nodes=$span_nodes span_fields=$span_fields divergent=$span_divergent records=$model_count closure=$closure_modules sigil_doc=$sigil_doc_sha"