diff --git a/scripts/check-dangling-nodes.sh b/scripts/check-dangling-nodes.sh new file mode 100755 index 0000000..ca8a4ee --- /dev/null +++ b/scripts/check-dangling-nodes.sh @@ -0,0 +1,210 @@ +#!/bin/bash +# Checks a Mermaid flowchart for dangling nodes (orphans, dead-ends, undefined references) + +usage() { + cat < + +Parses a Mermaid flowchart and reports: + - Orphan nodes (no incoming edges, except START) + - Dead-end nodes (no outgoing edges, except END) + - Undefined references (referenced in edges but never defined) +EOF + exit 1 +} + +[[ $# -eq 1 ]] || usage +FILE="$1" + +if [[ ! -f "$FILE" ]]; then + echo "Error: File '$FILE' not found" >&2 + exit 1 +fi + +awk ' +function trim(s) { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", s) + return s +} + +# Extract bare node ID (strip any [label] or (label) suffix) +function node_id(s) { + # Find first [ or ( and take everything before it + if (match(s, /[[(]/)) { + return trim(substr(s, 1, RSTART-1)) + } + return trim(s) +} + +/^[[:space:]]*$/ { next } +/^[[:space:]]*%%/ { next } +/^[[:space:]]*(classDef|class)[[:space:]]/ { next } +/^[[:space:]]*::/ { next } +/^[[:space:]]*subgraph[[:space:]]/ { next } +/^[[:space:]]*end[[:space:]]*$/ { next } +/^[[:space:]]*flowchart[[:space:]]/ { next } + +{ + line = $0 + gsub(/%.*$/, "", line) + + # Find node definitions: ID with optional ( before [ + while (match(line, /(^|[^A-Za-z_])[A-Za-z_][A-Za-z0-9_]*\(?\[/) > 0) { + match_start = RSTART + + # Calculate where the ID actually starts + leading_char = "" + if (match_start > 1) { + leading_char = substr(line, match_start - 1, 1) + } + + id_start = match_start + (leading_char != "" ? 1 : 0) + + rest_of_line = substr(line, id_start) + bracket_pos_in_rest = index(rest_of_line, "[") + if (bracket_pos_in_rest == 0) break + + bracket_pos = id_start + bracket_pos_in_rest - 1 + + node_id_str = substr(line, id_start, bracket_pos_in_rest - 1) + node_id_str = trim(node_id_str) + sub(/[(\[]+$/, "", node_id_str) + + if (node_id_str != "") { + defined[node_id_str] = 1 + + # Extract label between [" and "] + label_rest = substr(line, bracket_pos + 1) + if (match(label_rest, /"([^"]+)"/) > 0) { + label[node_id_str] = substr(label_rest, RSTART + 1, RLENGTH - 2) + } + } + + # Remove this node from line - find the closing ] and remove everything up to and including it + after_close = bracket_pos + index(substr(line, bracket_pos + 1), "]") + line = substr(line, after_close + 1) + } +} + +(/-->|-\.->/) { + line = $0 + gsub(/%.*$/, "", line) + + arrow = "" + if (match(line, /-->/)) { + arrow = "-->" + } else if (match(line, /-\.->/)) { + arrow = "-.->" + } else { + next + } + + n = split(line, parts, arrow) + if (n < 2) next + + sources_str = parts[1] + targets_str = parts[2] + + # Process sources + ns = split(sources_str, srcs, /&/) + for (i = 1; i <= ns; i++) { + src = node_id(srcs[i]) + if (src == "") continue + outgoing[src]++ + if (!(src in defined)) { + ref_line[src] = FNR + } + } + + # Process targets + nt = split(targets_str, tgts, /&/) + for (i = 1; i <= nt; i++) { + tgt = node_id(tgts[i]) + if (tgt == "") continue + incoming[tgt]++ + if (!(tgt in defined)) { + ref_line[tgt] = FNR + } + } +} + +END { + orphan_count = 0 + for (node in defined) { + if (node == "START" || node == "END") continue + if (!(node in incoming)) { + orphans[++orphan_count] = node + } + } + + deadend_count = 0 + for (node in defined) { + if (node == "START" || node == "END") continue + if (!(node in outgoing)) { + deadends[++deadend_count] = node + } + } + + undef_count = 0 + for (node in ref_line) { + if (!(node in defined)) { + undefined[++undef_count] = node + } + } + + print "=== DANGLING NODE REPORT ===" + print "File: " FILENAME + print "" + + total = orphan_count + deadend_count + undef_count + if (total == 0) { + print "No issues found! Flowchart appears well-formed." + exit 0 + } + + print "ORPHAN NODES (no incoming edges):" + if (orphan_count == 0) { + print " (none)" + } else { + for (i = 1; i <= orphan_count; i++) { + node = orphans[i] + if (label[node] != "") { + printf " - %s: %s\n", node, label[node] + } else { + printf " - %s\n", node + } + } + } + print "" + + print "DEAD-END NODES (no outgoing edges):" + if (deadend_count == 0) { + print " (none)" + } else { + for (i = 1; i <= deadend_count; i++) { + node = deadends[i] + if (label[node] != "") { + printf " - %s: %s\n", node, label[node] + } else { + printf " - %s\n", node + } + } + } + print "" + + print "UNDEFINED REFERENCES:" + if (undef_count == 0) { + print " (none)" + } else { + for (i = 1; i <= undef_count; i++) { + node = undefined[i] + printf " - %s: referenced on line %d but never defined\n", node, ref_line[node] + } + } + print "" + + print "TOTAL ISSUES: " total + + exit (total > 0 ? 1 : 0) +} +' "$FILE" diff --git a/src/inspiration/kings-quest-vi-chart.mmd b/src/inspiration/kings-quest-vi-chart.mmd index 5956788..00576a5 100644 --- a/src/inspiration/kings-quest-vi-chart.mmd +++ b/src/inspiration/kings-quest-vi-chart.mmd @@ -461,7 +461,7 @@ flowchart TD A_GET_COAL --> O_RECEIVE_COAL_IOM O_RECEIVE_COAL_IOM --> A_TRADE_COAL_FOR_EGG - O_TRADE_COAL_FOR_EGG -.-> O_RECEIVE_SULFUR_EGG["O: Receive Sulfur Egg"] + A_TRADE_COAL_FOR_EGG -.-> O_RECEIVE_SULFUR_EGG["O: Receive Sulfur Egg"] %% ============================================================================= %% PHASE 9: Realm of the Dead