AtlatestRepositoryfolio

folio / tree / testgate-m0.sh

1#!/usr/bin/env bash
2# M0 gate -- the Markdown -> folio/doc converter.
3#
4# EVERY ARM CARRIES A POSITIVE CONTROL. Sabotage proves green->red (the arm CAN
5# fire); a positive control proves red->green (the arm can STAND DOWN). An arm with
6# only one of the two is consistent with a check that always passes or always fails,
7# and both have shipped in this estate.
8#
9# Assertions are on MARKERS this script prints, never on exit status alone: a
10# non-zero exit is equally true of a gate that never ran.
12# INVOCATION (this gate sits OUTSIDE `sigil test` because the converter is Python,
13# so it must be named explicitly or it is t-f02ddd wearing a different hat):
15# bash test/gate-m0.sh
16# M0_GATE_ONLY_ARM=4 bash test/gate-m0.sh
18# Env: SIGIL_M0_COMPILER (default `sigil`), SIGIL_M0_REDIRECTS (default
19# $repo/dev-redirects.sgl), SIGIL_M0_DOC_DIR (default ../sigil-doc).
20set -uo pipefail
22repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
23cd "$repo" || exit 1
25COMPILER="${SIGIL_M0_COMPILER:-sigil}"
26REDIRECTS="${SIGIL_M0_REDIRECTS:-$repo/dev-redirects.sgl}"
27DOCDIR="${SIGIL_M0_DOC_DIR:-$repo/../sigil-doc}"
28CORPUS="${M0_GATE_CORPUS:-$repo/docs/f0/pressure-corpus}"
29ONLY="${M0_GATE_ONLY_ARM:-}"
31# Cache isolation per gate (t-7393fd is open).
32export XDG_CACHE_HOME="${XDG_CACHE_HOME_OVERRIDE:-$(mktemp -d)}"
33export SIGIL_REDIRECTS="$REDIRECTS"
35WORK="$(mktemp -d)"
36trap 'rm -rf "$WORK"' EXIT
38fail_count=0
39arm_ran=0
41note() { printf ' %s\n' "$*"; }
42fail() { printf ' FAIL: %s\n' "$*"; fail_count=$((fail_count + 1)); }
44want_arm() { [ -z "$ONLY" ] || [ "$ONLY" = "$1" ]; }
46# --- the python interpreter hop -------------------------------------------
47# python3 is not on this host's bare PATH. Resolve it ONCE and PRINT which one
48# was used, rather than letting a gate depend on an interpreter nobody named.
49if command -v python3 >/dev/null 2>&1; then
50 PY=(python3); PYVIA="PATH"
51elif command -v guix >/dev/null 2>&1; then
52 PY=(guix shell python -- python3); PYVIA="guix shell python"
53else
54 echo "M0_GATE_NO_PYTHON"; exit 1
55fi
56echo "M0 gate: python via $PYVIA; compiler $($COMPILER --version 2>&1 | head -1)"
58ALLOC="$WORK/id-allocation.json"
59convert() { # convert <out> <audit> [extra args...]
60 local out="$1" aud="$2"; shift 2
61 "${PY[@]}" tools/m0_convert.py "$CORPUS" --out "$out" --audit "$aud" \
62 --id-allocation "$ALLOC" "$@"
65parse_sgd() { # parse_sgd <file> -> exit status; stdout+stderr captured by caller
66 timeout 300 "$COMPILER" -L "$repo/build/dev/lib" -L "$repo/src" \
67 -L "$DOCDIR/../sigil-sxml/src" "$1" 2>&1
70jqp() { "${PY[@]}" -c "$1" "$2"; }
72# ==========================================================================
73# ARM 1 -- DETERMINISM. Rerun is byte-identical.
74# ==========================================================================
75if want_arm 1; then
76 arm_ran=$((arm_ran + 1)); echo "ARM 1: determinism"
77 convert "$WORK/a" "$WORK/aa" >/dev/null 2>&1
78 convert "$WORK/b" "$WORK/bb" >/dev/null 2>&1
79 if diff -r "$WORK/a" "$WORK/b" >/dev/null 2>&1 && diff -r "$WORK/aa" "$WORK/bb" >/dev/null 2>&1; then
80 note "two runs byte-identical (.sgd and audit)"
81 else
82 fail "rerun is not byte-identical"
83 fi
84 # POSITIVE CONTROL: the comparison must be able to SEE a difference. Without
85 # this, `diff -r` over two empty trees would satisfy the arm.
86 printf 'different\n' >>"$WORK/b/inbox.sgd"
87 if diff -r "$WORK/a" "$WORK/b" >/dev/null 2>&1; then
88 fail "positive control: diff did not detect an injected difference"
89 else
90 note "positive control: diff detects an injected difference"
91 fi
92 # MINTED IDS MUST REUSE, NOT RE-MINT. A task with no id gets a RANDOMLY ASSIGNED
93 # id (D4 forbids deriving one). Randomness is the enemy of byte-repeatability, so
94 # the allocation table is what makes the two compatible -- and that property needs
95 # its own check, because the pressure corpus contains no id-less task and would
96 # never exercise it.
97 mkdir -p "$WORK/mintc/notes"
98 printf '# Mint\n\n- [ ] A task with no id at all\n' >"$WORK/mintc/notes/mint.md"
99 MT="$WORK/mint-alloc.json"
100 "${PY[@]}" tools/m0_convert.py "$WORK/mintc" --out "$WORK/m1o" --audit "$WORK/m1a" \
101 --id-allocation "$MT" >/dev/null 2>&1
102 "${PY[@]}" tools/m0_convert.py "$WORK/mintc" --out "$WORK/m2o" --audit "$WORK/m2a" \
103 --id-allocation "$MT" >/dev/null 2>&1
104 if diff -r "$WORK/m1o" "$WORK/m2o" >/dev/null 2>&1; then
105 note "a minted id is REUSED from the allocation table on a second run"
106 else
107 fail "minted ids were re-minted; repeated runs are not byte-identical"
108 fi
109 # POSITIVE CONTROL: WITHOUT the table the ids must actually differ, or the check
110 # above is satisfied by a converter that mints nothing at all.
111 "${PY[@]}" tools/m0_convert.py "$WORK/mintc" --out "$WORK/m3o" --audit "$WORK/m3a" >/dev/null 2>&1
112 "${PY[@]}" tools/m0_convert.py "$WORK/mintc" --out "$WORK/m4o" --audit "$WORK/m4a" >/dev/null 2>&1
113 if diff -r "$WORK/m3o" "$WORK/m4o" >/dev/null 2>&1; then
114 fail "positive control: ids were identical WITHOUT a table; nothing is being minted"
115 else
116 note "positive control: without the table the ids genuinely differ (they are random)"
117 fi
119 # And the trees must be non-empty, or "identical" is vacuous.
120 n=$(find "$WORK/a" -name '*.sgd' | wc -l)
121 [ "$n" -ge 3 ] || fail "determinism compared only $n manuscripts"
122 note "compared $n manuscripts"
123fi
125# ==========================================================================
126# ARM 2 -- PARSEABLE .sgd, OCCURRENCE, through the real `#lang folio/doc`.
128# Split from wording deliberately: t-2f2af1 means an error raised INSIDE
129# `read-language-source` reaches the #lang wrapper with an EMPTY message, so an arm
130# asserting wording here would assert against "" and pass vacuously.
131# ==========================================================================
132if want_arm 2; then
133 arm_ran=$((arm_ran + 1)); echo "ARM 2: parseable .sgd (occurrence)"
134 rm -rf "$WORK/p" "$WORK/pa"; convert "$WORK/p" "$WORK/pa" >/dev/null 2>&1
135 parsed=0; failed=0
136 while IFS= read -r f; do
137 if parse_sgd "$f" >/dev/null 2>&1; then parsed=$((parsed + 1)); else failed=$((failed + 1)); note "unparsed: $f"; fi
138 done < <(find "$WORK/p" -name '*.sgd' | sort)
139 [ "$parsed" -ge 3 ] || fail "only $parsed manuscripts parsed"
140 [ "$failed" -eq 0 ] || fail "$failed emitted manuscripts did not parse"
141 note "parsed=$parsed failed=$failed"
142 # POSITIVE CONTROL, and it is the brief's inherited constraint: a folio leaf
143 # written BRACKET-ONLY parses as a NAMED BLOCK and demands `@end query`. If this
144 # file parses, the arm cannot see the failure it exists to catch.
145 cat >"$WORK/bracket-only.sgd" <<'EOF'
146#lang folio/doc
147@note["topics/bracket" title: "Bracket only" tags: '()]
149@query["q-x" title: "X" spec: '(status ready)]
151@end note
152EOF
153 if parse_sgd "$WORK/bracket-only.sgd" >/dev/null 2>&1; then
154 fail "positive control: a bracket-only @query PARSED; the arm cannot fail"
155 else
156 note "positive control: bracket-only @query refused (as it must be)"
157 fi
158fi
160# ==========================================================================
161# ARM 3 -- REFUSAL WORDING, on the refusals that DO carry it.
163# Errors raised while EVALUATING the returned syntax (duplicate id, laundering)
164# keep their full message; only read-language-source-internal errors are truncated.
165# This arm proves the gate reads DIAGNOSTICS, not just exit codes.
166# ==========================================================================
167if want_arm 3; then
168 arm_ran=$((arm_ran + 1)); echo "ARM 3: refusal wording"
169 out=$(parse_sgd "$repo/test/fixtures/f1b-duplicate.sgd" || true)
170 # grep -F: a Scheme predicate name ends in `?`, an ERE quantifier (t-d46271).
171 if printf '%s' "$out" | grep -qF "is declared 2 times in this document"; then
172 note "duplicate-id refusal carries its wording"
173 else
174 fail "duplicate-id refusal wording not found (got: $(printf '%s' "$out" | head -1))"
175 fi
176 # POSITIVE CONTROL: the same probe must STAND DOWN on a valid manuscript, or it
177 # is an arm that always fires.
178 ok=$(parse_sgd "$repo/test/fixtures/f1b-note.sgd" || true)
179 if printf '%s' "$ok" | grep -qF "is declared 2 times in this document"; then
180 fail "positive control: wording probe fired on a VALID manuscript"
181 else
182 note "positive control: wording probe stands down on a valid manuscript"
183 fi
184fi
186# ==========================================================================
187# ARM 4 -- IDENTITY ACCOUNTING. Population stated, gap zero, and the count is
188# re-derived INDEPENDENTLY of the converter by a raw grep over the source.
189# ==========================================================================
190if want_arm 4; then
191 arm_ran=$((arm_ran + 1)); echo "ARM 4: identity accounting"
192 rm -rf "$WORK/c" "$WORK/ca"; convert "$WORK/c" "$WORK/ca" >/dev/null 2>&1
193 # The converter's own ledger.
194 conv_pop=$("${PY[@]}" - "$WORK/ca" <<'EOF'
195import json, sys, pathlib
196total = 0
197for p in sorted(pathlib.Path(sys.argv[1]).glob("*.json")):
198 if p.name == "SUMMARY.json":
199 continue
200 total += json.loads(p.read_text())["identity"]["population_size"]
201print(total)
202EOF
204 # THE SECOND NET: a raw scan of the source bytes that shares no code with the
205 # converter. Validating a parser with values produced by the same parser is the
206 # tautology this estate forbids.
207 # grep -E, so no lookbehind: an earlier version of this line used a PCRE
208 # lookbehind that `grep -E` cannot compile, so it silently matched nothing and
209 # the arm was carried entirely by its fallback. Same family as t-d46271.
210 raw_pop=$(grep -rhoE 'id: *t-[a-z0-9-]+' "$CORPUS" | wc -l)
211 if [ "$conv_pop" -eq "$raw_pop" ]; then
212 note "identity population agrees with an independent raw scan: $conv_pop"
213 else
214 fail "identity population $conv_pop != independent raw scan $raw_pop"
215 fi
216 # Gap must be zero: visible + not-visible + unreachable == population.
217 "${PY[@]}" - "$WORK/ca" <<'EOF'
218import json, sys, pathlib
219bad = 0
220for p in sorted(pathlib.Path(sys.argv[1]).glob("*.json")):
221 if p.name == "SUMMARY.json":
222 continue
223 d = json.loads(p.read_text())["identity"]
224 if d["visible"] + d["not_visible"] + d["unreachable"] != d["population_size"]:
225 print(" FAIL: unaccounted identity gap in", p.name); bad = 1
226raise SystemExit(bad)
227EOF
228 [ $? -eq 0 ] || fail "identity population has an unaccounted gap"
229 note "gap zero in every audit record"
230 # POSITIVE CONTROL: the raw scan must be able to find ids at all.
231 [ "$raw_pop" -ge 9 ] || fail "positive control: raw scan found only $raw_pop ids; it may not be matching"
232 note "positive control: raw scan independently finds $raw_pop ids"
234 # CONTENT, NOT JUST IDS. Added after sabotage S1 walked past this arm: an
235 # id-only ledger cannot see a dropped task-body line, because the line carries
236 # no id. Ids are not content.
237 "${PY[@]}" - "$WORK/ca" <<'EOF'
238import json, sys, pathlib
239bad = 0
240for p in sorted(pathlib.Path(sys.argv[1]).glob("*.json")):
241 if p.name == "SUMMARY.json":
242 continue
243 c = json.loads(p.read_text())["content"]
244 if c["unaccounted_count"]:
245 print(f" FAIL: {p.name}: {c['unaccounted_count']} source body line(s) "
246 f"reached no manuscript: lines {c['unaccounted']}")
247 bad = 1
248raise SystemExit(bad)
249EOF
250 [ $? -eq 0 ] || fail "source body lines were dropped"
251 note "every source body line reached the manuscript"
253 # TAG FACTS, reconciled against a SECOND NET that shares no code with the
254 # converter. Added after sabotage S2: conflating the comma and space separators
255 # produced one tag "alpha beta", which the converter's own grammar filter then
256 # dropped with a warning -- so the corruption became a silent loss and the parse
257 # arm stayed green. Defensive filtering can HIDE a corruption; only a count
258 # taken independently sees it.
259 conv_tags=$("${PY[@]}" - "$WORK/ca" <<'EOF'
260import json, sys, pathlib
261print(sum(json.loads(p.read_text())["content"]["tags_emitted"]
262 for p in sorted(pathlib.Path(sys.argv[1]).glob("*.json"))
263 if p.name != "SUMMARY.json"))
264EOF
266 raw_tags=$("${PY[@]}" - "$CORPUS" <<'EOF'
267import re, sys, pathlib
268# Deliberately NOT the converter's parser: find each checkbox header's trailing
269# {...}, take its `tags` field, split on whitespace.
270total = 0
271for path in sorted(pathlib.Path(sys.argv[1]).rglob("*.md")):
272 for line in path.read_text(encoding="utf-8").split("\n"):
273 if not re.match(r"^[ \t]*- \[[ xX]\] ", line):
274 continue
275 m = re.search(r"\{([^{}]*)\}\s*$", line)
276 if not m:
277 continue
278 for field in m.group(1).split(","):
279 k, _, v = field.partition(":")
280 if k.strip() == "tags":
281 total += len(v.split())
282print(total)
283EOF
285 if [ "$conv_tags" -eq "$raw_tags" ]; then
286 note "task tag facts agree with an independent scan: $conv_tags"
287 else
288 fail "task tags emitted $conv_tags != independent scan $raw_tags"
289 fi
290 [ "$raw_tags" -gt 0 ] || fail "positive control: independent tag scan found none; it may not be matching"
291fi
293# ==========================================================================
294# ARM 5 -- WARNING DENOMINATOR EXACT.
295# ==========================================================================
296if want_arm 5; then
297 arm_ran=$((arm_ran + 1)); echo "ARM 5: warning denominator"
298 rm -rf "$WORK/d" "$WORK/da"; convert "$WORK/d" "$WORK/da" >/dev/null 2>&1
299 "${PY[@]}" - "$WORK/da" <<'EOF'
300import json, sys, pathlib
301s = json.loads((pathlib.Path(sys.argv[1]) / "SUMMARY.json").read_text())
302bad = 0
303if s["denominator"] != s["denominator_check"]:
304 print(" FAIL: denominator", s["denominator"], "!= per-class sum", s["denominator_check"]); bad = 1
305# EVERY class must be PRESENT, including at zero. A class absent from the report
306# is indistinguishable from one that fired zero times -- which is exactly what
307# sabotage S4 (omit one unrecognized construct) attacks.
308expected = {
309 "status-supplied-by-contract", "task-metadata-key-has-no-frozen-field",
310 "frontmatter-key-has-no-frozen-field", "ambiguous-link-target",
311 "inline-relation-hoisted", "unrecognized-construct",
312 "id-present-but-not-visible", "duplicate-id-in-source",
313 "value-outside-frozen-grammar", "mixed-list-split", "duplicate-anchor-slug",
314 "archived-source-address-derived", "title-derived-from-address",
315 "code-language-supplied", "table-row-width-normalized", "empty-section-dropped",
316 "buried-id-in-source", "task-id-minted", "provenance-key-not-a-relation",
317 "container-status-mapped",
319missing = expected - set(s["warning_counts"])
320if missing:
321 print(" FAIL: warning classes missing from the summary:", sorted(missing)); bad = 1
322# ...AND IN EVERY PER-FILE RECORD, not only the summary. Sabotage S4 removed a
323# class from `Report.counts()` and this arm stayed green, because the summary
324# rebuilds its own dict from Warn.ALL and never consulted the per-file records.
325# Checking the aggregate while the detail rots is how "absent" reads as "zero".
326for p in sorted(pathlib.Path(sys.argv[1]).glob("*.json")):
327 if p.name == "SUMMARY.json":
328 continue
329 got = set(json.loads(p.read_text())["warnings"]["counts"])
330 if expected - got:
331 print(f" FAIL: {p.name} omits warning class(es):", sorted(expected - got)); bad = 1
332# The per-file denominators must sum to the summary denominator.
333per = 0
334for p in sorted(pathlib.Path(sys.argv[1]).glob("*.json")):
335 if p.name == "SUMMARY.json":
336 continue
337 per += json.loads(p.read_text())["warnings"]["denominator"]
338if per != s["denominator"]:
339 print(" FAIL: per-file denominators sum to", per, "not", s["denominator"]); bad = 1
340# Converted count must carry its denominator.
341if s["files_discovered"] != s["files"] + s["skipped"]["count"]:
342 print(" FAIL: files_discovered != files + skipped"); bad = 1
343print(f" denominator={s['denominator']} classes={len(s['warning_counts'])} "
344 f"files={s['files']}/{s['files_discovered']} skipped={s['skipped']['count']}")
345raise SystemExit(bad)
346EOF
347 [ $? -eq 0 ] || fail "denominator is not exact"
348 # POSITIVE CONTROL: the denominator must be non-zero on this corpus, or an arm
349 # asserting equality would be comparing 0 to 0 forever.
350 d=$("${PY[@]}" -c 'import json,sys;print(json.load(open(sys.argv[1]))["denominator"])' "$WORK/da/SUMMARY.json")
351 [ "$d" -gt 0 ] || fail "positive control: denominator is 0; equality would be vacuous"
352 note "positive control: denominator is $d, not vacuously zero"
353fi
355# ==========================================================================
356# ARM 6 -- A CHANGED SOURCE INVALIDATES.
357# ==========================================================================
358if want_arm 6; then
359 arm_ran=$((arm_ran + 1)); echo "ARM 6: changed source invalidates"
360 cp -r "$CORPUS" "$WORK/corpus-copy"
361 rm -rf "$WORK/e" "$WORK/ea"
362 "${PY[@]}" tools/m0_convert.py "$WORK/corpus-copy" --out "$WORK/e" --audit "$WORK/ea" >/dev/null 2>&1
363 # Audit filenames now carry a path digest (they used to collide), so glob
364 # rather than hard-code -- a hard-coded name broke silently when the format
365 # changed, and the arm reported "a changed source did not invalidate".
366 audit_rev() { "${PY[@]}" - "$1" <<'PYREV'
367import glob, json, sys
368hits = sorted(glob.glob(sys.argv[1] + "/notes__pressure*.json"))
369if not hits:
370 raise SystemExit("no audit record for notes/pressure.md")
371print(json.load(open(hits[0]))["source"]["sha256"])
372PYREV
373 }
374 before=$(audit_rev "$WORK/ea")
375 # Flip a byte in a SEMANTICALLY INERT position -- inside an HTML comment.
376 # Invalidation is on BYTES; an arm that only noticed semantic change would be a
377 # weaker property silently substituted for the one the gate claims.
378 "${PY[@]}" - "$WORK/corpus-copy/notes/pressure.md" <<'EOF'
379import sys, pathlib
380p = pathlib.Path(sys.argv[1]); t = p.read_text()
381assert "terminated" in t, "fixture drifted: no comment to perturb"
382p.write_text(t.replace("terminated", "terminatEd", 1))
383EOF
384 rm -rf "$WORK/f" "$WORK/fa"
385 "${PY[@]}" tools/m0_convert.py "$WORK/corpus-copy" --out "$WORK/f" --audit "$WORK/fa" >/dev/null 2>&1
386 after=$(audit_rev "$WORK/fa")
387 if [ "$before" != "$after" ]; then
388 note "a one-byte change inside a COMMENT still invalidates ($before -> $after)"
389 else
390 fail "a changed source did not invalidate"
391 fi
392 # AND THE MANUSCRIPT MUST CHANGE TOO. The digest above is taken over file bytes
393 # BEFORE any conversion, so on its own this arm tested SHA-256 rather than the
394 # converter -- an adversarial review pointed out that no converter defect could
395 # redden it. This half is a converter property: the change must REACH the output.
396 # ...but that perturbation must NOT reach the manuscript, because it was inside
397 # an HTML comment and comments are not content. Asserting otherwise was wrong and
398 # this arm caught it: the two properties need two different perturbations.
399 if diff -r "$WORK/e" "$WORK/f" >/dev/null 2>&1; then
400 note "a comment-only change does NOT reach the manuscript (correct)"
401 else
402 fail "a change inside an HTML comment altered the emitted manuscript"
403 fi
405 # SECOND PERTURBATION, on real content. The digest above is taken over file bytes
406 # BEFORE any conversion, so on its own this arm tested SHA-256 rather than the
407 # converter -- an adversarial review pointed out no converter defect could redden
408 # it. This half is a converter property: a content change must REACH the output.
409 "${PY[@]}" - "$WORK/corpus-copy/notes/pressure.md" <<'PYMUT'
410import sys, pathlib
411p = pathlib.Path(sys.argv[1]); t = p.read_text()
412assert "Repeated phrase." in t, "fixture drifted: no prose to perturb"
413p.write_text(t.replace("Repeated phrase.", "Repeated phrasE.", 1))
414PYMUT
415 rm -rf "$WORK/f2" "$WORK/fa2"
416 "${PY[@]}" tools/m0_convert.py "$WORK/corpus-copy" --out "$WORK/f2" --audit "$WORK/fa2" >/dev/null 2>&1
417 if diff -r "$WORK/f" "$WORK/f2" >/dev/null 2>&1; then
418 fail "a change to real prose did NOT reach the emitted manuscript"
419 else
420 note "a content change reaches the emitted manuscript, not just the digest"
421 fi
423 # POSITIVE CONTROL: an UNCHANGED source must produce the SAME revision, or the
424 # arm would pass on a revision that changes every run.
425 rm -rf "$WORK/g" "$WORK/ga"
426 "${PY[@]}" tools/m0_convert.py "$WORK/corpus-copy" --out "$WORK/g" --audit "$WORK/ga" >/dev/null 2>&1
427 again=$(audit_rev "$WORK/ga")
428 # Compare against the CURRENT corpus state (after both perturbations), not the
429 # stale `$after` from before the second one -- that ordering bug made the control
430 # fail for a reason that was the gate's, not the converter's.
431 current=$(audit_rev "$WORK/fa2")
432 if [ "$current" = "$again" ]; then
433 note "positive control: an unchanged source keeps its revision"
434 else
435 fail "positive control: revision changed with no source change"
436 fi
437fi
439# ==========================================================================
440# ARM 7 -- NO BURIED ITEM, aimed at the SOURCE.
442# THE PREVIOUS VERSION OF THIS ARM WAS VACUOUS and an adversarial review proved it:
443# it lexically grepped the EMITTED manuscripts for a command whose id was not on the
444# same line. Every emission site is a single-line f-string whose first positional is
445# always `"..."` or `#f`, so the pattern matched UNCONDITIONALLY -- no input to the
446# converter could make it red. Worse, it produced FALSE positives: a code fence
447# containing the text `@task[foo]` tripped it, because the scan had no awareness of
448# raw `@code` bodies. Its "positive control" hand-wrote a buried file and proved only
449# that grep works.
451# The plan's gate item is about the SOURCE: F0 measured 1500 task ids on 1497 lines,
452# 9 off a header line, 8 of them genuine burials. That population exists, so an arm
453# aimed at it CAN fail. The converter now classifies an id that sits inside a task
454# block but not on its header line, and this arm reconciles that count against an
455# independent scan.
456# ==========================================================================
457if want_arm 7; then
458 arm_ran=$((arm_ran + 1)); echo "ARM 7: no buried item (source)"
459 rm -rf "$WORK/h" "$WORK/ha"; convert "$WORK/h" "$WORK/ha" >/dev/null 2>&1 || fail "convert failed"
460 buried=$("${PY[@]}" - "$WORK/ha" <<'EOF'
461import json, pathlib, sys
462print(sum(json.loads(p.read_text())["identity"]["buried_count"]
463 for p in sorted(pathlib.Path(sys.argv[1]).glob("*.json"))
464 if p.name != "SUMMARY.json"))
465EOF
467 if [ "$buried" -eq 0 ]; then
468 note "no buried id in this corpus (converter ledger)"
469 else
470 fail "$buried buried id(s) reported but the corpus is expected clean"
471 fi
473 # POSITIVE CONTROL -- and this one controls the thing that matters: the detector
474 # must find a burial WHEN ONE EXISTS. A corpus copy gets an id moved off its
475 # task's header line onto an indented body line, which is F0's burial shape.
476 cp -r "$CORPUS" "$WORK/buried-corpus"
477 "${PY[@]}" - "$WORK/buried-corpus/inbox.md" <<'EOF'
478import pathlib, sys
479p = pathlib.Path(sys.argv[1]); lines = p.read_text().split("\n")
480for i, line in enumerate(lines):
481 if "{id: t-f00001" in line:
482 lines[i] = line.split("{id:")[0].rstrip()
483 lines.insert(i + 1, " continued {id: t-f00001}")
484 break
485else:
486 raise SystemExit("fixture drifted: no t-f00001 header to bury")
487p.write_text("\n".join(lines))
488EOF
489 rm -rf "$WORK/hb"
490 "${PY[@]}" tools/m0_convert.py "$WORK/buried-corpus" --out "$WORK/hbo" --audit "$WORK/hb" >/dev/null 2>&1
491 buried2=$("${PY[@]}" - "$WORK/hb" <<'EOF'
492import json, pathlib, sys
493print(sum(json.loads(p.read_text())["identity"]["buried_count"]
494 for p in sorted(pathlib.Path(sys.argv[1]).glob("*.json"))
495 if p.name != "SUMMARY.json"))
496EOF
498 if [ "$buried2" -ge 1 ]; then
499 note "positive control: an injected burial IS detected ($buried2)"
500 else
501 fail "positive control: an injected burial was NOT detected; the arm cannot fire"
502 fi
503fi
505# ==========================================================================
506# ARM 8 -- THE CONVERTER NEVER WRITES INTO ITS SOURCE.
507# ==========================================================================
508if want_arm 8; then
509 arm_ran=$((arm_ran + 1)); echo "ARM 8: source tree untouched"
510 before_manifest=$(find "$CORPUS" -type f -exec sha256sum {} \; | sort)
511 rm -rf "$WORK/i" "$WORK/ia"; convert "$WORK/i" "$WORK/ia" >/dev/null 2>&1
512 after_manifest=$(find "$CORPUS" -type f -exec sha256sum {} \; | sort)
513 if [ "$before_manifest" = "$after_manifest" ]; then
514 note "every source byte unchanged after a full conversion"
515 else
516 fail "the converter modified its source tree"
517 fi
518 # POSITIVE CONTROL: the manifest must be able to SEE a change, and must be
519 # non-empty -- two empty manifests compare equal.
520 [ -n "$before_manifest" ] || fail "positive control: manifest is empty"
521 probe="$WORK/probe-manifest"; cp -r "$CORPUS" "$probe"
522 m1=$(find "$probe" -type f -exec sha256sum {} \; | sort)
523 printf 'x' >>"$probe/inbox.md"
524 m2=$(find "$probe" -type f -exec sha256sum {} \; | sort)
525 if [ "$m1" = "$m2" ]; then
526 fail "positive control: the manifest did not notice an injected change"
527 else
528 note "positive control: the manifest notices an injected change"
529 fi
530fi
532# ==========================================================================
533# ARM 9 -- FAITHFUL HEADINGS PARSE. The core-anchor namespace split, asserted.
535# This arm previously PINNED the pre-split behaviour, in which a faithfully
536# converted note with two `## Notes` headings was refused WHOLE-FILE. It was written
537# to go red when the split landed rather than to be skipped, and it did exactly
538# that. The split is now merged (F1b `5b7a5c3`), so the arm asserts the new
539# contract instead.
541# THE WARNING GOES TO STDERR, not stdout. An arm capturing only stdout would see
542# nothing and read it as absence -- which is why `parse_sgd` redirects 2>&1.
543# ==========================================================================
544if want_arm 9; then
545 arm_ran=$((arm_ran + 1)); echo "ARM 9: repeated core anchors warn, document accepted"
546 rm -rf "$WORK/m" "$WORK/ma"
547 "${PY[@]}" tools/m0_convert.py "$repo/test/fixtures/m0-corpus" \
548 --out "$WORK/m" --audit "$WORK/ma" >/dev/null 2>&1 || fail "m0-corpus conversion failed"
550 # The converter must actually have emitted the repeated slug faithfully, or this
551 # arm asserts a contract nothing exercises.
552 dups=$(grep -c 'id: "notes"' "$WORK/m/notes/coverage.sgd" || true)
553 if [ "$dups" -eq 2 ]; then
554 note "the converter emitted the repeated slug faithfully (2x), unrenamed"
555 else
556 fail "expected 2 faithful 'notes' slugs, found $dups"
557 fi
559 out=$(parse_sgd "$WORK/m/notes/coverage.sgd"); status=$?
560 if [ "$status" -ne 0 ]; then
561 fail "a faithfully-converted duplicate heading was REFUSED: $(printf '%s' "$out" | head -1)"
562 else
563 note "document ACCEPTED"
564 fi
565 # grep -F throughout: a Scheme predicate name ends in `?`, an ERE quantifier.
566 if printf '%s' "$out" | grep -qF "core anchor id" \
567 && printf '%s' "$out" | grep -qF "the document is accepted"; then
568 note "located warning carries both asserted strings"
569 else
570 fail "the core-anchor warning did not carry its exact strings"
571 fi
573 # POSITIVE CONTROL, and it is the one that matters: the split must NOT have
574 # weakened the folio node-id check. A duplicate FOLIO id must still REFUSE.
575 # Without this, a profile that simply stopped checking ids would satisfy
576 # everything above.
577 dup_out=$(parse_sgd "$repo/test/fixtures/f1b-duplicate.sgd" || true)
578 if printf '%s' "$dup_out" | grep -qF "is declared 2 times in this document" \
579 && ! printf '%s' "$dup_out" | grep -qF "the document is accepted"; then
580 note "positive control: a duplicate FOLIO node id is still REFUSED"
581 else
582 fail "positive control: duplicate folio node ids are no longer refused"
583 fi
584fi
586# ==========================================================================
587# ARM 10 -- THE AUDIT RECORD IS CHECKED AGAINST THE EMITTED MANUSCRIPT.
589# Every other net in this gate runs converter-vs-SOURCE. None of them checks that
590# the manuscript actually CONTAINS what the audit claims, so a converter reporting
591# five tasks while emitting three passes all nine. That is the converter lying about
592# its own output -- the one class no source-side net can see.
594# The second reader is F2a's passive inspector via `test/f2a-span-manifest.sgl`: a
595# SIGIL reader over the emitted .sgd BYTES, sharing no code with the Python
596# converter. F2a's own header argues the principle -- a check written against the
597# descriptor in the descriptor's own language agrees with itself and proves nothing.
598# Reusing it is a second opinion; a Python re-reader would be an echo.
599# ==========================================================================
600if want_arm 10; then
601 arm_ran=$((arm_ran + 1)); echo "ARM 10: audit facts vs the emitted manuscript"
602 rm -rf "$WORK/g10" "$WORK/g10a"; convert "$WORK/g10" "$WORK/g10a" >/dev/null 2>&1 \
603 || fail "conversion failed"
604 checked=0
605 while IFS= read -r f; do
606 rev="sha256:$(sha256sum "$f" | cut -d' ' -f1)"
607 F2A_MANIFEST_SOURCE="$f" F2A_MANIFEST_REVISION="$rev" \
608 timeout 300 "$COMPILER" -L "$repo/build/dev/lib" -L "$repo/src" -L "$repo/test" \
609 "$repo/test/f2a-span-manifest.sgl" >"$WORK/manifest.txt" 2>"$WORK/manifest.err" \
610 || { fail "manifest failed for $f: $(head -1 "$WORK/manifest.err")"; continue; }
611 if "${PY[@]}" - "$WORK/manifest.txt" "$WORK/g10a" <<'PYREC'
612import glob, json, sys
614rows = [l.rstrip("\n").split("\t") for l in open(sys.argv[1]) if l.startswith("NODE\t")]
615# NODE <path> <kind> <name> <vocabulary> <id> ...
616folio_ids, doc_id = set(), None
617for r in rows:
618 if len(r) < 6 or r[4] != "folio":
619 continue
620 if r[5] != "-":
621 if r[1] == "0":
622 doc_id = r[5]
623 else:
624 folio_ids.add(r[5])
625if doc_id is None:
626 print(" FAIL: manifest has no folio root node"); raise SystemExit(1)
628audit = None
629for path in glob.glob(sys.argv[2] + "/*.json"):
630 if path.endswith("SUMMARY.json"):
631 continue
632 d = json.loads(open(path).read())
633 if d["document_id"] == doc_id:
634 audit = d
635 break
636if audit is None:
637 print(f" FAIL: no audit record claims document {doc_id}"); raise SystemExit(1)
639claimed = {t["id"] for t in audit["identity"]["population"] if t["class"] == "visible"}
640claimed |= {e["id"] for e in audit["tasks"]["minted"]["entries"]}
641missing = claimed - folio_ids # audit claims it; the manuscript does not have it
642extra = folio_ids - claimed # the manuscript has it; the audit never mentions it
643if missing or extra:
644 print(f" FAIL: {doc_id}: audit claims but manuscript lacks {sorted(missing)}; "
645 f"manuscript has but audit omits {sorted(extra)}")
646 raise SystemExit(1)
647raise SystemExit(0)
648PYREC
649 then checked=$((checked + 1)); else fail "audit/manuscript mismatch in $f"; fi
650 done < <(find "$WORK/g10" -name '*.sgd' | sort)
651 [ "$checked" -ge 3 ] || fail "only $checked manuscripts reconciled; expected at least 3"
652 note "$checked manuscripts reconciled against their audit records"
654 # POSITIVE CONTROL: perturb the AUDIT's claim and confirm the arm reddens.
655 # Without it, an empty audit and an empty manifest satisfy the check.
656 "${PY[@]}" - "$WORK/g10a" <<'PYPERT'
657import glob, json, sys
658for path in sorted(glob.glob(sys.argv[1] + "/*.json")):
659 if path.endswith("SUMMARY.json"):
660 continue
661 d = json.loads(open(path).read())
662 vis = [e for e in d["identity"]["population"] if e["class"] == "visible"]
663 if vis:
664 vis[0]["id"] = "t-ffffff" # a task the manuscript does not contain
665 open(path, "w").write(json.dumps(d, indent=2, sort_keys=True))
666 print(f" perturbed {d['document_id']}")
667 raise SystemExit(0)
668raise SystemExit("no visible task to perturb -- the control cannot fire")
669PYPERT
670 caught=0
671 while IFS= read -r f; do
672 rev="sha256:$(sha256sum "$f" | cut -d' ' -f1)"
673 F2A_MANIFEST_SOURCE="$f" F2A_MANIFEST_REVISION="$rev" \
674 timeout 300 "$COMPILER" -L "$repo/build/dev/lib" -L "$repo/src" -L "$repo/test" \
675 "$repo/test/f2a-span-manifest.sgl" >"$WORK/manifest.txt" 2>/dev/null || continue
676 "${PY[@]}" - "$WORK/manifest.txt" "$WORK/g10a" <<'PYREC2'
677import glob, json, sys
678rows = [l.rstrip("\n").split("\t") for l in open(sys.argv[1]) if l.startswith("NODE\t")]
679folio_ids, doc_id = set(), None
680for r in rows:
681 if len(r) < 6 or r[4] != "folio" or r[5] == "-":
682 continue
683 if r[1] == "0": doc_id = r[5]
684 else: folio_ids.add(r[5])
685for path in glob.glob(sys.argv[2] + "/*.json"):
686 if path.endswith("SUMMARY.json"): continue
687 d = json.loads(open(path).read())
688 if d["document_id"] != doc_id: continue
689 claimed = {t["id"] for t in d["identity"]["population"] if t["class"] == "visible"}
690 claimed |= {e["id"] for e in d["tasks"]["minted"]["entries"]}
691 raise SystemExit(0 if claimed == folio_ids else 1)
692raise SystemExit(1)
693PYREC2
694 [ $? -ne 0 ] && caught=1
695 done < <(find "$WORK/g10" -name '*.sgd' | sort)
696 if [ "$caught" -eq 1 ]; then
697 note "positive control: a perturbed audit claim IS caught by the manifest"
698 else
699 fail "positive control: a perturbed audit claim was NOT caught; the arm cannot fire"
700 fi
701fi
703# ==========================================================================
704echo
705if [ "$fail_count" -eq 0 ]; then
706 if [ -n "$ONLY" ]; then
707 echo "M0_GATE_ARM_${ONLY}_GREEN"
708 else
709 echo "M0_GATE_GREEN arms=$arm_ran corpus=$(basename "$CORPUS") python=$PYVIA"
710 fi
711 exit 0
712fi
713echo "M0_GATE_RED arms=$arm_ran failures=$fail_count"
714exit 1