AtlatestRepositoryfolio
1
#!/usr/bin/env bash2
#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 a8
# literal. Adding a test, a witness or a node kind must not require editing a9
# number here; if it does, the check has stopped describing its subject and has10
# become a second thing to keep in sync.11
#12
# Every ABSENCE assertion carries a POSITIVE CONTROL in the SAME ARM, on EVERY13
# run. That matters more here than in most gates because two of this phase's14
# headline properties are absences -- "no tripwire fired" and "no core kind is15
# re-enumerated" -- and an all-empty result from a broken channel looks exactly16
# like an all-empty result from a working one.17
#18
# Positive controls plant into `$(mktemp -d)` copies and NEVER into the working19
# tree, so there is no restore step that has to be reached.20
#21
# THE ONE LITERAL THIS GATE DOES CARRY, and why. Arm 3 compares the profile's22
# declared refused witnesses against a written-out four-line expectation. That23
# 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.28
set -euo pipefail30
repo_dir="$(cd "$(dirname "$0")/.." && pwd)"31
cd "$repo_dir"33
# The INSTALLED compiler, not the dev build the older gates default to. Sigil34
# 0.18.6 shipped and the LICM wrong-code bug that forced `optimize: 0` on35
# SD-P7/F1a/F2a is fixed in it. Building at optimize 0 anyway, for parity with36
# the phases this compares against -- folio's `dev` config is already optimize 0.37
sigil="${SIGIL_F1B_COMPILER:-sigil}"38
redirects="${SIGIL_F1B_REDIRECTS:-$repo_dir/dev-redirects.sgl}"39
sigil_doc_dir="${SIGIL_F1B_DOC_DIR:-$repo_dir/../sigil-doc}"40
cache_dir="${XDG_CACHE_HOME:-$(mktemp -d)}"41
export XDG_CACHE_HOME="$cache_dir"42
# A DIRECT script run has no `--redirects` flag and reads the environment.43
export SIGIL_REDIRECTS="$redirects"45
work=$(mktemp -d)46
control_dir=$(mktemp -d)47
fail() { echo "F1B GATE RED: $*" >&2; exit 1; }49
# ARM SELECTOR -- for the sabotage driver only. Arms overlap on purpose (the50
# 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 fire53
# at all. `F1B_GATE_ONLY_ARM=<n>` runs arm 1 (the build, which every later arm54
# needs) plus arm <n>.55
#56
# IT CANNOT BE USED TO FAKE A GREEN: a selective run prints57
# `F1B_GATE_ARM_<n>_GREEN` and never `F1B_GATE_GREEN`. Every transcript is58
# graded on the full marker.59
only_arm="${F1B_GATE_ONLY_ARM:-}"60
case "$only_arm" in61
""|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" ;;63
esac64
should_run() { [ -z "$only_arm" ] || [ "$only_arm" = "$1" ]; }66
# THE COMPILER AND TEST RUNNER COLOUR THEIR OUTPUT, AND THE ESCAPES SIT BETWEEN67
# THE FIELDS A GATE WANTS TO READ. `Tests: ^[[32m30 passed^[[39m` defeats a68
# pattern as innocent as `Tests: +[0-9]+ passed`. Everything parsed here goes69
# through `plain` first.70
plain() { sed 's/\x1b\[[0-9;]*m//g' "$1"; }72
# WHEN A PROBE FAILS, PRINT WHAT IT SAID BEFORE PRINTING WHERE IT DIED.73
#74
# Every probe here prints ` FAIL: <the assertion>` for each failed check and75
# then raises, so a bare `tail -30` shows the tail of a STACK TRACE and drops the76
# assertions entirely. Sabotage leg S8 landed exactly there: arm 5 went red77
# correctly, and the transcript recorded thirty lines of frame addresses and not78
# one word about which property had broken.79
#80
# A diagnostic that omits the finding makes a real red unreadable, and an81
# unreadable red is the next thing to get "fixed" by loosening the arm.82
dump_probe_failure() { # $1 log83
echo "--- probe assertions that failed:" >&284
plain "$1" | grep -F "FAIL:" >&2 || echo " (none printed -- the probe raised before reporting)" >&285
echo "--- tail of the probe log:" >&286
tail -20 "$1" >&287
}89
# EVERY EXTERNAL TOOL MUST EXIST. A missing tool degrades an assertion into a90
# weaker one that cannot fail, silently.91
for tool in grep sed awk sort comm cmp wc head tail; do92
command -v "$tool" >/dev/null || fail "$tool is required by this gate and is not on PATH"93
done95
for 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; do107
[ -f "$f" ] || fail "$f is missing"108
done109
[ -f "$sigil_doc_dir/src/sigil/doc/model.sgl" ] || fail "sigil-doc not found at $sigil_doc_dir"111
echo "=== F1b gate ==============================================="112
echo "repo : $repo_dir @ $(git rev-parse HEAD)"113
echo "compiler : $($sigil --version 2>&1 | tail -1)"114
echo "sigil-doc : $(git -C "$sigil_doc_dir" rev-parse HEAD) (via dev-redirects, NOT a release)"115
sigil_doc_dirty=$(git -C "$sigil_doc_dir" status --porcelain | wc -l)116
echo "sigil-doc dirty : $sigil_doc_dirty files"117
echo "cache : $cache_dir"118
echo "============================================================"119
# Printing the SHA is not the same as depending on it. Every green here is120
# produced through a redirect to a local sigil-doc WORKING TREE, so an121
# uncommitted change there is an unrecorded input to the whole gate. Refuse122
# rather than produce evidence nobody can reproduce.123
if [ "$sigil_doc_dirty" != "0" ]; then124
git -C "$sigil_doc_dir" status --porcelain >&2125
fail "sigil-doc has $sigil_doc_dirty uncommitted file(s); evidence produced against it is not reproducible"126
fi127
# WHICH sigil-doc THIS EVIDENCE IS AGAINST -- the CONDITION, checked, not a128
# recorded SHA that goes stale in prose.129
#130
# The first version of this block FAILED if sigil-doc HEAD was a tag, encoding131
# the brief's statement that no released sigil-doc contained SD-P7. That fired132
# on its first run -- correctly, because the statement had EXPIRED: sigil-doc133
# v0.1.3 was tagged mid-phase and contains SD-P7 (`7795111` is an ancestor of134
# `f83d8d8`, and the only delta is `docs/v0.1.3-tag-message.md`, +11/-2, with135
# ZERO files changed under src/ or test/ -- measured, so evidence taken against136
# the earlier SHA holds).137
#138
# Being a release is GOOD news, not a red. So the assertion now checks the thing139
# that actually matters and cannot go stale: does this sigil-doc carry SD-P7's140
# API at all? A SHA comparison would have to be edited on every legitimate141
# upstream move, which is how a check stops describing its subject.142
sigil_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'))")143
echo "sigil-doc release: $sigil_doc_release"144
for api in make-content-profile make-section-with-profile content-profile-describer core-block-witnesses; do145
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"147
done149
# ---------------------------------------------------------------------------150
# ARM 1 -- the product builds, and the three new modules are IN the build.151
#152
# Asserted on the ARTIFACT, not on an exit code: this command is often piped153
# while iterating and a pipeline reports the last stage's status.154
# ---------------------------------------------------------------------------155
echo "--- arm 1: build"156
build_log="$work/build.log"157
if ! "$sigil" build --redirects "$redirects" > "$build_log" 2>&1; then158
tail -30 "$build_log" >&2; fail "build exited non-zero"159
fi160
plain "$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 by162
# NAME, against the ARTIFACT rather than against the log.163
#164
# The first version grepped the log for `COMPILE <module>`, which is only what a165
# COLD build prints -- a warm one prints `CACHED` and the arm went red on a166
# correct tree. A check that only passes at one cache warmth is a check that167
# will be "fixed" by deleting it. The compiled object is the thing that has to168
# exist either way, so that is what is asserted; the objects are also newer than169
# the sources, which is what distinguishes "built this run" from "left over".170
for m in reader author profile; do171
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"175
done176
modules=$(plain "$build_log" | grep -oE '^ +[0-9]+ modules' | grep -oE '[0-9]+' || true)177
modules=${modules%%$'\n'*}178
[ -n "$modules" ] || fail "build log carried no module count"179
echo "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 them181
# `F1B_GATE_ONLY_ARM=1` fell through to the final summary line, which references182
# variables only later arms set, and died on `reported: unbound variable` under183
# `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 wrong185
# signature.186
[ "$only_arm" = "1" ] && { echo "F1B_GATE_ARM_1_GREEN"; exit 0; }188
# ---------------------------------------------------------------------------189
# ARM 2 -- the reader suite.190
#191
# The expected count is COUNTED FROM THE SUITE FILE. A suite that silently192
# stopped running half its cases would still print a green summary; comparing193
# the reported count against the file's own `(test "` forms is what makes the194
# green mean "all of them ran".195
# ---------------------------------------------------------------------------196
if should_run 2; then197
echo "--- arm 2: reader suite"198
suite=test/test-folio-doc-reader.sgl199
expected_tests=$(grep -cE '^\(test "' "$suite")200
[ "$expected_tests" -ge 25 ] || fail "suite declares only $expected_tests tests; this phase needs more"201
suite_log="$work/suite.log"202
if ! "$sigil" -L build/dev/lib test --redirects "$redirects" "$suite" > "$suite_log" 2>&1; then203
tail -40 "$suite_log" >&2; fail "reader suite exited non-zero"204
fi205
reported=$(plain "$suite_log" | grep -oE 'Tests: +[0-9]+ passed' | grep -oE '[0-9]+' || true)206
reported=${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 the210
# failure path, not for `set -e` safety.211
#212
# An earlier version of this comment claimed the `&&` form "kills the gate on the213
# healthy path under set -e". MEASURED FALSE: bash exempts a failing left operand214
# 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 seven216
# places -- it could not reach its own green marker otherwise. An adversarial217
# review caught the contradiction between the comment and the code around it.218
#219
# Recorded rather than deleted: a wrong rationale attached to correct code is220
# how the next person "simplifies" the code to match the rationale.221
if plain "$suite_log" | grep -qE '[1-9][0-9]* failed'; then222
plain "$suite_log" | grep -E '[1-9][0-9]* failed' >&2223
fail "reader suite reported failures"224
fi225
# POSITIVE CONTROL for that absence: prove the pattern matches a real failure226
# summary. An absence check whose pattern never matched anything passes free.227
printf 'Tests: 29 passed, 1 failed (30 total)\n' > "$work/summary.control"228
grep -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"230
echo "arm 2 OK: $reported/$expected_tests tests passed, count derived from $suite"231
[ -n "$only_arm" ] && { echo "F1B_GATE_ARM_2_GREEN"; exit 0; }232
fi234
# ---------------------------------------------------------------------------235
# ARM 3 -- OBLIGATION (a): the refused witnesses, BY EXACT MEMBERSHIP.236
#237
# Fable's SD-P7 ruling: the model cannot require a stance on a concept it does238
# not own, so silence is made impossible in the CONSUMING language's gate. Exact239
# membership, not a subset check and not a count -- a fifth witness must fail as240
# loudly as a missing one.241
#242
# The ruling names `milestone` and `phase`. folio declares all FOUR structural243
# containers, because `note` and `project` are structural containers by folio's244
# own `folio-structural-container?` and a re-nested root launders exactly as245
# badly. Declaring two and leaving the other two to an undeclared predicate246
# property would keep half a known class in the silent position the ruling247
# exists to eliminate. Flagged for review as a deliberate upward deviation.248
# ---------------------------------------------------------------------------249
if should_run 3; then250
echo "--- arm 3: refused witnesses by EXACT MEMBERSHIP"251
probe_log="$work/profile.log"252
if ! "$sigil" -L build/dev/lib -L src -L test test/f1b-profile-probe.sgl > "$probe_log" 2>&1; then253
dump_probe_failure "$probe_log"; fail "profile probe exited non-zero"254
fi255
line=$(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"; }257
profile_checks=$(echo "$line" | awk '{print $2}' | cut -d= -f2)258
profile_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"262
plain "$probe_log" | grep '^F1B_REFUSED ' | awk '{print $2}' | sort > "$work/refused.actual"263
printf 'milestone\nnote\nphase\nproject\n' | sort > "$work/refused.expected"264
if ! cmp -s "$work/refused.actual" "$work/refused.expected"; then265
echo "declared refused witnesses:" >&2; cat "$work/refused.actual" >&2266
echo "required (EXACT membership):" >&2; cat "$work/refused.expected" >&2267
fail "the refused-witness set is not exactly {milestone, note, phase, project}"268
fi269
# POSITIVE CONTROLS, BOTH DIRECTIONS. An equality check that cannot report a270
# difference is not a membership assertion.271
printf 'milestone\nnote\nphase\n' | sort > "$work/refused.control-missing"272
cmp -s "$work/refused.actual" "$work/refused.control-missing" \273
&& fail "positive control: the comparator did not notice a MISSING witness"274
printf 'milestone\nnote\nphase\nproject\ntask\n' | sort > "$work/refused.control-extra"275
cmp -s "$work/refused.actual" "$work/refused.control-extra" \276
&& fail "positive control: the comparator did not notice an EXTRA witness"278
admitted_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"280
echo "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; }282
fi284
# ---------------------------------------------------------------------------285
# ARM 4 -- OBLIGATION (b): the section-child predicate DELEGATES.286
#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 a289
# profile dropping a whole construct and CANNOT see one narrowing WITHIN a kind290
# (an `aside` admitted, a `warning` refused). A predicate that DELEGATES to291
# `block-node?` makes within-kind narrowing structurally impossible; one that292
# re-enumerates reintroduces exactly the hole the floor was built to cover, and293
# the floor will not catch it. So the SPELLING is checked, not only the294
# behaviour.295
#296
# The forbidden token set is DERIVED from `block-node?`'s own disjuncts in297
# sigil-doc. Note the trap SD-P7 measured and paid for: `sed` ranges are298
# INCLUSIVE, so a range ending at the next definition includes that line's own299
# disjuncts and over-counts. This uses paren-balanced extraction instead, and300
# asserts the derived count is plausible before using it.301
# ---------------------------------------------------------------------------302
if should_run 4; then303
echo "--- arm 4: the section-child predicate delegates rather than re-enumerating"304
extract_definition() { # $1 file, $2 predicate name -> the whole balanced form305
awk -v name="$2" '306
index($0, "(define (" name " x)") { collecting = 1 }307
collecting {308
buf = buf " " $0309
line = $0310
n = gsub(/\(/, "(", line); m = gsub(/\)/, ")", line)311
depth += n - m312
if (depth <= 0) { print buf; exit }313
}314
' "$1"315
}316
core_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"318
core_kinds=$(echo "$core_def" | grep -oE '\([a-z][a-z0-9-]*\? x\)' | sed -E 's/^\(//; s/ x\)$//' | grep -v '^block-node?$' | sort -u)319
core_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"322
folio_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"324
echo "$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 name327
# ENDS IN `?` -- `admonition?`, `section?` -- so under ERE that trailing `?` is a328
# QUANTIFIER on the preceding character: `\(admonition? x\)` searches for329
# `(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.332
#333
# It was caught by the positive control below and by nothing else: the arm was334
# green, on a correct product, while asserting nothing whatsoever. Fixed-string335
# matching removes the class rather than escaping around it.336
reenumerated=""337
for k in $core_kinds; do338
echo "$folio_def" | grep -qF "($k x)" && reenumerated="$reenumerated $k"339
done340
[ -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 delegating344
# call HAS been expanded into its disjuncts, must report them. Without this an345
# empty `reenumerated` is equally consistent with "it delegates" and "the loop346
# never compared anything".347
cp src/folio/doc/profile.sgl "$control_dir/profile-expanded.sgl"348
expansion=$(echo "$core_kinds" | sed -E 's/^/(/; s/$/ x)/' | tr '\n' ' ')349
sed -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"351
control_def=$(extract_definition "$control_dir/profile-expanded.sgl" "folio-section-child?")352
control_hits=""353
for k in $core_kinds; do354
echo "$control_def" | grep -qF "($k x)" && control_hits="$control_hits $k"355
done356
[ -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, so360
# `(folio doc publication)` descends into `section` and nothing else. If a later361
# phase parameterizes a second container, that clause becomes incomplete and362
# 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.364
#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 the368
# dispatch cannot find the tenth; deriving the set can.369
#370
# Two source facts, both in sigil-doc's model:371
#372
# 1. A constructor that validates children with `require-block` accepts any373
# BLOCK node, and `section?` is a block node -- so it can transitively hold374
# folio nodes under the folio profile. Those are the direct holders.375
# 2. `require-list "make-X" <element>?` says X holds <element>. Closing over376
# those edges pulls in the aggregates: `item-list` holds `list-item`,377
# `table` holds `table-row`, `table-row` holds `table-cell`.378
#379
# Plus `section` itself, which is validated through the PROFILE rather than380
# through `require-block` and so appears in neither grep.381
#382
# `document` is then removed: it is the ROOT, lowered by383
# `folio-document->publication` directly rather than by `lower-child`.384
#385
# The previous version of this clause derived the PARAMETERIZED-constructor386
# count instead and asserted it was 1. That measured a true fact about SD-P7 and387
# an irrelevant one about this descent -- the same conflation as the product bug388
# it failed to catch, one instrument over. It is kept below, as a genuine but389
# DIFFERENT assertion, rather than deleted.390
model_sgl="$sigil_doc_dir/src/sigil/doc/model.sgl"391
direct_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"395
descent_set=$(printf '%s\nsection\n' "$direct_holders" | sort -u)396
# Close over the aggregate edges until stable.397
for _pass in 1 2 3 4; do398
while read -r holder element; do399
[ -n "$holder" ] || continue400
if echo "$descent_set" | grep -qx "$element"; then401
descent_set=$(printf '%s\n%s\n' "$descent_set" "$holder" | sort -u)402
fi403
done <<< "$(grep -oE 'require-list "make-[a-z-]+" [a-z-]+\?' "$model_sgl" \404
| sed -E 's/require-list "make-//; s/" / /; s/\?$//')"405
done406
# The root is lowered by `folio-document->publication`, not by `lower-child`.407
descent_set=$(echo "$descent_set" | grep -vx 'document')408
descent_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"411
missing_descent=""412
for kind in $descent_set; do413
grep -qF "((${kind}? child)" src/folio/doc/publication.sgl \414
|| missing_descent="$missing_descent $kind"415
done416
[ -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 genuinely419
# absent from the dispatch, or an empty `missing_descent` means nothing.420
grep -qF "((this-kind-cannot-exist? child)" src/folio/doc/publication.sgl \421
&& fail "positive control: the descent detector matched a kind that cannot exist"423
profiled_ctors=$(grep -oE 'make-[a-z-]+-with-profile' "$sigil_doc_dir/src/sigil/doc/model.sgl" | sort -u)424
profiled_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"427
echo "$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"429
grep -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"431
echo "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; }433
fi435
# ---------------------------------------------------------------------------436
# ARM 5 -- DELEGATION PARITY.437
#438
# The alignment is asserted BEFORE the probe, with a message saying how to439
# re-pad, so a future fixture edit cannot silently turn the strong comparison440
# (identical positions) into a weak one (identical shape).441
#442
# WHY THE SIGIL-SIDE FIXTURE IS `#lang sigil/doc` WITH AN `@article` ROOT, and443
# 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 put445
# `@chapter` behind the new genre language `#lang sigil/doc/book`. Both repairs446
# make the manuscript legal again; they are not equivalent here.447
#448
# 1. THE CLAIM. This arm's subject is folio delegating the BASE grammar. A449
# genre sibling is a different language, so re-heading the fixture would450
# leave the arm passing while testing something else -- the migration451
# hazard SD-P8 hit in its own corpus.452
# 2. THE ALIGNMENT. `#lang sigil/doc` and `#lang folio/doc` are both 15453
# bytes, which is what makes the shared body start at the same offset in454
# 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 actually457
# taken needs no re-padding at all.458
#459
# DO NOT rely on the byte-alignment check to catch a re-heading: it is a460
# 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.462
#463
# The rationale lives here rather than in the fixture because there is nowhere464
# in the fixture to put it: this arm fixes lines 1-2 at a byte total and465
# requires everything from line 3 to be byte-identical to the folio fixture, so466
# any comment added to one side would redden the arm that protects it.467
# ---------------------------------------------------------------------------468
if should_run 5; then469
echo "--- arm 5: delegation parity"470
# EACH SIDE'S LANGUAGE IS PINNED BY NAME, BEFORE ANY BYTE COUNT.471
#472
# The alignment check below is a SUM over two lines, so it cannot see a language473
# 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's476
# `document?` check still passes because the book genre also binds `document`477
# with multiplicity `one` -- and the arm goes green while comparing folio478
# against the GENRE grammar instead of the base grammar it names.479
#480
# That is this arm's own stated hazard (see item 1 in the header comment), and481
# until these two lines it was documented in prose and asserted nowhere. A guard482
# that is a comment is not a guard.483
sigil_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."486
folio_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."489
shifted_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."492
sigil_header=$(head -2 test/fixtures/f1b-parity-sigil.sgd | wc -c)493
folio_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.)"496
cmp -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 is499
# not a control for position sensitivity.500
shifted_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"503
cmp -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"506
parity_log="$work/parity.log"507
if ! "$sigil" -L build/dev/lib -L src -L test test/f1b-parity-probe.sgl > "$parity_log" 2>&1; then508
dump_probe_failure "$parity_log"; fail "parity probe exited non-zero"509
fi510
pline=$(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"; }512
p_failed=$(echo "$pline" | awk '{print $3}' | cut -d= -f2)513
p_bytes=$(echo "$pline" | awk '{print $4}' | cut -d= -f2)514
p_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. The519
# denominator is `(sigil doc author)`'s exported command set.520
author_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)522
author_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"524
echo "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; }526
fi528
# ---------------------------------------------------------------------------529
# ARM 6 -- THE PASSIVE NO-EXECUTION WITNESS.530
#531
# One manuscript per PROCESS, each with its own sentinel directory, so "no532
# marker" is a fact about one manuscript rather than a residue of another.533
# TWO detection channels (stdout markers, filesystem sentinels), each shown able534
# to fire IN THIS RUN by the control before any absence is allowed to mean535
# anything.536
#537
# REPORTED PER CHANNEL, NOT IN AGGREGATE, because one channel deliberately538
# survives: an expression in ARGUMENT POSITION still evaluates. The parser539
# splices argument syntax into the lowered call unchanged, so it runs before any540
# folio command sees a value. That hole is structural to `sigil/doc` argument541
# evaluation rather than specific to folio -- `@section["id" ...]` has it542
# identically -- and a fix confined to folio's eleven commands would be a partial543
# fix that LOOKS total. So it is PINNED HERE IN BOTH DIRECTIONS: if it ever544
# stops executing, this arm goes red and somebody has to update the guarantee's545
# wording rather than silently leaving an understated claim.546
# ---------------------------------------------------------------------------547
if should_run 6; then548
echo "--- arm 6: no-execution witness"549
# THE CONTROL AND THE SUBJECT MUST BE THE SAME MANUSCRIPT BUT FOR THEIR ROOT.550
#551
# Arm 5 pins its fixture pair byte-for-byte and says why; arm 6 did not, and an552
# adversarial review named the consequence: the "6 markers, 5 sentinels" this arm553
# reports are facts about the CONTROL file, presented as though they554
# characterised the file under test. Drop the macro and mid-prose tripwires from555
# the folio fixture, keep one author form, and every assertion below still556
# passes while the arm silently tests a third of what it claims.557
#558
# So the two are diffed here: identical except for the `#lang` line, the root559
# command line, and the `@end` line. The diff drops line 1 with `tail -n +2`, so560
# 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 was563
# right and the prose was wrong.)564
#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 that567
# PERMITS author forms, so its language is pinned by name here rather than left568
# to a comparison that is structurally blind to it -- the same gap arm 5 had.569
sigil_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."572
folio_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."575
witness_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."579
run_witness() { # $1 fixture stem -> writes $work/$1.log and $work/$1.sentinels580
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 || true585
ls "$dir" > "$work/$stem.sentinels" 2>/dev/null || true586
}587
run_witness witness-sigil588
run_witness witness-folio589
run_witness witness-argument591
# (a) THE POSITIVE CONTROL. Both channels must be shown able to fire.592
control_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"593
for marker in $control_markers; do594
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"; }596
done597
for sentinel in expr command macro midprose midprose-command; do598
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"; }600
done601
plain "$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 CONTROL604
# 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.606
plain "$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.610
plain "$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 tell614
# 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 reporting616
# "folio/doc refused the manuscript and fired neither channel". With the refusal617
# gone the manuscript is still refused -- its emitted `@(import (sigil fs))` fails618
# at module top level -- so no tripwire fires and every assertion above passes.619
#620
# The arm was conflating "it did not execute" with "it failed before it could621
# execute". Those are different facts and only the first is the claim.622
#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 wording625
# elsewhere: a direct `read-language-source` call, where the refusal is intact.626
# When [[t-2f2af1]] lands these merge; neither is deleted.627
cat > "$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)635
PROBE636
witness_reason=$("$sigil" -L build/dev/lib -L src -L test "$work/witness-reason.sgl" 2>&1 || true)637
echo "$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 would640
# satisfy the line above while leaving the count unasserted.641
echo "$witness_reason" | grep -qE 'found [1-9][0-9]*' \642
|| { echo "$witness_reason" >&2; fail "the author-form refusal named no count"; }643
if plain "$work/witness-folio.log" | grep -q 'F1B-WITNESS-'; then644
plain "$work/witness-folio.log" | grep 'F1B-WITNESS-' >&2645
fail "a tripwire fired under #lang folio/doc"646
fi647
if [ -s "$work/witness-folio.sentinels" ]; then648
cat "$work/witness-folio.sentinels" >&2649
fail "a filesystem sentinel was written under #lang folio/doc"650
fi652
# (c) THE PINNED LIMIT. Argument position still evaluates -- asserted in BOTH653
# directions so a future change cannot silently move it either way.654
plain "$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"; }656
plain "$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 stopped659
# isolating the one channel it exists to isolate.660
other=$(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"662
echo "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; }664
fi666
# ---------------------------------------------------------------------------667
# ARM 7 -- PUBLICATION FALLBACK, FROM A READ MANUSCRIPT.668
#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 = 2671
# printed nothing and exited 0. So a green from this probe is worthless until672
# the SAME probe has been shown to go red on the SAME run.673
# ---------------------------------------------------------------------------674
if should_run 7; then675
echo "--- arm 7: publication fallback from a read manuscript"676
pub_log="$work/publication.log"677
if ! "$sigil" -L build/dev/lib -L src -L test test/f1b-publication-probe.sgl > "$pub_log" 2>&1; then678
dump_probe_failure "$pub_log"; fail "publication probe exited non-zero"679
fi680
publine=$(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"; }682
pub_checks=$(echo "$publine" | awk '{print $2}' | cut -d= -f2)683
pub_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"686
selftest_log="$work/publication-selftest.log"687
if F1B_PROBE_SELFTEST=1 "$sigil" -L build/dev/lib -L src -L test \688
test/f1b-publication-probe.sgl > "$selftest_log" 2>&1; then689
fail "publication probe self-test EXITED 0 with a failure injected -- the harness cannot go red"690
fi691
plain "$selftest_log" | grep -qE 'F1B_PUBLICATION_PROBE checks=[0-9]+ failed=1$' \692
|| fail "publication probe self-test did not report exactly one failure"693
echo "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; }695
fi697
# ---------------------------------------------------------------------------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 comparison700
# cannot be satisfied by the self-test, and vice versa.701
# ---------------------------------------------------------------------------702
if should_run 8; then703
echo "--- arm 8: probe harnesses go red on injection"704
for probe in f1b-profile-probe f1b-parity-probe; do705
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; then707
fail "$probe self-test EXITED 0 with a failure injected -- the harness cannot go red"708
fi709
# `failed=1( |$)`, not `failed=1$`: the parity probe's summary continues with710
# `bytes=`, `children=` and `kinds=` after the field, so an end-anchored711
# pattern matched nothing and reported a working harness as broken. Anchoring712
# to the FIELD rather than to the line also keeps `failed=1` from matching713
# `failed=10`.714
plain "$log" | grep -qE 'failed=1( |$)' \715
|| { tail -10 "$log" >&2; fail "$probe self-test did not report exactly one failure"; }716
done717
echo "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; }719
fi721
# ---------------------------------------------------------------------------722
# ARM 9 -- READER-TIER PASSIVITY, by import allowlist.723
#724
# A DIFFERENT TIER FROM F1a's arm 6, deliberately. The semantic core may not725
# reach the reader or the author runtime; the reader tier must reach both and726
# nothing further. Two allowlists, each tight, rather than one loose one.727
# ---------------------------------------------------------------------------728
if should_run 9; then729
echo "--- arm 9: reader-tier passivity"730
reader_files="src/folio/doc/reader.sgl src/folio/doc/author.sgl src/folio/doc/profile.sgl"731
extract_imports() {732
sed '/^[[:space:]]*;/d' "$1" | awk '733
/\(import/ { collecting = 1; buf = "" }734
collecting {735
buf = buf " " $0736
line = $0737
n = gsub(/\(/, "(", line); m = gsub(/\)/, ")", line)738
depth += n - m739
if (depth <= 0) { print buf; collecting = 0; depth = 0 }740
}741
' | grep -oE '\([a-z][a-z0-9-]*( [a-z0-9-]+)+\)' | sort -u742
}743
allowed='^\((sigil (core|struct|syntax))\)$'744
allowed="$allowed"'|^\(sigil doc\)$|^\(sigil doc (model|traversal|reader)\)$'745
allowed="$allowed"'|^\(sigil doc private author-runtime\)$'746
allowed="$allowed"'|^\(sigil doc author\)$'747
allowed="$allowed"'|^\(folio doc( [a-z-]+)*\)$'748
for f in $reader_files; do749
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; do752
echo "$lib" | grep -qE "$allowed" || fail "$f imports $lib, which is outside the reader-tier allowlist"753
done <<< "$imports"754
done755
# POSITIVE CONTROL: the extractor must SEE a forbidden import when one is there.756
cp src/folio/doc/reader.sgl "$control_dir/reader-planted.sgl"757
sed -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"759
extract_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.762
for 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)'; do765
echo "$forbidden_lib" | grep -qE "$allowed" \766
&& fail "positive control: the allowlist ACCEPTS $forbidden_lib -- it is not restricting anything"767
done768
for permitted_lib in '(sigil core)' '(sigil doc reader)' '(sigil doc private author-runtime)' \769
'(sigil doc model)' '(folio doc model)' '(folio doc profile)'; do770
echo "$permitted_lib" | grep -qE "$allowed" \771
|| fail "positive control: the allowlist REFUSES $permitted_lib, which the reader tier legitimately needs"772
done773
echo "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; }775
fi777
# ---------------------------------------------------------------------------778
# ARM 10 -- the reader and F2a's inspector agree, and disagree where they must.779
#780
# AGREE on identity extraction: for a manuscript this reader ACCEPTS, the781
# inspector must report zero `missing-node-id`.782
# DISAGREE on duplicates, in the intended direction: the reader REFUSES, the783
# inspector DESCRIBES. A knowledge base indexes half-written notes as its normal784
# case, so the inspector must keep working on documents the reader rejects -- or785
# nothing can ever report the problem. Asserted rather than assumed.786
# ---------------------------------------------------------------------------787
if should_run 10; then788
echo "--- arm 10: reader/inspector layering"789
# The agreement half rides in the suite (it needs the descriptor API); here the790
# DISAGREEMENT is asserted, because it is the half that would silently rot if791
# the two layers were ever "harmonised".792
#793
# BOTH SIDES ARE ASSERTED IN THIS ARM. An earlier version checked only the794
# inspector, which made the arm's own claim -- "the layers disagree in the795
# intended direction" -- half-evidenced: deleting the reader's refusal entirely796
# would have left it green. The reader side is therefore re-asserted here even797
# 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 makes799
# the compiler print its help text and exit 0 -- which this arm read as "the800
# reader did not refuse". A gate that mistakes a usage error for a product801
# verdict is the shape that produced three false greens in this estate.802
cat > "$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)807
PROBE808
reader_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.810
#811
# The reader assertion used to run here, before the inspector probe -- so any812
# leg that made the reader stop refusing reddened the arm before the three813
# inspector clauses were ever evaluated, and those clauses (the half this arm's814
# comment calls the one that would silently rot) had nothing showing they can815
# fire. Named by an adversarial review. Ordering the independent half first816
# lets each half be reddened by its own leg: S17 removes the duplicate from the817
# fixture and reddens the inspector clauses; S14 neuters the reader and reddens818
# the reader clause below.819
dup_log="$work/dup.log"820
cat > "$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 dups826
(let loop ((ds (descriptor-diagnostics d)) (n 0))827
(if (null? ds) n828
(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)833
PROBE834
if ! "$sigil" -L build/dev/lib -L src -L test "$work/dup-probe.sgl" > "$dup_log" 2>&1; then835
tail -20 "$dup_log" >&2; fail "inspector probe exited non-zero"836
fi837
insp=$(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"; }839
i_parsed=$(echo "$insp" | awk '{print $2}' | cut -d= -f2)840
i_dups=$(echo "$insp" | awk '{print $3}' | cut -d= -f2)841
i_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.846
echo "$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"; }848
echo "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; }850
fi852
# ---------------------------------------------------------------------------853
# ARM 11 -- SUITE ARITHMETIC.854
#855
# A bare "N passed" cannot show that no pre-existing arm was silently lost or856
# gained. The arithmetic can. The baseline was MEASURED on folio master before857
# 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
# ---------------------------------------------------------------------------860
if should_run 11; then861
echo "--- arm 11: suite arithmetic"862
baseline_tests=351863
baseline_files=22864
added_tests=$(grep -cE '^\(test "' test/test-folio-doc-reader.sgl)865
full_log="$work/fullsuite.log"866
if ! "$sigil" -L build/dev/lib test --redirects "$redirects" > "$full_log" 2>&1; then867
tail -40 "$full_log" >&2; fail "the full suite exited non-zero"868
fi869
total=$(plain "$full_log" | grep -oE 'Tests: +[0-9]+ passed' | grep -oE '[0-9]+' | head -1)870
files=$(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"; }872
expected_total=$((baseline_tests + added_tests))873
expected_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"878
if plain "$full_log" | grep -qE '[1-9][0-9]* failed'; then879
plain "$full_log" | grep -E '[1-9][0-9]* failed' >&2880
fail "the full suite reported failures"881
fi882
# The two suites this phase is downstream of must be UNCHANGED. A movement in883
# either is a finding, not a rounding.884
for pair in "test-folio-doc-model 36" "test-folio-doc-inspect 37"; do885
set -- $pair886
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"888
done889
echo "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; }891
fi893
echo894
echo "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"