rdev/cookbooks/scripts/lib/tree-parser.sh
jordan 6c51469c89 fix: cookbook tree runner stdout/stderr separation and bash brace expansion
- Fix bash brace expansion issue with ${2:-{}} defaults causing extra } chars
- Redirect step status messages to stderr to prevent JSON output pollution
- Redirect wait_pipeline/wait_site/diagnose output to stderr
- Add SDLC handler tests for state, features, tasks, artifacts endpoints
- Add SDLC classifier tests for phase transitions and blocking
- Add SDLC CLI command tests for feature, task, branch, merge operations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 15:15:02 -07:00

401 lines
11 KiB
Bash
Executable File

#!/bin/bash
# Tree parser utilities for YAML cookbook definitions
#
# Usage:
# source "$(dirname "${BASH_SOURCE[0]}")/tree-parser.sh"
#
# Provides:
# - tree_parse() - Parse a tree YAML file
# - tree_get_step() - Get a specific step definition
# - tree_get_deps() - Get dependencies for a step
# - tree_execution_order() - Get topological sort of steps
# - tree_step_ready() - Check if step's dependencies are satisfied
# - tree_expand_template() - Expand Go template variables
# - tree_list() - List all available trees
# - tree_get_teardown() - Get teardown steps
# Trees directory
TREES_DIR="${TREES_DIR:-$(dirname "${BASH_SOURCE[0]}")/../../trees}"
# Check for yq
_tree_check_yq() {
if ! command -v yq &> /dev/null; then
echo "Error: yq is required but not installed" >&2
echo "Install with: brew install yq" >&2
return 1
fi
}
# Get tree file path
_tree_path() {
local tree_name="$1"
echo "$TREES_DIR/${tree_name}.yaml"
}
# Parse a tree YAML file
# Arguments: tree_name
# Returns: tree JSON on stdout
# Example: tree_json=$(tree_parse "landing-page")
tree_parse() {
local tree_name="$1"
local path
path="$(_tree_path "$tree_name")"
_tree_check_yq || return 1
if [[ ! -f "$path" ]]; then
echo "Error: Tree '$tree_name' not found at $path" >&2
return 1
fi
yq -o=json "$path"
}
# Get tree metadata
# Arguments: tree_name
# Returns: JSON with name, description, version
tree_get_meta() {
local tree_name="$1"
local tree
tree=$(tree_parse "$tree_name") || return 1
echo "$tree" | jq '{name: .name, description: .description, version: .version}'
}
# Get default vars from tree
# Arguments: tree_name
# Returns: JSON object of default vars
tree_get_default_vars() {
local tree_name="$1"
local tree
tree=$(tree_parse "$tree_name") || return 1
echo "$tree" | jq '.vars // {}'
}
# Get a specific step definition
# Arguments: tree_name step_name
# Returns: step JSON on stdout
# Example: step=$(tree_get_step "landing-page" "create-project")
tree_get_step() {
local tree_name="$1"
local step_name="$2"
local tree
tree=$(tree_parse "$tree_name") || return 1
local step
step=$(echo "$tree" | jq --arg step "$step_name" '.steps[$step] // null')
if [[ "$step" == "null" ]]; then
echo "Error: Step '$step_name' not found in tree '$tree_name'" >&2
return 1
fi
# Add step name to the JSON for convenience
echo "$step" | jq --arg name "$step_name" '. + {name: $name}'
}
# Get all step names
# Arguments: tree_name
# Returns: newline-separated list of step names
tree_get_steps() {
local tree_name="$1"
local tree
tree=$(tree_parse "$tree_name") || return 1
echo "$tree" | jq -r '.steps | keys[]'
}
# Get dependencies for a step
# Arguments: tree_name step_name
# Returns: newline-separated list of dependency step names
# Example: deps=$(tree_get_deps "landing-page" "add-component")
tree_get_deps() {
local tree_name="$1"
local step_name="$2"
local step
step=$(tree_get_step "$tree_name" "$step_name") || return 1
echo "$step" | jq -r '.depends_on // [] | .[]'
}
# Get topological sort of steps (execution order)
# Arguments: tree_name
# Returns: newline-separated list of step names in execution order
# Example: order=$(tree_execution_order "landing-page")
tree_execution_order() {
local tree_name="$1"
local tree
tree=$(tree_parse "$tree_name") || return 1
# Kahn's algorithm for topological sort
# Build adjacency list and in-degree count
local steps_json
steps_json=$(echo "$tree" | jq '.steps')
# Use jq to compute the topological order
echo "$steps_json" | jq -r '
# Build in-degree map and adjacency list
. as $steps |
(keys | map({key: ., value: 0}) | from_entries) as $initial_degrees |
reduce keys[] as $step (
{degrees: $initial_degrees, adj: {}};
. as $state |
($steps[$step].depends_on // []) as $deps |
reduce $deps[] as $dep (
$state;
.degrees[$step] += 1 |
.adj[$dep] = ((.adj[$dep] // []) + [$step])
)
) |
# Kahns algorithm
. as $graph |
{
result: [],
queue: [$graph.degrees | to_entries | .[] | select(.value == 0) | .key],
degrees: $graph.degrees,
adj: $graph.adj
} |
until(.queue | length == 0;
.queue[0] as $node |
.result += [$node] |
.queue = .queue[1:] |
reduce ((.adj[$node] // [])[] ) as $neighbor (
.;
.degrees[$neighbor] -= 1 |
if .degrees[$neighbor] == 0 then
.queue += [$neighbor]
else
.
end
)
) |
.result[]
'
}
# Check if a step's dependencies are satisfied
# Arguments: tree_name step_name completed_steps_json
# Returns: 0 if ready, 1 if not
# Example: if tree_step_ready "landing-page" "add-component" '["create-project"]'; then ...
tree_step_ready() {
local tree_name="$1"
local step_name="$2"
local completed_steps="$3" # JSON array of completed step names
local deps
deps=$(tree_get_deps "$tree_name" "$step_name") || return 1
if [[ -z "$deps" ]]; then
return 0 # No dependencies, always ready
fi
# Check each dependency
while IFS= read -r dep; do
if ! echo "$completed_steps" | jq -e --arg d "$dep" 'index($d) != null' > /dev/null; then
return 1 # Dependency not satisfied
fi
done <<< "$deps"
return 0
}
# Expand Go template variables in a string
# Arguments: template_string vars_json outputs_json
# Returns: expanded string
# Example: result=$(tree_expand_template "{{ .vars.project_name }}" '{"project_name":"test"}' '{}')
tree_expand_template() {
local template="$1"
local vars_json="${2:-"{}"}"
local outputs_json="${3:-"{}"}"
# Build context for template expansion
local context
context=$(jq -n \
--argjson vars "$vars_json" \
--argjson outputs "$outputs_json" \
'{vars: $vars, outputs: $outputs}')
# Simple template expansion using sed
# Handle {{ .vars.NAME }} patterns
local result="$template"
# Extract and replace all {{ .vars.xxx }} patterns
while [[ "$result" =~ \{\{[[:space:]]*\.vars\.([a-zA-Z_][a-zA-Z0-9_]*)[[:space:]]*\}\} ]]; do
local var_name="${BASH_REMATCH[1]}"
local var_value
var_value=$(echo "$vars_json" | jq -r ".[\"$var_name\"] // \"\"")
result="${result//\{\{ .vars.$var_name \}\}/$var_value}"
result="${result//\{\{.vars.$var_name\}\}/$var_value}"
done
# Extract and replace all {{ .outputs.step.key }} patterns
while [[ "$result" =~ \{\{[[:space:]]*\.outputs\.([a-zA-Z_][a-zA-Z0-9_-]*)\.([a-zA-Z_][a-zA-Z0-9_]*)[[:space:]]*\}\} ]]; do
local step_name="${BASH_REMATCH[1]}"
local key_name="${BASH_REMATCH[2]}"
local out_value
out_value=$(echo "$outputs_json" | jq -r ".[\"$step_name\"][\"$key_name\"] // \"\"")
result="${result//\{\{ .outputs.$step_name.$key_name \}\}/$out_value}"
result="${result//\{\{.outputs.$step_name.$key_name\}\}/$out_value}"
done
echo "$result"
}
# Expand templates in a step definition
# Arguments: step_json vars_json outputs_json
# Returns: step JSON with templates expanded
tree_expand_step() {
local step_json="$1"
local vars_json="${2:-"{}"}"
local outputs_json="${3:-"{}"}"
# Convert step to string and expand templates
local step_str
step_str=$(echo "$step_json" | jq -c '.')
local expanded
expanded=$(tree_expand_template "$step_str" "$vars_json" "$outputs_json")
echo "$expanded"
}
# List all available trees
# Returns: newline-separated list of tree names
# Example: trees=$(tree_list)
tree_list() {
_tree_check_yq || return 1
for f in "$TREES_DIR"/*.yaml; do
[[ -e "$f" ]] || continue
basename "$f" .yaml
done
}
# List trees with descriptions
# Returns: formatted list of trees with descriptions
tree_list_detail() {
_tree_check_yq || return 1
for f in "$TREES_DIR"/*.yaml; do
[[ -e "$f" ]] || continue
local name
name=$(basename "$f" .yaml)
local desc
desc=$(yq -r '.description // "No description"' "$f" 2>/dev/null)
printf " %-20s %s\n" "$name" "$desc"
done
}
# Get teardown steps
# Arguments: tree_name
# Returns: JSON array of teardown steps
# Example: teardown=$(tree_get_teardown "landing-page")
tree_get_teardown() {
local tree_name="$1"
local tree
tree=$(tree_parse "$tree_name") || return 1
echo "$tree" | jq '.teardown // []'
}
# Get step action type
# Arguments: step_json
# Returns: action type string
tree_step_action() {
local step_json="$1"
echo "$step_json" | jq -r '.action // "unknown"'
}
# Get step on_error behavior
# Arguments: step_json
# Returns: "fail" or "continue"
tree_step_on_error() {
local step_json="$1"
echo "$step_json" | jq -r '.on_error // "fail"'
}
# Get output extraction rules from step
# Arguments: step_json
# Returns: JSON array of output rules [{name, jq_path}]
tree_step_outputs() {
local step_json="$1"
# outputs can be:
# - array of {key: jq_path}
# Example: [{project_id: ".data.name"}]
echo "$step_json" | jq '
.outputs // [] |
map(to_entries | .[0] | {name: .key, jq_path: .value})
'
}
# Validate tree YAML
# Arguments: tree_name
# Returns: 0 if valid, 1 with error messages if not
tree_validate() {
local tree_name="$1"
local tree
tree=$(tree_parse "$tree_name") || return 1
local errors=()
# Check required fields
local name
name=$(echo "$tree" | jq -r '.name // ""')
if [[ -z "$name" ]]; then
errors+=("Missing required field: name")
fi
# Check that all steps have action field
local steps_without_action
steps_without_action=$(echo "$tree" | jq -r '.steps | to_entries | .[] | select(.value.action == null) | .key')
if [[ -n "$steps_without_action" ]]; then
while IFS= read -r step; do
errors+=("Step '$step' missing required field: action")
done <<< "$steps_without_action"
fi
# Check that dependencies reference existing steps
local all_steps
all_steps=$(echo "$tree" | jq -r '.steps | keys')
local invalid_deps
invalid_deps=$(echo "$tree" | jq -r --argjson all_steps "$all_steps" '
.steps | to_entries | .[] |
.key as $step |
(.value.depends_on // [])[] |
select(. as $dep | $all_steps | index($dep) == null) |
"\($step) depends on non-existent step: \(.)"
')
if [[ -n "$invalid_deps" ]]; then
while IFS= read -r err; do
errors+=("$err")
done <<< "$invalid_deps"
fi
# Check for cycles (execution_order will fail if there are cycles)
if ! tree_execution_order "$tree_name" > /dev/null 2>&1; then
errors+=("Dependency cycle detected")
fi
# Report errors
if [[ ${#errors[@]} -gt 0 ]]; then
echo "Validation errors in tree '$tree_name':" >&2
for err in "${errors[@]}"; do
echo " - $err" >&2
done
return 1
fi
return 0
}