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>
285 lines
10 KiB
Bash
Executable File
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
|