diff --git a/CHANGELOG.md b/CHANGELOG.md index 8633c09..e9aac4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 running client via `LARRY_BASE_URL` + `MANIFEST`. +## v0.9.10 — 2026-07-23 + +New: `hl7-diff-set` (`lib/hl7-diff-set.sh` + `bin/hl7-diff-set`, also runnable +as `larry tools hl7-diff-set`) — Bryan-direct ask, the natural finish to +today's regression tooling. Compares a WHOLE SET of paired baseline-vs- +candidate regression output files in one command instead of a hand-written +for-loop with hardcoded route names. Built for `hciroutetest`'s real on-disk +layout: one directory holding paired files named `.` where two +prefixes (e.g. `adt_in_regression_out_windows_single` / +`adt_in_regression_out_linux_single`) share a set of route suffixes. Auto- +discovers routes by globbing both prefixes (`.*`, portable bash-3.2- +safe glob, no `nullglob`/bash-4-isms); runs the EXISTING `hl7-diff.sh` engine +per pair (not reimplemented) with sane regression defaults (`--framing auto +--ignore MSH.7`, both overridable, plus `--include-fields` pass-through); +reports PASS/DIFF/MISSING/EXTRA per route (a baseline route with no candidate +file, or vice-versa, is reported — never silently skipped, and both count as +non-clean) plus a final summary line (`4 routes: 3 clean, 1 differs`). Exit 0 +iff every route is PASS, so it is a single scriptable regression gate. +`--format text` (default) also prints the full `hl7-diff --format text` +detail (indented) for any differing route; `--format tsv` prints one +machine row per route (`route\tstatus\tcount`) with the summary line routed +to stderr so stdout stays clean tsv. Self-tested (4-route mixed fixture: 1 +clean, 1 differing with a real absent-component diff, 1 dropped-on-candidate +MISSING, 1 candidate-only EXTRA) under both `bash --version` 5.3.9 and the +real `/bin/bash` 3.2.57 on this Mac — identical output, confirming the +bash-3.2/MobaXterm-safety constraint. Bryan's exact command for his live +single-site directory: +``` +hl7-diff-set adt_in_regression_out_windows_single adt_in_regression_out_linux_single \ + --dir /opt/cloverleaf/cis2025.01/integrator/adt/data/regression_testing/single +``` + +Fix: F-8 (tsk-2026-07-23-hl7diff-readability, Bryan-direct live feedback) — +`hl7-diff --format text` printed only the field path plus ONE value per diff +line (`MSH.9.3 ADT_A02`); the right-hand value was always present in the +underlying data (tsv format carried it correctly) but rendered as invisible +trailing whitespace when it was empty, so it looked like only one side was +shown. Confirmed against Bryan's live repro: Windows `MSH-9=ADT^A02^ADT_A02` +vs Linux `MSH-9=ADT^A02` — `MSH.9.3` is `ADT_A02` on Windows and genuinely +ABSENT (not blank) on Linux (only 2 components). Text mode now labels both +sides explicitly (`MSH.9.3 left='ADT_A02' right=''`), and +distinguishes an empty-but-present value (`(empty)`) from a component or +subcomponent that does not exist AT ALL on that side (`` — absence +cascades: a fully-absent field marks every component/subcomponent under it +absent too, regardless of that component's own split count). Also fixed in +the same pass: the `----- message N -----` header used a stale running +`DIFF_COUNT` guess printed BEFORE processing each message, so a clean +message 1 followed by a differing message 2 got the message-2 diffs +mislabeled under a "message 1" header (or no header at all) — the header is +now printed lazily, inside `emit()`, keyed off the real `msg_idx`, right +before that message's first emitted line. tsv/count output formats are +unchanged (still raw left/right values, no display tokens). Self-tested: +2-message fixture (message 1 clean, message 2 differs) now correctly shows +`----- message 2 -----`; separate fixture proves the `(empty)` token for a +genuinely-blank-but-present component. Verified `bash -n` clean and +`make-manifest.sh --check` clean. + ## v0.9.9 — 2026-07-23 Fix: REPL leaves the terminal corrupted after exit — Backspace prints a diff --git a/MANIFEST b/MANIFEST index 69408e5..2b45159 100644 --- a/MANIFEST +++ b/MANIFEST @@ -23,7 +23,7 @@ # scripts/make-manifest.sh and bump VERSION. # Top-level scripts -larry.sh 8da143113410eaf99aeea14ba12ebb13344b568d76d4711984d4c1dd75cbd4e0 +larry.sh 322316fefec54d88f0a0ac346129874195b6de4786739b2aa43e15a4fa3eb206 larry-tunnel.sh 6b050e4eeab15669f4858eaf3b807f168f211ced07815db9521bc40a093f6aaa larry-auth.sh a220cdf7878569dc3028951ee57fc8d5e706a8ca5c6aa45347b58facb386f831 larry-rollback.sh 91b5e9aa6c79266bf306dcfba4ca791c07971bd6924d67a779037531648aa6d0 @@ -31,9 +31,9 @@ install-larry.sh 19f42e8f6e54eaffffe33e1464a8d245721260aed047270a75987c0b25c936c uninstall-larry.sh c53ad2d8354c7adeb243b541f027f3f481e4a8661eecfd7af14d7ca53cfcaad9 # Metadata -VERSION 050990d5e9bd0494fa852a4a9ab0d60b91c583a63307920c7887a39e692f9998 -MANUAL.md 46cc8650bbd5daed20617dcf8626e0653da25bf2facf6781912087fcb7c43e57 -CHANGELOG.md a14b2be47d8db54419551218df02f4d4f4428e638f5ab7de58426c3aa8c11c2e +VERSION cd8f34a6873bdd24cb055e5d97d11fc0dac541a6939c13ef3b700c6bd28344e4 +MANUAL.md 7f442b2e3e0bfa7d8cadb588abb3f197dc03bc44f47b84fc5458edba8010ddfa +CHANGELOG.md 4dc1258c1de7ec0d116c881e562140d140d0ca987110c3d2afb060903b4a8cfe # Agent personas (system-prompt overlays) agents/larry.md 0a1ef737e7fc133ab35be09f79c3a4df33de814e0404b69b950932d0c8a01be1 @@ -74,7 +74,8 @@ lib/journal.sh 1c285df3f5677477c7b675c4f03236823005670b338a1781dd0c73d12fd1424f # HL7 utilities lib/hl7-sanitize.sh efd333dc3e267cf2280433e53bbb910023d0bc4b30724181b53873b410d83005 lib/hl7-desanitize.sh 2e5462a61ab1e8bd3fefb956bace8ca1ae33397a09024cbe766fa55c37a5aad6 -lib/hl7-diff.sh 1d81163869d003bb6528818553417350c1ab885f2f04eea5d2a46a06100e789b +lib/hl7-diff.sh 79e92c5c9326f6dab49673b719259ae46aaa1020b37b8ac91b5a26869f17656b +lib/hl7-diff-set.sh 40d40b1f8e688c584f03bc6efb9645e18daf0fd9081a595b08137d2438619777 lib/hl7-field.sh a640f7cbd9521dc96171ee1dbdf909170262101a1d7a433f6f0ce2bea8d42b02 lib/hl7-schema.sh 2ba4057a214867ff4950f10057ee4ffd7149e1a82ba94b07b6857d77bf10d75f @@ -182,6 +183,7 @@ bin/nc-regression 59a5f25fcea642433613f0933fe06dd9fa25b372e5d130e119cb466a076099 bin/nc-msgs 8a938cefedbb2bad9496c6f84831788b6ac45d34e8b16fc60d8aa47c25adea76 bin/hl7-field cb56984dfc9342563e05cdf0e3514a64df2099d24adeaa1d5b22cc6ce298eb73 bin/hl7-diff 2d965567ea4f737d97ea92313dfacd84b745cea74313d1be565a3234240b76f3 +bin/hl7-diff-set b2d6a21aa8ba55b0c6b7ae166e1dda73696e456a8c94ca0cacb686b2ae8cea30 bin/len2nl d73136e353d7964f097b4a50993ba2a3364550e13b7ae5a1b43af00ed8eddbe3 bin/hl7-sanitize 7e2d883b4e49501eccf3db0ef27f4866dc664c3bde51fd6ad2a7804ca7de8e4e bin/hl7-desanitize 78c5bda12e7374c056fac940ac1889ceca87f19a1e711dc658cc46b63b020925 diff --git a/MANUAL.md b/MANUAL.md index 191820a..5fdd74d 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -402,6 +402,17 @@ lib/nc-msgs.sh ADTto_3m --limit 100 --format raw \ Compare two HL7 files (or multi-message dumps) with field-level normalization. +**v0.9.10 (F-8, Bryan-direct live feedback):** `--format text` now labels +BOTH sides on every diff line — `SEG.FIELD left='...' right='...'` — instead +of printing only the left value with the right value rendering invisibly +blank. An empty-but-present value shows as the token `(empty)`; a component +or subcomponent that does not exist AT ALL on that side (the other side has +more components — e.g. left `MSH-9=ADT^A02^ADT_A02` vs right `MSH-9=ADT^A02`, +so `MSH.9.3` is absent, not blank, on the right) shows as ``. The +`----- message N -----` header is also now keyed off the real message index +(it used to print based on a stale running counter and could mislabel which +message a diff belonged to when an earlier message was clean). + ```bash # Default — ignores MSH.7 (timestamp); shows everything else lib/hl7-diff.sh left.hl7 right.hl7 @@ -423,6 +434,52 @@ lib/hl7-diff.sh --format tsv left.hl7 right.hl7 --- +## HL7 diff over a whole route SET (`hl7-diff-set` / `lib/hl7-diff-set.sh`) + +One command instead of a hand-written for-loop with hardcoded route names. +Built for `hciroutetest`'s real on-disk layout: one directory holding paired +files named `.` — a baseline (e.g. Windows) prefix and a +candidate (e.g. Linux) prefix that share a set of route suffixes: + +``` +adt_in_regression_out_windows_single.adt_process_out +adt_in_regression_out_linux_single.adt_process_out +adt_in_regression_out_windows_single.lab_adt_out +adt_in_regression_out_linux_single.lab_adt_out +... +``` + +```bash +# Bryan's real single-site regression directory — the actual command he runs: +hl7-diff-set adt_in_regression_out_windows_single adt_in_regression_out_linux_single \ + --dir /opt/cloverleaf/cis2025.01/integrator/adt/data/regression_testing/single + +# Override the ignore list / framing (both forward straight to hl7-diff) +hl7-diff-set --dir \ + --ignore "MSH.7,MSH.10" --framing len10 + +# Machine-parseable: one row per route, routestatusdiff_count. +# The summary line prints to STDERR in this mode, so stdout stays clean tsv — +# recommended for scripting/CI gates. +hl7-diff-set --dir --format tsv +``` + +Auto-discovers routes by globbing both prefixes (`.*`), so a route +present on only ONE side is never silently dropped: +- both files present, 0 diffs → **PASS** +- both files present, N>0 diffs → **DIFF** (`--format text` also prints the + full `hl7-diff --format text` detail, indented, for that route) +- baseline route has no candidate file → **MISSING** (a dropped output IS a + regression — counts as a failure) +- candidate has a route the baseline does not → **EXTRA** (reported, also + counts as non-clean) + +Prints a final summary line, e.g. `4 routes: 3 clean, 1 differs`. **Exit 0 +iff every route is PASS — non-zero otherwise — so it is a single scriptable +regression gate:** `hl7-diff-set ... --dir ... || echo "regression FAILED"`. + +--- + ## Jump thread generation (`lib/nc-make-jump.sh`) Generates the 3-thread cross-environment data-replay pattern: `linux__out` on OLD, `windows__in` + `windows__out` in NEW's `server_jump` site. Output is plain TCL text — no file writes. diff --git a/VERSION b/VERSION index 7e310ba..56f3151 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.9 +0.9.10 diff --git a/bin/hl7-diff-set b/bin/hl7-diff-set new file mode 100755 index 0000000..1bd7bfc --- /dev/null +++ b/bin/hl7-diff-set @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# hl7-diff-set — direct, on-PATH wrapper for lib/hl7-diff-set.sh: diff a WHOLE +# SET of paired Windows-vs-Linux regression output files in one command +# (auto-discovers routes from two shared file prefixes; no hand-written +# for-loop / hardcoded route list needed). Reuses the existing hl7-diff engine +# per pair — same --ignore/--framing semantics, sane regression defaults. +# +# hl7-diff-set adt_in_regression_out_windows_single adt_in_regression_out_linux_single \ +# --dir /opt/cloverleaf/cis2025.01/integrator/adt/data/regression_testing/single +# +# (no `larry tools` prefix needed). All args pass straight through; -h/--help +# is handled by the underlying tool. +set -o pipefail +_self="${BASH_SOURCE[0]}"; [ -L "$_self" ] && _self="$(readlink "$_self")" +. "$(cd "$(dirname "$_self")" && pwd)/_nc_common.sh" +lib="$(_nc_resolve_lib)" || { echo "hl7-diff-set: lib/ toolkit not found (set LARRY_LIB_DIR or LARRY_HOME)" >&2; exit 1; } +if [ $# -eq 0 ]; then exec bash "$lib/hl7-diff-set.sh" --help; fi +exec bash "$lib/hl7-diff-set.sh" "$@" diff --git a/larry.sh b/larry.sh index db5af59..8f1dccb 100755 --- a/larry.sh +++ b/larry.sh @@ -100,7 +100,7 @@ set -o pipefail # ───────────────────────────────────────────────────────────────────────────── # Config # ───────────────────────────────────────────────────────────────────────────── -LARRY_VERSION="0.9.9" +LARRY_VERSION="0.9.10" LARRY_HOME="${LARRY_HOME:-$HOME/.larry}" # ───────────────────────────────────────────────────────────────────────────── @@ -445,6 +445,7 @@ nc-msgs.sh|Native smat query: search/inspect archived messages without hcidbdump #HL7 hl7-field.sh|Extract a field by path (PID.3, MSH.10, PV1-3-4 …) from an HL7 v2 message hl7-diff.sh|HL7-aware diff with field-level normalization between two messages +hl7-diff-set.sh|Run hl7-diff over a WHOLE SET of paired baseline-vs-candidate regression output files (e.g. Windows vs Linux hciroutetest dumps) in one command — auto-discovers routes from two shared file prefixes, reports PASS/DIFF/MISSING/EXTRA per route + a summary line len2nl.sh|Convert length-prefixed / MLLP-framed HL7 to newline-readable form hl7-sanitize.sh|Tokenize PHI fields in HL7 v2 messages ([[CATEGORY_NNNN]] tokens) hl7-desanitize.sh|Reverse hl7-sanitize: restore original values from a token map diff --git a/lib/hl7-diff-set.sh b/lib/hl7-diff-set.sh new file mode 100755 index 0000000..bc4aaba --- /dev/null +++ b/lib/hl7-diff-set.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# hl7-diff-set.sh — run hl7-diff over a WHOLE SET of paired Windows-vs-Linux +# (or any baseline-vs-candidate) regression output files in one command, +# instead of hand-writing a for-loop with hardcoded route names. +# +# Built for hciroutetest's real on-disk layout (Bryan-direct, 2026-07-23): +# one directory holding paired files named ., where TWO +# prefixes (a baseline/"windows" set and a candidate/"linux" set) share a set +# of route suffixes after the last dot, e.g.: +# adt_in_regression_out_windows_single.adt_process_out +# adt_in_regression_out_linux_single.adt_process_out +# adt_in_regression_out_windows_single.lab_adt_out +# adt_in_regression_out_linux_single.lab_adt_out +# ... +# +# Usage: +# hl7-diff-set.sh +# [--dir PATH] # directory holding the paired files (default: .) +# [--ignore "FIELD,..."] # forwarded to hl7-diff. Default: MSH.7 +# [--include-fields "F,.."] # forwarded to hl7-diff (overrides --ignore for that set) +# [--framing auto|nl|0x1c|len10] # forwarded to hl7-diff. Default: auto +# [--format text|tsv] # how THIS command reports. Default: text +# +# hl7-diff-set.sh adt_in_regression_out_windows_single adt_in_regression_out_linux_single \ +# --dir /opt/cloverleaf/cis2025.01/integrator/adt/data/regression_testing/single +# +# What it does: +# 1. Globs "/.*", derives each route suffix (the part +# after "."), and pairs it with "/.". +# Also globs "/.*" so a candidate-only route (no +# baseline file) is found too — nothing is silently skipped. +# 2. Runs the existing lib/hl7-diff.sh on every matched pair (this does NOT +# reimplement diffing — same engine, same --ignore/--framing semantics). +# 3. Reports PASS/DIFF/MISSING/EXTRA per route, plus a one-line summary. +# +# Per-route outcomes: +# PASS — both files present, 0 field differences (post-ignore) +# DIFF — both files present, N>0 field differences (--format text also +# shows the full hl7-diff --format text detail, indented) +# MISSING — baseline route has no matching candidate file (a dropped output +# IS a regression — this counts as a failure) +# EXTRA — candidate has a route the baseline does not (reported, also +# counts as non-clean — never silently skipped) +# +# --format text (default) prints a human report to stdout: per-route lines, +# DIFF detail indented beneath, then the summary line. +# --format tsv prints ONE machine row per route to stdout: +# routestatusdiff_count +# (diff_count is "-" for MISSING/EXTRA, since no diff was actually run) — +# the summary line still prints, to STDERR, so stdout stays clean tsv. +# Recommended for scripting: hl7-diff.sh's OWN v0.9.9 text-mode message- +# header mislabel is fixed as of this release (F-8), but tsv remains the +# safer machine-parseable choice regardless. +# +# Exit codes: 0 = every route PASS (all clean); 1 = at least one route is +# DIFF/MISSING/EXTRA; 2 = usage/input error (bad flag, --dir missing, or +# neither prefix matched any file at all). +set -o pipefail + +NC_SELF="$0" +LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)" +HL7DIFF="$LIB_DIR/hl7-diff.sh" + +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 'hl7-diff-set: %s\n' "$*" >&2; exit 2; } + +DIR="." +IGNORE="MSH.7" +INCLUDE="" +FRAMING="auto" +FORMAT="text" +BASE_PREFIX="" +CAND_PREFIX="" + +while [ $# -gt 0 ]; do + case "$1" in + --dir) shift; DIR="$1" ;; + --ignore) shift; IGNORE="$1" ;; + --include-fields) shift; INCLUDE="$1" ;; + --framing) shift; FRAMING="$1" ;; + --format) shift; FORMAT="$1" ;; + -h|--help) sed -n '2,52p' "$NC_SELF"; exit 0 ;; + -*) die "unknown flag: $1" ;; + *) + if [ -z "$BASE_PREFIX" ]; then BASE_PREFIX="$1" + elif [ -z "$CAND_PREFIX" ]; then CAND_PREFIX="$1" + else die "extra arg: $1" + fi + ;; + esac + shift +done + +[ -x "$HL7DIFF" ] || [ -f "$HL7DIFF" ] || die "backing tool not found: $HL7DIFF" +[ -n "$BASE_PREFIX" ] && [ -n "$CAND_PREFIX" ] || die "usage: hl7-diff-set.sh [--dir PATH] [--ignore FIELDS] [--framing auto|nl|0x1c|len10] [--format text|tsv] (run --help for full usage)" +[ -d "$DIR" ] || die "no such --dir: $DIR" +case "$FORMAT" in text|tsv) ;; *) die "bad --format (want text|tsv)" ;; esac +case "$FRAMING" in auto|nl|0x1c|len10) ;; *) die "bad --framing (want auto|nl|0x1c|len10)" ;; esac + +# --- discovery: glob both prefixes, derive route suffixes ------------------- +# Portable (bash 3.2 / MobaXterm-safe) glob: a literal no-match pattern stays +# un-expanded, so every candidate is existence-checked with `[ -e ]` before +# use — no reliance on nullglob (bash-4-leaning / shopt-dependent). +_base_suffixes=() +for _f in "$DIR/$BASE_PREFIX".*; do + [ -e "$_f" ] || continue + _bn="$(basename "$_f")" + _base_suffixes+=("${_bn#"$BASE_PREFIX".}") +done + +_cand_suffixes=() +for _f in "$DIR/$CAND_PREFIX".*; do + [ -e "$_f" ] || continue + _bn="$(basename "$_f")" + _cand_suffixes+=("${_bn#"$CAND_PREFIX".}") +done + +if [ "${#_base_suffixes[@]}" -eq 0 ] && [ "${#_cand_suffixes[@]}" -eq 0 ]; then + die "no files matched '$BASE_PREFIX.*' or '$CAND_PREFIX.*' in $DIR" +fi + +# Union of both suffix lists, sorted + de-duped, for a stable/deterministic +# route order regardless of filesystem glob order. +ROUTES="$(printf '%s\n' "${_base_suffixes[@]}" "${_cand_suffixes[@]}" | grep -v '^$' | sort -u)" + +# --- run each pair ------------------------------------------------------------ +diff_args=(--ignore "$IGNORE" --framing "$FRAMING") +[ -n "$INCLUDE" ] && diff_args+=(--include-fields "$INCLUDE") + +n_routes=0 n_pass=0 n_diff=0 n_missing=0 n_extra=0 + +if [ "$FORMAT" = "text" ]; then + printf 'hl7-diff-set: baseline=%s candidate=%s dir=%s\n' "$BASE_PREFIX" "$CAND_PREFIX" "$DIR" + printf ' ignore: %s framing: %s\n\n' "$IGNORE" "$FRAMING" +fi + +while IFS= read -r route; do + [ -z "$route" ] && continue + n_routes=$((n_routes + 1)) + bfile="$DIR/$BASE_PREFIX.$route" + cfile="$DIR/$CAND_PREFIX.$route" + + if [ ! -f "$bfile" ]; then + n_extra=$((n_extra + 1)) + if [ "$FORMAT" = "tsv" ]; then + printf '%s\tEXTRA\t-\n' "$route" + else + printf ' %-24s EXTRA (candidate-only route — no baseline file: %s)\n' "$route" "$bfile" + fi + continue + fi + if [ ! -f "$cfile" ]; then + n_missing=$((n_missing + 1)) + if [ "$FORMAT" = "tsv" ]; then + printf '%s\tMISSING\t-\n' "$route" + else + printf ' %-24s MISSING (no candidate output — dropped route: %s)\n' "$route" "$cfile" + fi + continue + fi + + count="$("$HL7DIFF" "${diff_args[@]}" --format count "$bfile" "$cfile" 2>&1)" + count="$(coerce_int "$count" 0)" + + if [ "$count" -eq 0 ]; then + n_pass=$((n_pass + 1)) + if [ "$FORMAT" = "tsv" ]; then + printf '%s\tPASS\t0\n' "$route" + else + printf ' %-24s PASS\n' "$route" + fi + else + n_diff=$((n_diff + 1)) + if [ "$FORMAT" = "tsv" ]; then + printf '%s\tDIFF\t%s\n' "$route" "$count" + else + printf ' %-24s DIFF (%s diff(s))\n' "$route" "$count" + "$HL7DIFF" "${diff_args[@]}" --format text "$bfile" "$cfile" 2>&1 | sed 's/^/ /' + printf '\n' + fi + fi +done <&2 +else + printf '%s\n' "$_summary" +fi + +if [ "$n_diff" -eq 0 ] && [ "$n_missing" -eq 0 ] && [ "$n_extra" -eq 0 ] && [ "$n_routes" -gt 0 ]; then + exit 0 +fi +exit 1 diff --git a/lib/hl7-diff.sh b/lib/hl7-diff.sh index 601f136..386fe2c 100755 --- a/lib/hl7-diff.sh +++ b/lib/hl7-diff.sh @@ -32,8 +32,21 @@ # --ignore MSH.7 # # Output (text format): -# Per-message header, then one line per differing field: -# SEG.FIELD[.COMP[.SUB]] LEFT_VALUE RIGHT_VALUE +# Per-message header (printed once, right before that message's FIRST +# differing line — a message with no diffs gets no header), then one line +# per differing field, BOTH sides labeled so you never have to guess which +# value came from which file: +# SEG.FIELD[.COMP[.SUB]] left='LEFT_VALUE' right='RIGHT_VALUE' +# An empty-but-present value renders as the literal token (empty); a +# component/subcomponent that does not exist AT ALL on that side (the other +# side has more components, e.g. left MSH-9=ADT^A02^ADT_A02 vs right +# MSH-9=ADT^A02 — right's 3rd component is not merely blank, it is absent) +# renders as , so "blank" and "not there" are never visually the +# same thing. (F-8, tsk-2026-07-23-hl7diff-readability, Bryan-direct +# live-feedback: previously the right-hand value printed as a genuinely +# blank/whitespace field, which looked like only ONE value was shown per +# diff line — the data was always there in the underlying lv/rv, it just +# rendered invisibly.) # # Exit codes: 0 identical (post-ignore), 1 differences found, 2 input error set -o pipefail @@ -187,8 +200,14 @@ split_messages "$RIGHT" "$TMP_R" # v0.8.26: pipe the comparison output through the tty-gated sanitizer, then # propagate ${PIPESTATUS[0]} so awk's exit (0 identical, 1 differs) survives. +# SQ: a literal single-quote character, passed in via -v (OUTSIDE the +# single-quoted awk program text below) so the text-mode left/right quoting +# (F-8) never has to put a bare ' inside the awk source itself -- the whole +# program is one bash '...'-quoted string, and a stray ' in there would +# terminate it early and corrupt everything after (bash has no escape inside +# single quotes). awk -v IGNORE="$IGNORE" -v INCLUDE="$INCLUDE" -v FMT="$FORMAT" \ - -v LFILE="$LEFT" -v RFILE="$RIGHT" ' + -v LFILE="$LEFT" -v RFILE="$RIGHT" -v SQ="'" ' # F-7 fix (2026-07-23, tsk-2026-07-23-len10, Bryan-blocking, discovered while # validating len10 self-tests -- PRE-EXISTING since v0.3.0 initial release # e08f030, unrelated to the len10 change): n, i, ent, arr were used in this @@ -246,7 +265,15 @@ awk -v IGNORE="$IGNORE" -v INCLUDE="$INCLUDE" -v FMT="$FORMAT" \ return n } - function compare_field(seg, fidx, lv, rv, msg_idx, n_lc, n_rc, lc, rc, lcomp, rcomp, j, k, n_lsc, n_rsc, lsub, rsub, nm, diffs, ls, rs, ns) { + # F-8 fix (2026-07-23, tsk-2026-07-23-hl7diff-readability, Bryan-direct live + # feedback): lv_absent/rv_absent (new formal params, 6th/7th) tell emit() + # whether a side has NO such component/subcomponent at all (the other side + # has more components -- e.g. left MSH-9=ADT^A02^ADT_A02, right MSH-9=ADT^A02: + # component 3 on the right is ABSENT there, not merely blank) so text mode + # can render instead of a same-looking-as-blank (empty). Absence + # cascades down: if the whole field is absent, every component and + # subcomponent under it is absent too, regardless of its own split count. + function compare_field(seg, fidx, lv, rv, msg_idx, lv_absent, rv_absent, n_lc, n_rc, lc, rc, lcomp, rcomp, j, k, n_lsc, n_rsc, lsub, rsub, nm, diffs, ls, rs, ns, lc_absent, rc_absent, ls_absent, rs_absent) { if (lv == rv) return 0 # Try component-level if both have ^ if (lv ~ /\^/ || rv ~ /\^/) { @@ -257,6 +284,8 @@ awk -v IGNORE="$IGNORE" -v INCLUDE="$INCLUDE" -v FMT="$FORMAT" \ for (j=1; j<=nm; j++) { lc = (j <= n_lc) ? lcomp[j] : "" rc = (j <= n_rc) ? rcomp[j] : "" + lc_absent = lv_absent || (j > n_lc) + rc_absent = rv_absent || (j > n_rc) if (lc == rc) continue # subcomponent if (lc ~ /&/ || rc ~ /&/) { @@ -266,14 +295,16 @@ awk -v IGNORE="$IGNORE" -v INCLUDE="$INCLUDE" -v FMT="$FORMAT" \ for (k=1; k<=ns; k++) { ls = (k <= n_lsc) ? lsub[k] : "" rs = (k <= n_rsc) ? rsub[k] : "" + ls_absent = lc_absent || (k > n_lsc) + rs_absent = rc_absent || (k > n_rsc) if (ls != rs && !ignored(seg, fidx, j, k)) { - emit(msg_idx, seg "." fidx "." j "." k, ls, rs) + emit(msg_idx, seg "." fidx "." j "." k, ls, rs, ls_absent, rs_absent) diffs++ } } } else { if (!ignored(seg, fidx, j, "")) { - emit(msg_idx, seg "." fidx "." j, lc, rc) + emit(msg_idx, seg "." fidx "." j, lc, rc, lc_absent, rc_absent) diffs++ } } @@ -281,16 +312,40 @@ awk -v IGNORE="$IGNORE" -v INCLUDE="$INCLUDE" -v FMT="$FORMAT" \ return diffs } if (!ignored(seg, fidx, "", "")) { - emit(msg_idx, seg "." fidx, lv, rv) + emit(msg_idx, seg "." fidx, lv, rv, lv_absent, rv_absent) return 1 } return 0 } - function emit(msg_idx, path, lv, rv) { + # F-8 fix: text mode now (a) labels both sides, left=... right=..., so the + # reader never has to guess which value belongs to which file -- the data + # was always present in lv/rv, it just rendered invisibly when the + # right-hand value happened to be empty; (b) renders an empty-but-present + # value as the literal token (empty) and a component/subcomponent that does + # not exist AT ALL on that side as (see compare_field), so blank + # and not-there are never visually indistinguishable; (c) prints the + # "----- message N -----" header lazily, right before the first emitted + # line of a message, keyed off the msg_idx actually passed in -- this + # replaces the old preemptive header print in END (which used the STALE + # global DIFF_COUNT to decide whether to print a header for the message + # about to be processed, so a clean message 1 followed by a differing + # message 2 got the message-2 diffs mislabeled under a "----- message 1 + # -----" header, or no header at all). tsv/count output is untouched + # (still raw lv/rv). SQ is the single-quote character passed in via -v. + function emit(msg_idx, path, lv, rv, lv_absent, rv_absent, lv_disp, rv_disp) { # count mode: accumulate only — no per-diff output (final print is in END). - if (FMT == "tsv") printf "%d\t%s\t%s\t%s\n", msg_idx, path, lv, rv - else if (FMT != "count") printf " %-20s %-30s %s\n", path, lv, rv + if (FMT == "tsv") { + printf "%d\t%s\t%s\t%s\n", msg_idx, path, lv, rv + } else if (FMT == "text") { + if (msg_idx != LAST_HDR_MSG) { + printf "----- message %d -----\n", msg_idx + LAST_HDR_MSG = msg_idx + } + lv_disp = (lv_absent == 1) ? "" : ((lv == "") ? "(empty)" : lv) + rv_disp = (rv_absent == 1) ? "" : ((rv == "") ? "(empty)" : rv) + printf " %-20s left=%-24s right=%s\n", path, SQ lv_disp SQ, SQ rv_disp SQ + } DIFF_COUNT++ } @@ -307,7 +362,10 @@ awk -v IGNORE="$IGNORE" -v INCLUDE="$INCLUDE" -v FMT="$FORMAT" \ lv = (i <= nl) ? lf[i] : "" rv = (i <= nr) ? rf[i] : "" field_num = (seg_name == "MSH") ? i : (i - 1) - compare_field(seg_name, field_num, lv, rv, msg_idx) + # F-8: field itself absent on a side (that side segment does not have + # this many pipe-delimited fields) cascades to for every + # component/subcomponent under it -- see compare_field. + compare_field(seg_name, field_num, lv, rv, msg_idx, (i > nl), (i > nr)) } } @@ -333,7 +391,11 @@ awk -v IGNORE="$IGNORE" -v INCLUDE="$INCLUDE" -v FMT="$FORMAT" \ } } - BEGIN { DIFF_COUNT = 0; n_l = 0; n_r = 0 } + # F-8: LAST_HDR_MSG tracks which message index the text-mode "----- message + # N -----" header was last printed for, so emit() can print it lazily (and + # correctly) right before the first line of that message instead of the + # old preemptive/stale-DIFF_COUNT guess in the loop below. + BEGIN { DIFF_COUNT = 0; n_l = 0; n_r = 0; LAST_HDR_MSG = 0 } FNR == NR { L_MSGS[++n_l] = $0; next } { R_MSGS[++n_r] = $0 } @@ -360,7 +422,8 @@ awk -v IGNORE="$IGNORE" -v INCLUDE="$INCLUDE" -v FMT="$FORMAT" \ emit(i, "MESSAGE_PRESENCE", (lm != "" ? "present" : "missing"), (rm != "" ? "present" : "missing")) continue } - if (FMT == "text" && (i == 1 || DIFF_COUNT > 0)) printf "----- message %d -----\n", i + # F-8: header printing moved into emit() (lazy, keyed by the real + # msg_idx) -- no preemptive print here anymore. See emit()/LAST_HDR_MSG. diff_message(lm, rm, i) } if (FMT == "count") { print DIFF_COUNT; exit (DIFF_COUNT > 0 ? 1 : 0) }