531 lines
22 KiB
Bash
Executable File
531 lines
22 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# nc-paths.sh — deterministic route-chain path ENUMERATOR for Larry-Anywhere v3.
|
|
#
|
|
# This is the SINGLE walker backend for Cloverleaf message routing. It replaces
|
|
# the old dark `nc-parse.sh chain` BFS-node-set command (which only ever
|
|
# returned a flat set of reachable nodes, never enumerated paths, and was never
|
|
# wired into the LLM). It ports the v2 `paths` semantics
|
|
# (cloverleaf_tools/cli/legacy_workflow_commands.py paths_cmd + the three
|
|
# _enumerate_* helpers, lines 315-464) faithfully:
|
|
#
|
|
# - Downstream DFS from a start thread, following the DATAXLATE DEST list
|
|
# (find_outgoing). A leaf (no outgoing) OR a cycle hit terminates that path
|
|
# and the terminal node is included in the emitted chain.
|
|
# - Upstream DFS (mirror), following incoming threads (find_incoming).
|
|
# - All-mode: enumerate from every entry point (a thread with no incoming),
|
|
# deduped — gives the whole-site chain inventory (v2 list_full_routes).
|
|
#
|
|
# ROUTING RESOLUTION: next hop is resolved ONLY from the DATAXLATE { DEST <name> }
|
|
# list (via nc-parse.sh destinations / sources). It NEVER reads ICLSERVERPORT.
|
|
# This is deliberate: Bryan's old paths.tcl walked routes via
|
|
# `keylget data ICLSERVERPORT`, which THROWS on any thread lacking that key
|
|
# (every outbound/client thread), so the trace died on the first client thread.
|
|
# The DEST list is present on every routing thread regardless of direction and
|
|
# simply yields nothing (no crash) when a thread has no routes. DO NOT
|
|
# reintroduce an ICLSERVERPORT-based hop here.
|
|
#
|
|
# CROSS-SITE BY DEFAULT (Bryan's resolved decision, 2026-05-28): when a chain's
|
|
# terminal thread (a downstream leaf with no further DEST in its own site) is
|
|
# ALSO an entry/inbound thread declared in ANOTHER discovered site's NetConfig
|
|
# (correlated by shared thread name), the walk CONTINUES into that site — so the
|
|
# mux -> ancout -> CodaMetrix style chain is followed end to end across the site
|
|
# boundary. Pass --site-only to scope the walk to a single site.
|
|
#
|
|
# Robust cycle detection across sites: every walk carries the full ancestor set
|
|
# keyed by "site\037thread"; revisiting any (site,thread) ancestor terminates the
|
|
# path (the terminal node is still emitted), so the enumeration always
|
|
# terminates. A global max-depth cap (default 128, matching v2) is a second
|
|
# backstop.
|
|
#
|
|
# Output columns: SITE THREAD HOPS PATH
|
|
# THREAD = the start/anchor thread of the row
|
|
# HOPS = number of threads in the chain (len of the path list)
|
|
# PATH = the chain joined by " -> " (space-arrow-space)
|
|
# One row per enumerated root-to-leaf path; a branching thread yields N rows.
|
|
#
|
|
# Usage:
|
|
# nc-paths.sh --netconfig <file> <thread> [flags] # explicit NetConfig
|
|
# nc-paths.sh <thread> <site> [flags] # resolve site under $HCIROOT
|
|
# nc-paths.sh --all [--site <name>] [flags] # whole-site entry chains
|
|
#
|
|
# Flags:
|
|
# --upstream only the upstream chains feeding the thread
|
|
# --downstream only the downstream chains from the thread
|
|
# (neither flag = full paths containing the thread,
|
|
# v2 default, falling back to downstream-from-thread)
|
|
# --all enumerate from every entry point (no thread arg)
|
|
# --site <name> scope all-mode (or site resolution) to one site
|
|
# --site-only do NOT cross site boundaries (downstream only)
|
|
# --hciroot <dir> override $HCIROOT for site/cross-site discovery
|
|
# --netconfig <file> operate on one explicit NetConfig (implies the site is
|
|
# basename(dirname(file)); cross-site still scans $HCIROOT)
|
|
# --max-depth N recursion cap (default 128)
|
|
# --format tsv|table|jsonl default: table
|
|
#
|
|
# Exit codes: 0 OK, 1 usage error, 2 not found.
|
|
set -u
|
|
set -o pipefail
|
|
|
|
NC_SELF="$0"
|
|
LIB_DIR="$(cd "$(dirname "$NC_SELF")" && pwd)"
|
|
NCP="$LIB_DIR/nc-parse.sh"
|
|
|
|
die() { printf 'nc-paths: %s\n' "$*" >&2; exit 1; }
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Arg parsing
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
THREAD=""
|
|
SITE_ARG=""
|
|
NETCONFIG=""
|
|
HCIROOT_OVERRIDE=""
|
|
DIR_MODE="full" # full | up | down
|
|
ALL_MODE=0
|
|
SITE_ONLY=0
|
|
MAX_DEPTH=128
|
|
FORMAT="table"
|
|
|
|
POSITIONAL=()
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--upstream) DIR_MODE="up" ;;
|
|
--downstream) DIR_MODE="down" ;;
|
|
--all) ALL_MODE=1 ;;
|
|
--site) shift; SITE_ARG="${1:-}" ;;
|
|
--site-only) SITE_ONLY=1 ;;
|
|
--hciroot) shift; HCIROOT_OVERRIDE="${1:-}" ;;
|
|
--netconfig) shift; NETCONFIG="${1:-}" ;;
|
|
--max-depth) shift; MAX_DEPTH="${1:-128}" ;;
|
|
--format) shift; FORMAT="${1:-table}" ;;
|
|
-h|--help) sed -n '2,70p' "$NC_SELF" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
--*) die "unknown flag: $1" ;;
|
|
*) POSITIONAL+=("$1") ;;
|
|
esac
|
|
shift
|
|
done
|
|
|
|
case "$FORMAT" in tsv|table|jsonl) ;; *) die "bad --format: $FORMAT (tsv|table|jsonl)" ;; esac
|
|
|
|
# Positional shapes:
|
|
# <thread> (manual: thread only; site from $HCISITE/$HCISITEDIR)
|
|
# <thread> <site> (manual muscle-memory: thread + site)
|
|
if [ "${#POSITIONAL[@]}" -ge 1 ]; then THREAD="${POSITIONAL[0]}"; fi
|
|
if [ "${#POSITIONAL[@]}" -ge 2 ] && [ -z "$SITE_ARG" ]; then SITE_ARG="${POSITIONAL[1]}"; fi
|
|
if [ "${#POSITIONAL[@]}" -gt 2 ]; then die "too many positional args: ${POSITIONAL[*]}"; fi
|
|
|
|
if [ "$ALL_MODE" = "0" ] && [ -z "$THREAD" ]; then
|
|
die "no thread given (and --all not set). Try: nc-paths.sh <thread> <site> OR nc-paths.sh --all --site <name>"
|
|
fi
|
|
|
|
ROOT="${HCIROOT_OVERRIDE:-${HCIROOT:-}}"
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Site discovery — map every discovered NetConfig to a site name.
|
|
# Two parallel arrays (portable to bash 3.2 on macOS; no associative-array dep).
|
|
# SITE_NAMES[i] = site (basename of NetConfig's parent dir)
|
|
# SITE_NCS[i] = absolute NetConfig path
|
|
# An explicit --netconfig is always included; cross-site scanning still walks
|
|
# $HCIROOT so a terminal can hop into another site.
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
SITE_NAMES=()
|
|
SITE_NCS=()
|
|
|
|
_add_site() {
|
|
local name="$1" nc="$2" i
|
|
[ -f "$nc" ] || return 0
|
|
# de-dupe by NetConfig path
|
|
for ((i=0; i<${#SITE_NCS[@]}; i++)); do
|
|
[ "${SITE_NCS[$i]}" = "$nc" ] && return 0
|
|
done
|
|
SITE_NAMES+=("$name")
|
|
SITE_NCS+=("$nc")
|
|
}
|
|
|
|
_discover_sites() {
|
|
# explicit NetConfig first (its site name is the parent dir basename)
|
|
if [ -n "$NETCONFIG" ]; then
|
|
[ -f "$NETCONFIG" ] || die "not a file: $NETCONFIG"
|
|
_add_site "$(basename "$(dirname "$NETCONFIG")")" "$NETCONFIG"
|
|
fi
|
|
# When --site-only with an explicit NetConfig, do not scan further.
|
|
if [ "$SITE_ONLY" = "1" ] && [ -n "$NETCONFIG" ]; then
|
|
return 0
|
|
fi
|
|
# Otherwise discover all sites under $HCIROOT (for cross-site joins / site
|
|
# resolution / all-mode), same walk nc-find.sh uses.
|
|
if [ -n "$ROOT" ]; then
|
|
local nc sname
|
|
while IFS= read -r nc; do
|
|
sname=$(basename "$(dirname "$nc")")
|
|
# When --site-only (no explicit NetConfig) and a site was named, keep only it.
|
|
if [ "$SITE_ONLY" = "1" ] && [ -n "$SITE_ARG" ] && [ "$sname" != "$SITE_ARG" ]; then
|
|
continue
|
|
fi
|
|
_add_site "$sname" "$nc"
|
|
done < <(find "$ROOT" -maxdepth 2 -name NetConfig -type f 2>/dev/null | sort)
|
|
fi
|
|
}
|
|
|
|
# Resolve the NetConfig path for a given site name (first match wins).
|
|
_nc_for_site() {
|
|
local want="$1" i
|
|
for ((i=0; i<${#SITE_NAMES[@]}; i++)); do
|
|
if [ "${SITE_NAMES[$i]}" = "$want" ]; then
|
|
printf '%s' "${SITE_NCS[$i]}"
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# Given a thread name, find the FIRST discovered (site,nc) pair whose NetConfig
|
|
# declares that thread as a protocol. Emits "site\037nc" or returns 1.
|
|
US=$'\037' # unit separator — safe field delimiter for site/thread keys
|
|
_locate_thread() {
|
|
local want="$1" i sname nc
|
|
for ((i=0; i<${#SITE_NCS[@]}; i++)); do
|
|
sname="${SITE_NAMES[$i]}"; nc="${SITE_NCS[$i]}"
|
|
if "$NCP" list-protocols "$nc" 2>/dev/null | grep -qxF "$want"; then
|
|
printf '%s%s%s' "$sname" "$US" "$nc"
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# One-hop primitives (DEST-based, never ICLSERVERPORT).
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
_outgoing() { "$NCP" destinations "$1" "$2" 2>/dev/null; } # nc thread -> dest names
|
|
_incoming() { "$NCP" sources "$1" "$2" 2>/dev/null; } # nc thread -> source names
|
|
|
|
# Is <thread> an entry point (no incoming) in <nc>?
|
|
_is_entry_in() {
|
|
local nc="$1" t="$2"
|
|
[ -z "$(_incoming "$nc" "$t")" ]
|
|
}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Path enumeration. Emitted paths are written to $OUT_PATHS as one line each:
|
|
# site<TAB>chain where chain = thread1 -> thread2 -> ...
|
|
# We carry the running chain as a space-joined token list of "site\037thread"
|
|
# keys, and the ancestor set as newline-joined keys (for cycle detection).
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
OUT_PATHS=$(mktemp)
|
|
trap 'rm -f "$OUT_PATHS"' EXIT
|
|
|
|
# _emit_chain ANCHOR_SITE KEYCHAIN
|
|
# KEYCHAIN = space-separated list of "site\037thread" keys
|
|
# Renders to "anchor_site<TAB>t1 -> t2 -> ..." (thread names only in PATH).
|
|
_emit_chain() {
|
|
local anchor_site="$1" keychain="$2"
|
|
local out="" k thr first=1
|
|
for k in $keychain; do
|
|
thr="${k#*$US}"
|
|
if [ "$first" = "1" ]; then out="$thr"; first=0; else out="$out -> $thr"; fi
|
|
done
|
|
printf '%s\t%s\n' "$anchor_site" "$out"
|
|
}
|
|
|
|
# Downstream DFS. Mirrors v2 _enumerate_downstream_paths + cross-site hop.
|
|
# $1 anchor_site — site to report in the SITE column for these rows
|
|
# $2 cur_site — site of current thread
|
|
# $3 cur_nc — NetConfig of current thread
|
|
# $4 cur_thread — current thread name
|
|
# $5 keychain — space-joined ancestor keys NOT including current
|
|
# $6 seen — newline-joined ancestor keys (for cycle detection)
|
|
# $7 depth
|
|
_walk_down() {
|
|
local anchor_site="$1" cur_site="$2" cur_nc="$3" cur_thread="$4"
|
|
local keychain="$5" seen="$6" depth="$7"
|
|
local curkey="${cur_site}${US}${cur_thread}"
|
|
local newchain
|
|
if [ -z "$keychain" ]; then newchain="$curkey"; else newchain="$keychain $curkey"; fi
|
|
|
|
# cycle / depth cap → terminate, include current node (v2 semantics)
|
|
if [ "$depth" -gt "$MAX_DEPTH" ] || printf '%s\n' "$seen" | grep -qxF "$curkey"; then
|
|
_emit_chain "$anchor_site" "$newchain"
|
|
return 0
|
|
fi
|
|
|
|
# gather outgoing within the current site
|
|
local outgoing=()
|
|
local d
|
|
while IFS= read -r d; do
|
|
[ -z "$d" ] && continue
|
|
outgoing+=("$d")
|
|
done < <(_outgoing "$cur_nc" "$cur_thread")
|
|
|
|
if [ "${#outgoing[@]}" -gt 0 ]; then
|
|
local nseen
|
|
nseen="$seen"$'\n'"$curkey"
|
|
for d in "${outgoing[@]}"; do
|
|
_walk_down "$anchor_site" "$cur_site" "$cur_nc" "$d" "$newchain" "$nseen" $((depth+1))
|
|
done
|
|
return 0
|
|
fi
|
|
|
|
# No outgoing in this site = a leaf for this site. CROSS-SITE HOP:
|
|
# if cross-site is enabled and this leaf thread is an entry/inbound thread in
|
|
# ANOTHER site's NetConfig (shared name) that DOES have outgoing there,
|
|
# continue the walk into that site.
|
|
if [ "$SITE_ONLY" = "0" ]; then
|
|
local i osite onc okey
|
|
for ((i=0; i<${#SITE_NCS[@]}; i++)); do
|
|
osite="${SITE_NAMES[$i]}"; onc="${SITE_NCS[$i]}"
|
|
[ "$osite" = "$cur_site" ] && [ "$onc" = "$cur_nc" ] && continue
|
|
# the thread must exist in the other site AND have outgoing there
|
|
"$NCP" list-protocols "$onc" 2>/dev/null | grep -qxF "$cur_thread" || continue
|
|
[ -n "$(_outgoing "$onc" "$cur_thread")" ] || continue
|
|
okey="${osite}${US}${cur_thread}"
|
|
# cycle guard across sites: don't re-enter an ancestor (site,thread)
|
|
printf '%s\n' "$seen" | grep -qxF "$okey" && continue
|
|
# Continue the chain in the other site. We DROP the duplicate boundary
|
|
# node: cur_thread is already the last node in newchain, and it is the
|
|
# same thread name in osite, so we recurse on its destinations directly,
|
|
# carrying newchain as the prefix and marking both (site,thread) keys seen.
|
|
local nseen2
|
|
nseen2="$seen"$'\n'"$curkey"$'\n'"$okey"
|
|
local dd
|
|
while IFS= read -r dd; do
|
|
[ -z "$dd" ] && continue
|
|
_walk_down "$anchor_site" "$osite" "$onc" "$dd" "$newchain" "$nseen2" $((depth+1))
|
|
done < <(_outgoing "$onc" "$cur_thread")
|
|
# only join into the first matching downstream site, then stop scanning
|
|
return 0
|
|
done
|
|
fi
|
|
|
|
# true terminal — emit the chain
|
|
_emit_chain "$anchor_site" "$newchain"
|
|
}
|
|
|
|
# Upstream DFS. Mirrors v2 _enumerate_upstream_paths. Cross-site upstream:
|
|
# if a thread has no incoming in its own site but the same-named thread is a
|
|
# downstream/leaf in another site, follow that site's incoming (the feeders).
|
|
# builds the chain as a PREFIX (sources come before current)
|
|
_walk_up() {
|
|
local anchor_site="$1" cur_site="$2" cur_nc="$3" cur_thread="$4"
|
|
local keychain="$5" seen="$6" depth="$7"
|
|
local curkey="${cur_site}${US}${cur_thread}"
|
|
local newchain
|
|
if [ -z "$keychain" ]; then newchain="$curkey"; else newchain="$curkey $keychain"; fi
|
|
|
|
if [ "$depth" -gt "$MAX_DEPTH" ] || printf '%s\n' "$seen" | grep -qxF "$curkey"; then
|
|
_emit_chain "$anchor_site" "$newchain"
|
|
return 0
|
|
fi
|
|
|
|
local incoming=()
|
|
local s
|
|
while IFS= read -r s; do
|
|
[ -z "$s" ] && continue
|
|
incoming+=("$s")
|
|
done < <(_incoming "$cur_nc" "$cur_thread")
|
|
|
|
if [ "${#incoming[@]}" -gt 0 ]; then
|
|
local nseen
|
|
nseen="$seen"$'\n'"$curkey"
|
|
for s in "${incoming[@]}"; do
|
|
_walk_up "$anchor_site" "$cur_site" "$cur_nc" "$s" "$newchain" "$nseen" $((depth+1))
|
|
done
|
|
return 0
|
|
fi
|
|
|
|
# cross-site upstream hop: same-named thread fed in another site
|
|
if [ "$SITE_ONLY" = "0" ]; then
|
|
local i osite onc okey
|
|
for ((i=0; i<${#SITE_NCS[@]}; i++)); do
|
|
osite="${SITE_NAMES[$i]}"; onc="${SITE_NCS[$i]}"
|
|
[ "$osite" = "$cur_site" ] && [ "$onc" = "$cur_nc" ] && continue
|
|
"$NCP" list-protocols "$onc" 2>/dev/null | grep -qxF "$cur_thread" || continue
|
|
[ -n "$(_incoming "$onc" "$cur_thread")" ] || continue
|
|
okey="${osite}${US}${cur_thread}"
|
|
printf '%s\n' "$seen" | grep -qxF "$okey" && continue
|
|
local nseen2
|
|
nseen2="$seen"$'\n'"$curkey"$'\n'"$okey"
|
|
local ss
|
|
while IFS= read -r ss; do
|
|
[ -z "$ss" ] && continue
|
|
_walk_up "$anchor_site" "$osite" "$onc" "$ss" "$newchain" "$nseen2" $((depth+1))
|
|
done < <(_incoming "$onc" "$cur_thread")
|
|
return 0
|
|
done
|
|
fi
|
|
|
|
_emit_chain "$anchor_site" "$newchain"
|
|
}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Drivers
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
# Enumerate every full path in a site by starting from each entry point.
|
|
# Cross-site continuation happens naturally inside _walk_down. Dedup by the
|
|
# rendered "site\tchain" line.
|
|
_enumerate_all_in_site() {
|
|
local site="$1" nc="$2"
|
|
local entry tmp
|
|
tmp=$(mktemp)
|
|
# entry points = threads with no incoming in this site
|
|
"$NCP" list-protocols "$nc" 2>/dev/null | while IFS= read -r entry; do
|
|
[ -z "$entry" ] && continue
|
|
if _is_entry_in "$nc" "$entry"; then
|
|
printf '%s\n' "$entry" >> "$tmp"
|
|
fi
|
|
done
|
|
# if no entry points (every thread has an incoming, e.g. a pure cycle),
|
|
# fall back to all protocols as start points (v2 fallback)
|
|
if [ ! -s "$tmp" ]; then
|
|
"$NCP" list-protocols "$nc" 2>/dev/null > "$tmp"
|
|
fi
|
|
while IFS= read -r entry; do
|
|
[ -z "$entry" ] && continue
|
|
_walk_down "$site" "$site" "$nc" "$entry" "" "" 0
|
|
done < "$tmp"
|
|
rm -f "$tmp"
|
|
}
|
|
|
|
main_enumerate() {
|
|
_discover_sites
|
|
[ "${#SITE_NCS[@]}" -gt 0 ] || die "no NetConfig found (set \$HCIROOT, or pass --netconfig / --hciroot)"
|
|
|
|
local raw
|
|
raw=$(mktemp)
|
|
trap 'rm -f "$OUT_PATHS" "$raw"' EXIT
|
|
|
|
if [ "$ALL_MODE" = "1" ]; then
|
|
# whole-site entry chains; scope to --site if given (else every site)
|
|
local i sname snc
|
|
for ((i=0; i<${#SITE_NAMES[@]}; i++)); do
|
|
sname="${SITE_NAMES[$i]}"; snc="${SITE_NCS[$i]}"
|
|
if [ -n "$SITE_ARG" ] && [ "$sname" != "$SITE_ARG" ]; then continue; fi
|
|
_enumerate_all_in_site "$sname" "$snc" >> "$raw"
|
|
done
|
|
else
|
|
# locate the thread's home site
|
|
local home_site home_nc loc
|
|
if [ -n "$NETCONFIG" ]; then
|
|
home_nc="$NETCONFIG"; home_site="$(basename "$(dirname "$NETCONFIG")")"
|
|
"$NCP" list-protocols "$home_nc" 2>/dev/null | grep -qxF "$THREAD" \
|
|
|| die "thread not found in $home_nc: $THREAD"
|
|
elif [ -n "$SITE_ARG" ]; then
|
|
home_nc="$(_nc_for_site "$SITE_ARG")" || die "site not found under \$HCIROOT: $SITE_ARG"
|
|
home_site="$SITE_ARG"
|
|
"$NCP" list-protocols "$home_nc" 2>/dev/null | grep -qxF "$THREAD" \
|
|
|| die "thread not found in site $SITE_ARG: $THREAD"
|
|
else
|
|
loc="$(_locate_thread "$THREAD")" || die "thread not found in any discovered site: $THREAD"
|
|
home_site="${loc%%$US*}"; home_nc="${loc#*$US}"
|
|
fi
|
|
|
|
case "$DIR_MODE" in
|
|
up) _walk_up "$home_site" "$home_site" "$home_nc" "$THREAD" "" "" 0 >> "$raw" ;;
|
|
down) _walk_down "$home_site" "$home_site" "$home_nc" "$THREAD" "" "" 0 >> "$raw" ;;
|
|
full)
|
|
# v2 default: every full path (entry-point enumeration) that CONTAINS the
|
|
# thread; fall back to downstream-from-thread if none contain it.
|
|
local all_tmp
|
|
all_tmp=$(mktemp)
|
|
_enumerate_all_in_site "$home_site" "$home_nc" > "$all_tmp"
|
|
# cross-site: also enumerate full paths in any site whose entry chains
|
|
# could pass through the thread (the home site's own entry enumeration
|
|
# already crosses outward; inbound feeders in other sites are picked up
|
|
# because those sites' entry chains are enumerated in all-mode — but for
|
|
# a single-thread query we only have the home site's chains, so we also
|
|
# scan every discovered site's chains to catch upstream feeders).
|
|
if [ "$SITE_ONLY" = "0" ]; then
|
|
local j js jn
|
|
for ((j=0; j<${#SITE_NAMES[@]}; j++)); do
|
|
js="${SITE_NAMES[$j]}"; jn="${SITE_NCS[$j]}"
|
|
[ "$jn" = "$home_nc" ] && continue
|
|
_enumerate_all_in_site "$js" "$jn" >> "$all_tmp"
|
|
done
|
|
fi
|
|
# keep only chains containing the thread (match on " -> THREAD ->",
|
|
# leading "THREAD ->", or trailing "-> THREAD", or exact)
|
|
local kept
|
|
kept=$(awk -F'\t' -v t="$THREAD" '
|
|
{
|
|
chain=$2
|
|
# pad with arrows for unambiguous boundary matching
|
|
padded=" -> " chain " -> "
|
|
if (index(padded, " -> " t " -> ") > 0) print $0
|
|
}' "$all_tmp" | sort -u)
|
|
if [ -n "$kept" ]; then
|
|
printf '%s\n' "$kept" >> "$raw"
|
|
else
|
|
_walk_down "$home_site" "$home_site" "$home_nc" "$THREAD" "" "" 0 >> "$raw"
|
|
fi
|
|
rm -f "$all_tmp"
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
# dedup the raw "site<TAB>chain" lines, preserving first-seen order
|
|
awk '!seen[$0]++' "$raw" > "$OUT_PATHS"
|
|
rm -f "$raw"
|
|
trap 'rm -f "$OUT_PATHS"' EXIT
|
|
}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Render: OUT_PATHS holds "site<TAB>chain" lines. Build SITE THREAD HOPS PATH.
|
|
# THREAD = first node of the chain (the anchor/root for this row)
|
|
# HOPS = number of nodes in the chain
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
render() {
|
|
if [ ! -s "$OUT_PATHS" ]; then
|
|
printf 'No paths found.\n'
|
|
return 0
|
|
fi
|
|
# produce a 4-col TSV: site thread hops path
|
|
local tsv
|
|
tsv=$(awk -F'\t' '
|
|
{
|
|
site=$1; chain=$2
|
|
# first node
|
|
first=chain
|
|
sub(/ -> .*/, "", first)
|
|
# hop count = number of " -> " separators + 1
|
|
n=split(chain, parts, / -> /)
|
|
printf "%s\t%s\t%d\t%s\n", site, first, n, chain
|
|
}' "$OUT_PATHS")
|
|
|
|
case "$FORMAT" in
|
|
tsv)
|
|
printf 'site\tthread\thops\tpath\n'
|
|
printf '%s\n' "$tsv"
|
|
;;
|
|
jsonl)
|
|
printf '%s\n' "$tsv" | awk -F'\t' '
|
|
function esc(s){ gsub(/\\/,"\\\\",s); gsub(/"/,"\\\"",s); return s }
|
|
{ printf "{\"site\":\"%s\",\"thread\":\"%s\",\"hops\":%s,\"path\":\"%s\"}\n",
|
|
esc($1),esc($2),$3,esc($4) }'
|
|
;;
|
|
table)
|
|
{
|
|
printf 'SITE\tTHREAD\tHOPS\tPATH\n'
|
|
printf '%s\n' "$tsv"
|
|
} | awk -F'\t' '
|
|
{ for (i=1;i<=NF;i++){ if (length($i)>w[i]) w[i]=length($i); cell[NR,i]=$i }; rows=NR; cols=NF }
|
|
END {
|
|
for (r=1; r<=rows; r++) {
|
|
for (c=1; c<=cols; c++) printf "%-*s ", w[c], cell[r,c]
|
|
printf "\n"
|
|
if (r==1) { for (c=1; c<=cols; c++) { for (k=0;k<w[c];k++) printf "-"; printf " " }; printf "\n" }
|
|
}
|
|
}'
|
|
;;
|
|
esac
|
|
|
|
if [ "$FORMAT" = "table" ]; then
|
|
local n
|
|
n=$(printf '%s\n' "$tsv" | grep -c . )
|
|
printf '\n%d path(s)\n' "${n:-0}" >&2
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
main_enumerate
|
|
render
|