#!/usr/bin/env nu # DAG Cycle Detector for Reflection Mode Graphs # # Validates that the depends_on relationships within each Mode form a true # Directed Acyclic Graph using Kahn's topological sort algorithm. # # Nickel contracts (schema.ncl) already enforce: # - Step ID uniqueness within a mode # - Referential integrity (every depends_on.step references an existing step) # # This script adds the third guarantee: acyclicity. # # Usage: # nu scripts/content/validate-dag.nu site/reflection/content/blog/mod.ncl # nu scripts/content/validate-dag.nu --type blog # nu scripts/content/validate-dag.nu --all def main [ path: string = "" --type: string = "" # resolve path from site/reflection/content/{type}/mod.ncl --all # validate all content types under site/reflection/content/ --verbose ] { let paths = if $all { glob "site/reflection/content/*/mod.ncl" | where { |p| not ($p | str contains "_") } } else if $type != "" { [$"site/reflection/content/($type)/mod.ncl"] } else if $path != "" { [$path] } else { error make { msg: "Provide a path, --type {type}, or --all" } } mut any_error = false for p in $paths { if not ($p | path exists) { print $"(ansi red)NOT FOUND(ansi reset) ($p)" $any_error = true continue } let data = nickel export ($p | path expand) | from json if not ("modes" in $data) { print $"(ansi yellow)SKIP(ansi reset) ($p) — no modes field" continue } let results = $data.modes | items { |mode_name, mode| validate_mode $mode_name $mode $verbose } let errors = $results | where { |r| $r.ok == false } if ($errors | is-empty) { print $"(ansi green)✓(ansi reset) ($p) — ($results | length) modes acyclic" } else { for e in $errors { print $"(ansi red)CYCLE(ansi reset) ($p) / ($e.mode): ($e.msg)" } $any_error = true } } if $any_error { exit 1 } } # Validate a single mode using Kahn's topological sort. # Returns { ok: bool, mode: string, msg: string } def validate_mode [mode_name: string, mode: record, verbose: bool] { if not ("steps" in $mode) { return { ok: true, mode: $mode_name, msg: "" } } let steps = $mode.steps # in-degree of a step = number of its own prerequisites (len of depends_on) mut in_degree = $steps | reduce -f {} { |step, acc| let deps = if "depends_on" in $step { $step.depends_on } else { [] } $acc | insert $step.id ($deps | length) } # adj[X] = steps that depend on X — when X completes, decrement their in-degree mut adj = $steps | reduce -f {} { |step, acc| $acc | insert $step.id [] } for step in $steps { let deps = if "depends_on" in $step { $step.depends_on } else { [] } for dep in $deps { let cur = $adj | get $dep.step $adj = $adj | update $dep.step ($cur | append $step.id) } } # Kahn's algorithm: start with steps that have no dependencies mut queue = $steps | where { |s| ($in_degree | get $s.id) == 0 } | get id mut sorted = [] while ($queue | length) > 0 { let node = $queue | first $queue = $queue | skip 1 $sorted = $sorted | append $node for dependent in ($adj | get $node) { let new_deg = ($in_degree | get $dependent) - 1 $in_degree = $in_degree | update $dependent $new_deg if $new_deg == 0 { $queue = $queue | append $dependent } } } let total = $steps | length if ($sorted | length) == $total { if $verbose { print $" ($mode_name): topological order → ($sorted | str join ' → ')" } { ok: true, mode: $mode_name, msg: "" } } else { # Nodes not in sorted have cyclic dependencies let all_ids = $steps | get id let in_cycle = $all_ids | where { |id| not ($id in $sorted) } { ok: false, mode: $mode_name, msg: $"cycle among steps: ($in_cycle | str join ', ')" } } }