cloverleaf-larry/lib/nc-diff-interface.sh
Bryan Johnson e08f030df5 v0.3.0: initial release of Larry-Anywhere
Portable AI agent for Cloverleaf integration work. Pure bash + curl + jq.
Zero dependency on v1 wrapper scripts or v2 cloverleaf-tools.pyz.

27 native Anthropic tools:

NetConfig parsing (read)
  nc_list_protocols, nc_list_processes, nc_protocol_block,
  nc_protocol_field, nc_protocol_nested, nc_protocol_summary,
  nc_destinations, nc_sources, nc_xlate_refs, nc_tclproc_refs

NetConfig modification (journal-backed writes with rollback)
  nc_insert_protocol, nc_add_route, larry_rollback_list

Workflows
  nc_find_inbound, nc_make_jump (3-thread jump pattern), nc_find
  (tbn/tbp/tbh/tbpr/where replacements), nc_document, nc_diff_interface,
  nc_regression

Messages
  hl7_field, nc_msgs (smat is SQLite!), hl7_diff (with --ignore MSH.7)

File system
  read_file, list_dir, grep_files, glob_files, write_file, bash_exec

Validated against a 22-site real Cloverleaf test install. Five worked
examples end-to-end: jump-thread generation, smat MRN search, system
documentation, interface+connected diff, HL7-aware regression diff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 09:46:20 -07:00

285 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
# nc-diff-interface.sh — diff one Cloverleaf interface across two environments.
#
# Use case: "I made a change in test to interface X, forgot what, need to move to prod.
# Tell me exactly what differs."
#
# Compares:
# 1. The protocol block (TCL definition in NetConfig).
# 2. Every xlate (.xlt) file referenced by the protocol.
# 3. Every tclproc (.tcl) file referenced by the protocol.
# 4. (Optional) related tables (.tbl) — references found inside xlates/tclprocs.
#
# Usage:
# nc-diff-interface.sh --interface NAME --left NC_PATH_A --right NC_PATH_B
# [--out PATH] # markdown report (default: stdout)
# [--include-tables] # also diff .tbl files referenced by the xlates/tclprocs
# [--left-label LBL] # e.g. "TEST"
# [--right-label LBL] # e.g. "PROD"
#
# The two NetConfig paths must each be a site root NetConfig — site root is
# `dirname <NC_PATH>` and that's where Xlate/, tclprocs/, tables/ are looked up.
set -o pipefail
# Note: not using `set -u` — the connected-cluster traversal uses associative
# arrays whose emptiness shouldn't bash-fault.
NC_SELF="$0"
LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)"
NCP="$LIB_DIR/nc-parse.sh"
die() { printf 'nc-diff-interface: %s\n' "$*" >&2; exit 1; }
INTERFACE=""
NC_A=""
NC_B=""
OUT=""
INCLUDE_TABLES=0
LABEL_A="A"
LABEL_B="B"
DEPTH=1 # how many hops out from the named interface to also diff
while [ $# -gt 0 ]; do
case "$1" in
--interface) shift; INTERFACE="$1" ;;
--left) shift; NC_A="$1" ;;
--right) shift; NC_B="$1" ;;
--out) shift; OUT="$1" ;;
--include-tables) INCLUDE_TABLES=1 ;;
--left-label) shift; LABEL_A="$1" ;;
--right-label) shift; LABEL_B="$1" ;;
--depth) shift; DEPTH="$1" ;;
-h|--help) sed -n '2,22p' "$NC_SELF"; exit 0 ;;
-*) die "unknown flag: $1" ;;
*) die "extra arg: $1" ;;
esac
shift
done
[ -n "$INTERFACE" ] || die "missing --interface NAME"
[ -n "$NC_A" ] || die "missing --left NC_PATH"
[ -n "$NC_B" ] || die "missing --right NC_PATH"
[ -f "$NC_A" ] || die "no such file: $NC_A"
[ -f "$NC_B" ] || die "no such file: $NC_B"
[[ "$DEPTH" =~ ^[0-9]+$ ]] || die "--depth must be a non-negative integer"
SITE_A="$(dirname "$NC_A")"
SITE_B="$(dirname "$NC_B")"
out_target() {
if [ -n "$OUT" ]; then mkdir -p "$(dirname "$OUT")" 2>/dev/null; cat > "$OUT"
else cat
fi
}
# Helper: print a diff between two files in fenced code block, or a clear message
# if one or both are missing.
emit_file_diff() {
local title="$1" a="$2" b="$3"
printf '### %s\n\n' "$title"
if [ ! -e "$a" ] && [ ! -e "$b" ]; then
printf '_(missing on both sides)_\n\n'; return
elif [ ! -e "$a" ]; then
printf '**Only on %s**: `%s`\n\n' "$LABEL_B" "$b"
printf '```\n'; head -50 "$b"; printf '```\n\n'; return
elif [ ! -e "$b" ]; then
printf '**Only on %s**: `%s`\n\n' "$LABEL_A" "$a"
printf '```\n'; head -50 "$a"; printf '```\n\n'; return
fi
local sha_a sha_b
sha_a=$(shasum "$a" 2>/dev/null | awk '{print $1}' || md5 "$a")
sha_b=$(shasum "$b" 2>/dev/null | awk '{print $1}' || md5 "$b")
if [ "$sha_a" = "$sha_b" ]; then
printf '_identical (sha1 `%s`)_\n\n' "$sha_a"
else
printf '%s: `%s` (%s)\n' "$LABEL_A" "$a" "$sha_a"
printf '%s: `%s` (%s)\n\n' "$LABEL_B" "$b" "$sha_b"
printf '```diff\n'
diff -u "$a" "$b" 2>/dev/null || true
printf '```\n\n'
fi
}
# Same shape but for two strings (in-memory)
emit_text_diff() {
local title="$1" text_a="$2" text_b="$3"
printf '### %s\n\n' "$title"
local ta tb; ta=$(mktemp); tb=$(mktemp)
printf '%s\n' "$text_a" > "$ta"
printf '%s\n' "$text_b" > "$tb"
if cmp -s "$ta" "$tb"; then
printf '_identical_\n\n'
else
printf '```diff\n'
diff -u "$ta" "$tb" 2>/dev/null || true
printf '```\n\n'
fi
rm -f "$ta" "$tb"
}
# Find referenced files in a NetConfig+site root, for a given interface
collect_xlates() {
local nc="$1" site="$2"
"$NCP" xlate-refs "$nc" "$INTERFACE" 2>/dev/null \
| awk -v site="$site" '{print site"/Xlate/"$0}'
}
collect_tclprocs() {
local nc="$1" site="$2"
"$NCP" tclproc-refs "$nc" "$INTERFACE" 2>/dev/null \
| awk -v site="$site" '{print site"/tclprocs/"$0".tcl"}'
}
# Tables — referenced inside xlates and tclprocs (look for *.tbl references)
collect_table_refs() {
local site="$1"
shift
local files=("$@")
for f in "${files[@]}"; do
[ -f "$f" ] || continue
grep -hoE '[A-Za-z0-9_]+\.tbl' "$f" 2>/dev/null
done | sort -u | awk -v site="$site" '$0 != "" {print site"/tables/"$0}'
}
# Walk the connected graph N hops out from the named interface, combining
# sources and destinations from BOTH NetConfigs. Result: deduplicated thread set.
build_cluster() {
local depth="$1"
declare -A visited
visited["$INTERFACE"]=1
local frontier=("$INTERFACE")
local d
for ((d=0; d<depth; d++)); do
local next_frontier=()
local f
for f in "${frontier[@]}"; do
local nc
for nc in "$NC_A" "$NC_B"; do
local rel
for rel in $("$NCP" sources "$nc" "$f" 2>/dev/null) \
$("$NCP" destinations "$nc" "$f" 2>/dev/null); do
[ -z "$rel" ] && continue
if [ -z "${visited[$rel]:-}" ]; then
visited[$rel]=1
next_frontier+=("$rel")
fi
done
done
done
[ ${#next_frontier[@]} -eq 0 ] && break
frontier=("${next_frontier[@]}")
done
printf '%s\n' "${!visited[@]}" | sort
}
# Diff one thread's protocol block + its xlates + its tclprocs.
emit_thread_section() {
local iface="$1" idx="$2" total="$3"
printf '## [%d/%d] Thread `%s`\n\n' "$idx" "$total" "$iface"
local BLOCK_A BLOCK_B
BLOCK_A=$("$NCP" protocol-block "$NC_A" "$iface" 2>/dev/null || echo "")
BLOCK_B=$("$NCP" protocol-block "$NC_B" "$iface" 2>/dev/null || echo "")
if [ -z "$BLOCK_A" ] && [ -z "$BLOCK_B" ]; then
printf '_absent on both sides — referenced as DEST but block not present_\n\n'
return
elif [ -z "$BLOCK_A" ]; then
printf '**Only on %s.** Block on %s:\n\n```tcl\n%s\n```\n\n' "$LABEL_B" "$LABEL_B" "$BLOCK_B"
return
elif [ -z "$BLOCK_B" ]; then
printf '**Only on %s.** Block on %s:\n\n```tcl\n%s\n```\n\n' "$LABEL_A" "$LABEL_A" "$BLOCK_A"
return
fi
emit_text_diff "Protocol block" "$BLOCK_A" "$BLOCK_B"
# Xlates referenced
local X_A=() X_B=()
while IFS= read -r l; do X_A+=("$l"); done < <(collect_xlates_for "$NC_A" "$SITE_A" "$iface")
while IFS= read -r l; do X_B+=("$l"); done < <(collect_xlates_for "$NC_B" "$SITE_B" "$iface")
declare -A XSET
local p
for p in "${X_A[@]}" "${X_B[@]}"; do [ -n "$p" ] && XSET[$(basename "$p")]=1; done
if [ ${#XSET[@]} -gt 0 ]; then
printf '#### Xlates referenced by `%s`\n\n' "$iface"
local x
for x in "${!XSET[@]}"; do
emit_file_diff "Xlate \`$x\`" "$SITE_A/Xlate/$x" "$SITE_B/Xlate/$x"
done
fi
# Tclprocs referenced
local T_A=() T_B=()
while IFS= read -r l; do T_A+=("$l"); done < <(collect_tclprocs_for "$NC_A" "$SITE_A" "$iface")
while IFS= read -r l; do T_B+=("$l"); done < <(collect_tclprocs_for "$NC_B" "$SITE_B" "$iface")
declare -A TSET
for p in "${T_A[@]}" "${T_B[@]}"; do [ -n "$p" ] && TSET[$(basename "$p")]=1; done
if [ ${#TSET[@]} -gt 0 ]; then
printf '#### Tclprocs referenced by `%s`\n\n' "$iface"
local t
for t in "${!TSET[@]}"; do
emit_file_diff "Tclproc \`$t\`" "$SITE_A/tclprocs/$t" "$SITE_B/tclprocs/$t"
done
fi
}
# Per-iface collectors (versions of the originals scoped to a specific iface)
collect_xlates_for() {
"$NCP" xlate-refs "$1" "$3" 2>/dev/null | awk -v site="$2" '{print site"/Xlate/"$0}'
}
collect_tclprocs_for() {
"$NCP" tclproc-refs "$1" "$3" 2>/dev/null | awk -v site="$2" '{print site"/tclprocs/"$0".tcl"}'
}
# ─────────────────────────────────────────────────────────────────────────────
# Compose report
# ─────────────────────────────────────────────────────────────────────────────
{
printf '# Interface diff: `%s` + connected (depth %d)\n\n' "$INTERFACE" "$DEPTH"
printf '_%s_ → `%s`\n' "$LABEL_A" "$NC_A"
printf '_%s_ → `%s`\n\n' "$LABEL_B" "$NC_B"
# Build the cluster
CLUSTER=()
while IFS= read -r t; do CLUSTER+=("$t"); done < <(build_cluster "$DEPTH")
TOTAL=${#CLUSTER[@]}
printf '## Cluster (%d threads)\n\n' "$TOTAL"
printf 'These are the threads that will be diffed, starting from `%s` and walking %d hop(s) outward via sources/destinations on BOTH sides.\n\n' "$INTERFACE" "$DEPTH"
for t in "${CLUSTER[@]}"; do printf -- '- `%s`\n' "$t"; done
printf '\n'
# Diff each thread in the cluster
IDX=0
for t in "${CLUSTER[@]}"; do
IDX=$((IDX+1))
emit_thread_section "$t" "$IDX" "$TOTAL"
done
# Optional tables section
if [ "$INCLUDE_TABLES" = "1" ]; then
printf '## Tables referenced (across the whole cluster)\n\n'
declare -A TBL_SEEN
for t in "${CLUSTER[@]}"; do
while IFS= read -r f; do
[ -f "$f" ] && for tbl in $(grep -hoE '[A-Za-z0-9_]+\.tbl' "$f" 2>/dev/null); do
TBL_SEEN[$tbl]=1
done
done < <(collect_xlates_for "$NC_A" "$SITE_A" "$t"; \
collect_tclprocs_for "$NC_A" "$SITE_A" "$t"; \
collect_xlates_for "$NC_B" "$SITE_B" "$t"; \
collect_tclprocs_for "$NC_B" "$SITE_B" "$t")
done
if [ ${#TBL_SEEN[@]} -eq 0 ]; then
printf '_no .tbl references found inside the cluster_\n\n'
else
for tbl in "${!TBL_SEEN[@]}"; do
emit_file_diff "Table \`$tbl\`" "$SITE_A/tables/$tbl" "$SITE_B/tables/$tbl"
done
fi
fi
printf '---\n\n'
printf '_Generated %s by Larry-Anywhere nc-diff-interface.sh (depth=%d)._\n' \
"$(date -Iseconds 2>/dev/null || date)" "$DEPTH"
} | out_target
[ -n "$OUT" ] && printf 'nc-diff-interface: wrote %s\n' "$OUT" >&2