AtlatestRepositoryfolio

folio / tree / testgate-f1b.sh

1#!/usr/bin/env bash
2#
3# F1b -- the gate for `(folio doc reader)`, the `#lang folio/doc` profile.
4#
5# READ THIS BEFORE EDITING AN ASSERTION.
6#
7# Every count is DERIVED FROM THE ARTIFACT it describes, never written in as a
8# literal. Adding a test, a witness or a node kind must not require editing a
9# number here; if it does, the check has stopped describing its subject and has
10# become a second thing to keep in sync.
12# Every ABSENCE assertion carries a POSITIVE CONTROL in the SAME ARM, on EVERY
13# run. That matters more here than in most gates because two of this phase's
14# headline properties are absences -- "no tripwire fired" and "no core kind is
15# re-enumerated" -- and an all-empty result from a broken channel looks exactly
16# like an all-empty result from a working one.
18# Positive controls plant into `$(mktemp -d)` copies and NEVER into the working
19# tree, so there is no restore step that has to be reached.
21# THE ONE LITERAL THIS GATE DOES CARRY, and why. Arm 3 compares the profile's
22# declared refused witnesses against a written-out four-line expectation. That
23# is deliberate: it is the EXACT-MEMBERSHIP obligation from Fable's SD-P7 review,
24# whose entire purpose is that silence about structural containers is impossible.
25# A derived expectation would be satisfied by a profile that declared nothing,
26# which is the failure the obligation exists to prevent. It is a specification,
27# not a cached measurement.
28set -euo pipefail
30repo_dir="$(cd "$(dirname "$0")/.." && pwd)"
31cd "$repo_dir"
33# The INSTALLED compiler, not the dev build the older gates default to. Sigil
34# 0.18.6 shipped and the LICM wrong-code bug that forced `optimize: 0` on
35# SD-P7/F1a/F2a is fixed in it. Building at optimize 0 anyway, for parity with
36# the phases this compares against -- folio's `dev` config is already optimize 0.
37sigil="${SIGIL_F1B_COMPILER:-sigil}"
38redirects="${SIGIL_F1B_REDIRECTS:-$repo_dir/dev-redirects.sgl}"
39sigil_doc_dir="${SIGIL_F1B_DOC_DIR:-$repo_dir/../sigil-doc}"
40cache_dir="${XDG_CACHE_HOME:-$(mktemp -d)}"
41export XDG_CACHE_HOME="$cache_dir"
42# A DIRECT script run has no `--redirects` flag and reads the environment.
43export SIGIL_REDIRECTS="$redirects"
45work=$(mktemp -d)
46control_dir=$(mktemp -d)
47fail() { echo "F1B GATE RED: $*" >&2; exit 1; }
49# ARM SELECTOR -- for the sabotage driver only. Arms overlap on purpose (the
50# suite and the probes assert several properties through different instruments),
51# and that redundancy is a feature in a real run and a problem in a sabotage run,
52# because whichever arm fires FIRST hides whether the arm being tested can fire
53# at all. `F1B_GATE_ONLY_ARM=<n>` runs arm 1 (the build, which every later arm
54# needs) plus arm <n>.
56# IT CANNOT BE USED TO FAKE A GREEN: a selective run prints
57# `F1B_GATE_ARM_<n>_GREEN` and never `F1B_GATE_GREEN`. Every transcript is
58# graded on the full marker.
59only_arm="${F1B_GATE_ONLY_ARM:-}"
60case "$only_arm" in
61 ""|1|2|3|4|5|6|7|8|9|10|11) : ;;
62 *) fail "F1B_GATE_ONLY_ARM=$only_arm is not an arm this gate has" ;;
63esac
64should_run() { [ -z "$only_arm" ] || [ "$only_arm" = "$1" ]; }
66# THE COMPILER AND TEST RUNNER COLOUR THEIR OUTPUT, AND THE ESCAPES SIT BETWEEN
67# THE FIELDS A GATE WANTS TO READ. `Tests: ^[[32m30 passed^[[39m` defeats a
68# pattern as innocent as `Tests: +[0-9]+ passed`. Everything parsed here goes
69# through `plain` first.
70plain() { sed 's/\x1b\[[0-9;]*m//g' "$1"; }
72# WHEN A PROBE FAILS, PRINT WHAT IT SAID BEFORE PRINTING WHERE IT DIED.
74# Every probe here prints ` FAIL: <the assertion>` for each failed check and
75# then raises, so a bare `tail -30` shows the tail of a STACK TRACE and drops the
76# assertions entirely. Sabotage leg S8 landed exactly there: arm 5 went red
77# correctly, and the transcript recorded thirty lines of frame addresses and not
78# one word about which property had broken.
80# A diagnostic that omits the finding makes a real red unreadable, and an
81# unreadable red is the next thing to get "fixed" by loosening the arm.
82dump_probe_failure() { # $1 log
83 echo "--- probe assertions that failed:" >&2
84 plain "$1" | grep -F "FAIL:" >&2 || echo " (none printed -- the probe raised before reporting)" >&2
85 echo "--- tail of the probe log:" >&2
86 tail -20 "$1" >&2
89# EVERY EXTERNAL TOOL MUST EXIST. A missing tool degrades an assertion into a
90# weaker one that cannot fail, silently.
91for tool in grep sed awk sort comm cmp wc head tail; do
92 command -v "$tool" >/dev/null || fail "$tool is required by this gate and is not on PATH"
93done
95for f in src/folio/doc/reader.sgl src/folio/doc/author.sgl src/folio/doc/profile.sgl \
96 src/folio/doc/publication.sgl \
97 test/test-folio-doc-reader.sgl test/f1b-profile-probe.sgl \
98 test/f1b-parity-probe.sgl test/f1b-publication-probe.sgl \
99 test/f1b-witness-load.sgl \
100 test/fixtures/f1b-note.sgd test/fixtures/f1b-parity-sigil.sgd \
101 test/fixtures/f1b-parity-folio.sgd test/fixtures/f1b-parity-shifted.sgd \
102 test/fixtures/f1b-witness-folio.sgd test/fixtures/f1b-witness-sigil.sgd \
103 test/fixtures/f1b-witness-argument.sgd \
104 test/fixtures/f1b-laundering.sgd test/fixtures/f1b-duplicate.sgd \
105 test/fixtures/f1b-unique.sgd test/fixtures/f1b-nested-containers.sgd \
106 test/fixtures/f1b-repeated-section.sgd test/fixtures/f1b-cross-namespace.sgd; do
107 [ -f "$f" ] || fail "$f is missing"
108done
109[ -f "$sigil_doc_dir/src/sigil/doc/model.sgl" ] || fail "sigil-doc not found at $sigil_doc_dir"
111echo "=== F1b gate ==============================================="
112echo "repo : $repo_dir @ $(git rev-parse HEAD)"
113echo "compiler : $($sigil --version 2>&1 | tail -1)"
114echo "sigil-doc : $(git -C "$sigil_doc_dir" rev-parse HEAD) (via dev-redirects, NOT a release)"
115sigil_doc_dirty=$(git -C "$sigil_doc_dir" status --porcelain | wc -l)
116echo "sigil-doc dirty : $sigil_doc_dirty files"
117echo "cache : $cache_dir"
118echo "============================================================"
119# Printing the SHA is not the same as depending on it. Every green here is
120# produced through a redirect to a local sigil-doc WORKING TREE, so an
121# uncommitted change there is an unrecorded input to the whole gate. Refuse
122# rather than produce evidence nobody can reproduce.
123if [ "$sigil_doc_dirty" != "0" ]; then
124 git -C "$sigil_doc_dir" status --porcelain >&2
125 fail "sigil-doc has $sigil_doc_dirty uncommitted file(s); evidence produced against it is not reproducible"
126fi
127# WHICH sigil-doc THIS EVIDENCE IS AGAINST -- the CONDITION, checked, not a
128# recorded SHA that goes stale in prose.
130# The first version of this block FAILED if sigil-doc HEAD was a tag, encoding
131# the brief's statement that no released sigil-doc contained SD-P7. That fired
132# on its first run -- correctly, because the statement had EXPIRED: sigil-doc
133# v0.1.3 was tagged mid-phase and contains SD-P7 (`7795111` is an ancestor of
134# `f83d8d8`, and the only delta is `docs/v0.1.3-tag-message.md`, +11/-2, with
135# ZERO files changed under src/ or test/ -- measured, so evidence taken against
136# the earlier SHA holds).
138# Being a release is GOOD news, not a red. So the assertion now checks the thing
139# that actually matters and cannot go stale: does this sigil-doc carry SD-P7's
140# API at all? A SHA comparison would have to be edited on every legitimate
141# upstream move, which is how a check stops describing its subject.
142sigil_doc_release=$(git -C "$sigil_doc_dir" describe --tags --exact-match HEAD 2>/dev/null || echo "(untagged, ahead of $(git -C "$sigil_doc_dir" describe --tags --abbrev=0 2>/dev/null || echo 'no tag'))")
143echo "sigil-doc release: $sigil_doc_release"
144for api in make-content-profile make-section-with-profile content-profile-describer core-block-witnesses; do
145 grep -q "$api" "$sigil_doc_dir/src/sigil/doc/model.sgl" \
146 || fail "the sigil-doc at $sigil_doc_dir does not export $api -- SD-P7 is not in this tree, and nothing below can be evidence about folio's profile"
147done
149# ---------------------------------------------------------------------------
150# ARM 1 -- the product builds, and the three new modules are IN the build.
152# Asserted on the ARTIFACT, not on an exit code: this command is often piped
153# while iterating and a pipeline reports the last stage's status.
154# ---------------------------------------------------------------------------
155echo "--- arm 1: build"
156build_log="$work/build.log"
157if ! "$sigil" build --redirects "$redirects" > "$build_log" 2>&1; then
158 tail -30 "$build_log" >&2; fail "build exited non-zero"
159fi
160plain "$build_log" | grep -q "Bundle created" || { tail -30 "$build_log" >&2; fail "build produced no bundle"; }
161# The module COUNT is derived from the log; the three new modules are asserted by
162# NAME, against the ARTIFACT rather than against the log.
164# The first version grepped the log for `COMPILE <module>`, which is only what a
165# COLD build prints -- a warm one prints `CACHED` and the arm went red on a
166# correct tree. A check that only passes at one cache warmth is a check that
167# will be "fixed" by deleting it. The compiled object is the thing that has to
168# exist either way, so that is what is asserted; the objects are also newer than
169# the sources, which is what distinguishes "built this run" from "left over".
170for m in reader author profile; do
171 obj="build/dev/lib/folio/doc/$m.sgb"
172 [ -f "$obj" ] || fail "$obj does not exist -- folio/doc/$m.sgl is not in the module graph"
173 [ "$obj" -nt "src/folio/doc/$m.sgl" ] \
174 || fail "$obj is older than its source -- the build did not rebuild it"
175done
176modules=$(plain "$build_log" | grep -oE '^ +[0-9]+ modules' | grep -oE '[0-9]+' || true)
177modules=${modules%%$'\n'*}
178[ -n "$modules" ] || fail "build log carried no module count"
179echo "arm 1 OK: bundle built, ${modules} modules, all three new modules compiled"
180# Arm 1 needs its own early exit and marker like every other arm. Without them
181# `F1B_GATE_ONLY_ARM=1` fell through to the final summary line, which references
182# variables only later arms set, and died on `reported: unbound variable` under
183# `set -u` -- so arm 1 was not selectable and its sabotage leg could not grade it.
184# Found by the sabotage driver, which refused to accept a red carrying the wrong
185# signature.
186[ "$only_arm" = "1" ] && { echo "F1B_GATE_ARM_1_GREEN"; exit 0; }
188# ---------------------------------------------------------------------------
189# ARM 2 -- the reader suite.
191# The expected count is COUNTED FROM THE SUITE FILE. A suite that silently
192# stopped running half its cases would still print a green summary; comparing
193# the reported count against the file's own `(test "` forms is what makes the
194# green mean "all of them ran".
195# ---------------------------------------------------------------------------
196if should_run 2; then
197echo "--- arm 2: reader suite"
198suite=test/test-folio-doc-reader.sgl
199expected_tests=$(grep -cE '^\(test "' "$suite")
200[ "$expected_tests" -ge 25 ] || fail "suite declares only $expected_tests tests; this phase needs more"
201suite_log="$work/suite.log"
202if ! "$sigil" -L build/dev/lib test --redirects "$redirects" "$suite" > "$suite_log" 2>&1; then
203 tail -40 "$suite_log" >&2; fail "reader suite exited non-zero"
204fi
205reported=$(plain "$suite_log" | grep -oE 'Tests: +[0-9]+ passed' | grep -oE '[0-9]+' || true)
206reported=${reported%%$'\n'*}
207[ -n "$reported" ] || { tail -20 "$suite_log" >&2; fail "reader suite printed no test count"; }
208[ "$reported" = "$expected_tests" ] || fail "suite defines $expected_tests tests but only $reported ran/passed"
209# `if … then fail` rather than `grep -q … && fail …`, for READABILITY of the
210# failure path, not for `set -e` safety.
212# An earlier version of this comment claimed the `&&` form "kills the gate on the
213# healthy path under set -e". MEASURED FALSE: bash exempts a failing left operand
214# of `&&` from `set -e` (`set -euo pipefail; false && echo never; echo survived`
215# prints `survived, status=1`), and this gate relies on that exemption in seven
216# places -- it could not reach its own green marker otherwise. An adversarial
217# review caught the contradiction between the comment and the code around it.
219# Recorded rather than deleted: a wrong rationale attached to correct code is
220# how the next person "simplifies" the code to match the rationale.
221if plain "$suite_log" | grep -qE '[1-9][0-9]* failed'; then
222 plain "$suite_log" | grep -E '[1-9][0-9]* failed' >&2
223 fail "reader suite reported failures"
224fi
225# POSITIVE CONTROL for that absence: prove the pattern matches a real failure
226# summary. An absence check whose pattern never matched anything passes free.
227printf 'Tests: 29 passed, 1 failed (30 total)\n' > "$work/summary.control"
228grep -qE '[1-9][0-9]* failed' "$work/summary.control" \
229 || fail "positive control: the failure pattern does not match the runner's real failure summary"
230echo "arm 2 OK: $reported/$expected_tests tests passed, count derived from $suite"
231[ -n "$only_arm" ] && { echo "F1B_GATE_ARM_2_GREEN"; exit 0; }
232fi
234# ---------------------------------------------------------------------------
235# ARM 3 -- OBLIGATION (a): the refused witnesses, BY EXACT MEMBERSHIP.
237# Fable's SD-P7 ruling: the model cannot require a stance on a concept it does
238# not own, so silence is made impossible in the CONSUMING language's gate. Exact
239# membership, not a subset check and not a count -- a fifth witness must fail as
240# loudly as a missing one.
242# The ruling names `milestone` and `phase`. folio declares all FOUR structural
243# containers, because `note` and `project` are structural containers by folio's
244# own `folio-structural-container?` and a re-nested root launders exactly as
245# badly. Declaring two and leaving the other two to an undeclared predicate
246# property would keep half a known class in the silent position the ruling
247# exists to eliminate. Flagged for review as a deliberate upward deviation.
248# ---------------------------------------------------------------------------
249if should_run 3; then
250echo "--- arm 3: refused witnesses by EXACT MEMBERSHIP"
251probe_log="$work/profile.log"
252if ! "$sigil" -L build/dev/lib -L src -L test test/f1b-profile-probe.sgl > "$probe_log" 2>&1; then
253 dump_probe_failure "$probe_log"; fail "profile probe exited non-zero"
254fi
255line=$(plain "$probe_log" | grep -oE 'F1B_PROFILE_PROBE checks=[0-9]+ failed=[0-9]+') \
256 || { tail -20 "$probe_log" >&2; fail "profile probe printed no result line"; }
257profile_checks=$(echo "$line" | awk '{print $2}' | cut -d= -f2)
258profile_failed=$(echo "$line" | awk '{print $3}' | cut -d= -f2)
259[ "$profile_failed" = "0" ] || fail "profile probe reported $profile_failed failed checks"
260[ "$profile_checks" -ge 80 ] || fail "profile probe ran only $profile_checks checks; it is not exercising the witness lists"
262plain "$probe_log" | grep '^F1B_REFUSED ' | awk '{print $2}' | sort > "$work/refused.actual"
263printf 'milestone\nnote\nphase\nproject\n' | sort > "$work/refused.expected"
264if ! cmp -s "$work/refused.actual" "$work/refused.expected"; then
265 echo "declared refused witnesses:" >&2; cat "$work/refused.actual" >&2
266 echo "required (EXACT membership):" >&2; cat "$work/refused.expected" >&2
267 fail "the refused-witness set is not exactly {milestone, note, phase, project}"
268fi
269# POSITIVE CONTROLS, BOTH DIRECTIONS. An equality check that cannot report a
270# difference is not a membership assertion.
271printf 'milestone\nnote\nphase\n' | sort > "$work/refused.control-missing"
272cmp -s "$work/refused.actual" "$work/refused.control-missing" \
273 && fail "positive control: the comparator did not notice a MISSING witness"
274printf 'milestone\nnote\nphase\nproject\ntask\n' | sort > "$work/refused.control-extra"
275cmp -s "$work/refused.actual" "$work/refused.control-extra" \
276 && fail "positive control: the comparator did not notice an EXTRA witness"
278admitted_count=$(plain "$probe_log" | grep -c '^F1B_ADMITTED ' || true)
279[ "$admitted_count" -ge 5 ] || fail "only $admitted_count admitted witnesses; the profile barely extends anything"
280echo "arm 3 OK: refused = exactly {milestone note phase project}; $admitted_count admitted; $profile_checks probe checks; comparator proven to see a missing AND an extra member"
281[ -n "$only_arm" ] && { echo "F1B_GATE_ARM_3_GREEN"; exit 0; }
282fi
284# ---------------------------------------------------------------------------
285# ARM 4 -- OBLIGATION (b): the section-child predicate DELEGATES.
287# TWO HALVES, because the runtime half cannot see the failure mode Fable named.
288# SD-P7's anti-narrowing floor SAMPLES ONE WITNESS PER KIND, so it stops a
289# profile dropping a whole construct and CANNOT see one narrowing WITHIN a kind
290# (an `aside` admitted, a `warning` refused). A predicate that DELEGATES to
291# `block-node?` makes within-kind narrowing structurally impossible; one that
292# re-enumerates reintroduces exactly the hole the floor was built to cover, and
293# the floor will not catch it. So the SPELLING is checked, not only the
294# behaviour.
296# The forbidden token set is DERIVED from `block-node?`'s own disjuncts in
297# sigil-doc. Note the trap SD-P7 measured and paid for: `sed` ranges are
298# INCLUSIVE, so a range ending at the next definition includes that line's own
299# disjuncts and over-counts. This uses paren-balanced extraction instead, and
300# asserts the derived count is plausible before using it.
301# ---------------------------------------------------------------------------
302if should_run 4; then
303echo "--- arm 4: the section-child predicate delegates rather than re-enumerating"
304extract_definition() { # $1 file, $2 predicate name -> the whole balanced form
305 awk -v name="$2" '
306 index($0, "(define (" name " x)") { collecting = 1 }
307 collecting {
308 buf = buf " " $0
309 line = $0
310 n = gsub(/\(/, "(", line); m = gsub(/\)/, ")", line)
311 depth += n - m
312 if (depth <= 0) { print buf; exit }
313 }
314 ' "$1"
316core_def=$(extract_definition "$sigil_doc_dir/src/sigil/doc/model.sgl" "block-node?")
317[ -n "$core_def" ] || fail "could not extract block-node? from sigil-doc -- the extractor is broken, not the file"
318core_kinds=$(echo "$core_def" | grep -oE '\([a-z][a-z0-9-]*\? x\)' | sed -E 's/^\(//; s/ x\)$//' | grep -v '^block-node?$' | sort -u)
319core_kind_count=$(echo "$core_kinds" | grep -c . || true)
320[ "$core_kind_count" -ge 10 ] || fail "derived only $core_kind_count core block kinds from sigil-doc; the derivation is not matching"
322folio_def=$(extract_definition src/folio/doc/profile.sgl "folio-section-child?")
323[ -n "$folio_def" ] || fail "could not extract folio-section-child? -- the extractor is broken, not the file"
324echo "$folio_def" | grep -q 'block-node?' \
325 || fail "folio-section-child? does not call block-node? -- it must DELEGATE to the core floor, not restate it"
326# `grep -qF`, NOT `grep -qE`, and this is not a style choice. Every kind name
327# ENDS IN `?` -- `admonition?`, `section?` -- so under ERE that trailing `?` is a
328# QUANTIFIER on the preceding character: `\(admonition? x\)` searches for
329# `(admonitio x)` or `(admonition x)` and never for the literal `(admonition? x)`
330# that is actually in the file. The regex form matched NOTHING, in both loops,
331# and reported a clean delegating predicate for entirely the wrong reason.
333# It was caught by the positive control below and by nothing else: the arm was
334# green, on a correct product, while asserting nothing whatsoever. Fixed-string
335# matching removes the class rather than escaping around it.
336reenumerated=""
337for k in $core_kinds; do
338 echo "$folio_def" | grep -qF "($k x)" && reenumerated="$reenumerated $k"
339done
340[ -z "$reenumerated" ] \
341 && : || fail "folio-section-child? re-enumerates core kinds instead of delegating to block-node?:$reenumerated"
343# POSITIVE CONTROL: the same detector, run over a copy in which the delegating
344# call HAS been expanded into its disjuncts, must report them. Without this an
345# empty `reenumerated` is equally consistent with "it delegates" and "the loop
346# never compared anything".
347cp src/folio/doc/profile.sgl "$control_dir/profile-expanded.sgl"
348expansion=$(echo "$core_kinds" | sed -E 's/^/(/; s/$/ x)/' | tr '\n' ' ')
349sed -i "s|(or (block-node? x) (folio-work-item? x) (folio-relational-node? x))|(or $expansion (folio-work-item? x) (folio-relational-node? x))|" \
350 "$control_dir/profile-expanded.sgl"
351control_def=$(extract_definition "$control_dir/profile-expanded.sgl" "folio-section-child?")
352control_hits=""
353for k in $core_kinds; do
354 echo "$control_def" | grep -qF "($k x)" && control_hits="$control_hits $k"
355done
356[ -n "$control_hits" ] \
357 || fail "positive control: the re-enumeration detector did not fire on a deliberately expanded predicate"
359# AND THE OTHER DIRECTION: only ONE core container is parameterized today, so
360# `(folio doc publication)` descends into `section` and nothing else. If a later
361# phase parameterizes a second container, that clause becomes incomplete and
362# would silently drop nodes -- so the count is derived here and fails loudly.
363# THE DESCENT SET IS DERIVED FROM sigil-doc, NOT AUDITED FROM THE DISPATCH.
365# THIS IS THE POINT OF THE ARM. The list of containers `(folio doc publication)`
366# must descend into has been WRONG THREE TIMES -- `section` only, then eight,
367# then nine -- and each time the repair was "add the missing one". Auditing the
368# dispatch cannot find the tenth; deriving the set can.
370# Two source facts, both in sigil-doc's model:
372# 1. A constructor that validates children with `require-block` accepts any
373# BLOCK node, and `section?` is a block node -- so it can transitively hold
374# folio nodes under the folio profile. Those are the direct holders.
375# 2. `require-list "make-X" <element>?` says X holds <element>. Closing over
376# those edges pulls in the aggregates: `item-list` holds `list-item`,
377# `table` holds `table-row`, `table-row` holds `table-cell`.
379# Plus `section` itself, which is validated through the PROFILE rather than
380# through `require-block` and so appears in neither grep.
382# `document` is then removed: it is the ROOT, lowered by
383# `folio-document->publication` directly rather than by `lower-child`.
385# The previous version of this clause derived the PARAMETERIZED-constructor
386# count instead and asserted it was 1. That measured a true fact about SD-P7 and
387# an irrelevant one about this descent -- the same conflation as the product bug
388# it failed to catch, one instrument over. It is kept below, as a genuine but
389# DIFFERENT assertion, rather than deleted.
390model_sgl="$sigil_doc_dir/src/sigil/doc/model.sgl"
391direct_holders=$(grep -oE 'require-block "make-[a-z-]+"' "$model_sgl" \
392 | sed -E 's/require-block "make-//; s/"$//' | sort -u)
393[ "$(echo "$direct_holders" | grep -c .)" -ge 5 ] \
394 || fail "derived only $(echo "$direct_holders" | grep -c .) require-block holders from sigil-doc; the derivation is not matching"
395descent_set=$(printf '%s\nsection\n' "$direct_holders" | sort -u)
396# Close over the aggregate edges until stable.
397for _pass in 1 2 3 4; do
398 while read -r holder element; do
399 [ -n "$holder" ] || continue
400 if echo "$descent_set" | grep -qx "$element"; then
401 descent_set=$(printf '%s\n%s\n' "$descent_set" "$holder" | sort -u)
402 fi
403 done <<< "$(grep -oE 'require-list "make-[a-z-]+" [a-z-]+\?' "$model_sgl" \
404 | sed -E 's/require-list "make-//; s/" / /; s/\?$//')"
405done
406# The root is lowered by `folio-document->publication`, not by `lower-child`.
407descent_set=$(echo "$descent_set" | grep -vx 'document')
408descent_count=$(echo "$descent_set" | grep -c .)
409[ "$descent_count" -ge 9 ] \
410 || fail "the derived descent set has only $descent_count members; the closure is not matching"
411missing_descent=""
412for kind in $descent_set; do
413 grep -qF "((${kind}? child)" src/folio/doc/publication.sgl \
414 || missing_descent="$missing_descent $kind"
415done
416[ -z "$missing_descent" ] \
417 || fail "(folio doc publication) lower-child does not descend into:$missing_descent -- derived from sigil-doc's require-block call sites and require-list element edges. A folio node nested inside one of these is dropped SILENTLY from the rendering."
418# POSITIVE CONTROL: the detector must report a container that is genuinely
419# absent from the dispatch, or an empty `missing_descent` means nothing.
420grep -qF "((this-kind-cannot-exist? child)" src/folio/doc/publication.sgl \
421 && fail "positive control: the descent detector matched a kind that cannot exist"
423profiled_ctors=$(grep -oE 'make-[a-z-]+-with-profile' "$sigil_doc_dir/src/sigil/doc/model.sgl" | sort -u)
424profiled_count=$(echo "$profiled_ctors" | grep -c . || true)
425[ "$profiled_count" = "1" ] \
426 || fail "sigil-doc now has $profiled_count profile-parameterized constructors ($profiled_ctors); (folio doc publication)'s lower-children descends into 'section' ONLY and must be extended"
427echo "$profiled_ctors" | grep -q '^make-section-with-profile$' \
428 || fail "the single parameterized constructor is not make-section-with-profile; publication.sgl's assumption has moved"
429grep -q '((section? child)' src/folio/doc/publication.sgl \
430 || fail "publication.sgl no longer descends into section -- folio nodes under a @section will be dropped silently"
431echo "arm 4 OK: delegates to block-node?, re-enumerates none of $core_kind_count core kinds (detector proven to fire on an expanded copy); descent set DERIVED from sigil-doc = $descent_count containers, all present in lower-child"
432[ -n "$only_arm" ] && { echo "F1B_GATE_ARM_4_GREEN"; exit 0; }
433fi
435# ---------------------------------------------------------------------------
436# ARM 5 -- DELEGATION PARITY.
438# The alignment is asserted BEFORE the probe, with a message saying how to
439# re-pad, so a future fixture edit cannot silently turn the strong comparison
440# (identical positions) into a weak one (identical shape).
442# WHY THE SIGIL-SIDE FIXTURE IS `#lang sigil/doc` WITH AN `@article` ROOT, and
443# not `#lang sigil/doc/book` with a `@chapter` one. sigil-doc's D8 (`2ec9df9`)
444# moved the BASE language's root set from `(chapter)` to `(article)` and put
445# `@chapter` behind the new genre language `#lang sigil/doc/book`. Both repairs
446# make the manuscript legal again; they are not equivalent here.
448# 1. THE CLAIM. This arm's subject is folio delegating the BASE grammar. A
449# genre sibling is a different language, so re-heading the fixture would
450# leave the arm passing while testing something else -- the migration
451# hazard SD-P8 hit in its own corpus.
452# 2. THE ALIGNMENT. `#lang sigil/doc` and `#lang folio/doc` are both 15
453# bytes, which is what makes the shared body start at the same offset in
454# both files and lets this arm compare POSITIONS rather than shape.
455# `#lang sigil/doc/book` is 20, so that repair shifts the body by 5.
456# `@chapter` -> `@article` is 8 bytes either way, so the repair actually
457# taken needs no re-padding at all.
459# DO NOT rely on the byte-alignment check to catch a re-heading: it is a
460# SUM over two lines and a 5-byte-shorter title cancels the swap exactly.
461# The explicit `#lang` assertions at the top of this arm are what catch it.
463# The rationale lives here rather than in the fixture because there is nowhere
464# in the fixture to put it: this arm fixes lines 1-2 at a byte total and
465# requires everything from line 3 to be byte-identical to the folio fixture, so
466# any comment added to one side would redden the arm that protects it.
467# ---------------------------------------------------------------------------
468if should_run 5; then
469echo "--- arm 5: delegation parity"
470# EACH SIDE'S LANGUAGE IS PINNED BY NAME, BEFORE ANY BYTE COUNT.
472# The alignment check below is a SUM over two lines, so it cannot see a language
473# swap that pays for itself: re-heading the sigil side to `#lang sigil/doc/book`
474# adds 5 bytes to line 1, and a title 5 bytes shorter on line 2 takes them back.
475# The sum stays equal, `cmp` starts at line 3 and never looks, the probe's
476# `document?` check still passes because the book genre also binds `document`
477# with multiplicity `one` -- and the arm goes green while comparing folio
478# against the GENRE grammar instead of the base grammar it names.
480# That is this arm's own stated hazard (see item 1 in the header comment), and
481# until these two lines it was documented in prose and asserted nowhere. A guard
482# that is a comment is not a guard.
483sigil_lang=$(head -1 test/fixtures/f1b-parity-sigil.sgd)
484[ "$sigil_lang" = "#lang sigil/doc" ] \
485 || fail "the sigil-side parity fixture declares '$sigil_lang', not '#lang sigil/doc'. This arm's claim is that folio delegates the BASE grammar; a genre sibling such as '#lang sigil/doc/book' is a different language, so the comparison would no longer mean what this arm reports. Re-root the fixture instead of re-heading it."
486folio_lang=$(head -1 test/fixtures/f1b-parity-folio.sgd)
487[ "$folio_lang" = "#lang folio/doc" ] \
488 || fail "the folio-side parity fixture declares '$folio_lang', not '#lang folio/doc'; this arm would be comparing something other than folio's reader."
489shifted_lang=$(head -1 test/fixtures/f1b-parity-shifted.sgd)
490[ "$shifted_lang" = "#lang folio/doc" ] \
491 || fail "the shifted control declares '$shifted_lang', not '#lang folio/doc'; it must differ from the aligned fixture ONLY in root-line width."
492sigil_header=$(head -2 test/fixtures/f1b-parity-sigil.sgd | wc -c)
493folio_header=$(head -2 test/fixtures/f1b-parity-folio.sgd | wc -c)
494[ "$sigil_header" = "$folio_header" ] \
495 || fail "parity fixture headers are $sigil_header vs $folio_header bytes; the shared body no longer starts at the same offset, so positions cannot be compared. Re-pad @article's title string until the first two lines are the same byte length. (A language swap cannot be the cause here: both sides' #lang lines are asserted by name above.)"
496cmp -s <(tail -n +3 test/fixtures/f1b-parity-sigil.sgd) <(tail -n +3 test/fixtures/f1b-parity-folio.sgd) \
497 || fail "the two parity fixtures' shared bodies are no longer byte-identical"
498# The shifted control must differ in the header and agree in the body, or it is
499# not a control for position sensitivity.
500shifted_header=$(head -2 test/fixtures/f1b-parity-shifted.sgd | wc -c)
501[ "$shifted_header" != "$folio_header" ] \
502 || fail "the shifted control's header is the same length as the aligned one; it cannot demonstrate position sensitivity"
503cmp -s <(tail -n +3 test/fixtures/f1b-parity-folio.sgd) <(tail -n +3 test/fixtures/f1b-parity-shifted.sgd) \
504 || fail "the shifted control's body is not byte-identical to the aligned one; a difference would then have two possible causes"
506parity_log="$work/parity.log"
507if ! "$sigil" -L build/dev/lib -L src -L test test/f1b-parity-probe.sgl > "$parity_log" 2>&1; then
508 dump_probe_failure "$parity_log"; fail "parity probe exited non-zero"
509fi
510pline=$(plain "$parity_log" | grep -oE 'F1B_PARITY_PROBE checks=[0-9]+ failed=[0-9]+ bytes=[0-9]+ children=[0-9]+ kinds=[0-9]+') \
511 || { tail -20 "$parity_log" >&2; fail "parity probe printed no result line"; }
512p_failed=$(echo "$pline" | awk '{print $3}' | cut -d= -f2)
513p_bytes=$(echo "$pline" | awk '{print $4}' | cut -d= -f2)
514p_kinds=$(echo "$pline" | awk '{print $6}' | cut -d= -f2)
515[ "$p_failed" = "0" ] || fail "parity probe reported $p_failed failed checks"
516[ "$p_bytes" -ge 2000 ] || fail "the compared datum is only $p_bytes bytes; two nearly-empty strings agree for free"
517[ "$p_kinds" -ge 15 ] || fail "the shared body exercises only $p_kinds node kinds; it is not a shared-construct population"
518# THE POPULATION, stated with its exclusions rather than as a bare number. The
519# denominator is `(sigil doc author)`'s exported command set.
520author_exports=$(sed -n '/(export/,/(begin/p' "$sigil_doc_dir/src/sigil/doc/author.sgl" \
521 | tr ' ' '\n' | grep -E '^[a-z][a-z0-9-]*$' | grep -vE '^(export|begin)$' | sort -u)
522author_count=$(echo "$author_exports" | grep -c . || true)
523[ "$author_count" -ge 20 ] || fail "derived only $author_count author commands from sigil-doc; the derivation is not matching"
524echo "arm 5 OK: byte-identical canonical datum over ${p_bytes}B, ${p_kinds} node kinds exercised; one-byte-shift control differs; denominator = $author_count author exports (deliberately excluded: video, example, project-check, term-ref -- each needs external assets, a subprocess-backed verifier, or a glossary this fixture does not declare)"
525[ -n "$only_arm" ] && { echo "F1B_GATE_ARM_5_GREEN"; exit 0; }
526fi
528# ---------------------------------------------------------------------------
529# ARM 6 -- THE PASSIVE NO-EXECUTION WITNESS.
531# One manuscript per PROCESS, each with its own sentinel directory, so "no
532# marker" is a fact about one manuscript rather than a residue of another.
533# TWO detection channels (stdout markers, filesystem sentinels), each shown able
534# to fire IN THIS RUN by the control before any absence is allowed to mean
535# anything.
537# REPORTED PER CHANNEL, NOT IN AGGREGATE, because one channel deliberately
538# survives: an expression in ARGUMENT POSITION still evaluates. The parser
539# splices argument syntax into the lowered call unchanged, so it runs before any
540# folio command sees a value. That hole is structural to `sigil/doc` argument
541# evaluation rather than specific to folio -- `@section["id" ...]` has it
542# identically -- and a fix confined to folio's eleven commands would be a partial
543# fix that LOOKS total. So it is PINNED HERE IN BOTH DIRECTIONS: if it ever
544# stops executing, this arm goes red and somebody has to update the guarantee's
545# wording rather than silently leaving an understated claim.
546# ---------------------------------------------------------------------------
547if should_run 6; then
548echo "--- arm 6: no-execution witness"
549# THE CONTROL AND THE SUBJECT MUST BE THE SAME MANUSCRIPT BUT FOR THEIR ROOT.
551# Arm 5 pins its fixture pair byte-for-byte and says why; arm 6 did not, and an
552# adversarial review named the consequence: the "6 markers, 5 sentinels" this arm
553# reports are facts about the CONTROL file, presented as though they
554# characterised the file under test. Drop the macro and mid-prose tripwires from
555# the folio fixture, keep one author form, and every assertion below still
556# passes while the arm silently tests a third of what it claims.
558# So the two are diffed here: identical except for the `#lang` line, the root
559# command line, and the `@end` line. The diff drops line 1 with `tail -n +2`, so
560# what it sees is the root line and the `@end` line on EACH side -- four lines,
561# asserted as four below. (This sentence used to say "three differing lines,
562# asserted as three", which never matched the 4 the code requires; the code was
563# right and the prose was wrong.)
565# BECAUSE `tail -n +2` DROPS THE `#lang` LINE, THE DIFF CANNOT SEE THE LANGUAGE.
566# The control's whole job is to be the same manuscript under a language that
567# PERMITS author forms, so its language is pinned by name here rather than left
568# to a comparison that is structurally blind to it -- the same gap arm 5 had.
569sigil_witness_lang=$(head -1 test/fixtures/f1b-witness-sigil.sgd)
570[ "$sigil_witness_lang" = "#lang sigil/doc" ] \
571 || fail "the witness control declares '$sigil_witness_lang', not '#lang sigil/doc'. This arm's positive control must run under the BASE language; the diff below drops line 1 and cannot catch this."
572folio_witness_lang=$(head -1 test/fixtures/f1b-witness-folio.sgd)
573[ "$folio_witness_lang" = "#lang folio/doc" ] \
574 || fail "the witness subject declares '$folio_witness_lang', not '#lang folio/doc'; the refusal this arm asserts would be about a different language."
575witness_diff=$(diff <(tail -n +2 test/fixtures/f1b-witness-sigil.sgd) \
576 <(tail -n +2 test/fixtures/f1b-witness-folio.sgd) | grep -c '^[<>]' || true)
577[ "$witness_diff" = "4" ] \
578 || fail "the witness fixture pair differs in $witness_diff lines, not the 4 expected (root line and @end line, each side). The control must be the same manuscript as the subject but for its root, or its marker counts describe a different file."
579run_witness() { # $1 fixture stem -> writes $work/$1.log and $work/$1.sentinels
580 local stem="$1"
581 local dir="$work/sent-$stem"
582 rm -rf "$dir"; mkdir -p "$dir"
583 F1B_WITNESS_FIXTURE="test/fixtures/f1b-$stem.sgd" F1B_WITNESS_DIR="$dir" \
584 "$sigil" -L build/dev/lib -L src -L test test/f1b-witness-load.sgl > "$work/$stem.log" 2>&1 || true
585 ls "$dir" > "$work/$stem.sentinels" 2>/dev/null || true
587run_witness witness-sigil
588run_witness witness-folio
589run_witness witness-argument
591# (a) THE POSITIVE CONTROL. Both channels must be shown able to fire.
592control_markers="F1B-WITNESS-EXPR-RAN F1B-WITNESS-COMMAND-RAN F1B-WITNESS-MIDPROSE-RAN F1B-WITNESS-MIDPROSE-COMMAND-RAN F1B-WITNESS-MACRO-RAN F1B-WITNESS-MACRO-USE-RAN"
593for marker in $control_markers; do
594 plain "$work/witness-sigil.log" | grep -qF "$marker" \
595 || { cat "$work/witness-sigil.log" >&2; fail "positive control: evaluating the fixture under #lang sigil/doc did not print $marker -- channel 1 cannot fire, so its absence under folio/doc proves nothing"; }
596done
597for sentinel in expr command macro midprose midprose-command; do
598 grep -qx "f1b-witness-$sentinel" "$work/witness-sigil.sentinels" \
599 || { cat "$work/witness-sigil.sentinels" >&2; fail "positive control: the control run did not write the '$sentinel' sentinel -- channel 2 cannot fire"; }
600done
601plain "$work/witness-sigil.log" | grep -q 'F1B_WITNESS_OUTCOME LOADED' \
602 || fail "the control manuscript did not load; a control that did not run proves nothing"
603# RESTRAINT, mirroring F2a: a `@code` raw body must stay inert on the CONTROL
604# path too. Everything else in this arm requires the control to RUN author code,
605# so this is the one place it is checked for not running some.
606plain "$work/witness-sigil.log" | grep -q 'F1B-WITNESS-CODE-BODY-RAN' \
607 && fail "a @code raw body was evaluated on the control path"
609# (b) THE ASSERTION. Under folio/doc: refused, and nothing fired.
610plain "$work/witness-folio.log" | grep -q 'F1B_WITNESS_OUTCOME REFUSED' \
611 || { cat "$work/witness-folio.log" >&2; fail "the author-form manuscript was NOT refused under #lang folio/doc"; }
613# ...AND IT MUST BE REFUSED FOR *THIS* REASON, which the `#lang` path cannot tell
614# us. FOUND BY SABOTAGE, and it was the most important miss of the round:
615# disabling the author-form refusal entirely left this arm GREEN, still reporting
616# "folio/doc refused the manuscript and fired neither channel". With the refusal
617# gone the manuscript is still refused -- its emitted `@(import (sigil fs))` fails
618# at module top level -- so no tripwire fires and every assertion above passes.
620# The arm was conflating "it did not execute" with "it failed before it could
621# execute". Those are different facts and only the first is the claim.
623# The message that would discriminate is discarded by the compiler's `#lang`
624# wrapper, so the SECOND INSTRUMENT is used here exactly as it is for wording
625# elsewhere: a direct `read-language-source` call, where the refusal is intact.
626# When [[t-2f2af1]] lands these merge; neither is deleted.
627cat > "$work/witness-reason.sgl" <<'PROBE'
628(import (sigil core) (sigil fs) (folio doc reader))
629(define src (read-file-string "test/fixtures/f1b-witness-folio.sgd"))
630(define ctx (list (cons source-name: "test/fixtures/f1b-witness-folio.sgd")
631 (cons header-end-offset: 16)))
632(display (guard (exn (else (exception-message exn)))
633 (read-language-source src ctx) "NO-RAISE"))
634(newline)
635PROBE
636witness_reason=$("$sigil" -L build/dev/lib -L src -L test "$work/witness-reason.sgl" 2>&1 || true)
637echo "$witness_reason" | grep -q 'may not contain author forms' \
638 || { echo "$witness_reason" >&2; fail "the manuscript was refused, but NOT by the author-form refusal -- this arm's claim is that author forms are refused, not merely that the file failed to load"; }
639# ...and the refusal must have SEEN them all, or a refusal on the first one would
640# satisfy the line above while leaving the count unasserted.
641echo "$witness_reason" | grep -qE 'found [1-9][0-9]*' \
642 || { echo "$witness_reason" >&2; fail "the author-form refusal named no count"; }
643if plain "$work/witness-folio.log" | grep -q 'F1B-WITNESS-'; then
644 plain "$work/witness-folio.log" | grep 'F1B-WITNESS-' >&2
645 fail "a tripwire fired under #lang folio/doc"
646fi
647if [ -s "$work/witness-folio.sentinels" ]; then
648 cat "$work/witness-folio.sentinels" >&2
649 fail "a filesystem sentinel was written under #lang folio/doc"
650fi
652# (c) THE PINNED LIMIT. Argument position still evaluates -- asserted in BOTH
653# directions so a future change cannot silently move it either way.
654plain "$work/witness-argument.log" | grep -q 'F1B_WITNESS_OUTCOME LOADED' \
655 || { cat "$work/witness-argument.log" >&2; fail "the argument-position fixture did not load; it contains no author form and must parse"; }
656plain "$work/witness-argument.log" | grep -q 'F1B-WITNESS-ARG-RAN' \
657 || fail "the argument-position expression did NOT execute. That may be good news, but this gate pins the DISCLOSED limit in both directions: update the guarantee's wording in reader.sgl, the Session Log and this arm, then change this assertion deliberately."
658# ...and it must be the ONLY thing that ran there, or the fixture has stopped
659# isolating the one channel it exists to isolate.
660other=$(plain "$work/witness-argument.log" | grep -o 'F1B-WITNESS-[A-Z-]*' | grep -v '^F1B-WITNESS-ARG-RAN$' || true)
661[ -z "$other" ] || fail "the argument-position fixture fired other channels too ($other); it no longer isolates the disclosed limit"
662echo "arm 6 OK: 2 detection channels proven able to fire in this run (6 markers, 5 sentinels); folio/doc refused the manuscript and fired neither channel; @code body inert on both paths; argument position pinned as the one channel that still evaluates"
663[ -n "$only_arm" ] && { echo "F1B_GATE_ARM_6_GREEN"; exit 0; }
664fi
666# ---------------------------------------------------------------------------
667# ARM 7 -- PUBLICATION FALLBACK, FROM A READ MANUSCRIPT.
669# The probe is a direct script with a hand-rolled harness, because `(sigil test)`
670# forms are INERT in a direct run -- measured by F1a: a file asserting 1 = 2
671# printed nothing and exited 0. So a green from this probe is worthless until
672# the SAME probe has been shown to go red on the SAME run.
673# ---------------------------------------------------------------------------
674if should_run 7; then
675echo "--- arm 7: publication fallback from a read manuscript"
676pub_log="$work/publication.log"
677if ! "$sigil" -L build/dev/lib -L src -L test test/f1b-publication-probe.sgl > "$pub_log" 2>&1; then
678 dump_probe_failure "$pub_log"; fail "publication probe exited non-zero"
679fi
680publine=$(plain "$pub_log" | grep -oE 'F1B_PUBLICATION_PROBE checks=[0-9]+ failed=[0-9]+') \
681 || { tail -20 "$pub_log" >&2; fail "publication probe printed no result line"; }
682pub_checks=$(echo "$publine" | awk '{print $2}' | cut -d= -f2)
683pub_failed=$(echo "$publine" | awk '{print $3}' | cut -d= -f2)
684[ "$pub_failed" = "0" ] || fail "publication probe reported $pub_failed failed checks"
685[ "$pub_checks" -ge 40 ] || fail "publication probe ran only $pub_checks checks; it is not exercising three renderings"
686selftest_log="$work/publication-selftest.log"
687if F1B_PROBE_SELFTEST=1 "$sigil" -L build/dev/lib -L src -L test \
688 test/f1b-publication-probe.sgl > "$selftest_log" 2>&1; then
689 fail "publication probe self-test EXITED 0 with a failure injected -- the harness cannot go red"
690fi
691plain "$selftest_log" | grep -qE 'F1B_PUBLICATION_PROBE checks=[0-9]+ failed=1$' \
692 || fail "publication probe self-test did not report exactly one failure"
693echo "arm 7 OK: $pub_checks checks green across plain/HTML/Typst from a PARSED manuscript, and the harness demonstrably goes red on injection"
694[ -n "$only_arm" ] && { echo "F1B_GATE_ARM_7_GREEN"; exit 0; }
695fi
697# ---------------------------------------------------------------------------
698# ARM 8 -- the profile probe's own harness must be able to fail.
699# Split from arm 3 so that a sabotage leg aimed at the membership comparison
700# cannot be satisfied by the self-test, and vice versa.
701# ---------------------------------------------------------------------------
702if should_run 8; then
703echo "--- arm 8: probe harnesses go red on injection"
704for probe in f1b-profile-probe f1b-parity-probe; do
705 log="$work/$probe-selftest.log"
706 if F1B_PROBE_SELFTEST=1 "$sigil" -L build/dev/lib -L src -L test "test/$probe.sgl" > "$log" 2>&1; then
707 fail "$probe self-test EXITED 0 with a failure injected -- the harness cannot go red"
708 fi
709 # `failed=1( |$)`, not `failed=1$`: the parity probe's summary continues with
710 # `bytes=`, `children=` and `kinds=` after the field, so an end-anchored
711 # pattern matched nothing and reported a working harness as broken. Anchoring
712 # to the FIELD rather than to the line also keeps `failed=1` from matching
713 # `failed=10`.
714 plain "$log" | grep -qE 'failed=1( |$)' \
715 || { tail -10 "$log" >&2; fail "$probe self-test did not report exactly one failure"; }
716done
717echo "arm 8 OK: both probe harnesses proven able to go red on this run"
718[ -n "$only_arm" ] && { echo "F1B_GATE_ARM_8_GREEN"; exit 0; }
719fi
721# ---------------------------------------------------------------------------
722# ARM 9 -- READER-TIER PASSIVITY, by import allowlist.
724# A DIFFERENT TIER FROM F1a's arm 6, deliberately. The semantic core may not
725# reach the reader or the author runtime; the reader tier must reach both and
726# nothing further. Two allowlists, each tight, rather than one loose one.
727# ---------------------------------------------------------------------------
728if should_run 9; then
729echo "--- arm 9: reader-tier passivity"
730reader_files="src/folio/doc/reader.sgl src/folio/doc/author.sgl src/folio/doc/profile.sgl"
731extract_imports() {
732 sed '/^[[:space:]]*;/d' "$1" | awk '
733 /\(import/ { collecting = 1; buf = "" }
734 collecting {
735 buf = buf " " $0
736 line = $0
737 n = gsub(/\(/, "(", line); m = gsub(/\)/, ")", line)
738 depth += n - m
739 if (depth <= 0) { print buf; collecting = 0; depth = 0 }
740 }
741 ' | grep -oE '\([a-z][a-z0-9-]*( [a-z0-9-]+)+\)' | sort -u
743allowed='^\((sigil (core|struct|syntax))\)$'
744allowed="$allowed"'|^\(sigil doc\)$|^\(sigil doc (model|traversal|reader)\)$'
745allowed="$allowed"'|^\(sigil doc private author-runtime\)$'
746allowed="$allowed"'|^\(sigil doc author\)$'
747allowed="$allowed"'|^\(folio doc( [a-z-]+)*\)$'
748for f in $reader_files; do
749 imports=$(extract_imports "$f")
750 [ -n "$imports" ] || fail "no import form extracted from $f -- the extractor is broken, not the file"
751 while IFS= read -r lib; do
752 echo "$lib" | grep -qE "$allowed" || fail "$f imports $lib, which is outside the reader-tier allowlist"
753 done <<< "$imports"
754done
755# POSITIVE CONTROL: the extractor must SEE a forbidden import when one is there.
756cp src/folio/doc/reader.sgl "$control_dir/reader-planted.sgl"
757sed -i 's|(import (sigil core) (sigil syntax) (sigil doc reader))|(import (sigil core) (sigil syntax) (sigil doc reader) (sigil process))|' \
758 "$control_dir/reader-planted.sgl"
759extract_imports "$control_dir/reader-planted.sgl" | grep -q '(sigil process)' \
760 || fail "positive control: the import extractor did not see a planted (sigil process)"
761# ...and the allowlist must REFUSE what it should and ACCEPT what it must.
762for forbidden_lib in '(sigil process)' '(sigil shell)' '(sigil fs)' '(sigil io)' '(sigil socket)' \
763 '(sigil sqlite)' '(sigil doc verify)' '(sigil doc presentation)' \
764 '(sigil doc render html)' '(scheme file)'; do
765 echo "$forbidden_lib" | grep -qE "$allowed" \
766 && fail "positive control: the allowlist ACCEPTS $forbidden_lib -- it is not restricting anything"
767done
768for permitted_lib in '(sigil core)' '(sigil doc reader)' '(sigil doc private author-runtime)' \
769 '(sigil doc model)' '(folio doc model)' '(folio doc profile)'; do
770 echo "$permitted_lib" | grep -qE "$allowed" \
771 || fail "positive control: the allowlist REFUSES $permitted_lib, which the reader tier legitimately needs"
772done
773echo "arm 9 OK: 3 reader-tier files inside their allowlist; extractor proven to see a planted violation; allowlist proven to refuse 10 and accept 6"
774[ -n "$only_arm" ] && { echo "F1B_GATE_ARM_9_GREEN"; exit 0; }
775fi
777# ---------------------------------------------------------------------------
778# ARM 10 -- the reader and F2a's inspector agree, and disagree where they must.
780# AGREE on identity extraction: for a manuscript this reader ACCEPTS, the
781# inspector must report zero `missing-node-id`.
782# DISAGREE on duplicates, in the intended direction: the reader REFUSES, the
783# inspector DESCRIBES. A knowledge base indexes half-written notes as its normal
784# case, so the inspector must keep working on documents the reader rejects -- or
785# nothing can ever report the problem. Asserted rather than assumed.
786# ---------------------------------------------------------------------------
787if should_run 10; then
788echo "--- arm 10: reader/inspector layering"
789# The agreement half rides in the suite (it needs the descriptor API); here the
790# DISAGREEMENT is asserted, because it is the half that would silently rot if
791# the two layers were ever "harmonised".
793# BOTH SIDES ARE ASSERTED IN THIS ARM. An earlier version checked only the
794# inspector, which made the arm's own claim -- "the layers disagree in the
795# intended direction" -- half-evidenced: deleting the reader's refusal entirely
796# would have left it green. The reader side is therefore re-asserted here even
797# though the suite covers it, so this arm can fail for either half.
798# Via a temp SCRIPT, not `sigil -e`: there is no such flag, and passing it makes
799# the compiler print its help text and exit 0 -- which this arm read as "the
800# reader did not refuse". A gate that mistakes a usage error for a product
801# verdict is the shape that produced three false greens in this estate.
802cat > "$work/reader-dup.sgl" <<'PROBE'
803(import (sigil core) (folio doc))
804(display (guard (exn (else (exception-message exn)))
805 (load "test/fixtures/f1b-duplicate.sgd") "NO-RAISE"))
806(newline)
807PROBE
808reader_refusal=$("$sigil" -L build/dev/lib -L src -L test "$work/reader-dup.sgl" 2>&1 || true)
809# THE INSPECTOR HALF IS CHECKED FIRST, deliberately.
811# The reader assertion used to run here, before the inspector probe -- so any
812# leg that made the reader stop refusing reddened the arm before the three
813# inspector clauses were ever evaluated, and those clauses (the half this arm's
814# comment calls the one that would silently rot) had nothing showing they can
815# fire. Named by an adversarial review. Ordering the independent half first
816# lets each half be reddened by its own leg: S17 removes the duplicate from the
817# fixture and reddens the inspector clauses; S14 neuters the reader and reddens
818# the reader clause below.
819dup_log="$work/dup.log"
820cat > "$work/dup-probe.sgl" <<'PROBE'
821(import (sigil core) (sigil fs) (folio doc inspect))
822(define src (read-file-string "test/fixtures/f1b-duplicate.sgd"))
823(define d (inspect-folio-source src "test/fixtures/f1b-duplicate.sgd"
824 "sha256:0000000000000000000000000000000000000000000000000000000000000000" 16))
825(define dups
826 (let loop ((ds (descriptor-diagnostics d)) (n 0))
827 (if (null? ds) n
828 (loop (cdr ds) (if (eq? (diagnostic-code (car ds)) 'duplicate-node-id) (+ n 1) n)))))
829(display (string-append "F1B_INSPECTOR parsed=" (if (descriptor-parsed? d) "yes" "no")
830 " duplicates=" (number->string dups)
831 " status=" (symbol->string (descriptor-id-status d "t-dup"))))
832(newline)
833PROBE
834if ! "$sigil" -L build/dev/lib -L src -L test "$work/dup-probe.sgl" > "$dup_log" 2>&1; then
835 tail -20 "$dup_log" >&2; fail "inspector probe exited non-zero"
836fi
837insp=$(plain "$dup_log" | grep -oE 'F1B_INSPECTOR parsed=[a-z]+ duplicates=[0-9]+ status=[a-z]+') \
838 || { cat "$dup_log" >&2; fail "inspector probe printed no result line"; }
839i_parsed=$(echo "$insp" | awk '{print $2}' | cut -d= -f2)
840i_dups=$(echo "$insp" | awk '{print $3}' | cut -d= -f2)
841i_status=$(echo "$insp" | awk '{print $4}' | cut -d= -f2)
842[ "$i_parsed" = "yes" ] || fail "the inspector REFUSED the duplicate document; it must describe what the reader rejects"
843[ "$i_dups" -ge 2 ] || fail "the inspector reported $i_dups duplicate-node-id diagnostics; it must report every occurrence, not one"
844[ "$i_status" = "ambiguous" ] || fail "descriptor-id-status answered '$i_status', not 'ambiguous'"
845# ...and only now the reader half, so the two are independently reddenable.
846echo "$reader_refusal" | grep -q 'is declared 2 times' \
847 || { echo "$reader_refusal" >&2; fail "the READER did not refuse the duplicate document; half of this arm's claim is unevidenced"; }
848echo "arm 10 OK: the reader refuses the duplicate document and the inspector still describes it ($i_dups diagnostics, id status ambiguous) -- the layers disagree in the intended direction"
849[ -n "$only_arm" ] && { echo "F1B_GATE_ARM_10_GREEN"; exit 0; }
850fi
852# ---------------------------------------------------------------------------
853# ARM 11 -- SUITE ARITHMETIC.
855# A bare "N passed" cannot show that no pre-existing arm was silently lost or
856# gained. The arithmetic can. The baseline was MEASURED on folio master before
857# a single file was added, and is stated as a measurement rather than a memory:
858# 351 tests across 22 files, at 3516d45, cold isolated cache, sigil 0.18.6.
859# ---------------------------------------------------------------------------
860if should_run 11; then
861echo "--- arm 11: suite arithmetic"
862baseline_tests=351
863baseline_files=22
864added_tests=$(grep -cE '^\(test "' test/test-folio-doc-reader.sgl)
865full_log="$work/fullsuite.log"
866if ! "$sigil" -L build/dev/lib test --redirects "$redirects" > "$full_log" 2>&1; then
867 tail -40 "$full_log" >&2; fail "the full suite exited non-zero"
868fi
869total=$(plain "$full_log" | grep -oE 'Tests: +[0-9]+ passed' | grep -oE '[0-9]+' | head -1)
870files=$(plain "$full_log" | grep -oE 'Files: +[0-9]+ of [0-9]+ run' | grep -oE '[0-9]+' | head -1)
871[ -n "$total" ] || { tail -20 "$full_log" >&2; fail "the full suite printed no test total"; }
872expected_total=$((baseline_tests + added_tests))
873expected_files=$((baseline_files + 1))
874[ "$total" = "$expected_total" ] \
875 || fail "suite arithmetic does not close: baseline $baseline_tests + added $added_tests = $expected_total, but the runner reports $total. Either an arm was lost, or one was gained outside this phase's file."
876[ "$files" = "$expected_files" ] \
877 || fail "file arithmetic does not close: baseline $baseline_files + 1 = $expected_files, runner reports $files"
878if plain "$full_log" | grep -qE '[1-9][0-9]* failed'; then
879 plain "$full_log" | grep -E '[1-9][0-9]* failed' >&2
880 fail "the full suite reported failures"
881fi
882# The two suites this phase is downstream of must be UNCHANGED. A movement in
883# either is a finding, not a rounding.
884for pair in "test-folio-doc-model 36" "test-folio-doc-inspect 37"; do
885 set -- $pair
886 got=$(plain "$full_log" | grep -oE "ok +[0-9]+ passed +\./test/$1\.sgl" | grep -oE '[0-9]+' | head -1)
887 [ "$got" = "$2" ] || fail "$1 reports $got tests, not its recorded $2 -- an upstream phase's evidence moved"
888done
889echo "arm 11 OK: $baseline_tests + $added_tests = $total across $baseline_files + 1 = $files files; test-folio-doc-model 36 and test-folio-doc-inspect 37 both unchanged"
890[ -n "$only_arm" ] && { echo "F1B_GATE_ARM_11_GREEN"; exit 0; }
891fi
893echo
894echo "F1B_GATE_GREEN arms=11 modules=$modules tests=$reported/$expected_tests total=$total/$expected_total files=$files profile_checks=$profile_checks parity_bytes=$p_bytes parity_kinds=$p_kinds publication_checks=$pub_checks core_kinds=$core_kind_count"