v0.8.29: read/inspect tool validation pass — 7 portability/correctness fixes

Ran every read/analysis tool against the real 24-site integrator (lib + wired
dispatch). Fixed: nc-find --name (GNU sed \+ → POSIX; 0 rows on BSD/macOS),
nc-find tsv/jsonl exit-1-on-success, nc-parse tclproc-refs dropping
digit-leading procs (3M_check_ack), nc-xlate diff missing --site,
nc-diff-interface + nc-smat-diff printf '-'-leading option-injection dropping
output, nc-status not-up crashing on --format, and nc-status not-up's gawk-only
\<up\> word-boundary → portable form (BSD/macOS). Test matrix in Deliverables.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Bryan Johnson 2026-05-28 18:11:22 -07:00
parent d58e4e0ec8
commit 67cf5fed89
10 changed files with 120 additions and 30 deletions

View File

@ -4,6 +4,63 @@ All notable changes to `cloverleaf-larry` / `larry-anywhere` are recorded here.
Versioning is loose-semver; bumps trigger the in-process self-update on every Versioning is loose-semver; bumps trigger the in-process self-update on every
running client via `LARRY_BASE_URL` + `MANIFEST`. running client via `LARRY_BASE_URL` + `MANIFEST`.
## v0.8.29 — 2026-05-28
**★ READ/INSPECT TOOL VALIDATION PASS — 6 real bugs found & fixed.** Ran every
read/inspect/analysis tool against a real 24-site Cloverleaf integrator fixture
(via BOTH the `lib/<x>.sh` path and the wired `execute_tool` dispatch), with real
thread/site/xlate/table names discovered from the config. Same class as the
v0.8.28 nc-engine arg-parse crash: each only surfaced when actually run. A BSD-awk
word-boundary nit in `nc-status.sh` not-up (gawk-only `\<up\>` → portable
`(^|[^a-z])up([^a-z]|$)`) was also caught at the Vera gate and folded in.
- **`nc-find.sh` `--name` mode returned ZERO matches (BSD/macOS).** The protocol-
name extraction used the GNU-only `sed \+`. BSD sed (macOS) treats `\+` as a
literal `+`, so the thread name came back empty and every `--name` hit was
silently dropped. Now POSIX-portable (`[[:space:]][[:space:]]*` /
`[A-Za-z0-9_][A-Za-z0-9_]*`). Worked on GNU/Cygwin hosts; broke on BSD.
- **`nc-find.sh` exit 1 on success for tsv/jsonl.** The trailing
`[ "$FORMAT" = "table" ] && printf ...` test left the script exit code at 1 for
non-table formats (the `&&` short-circuits). Added explicit `exit 0` — a
successful search (even zero matches) now returns 0; mis-signaled failure to
any `$?`/`&&` caller.
- **`nc-parse.sh` `tclproc-refs` dropped digit-leading proc names.** The `{ PROC`
/ `{ PROCS` regexes required a leading `[A-Za-z_]`, so a real Cloverleaf proc
like `3M_check_ack` was never reported — which also blanked `nc_find --tclproc
3M_check_ack`. Widened to `[A-Za-z0-9_]+`; `PROCSCONTROL` still excluded.
- **`nc-xlate.sh diff` could not find site-scoped xlates.** Unlike show/ops/tree/
summary, `diff` did not accept `--site`, so `locate_xlate` only checked
`$HCIROOT/Xlate` and always died `no such xlate` for the common case (xlates
under `<site>/Xlate/`). `cmd_diff` now takes `--site` (applied to both names)
and larry.sh forwards it.
- **`nc-diff-interface.sh` printf option-injection.** `printf '---\n\n'` (the
markdown horizontal rule) parsed `---` as printf options → `printf: --: invalid
option`, dropping the rule. Now `printf '%s\n\n' '---'`.
- **`nc-smat-diff.sh` printf option-injection (8 lines).** `printf '- A: ...'`
format strings starting with `- ` errored `printf: - : invalid option` in
NON-interactive bash, dropping every summary bullet. Guarded with `printf --`.
- **`nc-status.sh not-up` crashed on `--format`.** `cmd_not_up` accepted only
`--site`/`--filter`; the larry.sh dispatch ALWAYS appends `--format`, so
`not-up` died `unknown flag: --format` and was unusable via the wired tool.
`cmd_not_up` now accepts `--format` and forwards it to `cmd_threads`.
All other read/inspect tools (nc_paths up/down/full/all/site-only incl. the
cross-site ADTto_CodaMetrix chain, nc_destinations/sources, nc_list_*,
nc_protocol_*, nc_find_inbound, nc_document single+system, nc_revisions
timeline+diff, nc_msgs raw+field-filter+json, nc_xlate list/show/ops/tree/summary,
nc_xlate_refs, nc_tclproc_refs, hl7_field, hl7_diff, nc_diff_interface,
nc_regression 6-phase + chain-walk command-gen, nc_smat_diff pairing, nc_engine
+ nc_status graceful degrade, list_sites ignore-rules, all 7 nc_tclgen templates
verified `info complete` in tclsh) PASSED unchanged. Test matrix:
`Deliverables/2026-05-28-cloverleaf-v3-tool-test-matrix.md`.
KNOWN / TRIAGE (not fixed this pass): `hl7-sanitize.sh` is a silent no-op on
LF-delimited input (`RS="\r"` reads the whole file as one record) — fixing needs
a portable CR/LF normalizer (BSD awk has no regex `RS`); `nc_engine route-test/
testxlate/resend` ignore `--dry-run` (only stop/start/bounce/restart honor it) and
the dispatch never forwards it; `lessons.sh:142` has the same `printf '---'`
option-injection (out-of-scope write tool).
## v0.8.28 — 2026-05-28 ## v0.8.28 — 2026-05-28
**★ EXPOSE 5 lib-only tools as first-class LLM tools.** A roadmap audit found **★ EXPOSE 5 lib-only tools as first-class LLM tools.** A roadmap audit found

View File

@ -23,16 +23,16 @@
# scripts/make-manifest.sh and bump VERSION. # scripts/make-manifest.sh and bump VERSION.
# Top-level scripts # Top-level scripts
larry.sh 4bc6355ebd04b3e301c28d9c34a8ec2fdae44fa223f1f065cdcd2790a4676e33 larry.sh 219c0b4f84aabec17baec7ba20c47849364bce9039bbcaa07ed7ba81b7b38a05
larry-tunnel.sh 6b050e4eeab15669f4858eaf3b807f168f211ced07815db9521bc40a093f6aaa larry-tunnel.sh 6b050e4eeab15669f4858eaf3b807f168f211ced07815db9521bc40a093f6aaa
larry-auth.sh a220cdf7878569dc3028951ee57fc8d5e706a8ca5c6aa45347b58facb386f831 larry-auth.sh a220cdf7878569dc3028951ee57fc8d5e706a8ca5c6aa45347b58facb386f831
larry-rollback.sh 91b5e9aa6c79266bf306dcfba4ca791c07971bd6924d67a779037531648aa6d0 larry-rollback.sh 91b5e9aa6c79266bf306dcfba4ca791c07971bd6924d67a779037531648aa6d0
install-larry.sh fa36e23a39eacbd0d7ecedd3b42131902f816ee7e98241dfc6e28c6e4ba80423 install-larry.sh fa36e23a39eacbd0d7ecedd3b42131902f816ee7e98241dfc6e28c6e4ba80423
# Metadata # Metadata
VERSION 4d1f87fb5ed962079a382ddacb5e4f28b461dd9d6c4a6c4085248832fa8111a5 VERSION 14624c56b466c22dbace115e50c329bc4bea74a2c25e1f3aa481766ea49ddff7
MANUAL.md c64bd0251a51ad150508b4e1185355bc4826a64071d4de339f92ed550dbfacde MANUAL.md c64bd0251a51ad150508b4e1185355bc4826a64071d4de339f92ed550dbfacde
CHANGELOG.md 567da1b7ddb2f8200eb42cfce6387f30ca753fc065abe4994f887e732d2a36f9 CHANGELOG.md f3a6ac02750188f6cec37e1d7454424c363706f2f9a8a6b041b449b1d783479c
# Agent personas (system-prompt overlays) # Agent personas (system-prompt overlays)
agents/larry.md 0a1ef737e7fc133ab35be09f79c3a4df33de814e0404b69b950932d0c8a01be1 agents/larry.md 0a1ef737e7fc133ab35be09f79c3a4df33de814e0404b69b950932d0c8a01be1
@ -91,20 +91,20 @@ lib/table-to-csv.sh ad98e73687bc9e9f6ae0cd79ed5ba26c856076902865230f822dec1a1bea
# NetConfig tooling # NetConfig tooling
lib/nc-engine.sh e2b12a1c019d40857b96d48d6c185b94aefadab604536ce41077ecc251b0bc58 lib/nc-engine.sh e2b12a1c019d40857b96d48d6c185b94aefadab604536ce41077ecc251b0bc58
lib/nc-status.sh 80d2023babff70c065ffab70b5ecf9bdfd80183ae5808f610335da9c8c27f97f lib/nc-status.sh fa08c5e48704d3d17e2206dafc8522ae7668b7a2f9b97f3521e05fd0ba739443
lib/nc-table.sh a6d5c11dd460cfb100ea50c74d57c1a46ef49112632037534a32cd28600abe7f lib/nc-table.sh a6d5c11dd460cfb100ea50c74d57c1a46ef49112632037534a32cd28600abe7f
lib/nc-xlate.sh b05caae72889f6404a2a1618ba3ba3666dc34f03d49a779664ea31396dddb112 lib/nc-xlate.sh 8621e6f0ef55524dd6ecba91fee055cf9cdc168791e75ba7c15d9bf501fe09bf
lib/nc-smat-diff.sh ac003954701ea6b7f4aa1f6941f8536af5b5cdfbb75e306789753d453f06800e lib/nc-smat-diff.sh 9c04d9e2f35f22c78d5f3c40a884ed23a3b6aaabc53ee27dfbfb66ab3166a567
lib/nc-create-thread.sh 5a9d5407c117183cad831d6b95f0e785b1b806f5ccc67f803c12b3695882b5b7 lib/nc-create-thread.sh 5a9d5407c117183cad831d6b95f0e785b1b806f5ccc67f803c12b3695882b5b7
lib/nc-tclgen.sh 5b8e73d7f6950a2b84f563132562ea82f62f4acac907257e233c7e68d85506c9 lib/nc-tclgen.sh 5b8e73d7f6950a2b84f563132562ea82f62f4acac907257e233c7e68d85506c9
lib/nc-parse.sh 3419b3f8d0cfdaf767f91551d6e2441d0743d80bd31515ffa61c769db1542c2f lib/nc-parse.sh 52fef42d7a4b361534ab0d921deef74586dfeb6c199c941cebb55abcc2c39d4f
lib/nc-paths.sh 388d2f4560736587a01218cadc1de612cd59e392819d16db2f56f19174c1111b lib/nc-paths.sh 388d2f4560736587a01218cadc1de612cd59e392819d16db2f56f19174c1111b
lib/nc-inbound.sh 52d28c5f8d97bdf96f0fc7b5300d35b106b8e1226578f4cda430deb2a8b4a91b lib/nc-inbound.sh 52d28c5f8d97bdf96f0fc7b5300d35b106b8e1226578f4cda430deb2a8b4a91b
lib/nc-make-jump.sh 08a0bc58a299c95c60a59a5202792daf0ada3a8a0be7dc1b4cccc5724f5c9c79 lib/nc-make-jump.sh 08a0bc58a299c95c60a59a5202792daf0ada3a8a0be7dc1b4cccc5724f5c9c79
lib/nc-msgs.sh 20517922d1153ec7827c833987497fb305d087b579911d1b9067d65ae156a19f lib/nc-msgs.sh 20517922d1153ec7827c833987497fb305d087b579911d1b9067d65ae156a19f
lib/nc-document.sh 47211e99089c0446d25a1e84545a734894720a1c9ad8f59b920332035e4ea880 lib/nc-document.sh 47211e99089c0446d25a1e84545a734894720a1c9ad8f59b920332035e4ea880
lib/nc-revisions.sh c27856f7decfc4c2e2c990f59eb20136fdff9cf0a52b9d9fbd9370613666a802 lib/nc-revisions.sh c27856f7decfc4c2e2c990f59eb20136fdff9cf0a52b9d9fbd9370613666a802
lib/nc-diff-interface.sh 6b64ec3070a3d75d1d79632c9aeb357177fdbcc77c474aa78e6f6929fda1a324 lib/nc-diff-interface.sh c922d10323f06346efa53ada68b44d32d9568ff0bd848c59af3404135f29d1ad
lib/nc-find.sh 8c79e0acad7de56e4e1f12d61e071a4b98c4e2310a1f7fb183697df521215e3f lib/nc-find.sh 2264877c56100378a1b780d640dcaa806aa5501ddd204c6b6a8eb5d3e07bf966
lib/nc-insert-protocol.sh ad1fa0bafbf4fdfb12bad20f9c22c3eed519f8846774331e26aa9becd6f8898a lib/nc-insert-protocol.sh ad1fa0bafbf4fdfb12bad20f9c22c3eed519f8846774331e26aa9becd6f8898a
lib/nc-regression.sh 70999a60608439f7bf1a3abb9f5e9854b5ea03025ef29ddbca683896346d1bce lib/nc-regression.sh 70999a60608439f7bf1a3abb9f5e9854b5ea03025ef29ddbca683896346d1bce

View File

@ -1 +1 @@
0.8.28 0.8.29

View File

@ -78,7 +78,7 @@ set -o pipefail
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Config # Config
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
LARRY_VERSION="0.8.28" LARRY_VERSION="0.8.29"
LARRY_HOME="${LARRY_HOME:-$HOME/.larry}" LARRY_HOME="${LARRY_HOME:-$HOME/.larry}"
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@ -4235,7 +4235,8 @@ tool_nc_xlate() {
[ -n "$site" ] && args+=(--site "$site") ;; [ -n "$site" ] && args+=(--site "$site") ;;
diff) diff)
[ -n "$name" ] && [ -n "$name2" ] || { echo "ERROR: nc_xlate diff needs name and name2"; return 1; } [ -n "$name" ] && [ -n "$name2" ] || { echo "ERROR: nc_xlate diff needs name and name2"; return 1; }
args=(diff "$name" "$name2") ;; args=(diff "$name" "$name2")
[ -n "$site" ] && args+=(--site "$site") ;;
"") args=(list); [ -n "$site" ] && args+=(--site "$site") ;; "") args=(list); [ -n "$site" ] && args+=(--site "$site") ;;
*) echo "ERROR: unknown nc_xlate subcommand: $subcmd (list|show|ops|tree|summary|diff)"; return 1 ;; *) echo "ERROR: unknown nc_xlate subcommand: $subcmd (list|show|ops|tree|summary|diff)"; return 1 ;;
esac esac

View File

@ -276,7 +276,7 @@ collect_tclprocs_for() {
fi fi
fi fi
printf '---\n\n' printf '%s\n\n' '---'
printf '_Generated %s by Larry-Anywhere nc-diff-interface.sh (depth=%d)._\n' \ printf '_Generated %s by Larry-Anywhere nc-diff-interface.sh (depth=%d)._\n' \
"$(date -Iseconds 2>/dev/null || date)" "$DEPTH" "$(date -Iseconds 2>/dev/null || date)" "$DEPTH"
} | out_target } | out_target

View File

@ -109,7 +109,11 @@ for nc in "${NCONFIGS[@]}"; do
# Partial match (substring) # Partial match (substring)
while IFS= read -r raw; do while IFS= read -r raw; do
line=$(printf '%s' "$raw" | cut -d: -f1) line=$(printf '%s' "$raw" | cut -d: -f1)
thread_name=$(printf '%s' "$raw" | sed -n 's/^[0-9]*:protocol[[:space:]]\+\([A-Za-z0-9_]\+\)[[:space:]]*{.*$/\1/p') # PORTABILITY: POSIX [[:space:]][[:space:]]* / [A-Za-z0-9_][A-Za-z0-9_]*
# instead of the GNU-only `\+`. BSD sed (macOS) treats `\+` as a
# literal `+`, so the GNU form extracted an empty name and every
# --name match was silently dropped on macOS/BSD hosts.
thread_name=$(printf '%s' "$raw" | sed -n 's/^[0-9]*:protocol[[:space:]][[:space:]]*\([A-Za-z0-9_][A-Za-z0-9_]*\)[[:space:]]*{.*$/\1/p')
[ -z "$thread_name" ] && continue [ -z "$thread_name" ] && continue
pname=$("$NCP" protocol-field "$nc" "$thread_name" PROCESSNAME 2>/dev/null | head -1) pname=$("$NCP" protocol-field "$nc" "$thread_name" PROCESSNAME 2>/dev/null | head -1)
pport=$("$NCP" protocol-nested "$nc" "$thread_name" PROTOCOL.PORT 2>/dev/null | head -1 | sed 's/^{}$//') pport=$("$NCP" protocol-nested "$nc" "$thread_name" PROTOCOL.PORT 2>/dev/null | head -1 | sed 's/^{}$//')
@ -233,3 +237,7 @@ esac
# (printf '%d' "5\r" fails with "invalid number"). # (printf '%d' "5\r" fails with "invalid number").
n=$(wc -l < "$RESULTS" | tr -cd '0-9') n=$(wc -l < "$RESULTS" | tr -cd '0-9')
[ "$FORMAT" = "table" ] && printf '\n%d match(es)\n' "${n:-0}" >&2 [ "$FORMAT" = "table" ] && printf '\n%d match(es)\n' "${n:-0}" >&2
# Always exit 0 on a successful search (even zero matches). Without this, the
# trailing `[ "$FORMAT" = "table" ] && ...` test leaves the script exit code at
# 1 for tsv/jsonl (the && short-circuits), mis-signaling failure to callers.
exit 0

View File

@ -448,12 +448,14 @@ cmd_tclproc_refs() {
{ {
line = $0 line = $0
# PROC <name> (singleton, e.g. DATAFORMAT.PROC) # PROC <name> (singleton, e.g. DATAFORMAT.PROC)
if (match(line, /\{ PROC [A-Za-z_][A-Za-z0-9_]*/)) { # Allow a LEADING DIGIT: Cloverleaf proc names like 3M_check_ack are valid
# TCL proc names. The old [A-Za-z_]-first class silently dropped them.
if (match(line, /\{ PROC [A-Za-z0-9_]+/)) {
v = substr(line, RSTART + 7, RLENGTH - 7) v = substr(line, RSTART + 7, RLENGTH - 7)
print v print v
} }
# PROCS <name> (singleton) # PROCS <name> (singleton)
if (match(line, /\{ PROCS [A-Za-z_][A-Za-z0-9_]*/)) { if (match(line, /\{ PROCS [A-Za-z0-9_]+/)) {
v = substr(line, RSTART + 8, RLENGTH - 8) v = substr(line, RSTART + 8, RLENGTH - 8)
print v print v
} }

View File

@ -124,10 +124,13 @@ B_KEYS=$(awk -F'\t' '{print $1}' "$OUT/b/sorted.tsv" | sort -u)
SUMMARY="$OUT/_summary.md" SUMMARY="$OUT/_summary.md"
{ {
printf '# smat diff: thread=%s\n\n' "$THREAD" printf '# smat diff: thread=%s\n\n' "$THREAD"
printf '- A: `%s/%s` (%d messages sampled)\n' "$ENV_A" "$SITE_A" "$A_COUNT" # printf -- guard: a format string starting with '- ' is otherwise parsed as
printf '- B: `%s/%s` (%d messages sampled)\n' "$ENV_B" "$SITE_B" "$B_COUNT" # an option by the bash printf builtin in NON-interactive shells ("printf: -
printf '- pair-on: `%s`\n' "$PAIR_ON" # : invalid option"), dropping the line. -- ends option parsing.
printf '- ignore: `%s`\n\n' "$IGNORE" printf -- '- A: `%s/%s` (%d messages sampled)\n' "$ENV_A" "$SITE_A" "$A_COUNT"
printf -- '- B: `%s/%s` (%d messages sampled)\n' "$ENV_B" "$SITE_B" "$B_COUNT"
printf -- '- pair-on: `%s`\n' "$PAIR_ON"
printf -- '- ignore: `%s`\n\n' "$IGNORE"
printf '## Per-pair diffs\n\n' printf '## Per-pair diffs\n\n'
printf '| %s | diffs | report |\n|---|---|---|\n' "$PAIR_ON" printf '| %s | diffs | report |\n|---|---|---|\n' "$PAIR_ON"
} > "$SUMMARY" } > "$SUMMARY"
@ -163,10 +166,11 @@ done < <(printf '%s\n%s\n' "$A_KEYS" "$B_KEYS" | sort -u)
{ {
printf '\n## Summary\n\n' printf '\n## Summary\n\n'
printf '- paired (A and B): %d\n' "$PAIRED" # printf -- guard (see note above): leading '- ' format strings.
printf '- A-only: %d\n' "$A_ONLY" printf -- '- paired (A and B): %d\n' "$PAIRED"
printf '- B-only: %d\n' "$B_ONLY" printf -- '- A-only: %d\n' "$A_ONLY"
printf '- total field differences (post-ignore): %d\n' "$DIFFS_TOTAL" printf -- '- B-only: %d\n' "$B_ONLY"
printf -- '- total field differences (post-ignore): %d\n' "$DIFFS_TOTAL"
} >> "$SUMMARY" } >> "$SUMMARY"
printf 'done. Summary: %s\n' "$SUMMARY" >&2 printf 'done. Summary: %s\n' "$SUMMARY" >&2

View File

@ -111,16 +111,21 @@ cmd_threads() {
cmd_not_up() { cmd_not_up() {
local site="${HCISITE:-}" local site="${HCISITE:-}"
local filter="" local filter=""
local format="text"
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--site) shift; site="$1" ;; --site) shift; site="$1" ;;
--filter) shift; filter="$1" ;; --filter) shift; filter="$1" ;;
# Accept --format and forward it to cmd_threads. The larry.sh dispatch
# ALWAYS appends --format, so without this not-up died "unknown flag:
# --format" and was unusable via the wired tool.
--format) shift; format="$1" ;;
*) die "unknown flag: $1" ;; *) die "unknown flag: $1" ;;
esac esac
shift shift
done done
cmd_threads --site "$site" ${filter:+--filter "$filter"} \ cmd_threads --site "$site" --format "$format" ${filter:+--filter "$filter"} \
| awk 'NR==1 || tolower($0) !~ /\<up\>/' | awk 'NR==1 || tolower($0) !~ /(^|[^a-z])up([^a-z]|$)/'
} }
cmd_connections() { cmd_connections() {

View File

@ -8,7 +8,7 @@
# ops <name> [--site SITE] list operations as TSV (op, in, out, err) # ops <name> [--site SITE] list operations as TSV (op, in, out, err)
# tree <name> [--site SITE] ASCII tree by op type # tree <name> [--site SITE] ASCII tree by op type
# summary <name> [--site SITE] counts by operation + segments touched # summary <name> [--site SITE] counts by operation + segments touched
# diff <name1> <name2> diff two xlates (semantic, sorted-by-op) # diff <name1> <name2> [--site SITE] diff two xlates (semantic, sorted-by-op)
set -o pipefail set -o pipefail
NC_SELF="$0" NC_SELF="$0"
@ -135,10 +135,23 @@ cmd_summary() {
} }
cmd_diff() { cmd_diff() {
local n1="$1" n2="$2" # Accept --site like every other subcommand. Without it, site-scoped xlates
# (the common case — xlates live under <site>/Xlate/) could not be located
# and diff always died with "no such xlate". --site applies to BOTH names.
local site="${HCISITE:-}"
local positional=()
while [ $# -gt 0 ]; do
case "$1" in
--site) shift; site="$1" ;;
*) positional+=("$1") ;;
esac
shift
done
local n1="${positional[0]:-}" n2="${positional[1]:-}"
[ -n "$n1" ] && [ -n "$n2" ] || die "usage: diff NAME1 NAME2 [--site SITE]"
local f1 f2 local f1 f2
f1=$(locate_xlate "$n1") || die "no such xlate: $n1" f1=$(locate_xlate "$n1" "$site") || die "no such xlate: $n1"
f2=$(locate_xlate "$n2") || die "no such xlate: $n2" f2=$(locate_xlate "$n2" "$site") || die "no such xlate: $n2"
diff -u <(parse_ops "$f1" | sort -k2) <(parse_ops "$f2" | sort -k2) diff -u <(parse_ops "$f1" | sort -k2) <(parse_ops "$f2" | sort -k2)
} }