cloverleaf-larry/lib/nc-smat-diff.sh
Bryan Johnson 67cf5fed89 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>
2026-05-28 18:11:22 -07:00

178 lines
6.6 KiB
Bash
Executable File

#!/usr/bin/env bash
# nc-smat-diff.sh — diff smat (message archive) content across two environments.
# Different from nc-regression which runs route_test; this just samples actual
# stored messages from each env's smatdb and compares them.
#
# Pairing strategy:
# By default, sample N most-recent messages from each side, then pair by
# MSH.10 (message control ID) — the standard HL7 unique identifier.
# Unmatched IDs are reported as A-only / B-only.
#
# Usage:
# nc-smat-diff.sh <thread> --env-a HCIROOT_A --env-b HCIROOT_B [opts]
#
# Options:
# --site-a SITE site on env-A (default $HCISITE)
# --site-b SITE site on env-B (default same as --site-a)
# --limit N messages to sample per side (default 50)
# --ignore FIELDS hl7-diff --ignore list (default "MSH.7")
# --out DIR output directory for per-msg diffs + summary
# --pair-on FIELD HL7 field to pair messages on (default MSH.10)
# --include-history include SmatHistory archives on both sides
# --after EXPR only messages after this time (e.g. "3 days ago")
set -o pipefail
NC_SELF="$0"
LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)"
NCM="$LIB_DIR/nc-msgs.sh"
HL7F="$LIB_DIR/hl7-field.sh"
HL7DIFF="$LIB_DIR/hl7-diff.sh"
# v0.7.5: shared CR-safety primitives — A_COUNT/B_COUNT/DIFFS_TOTAL all feed
# `%d` printf and arithmetic; Cygwin wc.exe CR-taint would crash them.
if [ -r "$LIB_DIR/cygwin-safe.sh" ]; then
# shellcheck disable=SC1090,SC1091
. "$LIB_DIR/cygwin-safe.sh"
else
coerce_int() { local r="${1:-}" d="${2:-0}" c; c=$(printf '%s' "$r" | tr -cd '0-9'); printf '%s' "${c:-$d}"; }
fi
die() { printf 'nc-smat-diff: %s\n' "$*" >&2; exit 1; }
THREAD=""
ENV_A=""
ENV_B=""
SITE_A="${HCISITE:-}"
SITE_B=""
LIMIT=50
IGNORE="MSH.7"
OUT=""
PAIR_ON="MSH.10"
INC_HIST=0
AFTER=""
while [ $# -gt 0 ]; do
case "$1" in
--env-a) shift; ENV_A="$1" ;;
--env-b) shift; ENV_B="$1" ;;
--site-a) shift; SITE_A="$1" ;;
--site-b) shift; SITE_B="$1" ;;
--limit) shift; LIMIT="$1" ;;
--ignore) shift; IGNORE="$1" ;;
--out) shift; OUT="$1" ;;
--pair-on) shift; PAIR_ON="$1" ;;
--include-history) INC_HIST=1 ;;
--after) shift; AFTER="$1" ;;
-h|--help) sed -n '2,22p' "$NC_SELF"; exit 0 ;;
-*) die "unknown flag: $1" ;;
*) [ -z "$THREAD" ] && THREAD="$1" || die "extra arg: $1" ;;
esac
shift
done
[ -n "$THREAD" ] || die "missing thread name"
[ -n "$ENV_A" ] && [ -n "$ENV_B" ] || die "--env-a and --env-b required"
[ -n "$SITE_A" ] || die "--site-a required (or set HCISITE)"
[ -z "$SITE_B" ] && SITE_B="$SITE_A"
[ -z "$OUT" ] && OUT=$(mktemp -d)
mkdir -p "$OUT/a" "$OUT/b" "$OUT/diff" 2>/dev/null
dump_side() {
local hciroot="$1" site="$2" target_dir="$3"
local nc_args=("$THREAD" --limit "$LIMIT" --format raw)
[ "$INC_HIST" = "1" ] && nc_args+=(--include-history)
[ -n "$AFTER" ] && nc_args+=(--after "$AFTER")
HCISITEDIR="$hciroot/$site" "$NCM" "${nc_args[@]}" > "$target_dir/all.raw" 2>"$target_dir/err"
# Split into individual messages on 0x1c, and index by pair-on field
awk -v RS=$'\x1c' -v dir="$target_dir" '
NF > 0 || $0 != "" {
n++
fpath = dir "/msg_" sprintf("%05d", n) ".hl7"
printf "%s", $0 > fpath
close(fpath)
}
' "$target_dir/all.raw"
# Build pair-on-field → file index
: > "$target_dir/index.tsv"
for f in "$target_dir"/msg_*.hl7; do
[ -f "$f" ] || continue
local key; key=$("$HL7F" "$PAIR_ON" "$f" 2>/dev/null | head -1)
[ -z "$key" ] && key="(no-$PAIR_ON)"
printf '%s\t%s\n' "$key" "$f" >> "$target_dir/index.tsv"
done
}
printf 'nc-smat-diff:\n thread: %s\n A: %s/%s\n B: %s/%s\n limit: %d ignore: %s pair-on: %s\n out: %s\n\n' \
"$THREAD" "$ENV_A" "$SITE_A" "$ENV_B" "$SITE_B" "$LIMIT" "$IGNORE" "$PAIR_ON" "$OUT" >&2
dump_side "$ENV_A" "$SITE_A" "$OUT/a"
dump_side "$ENV_B" "$SITE_B" "$OUT/b"
# v0.7.5: coerce_int on wc output — Cygwin wc.exe CR-taint defense.
A_COUNT=$(coerce_int "$(wc -l < "$OUT/a/index.tsv")" 0)
B_COUNT=$(coerce_int "$(wc -l < "$OUT/b/index.tsv")" 0)
printf 'A: %d msgs B: %d msgs\n\n' "$A_COUNT" "$B_COUNT" >&2
# Pair messages by key
sort -k1 "$OUT/a/index.tsv" > "$OUT/a/sorted.tsv"
sort -k1 "$OUT/b/index.tsv" > "$OUT/b/sorted.tsv"
A_KEYS=$(awk -F'\t' '{print $1}' "$OUT/a/sorted.tsv" | sort -u)
B_KEYS=$(awk -F'\t' '{print $1}' "$OUT/b/sorted.tsv" | sort -u)
SUMMARY="$OUT/_summary.md"
{
printf '# smat diff: thread=%s\n\n' "$THREAD"
# printf -- guard: a format string starting with '- ' is otherwise parsed as
# an option by the bash printf builtin in NON-interactive shells ("printf: -
# : invalid option"), dropping the line. -- ends option parsing.
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 '| %s | diffs | report |\n|---|---|---|\n' "$PAIR_ON"
} > "$SUMMARY"
DIFFS_TOTAL=0
A_ONLY=0
B_ONLY=0
PAIRED=0
while IFS= read -r key; do
[ -z "$key" ] && continue
a_files=$(grep -F "${key} " "$OUT/a/sorted.tsv" | awk -F'\t' '{print $2}' | head -1)
b_files=$(grep -F "${key} " "$OUT/b/sorted.tsv" | awk -F'\t' '{print $2}' | head -1)
if [ -z "$a_files" ] && [ -n "$b_files" ]; then
B_ONLY=$((B_ONLY+1))
echo "| \`$key\` | (only on B) | — |" >> "$SUMMARY"
elif [ -z "$b_files" ] && [ -n "$a_files" ]; then
A_ONLY=$((A_ONLY+1))
echo "| \`$key\` | (only on A) | — |" >> "$SUMMARY"
else
PAIRED=$((PAIRED+1))
report="$OUT/diff/${key//\//_}.md"
cnt=$("$HL7DIFF" --ignore "$IGNORE" --format count "$a_files" "$b_files" 2>/dev/null || echo "?")
{
printf '# msg %s\n\nA: `%s`\nB: `%s`\n\n' "$key" "$a_files" "$b_files"
"$HL7DIFF" --ignore "$IGNORE" "$a_files" "$b_files" 2>/dev/null
} > "$report"
echo "| \`$key\` | $cnt | [report](./diff/$(basename "$report")) |" >> "$SUMMARY"
# v0.7.5: coerce_int — defends against both Cygwin awk CR-taint AND the
# legitimate `?` fallback that would crash arithmetic.
DIFFS_TOTAL=$((DIFFS_TOTAL + $(coerce_int "$cnt" 0)))
fi
done < <(printf '%s\n%s\n' "$A_KEYS" "$B_KEYS" | sort -u)
{
printf '\n## Summary\n\n'
# printf -- guard (see note above): leading '- ' format strings.
printf -- '- paired (A and B): %d\n' "$PAIRED"
printf -- '- A-only: %d\n' "$A_ONLY"
printf -- '- B-only: %d\n' "$B_ONLY"
printf -- '- total field differences (post-ignore): %d\n' "$DIFFS_TOTAL"
} >> "$SUMMARY"
printf 'done. Summary: %s\n' "$SUMMARY" >&2
echo "$SUMMARY"