Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ jobs:
- run: python -m venv /tmp/samsarix-spirals-smoke
- run: /tmp/samsarix-spirals-smoke/bin/python -m pip install --no-deps dist/*.whl
- run: /tmp/samsarix-spirals-smoke/bin/samsarix-spirals validate examples/hello.json
- run: /tmp/samsarix-spirals-smoke/bin/samsarix-spirals explain examples/agent-tool-result.json --compact
- run: /tmp/samsarix-spirals-smoke/bin/samsarix-spirals run examples/hello.json --input examples/hello.input.json --compact
- run: /tmp/samsarix-spirals-smoke/bin/samsarix-spirals test examples/release-policy.json examples/release-policy.suite.json --json --compact
- run: /tmp/samsarix-spirals-smoke/bin/samsarix-spirals test examples/release-policy.json examples/release-policy.suite.json --junit
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ use semantic versioning while the public API remains pre-1.0.
top-level output allowlists.
- An agent tool-result example with approval, required-field, and adversarial extra-field
regression cases.
- A value-free `explain` API and CLI command for input/default path inventory and direct
step-dependency review without workflow execution.

### Changed

Expand Down
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ On macOS or Linux, use `.venv/bin/python` in place of `.venv\Scripts\python`.

```console
.venv\Scripts\samsarix-spirals validate examples/hello.json
.venv\Scripts\samsarix-spirals explain examples/agent-tool-result.json
.venv\Scripts\samsarix-spirals run examples/hello.json --input examples/hello.input.json
.venv\Scripts\samsarix-spirals test examples/release-policy.json examples/release-policy.suite.json
.venv\Scripts\samsarix-spirals test examples/agent-tool-result.json examples/agent-tool-result.suite.json
Expand All @@ -54,6 +55,14 @@ The output's `output` field is:
}
```

Use `explain` during review to see every referenced input/default path and each step's
direct dependencies without providing input or executing the workflow. The JSON report
contains paths and operation names, not resolved values:

```console
samsarix-spirals explain workflow.json --compact
```

Create a starter file without overwriting an existing path:

```console
Expand Down Expand Up @@ -110,9 +119,10 @@ expected outputs, or actual outputs.
## Python API

```python
from samsarix_spirals import load_workflow, run_workflow
from samsarix_spirals import explain_workflow, load_workflow, run_workflow

workflow = load_workflow("examples/hello.json")
print(explain_workflow(workflow).input_paths)
result = run_workflow(workflow, {"name": "Ada"})
print(result.output)
```
Expand Down Expand Up @@ -143,8 +153,8 @@ output, and later steps do not run.

## Project status and release boundary

The core journey—install, validate, run, regression-test, inspect output—has local
automated coverage.
The core journey—install, validate, explain, run, regression-test, inspect output—has
local automated coverage.
Before publishing, a maintainer still needs to observe the GitHub Actions matrix on the
target commit, confirm the distribution name is still available, create the tag, and
publish through an owned package index account. Those external steps are intentionally
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ this boundary.
arbitrary expressions.
- [ ] Add bounded list mapping, filtering, and string normalization without arbitrary
expressions.
- Add an `explain` command that shows dependencies and referenced input paths without
- [x] Add an `explain` command that shows dependencies and referenced input paths without
executing the workflow.
- Define compatibility and deprecation rules for every schema-visible operation.
- [x] Prove an agent-output contract with adversarial extra-field, approval, and
Expand Down
19 changes: 19 additions & 0 deletions docs/WORKFLOW_FORMAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,25 @@ Given the same workflow, input, and run limit, its JSON value result is the same

Object key order is not semantic. The CLI sorts keys when serializing its result.

## Static explanations

`samsarix-spirals explain WORKFLOW` validates a workflow and emits an `explain_version`
`1` JSON document without rendering templates or executing steps. It includes:

- the lexicographically sorted `input_paths` and `default_paths` referenced anywhere;
- each step's operation, direct prior-step dependencies, and direct input/default paths;
- the final output's direct dependencies and paths.

Paths preserve their template spelling, such as `input.items.0.name`. A reference to a
whole root is reported as `input` or `defaults`. Dependencies are step IDs, not workflow
values. When a workflow omits `output`, the explanation records the last step as the
implicit output dependency.

The report intentionally excludes defaults, inputs, resolved output, and other values.
This makes it suitable for code-review and inventory tooling, but it does not prove that
runtime input will contain every reported path; regression suites remain the executable
contract.

## Regression suite format

The `test` command accepts a workflow and a separate suite document. Suite version `1`
Expand Down
5 changes: 5 additions & 0 deletions src/samsarix_spirals/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""Samsarix Spirals public API."""

from .errors import SamsarixSpiralsError, WorkflowExecutionError, WorkflowValidationError
from .explain import OutputExplanation, StepExplanation, WorkflowExplanation, explain_workflow
from .model import Step, Workflow, load_workflow
from .runner import RunResult, StepResult, run_workflow
from .schema import get_schema
Expand All @@ -11,15 +12,19 @@
__version__ = "0.1.0"

__all__ = [
"OutputExplanation",
"RunResult",
"SamsarixSpiralsError",
"Step",
"StepExplanation",
"StepResult",
"SuiteResult",
"Workflow",
"WorkflowExecutionError",
"WorkflowExplanation",
"WorkflowSuite",
"WorkflowValidationError",
"explain_workflow",
"get_schema",
"load_suite",
"load_workflow",
Expand Down
14 changes: 13 additions & 1 deletion src/samsarix_spirals/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from . import __version__
from .errors import WorkflowExecutionError, WorkflowValidationError
from .explain import explain_workflow
from .model import (
DEFAULT_MAX_RUN_STEPS,
MAX_DOCUMENT_BYTES,
Expand Down Expand Up @@ -59,14 +60,18 @@
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="samsarix-spirals",
description="Validate and run deterministic local JSON workflows.",
description="Validate, explain, and run deterministic local JSON workflows.",
)
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
commands = parser.add_subparsers(dest="command", required=True)

validate = commands.add_parser("validate", help="validate a workflow without running it")
validate.add_argument("workflow", type=Path)

explain = commands.add_parser("explain", help="show workflow references without running it")
explain.add_argument("workflow", type=Path)
explain.add_argument("--compact", action="store_true", help="emit compact JSON")

run = commands.add_parser("run", help="run a workflow")
run.add_argument("workflow", type=Path)
run.add_argument(
Expand Down Expand Up @@ -108,6 +113,13 @@ def main(argv: Sequence[str] | None = None) -> int:
workflow = load_workflow(args.workflow)
print(f"valid: {args.workflow} ({len(workflow.steps)} steps)")
return 0
if args.command == "explain":
explanation = explain_workflow(load_workflow(args.workflow))
indent = None if args.compact else 2
print(
json.dumps(explanation.to_dict(), ensure_ascii=False, indent=indent, sort_keys=True)
)
return 0
if args.command == "init":
return _init_workflow(args.path)
if args.command == "schema":
Expand Down
140 changes: 140 additions & 0 deletions src/samsarix_spirals/explain.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) 2026 Samsarix LLC
"""Static, value-free workflow explanations."""

from __future__ import annotations

from dataclasses import dataclass

from .model import TEMPLATE_PATTERN, JsonValue, Workflow


@dataclass(frozen=True, slots=True)
class StepExplanation:
"""Direct references made by one workflow step."""

id: str
uses: str
depends_on: tuple[str, ...]
input_paths: tuple[str, ...]
default_paths: tuple[str, ...]

def to_dict(self) -> dict[str, JsonValue]:
"""Return a detached JSON-compatible representation."""
return {
"id": self.id,
"uses": self.uses,
"depends_on": list(self.depends_on),
"input_paths": list(self.input_paths),
"default_paths": list(self.default_paths),
}


@dataclass(frozen=True, slots=True)
class OutputExplanation:
"""Direct references used to produce the workflow output."""

depends_on: tuple[str, ...]
input_paths: tuple[str, ...]
default_paths: tuple[str, ...]

def to_dict(self) -> dict[str, JsonValue]:
"""Return a detached JSON-compatible representation."""
return {
"depends_on": list(self.depends_on),
"input_paths": list(self.input_paths),
"default_paths": list(self.default_paths),
}


@dataclass(frozen=True, slots=True)
class WorkflowExplanation:
"""A deterministic static summary of a validated workflow."""

workflow: str
steps: tuple[StepExplanation, ...]
output: OutputExplanation
input_paths: tuple[str, ...]
default_paths: tuple[str, ...]

def to_dict(self) -> dict[str, JsonValue]:
"""Return a detached JSON-compatible representation."""
return {
"explain_version": 1,
"workflow": self.workflow,
"input_paths": list(self.input_paths),
"default_paths": list(self.default_paths),
"steps": [step.to_dict() for step in self.steps],
"output": self.output.to_dict(),
}


def explain_workflow(workflow: Workflow) -> WorkflowExplanation:
"""Describe direct data references without executing *workflow*."""
steps: list[StepExplanation] = []
all_inputs: set[str] = set()
all_defaults: set[str] = set()

for step in workflow.steps:
references = _collect_references(step.arguments)
all_inputs.update(references.input_paths)
all_defaults.update(references.default_paths)
steps.append(
StepExplanation(
id=step.id,
uses=step.uses,
depends_on=references.depends_on,
input_paths=references.input_paths,
default_paths=references.default_paths,
)
)

if workflow.output_defined:
output = _collect_references(workflow.output)
else:
output = OutputExplanation(
depends_on=(workflow.steps[-1].id,),
input_paths=(),
default_paths=(),
)
all_inputs.update(output.input_paths)
all_defaults.update(output.default_paths)

return WorkflowExplanation(
workflow=workflow.name,
steps=tuple(steps),
output=output,
input_paths=tuple(sorted(all_inputs)),
default_paths=tuple(sorted(all_defaults)),
)


def _collect_references(value: JsonValue) -> OutputExplanation:
dependencies: set[str] = set()
inputs: set[str] = set()
defaults: set[str] = set()

def visit(child: JsonValue) -> None:
if isinstance(child, str):
for match in TEMPLATE_PATTERN.finditer(child):
reference = match.group(1)
root, *segments = reference.split(".")
if root == "input":
inputs.add(reference)
elif root == "defaults":
defaults.add(reference)
elif root == "steps":
dependencies.add(segments[0])
elif isinstance(child, list):
for item in child:
visit(item)
elif isinstance(child, dict):
for item in child.values():
visit(item)

visit(value)
return OutputExplanation(
depends_on=tuple(sorted(dependencies)),
input_paths=tuple(sorted(inputs)),
default_paths=tuple(sorted(defaults)),
)
21 changes: 21 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,27 @@ def test_validate_and_run_commands(tmp_path, capsys) -> None:
assert captured.err == ""


def test_explain_command_does_not_require_or_echo_input(tmp_path, capsys) -> None:
workflow = tmp_path / "workflow.json"
write_workflow(workflow)

assert main(["explain", str(workflow), "--compact"]) == 0
captured = capsys.readouterr()
result = json.loads(captured.out)
assert result["input_paths"] == ["input.name"]
assert result["steps"] == [
{
"id": "hello",
"uses": "set",
"depends_on": [],
"input_paths": ["input.name"],
"default_paths": [],
}
]
assert "Ada" not in captured.out
assert captured.err == ""


def test_run_reads_standard_input(tmp_path, capsys, monkeypatch) -> None:
workflow = tmp_path / "workflow.json"
write_workflow(workflow)
Expand Down
Loading
Loading