mirror of
https://github.com/github/spec-kit.git
synced 2026-07-05 21:49:47 +08:00
* docs: add workflows reference, reorganize into docs/reference/, and add --version flag - Move integrations.md, extensions.md, presets.md into docs/reference/ - New docs/reference/workflows.md: command reference for all workflow commands, built-in SDD Cycle workflow with Mermaid diagram, step types, expressions, input types, state/resume, and FAQ - Rename workflow input feature_name to spec with prompt 'Describe what you want to build' to match speckit.specify command terminology - Add --version / -V flag to root specify command with tests - Update docs/toc.yml, README.md links, and docs/upgrade.md cross-reference to use reference/ paths - Add workflow command to README CLI reference table * docs: update speckit_version requirement to >=0.7.2 in workflow example
301 lines
9.9 KiB
Python
301 lines
9.9 KiB
Python
"""Sandboxed expression evaluator for workflow templates.
|
|
|
|
Provides a safe Jinja2 subset for evaluating expressions in workflow YAML.
|
|
No file I/O, no imports, no arbitrary code execution.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
|
|
# -- Custom filters -------------------------------------------------------
|
|
|
|
def _filter_default(value: Any, default_value: Any = "") -> Any:
|
|
"""Return *default_value* when *value* is ``None`` or empty string."""
|
|
if value is None or value == "":
|
|
return default_value
|
|
return value
|
|
|
|
|
|
def _filter_join(value: Any, separator: str = ", ") -> str:
|
|
"""Join a list into a string with *separator*."""
|
|
if isinstance(value, list):
|
|
return separator.join(str(v) for v in value)
|
|
return str(value)
|
|
|
|
|
|
def _filter_map(value: Any, attr: str) -> list[Any]:
|
|
"""Map a list of dicts to a specific attribute."""
|
|
if isinstance(value, list):
|
|
result = []
|
|
for item in value:
|
|
if isinstance(item, dict):
|
|
# Support dot notation: "result.status" → item["result"]["status"]
|
|
parts = attr.split(".")
|
|
v = item
|
|
for part in parts:
|
|
if isinstance(v, dict):
|
|
v = v.get(part)
|
|
else:
|
|
v = None
|
|
break
|
|
result.append(v)
|
|
else:
|
|
result.append(item)
|
|
return result
|
|
return []
|
|
|
|
|
|
def _filter_contains(value: Any, substring: str) -> bool:
|
|
"""Check if a string or list contains *substring*."""
|
|
if isinstance(value, str):
|
|
return substring in value
|
|
if isinstance(value, list):
|
|
return substring in value
|
|
return False
|
|
|
|
|
|
# -- Expression resolution ------------------------------------------------
|
|
|
|
_EXPR_PATTERN = re.compile(r"\{\{(.+?)\}\}")
|
|
|
|
|
|
def _resolve_dot_path(obj: Any, path: str) -> Any:
|
|
"""Resolve a dotted path like ``steps.specify.output.file`` against *obj*.
|
|
|
|
Supports dict key access and list indexing (e.g., ``task_list[0]``).
|
|
"""
|
|
parts = path.split(".")
|
|
current = obj
|
|
for part in parts:
|
|
# Handle list indexing: name[0]
|
|
idx_match = re.match(r"^([\w-]+)\[(\d+)\]$", part)
|
|
if idx_match:
|
|
key, idx = idx_match.group(1), int(idx_match.group(2))
|
|
if isinstance(current, dict):
|
|
current = current.get(key)
|
|
else:
|
|
return None
|
|
if isinstance(current, list) and 0 <= idx < len(current):
|
|
current = current[idx]
|
|
else:
|
|
return None
|
|
elif isinstance(current, dict):
|
|
current = current.get(part)
|
|
else:
|
|
return None
|
|
if current is None:
|
|
return None
|
|
return current
|
|
|
|
|
|
def _build_namespace(context: Any) -> dict[str, Any]:
|
|
"""Build the variable namespace from a StepContext."""
|
|
ns: dict[str, Any] = {}
|
|
if hasattr(context, "inputs"):
|
|
ns["inputs"] = context.inputs or {}
|
|
if hasattr(context, "steps"):
|
|
ns["steps"] = context.steps or {}
|
|
if hasattr(context, "item"):
|
|
ns["item"] = context.item
|
|
if hasattr(context, "fan_in"):
|
|
ns["fan_in"] = context.fan_in or {}
|
|
return ns
|
|
|
|
|
|
def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
|
|
"""Evaluate a simple expression against the namespace.
|
|
|
|
Supports:
|
|
- Dot-path access: ``steps.specify.output.file``
|
|
- Comparisons: ``==``, ``!=``, ``>``, ``<``, ``>=``, ``<=``
|
|
- Boolean operators: ``and``, ``or``, ``not``
|
|
- ``in``, ``not in``
|
|
- Pipe filters: ``| default('...')``, ``| join(', ')``, ``| contains('...')``, ``| map('...')``
|
|
- String and numeric literals
|
|
"""
|
|
expr = expr.strip()
|
|
|
|
# String literal — check before pipes and operators so quoted strings
|
|
# containing | or operator keywords are not mis-parsed.
|
|
if (expr.startswith("'") and expr.endswith("'")) or (
|
|
expr.startswith('"') and expr.endswith('"')
|
|
):
|
|
return expr[1:-1]
|
|
|
|
# Handle pipe filters
|
|
if "|" in expr:
|
|
parts = expr.split("|", 1)
|
|
value = _evaluate_simple_expression(parts[0].strip(), namespace)
|
|
filter_expr = parts[1].strip()
|
|
|
|
# Parse filter name and argument
|
|
filter_match = re.match(r"(\w+)\((.+)\)", filter_expr)
|
|
if filter_match:
|
|
fname = filter_match.group(1)
|
|
farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace)
|
|
if fname == "default":
|
|
return _filter_default(value, farg)
|
|
if fname == "join":
|
|
return _filter_join(value, farg)
|
|
if fname == "map":
|
|
return _filter_map(value, farg)
|
|
if fname == "contains":
|
|
return _filter_contains(value, farg)
|
|
# Filter without args
|
|
filter_name = filter_expr.strip()
|
|
if filter_name == "default":
|
|
return _filter_default(value)
|
|
return value
|
|
|
|
# Boolean operators — parse 'or' first (lower precedence) so that
|
|
# 'a or b and c' is evaluated as 'a or (b and c)'.
|
|
if " or " in expr:
|
|
parts = expr.split(" or ", 1)
|
|
left = _evaluate_simple_expression(parts[0].strip(), namespace)
|
|
right = _evaluate_simple_expression(parts[1].strip(), namespace)
|
|
return bool(left) or bool(right)
|
|
|
|
if " and " in expr:
|
|
parts = expr.split(" and ", 1)
|
|
left = _evaluate_simple_expression(parts[0].strip(), namespace)
|
|
right = _evaluate_simple_expression(parts[1].strip(), namespace)
|
|
return bool(left) and bool(right)
|
|
|
|
if expr.startswith("not "):
|
|
inner = _evaluate_simple_expression(expr[4:].strip(), namespace)
|
|
return not bool(inner)
|
|
|
|
# Comparison operators (order matters — check multi-char ops first)
|
|
for op in ("!=", "==", ">=", "<=", ">", "<", " not in ", " in "):
|
|
if op in expr:
|
|
parts = expr.split(op, 1)
|
|
left = _evaluate_simple_expression(parts[0].strip(), namespace)
|
|
right = _evaluate_simple_expression(parts[1].strip(), namespace)
|
|
if op == "==":
|
|
return left == right
|
|
if op == "!=":
|
|
return left != right
|
|
if op == ">":
|
|
return _safe_compare(left, right, ">")
|
|
if op == "<":
|
|
return _safe_compare(left, right, "<")
|
|
if op == ">=":
|
|
return _safe_compare(left, right, ">=")
|
|
if op == "<=":
|
|
return _safe_compare(left, right, "<=")
|
|
if op == " in ":
|
|
return left in right if right is not None else False
|
|
if op == " not in ":
|
|
return left not in right if right is not None else True
|
|
|
|
# Numeric literal
|
|
try:
|
|
if "." in expr:
|
|
return float(expr)
|
|
return int(expr)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Boolean literal
|
|
if expr.lower() == "true":
|
|
return True
|
|
if expr.lower() == "false":
|
|
return False
|
|
|
|
# Null
|
|
if expr.lower() in ("none", "null"):
|
|
return None
|
|
|
|
# List literal (simple)
|
|
if expr.startswith("[") and expr.endswith("]"):
|
|
inner = expr[1:-1].strip()
|
|
if not inner:
|
|
return []
|
|
items = [_evaluate_simple_expression(i.strip(), namespace) for i in inner.split(",")]
|
|
return items
|
|
|
|
# Variable reference (dot-path)
|
|
return _resolve_dot_path(namespace, expr)
|
|
|
|
|
|
def _safe_compare(left: Any, right: Any, op: str) -> bool:
|
|
"""Safely compare two values, coercing types when possible."""
|
|
try:
|
|
if isinstance(left, str):
|
|
left = float(left) if "." in left else int(left)
|
|
if isinstance(right, str):
|
|
right = float(right) if "." in right else int(right)
|
|
except (ValueError, TypeError):
|
|
return False
|
|
try:
|
|
if op == ">":
|
|
return left > right # type: ignore[operator]
|
|
if op == "<":
|
|
return left < right # type: ignore[operator]
|
|
if op == ">=":
|
|
return left >= right # type: ignore[operator]
|
|
if op == "<=":
|
|
return left <= right # type: ignore[operator]
|
|
except TypeError:
|
|
return False
|
|
return False
|
|
|
|
|
|
def evaluate_expression(template: str, context: Any) -> Any:
|
|
"""Evaluate a template string with ``{{ ... }}`` expressions.
|
|
|
|
If the entire string is a single expression, returns the raw value
|
|
(preserving type). Otherwise, substitutes each expression inline
|
|
and returns a string.
|
|
|
|
Parameters
|
|
----------
|
|
template:
|
|
The template string (e.g., ``"{{ steps.plan.output.task_count }}"``
|
|
or ``"Processed {{ inputs.spec }}"``.
|
|
context:
|
|
A ``StepContext`` or compatible object.
|
|
|
|
Returns
|
|
-------
|
|
The resolved value (any type for single-expression templates,
|
|
string for multi-expression or mixed templates).
|
|
"""
|
|
if not isinstance(template, str):
|
|
return template
|
|
|
|
namespace = _build_namespace(context)
|
|
|
|
# Single expression: return typed value
|
|
match = _EXPR_PATTERN.fullmatch(template.strip())
|
|
if match:
|
|
return _evaluate_simple_expression(match.group(1).strip(), namespace)
|
|
|
|
# Multi-expression: string interpolation
|
|
def _replacer(m: re.Match[str]) -> str:
|
|
val = _evaluate_simple_expression(m.group(1).strip(), namespace)
|
|
return str(val) if val is not None else ""
|
|
|
|
return _EXPR_PATTERN.sub(_replacer, template)
|
|
|
|
|
|
def evaluate_condition(condition: str, context: Any) -> bool:
|
|
"""Evaluate a condition expression and return a boolean.
|
|
|
|
Convenience wrapper around ``evaluate_expression`` that coerces
|
|
the result to bool.
|
|
"""
|
|
result = evaluate_expression(condition, context)
|
|
# Treat plain "false"/"true" strings as booleans so that
|
|
# condition: "false" (without {{ }}) behaves as expected.
|
|
if isinstance(result, str):
|
|
lower = result.lower()
|
|
if lower == "false":
|
|
return False
|
|
if lower == "true":
|
|
return True
|
|
return bool(result)
|