Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 68 additions & 5 deletions bin/fm-classify-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,74 @@ status_is_paused_or_captain_held() { # <status-line>
# the historical one-open-decision-per-task behavior (a bare "resolved:" closes
# "default"). A stated key whose slug fails the charset below is rejected (the
# folds skip the line), never rewritten to "default".
# The parsers are pure reads of a single line; the verb parser strips any key
# token before the colon so the leading word is recovered cleanly.
# The parsers are pure reads of a single line. Status metadata may carry any
# number of complete whitespace-separated "[name=value]" tags before the colon
# (any name, typically corr= and key=). Verb recovery strips that suffix only
# when every token is strictly well-formed: no whitespace or '[' in a token
# body, and a locale-independent ASCII slug name (explicit A-Za-z0-9._-
# allowlist, not locale-widened ranges). Incomplete or malformed bracket
# material keeps the verb glued so the line stays inert. When the complete-tag
# check fails, the historical peel from the first "[key=" is preserved so
# key-led stray and unclosed-key lines keep folding as they do today.
# head=${v%%\[*} below is only a candidate prefix assigned when the complete-tag
# predicate passes - not an unconditional first-bracket peel.
# 0 when <s> is empty or a whitespace-separated run of complete
# "[name=value]" metadata tags with locale-independent ASCII slug names.
_fm_is_wholly_complete_metadata_tags() { # <before-colon-tag-suffix>
local s=$1 token rest name
while [ -n "$s" ]; do
s=${s#"${s%%[![:space:]]*}"}
[ -n "$s" ] || return 0
case "$s" in
\[*) ;;
*) return 1 ;;
esac
case "$s" in
*\]*) ;;
*) return 1 ;;
esac
rest=${s#\[}
token=${rest%%\]*}
rest=${rest#*\]}
# Fail-closed: token body may not contain whitespace or another '['.
case "$token" in
*[[:space:]]*|*\[*) return 1 ;;
esac
case "$token" in
*=*)
name=${token%%=*}
# Locale-independent ASCII slug name (explicit allowlist; no A-Z ranges).
case "$name" in
''|*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-]*) return 1 ;;
esac
;;
*) return 1 ;;
esac
if [ -n "$rest" ]; then
case "$rest" in
[[:space:]]*) ;;
*) return 1 ;;
esac
fi
s=$rest
done
return 0
}
status_line_verb() { # <status-line> -> leading verb word
local v=${1%%:*}
v=${v%%\[key=*}
local v=${1%%:*} head tags_region
case "$v" in
*\[*)
head=${v%%\[*}
tags_region=${v#"$head"}
if _fm_is_wholly_complete_metadata_tags "$tags_region"; then
v=$head
else
case "$v" in
*\[key=*) v=${v%%\[key=*} ;;
esac
fi
;;
esac
v=${v#"${v%%[![:space:]]*}"}
v=${v%"${v##*[![:space:]]}"}
printf '%s' "$v"
Expand Down Expand Up @@ -454,7 +517,7 @@ _fm_open_decisions_cursor_path() { # <status-file>
printf '%s/.%s.open-decisions-cursor' "$dir" "${base%.status}"
}

FM_OPEN_DECISIONS_FOLD_VERSION=3
FM_OPEN_DECISIONS_FOLD_VERSION=4

# Portable device:inode identity for the rotation/recreation check below.
_fm_open_decisions_file_ident() { # <file> -> "dev:inode", empty on I/O failure
Expand Down
140 changes: 140 additions & 0 deletions tests/fm-classify-decision-key.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,140 @@ test_incremental_agrees_with_full_fold_across_appends() {
pass "the incremental fold matches the full fold across appends in both key positions"
}

# Metadata-tag verb recovery: complete before-colon [name=value] tags (any
# well-formed name, typically corr=) must not glue onto the verb. Fail-closed
# on malformed bodies/names so phantom keys never open. Key-led stray still
# peels via the historical [key= fallback.
test_corr_then_key_opens_and_closes_under_stated_key() {
local dir expected
dir=$(case_dir corr-then-key)
printf 'needs-decision [corr=d448ea86afa4bf67] [key=project-registration-path]: no proven path\n' \
> "$dir/t.status"
expected=$(printf 'project-registration-path\tneeds-decision\tno proven path\n')
assert_fold "$dir/t.status" "$expected" "corr-then-key open"

printf 'resolved [corr=deadbeef] [key=project-registration-path]: answered: courier\n' \
>> "$dir/t.status"
assert_fold "$dir/t.status" "" "corr-tagged resolved closes stated key"
pass "corr-then-key opens and closes under the stated key"
}

test_shape_i_corr_before_colon_key_at_note_head() {
local dir expected
dir=$(case_dir shape-i)
printf 'needs-decision [corr=d448ea86afa4bf67]: [key=project-registration-path] pick path\n' \
> "$dir/t.status"
expected=$(printf 'project-registration-path\tneeds-decision\tpick path\n')
assert_fold "$dir/t.status" "$expected" "shape I open"
printf 'resolved [corr=d448ea86afa4bf67] [key=project-registration-path]: answered: courier\n' \
>> "$dir/t.status"
assert_fold "$dir/t.status" "" "shape I close"
pass "corr before colon with key at note head opens and closes the stated key"
}

test_key_led_stray_and_unclosed_still_fold() {
local dir
dir=$(case_dir key-led-preserve)
printf 'needs-decision [key=stray-slug] stray prose: keep key open\n' > "$dir/a.status"
assert_fold "$dir/a.status" \
"$(printf 'stray-slug\tneeds-decision\tkeep key open\n')" "key-led stray open"

printf 'needs-decision [key=k1]: open\n' > "$dir/b.status"
printf 'resolved [key=k1] stray prose: answered\n' >> "$dir/b.status"
assert_fold "$dir/b.status" "" "key-led stray close"

printf 'needs-decision [key=unclosed-slug: missing close\n' > "$dir/c.status"
assert_fold "$dir/c.status" \
"$(printf 'default\tneeds-decision\tmissing close\n')" "unclosed key peels to default"
pass "key-led stray and unclosed-key lines keep today's fold behavior"
}

test_malformed_metadata_never_opens_phantom_keys() {
local dir
dir=$(case_dir malformed-meta)
# Cross-token invalid+valid (round-2 codex).
printf 'needs-decision [draft] [corr=x]: must stay inert\n' > "$dir/mixed.status"
assert_fold "$dir/mixed.status" "" "draft then corr"
printf 'needs-decision [draft] [corr=x] [key=phantom]: must stay inert\n' > "$dir/mixedk.status"
assert_fold "$dir/mixedk.status" "" "draft then corr then key"

# Malformed bodies with embedded [key=...] (round-3).
printf 'needs-decision [corr=abc [key=phantom]: truncated corr\n' > "$dir/trunc.status"
assert_fold "$dir/trunc.status" "" "truncated corr embeds key"
printf 'needs-decision [[key=phantom]: doubled bracket\n' > "$dir/dbl.status"
assert_fold "$dir/dbl.status" "" "doubled open bracket"
printf 'needs-decision [draft[corr=x] [key=phantom]: nested open\n' > "$dir/nest.status"
assert_fold "$dir/nest.status" "" "nested open bracket"
printf 'needs-decision [draft status=review] [key=phantom]: spaced name\n' > "$dir/space.status"
assert_fold "$dir/space.status" "" "spaced name body"

# Punctuated / locale-widened names (round-4/5).
printf 'needs-decision [draft!status=review] [key=phantom]: punct bang\n' > "$dir/bang.status"
assert_fold "$dir/bang.status" "" "punctuated name bang"
printf 'needs-decision [dräft=review] [key=phantom]: unicode name\n' > "$dir/uni.status"
assert_fold "$dir/uni.status" "" "latin-extended name"

# Close-side twin: malformed closer must not close a real open key.
printf 'needs-decision [key=realc]: keep open\n' > "$dir/close.status"
printf 'resolved [drop!name=x] [key=realc]: must not close\n' >> "$dir/close.status"
assert_fold "$dir/close.status" \
"$(printf 'realc\tneeds-decision\tkeep open\n')" "malformed closer leaves realc open"

# Adjacent tags and empty name stay inert on the complete-tag path.
printf 'needs-decision [corr=x][key=adj]: adjacent\n' > "$dir/adj.status"
assert_fold "$dir/adj.status" "" "adjacent tags"
printf 'needs-decision [[=x]: empty-ish name\n' > "$dir/eq.status"
assert_fold "$dir/eq.status" "" "degenerate body"

# Shape C grammar control: end-of-note key is prose, opens default only.
printf 'needs-decision: no proven path [key=project-registration-path]\n' > "$dir/end.status"
assert_fold "$dir/end.status" \
"$(printf 'default\tneeds-decision\tno proven path [key=project-registration-path]\n')" \
"end-of-note key prose"

pass "malformed metadata never opens phantom keys; key-end grammar and close twin hold"
}

test_shared_verb_recovery_for_valid_corr_tags() {
local line
line='needs-decision [corr=d448ea86afa4bf67] [key=project-registration-path]: path'
status_is_captain_relevant "$line" \
|| fail "corr-tagged needs-decision should be captain-relevant"
status_frees_capacity "$line" \
|| fail "corr-tagged needs-decision should free capacity"

line='resolved [draft]: prose'
status_frees_capacity "$line" \
&& fail "resolved [draft] must not free capacity"
line='resolved [draft] [corr=x]: prose'
status_frees_capacity "$line" \
&& fail "resolved [draft] [corr=x] must not free capacity"

line='paused [corr=d448ea86afa4bf67]: waiting'
status_is_paused "$line" \
|| fail "corr-tagged paused should match the pause verb"

local dir
dir=$(case_dir activity)
printf 'working [corr=abc] [key=work-slug]: phase one\n' > "$dir/a.status"
[ "$(status_open_activities "$dir/a.status")" = "$(printf 'work-slug\tworking\tphase one\n')" ] \
|| fail "activity should open under corr-tagged working: $(status_open_activities "$dir/a.status")"
printf 'done [corr=x] [key=work-slug]: finished\n' >> "$dir/a.status"
[ -z "$(status_open_activities "$dir/a.status")" ] \
|| fail "activity should close under corr-tagged done: $(status_open_activities "$dir/a.status")"

pass "valid corr tags recover shared classifiers; malformed draft tags stay inert"
}

test_captain_held_with_corr_closes() {
local dir
dir=$(case_dir captain-held-corr)
printf 'needs-decision [key=api-shape]: pick REST or RPC\n' > "$dir/t.status"
printf 'captain-held [corr=aaaa] [key=api-shape]: tracked by hold-1\n' >> "$dir/t.status"
assert_fold "$dir/t.status" "" "captain-held with corr closes"
pass "captain-held with a leading corr tag closes the stated key"
}

test_stated_key_is_honored_in_both_positions
test_bare_keyless_line_still_folds_to_default
test_resolution_closes_across_positions
Expand All @@ -179,3 +313,9 @@ test_two_colon_form_decisions_stay_distinct
test_mid_note_prose_mention_is_not_a_stated_key
test_malformed_stated_key_never_collapses_to_default
test_incremental_agrees_with_full_fold_across_appends
test_corr_then_key_opens_and_closes_under_stated_key
test_shape_i_corr_before_colon_key_at_note_head
test_key_led_stray_and_unclosed_still_fold
test_malformed_metadata_never_opens_phantom_keys
test_shared_verb_recovery_for_valid_corr_tags
test_captain_held_with_corr_closes
87 changes: 87 additions & 0 deletions tests/fm-send-resolve-key.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,90 @@ test_flag_misuse_refuses() {
pass "fm-send --resolve-key: --key, empty message, explicit targets, and malformed keys refuse loudly"
}

# Filed adopted-stack regression: corr-tagged open key must be answerable.
test_corr_tagged_open_key_is_answerable() {
local dir fb log home rc out
dir="$TMP_ROOT/corr-tagged"; mkdir -p "$dir"
fb=$(make_stubs "$dir"); log="$dir/send.log"
home=$(setup_home corr-tagged)
fm_write_meta "$home/state/t-corr.meta" "window=sess:fm-t-corr" "kind=ship"
printf 'needs-decision [corr=d448ea86afa4bf67] [key=project-registration-path]: no proven IPC path\n' \
> "$home/state/t-corr.status"

out=$(drain_out "$home")
printf '%s' "$out" | grep -F '[key=project-registration-path]' >/dev/null \
|| fail "precondition: corr-tagged decision should list as open: $out"

run_send "$fb" "$home" "$log" t-corr --resolve-key project-registration-path "use courier UI path"; rc=$?
expect_code 0 "$rc" "answering a corr-tagged open key should succeed"
assert_contains "$(cat "$log")" "use courier UI path" "the answer text should reach the worker"
grep -F 'resolved [key=project-registration-path]: answered: use courier UI path' \
"$home/state/t-corr.status" >/dev/null \
|| fail "missing closing resolved line:"$'\n'"$(cat "$home/state/t-corr.status")"

out=$(drain_out "$home")
if printf '%s' "$out" | grep -F 'OPEN DECISIONS' >/dev/null; then
fail "answered corr-tagged decision still lists as open: $out"
fi
pass "fm-send --resolve-key: a corr-tagged open key is answerable end to end"
}

# Fail-closed: malformed metadata must not invent a phantom open key that sends.
test_malformed_metadata_refuses_phantom_resolve_key() {
local dir fb log home err rc
dir="$TMP_ROOT/phantom-refuse"; mkdir -p "$dir"
fb=$(make_stubs "$dir"); log="$dir/send.log"; err="$dir/send.err"
home=$(setup_home phantom-refuse)
fm_write_meta "$home/state/t-ph.meta" "window=sess:fm-t-ph" "kind=ship"

refuse_case() { # <status-line> <label>
printf '%s\n' "$1" > "$home/state/t-ph.status"
: > "$log"
env PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$home" FM_HOME="$home" FM_SEND_LOG="$log" FM_SEND_SETTLE=0 \
"$SEND" t-ph --resolve-key phantom "this must not send" >/dev/null 2>"$err"; rc=$?
[ "$rc" -ne 0 ] || fail "$2: expected refuse, got rc=0"
[ ! -s "$log" ] || fail "$2: transport received bytes: $(cat "$log")"
if grep -F 'resolved [key=phantom]' "$home/state/t-ph.status" >/dev/null; then
fail "$2: resolved line was appended: $(cat "$home/state/t-ph.status")"
fi
}

refuse_case 'needs-decision [draft] [corr=d448ea86afa4bf67] [key=phantom]: mixed' "mixed invalid+valid"
refuse_case 'needs-decision [corr=abc [key=phantom]: truncated corr' "truncated corr body"
refuse_case 'needs-decision [[key=phantom]: doubled bracket' "doubled bracket"
refuse_case 'needs-decision [draft!status=review] [key=phantom]: punct bang' "punctuated name"
refuse_case 'needs-decision [dräft=review] [key=phantom]: unicode name' "unicode name"

# Stock macOS Bash 3.2 + en_US.UTF-8: range patterns widen; allowlist must still refuse.
if [ -x /bin/bash ]; then
printf 'needs-decision [dräft=review] [key=phantom]: stock-bash unicode\n' > "$home/state/t-ph.status"
: > "$log"
env LC_ALL=en_US.UTF-8 PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$home" FM_HOME="$home" \
FM_SEND_LOG="$log" FM_SEND_SETTLE=0 \
/bin/bash "$SEND" t-ph --resolve-key phantom "this must not send" >/dev/null 2>"$err"; rc=$?
[ "$rc" -ne 0 ] || fail "stock bash UTF-8 unicode name should refuse"
[ ! -s "$log" ] || fail "stock bash UTF-8 unicode name still sent: $(cat "$log")"
fi

pass "fm-send --resolve-key: malformed metadata never invents a sendable phantom key"
}

# Key-led stray preserve: still answerable under the historical peel.
test_key_led_stray_preserve_is_answerable() {
local dir fb log home rc
dir="$TMP_ROOT/key-stray"; mkdir -p "$dir"
fb=$(make_stubs "$dir"); log="$dir/send.log"
home=$(setup_home key-stray)
fm_write_meta "$home/state/t-ks.meta" "window=sess:fm-t-ks" "kind=ship"
printf 'needs-decision [key=stray-slug] stray prose: keep\n' > "$home/state/t-ks.status"

run_send "$fb" "$home" "$log" t-ks --resolve-key stray-slug "answered stray"; rc=$?
expect_code 0 "$rc" "key-led stray should remain answerable"
grep -F 'resolved [key=stray-slug]: answered: answered stray' "$home/state/t-ks.status" >/dev/null \
|| fail "missing close for key-led stray:"$'\n'"$(cat "$home/state/t-ks.status")"
pass "fm-send --resolve-key: key-led stray preserve remains answerable"
}

test_answer_send_closes_open_decision
test_colon_first_key_position_is_answerable
test_rejected_close_fails_after_delivery
Expand All @@ -464,3 +548,6 @@ test_local_secondmate_answer_marked_and_closed
test_remote_secondmate_answer_closes_locally
test_remote_transport_failure_does_not_close
test_flag_misuse_refuses
test_corr_tagged_open_key_is_answerable
test_malformed_metadata_refuses_phantom_resolve_key
test_key_led_stray_preserve_is_answerable
41 changes: 41 additions & 0 deletions tests/fm-wake-drain-open-decisions-cursor.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -311,9 +311,50 @@ test_previous_fold_cache_is_refolded_under_current_semantics() {
pass "an old fold cache is rebuilt once before same-version incremental reads resume"
}

# Fold-version bump (3->4) must invalidate a pre-fix cursor that sat at EOF
# with an empty open set over a corr-tagged open decision, and re-surface it.
test_pre_fix_cursor_refolds_corr_tagged_decision() {
local dir state status cursor out probe status_bytes ident probe_bytes
dir=$(make_case cursor-corr-tag-migration)
state="$dir/state"
status="$state/task7.status"
cursor="$state/.task7.open-decisions-cursor"
out="$dir/drain.out"
probe="$dir/probe.tsv"

printf 'needs-decision [corr=d448ea86afa4bf67] [key=project-registration-path]: no proven path\n' \
> "$status"
FM_STATE_OVERRIDE="$state" "$DRAIN" > "$out" \
|| fail "bootstrap drain for the corr-tag cursor migration failed"
grep -F 'task7 [key=project-registration-path] needs-decision: no proven path' "$out" >/dev/null \
|| fail "bootstrap drain should surface the corr-tagged decision: $(cat "$out")"
ident=$(sed -n 's/^ident=//p' "$cursor")
[ -n "$ident" ] || fail "bootstrap drain did not persist a file identity"
status_bytes=$(LC_ALL=C wc -c < "$status" | tr -d '[:space:]')
{
printf 'version=3\n'
printf 'offset=%s\n' "$status_bytes"
printf 'ident=%s\n' "$ident"
} > "$cursor"
: > "$probe"

FM_STATE_OVERRIDE="$state" FM_OPEN_DECISIONS_READ_PROBE="$probe" "$DRAIN" > "$out" \
|| fail "drain failed while migrating the pre-fix corr-tag cursor"
grep -F 'task7 [key=project-registration-path] needs-decision: no proven path' "$out" >/dev/null \
|| fail "the pre-fix cursor hid the corr-tagged decision after migration: $(cat "$out")"
probe_bytes=$(last_probe_bytes "$probe" "$status")
[ "$probe_bytes" = "$status_bytes" ] \
|| fail "migration should re-fold all $status_bytes bytes, got $probe_bytes"
grep -F 'version=4' "$cursor" >/dev/null \
|| fail "cursor should rewrite at fold version 4: $(cat "$cursor")"

pass "a pre-fix v3 cursor refolds a corr-tagged open decision at version 4"
}

test_truncated_log_falls_back_to_a_full_refold_not_a_dropped_decision
test_same_size_rewrite_is_detected_via_inode_identity
test_read_failure_never_silently_returns_empty
test_cursor_cache_read_failure_refolds_authoritative_status
test_previous_fold_cache_is_refolded_under_current_semantics
test_buried_decision_survives_many_growing_drains_and_resolution_clears_it
test_pre_fix_cursor_refolds_corr_tagged_decision
Loading