AtlatestRepositoryfolio
1
#!/usr/bin/env bash2
# M0 gate -- the Markdown -> folio/doc converter.3
#4
# EVERY ARM CARRIES A POSITIVE CONTROL. Sabotage proves green->red (the arm CAN5
# fire); a positive control proves red->green (the arm can STAND DOWN). An arm with6
# 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: a10
# non-zero exit is equally true of a gate that never ran.11
#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):14
#15
# bash test/gate-m0.sh16
# M0_GATE_ONLY_ARM=4 bash test/gate-m0.sh17
#18
# Env: SIGIL_M0_COMPILER (default `sigil`), SIGIL_M0_REDIRECTS (default19
# $repo/dev-redirects.sgl), SIGIL_M0_DOC_DIR (default ../sigil-doc).20
set -uo pipefail22
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"23
cd "$repo" || exit 125
COMPILER="${SIGIL_M0_COMPILER:-sigil}"26
REDIRECTS="${SIGIL_M0_REDIRECTS:-$repo/dev-redirects.sgl}"27
DOCDIR="${SIGIL_M0_DOC_DIR:-$repo/../sigil-doc}"28
CORPUS="${M0_GATE_CORPUS:-$repo/docs/f0/pressure-corpus}"29
ONLY="${M0_GATE_ONLY_ARM:-}"31
# Cache isolation per gate (t-7393fd is open).32
export XDG_CACHE_HOME="${XDG_CACHE_HOME_OVERRIDE:-$(mktemp -d)}"33
export SIGIL_REDIRECTS="$REDIRECTS"35
WORK="$(mktemp -d)"36
trap 'rm -rf "$WORK"' EXIT38
fail_count=039
arm_ran=041
note() { printf ' %s\n' "$*"; }42
fail() { printf ' FAIL: %s\n' "$*"; fail_count=$((fail_count + 1)); }44
want_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 one48
# was used, rather than letting a gate depend on an interpreter nobody named.49
if command -v python3 >/dev/null 2>&1; then50
PY=(python3); PYVIA="PATH"51
elif command -v guix >/dev/null 2>&1; then52
PY=(guix shell python -- python3); PYVIA="guix shell python"53
else54
echo "M0_GATE_NO_PYTHON"; exit 155
fi56
echo "M0 gate: python via $PYVIA; compiler $($COMPILER --version 2>&1 | head -1)"58
ALLOC="$WORK/id-allocation.json"59
convert() { # convert <out> <audit> [extra args...]60
local out="$1" aud="$2"; shift 261
"${PY[@]}" tools/m0_convert.py "$CORPUS" --out "$out" --audit "$aud" \62
--id-allocation "$ALLOC" "$@"63
}65
parse_sgd() { # parse_sgd <file> -> exit status; stdout+stderr captured by caller66
timeout 300 "$COMPILER" -L "$repo/build/dev/lib" -L "$repo/src" \67
-L "$DOCDIR/../sigil-sxml/src" "$1" 2>&168
}70
jqp() { "${PY[@]}" -c "$1" "$2"; }72
# ==========================================================================73
# ARM 1 -- DETERMINISM. Rerun is byte-identical.74
# ==========================================================================75
if want_arm 1; then76
arm_ran=$((arm_ran + 1)); echo "ARM 1: determinism"77
convert "$WORK/a" "$WORK/aa" >/dev/null 2>&178
convert "$WORK/b" "$WORK/bb" >/dev/null 2>&179
if diff -r "$WORK/a" "$WORK/b" >/dev/null 2>&1 && diff -r "$WORK/aa" "$WORK/bb" >/dev/null 2>&1; then80
note "two runs byte-identical (.sgd and audit)"81
else82
fail "rerun is not byte-identical"83
fi84
# POSITIVE CONTROL: the comparison must be able to SEE a difference. Without85
# 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; then88
fail "positive control: diff did not detect an injected difference"89
else90
note "positive control: diff detects an injected difference"91
fi92
# MINTED IDS MUST REUSE, NOT RE-MINT. A task with no id gets a RANDOMLY ASSIGNED93
# id (D4 forbids deriving one). Randomness is the enemy of byte-repeatability, so94
# the allocation table is what makes the two compatible -- and that property needs95
# its own check, because the pressure corpus contains no id-less task and would96
# 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>&1102
"${PY[@]}" tools/m0_convert.py "$WORK/mintc" --out "$WORK/m2o" --audit "$WORK/m2a" \103
--id-allocation "$MT" >/dev/null 2>&1104
if diff -r "$WORK/m1o" "$WORK/m2o" >/dev/null 2>&1; then105
note "a minted id is REUSED from the allocation table on a second run"106
else107
fail "minted ids were re-minted; repeated runs are not byte-identical"108
fi109
# POSITIVE CONTROL: WITHOUT the table the ids must actually differ, or the check110
# 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>&1112
"${PY[@]}" tools/m0_convert.py "$WORK/mintc" --out "$WORK/m4o" --audit "$WORK/m4a" >/dev/null 2>&1113
if diff -r "$WORK/m3o" "$WORK/m4o" >/dev/null 2>&1; then114
fail "positive control: ids were identical WITHOUT a table; nothing is being minted"115
else116
note "positive control: without the table the ids genuinely differ (they are random)"117
fi119
# 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"123
fi125
# ==========================================================================126
# ARM 2 -- PARSEABLE .sgd, OCCURRENCE, through the real `#lang folio/doc`.127
#128
# Split from wording deliberately: t-2f2af1 means an error raised INSIDE129
# `read-language-source` reaches the #lang wrapper with an EMPTY message, so an arm130
# asserting wording here would assert against "" and pass vacuously.131
# ==========================================================================132
if want_arm 2; then133
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>&1135
parsed=0; failed=0136
while IFS= read -r f; do137
if parse_sgd "$f" >/dev/null 2>&1; then parsed=$((parsed + 1)); else failed=$((failed + 1)); note "unparsed: $f"; fi138
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 leaf143
# written BRACKET-ONLY parses as a NAMED BLOCK and demands `@end query`. If this144
# file parses, the arm cannot see the failure it exists to catch.145
cat >"$WORK/bracket-only.sgd" <<'EOF'146
#lang folio/doc147
@note["topics/bracket" title: "Bracket only" tags: '()]149
@query["q-x" title: "X" spec: '(status ready)]151
@end note152
EOF153
if parse_sgd "$WORK/bracket-only.sgd" >/dev/null 2>&1; then154
fail "positive control: a bracket-only @query PARSED; the arm cannot fail"155
else156
note "positive control: bracket-only @query refused (as it must be)"157
fi158
fi160
# ==========================================================================161
# ARM 3 -- REFUSAL WORDING, on the refusals that DO carry it.162
#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
# ==========================================================================167
if want_arm 3; then168
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"; then172
note "duplicate-id refusal carries its wording"173
else174
fail "duplicate-id refusal wording not found (got: $(printf '%s' "$out" | head -1))"175
fi176
# POSITIVE CONTROL: the same probe must STAND DOWN on a valid manuscript, or it177
# 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"; then180
fail "positive control: wording probe fired on a VALID manuscript"181
else182
note "positive control: wording probe stands down on a valid manuscript"183
fi184
fi186
# ==========================================================================187
# ARM 4 -- IDENTITY ACCOUNTING. Population stated, gap zero, and the count is188
# re-derived INDEPENDENTLY of the converter by a raw grep over the source.189
# ==========================================================================190
if want_arm 4; then191
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>&1193
# The converter's own ledger.194
conv_pop=$("${PY[@]}" - "$WORK/ca" <<'EOF'195
import json, sys, pathlib196
total = 0197
for p in sorted(pathlib.Path(sys.argv[1]).glob("*.json")):198
if p.name == "SUMMARY.json":199
continue200
total += json.loads(p.read_text())["identity"]["population_size"]201
print(total)202
EOF203
)204
# THE SECOND NET: a raw scan of the source bytes that shares no code with the205
# converter. Validating a parser with values produced by the same parser is the206
# tautology this estate forbids.207
# grep -E, so no lookbehind: an earlier version of this line used a PCRE208
# lookbehind that `grep -E` cannot compile, so it silently matched nothing and209
# 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" ]; then212
note "identity population agrees with an independent raw scan: $conv_pop"213
else214
fail "identity population $conv_pop != independent raw scan $raw_pop"215
fi216
# Gap must be zero: visible + not-visible + unreachable == population.217
"${PY[@]}" - "$WORK/ca" <<'EOF'218
import json, sys, pathlib219
bad = 0220
for p in sorted(pathlib.Path(sys.argv[1]).glob("*.json")):221
if p.name == "SUMMARY.json":222
continue223
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 = 1226
raise SystemExit(bad)227
EOF228
[ $? -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: an235
# id-only ledger cannot see a dropped task-body line, because the line carries236
# no id. Ids are not content.237
"${PY[@]}" - "$WORK/ca" <<'EOF'238
import json, sys, pathlib239
bad = 0240
for p in sorted(pathlib.Path(sys.argv[1]).glob("*.json")):241
if p.name == "SUMMARY.json":242
continue243
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 = 1248
raise SystemExit(bad)249
EOF250
[ $? -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 the254
# converter. Added after sabotage S2: conflating the comma and space separators255
# produced one tag "alpha beta", which the converter's own grammar filter then256
# dropped with a warning -- so the corruption became a silent loss and the parse257
# arm stayed green. Defensive filtering can HIDE a corruption; only a count258
# taken independently sees it.259
conv_tags=$("${PY[@]}" - "$WORK/ca" <<'EOF'260
import json, sys, pathlib261
print(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"))264
EOF265
)266
raw_tags=$("${PY[@]}" - "$CORPUS" <<'EOF'267
import re, sys, pathlib268
# Deliberately NOT the converter's parser: find each checkbox header's trailing269
# {...}, take its `tags` field, split on whitespace.270
total = 0271
for 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
continue275
m = re.search(r"\{([^{}]*)\}\s*$", line)276
if not m:277
continue278
for field in m.group(1).split(","):279
k, _, v = field.partition(":")280
if k.strip() == "tags":281
total += len(v.split())282
print(total)283
EOF284
)285
if [ "$conv_tags" -eq "$raw_tags" ]; then286
note "task tag facts agree with an independent scan: $conv_tags"287
else288
fail "task tags emitted $conv_tags != independent scan $raw_tags"289
fi290
[ "$raw_tags" -gt 0 ] || fail "positive control: independent tag scan found none; it may not be matching"291
fi293
# ==========================================================================294
# ARM 5 -- WARNING DENOMINATOR EXACT.295
# ==========================================================================296
if want_arm 5; then297
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>&1299
"${PY[@]}" - "$WORK/da" <<'EOF'300
import json, sys, pathlib301
s = json.loads((pathlib.Path(sys.argv[1]) / "SUMMARY.json").read_text())302
bad = 0303
if s["denominator"] != s["denominator_check"]:304
print(" FAIL: denominator", s["denominator"], "!= per-class sum", s["denominator_check"]); bad = 1305
# EVERY class must be PRESENT, including at zero. A class absent from the report306
# is indistinguishable from one that fired zero times -- which is exactly what307
# sabotage S4 (omit one unrecognized construct) attacks.308
expected = {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",318
}319
missing = expected - set(s["warning_counts"])320
if missing:321
print(" FAIL: warning classes missing from the summary:", sorted(missing)); bad = 1322
# ...AND IN EVERY PER-FILE RECORD, not only the summary. Sabotage S4 removed a323
# class from `Report.counts()` and this arm stayed green, because the summary324
# 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".326
for p in sorted(pathlib.Path(sys.argv[1]).glob("*.json")):327
if p.name == "SUMMARY.json":328
continue329
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 = 1332
# The per-file denominators must sum to the summary denominator.333
per = 0334
for p in sorted(pathlib.Path(sys.argv[1]).glob("*.json")):335
if p.name == "SUMMARY.json":336
continue337
per += json.loads(p.read_text())["warnings"]["denominator"]338
if per != s["denominator"]:339
print(" FAIL: per-file denominators sum to", per, "not", s["denominator"]); bad = 1340
# Converted count must carry its denominator.341
if s["files_discovered"] != s["files"] + s["skipped"]["count"]:342
print(" FAIL: files_discovered != files + skipped"); bad = 1343
print(f" denominator={s['denominator']} classes={len(s['warning_counts'])} "344
f"files={s['files']}/{s['files_discovered']} skipped={s['skipped']['count']}")345
raise SystemExit(bad)346
EOF347
[ $? -eq 0 ] || fail "denominator is not exact"348
# POSITIVE CONTROL: the denominator must be non-zero on this corpus, or an arm349
# 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"353
fi355
# ==========================================================================356
# ARM 6 -- A CHANGED SOURCE INVALIDATES.357
# ==========================================================================358
if want_arm 6; then359
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>&1363
# Audit filenames now carry a path digest (they used to collide), so glob364
# rather than hard-code -- a hard-coded name broke silently when the format365
# changed, and the arm reported "a changed source did not invalidate".366
audit_rev() { "${PY[@]}" - "$1" <<'PYREV'367
import glob, json, sys368
hits = sorted(glob.glob(sys.argv[1] + "/notes__pressure*.json"))369
if not hits:370
raise SystemExit("no audit record for notes/pressure.md")371
print(json.load(open(hits[0]))["source"]["sha256"])372
PYREV373
}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 a377
# weaker property silently substituted for the one the gate claims.378
"${PY[@]}" - "$WORK/corpus-copy/notes/pressure.md" <<'EOF'379
import sys, pathlib380
p = pathlib.Path(sys.argv[1]); t = p.read_text()381
assert "terminated" in t, "fixture drifted: no comment to perturb"382
p.write_text(t.replace("terminated", "terminatEd", 1))383
EOF384
rm -rf "$WORK/f" "$WORK/fa"385
"${PY[@]}" tools/m0_convert.py "$WORK/corpus-copy" --out "$WORK/f" --audit "$WORK/fa" >/dev/null 2>&1386
after=$(audit_rev "$WORK/fa")387
if [ "$before" != "$after" ]; then388
note "a one-byte change inside a COMMENT still invalidates ($before -> $after)"389
else390
fail "a changed source did not invalidate"391
fi392
# AND THE MANUSCRIPT MUST CHANGE TOO. The digest above is taken over file bytes393
# BEFORE any conversion, so on its own this arm tested SHA-256 rather than the394
# converter -- an adversarial review pointed out that no converter defect could395
# 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 inside397
# an HTML comment and comments are not content. Asserting otherwise was wrong and398
# this arm caught it: the two properties need two different perturbations.399
if diff -r "$WORK/e" "$WORK/f" >/dev/null 2>&1; then400
note "a comment-only change does NOT reach the manuscript (correct)"401
else402
fail "a change inside an HTML comment altered the emitted manuscript"403
fi405
# SECOND PERTURBATION, on real content. The digest above is taken over file bytes406
# BEFORE any conversion, so on its own this arm tested SHA-256 rather than the407
# converter -- an adversarial review pointed out no converter defect could redden408
# it. This half is a converter property: a content change must REACH the output.409
"${PY[@]}" - "$WORK/corpus-copy/notes/pressure.md" <<'PYMUT'410
import sys, pathlib411
p = pathlib.Path(sys.argv[1]); t = p.read_text()412
assert "Repeated phrase." in t, "fixture drifted: no prose to perturb"413
p.write_text(t.replace("Repeated phrase.", "Repeated phrasE.", 1))414
PYMUT415
rm -rf "$WORK/f2" "$WORK/fa2"416
"${PY[@]}" tools/m0_convert.py "$WORK/corpus-copy" --out "$WORK/f2" --audit "$WORK/fa2" >/dev/null 2>&1417
if diff -r "$WORK/f" "$WORK/f2" >/dev/null 2>&1; then418
fail "a change to real prose did NOT reach the emitted manuscript"419
else420
note "a content change reaches the emitted manuscript, not just the digest"421
fi423
# POSITIVE CONTROL: an UNCHANGED source must produce the SAME revision, or the424
# 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>&1427
again=$(audit_rev "$WORK/ga")428
# Compare against the CURRENT corpus state (after both perturbations), not the429
# stale `$after` from before the second one -- that ordering bug made the control430
# fail for a reason that was the gate's, not the converter's.431
current=$(audit_rev "$WORK/fa2")432
if [ "$current" = "$again" ]; then433
note "positive control: an unchanged source keeps its revision"434
else435
fail "positive control: revision changed with no source change"436
fi437
fi439
# ==========================================================================440
# ARM 7 -- NO BURIED ITEM, aimed at the SOURCE.441
#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 the444
# same line. Every emission site is a single-line f-string whose first positional is445
# always `"..."` or `#f`, so the pattern matched UNCONDITIONALLY -- no input to the446
# converter could make it red. Worse, it produced FALSE positives: a code fence447
# containing the text `@task[foo]` tripped it, because the scan had no awareness of448
# raw `@code` bodies. Its "positive control" hand-wrote a buried file and proved only449
# that grep works.450
#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 arm453
# aimed at it CAN fail. The converter now classifies an id that sits inside a task454
# block but not on its header line, and this arm reconciles that count against an455
# independent scan.456
# ==========================================================================457
if want_arm 7; then458
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'461
import json, pathlib, sys462
print(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"))465
EOF466
)467
if [ "$buried" -eq 0 ]; then468
note "no buried id in this corpus (converter ledger)"469
else470
fail "$buried buried id(s) reported but the corpus is expected clean"471
fi473
# POSITIVE CONTROL -- and this one controls the thing that matters: the detector474
# must find a burial WHEN ONE EXISTS. A corpus copy gets an id moved off its475
# 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'478
import pathlib, sys479
p = pathlib.Path(sys.argv[1]); lines = p.read_text().split("\n")480
for 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
break485
else:486
raise SystemExit("fixture drifted: no t-f00001 header to bury")487
p.write_text("\n".join(lines))488
EOF489
rm -rf "$WORK/hb"490
"${PY[@]}" tools/m0_convert.py "$WORK/buried-corpus" --out "$WORK/hbo" --audit "$WORK/hb" >/dev/null 2>&1491
buried2=$("${PY[@]}" - "$WORK/hb" <<'EOF'492
import json, pathlib, sys493
print(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"))496
EOF497
)498
if [ "$buried2" -ge 1 ]; then499
note "positive control: an injected burial IS detected ($buried2)"500
else501
fail "positive control: an injected burial was NOT detected; the arm cannot fire"502
fi503
fi505
# ==========================================================================506
# ARM 8 -- THE CONVERTER NEVER WRITES INTO ITS SOURCE.507
# ==========================================================================508
if want_arm 8; then509
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>&1512
after_manifest=$(find "$CORPUS" -type f -exec sha256sum {} \; | sort)513
if [ "$before_manifest" = "$after_manifest" ]; then514
note "every source byte unchanged after a full conversion"515
else516
fail "the converter modified its source tree"517
fi518
# POSITIVE CONTROL: the manifest must be able to SEE a change, and must be519
# 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" ]; then526
fail "positive control: the manifest did not notice an injected change"527
else528
note "positive control: the manifest notices an injected change"529
fi530
fi532
# ==========================================================================533
# ARM 9 -- FAITHFUL HEADINGS PARSE. The core-anchor namespace split, asserted.534
#535
# This arm previously PINNED the pre-split behaviour, in which a faithfully536
# converted note with two `## Notes` headings was refused WHOLE-FILE. It was written537
# to go red when the split landed rather than to be skipped, and it did exactly538
# that. The split is now merged (F1b `5b7a5c3`), so the arm asserts the new539
# contract instead.540
#541
# THE WARNING GOES TO STDERR, not stdout. An arm capturing only stdout would see542
# nothing and read it as absence -- which is why `parse_sgd` redirects 2>&1.543
# ==========================================================================544
if want_arm 9; then545
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 this551
# arm asserts a contract nothing exercises.552
dups=$(grep -c 'id: "notes"' "$WORK/m/notes/coverage.sgd" || true)553
if [ "$dups" -eq 2 ]; then554
note "the converter emitted the repeated slug faithfully (2x), unrenamed"555
else556
fail "expected 2 faithful 'notes' slugs, found $dups"557
fi559
out=$(parse_sgd "$WORK/m/notes/coverage.sgd"); status=$?560
if [ "$status" -ne 0 ]; then561
fail "a faithfully-converted duplicate heading was REFUSED: $(printf '%s' "$out" | head -1)"562
else563
note "document ACCEPTED"564
fi565
# 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"; then568
note "located warning carries both asserted strings"569
else570
fail "the core-anchor warning did not carry its exact strings"571
fi573
# POSITIVE CONTROL, and it is the one that matters: the split must NOT have574
# weakened the folio node-id check. A duplicate FOLIO id must still REFUSE.575
# Without this, a profile that simply stopped checking ids would satisfy576
# 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"; then580
note "positive control: a duplicate FOLIO node id is still REFUSED"581
else582
fail "positive control: duplicate folio node ids are no longer refused"583
fi584
fi586
# ==========================================================================587
# ARM 10 -- THE AUDIT RECORD IS CHECKED AGAINST THE EMITTED MANUSCRIPT.588
#589
# Every other net in this gate runs converter-vs-SOURCE. None of them checks that590
# the manuscript actually CONTAINS what the audit claims, so a converter reporting591
# five tasks while emitting three passes all nine. That is the converter lying about592
# its own output -- the one class no source-side net can see.593
#594
# The second reader is F2a's passive inspector via `test/f2a-span-manifest.sgl`: a595
# SIGIL reader over the emitted .sgd BYTES, sharing no code with the Python596
# converter. F2a's own header argues the principle -- a check written against the597
# 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
# ==========================================================================600
if want_arm 10; then601
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=0605
while IFS= read -r f; do606
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'612
import glob, json, sys614
rows = [l.rstrip("\n").split("\t") for l in open(sys.argv[1]) if l.startswith("NODE\t")]615
# NODE <path> <kind> <name> <vocabulary> <id> ...616
folio_ids, doc_id = set(), None617
for r in rows:618
if len(r) < 6 or r[4] != "folio":619
continue620
if r[5] != "-":621
if r[1] == "0":622
doc_id = r[5]623
else:624
folio_ids.add(r[5])625
if doc_id is None:626
print(" FAIL: manifest has no folio root node"); raise SystemExit(1)628
audit = None629
for path in glob.glob(sys.argv[2] + "/*.json"):630
if path.endswith("SUMMARY.json"):631
continue632
d = json.loads(open(path).read())633
if d["document_id"] == doc_id:634
audit = d635
break636
if audit is None:637
print(f" FAIL: no audit record claims document {doc_id}"); raise SystemExit(1)639
claimed = {t["id"] for t in audit["identity"]["population"] if t["class"] == "visible"}640
claimed |= {e["id"] for e in audit["tasks"]["minted"]["entries"]}641
missing = claimed - folio_ids # audit claims it; the manuscript does not have it642
extra = folio_ids - claimed # the manuscript has it; the audit never mentions it643
if 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)647
raise SystemExit(0)648
PYREC649
then checked=$((checked + 1)); else fail "audit/manuscript mismatch in $f"; fi650
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'657
import glob, json, sys658
for path in sorted(glob.glob(sys.argv[1] + "/*.json")):659
if path.endswith("SUMMARY.json"):660
continue661
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 contain665
open(path, "w").write(json.dumps(d, indent=2, sort_keys=True))666
print(f" perturbed {d['document_id']}")667
raise SystemExit(0)668
raise SystemExit("no visible task to perturb -- the control cannot fire")669
PYPERT670
caught=0671
while IFS= read -r f; do672
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 || continue676
"${PY[@]}" - "$WORK/manifest.txt" "$WORK/g10a" <<'PYREC2'677
import glob, json, sys678
rows = [l.rstrip("\n").split("\t") for l in open(sys.argv[1]) if l.startswith("NODE\t")]679
folio_ids, doc_id = set(), None680
for r in rows:681
if len(r) < 6 or r[4] != "folio" or r[5] == "-":682
continue683
if r[1] == "0": doc_id = r[5]684
else: folio_ids.add(r[5])685
for path in glob.glob(sys.argv[2] + "/*.json"):686
if path.endswith("SUMMARY.json"): continue687
d = json.loads(open(path).read())688
if d["document_id"] != doc_id: continue689
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)692
raise SystemExit(1)693
PYREC2694
[ $? -ne 0 ] && caught=1695
done < <(find "$WORK/g10" -name '*.sgd' | sort)696
if [ "$caught" -eq 1 ]; then697
note "positive control: a perturbed audit claim IS caught by the manifest"698
else699
fail "positive control: a perturbed audit claim was NOT caught; the arm cannot fire"700
fi701
fi703
# ==========================================================================704
echo705
if [ "$fail_count" -eq 0 ]; then706
if [ -n "$ONLY" ]; then707
echo "M0_GATE_ARM_${ONLY}_GREEN"708
else709
echo "M0_GATE_GREEN arms=$arm_ran corpus=$(basename "$CORPUS") python=$PYVIA"710
fi711
exit 0712
fi713
echo "M0_GATE_RED arms=$arm_ran failures=$fail_count"714
exit 1