AtlatestRepositoryfolio

folio / tree / testgate-f1a.sh

1#!/usr/bin/env bash
2#
3# F1a -- the gate for the frozen `folio/doc` semantic model.
4#
5# READ THIS BEFORE EDITING AN ASSERTION.
6#
7# Every count this gate checks is DERIVED FROM THE ARTIFACT it describes, never
8# written in as a literal. Adding a test, a record or a node kind must not
9# require editing a number here; if it does, the check has stopped describing
10# its subject and has become a second thing to keep in sync. That drift is the
11# single most common defect in this estate's gates.
13# Every ABSENCE assertion carries a POSITIVE CONTROL that plants the forbidden
14# thing in a throwaway copy and requires the pattern to fire on it. An absence
15# check with no positive control passes for free -- when the tool is missing,
16# when the path is wrong, when the pattern never matched anything in its life.
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. A sabotage driver
20# whose undo can be skipped by an early exit leaves the damage behind.
22# WHAT THIS GATE DELIBERATELY DOES NOT CHECK: the live-estate census behind the
23# document-address grammar. That measurement (2620 note names + 49 project
24# names, 1 rejection) is a DESIGN-TIME derivation recorded with its command in
25# the session log, not a standing check. Pinning a snapshot of a growing estate
26# into a gate produces a check that describes last week and fails on growth.
27# What is durable is the grammar's BEHAVIOUR per input class, and that is
28# asserted exhaustively in the model suite instead.
29set -euo pipefail
31repo_dir="$(cd "$(dirname "$0")/.." && pwd)"
32cd "$repo_dir"
34sigil="${SIGIL_F1A_COMPILER:-$repo_dir/../task-sigil-language-readers/build/dev/bin/sigil}"
35redirects="${SIGIL_F1A_REDIRECTS:-$repo_dir/dev-redirects.sgl}"
36sigil_doc_dir="${SIGIL_F1A_DOC_DIR:-$repo_dir/../sigil-doc}"
37cache_dir="${XDG_CACHE_HOME:-$(mktemp -d)}"
38export XDG_CACHE_HOME="$cache_dir"
40# The `test` subcommand takes `--redirects` as a flag; a DIRECT script run has no
41# such flag and reads the environment instead. Without this, arms 3 and 4 die
42# with "Missing dependencies" listing every package -- which is a real red, but
43# for a setup reason rather than anything about the product. Caught because the
44# gate was run as a script rather than trusted to match the commands I had been
45# running by hand, where this was set in the shell.
46export SIGIL_REDIRECTS="$redirects"
48fail() { echo "F1A GATE RED: $*" >&2; exit 1; }
50# THE COMPILER AND TEST RUNNER COLOUR THEIR OUTPUT, AND THE ESCAPES SIT BETWEEN
51# THE FIELDS A GATE WANTS TO READ. The summary line is really:
53# Tests: ^[[32m30 passed^[[39m^[[2m (30 total)^[[22m
55# so a pattern as innocent as `Tests: +[0-9]+ passed` matches NOTHING -- the
56# escape sequence sits between the spaces and the digits. That cost two full
57# gate runs here. It happened to fail safe; a pattern like `grep -c passed`
58# would have quietly returned the wrong number instead. Every log this gate
59# parses goes through `plain` first.
60plain() { sed 's/\x1b\[[0-9;]*m//g' "$1"; }
62echo "=== F1a gate ==============================================="
63echo "repo : $repo_dir @ $(git rev-parse HEAD)"
64echo "compiler : $($sigil --version 2>&1 | tail -1)"
65# The evidence for this phase was produced against sigil-doc MASTER through a
66# dev redirect, because no RELEASED sigil-doc contains `url-link` yet (v0.1.1 =
67# 42a0686 predates SD-P2). Recording the SHA here means a later reader cannot
68# mistake this for evidence against a tag.
69echo "sigil-doc : $(git -C "$sigil_doc_dir" rev-parse HEAD) (via dev-redirects, NOT a release)"
70sigil_doc_dirty=$(git -C "$sigil_doc_dir" status --porcelain | wc -l)
71echo "sigil-doc dirty : $sigil_doc_dirty files"
72echo "============================================================"
73# Printing the SHA is not the same as depending on it. Because every green
74# result here is produced through a redirect to a local sigil-doc WORKING TREE,
75# an uncommitted change there is an unrecorded input to this whole gate -- the
76# resolved-dependency-versions rule, one layer down. Refuse rather than produce
77# evidence nobody can reproduce.
78if [ "$sigil_doc_dirty" != "0" ]; then
79 git -C "$sigil_doc_dir" status --porcelain >&2
80 fail "sigil-doc has $sigil_doc_dirty uncommitted file(s); evidence produced against it is not reproducible"
81fi
83# ---------------------------------------------------------------------------
84# ARM 1 -- the product still builds with the new dependency.
86# Asserted on the ARTIFACT, not on an exit code: this command is often piped
87# while iterating, and a pipeline reports the last stage's status. "Bundle
88# created" is a claim only a finished build makes.
89# ---------------------------------------------------------------------------
90echo "--- arm 1: build"
91build_log=$(mktemp)
92if ! "$sigil" build --redirects "$redirects" > "$build_log" 2>&1; then
93 tail -30 "$build_log" >&2; fail "build exited non-zero"
94fi
95plain "$build_log" | grep -q "Bundle created" || { tail -30 "$build_log" >&2; fail "build produced no bundle"; }
96modules=$(plain "$build_log" | grep -oE '^ +[0-9]+ modules' | grep -oE '[0-9]+' || true)
97modules=${modules%%$'\n'*}
98[ -n "$modules" ] || fail "build log carried no module count"
99echo "arm 1 OK: bundle built, ${modules} modules"
101# ---------------------------------------------------------------------------
102# ARM 2 -- the model suite.
104# The expected test count is COUNTED FROM THE SUITE FILE. A suite that silently
105# stopped running half its cases would still print a green summary; comparing
106# the reported count against the file's own `(test "` forms is what makes the
107# green mean "all of them ran".
108# ---------------------------------------------------------------------------
109echo "--- arm 2: model suite"
110suite=test/test-folio-doc-model.sgl
111expected_tests=$(grep -cE '^\(test "' "$suite")
112[ "$expected_tests" -gt 20 ] || fail "suite declares only $expected_tests tests; expected the frozen vocabulary to need more"
113suite_log=$(mktemp)
114if ! "$sigil" -L build/dev/lib test --redirects "$redirects" "$suite" > "$suite_log" 2>&1; then
115 tail -30 "$suite_log" >&2; fail "model suite exited non-zero"
116fi
117reported=$(plain "$suite_log" | grep -oE 'Tests: +[0-9]+ passed' | grep -oE '[0-9]+' || true)
118reported=${reported%%$'\n'*}
119[ -n "$reported" ] || { tail -20 "$suite_log" >&2; fail "model suite printed no test count"; }
120[ "$reported" = "$expected_tests" ] || fail "suite defines $expected_tests tests but only $reported ran/passed"
121# NOT `grep -q … && fail …`. Under `set -e` that form kills the gate on the
122# HEALTHY path: grep finds nothing, returns 1, and the && list's status is 1.
123# It cost a full run here and it fails in the safe direction only by luck --
124# the same construct guarding a benign action would have been a silent pass.
125# `[1-9][0-9]*` rather than `[0-9]+` so a hypothetical "0 failed" summary is
126# not read as a failure.
127if plain "$suite_log" | grep -qE '[1-9][0-9]* failed'; then
128 plain "$suite_log" | grep -E '[1-9][0-9]* failed' >&2
129 fail "model suite reported failures"
130fi
131# POSITIVE CONTROL for the absence assertion above: prove the pattern matches
132# the runner's real failure summary. An absence check whose pattern never
133# matched anything passes for free, which is this gate's own stated rule.
134printf 'Tests: 29 passed, 1 failed (30 total)\n' > "$suite_log.control"
135grep -qE '[1-9][0-9]* failed' "$suite_log.control" \
136 || fail "positive control: the failure pattern does not match the runner's real failure summary"
137echo "arm 2 OK: $reported/$expected_tests tests passed, count derived from $suite"
139# ---------------------------------------------------------------------------
140# ARM 3 -- publication fallback, and PROOF ITS HARNESS CAN FAIL.
142# The probe is a direct script with a hand-rolled harness, because `(sigil test)`
143# forms are INERT in a direct run -- measured: a file asserting 1 = 2 printed
144# nothing and exited 0. So a green from this probe is worthless until the same
145# probe has been shown to go red on the same run. That is the self-test leg.
146# ---------------------------------------------------------------------------
147echo "--- arm 3: publication fallback"
148probe_log=$(mktemp)
149if ! "$sigil" -L build/dev/lib -L src -L test test/f1a-publication-probe.sgl > "$probe_log" 2>&1; then
150 tail -20 "$probe_log" >&2; fail "publication probe exited non-zero"
151fi
152line=$(plain "$probe_log" | grep -oE 'F1A_PUBLICATION_PROBE checks=[0-9]+ failed=[0-9]+') \
153 || { tail -20 "$probe_log" >&2; fail "publication probe printed no result line"; }
154checks=$(echo "$line" | grep -oE 'checks=[0-9]+' | grep -oE '[0-9]+')
155failed=$(echo "$line" | grep -oE 'failed=[0-9]+' | grep -oE '[0-9]+')
156[ "$failed" = "0" ] || fail "publication probe reported $failed failed checks"
157[ "$checks" -ge 50 ] || fail "publication probe ran only $checks checks; it is not exercising three renderings"
159selftest_log=$(mktemp)
160if F1A_PROBE_SELFTEST=1 "$sigil" -L build/dev/lib -L src -L test \
161 test/f1a-publication-probe.sgl > "$selftest_log" 2>&1; then
162 fail "publication probe self-test EXITED 0 with a failure injected -- the harness cannot go red"
163fi
164plain "$selftest_log" | grep -qE 'F1A_PUBLICATION_PROBE checks=[0-9]+ failed=1$' \
165 || fail "publication probe self-test did not report exactly one failure"
166echo "arm 3 OK: $checks checks green, and the harness demonstrably goes red on injection"
168# ---------------------------------------------------------------------------
169# ARM 4 -- vocabulary coverage, both sides derived.
171# The kind list comes from the model's own `define-struct` lines; the exercised
172# list comes from the pressure manuscript's event stream. Adding a record
173# without adding it to the manuscript fails here rather than shipping an
174# unexercised node. Neither side is a list somebody maintains by hand.
175# ---------------------------------------------------------------------------
176echo "--- arm 4: vocabulary coverage"
177kinds=$(grep -oE '^ *\(define-struct [a-z-]+-record' src/folio/doc/model.sgl \
178 | sed -E 's/.*\(define-struct //; s/-record$//' | sort)
179kind_count=$(echo "$kinds" | wc -l)
180[ "$kind_count" -ge 11 ] || fail "only $kind_count records found in the model; the derivation is not matching"
181cov_log=$(mktemp)
182if ! "$sigil" -L build/dev/lib -L src -L test test/f1a-coverage-probe.sgl > "$cov_log" 2>&1; then
183 tail -20 "$cov_log" >&2; fail "coverage probe exited non-zero"
184fi
185plain "$cov_log" | grep -q 'F1A_COVERAGE_PROBE kinds=' || fail "coverage probe printed no result line"
186detect_missing() { # $1 = newline-separated kind list; echoes those absent from $cov_log
187 local out=""
188 for k in $1; do
189 plain "$cov_log" | grep -qE "^F1A_KIND ${k}\$" || out="$out $k"
190 done
191 echo "$out"
193missing=$(detect_missing "$kinds")
194[ -z "$missing" ] || fail "the pressure manuscript never exercises:$missing"
195# POSITIVE CONTROL: run the SAME detector over a list containing a kind that
196# cannot possibly be present, and require it to be reported. Without this, an
197# empty `missing` is equally consistent with "everything is covered" and "the
198# loop never compared anything".
199control_missing=$(detect_missing "$(printf '%s\nthis-kind-cannot-exist\n' "$kinds")")
200case "$control_missing" in
201 *this-kind-cannot-exist*) : ;;
202 *) fail "positive control: the coverage detector did not report a kind that is definitely absent" ;;
203esac
204echo "arm 4 OK: all $kind_count model records exercised; detector proven to report an absent kind"
206# ---------------------------------------------------------------------------
207# ARM 5 -- no SQLite / MCP / Slate vocabulary in the semantic core.
209# Scope is `src/folio/doc/**` and that scoping is the whole point: the rest of
210# this repository is a working MCP server and is FULL of these words. The
211# positive control below plants each forbidden word into a throwaway copy and
212# requires the pattern to fire, which is what separates "the core is clean" from
213# "my pattern never matched anything".
214# ---------------------------------------------------------------------------
215echo "--- arm 5: no backend/runtime vocabulary leakage"
216core_files=$(find src/folio/doc.sgl src/folio/doc -type f -name '*.sgl' | sort)
217core_count=$(echo "$core_files" | wc -l)
218[ "$core_count" -ge 6 ] || fail "expected at least 6 semantic-core files, found $core_count"
219for f in $core_files; do [ -f "$f" ] || fail "core file $f does not exist"; done
220# `\bslate\b`, not bare `slate`: the unanchored form matches "translate".
221# Safe direction (it can only over-match), fixed anyway -- a pattern that
222# fires on an innocent word teaches people to loosen the arm.
223forbidden='sqlite|fts5|json-rpc|jsonrpc|\bmcp\b|\bslate\b'
224if echo "$core_files" | xargs grep -rniE "$forbidden" ; then
225 fail "backend/runtime vocabulary leaked into the semantic core"
226fi
227# positive control, in a copy -- nothing to restore, so nothing to forget
228control_dir=$(mktemp -d)
229cp -r src/folio/doc "$control_dir/doc"
230for word in sqlite fts5 json-rpc mcp slate; do
231 echo ";; planted $word" >> "$control_dir/doc/model.sgl"
232 grep -rqiE "$forbidden" "$control_dir/doc/model.sgl" \
233 || fail "positive control: the leakage pattern did NOT fire on planted '$word'"
234 sed -i '$ d' "$control_dir/doc/model.sgl"
235done
236echo "arm 5 OK: $core_count core files clean; pattern proven to fire on all 5 planted words"
238# ---------------------------------------------------------------------------
239# ARM 6 -- passivity by import allowlist.
241# The model may reach the language, structs, source locations and sigil-doc, and
242# nothing else. No filesystem, no process, no network, no shell. Import forms
243# are extracted with balanced-paren awk rather than by grepping for library-like
244# tokens, so a library named in a COMMENT cannot satisfy or trip this arm.
245# ---------------------------------------------------------------------------
246echo "--- arm 6: passivity (import allowlist)"
247# Comments are stripped BEFORE extraction. The previous version claimed a
248# library named in a comment "cannot satisfy or trip this arm"; review
249# demonstrated the opposite by putting `(sigil process)` in a comment on a line
250# containing `(import`, and the extractor emitted it. That is the same
251# self-matching shape arm 7 was already fixed for.
253# The token filter deliberately matches ANY `(word ...)` library form, not just
254# sigil/folio ones, so an import from an unexpected namespace is SEEN and then
255# refused by the allowlist rather than being invisible to both.
256extract_imports() {
257 sed '/^[[:space:]]*;/d' "$1" | awk '
258 /\(import/ { collecting = 1; buf = "" }
259 collecting {
260 buf = buf " " $0
261 n = gsub(/\(/, "(", $0); m = gsub(/\)/, ")", $0)
262 depth += n - m
263 if (depth <= 0) { print buf; collecting = 0; depth = 0 }
264 }
265 ' | grep -oE '\([a-z][a-z0-9-]*( [a-z0-9-]+)+\)' | sort -u
267# `(sigil doc …)` is NOT blanket-allowed, and this is an EXACT list rather than
268# a namespace pattern. `(sigil doc verify)` reaches `(sigil shell)` and
269# `(sigil process)`, and `presentation`, `render html`, `render typst` and
270# `print` all reach it in turn -- so allowing the namespace would let the
271# semantic core import a subprocess runner while this arm stayed green. Only the
272# four sigil-doc modules the core actually needs are listed; anything else,
273# inside that namespace or outside it, is refused.
274# WIDENED BY F1b, BY EXACTLY TWO ENTRIES, and the justification is a measurement
275# rather than an argument. `#lang folio/doc` resolves to `(folio doc reader)`,
276# which FORCES `src/folio/doc/reader.sgl` into the directory this arm scans; that
277# file must import `(sigil doc reader)`, and `src/folio/doc/author.sgl` must
278# import `(sigil doc private author-runtime)` to bind the three names the parser
279# emits by bare name. Neither was on this list, so the arm failed -- correctly,
280# by its own spec, on a correct tree.
282# THE MEASUREMENT: `test/f2a-import-closure.py` walked the transitive closure of
283# `(sigil doc reader)` over `../sigil-doc/src`, `../sigil/packages/sigil-stdlib/src`
284# and `src`. Zero unresolvable libraries; the only forbidden modules reachable
285# are the three EVALUATION-SIDE entries F2a forbids by design (the author
286# runtime, `(sigil doc model)`, `(sigil doc author)`). NONE of `(sigil fs)`,
287# `(sigil io)`, `(sigil socket)`, `(sigil process)`, `(sigil shell)` or
288# `(sigil doc verify)` is reachable. The passivity claim this arm protects is
289# therefore intact.
291# The reader TIER has its own, separately tight allowlist in
292# `test/gate-f1b.sh` arm 9. Two narrow lists, not one loose one.
293# AND FOUR MORE THAT WERE ALREADY BROKEN, by F2a's merge (`7d838e2`), NOT by
294# F1b. `src/folio/doc/inspect.sgl` imports `(sigil doc inspect)`,
295# `(sigil list)`, `(sigil math)` and `(sigil string)`, none of which was on this
296# list -- so this arm has been RED ON FOLIO MASTER since that commit landed, on
297# four independent counts, and arm 5 has been red on a fifth (a comment naming a
298# specific editor and bytecode target). Verified against a pristine
299# `git archive` of master extracted to /tmp, not inferred from this worktree.
300# F1a's own recorded green line says `core_files=7`; master has 8. That count
301# moving is independent proof nobody re-ran the arm after F2a landed.
303# Each is legitimately passive: `(sigil doc inspect)` is SD-P4's passive parse
304# surface, whose no-I/O closure `test/gate-f2a.sh` arm 8 walks and enforces;
305# `(sigil list)`, `(sigil math)` and `(sigil string)` are pure data stdlib.
306allowed='^\((sigil (core|struct|syntax|list|math|string))\)$'
307allowed="$allowed"'|^\(sigil doc\)$|^\(sigil doc (model|traversal|reader|inspect)\)$'
308allowed="$allowed"'|^\(sigil doc private author-runtime\)$'
309# `(sigil doc author)` is admitted for a reason worth reading, because the
310# obvious one is wrong. `src/folio/doc/reader.sgl` does NOT import it. What
311# this extractor sees is the PRELUDE the reader EMITS -- a quoted datum,
312# `'(import (folio doc) (sigil doc author) (folio doc author))` -- and the
313# extractor cannot tell a real import form from a quoted one that happens to
314# look like it. That is a genuine limitation of this instrument, newly exposed
315# by the first module whose JOB is to emit import forms; it is recorded here
316# rather than worked around silently.
318# It is admitted anyway, and honestly: the edge is REAL, merely deferred. A
319# folio manuscript's scope does link `(sigil doc author)` at evaluation time, by
320# design -- that is what "reuse the author vocabulary rather than fork it" means.
321# Refusing it would state something false about the dependency graph in order to
322# keep a pattern tidy.
323allowed="$allowed"'|^\(sigil doc author\)$'
324allowed="$allowed"'|^\(sigil doc render (plain|debug)\)$'
325allowed="$allowed"'|^\(folio doc( [a-z-]+)*\)$'
326for f in $core_files; do
327 imports=$(extract_imports "$f")
328 [ -n "$imports" ] || fail "no import form extracted from $f -- the extractor is broken, not the file"
329 while IFS= read -r lib; do
330 echo "$lib" | grep -qE "$allowed" || fail "$f imports $lib, which is outside the passive allowlist"
331 done <<< "$imports"
332done
333# POSITIVE CONTROL: the extractor must SEE a forbidden import when one is there.
334cp -r src/folio/doc "$control_dir/doc2"
335sed -i 's/(import (sigil core) (sigil struct) (sigil syntax) (sigil doc model))/(import (sigil core) (sigil struct) (sigil syntax) (sigil doc model) (sigil process))/' \
336 "$control_dir/doc2/model.sgl"
337planted=$(extract_imports "$control_dir/doc2/model.sgl")
338echo "$planted" | grep -q '(sigil process)' \
339 || fail "positive control: the import extractor did not see a planted (sigil process)"
340for forbidden_lib in '(sigil process)' '(sigil shell)' '(sigil fs)' '(sigil sqlite)' \
341 '(sigil doc verify)' '(sigil doc presentation)' '(sigil doc render html)' \
342 '(scheme file)' '(srfi 1)'; do
343 if echo "$forbidden_lib" | grep -qE "$allowed"; then
344 fail "positive control: the allowlist ACCEPTS $forbidden_lib -- it is not restricting anything"
345 fi
346done
347# ...and the converse: it must still ACCEPT what the core legitimately uses, or
348# the arm is just refusing everything and calling that security.
349for permitted_lib in '(sigil core)' '(sigil doc model)' '(sigil doc render plain)' '(folio doc model)' \
350 '(sigil doc reader)' '(sigil doc private author-runtime)'; do
351 echo "$permitted_lib" | grep -qE "$allowed" \
352 || fail "positive control: the allowlist REFUSES $permitted_lib, which the core legitimately needs"
353done
354# THE WIDENING IS ASSERTED IN BOTH DIRECTIONS. A widened allowlist that no
355# longer refuses anything is the failure mode of widening one, so the refusal
356# controls above are not merely retained: these two neighbours of the newly
357# admitted entries must STILL be refused, or the two additions have been written
358# as a namespace pattern instead of as exact names.
359for near_miss in '(sigil doc private parser)' '(sigil doc render typst)' '(sigil doc print)'; do
360 echo "$near_miss" | grep -qE "$allowed" \
361 && fail "positive control: widening for the reader tier also admitted $near_miss -- the new entries are not exact"
362done
363echo "arm 6 OK: every import in $core_count files is inside the allowlist; extractor proven to see a planted violation"
365# ---------------------------------------------------------------------------
366# ARM 7 -- `phase` stays a non-executable ordered container.
368# D3 defers a workflow engine. An engine does not arrive as a commit called
369# "add a workflow engine"; it arrives as one innocuous field. So the record's
370# field set is pinned STRUCTURALLY, derived from the source, and the whole core
371# is checked for execution vocabulary.
372# ---------------------------------------------------------------------------
373echo "--- arm 7: phase is a non-executable ordered container"
374phase_line=$(grep -E '^ *\(define-struct phase-record' src/folio/doc/model.sgl) \
375 || fail "no phase-record definition found"
376phase_fields=$(echo "$phase_line" | grep -oE '\([a-z-]+\)' | sed -E 's/[()]//g' | grep -v '^define-struct$')
377phase_field_list=$(echo "$phase_fields" | tr '\n' ' ' | sed -E 's/ +$//')
378[ "$phase_field_list" = "id title status order children location" ] \
379 || fail "phase-record fields are now '$phase_field_list'; D3 freezes exactly 'id title status order children location'"
380# THIS ARM SCANS CODE, NOT PROSE, AND THAT DISTINCTION IS LOAD-BEARING.
382# On its first run it went RED against two of this model's own COMMENTS -- the
383# lines explaining that a phase "carries no command, no runner, no retry policy"
384# and that an engine "arrives one innocuous field at a time (a command, a
385# runner, a retry count)". That is a check matching its own explanatory text: a
386# closed loop, invisible from inside because everything it reports is internally
387# consistent. The comments are correct and must stay; it is the arm that was
388# wrong to read them.
390# Full-line comments only are stripped -- NOT everything after the first `;` --
391# because `publication.sgl` contains a semicolon inside a string literal
392# ("…this edition; pass #f") and a blunter filter would silently truncate real
393# code. The positive control below therefore plants CODE, not a comment; a
394# control that planted a comment would now prove nothing.
396# Arm 5 above deliberately does the OPPOSITE and scans comments too, because
397# there the concern is the vocabulary appearing in the semantic core at all.
398code_only() { sed '/^[[:space:]]*;/d' "$1"; }
399# `\bcommand\b` WAS in this set and was REMOVED by F1b, deliberately and with a
400# reason rather than to make a red go away.
402# It was added because the model's own warning names "a command, a runner, a
403# retry count" as how a workflow engine creeps in one innocuous field at a time.
404# That reasoning is sound for `model.sgl`, where `command` could only mean
405# something executable. It stopped being sound the moment a READER joined this
406# directory: in `sigil/doc` a "command" is a MANUSCRIPT COMMAND -- `@p`, `@task`,
407# `@section` -- and the author runtime's own signatures are
408# `author-normalize value loc command` and `author-flatten values loc command`.
409# The word now has two meanings here and the pattern cannot tell them apart, so
410# it matches `(define (children command values))` and calls a manuscript command
411# an execution engine.
413# The alternative was renaming the parameter, which was considered and REFUSED:
414# contorting product code to satisfy a gate's vocabulary corrupts the thing being
415# measured in order to protect the measurement.
417# WHAT STILL PROTECTS D3, so this is a narrowing and not a hole: `runner`,
418# `retry`, `exit-status`, `subprocess` and `spawn` have no second meaning in this
419# directory and all five remain refused; arm 7's FIELD-SET assertion above pins
420# `phase-record` to exactly six fields structurally, which is the check that
421# actually stops an engine arriving one field at a time; and the positive control
422# below still plants executable code and requires the pattern to fire.
423exec_vocab='\brunner\b|\bretry\b|\bexit-status\b|\bsubprocess\b|\bspawn\b'
424for f in $core_files; do
425 if code_only "$f" | grep -niE "$exec_vocab"; then
426 fail "execution vocabulary appeared in $f's CODE -- a workflow engine is creeping in"
427 fi
428done
429# POSITIVE CONTROL: plant it as code, past the comment filter.
430cp src/folio/doc/model.sgl "$control_dir/exec-control.sgl"
431echo '(define (spawn-runner) (retry-subprocess))' >> "$control_dir/exec-control.sgl"
432code_only "$control_dir/exec-control.sgl" | grep -qiE "$exec_vocab" \
433 || fail "positive control: the execution-vocabulary pattern did not fire on planted CODE"
434# ...and prove the comment filter is what spares the real file: the same planted
435# text as a comment must NOT be seen.
436cp src/folio/doc/model.sgl "$control_dir/exec-control2.sgl"
437echo ';; (define (spawn-runner) (retry-subprocess))' >> "$control_dir/exec-control2.sgl"
438if code_only "$control_dir/exec-control2.sgl" | grep -qiE "$exec_vocab"; then
439 fail "the comment filter is not filtering -- arm 7 would keep matching its own prose"
440fi
441echo "arm 7 OK: phase-record is exactly [$phase_field_list]; no execution vocabulary; pattern proven to fire"
443# ---------------------------------------------------------------------------
444# ARM 8 -- the canonical datum is TOTAL over every record's fields.
446# Review's point, and it is a real hole: nothing checked that `folio->datum`
447# actually serialises every field a record declares. Adding a field to a record
448# and forgetting the datum was invisible everywhere -- the exact-datum literal
449# only covers the note root, and a field nobody serialises simply does not
450# appear, so no assertion can miss it.
452# Both sides are derived: field names come from `model.sgl`'s `define-struct`
453# lines, and each record's datum branch is sliced out of `traversal.sgl` between
454# its `((<rec>? n)` head and its closing `(loc->datum (<rec>-location n))`.
455# `location` is excluded because it IS that closing tuple.
456# ---------------------------------------------------------------------------
457echo "--- arm 8: the canonical datum serialises every declared field"
458datum_missing=""
459datum_checked=0
460while read -r rec fields; do
461 branch=$(awk -v r="$rec" '
462 $0 ~ "\\(\\(" r "\\? n\\)" { inb = 1 }
463 inb { print }
464 inb && $0 ~ "loc->datum \\(" r "-location n\\)" { exit }
465 ' src/folio/doc/traversal.sgl)
466 [ -n "$branch" ] || fail "no folio->datum branch found for $rec -- the slicer is broken, not the file"
467 # COMMENTS STRIPPED BEFORE THE FIELD SEARCH. Without this, branch prose such
468 # as `;; we do not serialise 'due` SATISFIES totality for `due` -- the arm
469 # would confirm a field is serialised by reading a comment saying it is not.
470 #
471 # This is the same self-matching shape arm 7 was fixed for, in the arm written
472 # immediately afterwards. I predicted that shape in arm 5, defended arm 5, and
473 # left its neighbours open; the lesson is to defend the CLASS, not the
474 # prediction. Arms 2 and 4 are immune by `^`-anchoring and arm 5 scans
475 # comments deliberately, so arm 8 was the only remaining exposure.
476 branch_code=$(echo "$branch" | sed '/^[[:space:]]*;/d')
477 for f in $fields; do
478 [ "$f" = "location" ] && continue
479 datum_checked=$((datum_checked + 1))
480 echo "$branch_code" | grep -qE "'$f\b" || datum_missing="$datum_missing $rec.$f"
481 done
482done < <(grep -oE '^ *\(define-struct [a-z-]+-record.*' src/folio/doc/model.sgl \
483 | sed -E "s/^ *\(define-struct ([a-z-]+)-record/\1/; s/[()]//g")
484[ -z "$datum_missing" ] || fail "folio->datum never serialises:$datum_missing"
485[ "$datum_checked" -ge 40 ] || fail "arm 8 only checked $datum_checked fields; the derivation is not matching"
486# POSITIVE CONTROL: the same slice-and-search must REPORT a field that is
487# genuinely absent, or an empty result means nothing.
488control_branch=$(awk '
489 $0 ~ /\(\(task\? n\)/ { inb = 1 }
490 inb { print }
491 inb && $0 ~ /loc->datum \(task-location n\)/ { exit }
492' src/folio/doc/traversal.sgl)
493if echo "$control_branch" | grep -qE "'definitely-not-a-field\b"; then
494 fail "positive control: the datum slicer matched a field that cannot exist"
495fi
496echo "$control_branch" | sed '/^[[:space:]]*;/d' | grep -qE "'status\b" \
497 || fail "positive control: the datum slicer cannot even see task's status field"
498# ...and the comment filter must be what spares it: the same field name, present
499# ONLY in a comment, must NOT satisfy totality.
500if printf ' ;; we deliberately do not serialise %s\n' "'never-a-real-field" \
501 | sed '/^[[:space:]]*;/d' | grep -qE "'never-a-real-field\b"; then
502 fail "arm 8's comment filter is not filtering -- branch prose can satisfy totality"
503fi
504echo "arm 8 OK: $datum_checked declared fields all serialised; slicer proven to see and to miss"
506echo
507echo "F1A_GATE_GREEN arms=8 tests=$reported checks=$checks kinds=$kind_count core_files=$core_count fields=$datum_checked"