AtlatestRepositoryfolio
1
#!/bin/sh2
# gate-f4a.sh -- F4a: typed graph tables + rebuild/incremental convergence.3
#4
# SETUP-FAILED IS A DISTINCT OUTCOME from RED and from GREEN. Where this gate5
# cannot establish its subject it says so in those words and exits 3, claiming6
# nothing about the product. "The check found nothing" and "the check never7
# ran" are two different facts and this file never lets them print the same way.8
#9
# THE TOOLCHAIN IS PINNED HERE, NOT INHERITED10
#11
# The three folio gates in this repo do not agree about which compiler they run12
# under: `gate-f1a.sh` and `gate-f2a.sh` default to13
# `../task-sigil-language-readers/build/dev/bin/sigil` (0.18.3-dev), while14
# `gate-f2b.sh` takes `${SIGIL:-sigil}`. Measured 2026-08-24: PATH sigil is15
# 0.19.2 and the COMMITTED LOCK pins sigil-run 0.18.2, and that pair builds rc=016
# and produces a bundle that dies on load with "unsupported bytecode version 1117
# (expected 10)". The combination that works is PATH sigil plus18
# `SIGIL_REDIRECTS=dev-redirects.sgl`, which resolves the runtime out of the19
# `../sigil` working tree so both halves come from one toolchain.20
#21
# So this gate prints BOTH identities in its banner and probes the artifact22
# BEHAVIOURALLY before asserting anything. A version string is a proxy; asking23
# the binary to do something only a working build can do is the measurement.24
#25
# EVERY ARM STATES A DENOMINATOR, AND THE COUNTING ARMS STATE A FLOOR26
#27
# A count with no denominator is a sample wearing a population's clothes. And28
# an upper bound alone is satisfied by a dead scanner -- "no cycles found" is29
# what a detector that cannot see anything also reports -- so arms 3 and 5 carry30
# a planted positive that MUST be found alongside the negative that must not.31
#32
# Usage: sh test/gate-f4a.sh [arm ...] (no args = all arms)34
set -u36
repo_dir=$(cd "$(dirname "$0")/.." && pwd)37
cd "$repo_dir" || exit 139
sigil="${SIGIL:-sigil}"40
redirects="${SIGIL_F4A_REDIRECTS:-$repo_dir/dev-redirects.sgl}"41
export SIGIL_REDIRECTS="$redirects"43
# Scratch lives in /tmp. The one exception is `sigil build`, which writes44
# `build/dev/...` under the repo root -- gitignored, but it IS a write. No45
# SOURCE file is touched by this gate.46
work=$(mktemp -d /tmp/gate-f4a.XXXXXX) || { echo "SETUP-FAILED: no temp dir"; exit 3; }47
trap 'rm -rf "$work"' EXIT INT TERM49
FIXTURES="$repo_dir/test/fixtures/f4a/notes"50
PROBE="$repo_dir/tools/f4a_graph_probe.sgl"52
fails=053
fail () { echo "GATE-F4A FAIL: $*" >&2; fails=$((fails + 1)); }54
setup_failed () { echo "GATE-F4A SETUP-FAILED: $*" >&2; exit 3; }56
plain () { sed 's/\x1b\[[0-9;]*m//g'; }58
want_arm () {59
[ $# -ge 0 ] || return 160
if [ -z "$ARMS" ]; then return 0; fi61
for a in $ARMS; do [ "$a" = "$1" ] && return 0; done62
return 163
}65
ARMS="$*"67
# --------------------------------------------------------------------------68
# The probe wrapper.69
#70
# `tools/f4a_graph_probe.sgl` emits FACTS and never a verdict; every comparison71
# in this file is made here, in the shell, against those facts. If the probe72
# decided pass/fail, a sabotage planted in `(folio graph)` could make the73
# product report that it is fine, and the gate would be asserting the product's74
# opinion of itself.75
#76
# The PROBE_DONE marker is mandatory. Without it, a probe that died inside the77
# product prints nothing and every `grep -c` below returns 0 -- which is exactly78
# what a clean corpus also returns.79
# --------------------------------------------------------------------------80
probe () {81
out="$work/probe.$$"82
if ! "$sigil" -L "$repo_dir/build/dev/lib" -L "$repo_dir/src" "$PROBE" "$@" \83
> "$out" 2>"$out.err"; then84
echo "PROBE_FAILED $*" >&285
head -10 "$out.err" | sed "s/^/ /" >&286
cat "$out"87
return 188
fi89
if ! grep -q '^PROBE_DONE' "$out"; then90
echo "PROBE_INCOMPLETE $* -- no PROBE_DONE marker; the probe did not run to completion" >&291
return 192
fi93
grep -v '\[INFO\]' "$out"94
return 095
}97
field () { awk -F'\t' -v n="$2" '$1 == v { print $n }' v="$1"; }98
count_tag () { grep -c "^$1 " || true; }100
new_ws () {101
d=$(mktemp -d "$work/ws.XXXXXX")102
cp -r "$FIXTURES" "$d/notes"103
echo "$d"104
}106
dump_to () {107
# $1 = workspace, $2 = output file108
probe dump "$1" ops file | grep '^R ' | cut -f2- > "$2"109
}111
# ==========================================================================112
# BANNER + BEHAVIOURAL PRESENCE PROBE113
# ==========================================================================115
echo "=========================================================================="116
echo "gate-f4a: typed graph tables + rebuild/incremental convergence"117
echo "=========================================================================="118
echo "compiler : $("$sigil" --version 2>&1 | plain | tail -1)"119
echo "compiler path : $(command -v "$sigil" || echo "$sigil")"120
echo "SIGIL_REDIRECTS : $redirects"121
if [ -d "$repo_dir/../sigil" ]; then122
echo "redirect target : ../sigil $(git -C "$repo_dir/../sigil" rev-parse --short HEAD 2>/dev/null) \123
$(git -C "$repo_dir/../sigil" describe --tags 2>/dev/null)"124
else125
echo "redirect target : ../sigil ABSENT"126
fi127
echo "folio tree : $(git -C "$repo_dir" rev-parse --short HEAD) on \128
$(git -C "$repo_dir" rev-parse --abbrev-ref HEAD)"129
echo "optimize : dev config, optimize 0 (t-4dfcbd honoured by choice; see the task note)"130
echo132
[ -d "$FIXTURES" ] || setup_failed "fixture corpus missing: $FIXTURES"134
if [ "${F4A_NO_BUILD:-0}" != "1" ]; then135
echo "building (the artifact under test must match the source that is being asserted about)..."136
if ! "$sigil" build --redirects "$redirects" > "$work/build.log" 2>&1; then137
tail -20 "$work/build.log" >&2138
setup_failed "sigil build failed; see $work/build.log"139
fi140
fi142
[ -d "$repo_dir/build/dev/lib" ] || setup_failed "build/dev/lib is absent; run \`sigil build\`"144
# PRESENCE, ESTABLISHED BEHAVIOURALLY.145
#146
# `sigil build` exiting 0 is NOT evidence that the artifact works -- measured on147
# this very repo, the committed lock produces a bundle that exits 0 at build and148
# dies at load. So the subject is established by asking the built module to do149
# something only a working build can do: enumerate the model's edge kinds.150
if ! probe vocabulary > "$work/vocab.txt"; then151
setup_failed "the graph module could not be exercised at all (see above)"152
fi153
kinds=$(count_tag EDGE_KIND < "$work/vocab.txt")154
if [ "$kinds" -eq 0 ]; then155
setup_failed "the built module reported ZERO built-in edge kinds; the artifact is not the subject"156
fi157
echo "presence : graph module live, $kinds built-in edge kinds enumerated from (folio doc model)"158
echo160
corpus_files=$(find "$FIXTURES" -type f | wc -l | tr -d ' ')161
echo "corpus : $corpus_files files under test/fixtures/f4a/notes"162
echo " ($(find "$FIXTURES" -name '*.sgd' | wc -l | tr -d ' ') .sgd, \163
$(find "$FIXTURES" -name '*.md' | wc -l | tr -d ' ') .md)"164
echo165
# ==========================================================================166
# ARM 1 -- FULL REBUILD VERSUS EVERY MUTATION SEQUENCE167
# ==========================================================================168
#169
# The phase's centre. Each sequence drives the INCREMENTAL entry points over a170
# fresh workspace, dumps the canonical relation, then rebuilds from scratch in171
# the same database and dumps again. The two dumps must be byte-identical.172
#173
# What "logically" means is decided by `graph-relation-dump`, and its exclusions174
# are argued in the task note: no `path`, no `indexed_at`, no rowids, no175
# `revisions` log; edges and diagnostics compared as MULTISETS.176
#177
# THE DIFFER IS CONTROLLED BEFORE IT IS TRUSTED. A comparison that cannot178
# report a difference reports "converged" on every input, which is this estate's179
# most-repeated false green. Control 1a perturbs a dump by one line and180
# requires the comparison to notice.181
if want_arm 1; then182
echo "--- arm 1: rebuild vs every mutation sequence -------------------------"183
arm1_fails=$fails184
seqs=0185
converged=0187
# ---- control 1a: the comparison can see a difference ----188
ws=$(new_ws)189
probe rebuild "$ws" ops file > /dev/null || fail "arm1 control: rebuild failed"190
dump_to "$ws" "$work/ctl.txt"191
sed '1d' "$work/ctl.txt" > "$work/ctl-perturbed.txt"192
if diff -q "$work/ctl.txt" "$work/ctl-perturbed.txt" > /dev/null 2>&1; then193
fail "arm1 control 1a: the comparison reported IDENTICAL for two files that differ by one line."194
echo " Every 'converged' below would be meaningless. Fix the comparison first." >&2195
else196
echo " control 1a : comparison sees a one-line perturbation ... OK"197
fi198
ctl_lines=$(wc -l < "$work/ctl.txt" | tr -d ' ')199
if [ "$ctl_lines" -lt 100 ]; then200
fail "arm1 control 1a: the relation dump is only $ctl_lines lines; the corpus should produce hundreds."201
fi202
echo " control 1a : baseline relation dump is $ctl_lines lines"204
# ---- the sequences ----205
# $1 = label, $2 = shell function name applied to \$ws206
run_seq () {207
label=$1; body=$2208
seqs=$((seqs + 1))209
ws=$(new_ws)210
if ! $body "$ws" > "$work/seq.log" 2>&1; then211
fail "arm1 [$label]: the mutation sequence itself failed"212
sed 's/^/ /' "$work/seq.log" | head -8 >&2213
return214
fi215
dump_to "$ws" "$work/incr.txt"216
probe rebuild "$ws" ops file > /dev/null || { fail "arm1 [$label]: rebuild failed"; return; }217
dump_to "$ws" "$work/rebuilt.txt"218
a=$(wc -l < "$work/incr.txt" | tr -d ' ')219
b=$(wc -l < "$work/rebuilt.txt" | tr -d ' ')220
if [ "$a" -eq 0 ] || [ "$b" -eq 0 ]; then221
# A FLOOR ON THE COMPARISON ITSELF. Two empty dumps are identical, and an222
# arm that scored that as convergence would pass hardest exactly when the223
# indexer had stopped working.224
fail "arm1 [$label]: a dump was EMPTY (incr=$a rebuild=$b); two empty dumps compare equal"225
return226
fi227
if diff -u "$work/incr.txt" "$work/rebuilt.txt" > "$work/diff.txt" 2>&1; then228
converged=$((converged + 1))229
echo " seq $seqs [$label]: incr=$a rebuild=$b lines ... CONVERGED"230
else231
fail "arm1 [$label]: incremental and rebuild DIVERGE (incr=$a rebuild=$b)"232
head -25 "$work/diff.txt" | sed 's/^/ /' >&2233
fi234
}236
seq_forward () {237
for f in $(find "$1/notes" -type f | sort); do238
probe index "$1" ops file "$f" > /dev/null || return 1239
done240
}241
seq_reverse () {242
for f in $(find "$1/notes" -type f | sort -r); do243
probe index "$1" ops file "$f" > /dev/null || return 1244
done245
}246
seq_twice () {247
seq_forward "$1" || return 1248
seq_forward "$1" || return 1249
}250
seq_edit () {251
seq_forward "$1" || return 1252
printf '\n@p{An appended paragraph mentioning [[topics/workflow]].}\n' >> "$1/notes/topics/beta.sgd"253
probe index "$1" ops file "$1/notes/topics/beta.sgd" > /dev/null || return 1254
}255
seq_delete () {256
seq_forward "$1" || return 1257
rm "$1/notes/topics/workflow.sgd"258
probe remove "$1" ops file "ops::topics/workflow::sgd" > /dev/null || return 1259
}260
seq_delete_readd () {261
cp "$1/notes/topics/workflow.sgd" "$work/workflow.keep"262
seq_forward "$1" || return 1263
rm "$1/notes/topics/workflow.sgd"264
probe remove "$1" ops file "ops::topics/workflow::sgd" > /dev/null || return 1265
cp "$work/workflow.keep" "$1/notes/topics/workflow.sgd"266
probe index "$1" ops file "$1/notes/topics/workflow.sgd" > /dev/null || return 1267
}268
# A LINK TARGET APPEARING LATE. This is the sequence that catches an edge269
# resolution frozen at insert time: alpha points at gamma, and here gamma is270
# indexed AFTER alpha, so an implementation that decided alpha's verdict when271
# alpha was touched would record `missing` and a rebuild would not.272
seq_target_late () {273
for f in $(find "$1/notes" -type f ! -name 'gamma.sgd' | sort); do274
probe index "$1" ops file "$f" > /dev/null || return 1275
done276
probe index "$1" ops file "$1/notes/topics/gamma.sgd" > /dev/null || return 1277
}278
# The mirror: a link target REMOVED after everything resolved against it.279
seq_target_removed () {280
seq_forward "$1" || return 1281
rm "$1/notes/topics/gamma.sgd"282
probe remove "$1" ops file "ops::topics/gamma::sgd" > /dev/null || return 1283
}284
# ONE SIDE OF A COLLISION ARRIVES LAST -- the slug genuinely goes from sole to285
# contested mid-sequence.286
#287
# THE SECOND SIDE IS HELD OFF DISK, and that is the whole sequence. The first288
# version of this only varied INDEX ORDER: `new_ws` copies the entire fixture289
# tree up front, so both sides were on disk from step one, the census reported290
# both contested immediately, and this ran the same experiment as291
# `seq_forward`. It converged, and it was testing nothing this file did not292
# already test. Found by an adversarial review; the transition it was named293
# for was a real convergence defect, and this shape is what reddens on it.294
seq_collision_late () {295
mv "$1/notes/topics/contested.md" "$work/contested-held.md" || return 1296
for f in $(find "$1/notes" -type f | sort); do297
probe index "$1" ops file "$f" > /dev/null || return 1298
done299
mv "$work/contested-held.md" "$1/notes/topics/contested.md" || return 1300
probe index "$1" ops file "$1/notes/topics/contested.md" > /dev/null || return 1301
}302
# The mirror: a collision DISSOLVES, and the survivor must stop being303
# contested. A reconciler that only ever sets the flag passes the sequence304
# above and fails this one.305
seq_collision_dissolved () {306
seq_forward "$1" || return 1307
rm "$1/notes/topics/contested.md"308
probe remove "$1" ops file "ops::topics/contested::markdown" > /dev/null || return 1309
}310
# A RENAME THAT CHANGES THE SLUG, applied incrementally. The new path indexes311
# under a NEW uid, so nothing the caller can say identifies the old document --312
# `delete-document-rows!` keys on the uid it is given. Before the reconciler,313
# the old document's rows survived forever, including edges still `resolved`314
# against a document that no longer existed (measured: incr 612 vs rebuild 593).315
# Arm 6(c) covers rename via a full REBUILD, so this path had no coverage.316
seq_rename_incremental () {317
seq_forward "$1" || return 1318
mv "$1/notes/topics/workflow.sgd" "$1/notes/topics/workflow-renamed.sgd"319
probe index "$1" ops file "$1/notes/topics/workflow-renamed.sgd" > /dev/null || return 1320
}322
run_seq "forward" seq_forward323
run_seq "reverse" seq_reverse324
run_seq "index-twice" seq_twice325
run_seq "edit-in-place" seq_edit326
run_seq "delete" seq_delete327
run_seq "delete-then-readd" seq_delete_readd328
run_seq "target-late" seq_target_late329
run_seq "target-removed" seq_target_removed330
run_seq "collision-late" seq_collision_late331
run_seq "collision-dissolved" seq_collision_dissolved332
run_seq "rename-incremental" seq_rename_incremental334
echo " DENOMINATOR : $seqs mutation sequences over a $corpus_files-file corpus; \335
$converged converged, $((seqs - converged)) diverged"336
if [ "$seqs" -lt 11 ]; then337
fail "arm1: only $seqs sequences ran; the arm is specified with 11"338
fi339
[ "$fails" -eq "$arm1_fails" ] && echo "arm 1 OK" || echo "arm 1 FAILED"340
echo341
fi342
# ==========================================================================343
# ARM 2 -- BACKLINKS BY TYPE344
# ==========================================================================345
#346
# Membership is asserted EXACTLY, not by count. A count is satisfied by the347
# right number of wrong answers, and this arm exists because "who points at348
# this, and how" is the query the whole edge table is for.349
if want_arm 2; then350
echo "--- arm 2: backlinks by type -----------------------------------------"351
arm2_fails=$fails352
ws=$(new_ws)353
probe rebuild "$ws" ops file > /dev/null || fail "arm2: rebuild failed"355
beta="ops::topics/beta::sgd#"356
probe backlinks "$ws" ops file "$beta" > "$work/bl.txt" || fail "arm2: backlinks probe failed"358
# Expected, derived from the fixtures rather than from a memory of them:359
# alpha declares depends-on -> beta (1)360
# alpha's prose mentions -> beta (2, deliberate twins)361
# plain.md mentions -> beta (1)362
# alpha declares invented-here -> beta (EXCLUDED: not a built-in kind)363
got_depends=$(grep -c "^BACKLINK depends-on ops::topics/alpha::sgd#$" "$work/bl.txt" || true)364
got_mentions=$(grep -c "^BACKLINK mentions " "$work/bl.txt" || true)365
got_invented=$(grep -c "^BACKLINK invented-here " "$work/bl.txt" || true)366
total=$(grep -c '^BACKLINK ' "$work/bl.txt" || true)367
kinds=$(field BACKLINK_KINDS 2 < "$work/bl.txt")369
[ "$got_depends" -eq 1 ] || fail "arm2: expected exactly 1 depends-on backlink from alpha, got $got_depends"370
[ "$got_mentions" -eq 3 ] || fail "arm2: expected 3 mentions backlinks (2 twins from alpha + 1 from plain.md), got $got_mentions"371
# THE EXCLUSION, ASSERTED RATHER THAN DOCUMENTED.372
[ "$got_invented" -eq 0 ] || fail "arm2: an UNREGISTERED edge kind appeared in a built-in invariant result"373
[ "$total" -eq 4 ] || fail "arm2: expected 4 backlink rows in total, got $total"374
[ "$kinds" = "2" ] || fail "arm2: expected 2 distinct kinds, got '$kinds'"376
# A node-level backlink, so the arm is not only exercising document nodes.377
qopen="ops::topics/questions::sgd#0.0"378
probe backlinks "$ws" ops file "$qopen" > "$work/bl2.txt" || fail "arm2: node backlinks probe failed"379
ans=$(grep -c "^BACKLINK answers ops::topics/gamma::sgd#$" "$work/bl2.txt" || true)380
[ "$ans" -eq 1 ] || fail "arm2: expected gamma's answers edge to land on the q-open NODE, got $ans"382
# A FLOOR ON THE INSTRUMENT. A backlink query that returned nothing for383
# everything would satisfy every "expected 0" above. Something must come back.384
[ "$total" -gt 0 ] || fail "arm2: the backlink query returned nothing at all"386
echo " DENOMINATOR : 2 targets queried; \387
$(probe edges "$ws" ops file | grep -c '^EDGE ' || true) edges in the graph, \388
$(probe edges "$ws" ops file | grep -c ' 1 1 ' || true) of them declared+built-in"389
echo " beta : depends-on=$got_depends mentions=$got_mentions unregistered=$got_invented total=$total kinds=$kinds"390
[ "$fails" -eq "$arm2_fails" ] && echo "arm 2 OK" || echo "arm 2 FAILED"391
echo392
fi394
# ==========================================================================395
# ARM 3 -- DEPENDENCY CYCLES (cap AND floor)396
# ==========================================================================397
#398
# "No cycles found" is what a working detector on a clean graph reports and it399
# is also what a detector that cannot see anything reports. The folio 0.2.10400
# integrity scanner said "No buried task ids found" over a corpus holding six.401
# So this arm requires BOTH: the planted cycle is found, and removing the one402
# edge that closes it makes the count drop to zero.403
#404
# The closing edge is spelled `blocks`, from the far end. A detector that only405
# followed `depends-on` would call this corpus a clean DAG.406
if want_arm 3; then407
echo "--- arm 3: dependency cycles -----------------------------------------"408
arm3_fails=$fails410
ws=$(new_ws)411
probe rebuild "$ws" ops file > /dev/null || fail "arm3: rebuild failed"412
probe cycles "$ws" ops file > "$work/cy.txt" || fail "arm3: cycles probe failed"413
n=$(field CYCLE_COUNT 2 < "$work/cy.txt")414
ring=$(grep '^CYCLE ' "$work/cy.txt" | head -1 | cut -f2)416
# FLOOR: the planted cycle must be found, and it must be THE planted one.417
[ "$n" = "1" ] || fail "arm3 FLOOR: expected exactly 1 cycle in the corpus, got '$n'"418
case "$ring" in419
*"topics/alpha"*"topics/beta"*"topics/gamma"*"topics/alpha"*) : ;;420
*) fail "arm3 FLOOR: the reported cycle is not alpha->beta->gamma->alpha: '$ring'" ;;421
esac423
# CAP: remove the one closing edge and the count must go to zero. Without424
# this, "1 cycle" could be produced by a detector that reports a cycle for425
# any graph at all.426
ws2=$(new_ws)427
sed -i '/CYCLE-CLOSING EDGE/d' "$ws2/notes/topics/alpha.sgd"428
probe rebuild "$ws2" ops file > /dev/null || fail "arm3: control rebuild failed"429
probe cycles "$ws2" ops file > "$work/cy2.txt" || fail "arm3: control cycles probe failed"430
n2=$(field CYCLE_COUNT 2 < "$work/cy2.txt")431
[ "$n2" = "0" ] || fail "arm3 CAP: with the closing edge removed the corpus is a DAG, but $n2 cycles were reported"433
# The control must have actually changed the input, or the CAP is a434
# measurement of the same file twice.435
if diff -q "$ws/notes/topics/alpha.sgd" "$ws2/notes/topics/alpha.sgd" > /dev/null 2>&1; then436
fail "arm3: the CAP control did not change alpha.sgd; the sed matched nothing"437
fi439
dep_edges=$(probe edges "$ws" ops file | grep -cE ' (depends-on|blocks) ' || true)440
echo " DENOMINATOR : $dep_edges dependency-relation edges (depends-on + blocks) over $corpus_files files"441
echo " FLOOR : planted cycle found ($n) -- $ring"442
echo " CAP : closing edge removed -> $n2 cycles"443
[ "$fails" -eq "$arm3_fails" ] && echo "arm 3 OK" || echo "arm 3 FAILED"444
echo445
fi447
# ==========================================================================448
# ARM 4 -- DECISION SUPERSESSION449
# ==========================================================================450
if want_arm 4; then451
echo "--- arm 4: decision supersession -------------------------------------"452
arm4_fails=$fails453
ws=$(new_ws)454
probe rebuild "$ws" ops file > /dev/null || fail "arm4: rebuild failed"455
probe supersessions "$ws" ops file > "$work/sup.txt" || fail "arm4: supersessions probe failed"456
n=$(field SUPERSESSION_COUNT 2 < "$work/sup.txt")457
[ "$n" = "1" ] || fail "arm4: expected exactly 1 supersession, got '$n'"459
# The superseding node must be the SUPERSEDED decision's replacement, and both460
# ends must be real nodes -- an edge to "" would count the same otherwise.461
line=$(grep '^SUPERSEDES ' "$work/sup.txt" | head -1)462
from=$(echo "$line" | cut -f2)463
to=$(echo "$line" | cut -f3)464
[ -n "$from" ] || fail "arm4: the supersession has an empty source"465
[ -n "$to" ] || fail "arm4: the supersession has an empty target"466
probe nodes "$ws" ops file > "$work/nodes.txt" || fail "arm4: nodes probe failed"467
grep -q "^NODE $from " "$work/nodes.txt" || fail "arm4: superseding uid $from is not a node in the graph"468
grep -q "^NODE $to " "$work/nodes.txt" || fail "arm4: superseded uid $to is not a node in the graph"470
# AN INDEPENDENT EXPECTATION, and the two checks above are why it is needed.471
#472
# Those compare the product's EDGE output against the product's own NODE473
# output: a product that minted a consistent wrong uid in both tables passes474
# them. That is the same defect as a differential over shared code, one level475
# down. These two assert the `node_id` column against the literals WRITTEN IN476
# `topics/decisions.sgd`, which no wrong product can move.477
from_id=$(grep "^NODE $from " "$work/nodes.txt" | head -1 | cut -f8)478
to_id=$(grep "^NODE $to " "$work/nodes.txt" | head -1 | cut -f8)479
[ "$from_id" = "d-path-identity" ] \480
|| fail "arm4: the superseding node's id should be d-path-identity (from the fixture), got '$from_id'"481
[ "$to_id" = "d-path-identity-v0" ] \482
|| fail "arm4: the superseded node's id should be d-path-identity-v0 (from the fixture), got '$to_id'"484
# The superseded decision must still carry status `superseded` in its fields:485
# the graph records the edge, the document records the state, and a phase that486
# made those disagree would have an index asserting something the source does487
# not say.488
st=$(grep "^NODE $to " "$work/nodes.txt" | head -1)489
echo "$st" | grep -q 'decision' || fail "arm4: the superseded node is not a decision: $st"491
decisions=$(grep -c ' decision ' "$work/nodes.txt" || true)492
echo " DENOMINATOR : $decisions decision nodes in the corpus; 1 supersedes edge expected, $n found"493
echo " edge : $from supersedes $to"494
[ "$fails" -eq "$arm4_fails" ] && echo "arm 4 OK" || echo "arm 4 FAILED"495
echo496
fi497
# ==========================================================================498
# ARM 5 -- ORPHAN / MISSING / AMBIGUOUS / EXPLICIT CROSS-WORKSPACE499
# ==========================================================================500
#501
# Four verdicts, four separate assertions, because collapsing any two of them is502
# the taxonomy's shape 17 and it is how a duplicated id gets treated as an503
# absent one. `cross-workspace` in particular is NOT a failure: it is the504
# answer "you did not ask me to find this here", and folding it into `missing`505
# would turn every legitimate foreign reference into a broken link.506
if want_arm 5; then507
echo "--- arm 5: orphan / missing / ambiguous / cross-workspace -------------"508
arm5_fails=$fails509
ws=$(new_ws)510
probe rebuild "$ws" ops file > /dev/null || fail "arm5: rebuild failed"511
probe unresolved "$ws" ops file > "$work/unres.txt" || fail "arm5: unresolved probe failed"512
probe orphans "$ws" ops file > "$work/orph.txt" || fail "arm5: orphans probe failed"513
probe edges "$ws" ops file > "$work/edges.txt" || fail "arm5: edges probe failed"515
declared=$(grep -c '^EDGE [^ ]* [^ ]* [01] 1 ' "$work/edges.txt" || true)516
missing=$(grep -c ' missing$' "$work/unres.txt" || true)517
ambiguous=$(grep -c ' ambiguous$' "$work/unres.txt" || true)518
crossws=$(grep -c ' cross-workspace$' "$work/unres.txt" || true)519
unres_total=$(field UNRESOLVED_COUNT 2 < "$work/unres.txt")520
orphans=$(field ORPHAN_COUNT 2 < "$work/orph.txt")522
# FLOOR -- one planted instance of each, by exact target.523
grep -q ' topics/nowhere missing$' "$work/unres.txt" \524
|| fail "arm5 FLOOR: the planted MISSING target topics/nowhere was not reported"525
grep -q ' topics/contested ambiguous$' "$work/unres.txt" \526
|| fail "arm5 FLOOR: the planted AMBIGUOUS target topics/contested was not reported"527
grep -q ' other topics/alpha cross-workspace$' "$work/unres.txt" \528
|| fail "arm5 FLOOR: the planted CROSS-WORKSPACE target other:topics/alpha was not reported"529
grep -q '^ORPHAN ops::topics/orphan::markdown$' "$work/orph.txt" \530
|| fail "arm5 FLOOR: the planted orphan note was not reported"532
# CAP -- and this is the half that matters. Every declared edge that is NOT533
# one of the three planted failures must be RESOLVED. Without it, an534
# implementation that marked everything `missing` would satisfy every FLOOR535
# above and read as a perfect score.536
[ "$missing" -eq 1 ] || fail "arm5 CAP: expected exactly 1 missing target, got $missing"537
[ "$ambiguous" -eq 1 ] || fail "arm5 CAP: expected exactly 1 ambiguous target, got $ambiguous"538
[ "$crossws" -eq 1 ] || fail "arm5 CAP: expected exactly 1 cross-workspace target, got $crossws"539
[ "$unres_total" = "3" ] || fail "arm5 CAP: expected 3 unresolved declared edges in total, got $unres_total"541
# AMBIGUOUS IS NOT MISSING -- asserted as a distinction, not assumed. The542
# contested slug HAS two documents in the graph; a lookup that answered543
# "missing" for it would be claiming the notes are absent when both are here.544
probe documents "$ws" ops file > "$work/docs.txt" || fail "arm5: documents probe failed"545
contested_docs=$(grep -c '^DOCUMENT ops::topics/contested::' "$work/docs.txt" || true)546
[ "$contested_docs" -eq 2 ] \547
|| fail "arm5: the ambiguous slug should have 2 documents indexed, got $contested_docs"549
# Orphan CAP: a note that IS linked must not be listed.550
grep -q '^ORPHAN ops::topics/alpha::sgd$' "$work/orph.txt" \551
&& fail "arm5 CAP: alpha is heavily linked and must not be reported as an orphan"553
# THE DIAGNOSTICS TABLE, ASSERTED RATHER THAN MERELY DUMPED.554
#555
# Diagnostic rows are inside the convergence relation, so arm 1 catches a556
# DIVERGENCE in them -- but `index-source!` guards every per-file failure into557
# one `index-failure` row, and BOTH paths take that guard identically, so a558
# document that had become unindexable would converge perfectly. Nothing else559
# in this gate reads diagnostic CONTENT. Two literals close it: every fixture560
# is indexed, and none of them failed.561
probe diagnostics "$ws" ops file > "$work/diag5.txt" || fail "arm5: diagnostics probe failed"562
doc_count=$(field DOCUMENT_COUNT 2 < "$work/docs.txt")563
[ "$doc_count" = "$corpus_files" ] \564
|| fail "arm5: expected $corpus_files documents indexed, got $doc_count"565
idxfail=$(grep -c ' index-failure ' "$work/diag5.txt" || true)566
[ "$idxfail" -eq 0 ] \567
|| fail "arm5: $idxfail document(s) failed to index -- the guard turned a defect into a diagnostic row"568
contested_diags=$(grep -c ' contested-source ' "$work/diag5.txt" || true)569
[ "$contested_diags" -eq 2 ] \570
|| fail "arm5: expected 2 contested-source diagnostics (one per side of the collision), got $contested_diags"572
# D12's SECOND GRAMMAR. `[[t-rebuild]]` is a NODE ID, not a document573
# address, and nothing in the syntax says which a bare target is. Measured574
# over the live estate: 4,510 of 29,260 link occurrences are task ids -- the575
# largest single class -- and every one read `missing` before a bare target576
# was tried as a node id. A broken-links report in which 82% of the entries577
# are correct references into another address space is one nobody can act on.578
#579
# Its own workspace, so arm 5's literal counts above stay put. Both580
# directions: a real node id resolves TO THE NODE, an invented one stays581
# missing -- without the second half this passes on a module that resolves582
# every bare target to something.583
ws5=$(new_ws)584
printf 'Tags: f4a\n# Node ref\n\nBy node id [[t-rebuild]], and [[t-nosuchtask]] which does not exist.\n' \585
> "$ws5/notes/topics/noderef.md"586
probe rebuild "$ws5" ops file > /dev/null || fail "arm5: node-id rebuild failed"587
probe edges "$ws5" ops file > "$work/e5b.txt" || fail "arm5: node-id edges probe failed"588
nid_res=$(awk -F'\t' '$1 == "EDGE" && $8 == "t-rebuild" { print $11 }' "$work/e5b.txt" | head -1)589
nid_uid=$(awk -F'\t' '$1 == "EDGE" && $8 == "t-rebuild" { print $10 }' "$work/e5b.txt" | head -1)590
bad_res=$(awk -F'\t' '$1 == "EDGE" && $8 == "t-nosuchtask" { print $11 }' "$work/e5b.txt" | head -1)591
[ "$nid_res" = "resolved" ] \592
|| fail "arm5: a bare node-id target read '$nid_res'; D12's second grammar is not being tried"593
case "$nid_uid" in594
*"::sgd#"*) : ;;595
*) fail "arm5: the node-id target resolved to '$nid_uid', which is not a node inside a document" ;;596
esac597
[ "$bad_res" = "missing" ] \598
|| fail "arm5 RESTRAINT: an invented node id read '$bad_res', not 'missing'; \599
bare targets are resolving to something regardless of whether it exists"601
echo " node ids : [[t-rebuild]] -> $nid_uid; [[t-nosuchtask]] -> $bad_res"602
echo " diagnostics : $(field DIAGNOSTIC_COUNT 2 < "$work/diag5.txt") rows; \603
index-failure=$idxfail contested-source=$contested_diags; $doc_count of $corpus_files files indexed"604
echo " DENOMINATOR : $declared declared edges; $unres_total unresolved \605
(missing=$missing ambiguous=$ambiguous cross-workspace=$crossws), $((declared - unres_total)) resolved"606
echo " orphans : $orphans of $(field DOCUMENT_COUNT 2 < "$work/docs.txt") documents"607
[ "$fails" -eq "$arm5_fails" ] && echo "arm 5 OK" || echo "arm 5 FAILED"608
echo609
fi611
# ==========================================================================612
# ARM 6 -- PATH/PROVIDER MOVE WITH STABLE WORKSPACE-QUALIFIED IDENTITY613
# ==========================================================================614
#615
# Three obligations, and the third is the one that keeps the first honest:616
#617
# (a) moving the root AND changing the provider changes no uid at all;618
# (b) the `path` column DOES follow the move -- provenance stays true. Path619
# is excluded from the convergence relation on purpose, so nothing else620
# in this gate would notice if it went stale, and a stale absolute path621
# pointing into a deleted directory reads as corruption;622
# (c) FLOOR: something that SHOULD move identity does. Renaming a note so its623
# slug changes must produce a different doc uid. Without (c), an624
# implementation whose uids never changed for any reason would score a625
# perfect (a).626
if want_arm 6; then627
echo "--- arm 6: path/provider move with stable identity --------------------"628
arm6_fails=$fails630
ws=$(new_ws)631
probe rebuild "$ws" ops file > /dev/null || fail "arm6: rebuild failed"632
dump_to "$ws" "$work/before.txt"633
probe documents "$ws" ops file | grep '^DOCUMENT ' | cut -f2 | sort > "$work/uids-before.txt"634
before_path=$(probe documents "$ws" ops file | grep '^DOCUMENT ops::topics/alpha::sgd ' | cut -f5)636
# The move: a different directory AND a different provider name.637
moved="$work/moved.$$"638
mv "$ws" "$moved" || setup_failed "arm6: could not move the workspace"639
probe remount "$moved" ops git > /dev/null || fail "arm6: remount failed"641
dump_to "$moved" "$work/after.txt"642
probe documents "$moved" ops git | grep '^DOCUMENT ' | cut -f2 | sort > "$work/uids-after.txt"643
after_path=$(probe documents "$moved" ops git | grep '^DOCUMENT ops::topics/alpha::sgd ' | cut -f5)645
# AN INDEPENDENT FLOOR UNDER (a). "the uid set is unchanged" is satisfied by646
# two EMPTY sets, so the size is pinned to the corpus's file count -- a647
# literal the product cannot move -- before the invariance claim is made.648
uids_n=$(wc -l < "$work/uids-before.txt" | tr -d ' ')649
[ "$uids_n" -eq "$corpus_files" ] \650
|| fail "arm6 FLOOR: expected $corpus_files document uids before the move, got $uids_n; \651
an empty set would satisfy the invariance check below"653
# (a) identity654
if diff -u "$work/uids-before.txt" "$work/uids-after.txt" > "$work/uid-diff.txt" 2>&1; then655
echo " identity : $(wc -l < "$work/uids-before.txt" | tr -d ' ') document uids unchanged across the move"656
else657
fail "arm6 (a): document uids CHANGED across a path+provider move"658
head -10 "$work/uid-diff.txt" | sed 's/^/ /' >&2659
fi660
if diff -u "$work/before.txt" "$work/after.txt" > "$work/rel-diff.txt" 2>&1; then661
echo " relation : the full relation dump is unchanged across the move"662
else663
fail "arm6 (a): the relation dump changed across a path+provider move"664
head -15 "$work/rel-diff.txt" | sed 's/^/ /' >&2665
fi667
# (b) provenance -- the assertion that pays for excluding `path` from (a).668
[ -n "$before_path" ] || fail "arm6 (b): could not read alpha's path before the move"669
[ -n "$after_path" ] || fail "arm6 (b): could not read alpha's path after the move"670
[ "$before_path" != "$after_path" ] \671
|| fail "arm6 (b): the path column did NOT follow the move (still $after_path)"672
case "$after_path" in673
"$moved"/*) echo " provenance : path followed the move -> $after_path" ;;674
*) fail "arm6 (b): the path column does not point into the new root: $after_path" ;;675
esac676
[ -f "$after_path" ] || fail "arm6 (b): the recorded path does not exist on disk: $after_path"678
# (d) THE HARD ONE: a full REBUILD after the move must converge with the679
# pre-move graph. (a) only shows that nothing was rewritten; this shows that680
# re-deriving everything from the new location produces the same relation --681
# which is the actual claim that the relation is path-independent, and the one682
# that catches a mount point smuggled in through some other column. An683
# absolute path embedded in a diagnostic MESSAGE is exactly that, and it is684
# what this check was added for.685
probe rebuild "$moved" ops git > /dev/null || fail "arm6 (d): rebuild after the move failed"686
dump_to "$moved" "$work/rebuilt-after-move.txt"687
if diff -u "$work/before.txt" "$work/rebuilt-after-move.txt" > "$work/move-rb-diff.txt" 2>&1; then688
echo " path-indep : a full rebuild under the new root converges with the pre-move graph"689
else690
fail "arm6 (d): a rebuild after the move does NOT converge with the pre-move graph -- \691
something path-derived is inside the convergence relation"692
head -15 "$work/move-rb-diff.txt" | sed 's/^/ /' >&2693
fi695
# (c) FLOOR -- identity is not simply frozen.696
ws3=$(new_ws)697
probe rebuild "$ws3" ops file > /dev/null || fail "arm6: floor rebuild failed"698
mv "$ws3/notes/topics/workflow.sgd" "$ws3/notes/topics/workflow-renamed.sgd"699
probe rebuild "$ws3" ops file > /dev/null || fail "arm6: floor re-rebuild failed"700
probe documents "$ws3" ops file > "$work/docs3.txt" || fail "arm6: floor documents failed"701
if grep -q '^DOCUMENT ops::topics/workflow::sgd ' "$work/docs3.txt"; then702
fail "arm6 (c) FLOOR: renaming a note to a different SLUG left its old uid in place -- \703
identity is frozen rather than derived, and (a) proves nothing"704
fi705
grep -q '^DOCUMENT ops::topics/workflow-renamed::sgd ' "$work/docs3.txt" \706
|| fail "arm6 (c) FLOOR: the renamed note did not appear under its new uid"708
echo " DENOMINATOR : $(wc -l < "$work/uids-before.txt" | tr -d ' ') documents moved; \709
1 slug rename as the floor"710
[ "$fails" -eq "$arm6_fails" ] && echo "arm 6 OK" || echo "arm 6 FAILED"711
echo712
fi713
# ==========================================================================714
# ARM 7 -- CORRUPT / MISSING DATABASE RECOVERY715
# ==========================================================================716
#717
# Recovery is not "did not crash". A database that opens and answers WRONG is718
# worse than one that refuses, so this arm requires three things of each case:719
# the open reports the right STATE (`created` / `recovered` / `intact` -- three720
# answers, never one), the rebuilt graph converges with the pre-damage graph,721
# and -- the floor -- an undamaged open reports `intact`, so "recovered" is not722
# simply what this code says every time.723
if want_arm 7; then724
echo "--- arm 7: corrupt / missing database recovery ------------------------"725
arm7_fails=$fails726
cases=0727
recovered=0729
ws=$(new_ws)730
st=$(probe rebuild "$ws" ops file | field OPEN 5)731
[ "$st" = "created" ] || fail "arm7: a workspace with no database should open 'created', got '$st'"732
dump_to "$ws" "$work/healthy.txt"733
healthy_lines=$(wc -l < "$work/healthy.txt" | tr -d ' ')734
[ "$healthy_lines" -gt 100 ] || fail "arm7: the healthy baseline is only $healthy_lines lines"736
# FLOOR ON THE STATE REPORT: an untouched database must say `intact`.737
st=$(probe documents "$ws" ops file | field OPEN 5)738
[ "$st" = "intact" ] || fail "arm7 FLOOR: an undamaged database opened '$st', not 'intact'; \739
if every open says 'recovered' the cases below assert nothing"740
echo " floor : undamaged open reports 'intact'"742
damage () {743
label=$1; how=$2744
cases=$((cases + 1))745
d=$(new_ws)746
probe rebuild "$d" ops file > /dev/null || { fail "arm7 [$label]: setup rebuild failed"; return; }747
eval "$how"748
st=$(probe meta "$d" ops file | field OPEN_STATE 2)749
case "$label" in750
missing) want=created ;;751
*) want=recovered ;;752
esac753
if [ "$st" != "$want" ]; then754
fail "arm7 [$label]: open reported '$st', expected '$want'"755
return756
fi757
recovered=$((recovered + 1))758
probe rebuild "$d" ops file > "$work/rec-rebuild.txt" \759
|| { fail "arm7 [$label]: rebuild after recovery failed"; return; }761
# AN INDEPENDENT EXPECTATION BEFORE THE SELF-REFERENTIAL ONE.762
#763
# The baseline comparison below is product-vs-product: if row construction764
# were broken, the healthy dump and the recovered dump would be broken765
# identically and match. These two are literals -- the corpus's file count,766
# and a doc uid spelled out here -- so a recovery that produced an empty or767
# differently-keyed graph fails whatever the baseline says.768
rec_docs=$(field DOCS 2 < "$work/rec-rebuild.txt")769
[ "$rec_docs" = "$corpus_files" ] \770
|| fail "arm7 [$label]: recovered graph has $rec_docs documents, expected $corpus_files"771
probe documents "$d" ops file | grep -q '^DOCUMENT ops::topics/alpha::sgd ' \772
|| fail "arm7 [$label]: the recovered graph does not contain ops::topics/alpha::sgd"774
dump_to "$d" "$work/rec.txt"775
if diff -u "$work/healthy.txt" "$work/rec.txt" > "$work/rec-diff.txt" 2>&1; then776
echo " case $cases [$label]: state=$st, rebuilt graph converges with the healthy baseline"777
else778
fail "arm7 [$label]: the recovered graph does NOT match the healthy baseline"779
head -12 "$work/rec-diff.txt" | sed 's/^/ /' >&2780
fi781
}783
damage missing 'rm -f "$d/.folio-graph.db" "$d/.folio-graph.db-wal" "$d/.folio-graph.db-shm"'784
damage truncated 'true > "$d/.folio-graph.db"'785
damage garbage 'head -c 40000 /dev/urandom > "$d/.folio-graph.db"'786
damage header-only 'printf "SQLite format 3\000this is not a real database" > "$d/.folio-graph.db"'787
# A structurally VALID SQLite file that is simply not our schema.788
damage foreign-schema 'probe damage-foreign "$d" > /dev/null'789
# Our tables, at a schema version these queries were not written for.790
damage stale-version 'probe damage-version "$d" > /dev/null'791
# Our schema minus one table -- the shape a partial migration leaves.792
damage dropped-table 'probe damage-drop-table "$d" > /dev/null'794
echo " DENOMINATOR : $cases damage cases; $recovered reported the expected open state and reconverged"795
[ "$cases" -ge 7 ] || fail "arm7: only $cases damage cases ran; the arm is specified with 7"796
[ "$fails" -eq "$arm7_fails" ] && echo "arm 7 OK" || echo "arm 7 FAILED"797
echo798
fi800
# ==========================================================================801
# ARM 8 -- ALL FOURTEEN BUILT-IN KINDS, AND THE UNKNOWN-KIND EXCLUSION802
# ==========================================================================803
#804
# Two claims, and the second is the one the plan calls out as deliberate:805
# unknown custom edge kinds remain INSPECTABLE DATA and are EXCLUDED from the806
# built-in invariants. Both halves are asserted -- the unknown edge is in the807
# table with its target resolved, and it is absent from every invariant result a808
# known kind in the same position appears in.809
#810
# The gate also checks this module's ONE restatement. `(folio graph)` carries811
# name-level `container-names` / `work-item-names` sets because the model812
# classifies RECORDS and the inspector hands out NAMES, and no exported813
# name-level classifier exists. A restatement goes stale silently, so the union814
# of those sets plus the relational and record names must be EXACTLY the frozen815
# vocabulary as the artifact reports it.816
if want_arm 8; then817
echo "--- arm 8: fourteen kinds + unknown-kind exclusion --------------------"818
arm8_fails=$fails819
probe vocabulary > "$work/vocab.txt" || fail "arm8: vocabulary probe failed"821
# The list is ENUMERATED FROM THE MODEL by the product, and compared here822
# against the plan's fourteen. If the model gains a kind this fails loudly823
# rather than the graph quietly ignoring it.824
grep '^EDGE_KIND ' "$work/vocab.txt" | cut -f2 | sort > "$work/kinds.txt"825
cat > "$work/kinds-expected.txt" <<'EOK'826
answers827
belongs-to828
blocks829
contains830
defines-term831
depends-on832
follows-procedure833
governed-by-workflow834
mentions835
references-term836
related-to837
supersedes838
supports839
transcludes840
EOK841
if diff -u "$work/kinds-expected.txt" "$work/kinds.txt" > "$work/kd.txt" 2>&1; then842
echo " kinds : all 14 plan kinds present, enumerated from (folio doc model)"843
else844
fail "arm8: the model's built-in edge kinds are not the plan's fourteen"845
cat "$work/kd.txt" | sed 's/^/ /' >&2846
fi848
# The restatement check.849
grep '^VOCAB_NAME ' "$work/vocab.txt" | cut -f2 | sort > "$work/vocab-names.txt"850
cat > "$work/vocab-expected.txt" <<'EOV'851
decision852
milestone853
note854
phase855
project856
query857
question858
related859
task860
transclude861
update862
EOV863
if diff -u "$work/vocab-expected.txt" "$work/vocab-names.txt" > "$work/vd.txt" 2>&1; then864
echo " vocabulary : frozen names unchanged; graph.sgl's container/work-item split still covers them"865
else866
fail "arm8: the frozen vocabulary moved. (folio graph)'s container-names/work-item-names \867
sets are a name-level RESTATEMENT of a categorisation the model makes over records, and they must be revisited."868
cat "$work/vd.txt" | sed 's/^/ /' >&2869
fi871
ws=$(new_ws)872
probe rebuild "$ws" ops file > /dev/null || fail "arm8: rebuild failed"873
probe edges "$ws" ops file > "$work/e8.txt" || fail "arm8: edges probe failed"875
# SUPPORTED MEANS EXERCISED, NOT LISTED.876
#877
# The check above only shows the MODEL names fourteen kinds. A graph that878
# could store none of them would pass it. So every one of the fourteen must879
# appear as a real edge produced from the fixture corpus, and the ones that880
# are missing are NAMED -- "13 of 14" with no list is a number nobody can act881
# on.882
present=0883
missing_kinds=""884
while read -r k; do885
if grep -q " $k " "$work/e8.txt"; then886
present=$((present + 1))887
else888
missing_kinds="$missing_kinds $k"889
fi890
done < "$work/kinds-expected.txt"891
if [ "$present" -eq 14 ]; then892
echo " exercised : all 14 built-in kinds occur as real edges in the corpus"893
else894
fail "arm8: only $present of 14 built-in edge kinds are exercised by the corpus; missing:$missing_kinds"895
fi897
# HALF ONE: the unknown kind is STORED, with its target RESOLVED like any898
# other edge. "Excluded from invariants" must not have quietly become899
# "dropped", and it must not have become "left unresolved" either.900
unknown_line=$(grep ' invented-here ' "$work/e8.txt" | head -1)901
[ -n "$unknown_line" ] || fail "arm8: the unregistered edge kind is not in the edges table at all"902
echo "$unknown_line" | grep -q ' 0 1 ' \903
|| fail "arm8: the unregistered edge is not marked kind_known=0/declared=1: $unknown_line"904
echo "$unknown_line" | grep -q ' resolved$' \905
|| fail "arm8: the unregistered edge's target was not resolved: $unknown_line"907
# HALF TWO: it is excluded from the invariants. The comparison is made908
# against a KNOWN kind in the SAME position -- alpha's depends-on also points909
# at beta -- so the exclusion is shown to be about the KIND and not about the910
# edge being unreachable.911
probe backlinks "$ws" ops file "ops::topics/beta::sgd#" > "$work/bl8.txt" || fail "arm8: backlinks failed"912
grep -q '^BACKLINK depends-on ' "$work/bl8.txt" \913
|| fail "arm8 CONTROL: the known-kind edge in the same position is missing, so the exclusion below proves nothing"914
grep -q '^BACKLINK invented-here ' "$work/bl8.txt" \915
&& fail "arm8: the unregistered kind appeared in backlinks-by-type"917
# And in the other two invariants, via a corpus that spells a CYCLE and a918
# SUPERSESSION in an unregistered kind. If exclusion were only implemented in919
# the backlink query this is where it shows.920
ws9=$(new_ws)921
cat > "$ws9/notes/topics/unknown-cycle.sgd" <<'EOU'922
#lang sigil/doc923
@note["topics/unknown-cycle" title: "Unknown-kind cycle" tags: '("f4a")]925
@p{These two edges spell a cycle and a supersession in a kind nobody registered.926
Stored and readable; invisible to the built-in invariants.}928
@related[document: "topics/unknown-cycle" kind: 'invented-cycle]{a self-loop}929
@related[document: "topics/glossary" node: "term-graph" kind: 'invented-supersede]{not a supersession}931
@end note932
EOU933
probe rebuild "$ws9" ops file > /dev/null || fail "arm8: unknown-cycle rebuild failed"934
probe edges "$ws9" ops file > "$work/e9.txt" || fail "arm8: unknown-cycle edges failed"935
# Count the two NEW unregistered kinds by name. A bare `invented-` prefix936
# also matches the base corpus's `invented-here`, which made this assertion937
# fail for the wrong reason on its first run -- a gate bug, and the kind that938
# would have read as a product defect.939
stored=$(grep -cE ' invented-(cycle|supersede) ' "$work/e9.txt" || true)940
[ "$stored" -eq 2 ] || fail "arm8: expected 2 unregistered edges stored, got $stored"941
grep -q ' invented-here ' "$work/e9.txt" \942
|| fail "arm8: the base corpus's unregistered edge vanished when a second one was added"943
c9=$(probe cycles "$ws9" ops file | field CYCLE_COUNT 2)944
# The corpus's own alpha-beta-gamma cycle is still there; the unknown self-loop945
# must NOT add a second one.946
[ "$c9" = "1" ] || fail "arm8: an unregistered kind changed the cycle count ($c9, expected 1)"947
s9=$(probe supersessions "$ws9" ops file | field SUPERSESSION_COUNT 2)948
[ "$s9" = "1" ] || fail "arm8: an unregistered kind changed the supersession count ($s9, expected 1)"950
total_edges=$(field EDGE_COUNT 2 < "$work/e9.txt")951
echo " DENOMINATOR : $total_edges edges; $stored unregistered, stored and readable, \952
0 admitted to backlinks / cycles / supersession"953
[ "$fails" -eq "$arm8_fails" ] && echo "arm 8 OK" || echo "arm 8 FAILED"954
echo955
fi957
# ==========================================================================958
# ARM 9 -- FTS TEXT AND SPANS/REVISIONS ARE REAL TABLES DOING REAL WORK959
# ==========================================================================960
#961
# The spec names six table groups. Documents, nodes, edges and diagnostics are962
# asserted by every arm above; FTS and spans/revisions are not, because FTS is a963
# projection (deliberately outside the convergence relation, see the module964
# header) and revisions are an append-only log. So they are covered965
# BEHAVIOURALLY here: a planted term must be FOUND, a term that is not there966
# must NOT be, and a multi-byte span must have a byte offset that differs from967
# its scalar offset -- an ASCII-only corpus satisfies that for free.968
if want_arm 9; then969
echo "--- arm 9: FTS, spans and revisions ----------------------------------"970
arm9_fails=$fails971
ws=$(new_ws)972
probe rebuild "$ws" ops file > /dev/null || fail "arm9: rebuild failed"974
hits=$(probe search "$ws" ops file "Πολύγλωσσο" | field HIT_COUNT 2)975
[ "$hits" = "1" ] || fail "arm9: FTS did not find the planted multi-byte term (hits=$hits)"976
miss=$(probe search "$ws" ops file "zzzznotpresentzzzz" | field HIT_COUNT 2)977
[ "$miss" = "0" ] || fail "arm9 RESTRAINT: FTS returned $miss hits for a term that is not in the corpus"978
echo " fts : planted term found (1), absent term not found (0)"980
probe spans "$ws" ops file > "$work/spans.txt" || fail "arm9: spans probe failed"981
span_total=$(field SPAN_COUNT 2 < "$work/spans.txt")982
[ "$span_total" -gt 0 ] || fail "arm9: no spans were recorded at all"983
# byte_start != scalar_start somewhere, which can only happen after multi-byte984
# text. The columns are SPAN owner_kind owner_uid part byte_start byte_length985
# scalar_start scalar_end line column_no -- so byte_start is $5 and986
# scalar_start is $7. The first version of this line compared $4 (`part`, a987
# word) against $6 (`byte_length`, a number); they differ for every row, so it988
# reported "258 of 258 divergent" and would have passed on a pure-ASCII989
# corpus. A vacuous assert that prints a large, plausible number.990
divergent=$(awk -F'\t' '$1 == "SPAN" && $5 != "" && $7 != "" && $5 != $7' "$work/spans.txt" | wc -l | tr -d ' ')991
same=$(awk -F'\t' '$1 == "SPAN" && $5 != "" && $7 != "" && $5 == $7' "$work/spans.txt" | wc -l | tr -d ' ')992
[ "$divergent" -gt 0 ] \993
|| fail "arm9: every span has byte_start == scalar_start; the corpus is effectively ASCII \994
and the span columns are not being exercised"995
# ...AND a floor from the other side: if EVERY span diverged, the two columns996
# are probably not what this check thinks they are. Most of the corpus is997
# ASCII and must agree.998
[ "$same" -gt 0 ] \999
|| fail "arm9: NO span has byte_start == scalar_start; most of this corpus is ASCII, \1000
so the comparison is almost certainly reading the wrong columns"1001
echo " spans : $span_total spans; $divergent with byte offset != scalar offset, $same equal"1003
probe revisions "$ws" ops file > "$work/revs.txt" || fail "arm9: revisions probe failed"1004
rev_total=$(field REVISION_COUNT 2 < "$work/revs.txt")1005
[ "$rev_total" -eq 12 ] || fail "arm9: expected 12 revision rows after a rebuild, got $rev_total"1006
bad_rev=$(awk -F'\t' '$1 == "REVISION" && $3 !~ /^sha256:[0-9a-f]{64}$/' "$work/revs.txt" | wc -l | tr -d ' ')1007
[ "$bad_rev" -eq 0 ] || fail "arm9: $bad_rev revision values are not lowercase sha256: hex"1009
# The revisions LOG grows on edit while the CURRENT revision is replaced.1010
# This is the fact that justifies keeping the log out of the convergence1011
# relation, so it is measured rather than asserted in a comment.1012
printf '\n@p{edited}\n' >> "$ws/notes/topics/workflow.sgd"1013
probe index "$ws" ops file "$ws/notes/topics/workflow.sgd" > /dev/null || fail "arm9: re-index failed"1014
rev_after=$(probe revisions "$ws" ops file | field REVISION_COUNT 2)1015
[ "$rev_after" -eq 13 ] \1016
|| fail "arm9: the revision LOG should have grown to 13 after one edit, got $rev_after"1017
cur=$(probe documents "$ws" ops file | grep '^DOCUMENT ops::topics/workflow::sgd ' | wc -l | tr -d ' ')1018
[ "$cur" -eq 1 ] || fail "arm9: a document must have exactly one CURRENT row, got $cur"1019
echo " revisions : log $rev_total -> $rev_after after one edit; current rows still 1 per document"1021
echo " DENOMINATOR : $corpus_files documents; $span_total spans; $rev_after revision observations"1022
[ "$fails" -eq "$arm9_fails" ] && echo "arm 9 OK" || echo "arm 9 FAILED"1023
echo1024
fi1026
# ==========================================================================1027
# ARM 10 -- A PROSE MENTION MUST BE A DOCUMENT ADDRESS1028
# ==========================================================================1029
#1030
# `extract-crosslinks` matches `[[...]]` anywhere in the source, and bash spells1031
# its test command `[[ -f "$x" ]]`. Measured over the live estate: 3,8041032
# distinct targets, of which the filter drops 111 -- 49 pasted opaque ids, 241033
# repo-relative paths, 16 prose placeholders, 12 shell test expressions, 3 bare1034
# anchors -- and NONE of the 111 resolve to a note that exists.1035
#1036
# An earlier version of this comment said "1,397 of them shell syntax". That1037
# was the count of targets that do not RESOLVE, not of shell forms, and it was1038
# wrong by about a hundred times. Kept visible rather than edited away.1039
#1040
# The filter is D12's document-address grammar, taken from `(folio doc model)`.1041
# This arm asserts FOUR directions, because a filter is exactly the kind of1042
# check that is easy to make too strong and impossible to notice -- and the1043
# aliased-link case below is one it WAS too strong for, dropping 15 real links1044
# in the estate until the target/alias split was added:1045
#1046
# (a) a real link becomes an edge1047
# (b) shell syntax in a fenced code block becomes NO edge, and the skipped1048
# count is REPORTED rather than swallowed1049
# (c) RESTRAINT: a genuinely foreign `workspace:slug` target still becomes a1050
# cross-workspace edge -- without this the arm passes on a module that1051
# simply stopped emitting mentions1052
if want_arm 10; then1053
echo "--- arm 10: prose mentions must be document addresses ------------------"1054
arm10_fails=$fails1055
ws=$(mktemp -d "$work/ws10.XXXXXX"); mkdir -p "$ws/notes/topics"1056
printf 'Tags: t\n# Target\n\nA real note.\n' > "$ws/notes/topics/target.md"1057
cat > "$ws/notes/index.md" <<'EOF10'1058
Tags: t1059
# Index1061
A real link to [[topics/target]] and a foreign one to [[elsewhere:topics/x]].1063
```sh1064
if [[ -f "$src_bin" ]]; then1065
[[ "$status" -eq 124 ]] && echo timeout1066
[[ -n $leaf ]] || exit 11067
fi1068
```1069
EOF101070
probe rebuild "$ws" ops file > /dev/null || fail "arm10: rebuild failed"1071
probe edges "$ws" ops file > "$work/e10.txt" || fail "arm10: edges probe failed"1072
probe diagnostics "$ws" ops file > "$work/d10.txt" || fail "arm10: diagnostics probe failed"1074
edge_field () { awk -F'\t' -v k="$1" -v n="$2" \1075
'$1 == "EDGE" && $8 == k { print $n }' "$work/e10.txt" | head -1; }1077
# (a) the real link is an edge, resolved to the real document1078
[ "$(edge_field 'topics/target' 11)" = "resolved" ] \1079
|| fail "arm10: the real link did not resolve (got '$(edge_field 'topics/target' 11)')"1080
[ "$(edge_field 'topics/target' 10)" = "ops::topics/target::markdown#" ] \1081
|| fail "arm10: the real link resolved to '$(edge_field 'topics/target' 10)'"1083
# (b) THE FILTER. Three bash test expressions, zero edges, and the count said.1084
shell_edges=$(awk -F'\t' '$1 == "EDGE" && ($8 ~ /\$/ || $8 ~ /"/ || $8 ~ /^ / || $8 ~ /-eq|-f |-n /)' \1085
"$work/e10.txt" | wc -l | tr -d ' ')1086
[ "$shell_edges" -eq 0 ] \1087
|| fail "arm10: $shell_edges shell test expression(s) became mentions edges"1088
total_edges=$(field EDGE_COUNT 2 < "$work/e10.txt")1089
[ "$total_edges" -eq 2 ] \1090
|| fail "arm10: expected exactly 2 mentions edges (1 local + 1 foreign), got $total_edges"1091
skipped=$(grep -c ' unaddressable-mention ' "$work/d10.txt" || true)1092
[ "$skipped" -eq 1 ] \1093
|| fail "arm10: the skipped mentions were not reported as a diagnostic (got $skipped rows)"1094
grep -q ' unaddressable-mention 3 bracketed' "$work/d10.txt" \1095
|| fail "arm10: the diagnostic does not report 3 skipped spans: $(grep unaddressable "$work/d10.txt")"1097
# (d) AN ALIASED LINK IS A LINK. `[[target|shown text]]` is ordinary wiki1098
# syntax and `extract-crosslinks` returns the whole string, alias included, so1099
# a naive address filter classifies every one of them as unaddressable.1100
# Measured over the estate: 15 real links dropped that way, found by LISTING1101
# what the filter removed rather than counting it -- the count said "none of1102
# the dropped targets resolve", which was true only because the resolve check1103
# was also looking at the alias.1104
ws2=$(mktemp -d "$work/ws10b.XXXXXX"); mkdir -p "$ws2/notes/topics"1105
printf 'Tags: t\n# Target\n\nreal\n' > "$ws2/notes/topics/target.md"1106
printf 'Tags: t\n# Index\n\nAliased [[topics/target|some display text]].\n' > "$ws2/notes/index.md"1107
probe rebuild "$ws2" ops file > /dev/null || fail "arm10: alias rebuild failed"1108
probe edges "$ws2" ops file > "$work/e10b.txt" || fail "arm10: alias edges probe failed"1109
alias_res=$(awk -F'\t' '$1 == "EDGE" && $8 == "topics/target" { print $11 }' "$work/e10b.txt" | head -1)1110
[ "$alias_res" = "resolved" ] \1111
|| fail "arm10 (d): an aliased link [[target|text]] did not resolve (got '$alias_res'); \1112
the address filter is eating the alias along with the target"1113
alias_diag=$(probe diagnostics "$ws2" ops file | grep -c ' unaddressable-mention ' || true)1114
[ "$alias_diag" -eq 0 ] \1115
|| fail "arm10 (d): an aliased link was reported unaddressable"1117
# (c) RESTRAINT -- the foreign target still reads cross-workspace1118
[ "$(edge_field 'topics/x' 11)" = "cross-workspace" ] \1119
|| fail "arm10 RESTRAINT: the foreign target reads '$(edge_field 'topics/x' 11)'; \1120
the filter swallowed it instead of classifying it"1121
[ "$(edge_field 'topics/x' 7)" = "elsewhere" ] \1122
|| fail "arm10 RESTRAINT: the foreign edge lost its workspace name"1124
echo " DENOMINATOR : 5 bracketed spans in 1 note; 2 are addresses, 3 are shell syntax"1125
echo " result : $total_edges edges (1 resolved, 1 cross-workspace), $skipped diagnostic reporting 3 skipped"1126
[ "$fails" -eq "$arm10_fails" ] && echo "arm 10 OK" || echo "arm 10 FAILED"1127
echo1128
fi1132
# ==========================================================================1133
echo "=========================================================================="1134
if [ "$fails" -eq 0 ]; then1135
echo "GATE-F4A GREEN"1136
exit 01137
else1138
echo "GATE-F4A RED: $fails failure(s)"1139
exit 11140
fi