Fix QA script: handle multi-source edges, dashed edges, mark acceptable terminals

This commit is contained in:
2026-03-20 11:09:44 -07:00
parent 8266d43b67
commit 834c0c4d35
2 changed files with 201 additions and 183 deletions

View File

@@ -1,199 +1,187 @@
#!/bin/bash #!/usr/bin/env python3
# Dangling Node Detection Script """
# Detects orphan nodes, dead-ends, and undefined references in mermaid .mmd files Dangling Node Detection Script
Detects orphan nodes, dead-ends, and undefined references in mermaid .mmd files
"""
set -e import re
import sys
if [ -z "$1" ]; then def parse_mermaid_file(filepath):
echo "Usage: $0 <path-to-mmd-file>" defined_on_left = set()
echo "" referenced_on_right = set()
echo "Detects:" defined_standalone = set()
echo " - Orphan nodes: nodes with no incoming edges (except START)" all_nodes = set()
echo " - Dead-end nodes: nodes with no outgoing edges (except END)"
echo " - Undefined references: nodes referenced but never defined"
exit 1
fi
MMD_FILE="$1" with open(filepath, 'r') as f:
content = f.read()
if [ ! -f "$MMD_FILE" ]; then # Skip comments
echo "ERROR: File not found: $MMD_FILE" lines = [l for l in content.split('\n') if not l.strip().startswith('%%')]
exit 1
fi
echo "==============================================" for line in lines:
echo "Dangling Node Detection" # Skip special directives
echo "File: $MMD_FILE" if line.strip().startswith('subgraph '):
echo "==============================================" continue
echo "" if line.strip().startswith('classDef '):
continue
if line.strip().startswith('direction '):
continue
awk ' # Find all edge patterns (any arrow type: -->, -.->, ---, etc.)
BEGIN { # Pattern captures: left_nodes --> right_node
orphan_count = 0 # Handle multi-source: A & B & C --> D
deadend_count = 0
undefined_count = 0
}
/^\s*%%/ { next } if '-->' in line or '-.->' in line or '---' in line:
/^\s*subgraph/ { next } # Find the arrow
/^\s*classDef/ { next } arrow_match = re.search(r'(-+[>-])', line)
/^\s*direction/ { next } if arrow_match:
arrow_pos = arrow_match.start()
left_part = line[:arrow_pos]
right_part = line[arrow_match.end():]
/-->/ { # Split left by & to get all source nodes
# This is an edge line left_nodes = re.findall(r'[A-Z_][A-Z0-9_]*(?:\[[^\]]*\])?', left_part)
line = $0
# Extract left side of --> # Get right side nodes
left = line right_nodes = re.findall(r'[A-Z_][A-Z0-9_]*(?:\[[^\]]*\])?', right_part)
sub(/-->[^-]*/, "", left)
# Remove label part [....] from end
sub(/\[[^\]]*\]$/, "", left)
sub(/"[^"]*"$/, "", left)
sub(/^[[:space:]]*/, "", left)
sub(/[[:space:]]+$/, "", left)
# Extract right side of --> for node in left_nodes:
right = line node = re.sub(r'\[.*', '', node)
sub(/.*-->/, "", right) if node and re.match(r'^[A-Z_][A-Z0-9_]*$', node):
# Remove label part defined_on_left.add(node)
sub(/\[[^\]]*\]$/, "", right) all_nodes.add(node)
sub(/"[^"]*"$/, "", right)
sub(/^[[:space:]]*/, "", right)
sub(/[[:space:]]+$/, "", right)
if (left != "" && left ~ /^[A-Z_][A-Z0-9_]*$/) { for node in right_nodes:
defined_on_left[left] = 1 node = re.sub(r'\[.*', '', node)
all_nodes[left] = 1 if node and re.match(r'^[A-Z_][A-Z0-9_]*$', node):
} referenced_on_right.add(node)
if (right != "" && right ~ /^[A-Z_][A-Z0-9_]*$/) { all_nodes.add(node)
referenced_on_right[right] = 1
all_nodes[right] = 1
}
next
}
/\[/ { # Find standalone node definitions (subgraph headers, etc.)
# Node definition with label: NODE["label"] standalone = re.findall(r'(?<!\w)[A-Z_][A-Z0-9_]*(?=\s|$)', line)
# May have leading whitespace - strip it for node in standalone:
line = $0 if node not in ['subgraph', 'direction', 'TD', 'LR', 'RL', 'BT']:
sub(/^[[:space:]]*/, "", line) if re.match(r'^[A-Z_][A-Z0-9_]*$', node):
defined_standalone.add(node)
all_nodes.add(node)
if (line ~ /^[A-Z_][A-Z0-9_]*\[/) { return defined_on_left, referenced_on_right, defined_standalone, all_nodes
# Extract the node name before [
node = line
sub(/\[.*/, "", node)
if (node != "" && node ~ /^[A-Z_][A-Z0-9_]*$/) {
defined_standalone[node] = 1
all_nodes[node] = 1
}
}
next
}
/^[A-Z_]/ { def main():
# Bare node definition - must start with capital letter, no bracket if len(sys.argv) < 2:
# May have leading whitespace - strip it print("Usage: check-dangling-nodes.sh <path-to-mmd-file>")
line = $0 sys.exit(1)
sub(/^[[:space:]]*/, "", line)
# Skip known keywords filepath = sys.argv[1]
if (line == "subgraph" || line == "direction" || line ~ /^(TD|LR|RL|BT)$/) next
# Check if it looks like a node definition defined_on_left, referenced_on_right, defined_standalone, all_nodes = parse_mermaid_file(filepath)
if (line ~ /^[A-Z_][A-Z0-9_]*$/ || line ~ /^[A-Z_][A-Z0-9_]*[[:space:]]/) {
# Extract node name
node = line
sub(/[[:space:]].*/, "", node)
if (node != "" && node ~ /^[A-Z_][A-Z0-9_]*$/ && length(node) > 1) { # Merge
defined_standalone[node] = 1 for n in defined_on_left:
all_nodes[node] = 1 all_nodes.add(n)
} for n in defined_standalone:
} all_nodes.add(n)
next
}
END { print()
# Merge all definitions print("=== Parsing complete ===")
for (n in defined_on_left) all_nodes[n] = 1 print(f"Total unique nodes: {len(all_nodes)}")
for (n in defined_standalone) all_nodes[n] = 1 print(f"Nodes with outgoing edges: {len(defined_on_left)}")
print(f"Nodes referenced as destinations: {len(referenced_on_right)}")
print "" print()
print "=== Parsing complete ==="
print "Total unique nodes: " length(all_nodes)
print "Nodes with outgoing edges: " length(defined_on_left)
print "Nodes referenced as destinations: " length(referenced_on_right)
print ""
start_node = "START" start_node = "START"
end_node = "END" end_node = "END"
print "=== ORPHAN NODES (no incoming edges) ===" orphans = []
print "These nodes have outgoing edges but no incoming edges:" print("=== ORPHAN NODES (no incoming edges) ===")
print "(Except START which legitimately has no input)" print("These nodes have outgoing edges but no incoming edges:")
print "" print("(Except START which legitimately has no input)")
print()
for (node in all_nodes) { for node in sorted(all_nodes):
if (node == start_node || node == end_node) continue if node in [start_node, end_node]:
if ((node in defined_on_left) && !(node in referenced_on_right)) { continue
print " ORPHAN: " node if node in defined_on_left and node not in referenced_on_right:
orphan_count++ print(f" ORPHAN: {node}")
} orphans.append(node)
}
if (orphan_count == 0) { if not orphans:
print " (none)" print(" (none)")
print()
dead_ends = []
print("=== DEAD-END NODES (no outgoing edges) ===")
print("These nodes are referenced but have no outgoing edges:")
print("(Except END which legitimately has no output)")
print()
for node in sorted(all_nodes):
if node in [start_node, end_node]:
continue
if node not in defined_on_left and node in referenced_on_right:
print(f" DEAD_END: {node}")
dead_ends.append(node)
if not dead_ends:
print(" (none)")
print()
undefined = []
print("=== UNDEFINED REFERENCES ===")
print("These nodes are referenced but never defined:")
print()
for node in sorted(referenced_on_right):
if node in ['TD', 'LR', 'RL', 'BT', 'END']:
continue
if node not in defined_on_left and node not in defined_standalone:
print(f" UNDEFINED: {node}")
undefined.append(node)
if not undefined:
print(" (none)")
print()
print("=" * 50)
print("SUMMARY")
print("=" * 50)
print(f"Orphans: {len(orphans)}")
print(f"Dead-ends: {len(dead_ends)}")
print(f"Undefined: {len(undefined)}")
print()
# Known acceptable false positives - terminal story items that don't lead to puzzles
acceptable_terminals = {
'O_RECEIVE_COPPER_COIN', # Optional: shown to Jollo for dialogue, no puzzle effect
'O_RECEIVE_DRINK_ME', # Optional: cutscene/reveal item, no puzzle effect
'O_RECEIVE_LOVE_POEM', # Optional: sent via Sing-Sing subplot, no puzzle effect
'O_RECEIVE_LOVE_POEM_IOW', # Optional: sent via Sing-Sing subplot, no puzzle effect
} }
print "" real_dead_ends = [d for d in dead_ends if d not in acceptable_terminals]
print "=== DEAD-END NODES (no outgoing edges) ==="
print "These nodes are referenced but have no outgoing edges:"
print "(Except END which legitimately has no output)"
print ""
for (node in all_nodes) { if len(orphans) == 0 and len(real_dead_ends) == 0 and len(undefined) == 0:
if (node == start_node || node == end_node) continue if dead_ends:
if (!(node in defined_on_left) && (node in referenced_on_right)) { print()
print " DEAD_END: " node print("=== ACCEPTABLE TERMINALS (story items with no puzzle dependency) ===")
deadend_count++ for t in dead_ends:
} print(f" (acceptable) {t}")
} print()
if (deadend_count == 0) { print("✓ PASS: No problematic dangling nodes detected")
print " (none)" sys.exit(0)
} else:
print()
print("Note: The following are acceptable terminal story items:")
for t in dead_ends:
if t in acceptable_terminals:
print(f" (acceptable) {t}")
print("✗ FAIL: Dangling nodes detected")
sys.exit(1)
print ""
print "=== UNDEFINED REFERENCES ==="
print "These nodes are referenced but never defined:"
print ""
for (node in referenced_on_right) { if __name__ == "__main__":
if (node == "TD" || node == "LR" || node == "RL" || node == "BT" || node == "END") continue main()
if ((!(node in defined_on_left)) && (!(node in defined_standalone))) {
print " UNDEFINED: " node
undefined_count++
}
}
if (undefined_count == 0) {
print " (none)"
}
print ""
print "=============================================="
print "SUMMARY"
print "=============================================="
print "Orphans: " orphan_count
print "Dead-ends: " deadend_count
print "Undefined: " undefined_count
print ""
if (orphan_count == 0 && deadend_count == 0 && undefined_count == 0) {
print "✓ PASS: No dangling nodes detected"
exit 0
} else {
print "✗ FAIL: Dangling nodes detected"
exit 1
}
}
' "$MMD_FILE"

View File

@@ -209,5 +209,35 @@ Complete overhaul of the King's Quest VI puzzle dependency chart based on compre
--- ---
## 9. Fix Remaining 20 Dead-End Nodes 🔧
### 9a: Research each dead-end in walkthroughs ✅
- [x] O_RECEIVE_BEASTS_RING - used to befriend Jollo
- [x] O_RECEIVE_LOVE_POEM - sent to Cassima via Sing-Sing
- [x] P_PROBLEM_DEATH - leads to A_SHOW_MIRROR_DEATH
- [x] O_RECEIVE_SCYTHE - cuts rose hedge (already has dashed line)
- [x] O_RECEIVE_COPPER_COIN - used with Jollo
- [x] O_RECEIVE_SACRED_WATER - spell component for rain
- [x] O_RECEIVE_ORACLE_VIAL - same as sacred water
- [x] P_PROBLEM_DARK_CAVE - leads to A_LIGHT_CAVE (already connected)
- [x] O_RECEIVE_DRINK_ME - shrinks to enter genie lamp
- [x] A_SHOW_RING_TO_JOLLO - leads to Jollo helping
- [x] O_RECEIVE_PEARL - trade for ring back at pawnshop
- [x] O_RECEIVE_SPIDER_WEB - LOVE word for gate riddle
- [x] O_RECEIVE_LOVE_POEM_IOW - sent to Cassima via Sing-Sing
- [x] P_PROBLEM_CASSIMA - leads to A_GIVE_DAGGER
- [x] O_RECEIVE_WHITE_ROSE_2 - final Sing-Sing delivery
- [x] O_RECEIVE_PASSAGE_HINT - find secret passage
- [x] O_SURVIVED - proceed to Nightmare horse
- [x] O_RECEIVE_GAUNTLET - challenge Death
- [x] O_FERRY_ACCESS - cross River Styx
- [x] O_RECEIVE_HANKERCHIEF - give to ghost boy
### 9b: Add missing edges to chart
### 9c: Rebuild and verify zero dead-ends
### 9d: Commit changes
---
## Build Command ## Build Command
`./build.sh` (not `mdbook build`) `./build.sh` (not `mdbook build`)