diff --git a/tests/checks/81-recursive-structure-guard.sh b/tests/checks/81-recursive-structure-guard.sh new file mode 100644 index 0000000..20245bb --- /dev/null +++ b/tests/checks/81-recursive-structure-guard.sh @@ -0,0 +1,502 @@ +#!/usr/bin/env bash +# +# AC27 — recursive model, canonical-reference, and per-level-allocation guard. +# +# The recursive lifecycle model (the standard cycle applies uniformly at every +# node, any node may decompose to arbitrary depth, fine-grained tasks.md entries +# are not nodes, per-level child reservation, and immediate-parent sequence +# allocation) and the canonical-reference grammar are prompt behaviour in the +# framework; there is no committed production checker. This area pins that +# behaviour to a committed, reviewable fixture tree, the same way +# tests/checks/80-cycle-fixture.sh pins the prompt-only CYCLIC-DEP rule and +# tests/checks/85-conflict-guards.sh pins the declared-conflict model. +# +# It is fixture-scoped test code, not a production checker: it implements no +# command and is invoked by no framework command. Its fixture root, +# tests/fixtures/recursive-structure/items/, is a self-contained tree that +# shadows no live path; the guard reads only that fixture root and the live +# authority docs, and never reads live work/**. The suite is read-only and +# fresh-clone-clean. +# +# The residual — that a live prompt run derives the same shape — is not +# executable in CI and is documented in tests/README.md and verify.md. +# +# Sourced by tests/run.sh, so it must not call exit. Prints ok/FAIL lines +# labelled AC27 plus a sub-area, so a mutation self-check can name the guard it +# trips: fixture-depth, hybrid-node, reservation-marker, allocation-scoping, +# reference-depth, duplicate-derivation, duplicate-position, contract-pins, +# read-only-contract. + +echo "== AC27 recursive structure, reference, and allocation guard ==" + +# The fixture root and its fixture-local "work root". Every item reference is +# resolved inside $RS_ITEMS; the guard never touches the live work/ tree. +RS_ROOT="tests/fixtures/recursive-structure" +RS_ITEMS="$RS_ROOT/items" + +# --- helpers --------------------------------------------------------------- + +# An item directory name is a four-digit prefix, a dash, then a kebab slug. +rs_is_item_dir() { # 0 when $1 is an item directory basename + case "$1" in + [0-9][0-9][0-9][0-9]-[a-z0-9]*) return 0 ;; + *) return 1 ;; + esac +} + +# Resolve a decomposing node's manifest through the same manifest-then-alias +# lookup the sibling guards use: the modern `manifest.md` spelling when present, +# else the recognized deprecated alias `roadmap.md`, else nothing. +rs_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 +} + +# A node's immediate child item directories, as references relative to +# $RS_ITEMS. Empty for a node that holds no manifest (it does not decompose). +rs_children() { # $1 = node directory; one child ref per line on stdout + local d="$1" c + [ -d "$d" ] || return 0 + rs_manifest "$d" >/dev/null 2>&1 || return 0 + for c in "$d"/*/; do + [ -d "$c" ] || continue + c="${c%/}" + rs_is_item_dir "${c##*/}" || continue + printf '%s\n' "${c#"$RS_ITEMS"/}" + done +} + +# The maximum item-directory depth under $RS_ITEMS: 1 for a top-level item, +# 4 for a four-level (`////`) branch. A directory chain +# counts only when every segment is an item directory name. Prints 0 when the +# fixture root is absent. +rs_max_depth() { + if [ ! -d "$RS_ITEMS" ]; then printf '0\n'; return 0; fi + find "$RS_ITEMS" -type d 2>/dev/null | awk -v root="$RS_ITEMS" ' + { + rel = $0 + sub("^" root "/", "", rel) + if (rel == $0 || rel == "") next + n = split(rel, seg, "/") + ok = 1 + for (i = 1; i <= n; i++) + if (seg[i] !~ /^[0-9][0-9][0-9][0-9]-[a-z0-9-]+$/) { ok = 0; break } + if (ok && n > max) max = n + } + END { print (max == "" ? 0 : max) } + ' +} + +# 0 when $1 contains an immediate item child directory. +rs_has_item_child() { # $1 = directory + local c + for c in "$1"/*/; do + [ -d "$c" ] || continue + c="${c%/}" + rs_is_item_dir "${c##*/}" && return 0 + done + return 1 +} + +# Emit one allocation record per top-level item (``P|``) and per immediate +# child of a decomposing node (``C|||``); the +# awk analyzer below derives the duplicate pairs from it. Identity is the full +# canonical reference, so equal local-id text under different parents is never +# collapsed into a collision. +rs_alloc_events() { + local node child + for node in "$RS_ITEMS"/*/; do + [ -d "$node" ] || continue + node="${node%/}" + rs_is_item_dir "${node##*/}" || continue + printf 'P|%s\n' "${node##*/}" + done + while IFS= read -r node; do + [ -d "$node" ] || continue + rs_is_item_dir "${node##*/}" || continue + while IFS= read -r child; do + [ -n "$child" ] || continue + printf 'C|%s|%s|%s\n' "${node#"$RS_ITEMS"/}" "$child" "${child##*/}" + done < <(rs_children "$node") + done < <(find "$RS_ITEMS" -type d 2>/dev/null | LC_ALL=C sort) +} + +# Derive the allocation finding set, sorted under LC_ALL=C. A top-level pair with +# an equal four-digit NNNN is ``DUPLICATE-PREFIX``; a same-parent pair of +# immediate children with an equal four-digit MMMM is ``DUPLICATE-CHILD``. The +# two kinds are produced by separate loops, so neither can be emitted at the +# other's tree position. At depth >= 3 the DUPLICATE-CHILD line also names the +# shared parent and shared local prefix, so a collision is unambiguous at depth. +rs_dup_findings() { + rs_alloc_events | awk -F'|' ' + $1 == "P" { top[++nt] = $2; next } + $1 == "C" { nc++; par[nc] = $2; ref[nc] = $3; loc[nc] = $4; next } + END { + for (i = 1; i <= nt; i++) + for (j = i + 1; j <= nt; j++) + if (substr(top[i], 1, 4) == substr(top[j], 1, 4)) { + a = top[i]; b = top[j] + if (a > b) { t = a; a = b; b = t } + print "DUPLICATE-PREFIX|" a "|" b + } + for (i = 1; i <= nc; i++) + for (j = i + 1; j <= nc; j++) { + if (par[i] != par[j]) continue + if (substr(loc[i], 1, 4) != substr(loc[j], 1, 4)) continue + a = ref[i]; b = ref[j] + if (a > b) { t = a; a = b; b = t } + if (gsub("/", "/", a) >= 2) + print "DUPLICATE-CHILD|" a "|" b "|" par[i] "|" substr(loc[i], 1, 4) + else + print "DUPLICATE-CHILD|" a "|" b + } + } + ' | LC_ALL=C sort +} + +rs_show_diff() { + printf ' expected:\n%s\n actual:\n%s\n' "$1" "$2" +} + +rs_block_contains() { # $1 haystack, $2 literal, $3 label + if printf '%s\n' "$1" | grep -qF -- "$2"; then ok "$3"; else bad "$3 (missing '$2')"; fi +} + +rs_block_absent() { # $1 haystack, $2 literal, $3 label + if printf '%s\n' "$1" | grep -qF -- "$2"; then bad "$3 (found '$2')"; else ok "$3"; fi +} + +# --- AC1 fixture-depth ------------------------------------------------------ +# The fixture root must exist and its deepest branch must reach four item +# levels. A missing or shallow root is a hard failure, never a skip. + +if [ ! -d "$RS_ITEMS" ]; then + bad "AC27 fixture-depth: committed fixture root missing at $RS_ITEMS (must fail, not skip)" +else + ok "AC27 fixture-depth: committed fixture root exists at $RS_ITEMS" + RS_DEPTH="$(rs_max_depth)" + if [ "$RS_DEPTH" -ge 4 ]; then + ok "AC27 fixture-depth: tree reaches $RS_DEPTH item levels (>= 4)" + else + bad "AC27 fixture-depth: deepest branch is $RS_DEPTH item levels (< 4)" + fi +fi + +# --- AC2 hybrid-node -------------------------------------------------------- +# At least one node both decomposes (holds a manifest) and carries one of its +# own phase artifacts, pinning that a node may carry an artifact and a child set +# at the same time. + +RS_HYBRID="" +if [ -d "$RS_ITEMS" ]; then + while IFS= read -r rs_d; do + [ -d "$rs_d" ] || continue + rs_is_item_dir "${rs_d##*/}" || continue + rs_manifest "$rs_d" >/dev/null 2>&1 || continue + for rs_a in spec design tasks verify review ship; do + if [ -f "$rs_d/$rs_a.md" ]; then + RS_HYBRID="${rs_d#"$RS_ITEMS"/}" + break + fi + done + if [ -n "$RS_HYBRID" ]; then break; fi + done < <(find "$RS_ITEMS" -type d 2>/dev/null | LC_ALL=C sort) +fi +if [ -n "$RS_HYBRID" ]; then + ok "AC27 hybrid-node: $RS_HYBRID both decomposes and carries its own phase artifact" +else + bad "AC27 hybrid-node: no node both decomposes and carries its own phase artifact" +fi + +# --- AC3 reservation-marker ------------------------------------------------- +# Every child directory, at every level, holds a `.gitkeep`, and at least one +# leaf child holds only that marker and no phase artifacts. + +RS_RESERV_MISSING="" +RS_MARKER_ONLY="" +if [ -d "$RS_ITEMS" ]; then + while IFS= read -r rs_node; do + [ -d "$rs_node" ] || continue + rs_is_item_dir "${rs_node##*/}" || continue + rs_manifest "$rs_node" >/dev/null 2>&1 || continue + while IFS= read -r rs_child; do + [ -n "$rs_child" ] || continue + if [ ! -f "$RS_ITEMS/$rs_child/.gitkeep" ]; then + RS_RESERV_MISSING="$RS_RESERV_MISSING $rs_child" + continue + fi + if ! rs_has_item_child "$RS_ITEMS/$rs_child"; then + rs_own=0 + for rs_a in manifest spec design tasks verify review ship; do + if [ -f "$RS_ITEMS/$rs_child/$rs_a.md" ]; then rs_own=1; fi + done + if [ "$rs_own" -eq 0 ] && [ -z "$RS_MARKER_ONLY" ]; then + RS_MARKER_ONLY="$rs_child" + fi + fi + done < <(rs_children "$rs_node") + done < <(find "$RS_ITEMS" -type d 2>/dev/null | LC_ALL=C sort) +fi + +if [ -z "$RS_RESERV_MISSING" ]; then + ok "AC27 reservation-marker: every child directory at every level carries .gitkeep" +else + bad "AC27 reservation-marker: child directories lack .gitkeep:${RS_RESERV_MISSING}" +fi +if [ -n "$RS_MARKER_ONLY" ]; then + ok "AC27 reservation-marker: marker-only leaf exists ($RS_MARKER_ONLY)" +else + bad "AC27 reservation-marker: no marker-only leaf (a child with only .gitkeep) exists" +fi + +# --- AC4 allocation-scoping ------------------------------------------------- +# Two distinct parents each independently begin their local numbering at 0001, +# and a depth-3 grandchild is allocated under its immediate parent. + +RS_ALLOC_PARENTS=0 +if [ -d "$RS_ITEMS" ]; then + while IFS= read -r rs_node; do + [ -d "$rs_node" ] || continue + rs_is_item_dir "${rs_node##*/}" || continue + rs_manifest "$rs_node" >/dev/null 2>&1 || continue + rs_started=0 + while IFS= read -r rs_child; do + [ -n "$rs_child" ] || continue + case "${rs_child##*/}" in + 0001-*) rs_started=1 ;; + esac + done < <(rs_children "$rs_node") + if [ "$rs_started" -eq 1 ]; then + RS_ALLOC_PARENTS=$((RS_ALLOC_PARENTS + 1)) + fi + done < <(find "$RS_ITEMS" -type d 2>/dev/null | LC_ALL=C sort) +fi +if [ "$RS_ALLOC_PARENTS" -ge 2 ]; then + ok "AC27 allocation-scoping: $RS_ALLOC_PARENTS distinct parents each begin their local numbering at 0001" +else + bad "AC27 allocation-scoping: fewer than two parents begin their local numbering at 0001 (got $RS_ALLOC_PARENTS)" +fi + +if rs_children "$RS_ITEMS/9001-tree/0001-branch" | grep -qxF '9001-tree/0001-branch/0001-leaf'; then + ok "AC27 allocation-scoping: a grandchild is allocated under its immediate parent (9001-tree/0001-branch/0001-leaf)" +else + bad "AC27 allocation-scoping: grandchild 9001-tree/0001-branch/0001-leaf is not listed under its immediate parent" +fi + +# The grandchild's local number is scoped to its immediate parent, not derived +# from the top-level 9001 sequence: its manifest names its immediate parent. +RS_GC="$RS_ITEMS/9001-tree/0001-branch/0001-leaf" +if [ -f "$RS_GC/manifest.md" ]; then + RS_GC_PARENT="$(fm "$RS_GC/manifest.md" | awk ' + /^parent:/ { v = $0; sub(/^parent:[ \t]*/, "", v); gsub(/^[ \t]+|[ \t]+$/, "", v); print v; exit } + ')" + if [ "$RS_GC_PARENT" = "9001-tree/0001-branch" ]; then + ok "AC27 allocation-scoping: grandchild's local id is scoped to its immediate parent" + else + bad "AC27 allocation-scoping: grandchild's parent is '$RS_GC_PARENT', not its immediate parent 9001-tree/0001-branch" + fi +else + bad "AC27 allocation-scoping: grandchild manifest missing at $RS_GC/manifest.md" +fi + +# --- AC5 reference-depth ---------------------------------------------------- +# A depth-four reference resolves to its directory, and the documented grammar +# is pinned as the arbitrary-depth nesting form. + +RS_DEEP_REF="9001-tree/0001-branch/0001-leaf/0001-deep" +if [ -d "$RS_ITEMS/$RS_DEEP_REF" ]; then + ok "AC27 reference-depth: depth-four reference $RS_DEEP_REF resolves to its directory" +else + bad "AC27 reference-depth: depth-four reference $RS_DEEP_REF does not resolve" +fi + +if printf '%s\n' "$RS_DEEP_REF" | grep -qE '^[0-9]{4}-[a-z0-9-]+(/[0-9]{4}-[a-z0-9-]+)*$'; then + ok "AC27 reference-depth: depth-four reference matches the arbitrary-depth grammar" +else + bad "AC27 reference-depth: depth-four reference does not match ^[0-9]{4}-[a-z0-9-]+(/[0-9]{4}-[a-z0-9-]+)*$" +fi +need "$CONV" '^[0-9]{4}-[a-z0-9-]+(/[0-9]{4}-[a-z0-9-]+)*$' \ + "AC27 reference-depth: $CONV pins the arbitrary-depth reference grammar" + +# --- AC6/AC7 duplicate-derivation ------------------------------------------- +# Derive the allocation findings for the fixture's duplicate samples and compare +# the derived set to the committed expected block. A top-level collision is a +# DUPLICATE-PREFIX; a same-parent collision at depth >= 3 is a DUPLICATE-CHILD +# that also names the shared parent and shared local prefix. + +RS_DUP_ALL="$(rs_dup_findings)" +RS_DUP_PREFIX="$(printf '%s\n' "$RS_DUP_ALL" | grep '^DUPLICATE-PREFIX|' || true)" +RS_DUP_CHILD="$(printf '%s\n' "$RS_DUP_ALL" | grep '^DUPLICATE-CHILD|' || true)" + +RS_EXPECTED_DUP="$(cat <<'EOF' +DUPLICATE-CHILD|9001-tree/0001-branch/0001-leaf/0009-dup-x|9001-tree/0001-branch/0001-leaf/0009-dup-y|9001-tree/0001-branch/0001-leaf|0009 +DUPLICATE-PREFIX|9010-dup-a|9010-dup-b +EOF +)" + +if [ "$RS_DUP_ALL" = "$RS_EXPECTED_DUP" ]; then + ok "AC27 duplicate-derivation: derived allocation findings match the expected block" +else + bad "AC27 duplicate-derivation: derived allocation findings diverged from the expected block" + rs_show_diff "$RS_EXPECTED_DUP" "$RS_DUP_ALL" +fi + +if [ "$(printf '%s\n' "$RS_DUP_PREFIX" | grep -c .)" -eq 1 ] && + printf '%s\n' "$RS_DUP_PREFIX" | grep -qxF 'DUPLICATE-PREFIX|9010-dup-a|9010-dup-b'; then + ok "AC27 duplicate-derivation: exactly one DUPLICATE-PREFIX names both top-level offenders" +else + bad "AC27 duplicate-derivation: expected exactly one DUPLICATE-PREFIX for the 9010 pair" +fi + +if [ "$(printf '%s\n' "$RS_DUP_CHILD" | grep -c .)" -eq 1 ] && + printf '%s\n' "$RS_DUP_CHILD" | grep -qxF 'DUPLICATE-CHILD|9001-tree/0001-branch/0001-leaf/0009-dup-x|9001-tree/0001-branch/0001-leaf/0009-dup-y|9001-tree/0001-branch/0001-leaf|0009'; then + ok "AC27 duplicate-derivation: exactly one DUPLICATE-CHILD names both depth-4 offenders plus the shared parent and local prefix" +else + bad "AC27 duplicate-derivation: expected exactly one depth-4 DUPLICATE-CHILD with shared parent and local prefix" +fi + +# --- AC8 duplicate-position ------------------------------------------------- +# The negative clamp: a top-level collision is never reported at the deeper +# position, a deeper collision is never reported at the top-level position, and +# equal local-id text under different parents is never a collision. + +rs_block_absent "$RS_DUP_CHILD" '9010-dup' \ + "AC27 duplicate-position: the top-level 9010 collision is not reported as DUPLICATE-CHILD" +rs_block_absent "$RS_DUP_PREFIX" '0009-dup' \ + "AC27 duplicate-position: the depth-4 0009 collision is not reported as DUPLICATE-PREFIX" +rs_block_absent "$RS_DUP_CHILD" '9001-tree/0001-branch|9002-tree/0001-branch' \ + "AC27 duplicate-position: '0001-branch' under 9001-tree and 9002-tree is not a same-parent duplicate" +rs_block_absent "$RS_DUP_CHILD" '9002-tree/0001-branch|9001-tree/0001-branch' \ + "AC27 duplicate-position: '0001-branch' under 9002-tree and 9001-tree is not a same-parent duplicate" +rs_block_absent "$RS_DUP_CHILD" '9001-tree/0001-branch/0001-leaf|9002-tree/0001-branch/0001-leaf' \ + "AC27 duplicate-position: '0001-leaf' under the two branches is not a same-parent duplicate" +rs_block_absent "$RS_DUP_CHILD" '9002-tree/0001-branch/0001-leaf|9001-tree/0001-branch/0001-leaf' \ + "AC27 duplicate-position: '0001-leaf' under the two branches (reverse order) is not a same-parent duplicate" + +# --- AC9/AC10/AC11/AC12 contract-pins --------------------------------------- +# Pin the shipped recursive-model, canonical-reference, per-level-allocation, and +# "prompt behavior only — no committed executable checker" prose in the live +# authority, so a dropped or weakened statement is caught and named. Statements +# that wrap across source lines are matched against a whitespace-flattened copy +# (the same flat() technique the sibling guards use). + +RS_CONV_FLAT="$(tr '\n' ' ' < "$CONV" | tr -s ' ')" + +# AC9 — the standard cycle applies uniformly at every node, decomposition imposes +# no maximum recursion depth, and nesting continues to arbitrary depth. +need "$WF" 'applies uniformly at every node' \ + "AC27 contract-pins: $WF states the cycle applies uniformly at every node" +need "$WF" 'no maximum recursion depth' \ + "AC27 contract-pins: $WF states decomposition imposes no maximum recursion depth" +need "$WF" 'nesting continues to arbitrary depth' \ + "AC27 contract-pins: $WF states nesting continues to arbitrary depth" + +# AC10 — fine-grained tasks.md entries are not nodes, pinned in the authority and +# asserted against the fixture: no tasks.md entry, and no task-like directory, +# creates an item directory. +need "$WF" 'are **not** nodes' \ + "AC27 contract-pins: $WF states fine-grained task entries are not nodes" +RS_TASK_BAD="" +if [ -f "$RS_ITEMS/9001-tree/tasks.md" ]; then + while IFS= read -r rs_t; do + [ -n "$rs_t" ] || continue + if [ -n "$(find "$RS_ITEMS" -type d -name "$rs_t" 2>/dev/null)" ]; then + RS_TASK_BAD="$RS_TASK_BAD $rs_t" + fi + done < <(grep -oE '\*\*T[0-9]+\*\*' "$RS_ITEMS/9001-tree/tasks.md" | tr -d '*') +fi +RS_TASKLIKE="$(find "$RS_ITEMS" -type d -name 'T[0-9]*' 2>/dev/null)" +if [ -z "$RS_TASK_BAD" ] && [ -z "$RS_TASKLIKE" ]; then + ok "AC27 contract-pins: no fixture task entry names an item directory (task entries are not nodes)" +else + bad "AC27 contract-pins: a fixture task entry names a directory (named:${RS_TASK_BAD:-none}; task-like dirs:${RS_TASKLIKE:-none})" +fi + +# AC11 — every child directory is reserved at every level, per-level allocation +# is scoped to the immediate parent, and a local number is never reused. +rs_block_contains "$RS_CONV_FLAT" \ + 'each child directory is reserved with a `.gitkeep` at that level' \ + "AC27 contract-pins: $CONV states each child directory is reserved at every level" +need "$CONV" 'greatest 4-digit prefix ever committed under that parent and add one' \ + "AC27 contract-pins: $CONV states immediate-parent allocation starts from the greatest committed prefix" +rs_block_contains "$RS_CONV_FLAT" 'scoped to its **immediate** parent' \ + "AC27 contract-pins: $CONV states allocation is scoped to the immediate parent" +need "$CONV" 'never reused within that parent' \ + "AC27 contract-pins: $CONV states a local number is never reused within that parent" + +# AC12 — the merge-integrity guard and the declared-conflict check are each +# "prompt behavior only" with no committed executable checker. Both sentences +# wrap across source lines, so each is matched against a flattened copy of its +# own authority section rather than the whole file. +RS_MIG_AUTH="$(awk ' + /^### Merge-integrity guard[ \t]*$/ { f = 1; print; next } + f && /^### / { exit } + f { print } +' "$WF")" +RS_DCC_AUTH="$(awk ' + /^## Declared-conflict check[ \t]*$/ { f = 1; print; next } + f && /^## / { exit } + f { print } +' "$WF")" +RS_MIG_FLAT="$(printf '%s\n' "$RS_MIG_AUTH" | tr '\n' ' ' | tr -s ' ')" +RS_DCC_FLAT="$(printf '%s\n' "$RS_DCC_AUTH" | tr '\n' ' ' | tr -s ' ')" + +rs_block_contains "$RS_MIG_FLAT" 'prompt behavior only' \ + "AC27 contract-pins: $WF merge-integrity guard is prompt behavior only" +rs_block_contains "$RS_MIG_FLAT" 'no committed checker, script, helper, or executable tool' \ + "AC27 contract-pins: $WF merge-integrity guard has no committed checker" +rs_block_contains "$RS_DCC_FLAT" 'prompt behavior only' \ + "AC27 contract-pins: $WF declared-conflict check is prompt behavior only" +rs_block_contains "$RS_DCC_FLAT" 'no committed checker, script, helper, or executable tool' \ + "AC27 contract-pins: $WF declared-conflict check has no committed checker" + +# --- AC13 read-only-contract ------------------------------------------------- +# The guard reads only its committed fixture root and the live authority docs; it +# never reads live work/**, references no live work path, and contains no +# absolute or escaping path. It is fixture-scoped test code invoked by no +# framework command, so it adds no committed production checker. + +if [ -d "$RS_ROOT" ] && ! grep -rq 'work/' "$RS_ROOT" 2>/dev/null; then + ok "AC27 read-only-contract: the fixture references no live work/ path" +else + bad "AC27 read-only-contract: the fixture references a live work/ path" +fi + +# Fixture self-containment: no `..` escape token and no absolute filesystem path +# token. The absolute pattern matches only a known top-level directory, so the +# framework command tokens (`/build`, `/plan`, ...) are not mistaken for absolute +# paths (the same clamp the sibling guards use). +if grep -rqE '\.\.|(^|[^A-Za-z0-9._/-])/(home|tmp|Users|etc|var|root|opt|mnt)/' "$RS_ROOT" 2>/dev/null; then + bad "AC27 read-only-contract: the fixture references an absolute or escaping path" +else + ok "AC27 read-only-contract: the fixture references no absolute or escaping path" +fi + +# The guard is invoked by no framework command and adds no committed production +# checker: it lives under tests/checks/ (sourced by tests/run.sh), and no +# production surface (a command, agent, skill, doc, or top-level README/AGENTS) +# names it. A test-doc registration (tests/README.md) is not a production surface. +RS_GUARD_PATH="${BASH_SOURCE[0]}" +case "$RS_GUARD_PATH" in + */tests/checks/*) + ok "AC27 read-only-contract: the guard lives under tests/checks/ (fixture-scoped test code)" ;; + *) + bad "AC27 read-only-contract: the guard does not live under tests/checks/ ($RS_GUARD_PATH)" ;; +esac + +RS_GUARD_STEM="$(basename "${BASH_SOURCE[0]%.sh}")" +RS_PROD_HITS="" +for rs_prod in .opencode docs README.md AGENTS.md; do + [ -e "$rs_prod" ] || continue + if grep -rq -- "$RS_GUARD_STEM" "$rs_prod" 2>/dev/null; then + RS_PROD_HITS="$RS_PROD_HITS $rs_prod" + fi +done +if [ -z "$RS_PROD_HITS" ]; then + ok "AC27 read-only-contract: no production surface names the guard (invoked by no framework command)" +else + bad "AC27 read-only-contract: a production surface names the guard:${RS_PROD_HITS}" +fi diff --git a/tests/fixtures/recursive-structure/items/9001-tree/0001-branch/.gitkeep b/tests/fixtures/recursive-structure/items/9001-tree/0001-branch/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/recursive-structure/items/9001-tree/0001-branch/0001-leaf/.gitkeep b/tests/fixtures/recursive-structure/items/9001-tree/0001-branch/0001-leaf/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/recursive-structure/items/9001-tree/0001-branch/0001-leaf/0001-deep/.gitkeep b/tests/fixtures/recursive-structure/items/9001-tree/0001-branch/0001-leaf/0001-deep/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/recursive-structure/items/9001-tree/0001-branch/0001-leaf/0009-dup-x/.gitkeep b/tests/fixtures/recursive-structure/items/9001-tree/0001-branch/0001-leaf/0009-dup-x/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/recursive-structure/items/9001-tree/0001-branch/0001-leaf/0009-dup-y/.gitkeep b/tests/fixtures/recursive-structure/items/9001-tree/0001-branch/0001-leaf/0009-dup-y/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/recursive-structure/items/9001-tree/0001-branch/0001-leaf/manifest.md b/tests/fixtures/recursive-structure/items/9001-tree/0001-branch/0001-leaf/manifest.md new file mode 100644 index 0000000..9e4cd0b --- /dev/null +++ b/tests/fixtures/recursive-structure/items/9001-tree/0001-branch/0001-leaf/manifest.md @@ -0,0 +1,37 @@ +--- +feature: 9001-tree/0001-branch/0001-leaf +phase: manifest +status: final +created: 2026-10-14 +updated: 2026-10-14 +parent: 9001-tree/0001-branch +--- + +# Manifest — Fixture depth-3 decomposing leaf + +## Initiative + +A fixture grandchild that itself decomposes into the depth-4 leaves, pinning +nesting to arbitrary depth and a same-parent duplicate pair at depth ≥ 3. + +## Assumptions + +- Fixture data only. + +## Children + +| Local id | Title | Scope | Depends on | conflicts-with | Canonical reference | +| -------- | ----- | ----- | ---------- | -------------- | ------------------- | +| 0001-deep | Depth-4 marker-only leaf | Fixture scope for a marker-only depth-4 leaf. | — | — | 9001-tree/0001-branch/0001-leaf/0001-deep | +| 0009-dup-x | Duplicate x | Fixture scope for a same-parent duplicate sample. | — | — | 9001-tree/0001-branch/0001-leaf/0009-dup-x | +| 0009-dup-y | Duplicate y | Fixture scope for a same-parent duplicate sample. | — | — | 9001-tree/0001-branch/0001-leaf/0009-dup-y | + +## Sequencing + +1. 0001-deep +2. 0009-dup-x +3. 0009-dup-y + +## Open issues + +- None. The `0009` pair is a deliberate same-parent duplicate sample. diff --git a/tests/fixtures/recursive-structure/items/9001-tree/0001-branch/manifest.md b/tests/fixtures/recursive-structure/items/9001-tree/0001-branch/manifest.md new file mode 100644 index 0000000..37303e9 --- /dev/null +++ b/tests/fixtures/recursive-structure/items/9001-tree/0001-branch/manifest.md @@ -0,0 +1,33 @@ +--- +feature: 9001-tree/0001-branch +phase: manifest +status: final +created: 2026-10-14 +updated: 2026-10-14 +parent: 9001-tree +--- + +# Manifest — Fixture decomposing branch + +## Initiative + +A fixture child that itself decomposes, pinning that any node may decompose to +arbitrary depth. Fixture data only. + +## Assumptions + +- Fixture data only. + +## Children + +| Local id | Title | Scope | Depends on | conflicts-with | Canonical reference | +| -------- | ----- | ----- | ---------- | -------------- | ------------------- | +| 0001-leaf | Decomposing leaf | Fixture scope for a grandchild that itself decomposes. | — | — | 9001-tree/0001-branch/0001-leaf | + +## Sequencing + +1. 0001-leaf + +## Open issues + +- None. diff --git a/tests/fixtures/recursive-structure/items/9001-tree/0002-leaf/.gitkeep b/tests/fixtures/recursive-structure/items/9001-tree/0002-leaf/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/recursive-structure/items/9001-tree/manifest.md b/tests/fixtures/recursive-structure/items/9001-tree/manifest.md new file mode 100644 index 0000000..de56368 --- /dev/null +++ b/tests/fixtures/recursive-structure/items/9001-tree/manifest.md @@ -0,0 +1,36 @@ +--- +feature: 9001-tree +phase: manifest +status: final +created: 2026-10-14 +updated: 2026-10-14 +--- + +# Manifest — Fixture hybrid tree + +## Initiative + +A committed test fixture, not a real item. It is the top-level **hybrid** node of +the recursive-structure guard (`tests/checks/81-recursive-structure-guard.sh`, +`AC27`): it both decomposes into children and carries its own `spec.md`/`tasks.md`. +Its tree reaches four item levels. + +## Assumptions + +- Fixture data only; never shipped and never executed by `/status`. + +## Children + +| Local id | Title | Scope | Depends on | conflicts-with | Canonical reference | +| -------- | ----- | ----- | ---------- | -------------- | ------------------- | +| 0001-branch | Decomposing branch | Fixture scope for a child that itself decomposes. | — | — | 9001-tree/0001-branch | +| 0002-leaf | Marker-only leaf | Fixture scope for a marker-only leaf child. | — | — | 9001-tree/0002-leaf | + +## Sequencing + +1. 0001-branch +2. 0002-leaf + +## Open issues + +- None. The fixture is deliberate, acyclic test data. diff --git a/tests/fixtures/recursive-structure/items/9001-tree/spec.md b/tests/fixtures/recursive-structure/items/9001-tree/spec.md new file mode 100644 index 0000000..a3598f6 --- /dev/null +++ b/tests/fixtures/recursive-structure/items/9001-tree/spec.md @@ -0,0 +1,13 @@ +--- +feature: 9001-tree +phase: spec +status: final +created: 2026-10-14 +updated: 2026-10-14 +--- + +# Fixture spec — 9001-tree + +The hybrid fixture node: it both decomposes (its `manifest.md`) and carries this +own phase artifact, pinning that a node may carry an artifact and a child set at +the same time. Fixture data only. diff --git a/tests/fixtures/recursive-structure/items/9001-tree/tasks.md b/tests/fixtures/recursive-structure/items/9001-tree/tasks.md new file mode 100644 index 0000000..4abd695 --- /dev/null +++ b/tests/fixtures/recursive-structure/items/9001-tree/tasks.md @@ -0,0 +1,17 @@ +--- +feature: 9001-tree +phase: tasks +status: final +created: 2026-10-14 +updated: 2026-10-14 +--- + +# Fixture tasks — 9001-tree + +Fine-grained task entries are not nodes: neither `T1` nor `T2` names a directory +in this fixture tree. + +- [ ] **T1** — Fixture task entry; it creates no item directory. [AC1] + Verify: true. +- [ ] **T2** — Fixture task entry; it creates no item directory. [AC2] + Verify: true. diff --git a/tests/fixtures/recursive-structure/items/9002-tree/0001-branch/.gitkeep b/tests/fixtures/recursive-structure/items/9002-tree/0001-branch/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/recursive-structure/items/9002-tree/0001-branch/0001-leaf/.gitkeep b/tests/fixtures/recursive-structure/items/9002-tree/0001-branch/0001-leaf/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/recursive-structure/items/9002-tree/0001-branch/manifest.md b/tests/fixtures/recursive-structure/items/9002-tree/0001-branch/manifest.md new file mode 100644 index 0000000..008c9c6 --- /dev/null +++ b/tests/fixtures/recursive-structure/items/9002-tree/0001-branch/manifest.md @@ -0,0 +1,34 @@ +--- +feature: 9002-tree/0001-branch +phase: manifest +status: final +created: 2026-10-14 +updated: 2026-10-14 +parent: 9002-tree +--- + +# Manifest — Fixture second branch + +## Initiative + +A fixture child that itself decomposes, pinning that its grandchild's local +number is allocated under its immediate parent and that equal local-id text under +a different parent is not a duplicate. + +## Assumptions + +- Fixture data only. + +## Children + +| Local id | Title | Scope | Depends on | conflicts-with | Canonical reference | +| -------- | ----- | ----- | ---------- | -------------- | ------------------- | +| 0001-leaf | Marker-only leaf | Fixture scope for a marker-only leaf grandchild. | — | — | 9002-tree/0001-branch/0001-leaf | + +## Sequencing + +1. 0001-leaf + +## Open issues + +- None. diff --git a/tests/fixtures/recursive-structure/items/9002-tree/manifest.md b/tests/fixtures/recursive-structure/items/9002-tree/manifest.md new file mode 100644 index 0000000..16168d9 --- /dev/null +++ b/tests/fixtures/recursive-structure/items/9002-tree/manifest.md @@ -0,0 +1,32 @@ +--- +feature: 9002-tree +phase: manifest +status: final +created: 2026-10-14 +updated: 2026-10-14 +--- + +# Manifest — Fixture second node + +## Initiative + +A second top-level fixture decomposing node whose local numbering independently +begins at `0001`, pinning per-immediate-parent allocation scoping. + +## Assumptions + +- Fixture data only. + +## Children + +| Local id | Title | Scope | Depends on | conflicts-with | Canonical reference | +| -------- | ----- | ----- | ---------- | -------------- | ------------------- | +| 0001-branch | Decomposing branch | Fixture scope for a child that itself decomposes. | — | — | 9002-tree/0001-branch | + +## Sequencing + +1. 0001-branch + +## Open issues + +- None. diff --git a/tests/fixtures/recursive-structure/items/9010-dup-a/spec.md b/tests/fixtures/recursive-structure/items/9010-dup-a/spec.md new file mode 100644 index 0000000..2a23d0d --- /dev/null +++ b/tests/fixtures/recursive-structure/items/9010-dup-a/spec.md @@ -0,0 +1,12 @@ +--- +feature: 9010-dup-a +phase: spec +status: final +created: 2026-10-14 +updated: 2026-10-14 +--- + +# Fixture spec — 9010-dup-a + +A top-level fixture item sharing the `9010` four-digit prefix with `9010-dup-b`, +pinning a top-level `DUPLICATE-PREFIX` finding. Fixture data only. diff --git a/tests/fixtures/recursive-structure/items/9010-dup-b/spec.md b/tests/fixtures/recursive-structure/items/9010-dup-b/spec.md new file mode 100644 index 0000000..f596a99 --- /dev/null +++ b/tests/fixtures/recursive-structure/items/9010-dup-b/spec.md @@ -0,0 +1,12 @@ +--- +feature: 9010-dup-b +phase: spec +status: final +created: 2026-10-14 +updated: 2026-10-14 +--- + +# Fixture spec — 9010-dup-b + +A top-level fixture item sharing the `9010` four-digit prefix with `9010-dup-a`, +pinning a top-level `DUPLICATE-PREFIX` finding. Fixture data only. diff --git a/tests/mutation.sh b/tests/mutation.sh index 3775a5a..ce3fccd 100755 --- a/tests/mutation.sh +++ b/tests/mutation.sh @@ -119,6 +119,7 @@ stage_copy() { # $1 = destination copy root cp -R "$REPO_ROOT/tests/fixtures/cyclic-roadmap" "$copy/tests/fixtures/cyclic-roadmap" cp -R "$REPO_ROOT/tests/fixtures/declared-conflicts" "$copy/tests/fixtures/declared-conflicts" cp -R "$REPO_ROOT/tests/fixtures/backtracking" "$copy/tests/fixtures/backtracking" + cp -R "$REPO_ROOT/tests/fixtures/recursive-structure" "$copy/tests/fixtures/recursive-structure" } # --------------------------------------------------------------------------- diff --git a/work/0015-recursive-workflow-guards/0001-recursive-structure-guard/review.md b/work/0015-recursive-workflow-guards/0001-recursive-structure-guard/review.md new file mode 100644 index 0000000..f5dcb05 --- /dev/null +++ b/work/0015-recursive-workflow-guards/0001-recursive-structure-guard/review.md @@ -0,0 +1,129 @@ +--- +feature: 0015-recursive-workflow-guards/0001-recursive-structure-guard +phase: review +status: final +created: 2026-10-14 +updated: 2026-10-14 +parent: 0015-recursive-workflow-guards +--- + +# Review — Recursive model, reference, and allocation guard + +## Verdict + +**approve** — the change adds a self-contained, fixture-based agreement area that +satisfies every acceptance criterion I can substantiate, edits no production +surface, and presents no defect I can defend; the only claims not independently +re-executed here are the suite/mutation run results, which this review +environment cannot run (see "Not reviewed"). + +No finding of any severity survives, so the verdict is `approve`. + +## Diff basis and method + +Exact commands: + +- `git merge-base HEAD origin/main` → `a006142` = `HEAD`. The item's plan commit + is already contained in `origin/main`, so the implementation under review is + the uncommitted working tree, and the base is `HEAD`. +- `git status --porcelain=v1 --untracked-files=all` +- `git diff` / `git diff --stat` +- Read every untracked file in full (the new check file and all 17 fixture + files) and the touched `tests/mutation.sh` hunk. + +Surfaces under review: + +- Modified: `tests/mutation.sh` (one appended line), and the item's own + `tasks.md` (check boxes only). +- New: `tests/checks/81-recursive-structure-guard.sh`; + `tests/fixtures/recursive-structure/**` (17 committed files); + `work//verify.md` (tester artifact). +- No file under `docs/**`, `.opencode/**`, `README.md`, `AGENTS.md`, or + `template/**` is touched (AC16). + +`work//challenges.md` is absent, so there was no open challenge to +adjudicate before forward work; no prior `review.md` exists. + +## Findings + +### Blockers + +- None. + +### Major + +- None. + +### Minor + +- None. + +### Nits + +- None. + +## Acceptance criteria walk + +| AC | Result | Evidence | +| -- | ------ | -------- | +| AC1 fixture depth | met | `tests/checks/81-recursive-structure-guard.sh:176-186` reports `bad` (never `skip`) when `$RS_ITEMS` is absent; `rs_max_depth` (`:76-91`) fails below 4. Deepest committed branch is `9001-tree/0001-branch/0001-leaf/0001-deep` (4 levels; confirmed on disk). | +| AC2 hybrid node | met | `:188-212` finds a node holding both a manifest and an own phase artifact. `tests/fixtures/recursive-structure/items/9001-tree/` holds `manifest.md` + `spec.md` + `tasks.md`. | +| AC3 per-level reservation marker | met | `:214-253` checks every child of every manifest node carries `.gitkeep` and that a marker-only leaf exists. On disk all 8 child `.gitkeep`s exist; `0001-deep`, `0009-dup-x`, `0009-dup-y`, `0002-leaf`, and `9002-tree/0001-branch/0001-leaf` are marker-only. | +| AC4 per-level allocation scoping | met | `:255-303`: ≥2 parents begin at `0001` (fixture yields 5: `9001-tree`, `9001-tree/0001-branch`, `9001-tree/0001-branch/0001-leaf`, `9002-tree`, `9002-tree/0001-branch`); the grandchild `9001-tree/0001-branch/0001-leaf` is listed under its immediate parent and its `parent:` frontmatter is `9001-tree/0001-branch`. | +| AC5 reference nests to depth | met | `:305-322`: `9001-tree/0001-branch/0001-leaf/0001-deep` resolves and matches the grammar; the literal grammar is pinned with `need "$CONV"` against `docs/artifact-conventions.md:87`. | +| AC6 top-level duplicate derived | met | `:330-352`: `rs_dup_findings` (`:133-158`) emits exactly `DUPLICATE-PREFIX\|9010-dup-a\|9010-dup-b` and the derived set is compared to the committed expected block (`:334-338`). Traced the two top-level loops and the prefix comparison. | +| AC7 same-parent depth ≥3 duplicate derived | met | `:354-359`: the expected block's `DUPLICATE-CHILD` line carries both full refs, the shared parent `.../0001-leaf`, and the shared local prefix `0009`; the depth gate (`:151`, `gsub("/","/",a) >= 2`) matches the design's "depth ≥ 3 segments". | +| AC8 position never mislabeled | met | `:361-377`: expected-block equality plus six negative clamps; the two finding kinds are emitted by separate loops (`:138-155`), and the cross-parent equal-text pairs (`0001-branch`, `0001-leaf`) are proven absent. `rs_dup_findings` keys identity on the full parent path (`par[i] == par[j]`). | +| AC9 model uniform at every node | met | `:390-395` pin `applies uniformly at every node`, `no maximum recursion depth`, `nesting continues to arbitrary depth`; all three verified present in `docs/workflow.md` (lines 61, 62, 157). | +| AC10 task entries are not nodes | met | `:400-416`: pins `are **not** nodes` (`docs/workflow.md:105`) and scans the fixture — `9001-tree/tasks.md` lists `T1`/`T2`, no `T*` directory exists. | +| AC11 reservation/allocation prose pinned | met | `:420-428`: flatten-matched `each child directory is reserved with a `.gitkeep` at that level` (`docs/artifact-conventions.md:196-197`) and `scoped to its **immediate** parent` (`:685-686`), plus `need` on `greatest 4-digit prefix ever committed under that parent and add one` (`:684`) and `never reused within that parent` (`:688`). I confirmed each phrase flattens to the asserted substring. | +| AC12 no committed executable checker | met | `:430-454` extracts the `### Merge-integrity guard` and `## Declared-conflict check` sections and pins `prompt behavior only` + `no committed checker, script, helper, or executable tool` in each (both confirmed present; the latter wraps and is flatten-matched). `:478-501` pins that the guard itself lives under `tests/checks/` and no production surface (`.opencode`, `docs`, `README.md`, `AGENTS.md`) names its stem. | +| AC13 read-only and self-contained | met | `:456-476` fails when the fixture references `work/` or an absolute/escaping token; confirmed the fixture contains neither (`grep` over `tests/fixtures/recursive-structure` finds no `work/`, no `..`, no `/(home|tmp|Users|etc|var|root|opt|mnt)/`). The guard reads only `$RS_ROOT`, `$CONV`, `$WF`, and the production-surface trees; it writes nothing and takes no lock. | +| AC14 suite stays green | met (static) | The area is sourced by `tests/run.sh` with no `run.sh` edit, labels all 37 assertions `AC27`, and is registered nowhere it needn't be. I confirmed no existing area enumerates `tests/checks/*.sh` or validates a registration table, and that `80-cycle-fixture.sh:66-71` (which scans all of `tests/fixtures` for `roadmap.md`) is unaffected because the new fixture uses `manifest.md`. Not independently re-executed (see "Not reviewed"). | +| AC15 mutation clean runs stay green | met (static) | `tests/mutation.sh:122` stages `tests/fixtures/recursive-structure` into the clean copy, so the two whole-suite clean runs and every worker copy see the fixture root. Append-only, single line, as designed. Not independently re-executed. | +| AC16 no production surface touched | met | `git status`/`git diff --name-only -- ':!work/'` name only `tests/mutation.sh` (modified) plus new files under `tests/checks/` and `tests/fixtures/recursive-structure/`; no `docs/**`, `.opencode/**`, `README.md`, `AGENTS.md`, or `template/**`. | + +## Correctness, security, tests, conventions, scope + +- **Correctness.** I re-derived `rs_alloc_events` → `rs_dup_findings` by hand over + the committed tree: the only top-level prefix collision is `9010-dup-a`/ + `9010-dup-b`, and the only same-parent collision is `0009-dup-x`/`0009-dup-y` + under `.../0001-leaf`; the emitted, `LC_ALL=C sort`-ordered set equals the + committed expected block, and command-substitution trailing-newline stripping + makes the `[ "$RS_DUP_ALL" = "$RS_EXPECTED_DUP" ]` comparison sound. +- **Shell robustness.** The helpers are bash-3.2-safe (no associative arrays, no + `mapfile`; read loops fed by process substitution), the file is sourced so it + calls no `exit`, and all variables referenced under `set -u` are initialized. + No symbol collision with the other single-process sourced areas (`RS_*`/`rs_*` + appear only here). +- **Security.** No secrets, no network, no writes, no `eval`; input is committed + fixture data. +- **Tests.** The guard is itself the test and is reasonably mutation-sensitive + (37 assertions; the expected-block equality is the load-bearing check, with the + negative clamps and the prose pins as additional tripwires). I did not find a + weakened or vacuous assertion that would let a real regression pass. +- **Conventions.** Matches the sibling fixture guards (`80`, `85`, `87`): one + `tests/checks/*.sh` per area, `ok`/`bad`/`need` harness reuse, manifest-then- + alias resolution, `AC27`-labelled sub-areas, `LC_ALL=C` ordering. +- **Scope.** Purely additive; the only shared surface touched is the one + `stage_copy()` line, exactly as designed. No scope creep. + +## Not reviewed (nuances, not findings) + +- **Execution of the suite/mutation pass.** This sandbox denies `bash`, so I + could not execute `bash tests/run.sh`, `FRAMEWORK_TEST_AREAS=81-... bash + tests/run.sh`, or `bash tests/mutation.sh`. AC14/AC15 rest on the recorded + results in `verify.md` plus my static confirmation that the fixture is present, + the labels are `AC27`, and the `stage_copy()` line exists. If a maintainer wants + an independently re-run proof, that is the remaining gap. +- **The guard derives structure from directory names and `parent:` frontmatter, + not from the fixture manifests' `Children` tables** (`verify.md` records this). + That is within scope: no acceptance criterion here requires table parsing, and + `0004-recursive-integrity-guard` owns the invariant set. +- **`AC27` is provisional** (design's carried-forward open issue); registration in + `tests/README.md` and the committed mutation jobs are owned by + `0005-guard-registration-sweep`. Not a defect of this item. +- **The read-only scan covers the fixture text, not the guard script** + (`verify.md` acknowledges it). The script contains no resolved absolute or + escaping path; the `\.\.`/`/(home|…)/` tokens are its detection regex, not a + path it uses. I agree this is a documentation nuance, not an unmet criterion. diff --git a/work/0015-recursive-workflow-guards/0001-recursive-structure-guard/tasks.md b/work/0015-recursive-workflow-guards/0001-recursive-structure-guard/tasks.md index 5ef0e59..e9da935 100644 --- a/work/0015-recursive-workflow-guards/0001-recursive-structure-guard/tasks.md +++ b/work/0015-recursive-workflow-guards/0001-recursive-structure-guard/tasks.md @@ -14,7 +14,7 @@ Ordered, dependency-aware. One task ≈ one focused commit. Every task leaves `tests/checks/81-recursive-structure-guard.sh` and the fixture root is `tests/fixtures/recursive-structure/` (see `design.md`). -- [ ] **T1** — Author the committed fixture root +- [x] **T1** — Author the committed fixture root `tests/fixtures/recursive-structure/items/` exactly as tabulated in `design.md`: the hybrid `9001-tree/` (`manifest.md`, `spec.md`, `tasks.md`), its decomposing `0001-branch/` → `0001-leaf/` chain down to the depth-4 @@ -28,7 +28,7 @@ Ordered, dependency-aware. One task ≈ one focused commit. Every task leaves matching directory; no fixture text contains `work/`, `..`, or an absolute path. [AC1, AC2, AC3, AC4, AC6, AC7, AC8, AC10] Verify: `bash -c 'set -eu; R=tests/fixtures/recursive-structure/items; for f in 9001-tree/manifest.md 9001-tree/spec.md 9001-tree/tasks.md 9001-tree/0001-branch/.gitkeep 9001-tree/0001-branch/manifest.md 9001-tree/0001-branch/0001-leaf/0001-deep/.gitkeep 9001-tree/0001-branch/0001-leaf/0009-dup-x/.gitkeep 9001-tree/0001-branch/0001-leaf/0009-dup-y/.gitkeep 9001-tree/0002-leaf/.gitkeep 9002-tree/0001-branch/0001-leaf/.gitkeep 9010-dup-a/spec.md 9010-dup-b/spec.md; do test -f "$R/$f"; done; ! grep -rq "work/" "$R"; echo ok'`. -- [ ] **T2** — Author `tests/checks/81-recursive-structure-guard.sh` with the +- [x] **T2** — Author `tests/checks/81-recursive-structure-guard.sh` with the header, `AC27` labels, path constants, the `rs_is_item_dir`/`rs_manifest`/ `rs_children`/`rs_max_depth` helpers, and the fixture-shape sub-areas `fixture-depth` (fail — not skip — when the root is absent; fail when the @@ -42,7 +42,7 @@ Ordered, dependency-aware. One task ≈ one focused commit. Every task leaves `docs/artifact-conventions.md`). Sourced by `run.sh`, so it must not call `exit`. [AC1, AC2, AC3, AC4, AC5] [depends: T1] Verify: `FRAMEWORK_TEST_AREAS=81-recursive-structure-guard bash tests/run.sh` exits 0. -- [ ] **T3** — Add the duplicate-derivation sub-areas to +- [x] **T3** — Add the duplicate-derivation sub-areas to `81-recursive-structure-guard.sh`: `rs_dup_findings` (full-canonical-path identity; `DUPLICATE-PREFIX` for the top-level `9010` pair and `DUPLICATE-CHILD` for the depth-4 `0009` pair with shared parent and shared @@ -52,7 +52,7 @@ Ordered, dependency-aware. One task ≈ one focused commit. Every task leaves `DUPLICATE-CHILD` and no deeper collision labeled `DUPLICATE-PREFIX`). [AC6, AC7, AC8] [depends: T2] Verify: `FRAMEWORK_TEST_AREAS=81-recursive-structure-guard bash tests/run.sh` exits 0 and prints the `duplicate-derivation` and `duplicate-position` ok lines. -- [ ] **T4** — Add the `contract-pins` sub-area to +- [x] **T4** — Add the `contract-pins` sub-area to `81-recursive-structure-guard.sh`: pin `applies uniformly at every node`, `no maximum recursion depth`, and `nesting continues to arbitrary depth` on `docs/workflow.md`; `are **not** nodes` on `docs/workflow.md`; the @@ -62,20 +62,20 @@ Ordered, dependency-aware. One task ≈ one focused commit. Every task leaves `no committed checker, script, helper, or executable tool` (using `flat` for the declared-conflict sentence, which wraps). [AC9, AC10, AC11, AC12] [depends: T3] Verify: `FRAMEWORK_TEST_AREAS=81-recursive-structure-guard bash tests/run.sh` exits 0 and prints the `contract-pins` ok lines. -- [ ] **T5** — Add the `read-only-contract` sub-area to +- [x] **T5** — Add the `read-only-contract` sub-area to `81-recursive-structure-guard.sh`: fail when the fixture root references a live `work/` path or contains a `..`/absolute path token; assert the guard adds no committed production checker (it is invoked by no framework command). [AC13] [depends: T4] Verify: `FRAMEWORK_TEST_AREAS=81-recursive-structure-guard bash tests/run.sh` exits 0; run it twice capturing output under `scratch/` and `diff` the two outputs → identical (idempotent, writes no file). -- [ ] **T6** — Append exactly one line to `stage_copy()` in `tests/mutation.sh` +- [x] **T6** — Append exactly one line to `stage_copy()` in `tests/mutation.sh` copying `tests/fixtures/recursive-structure` into the staged copy, so the mutation self-check's clean whole-suite runs see the fixture root (append-only; no mutation job is added — `0005` owns those). [AC15] [depends: T5] Verify: `bash tests/mutation.sh` exits 0 (its two clean whole-suite runs pass with the new area present). -- [ ] **T7** — Confirm the committed suite and scope: fix any regression surfaced +- [x] **T7** — Confirm the committed suite and scope: fix any regression surfaced by T2–T6. [AC14, AC16] [depends: T6] Verify: `bash tests/run.sh` exits 0 with every existing area green and the new area labelling its assertions `AC27`; `git diff --name-only` (excluding diff --git a/work/0015-recursive-workflow-guards/0001-recursive-structure-guard/verify.md b/work/0015-recursive-workflow-guards/0001-recursive-structure-guard/verify.md new file mode 100644 index 0000000..d4b98bb --- /dev/null +++ b/work/0015-recursive-workflow-guards/0001-recursive-structure-guard/verify.md @@ -0,0 +1,113 @@ +--- +feature: 0015-recursive-workflow-guards/0001-recursive-structure-guard +phase: test +status: final +created: 2026-10-14 +updated: 2026-10-14 +parent: 0015-recursive-workflow-guards +--- + +# Verification — Recursive model, reference, and allocation guard + +## Commands run + +| Command | Result | Notes | +| ------- | ------ | ----- | +| `bash tests/run.sh` | PASS | `TOTAL: 540 passed, 0 failed, 0 skipped` — full committed suite green with the new area present | +| `FRAMEWORK_TEST_AREAS=81-recursive-structure-guard bash tests/run.sh` | PASS | `TOTAL: 37 passed, 0 failed, 0 skipped`; 37 assertions all labelled `AC27` | +| `bash tests/mutation.sh` | PASS | clean whole-suite runs `540/0/0` and `538/0/2 skipped`; `MUTATION TOTAL: 30 checked passed, 0 failed`; `whole-suite runs: 2` | +| `bash tests/mutation.sh --selftest` | PASS | `SELFTEST: 6 passed, 0 failed` | +| `bash tests/run.sh` twice, `diff` of `AC27` output | PASS | identical (idempotent); two concurrent area runs produced identical findings | +| full `stage_copy` surfaces with **no `work/`**, `bash tests/run.sh ` | PASS | `TOTAL: 540 passed, 0 failed, 0 skipped` — fresh-clone-without-`work/**` proxy | +| `bash tests/run.sh ` with `FRAMEWORK_TEST_NO_PY/_OPENCODE/_NPM=1` | PASS | area `37/0/0` — guard needs only bash/git and never skips | +| `git diff --name-only -- ':!work/'` + `git status --untracked-files=all` | PASS | only `tests/mutation.sh` (modified) and `tests/checks/81-*`/`tests/fixtures/recursive-structure/**` (new) | +| scratch mutation harness (ephemeral, 28 targeted mutations) | PASS | every targeted regression was caught and named; see below | + +## Acceptance coverage + +| Criterion | Test(s) | Result | +| --------- | ------- | ------ | +| AC1 fixture depth | `tests/checks/81-recursive-structure-guard.sh` → `AC27 fixture-depth` (root present, `rs_max_depth ≥ 4`); independent mutation: remove root → `FAIL ... fixture root missing`; reduce to 3 levels → `FAIL ... deepest branch is 3 item levels` | PASS | +| AC2 hybrid node | `AC27 hybrid-node` (finds `9001-tree`, a manifest plus own `spec.md`); mutation: strip `9001-tree/spec.md`+`tasks.md` → `FAIL ... no node both decomposes` | PASS | +| AC3 per-level reservation marker | `AC27 reservation-marker` (every child of every manifest node carries `.gitkeep`; a marker-only leaf exists); mutations: remove one `.gitkeep` → FAIL; give every marker-only leaf an artifact → `FAIL ... no marker-only leaf` | PASS | +| AC4 allocation scoping | `AC27 allocation-scoping` (`5 distinct parents each begin ... at 0001`; grandchild under its immediate parent; `parent: 9001-tree/0001-branch` frontmatter); mutations: wrong grandchild `parent:` → FAIL; rename every child so no parent starts `0001` → `FAIL ... fewer than two parents begin` | PASS | +| AC5 reference nests to depth | `AC27 reference-depth` (depth-four reference resolves; `^[0-9]{4}-[a-z0-9-]+(/[0-9]{4}-[a-z0-9-]+)*$` pinned in `docs/artifact-conventions.md`); mutations: remove `0001-deep` → FAIL; tamper the pinned regex → FAIL | PASS | +| AC6 top-level duplicate derived | `AC27 duplicate-derivation` (expected-block equality; exactly one `DUPLICATE-PREFIX|9010-dup-a|9010-dup-b`); mutations: add a third `9010-*` top-level item → block mismatch FAIL; add a real sibling `0001-zzz` under `9001-tree` → block mismatch FAIL | PASS | +| AC7 same-parent depth ≥ 3 duplicate derived | `AC27 duplicate-derivation` (exactly one `DUPLICATE-CHILD|…/0009-dup-x|…/0009-dup-y|…/0001-leaf|0009`); mutation: break the depth-4 pair → block mismatch FAIL | PASS | +| AC8 never mislabeled | `AC27 duplicate-position` — six negative assertions: `9010-dup` absent from the `DUPLICATE-CHILD` set, `0009-dup` absent from the `DUPLICATE-PREFIX` set, and the four equal-text/different-parent pairs absent from `DUPLICATE-CHILD`; the two finding kinds are emitted by separate loops | PASS | +| AC9 model uniform at every node | `AC27 contract-pins` — `need` on `docs/workflow.md` for `applies uniformly at every node`, `no maximum recursion depth`, `nesting continues to arbitrary depth`; mutation drops each phrase → named FAIL | PASS | +| AC10 task entries are not nodes | `AC27 contract-pins` — `need` `are **not** nodes` + fixture scan (no `T*` directory, no listed task names a directory); mutation: create `items/T1/` → FAIL | PASS | +| AC11 reservation/allocation prose pinned | `AC27 contract-pins` — `flat`-matched `each child directory is reserved with a \`.gitkeep\` at that level` and `scoped to its **immediate** parent`; `need` `greatest 4-digit prefix ever committed under that parent and add one`, `never reused within that parent`; mutation drops each (incl. the wrapped first two) → named FAIL | PASS | +| AC12 no committed executable checker | `AC27 contract-pins` — `prompt behavior only` + `no committed checker, script, helper, or executable tool` in both the `### Merge-integrity guard` and `## Declared-conflict check` sections (`flat`-matched); `AC27 read-only-contract` — the guard lives under `tests/checks/` and no production surface names it; mutations drop each phrase, and a doc reference to the guard stem → named FAIL | PASS | +| AC13 read-only and self-contained | `AC27 read-only-contract` — fixture references no live `work/` path and no `..`/absolute token; mutations injecting `work/foo` and `../bar` → FAIL; idempotence: two runs and two concurrent runs produced identical `AC27` output and modified no file | PASS | +| AC14 suite stays green | `bash tests/run.sh` → `540/0/0`; staged copy with no `work/**` → `540/0/0`; area → `37/0/0`, every assertion labelled `AC27` | PASS | +| AC15 mutation clean runs stay green | `bash tests/mutation.sh`: `clean copy (no work/)` and `clean copy with optional tools absent` both passed with the new fixture root staged by T6's `stage_copy()` line; 30/30 mutations caught | PASS | +| AC16 no production surface touched | `git status`/`git diff --name-only -- ':!work/'` name only `tests/mutation.sh`; untracked files are only under `tests/checks/` and `tests/fixtures/recursive-structure/`; no `docs/**`, `.opencode/**`, `README.md`, `AGENTS.md`, or `template/**` is modified | PASS | + +## Edge cases + +| Edge case | Coverage | Result | +| --------- | -------- | ------ | +| Missing fixture | Mutation removes the fixture root → hard `FAIL` naming the root (never a skip) | PASS | +| Shallow fixture | Mutation reduces the tree to 3 levels → `FAIL ... deepest branch is 3 item levels` | PASS | +| Equal text, different parents | Fixture contains `0001-branch`/`0001-leaf` text under both `9001-tree` and `9002-tree`; `duplicate-position` asserts neither is a same-parent collision; identity is the full canonical path | PASS | +| Shared prefix at depth ≥ 3 | `DUPLICATE-CHILD` line carries the shared parent (`…/0001-leaf`) and shared local prefix (`0009`) | PASS | +| Marker-only node | `9001-tree/0002-leaf` etc. are marker-only and produce no false duplicate | PASS | +| Empty or whitespace-only token | **Not exercised by this guard.** This area parses no conflict/token grammar (it derives structure from directory names); token well-formedness is owned by `0003-recursive-conflicts-guard`. The pinned anchored reference regex does reject empty segments, but the guard adds no negative assertion for it. | N/A (out of area) | +| Staged-copy execution | `tests/mutation.sh` stages only the fixture surfaces (no `work/`) and the guard resolves sibling files relative to its own location; clean runs pass | PASS | +| Idempotence and concurrency | Two serial runs and two concurrent runs yield identical findings; runs modify no file (git status unchanged) | PASS | +| Optional-tool absence | Area run with `FRAMEWORK_TEST_NO_PY/_OPENCODE/_NPM=1` → `37/0/0` | PASS | + +## Independent mutation evidence + +The guard's sensitivity was independently re-checked (not by the builder's own +assertions) with an ephemeral scratch harness that stages the project surfaces +into `scratch/` and mutates only the staged copy — the same mechanism +`tests/mutation.sh` uses — then runs `FRAMEWORK_TEST_AREAS=81-recursive-structure-guard`. +Twenty-eight correctly-targeted mutations, each with the exact `AC27` label it +must trip, were all caught and named: + +- fixture-depth: missing root; shallow-to-3 +- hybrid-node: strip the hybrid node's own artifacts +- reservation-marker: drop a `.gitkeep`; make every marker-only leaf carry an artifact +- allocation-scoping: corrupt the grandchild `parent:`; remove every `0001-` child start +- reference-depth: delete the depth-4 directory; tamper the pinned grammar literal +- duplicate-derivation: add a third top-level `9010-*`; rename a depth-4 dup; add a real sibling dup +- contract-pins: drop each of `applies uniformly at every node`, `no maximum recursion depth`, + `nesting continues to arbitrary depth`, `are **not** nodes`, the wrapped reservation phrase, + `scoped to its **immediate** parent`, `greatest 4-digit prefix ever committed under that parent + and add one`, `never reused within that parent`, and `prompt behavior only` / + `no committed checker, script, helper, or executable tool` in each of the merge-integrity and + declared-conflict sections; create a task-like `T1/` directory +- read-only-contract: inject a `work/` token; inject a `../` token; name the guard from a production doc + +Three initially mis-targeted probes (a single-line `sed` that missed the +wrapped prose, a removal that left depth 4 via the `0009` pair, and a +top-level-phrase edit that missed the child-level pinned sentence) were +corrected; none indicated a guard gap. No mutation escaped. + +## Gaps and residual risk + +- **Suite token `AC27` is provisional.** The design records it for `0005-guard-registration-sweep` + to reconcile against siblings `0002`–`0004` and register in `tests/README.md`. The guard's + labels are internally consistent (`AC27` on all 37 assertions). +- **Committed mutation jobs for this guard do not exist yet** (owned by `0005`). AC15's + requirement is only that the *clean* whole-suite runs stay green, which they do; sensitivity was + established here by the independent scratch harness rather than committed mutation jobs. +- **The guard derives structure from directory names and `parent:` frontmatter**, not from the + fixture manifests' `Children` tables. Malformed table content in the fixture (e.g. swapped + header columns) is not caught by *this* guard; the recursive integrity/table checks are owned by + `0004-recursive-integrity-guard`/`0005`. No acceptance criterion here requires table parsing. +- **`AC8` "never mislabeled" is primarily structural** (two independent emission paths); the + expected-block equality is the load-bearing assertion, with the six negative members as clamps. +- **The read-only-contract scan covers the fixture text**, not the guard script itself. The script + contains no absolute/escaping path in practice (verified by inspection); this is a documentation + nuance, not an unmet criterion. + +No implementation, spec, or design defect was found; no `backtracks.md` finding is recorded. + +## Verdict + +Every acceptance criterion and applicable edge case is covered by a passing, +mutation-sensitive assertion. The committed suite and the mutation self-check's +clean runs are green. No test was weakened, skipped, or deleted.