diff --git a/tests/checks/80-cycle-fixture.sh b/tests/checks/80-cycle-fixture.sh index e93a055..6b6da56 100644 --- a/tests/checks/80-cycle-fixture.sh +++ b/tests/checks/80-cycle-fixture.sh @@ -59,13 +59,21 @@ else fi # (i-b) AC3 (negative): the migration moved every other decomposing-node fixture -# to `manifest.md`, so the retained cyclic alias must be the only `roadmap.md` -# left under tests/fixtures/. A manifest-first resolver masks a stray duplicate, -# so the fixture set is pinned explicitly; this names no migrated fixture's old -# path (AC5), only the retained legacy alias the migration deliberately keeps. -legacy_fixtures="$(find tests/fixtures -name roadmap.md 2>/dev/null | LC_ALL=C sort)" +# to `manifest.md`, so the retained cyclic alias must be the only *stray* +# `roadmap.md` left under tests/fixtures/ — excluding the single documented +# deliberate exception, tests/fixtures/recursive-integrity/items/9203-spelling-both/roadmap.md, +# a dual-spelling drift fixture owned by the recursive-integrity guard +# (tests/checks/91-integrity-guard.sh, AC30). A manifest-first resolver masks a +# stray duplicate, so the stray-alias set is pinned explicitly: it drops only that +# one documented path and its remainder must still equal the retained legacy alias, +# so any third/stray alias still fails. This names no migrated fixture's old path +# (AC5), only the retained legacy alias the migration keeps and the deliberate +# exception the integrity guard owns. +DELIBERATE_ALIAS='tests/fixtures/recursive-integrity/items/9203-spelling-both/roadmap.md' +legacy_fixtures="$(find tests/fixtures -name roadmap.md 2>/dev/null \ + | grep -vxF -- "$DELIBERATE_ALIAS" | LC_ALL=C sort)" if [ "$legacy_fixtures" = "tests/fixtures/cyclic-roadmap/roadmap.md" ]; then - ok "AC13 the retained cyclic alias is the only roadmap.md fixture (AC3)" + ok "AC13 the retained cyclic alias is the only stray roadmap.md fixture (AC3)" else bad "AC13 unexpected roadmap.md fixture set (AC3): $(printf '%s' "$legacy_fixtures" | tr '\n' ' ')" fi diff --git a/tests/checks/91-integrity-guard.sh b/tests/checks/91-integrity-guard.sh new file mode 100644 index 0000000..c6aa471 --- /dev/null +++ b/tests/checks/91-integrity-guard.sh @@ -0,0 +1,805 @@ +#!/usr/bin/env bash +# +# AC30 — recursive merge-integrity invariant evaluation. +# +# The merge-integrity guard (docs/workflow.md -> "Merge conflicts" -> +# "Merge-integrity guard", mirrored in .opencode/skill/merge-conflict/SKILL.md) +# is prompt behaviour: the framework commits no checker. This area pins its +# RECURSIVE shape to a committed fixture, the same way +# tests/checks/80-cycle-fixture.sh pins the prompt-only CYCLIC-DEP rule. It +# reimplements the invariant evaluation as a genuine recursive scan over +# tests/fixtures/recursive-integrity/items/ — the top level against the +# top-level items and every decomposing node against its own immediate children +# — so an evaluation that flattens to two levels misses the depth-three +# collision and fails per-node-eval. +# +# It is fixture-scoped test code, not a production checker: it implements no +# command, is invoked by no framework command, and never reads live work/**. +# Item references resolve inside the fixture root, which shadows the live work/ +# root. The suite is read-only and fresh-clone-clean. +# +# It references the sibling agreement areas that own overlapping literals by +# name and re-asserts none of their literals: the recursive structure guard +# 0001-recursive-structure-guard (the DUPLICATE-PREFIX / DUPLICATE-CHILD +# derivation), tests/checks/10-readiness.sh, tests/checks/20-lifecycle.sh, +# tests/checks/80-cycle-fixture.sh (the CYCLIC-DEP diagnostic and the +# roadmap.md migration guard), tests/checks/85-conflict-guards.sh, +# tests/checks/87-backtrack-guards.sh, tests/checks/88-renumber-sweep.sh (the +# renumber prose rewrite lists), tests/checks/90-packaging.sh (the duplicated +# inventory/count fact), and tests/checks/96-signature-sweep.sh. +# +# Reads only tests/fixtures/** and live surfaces (never work/**); sourced by +# tests/run.sh, so it must not call exit. Prints ok/FAIL lines labelled AC30 +# plus a sub-area, so the mutation self-check (tests/mutation.sh) can name the +# guard it trips: per-node-eval, structural-graph, spelling-drift, report-only, +# renumber-sweep, fixture-existence, read-only-contract, mutation-staging, +# no-duplication. + +echo "== AC30 recursive merge-integrity invariant evaluation ==" + +FIG_ROOT="tests/fixtures/recursive-integrity" +FIG_ITEMS="$FIG_ROOT/items" +FIG_CANON_HEADER='| Local id | Title | Scope | Depends on | conflicts-with | Canonical reference |' + +# --- helpers --------------------------------------------------------------- + +# Resolve a decomposing node's manifest: its modern `manifest.md` spelling when +# present, else the recognized deprecated alias `roadmap.md`, else nothing. Lets +# the scan read either spelling without hardcoding a migrated fixture path. +fig_manifest() { # $1 = node directory; prints the manifest path, or nothing + local d="$1" base + for base in manifest roadmap; do + if [ -f "$d/$base.md" ]; then printf '%s\n' "$d/$base.md"; return 0; fi + done + return 1 +} + +# Declared `phase` value from the manifest frontmatter (empty when absent). +fig_phase_of() { + awk ' + NR == 1 && $0 == "---" { f = 1; next } + f && $0 == "---" { exit } + f && /^phase:/ { + v = $0 + sub(/^phase:[ \t]*/, "", v) + gsub(/^[ \t]+|[ \t]+$/, "", v) + print v + exit + } + ' "$1" +} + +# Parse a manifest Children table by header name (not fixed positions), so the +# six-column header parses. Emits "|" per data row. +fig_children_rows() { + awk ' + /^## Children[ \t]*$/ { insec = 1; next } + insec && /^## / { insec = 0 } + !insec { next } + /^\|/ { + n = split($0, a, "|") + if (!hdr) { + for (i = 2; i < n; i++) { + c = a[i] + gsub(/^[ \t]+|[ \t]+$/, "", c) + if (c != "") col[c] = i + } + hdr = 1 + next + } + idcell = ""; dep = "" + if ("Local id" in col) idcell = a[col["Local id"]] + if ("Depends on" in col) dep = a[col["Depends on"]] + gsub(/^[ \t]+|[ \t]+$/, "", idcell) + gsub(/^[ \t]+|[ \t]+$/, "", dep) + if (idcell == "" || idcell ~ /^-+$/) next + print idcell "|" dep + } + ' "$1" +} + +# The node's `## Open issues` block, through the next section heading. The +# withdrawal record lives here; it names the retained child directory. +fig_open_issues() { + awk ' + /^## Open issues[ \t]*$/ { on = 1; next } + on && /^## / { exit } + on { print } + ' "$1" +} + +# Trim each comma-separated token of a `Depends on` cell; one token per line. +fig_dep_tokens() { + printf '%s' "$1" | awk -F',' '{ + for (i = 1; i <= NF; i++) { + t = $i + gsub(/^[ \t]+|[ \t]+$/, "", t) + if (t != "") print t + } + }' +} + +fig_has_line() { # $1 = newline list, $2 = whole-line literal + printf '%s\n' "$1" | grep -qxF -- "$2" +} + +# --- finding accumulators --------------------------------------------------- +# Findings normalize to "CODE|[,]"; offenders are canonical +# refs relative to items/, sorted. Three category globals keep one mutation +# naming one sub-area. + +FIG_DUP="" +FIG_GRAPH="" +FIG_DRIFT="" + +fig_add_dup() { FIG_DUP="$FIG_DUP +$1"; } +fig_add_graph() { FIG_GRAPH="$FIG_GRAPH +$1"; } +fig_add_drift() { FIG_DRIFT="$FIG_DRIFT +$1"; } + +# Group same-parent prefixes and record a collision. $1 = code, $2 = newline +# list of full offender refs (all sharing one parent). The immediate child's +# 4-digit prefix is the last path segment's leading number. +fig_check_dups() { + local code="$1" refs="$2" prefix member members cnt joined seg + local prefixes + prefixes="$(printf '%s\n' "$refs" \ + | awk 'NF { n = split($0, seg, "/"); s = seg[n]; sub(/-.*/, "", s); print s }' \ + | LC_ALL=C sort -u)" + for prefix in $prefixes; do + [ -n "$prefix" ] || continue + members="" + for member in $refs; do + [ -n "$member" ] || continue + seg="${member##*/}" + seg="${seg%%-*}" + if [ "$seg" = "$prefix" ]; then + members="$members +$member" + fi + done + cnt="$(printf '%s\n' "$members" | grep -c '[^[:space:]]')" + if [ "$cnt" -ge 2 ]; then + joined="$(printf '%s\n' "$members" | grep '[^[:space:]]' | LC_ALL=C sort -u \ + | awk 'NF { if (s != "") s = s ","; s = s $0 } END { print s }')" + fig_add_dup "$code|$joined" + fi + done +} + +# --- recursive invariant scan ---------------------------------------------- +# Evaluate the invariant set for one node against its own immediate children, +# then recurse into every child that is itself a decomposing node. The top-level +# prefix grouping is done once by fig_scan_all. + +fig_scan_node() { # $1 = canonical ref relative to items/, $2 = node directory + local ref="$1" dir="$2" + local man base phase rows opensec + local cdir localid + local dirs="" rowids="" childrefs="" + + # A node holding both manifest spellings is drift; the modern spelling stays + # authoritative and neither file is merged, normalized, or deleted. + if [ -f "$dir/manifest.md" ] && [ -f "$dir/roadmap.md" ]; then + fig_add_drift "DRIFT-FACT|$ref" + fi + + man="$(fig_manifest "$dir")" || return 0 + base="${man##*/}" + base="${base%.md}" + phase="$(fig_phase_of "$man")" + # A manifest's spelling and its declared `phase` value must agree + # (manifest.md/manifest, roadmap.md/roadmap); a disagreement is drift. + if [ "$base" = "manifest" ] && [ "$phase" != "manifest" ]; then + fig_add_drift "DRIFT-FACT|$ref" + elif [ "$base" = "roadmap" ] && [ "$phase" != "roadmap" ]; then + fig_add_drift "DRIFT-FACT|$ref" + fi + + rows="$(fig_children_rows "$man")" + opensec="$(fig_open_issues "$man")" + + # Immediate child directories (NNNN-slug only). + for cdir in "$dir"/*/; do + [ -d "$cdir" ] || continue + localid="${cdir%/}" + localid="${localid##*/}" + case "$localid" in + [0-9][0-9][0-9][0-9]-*) : ;; + *) continue ;; + esac + dirs="$dirs +$localid" + done + + rowids="$(printf '%s\n' "$rows" | awk -F'|' 'NF { print $1 }')" + + # MISSING-CHILD: a Children row whose child directory is absent. + local rid deprow dep d + for rid in $rowids; do + [ -n "$rid" ] || continue + if ! fig_has_line "$dirs" "$rid"; then + fig_add_graph "MISSING-CHILD|$ref/$rid" + fi + done + + # UNLISTED-CHILD: a child directory absent from the table, unless the node's + # open issues name it as a deliberate withdrawal (report-only, never a fault). + for localid in $dirs; do + [ -n "$localid" ] || continue + if fig_has_line "$rowids" "$localid"; then continue; fi + if printf '%s\n' "$opensec" | grep -qF -- "$ref/$localid"; then continue; fi + fig_add_graph "UNLISTED-CHILD|$ref/$localid" + done + + # DANGLING-DEP: a `Depends on` local id resolving to no row and no directory. + while IFS= read -r deprow; do + [ -n "$deprow" ] || continue + rid="${deprow%%|*}" + dep="${deprow#*|}" + [ -n "$dep" ] || continue + for d in $(fig_dep_tokens "$dep"); do + [ "$d" = "—" ] && continue + if fig_has_line "$rowids" "$d"; then continue; fi + fig_add_graph "DANGLING-DEP|$ref/$rid" + done + done </dev/null 2>&1; then + fig_scan_node "$ref/$localid" "$dir/$localid" + fi + done +} + +fig_scan_all() { + local pd pname toprefs="" + for pd in "$FIG_ITEMS"/*/; do + [ -d "$pd" ] || continue + pname="${pd%/}" + pname="${pname##*/}" + case "$pname" in + [0-9][0-9][0-9][0-9]-*) : ;; + *) continue ;; + esac + toprefs="$toprefs +$pname" + done + # DUPLICATE-PREFIX: two top-level items sharing a 4-digit prefix. + fig_check_dups "DUPLICATE-PREFIX" "$toprefs" + for pname in $toprefs; do + [ -n "$pname" ] || continue + fig_scan_node "$pname" "$FIG_ITEMS/$pname" + done +} + +fig_block() { # normalize a category accumulator to a sorted, deduped block + printf '%s\n' "$1" | grep -v '^$' | LC_ALL=C sort -u +} + +# The three category blocks, one mutation per sub-area. +fixture_duplicates() { fig_block "$FIG_DUP"; } +fixture_graph() { fig_block "$FIG_GRAPH"; } +fixture_drift() { fig_block "$FIG_DRIFT"; } + +fig_cmp() { # $1 label, $2 expected, $3 actual + if [ "$2" = "$3" ]; then + ok "AC30 $1: matches the expected block" + else + bad "AC30 $1: diverged from the expected block" + printf ' expected:\n%s\n actual:\n%s\n' "$2" "$3" + fi +} + +# --- analyzers and expected outcome blocks ---------------------------------- + +fig_scan_all + +ACT_DUP="$(fixture_duplicates)" +ACT_GRAPH="$(fixture_graph)" +ACT_DRIFT="$(fixture_drift)" + +# The reviewed expectation over the committed fixture: exactly the planted +# duplicates, the structural graph faults (with the recorded withdrawal +# excluded), and the two drift nodes. A two-level-only scan misses the +# depth-three DUPLICATE-CHILD and diverges here. +EXP_DUP="$(cat <<'EOF' +DUPLICATE-CHILD|9211-deep/0001-mid/0001-alpha,9211-deep/0001-mid/0001-beta +DUPLICATE-PREFIX|9210-dup-a,9210-dup-b +EOF +)" + +EXP_GRAPH="$(cat <<'EOF' +DANGLING-DEP|9202-graph-faults/0003-dangling +MISSING-CHILD|9202-graph-faults/0002-missing +UNLISTED-CHILD|9202-graph-faults/0005-unlisted +EOF +)" + +EXP_DRIFT="$(cat <<'EOF' +DRIFT-FACT|9203-spelling-both +DRIFT-FACT|9204-phase-mismatch +EOF +)" + +# --- AC30 per-node-eval (AC2) ----------------------------------------------- + +fig_cmp "per-node-eval" "$EXP_DUP" "$ACT_DUP" + +# The collision is attributed to its OWN node, not flattened to the top level: +# both offenders carry the depth-three parent's canonical reference. +if printf '%s\n' "$ACT_DUP" \ + | grep -qF 'DUPLICATE-CHILD|9211-deep/0001-mid/0001-alpha,9211-deep/0001-mid/0001-beta'; then + ok "AC30 per-node-eval: the depth-three collision is attributed to 9211-deep/0001-mid" +else + bad "AC30 per-node-eval: the depth-three collision is not attributed to 9211-deep/0001-mid" +fi + +# --- AC30 structural-graph (AC3) -------------------------------------------- + +fig_cmp "structural-graph" "$EXP_GRAPH" "$ACT_GRAPH" + +# The recorded withdrawal is report-only: it yields no UNLISTED-CHILD fault, so +# a deliberate withdrawal is never counted as an accidental graph fault. +if printf '%s\n' "$ACT_GRAPH" | grep -qF 'UNLISTED-CHILD|9202-graph-faults/0004-withdrawn'; then + bad "AC30 structural-graph: the recorded deliberate withdrawal was reported as UNLISTED-CHILD" +else + ok "AC30 structural-graph: the recorded deliberate withdrawal yields no UNLISTED-CHILD fault" +fi + +# --- AC30 spelling-drift (AC4) ---------------------------------------------- + +fig_cmp "spelling-drift" "$EXP_DRIFT" "$ACT_DRIFT" + +# The modern `manifest.md` spelling is authoritative over the deprecated alias: +# the evaluation resolves a dual-spelling node's manifest to the modern spelling, +# never the alias, so the redundant `roadmap.md` is reported (above) and left +# untouched. A resolver that preferred the alias would be wrong and is caught +# here even though both files carry the same rows. +FIG_DRIFT_MAN="$(fig_manifest "$FIG_ITEMS/9203-spelling-both")" +if [ "$FIG_DRIFT_MAN" = "$FIG_ITEMS/9203-spelling-both/manifest.md" ]; then + ok "AC30 spelling-drift: the modern manifest is authoritative over the deprecated alias" +else + bad "AC30 spelling-drift: the modern manifest is not authoritative (resolved '${FIG_DRIFT_MAN:-none}')" +fi + +# The dual-spelling node keeps both files: the inconsistency is reported, never +# merged, normalized, dropped, or auto-repaired, so neither spelling disappears. +if [ -f "$FIG_ITEMS/9203-spelling-both/manifest.md" ] \ + && [ -f "$FIG_ITEMS/9203-spelling-both/roadmap.md" ]; then + ok "AC30 spelling-drift: both spellings are retained, never dropped or auto-repaired" +else + bad "AC30 spelling-drift: a dual-spelling file was dropped or auto-repaired" +fi + +# --- AC30 report-only (AC5, AC2) -------------------------------------------- +# The merge-integrity guard commits no checker, so its report-only, non-fatal +# contract lives only on the authority docs/workflow.md -> "### Merge-integrity +# guard" and its operational mirror .opencode/skill/merge-conflict/SKILL.md. +# Both surfaces are pinned here, by path; no sibling agreement area owns these +# literals. The recursive authority phrases are pinned the same way: the +# invariant set is evaluated at every node, no node's check depends on another's +# (authority), and every node at every depth (skill). + +FIG_WF='docs/workflow.md' +FIG_SKILL="$SKILL_DIR/merge-conflict/SKILL.md" + +# Authority block: docs/workflow.md -> "### Merge-integrity guard", through the +# next heading at the same or a higher level. +fig_mi_block() { + awk ' + /^### Merge-integrity guard[ \t]*$/ { on = 1; next } + on && /^#{2,3} / { exit } + on { print } + ' "$1" +} + +# Skill block: the reconcile procedure's post-merge integrity pass (step 6), +# through the next numbered step, carrying the report-only contract sentence. +fig_skill_post_block() { + awk ' + /6\. \*\*Post-merge integrity pass/ { on = 1 } + on && /^7\. / { exit } + on { print } + ' "$1" +} + +# Collapse a prose block to one line so a contract phrase that wraps across the +# surface's line width still matches as one literal. +fig_flat() { tr '\n' ' ' | tr -s ' '; } + +# fig_pin