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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,6 @@ jobs:
- run: /tmp/samsarix-spirals-smoke/bin/samsarix-spirals validate examples/hello.json
- 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
- run: /tmp/samsarix-spirals-smoke/bin/samsarix-spirals schema workflow --compact
- run: /tmp/samsarix-spirals-smoke/bin/samsarix-spirals schema suite --compact
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,14 @@ use semantic versioning while the public API remains pre-1.0.
contracts through the Python API and `samsarix-spirals test` command.
- A release-policy example that demonstrates a practical CI approval gate.
- Competitive positioning, flagship use cases, and measurable adoption gates.
- Bundled JSON Schema Draft 2020-12 documents with CLI and Python discovery APIs.
- Deterministic, value-redacted JUnit XML reports for native CI ingestion.

### Changed

- Exact suite-output comparison now distinguishes JSON booleans from numbers while
retaining JSON numeric equality between integer and decimal representations.

- Renamed the product, distribution, import package, CLI, and public base exception from
Helix Spirals to Samsarix Spirals under Samsarix LLC ownership.
- Replaced the inconsistent custom BSL text with the unmodified Mozilla Public License 2.0
Expand Down
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ include NOTICE
include SECURITY.md
recursive-include docs *.md
recursive-include examples *.json
recursive-include src/samsarix_spirals/schemas *.json
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ On macOS or Linux, use `.venv/bin/python` in place of `.venv\Scripts\python`.
.venv\Scripts\samsarix-spirals validate examples/hello.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 schema workflow --compact
```

The output's `output` field is:
Expand Down Expand Up @@ -76,11 +77,31 @@ reduces accidental disclosure of fixture data in CI logs.
```console
samsarix-spirals test workflow.json workflow.suite.json
samsarix-spirals test workflow.json workflow.suite.json --json --compact
samsarix-spirals test workflow.json workflow.suite.json --junit
```

See [`examples/release-policy.suite.json`](examples/release-policy.suite.json) for a
release approval gate with both successful and rejected cases.

## JSON Schemas and CI reports

Draft 2020-12 schemas for workflow and suite version `1` ship inside every wheel. Print
them without locating package files:

```console
samsarix-spirals schema workflow
samsarix-spirals schema suite --compact
```

The schemas provide editor completion and structural validation. Runtime validation is
still authoritative for document byte/depth budgets, unique IDs and names, and semantic
template references. The schema `$id` values are stable identifiers; they do not promise
that a public schema host is deployed yet.

For CI systems that ingest JUnit XML, use `--junit`. The deterministic report contains
suite and case names plus non-sensitive mismatch categories, but never fixture inputs,
expected outputs, or actual outputs.

## Python API

```python
Expand All @@ -91,6 +112,9 @@ result = run_workflow(workflow, {"name": "Ada"})
print(result.output)
```

Bundled schemas and suite reports are also available through `get_schema` and
`suite_result_to_junit_xml` in the typed Python API.

The pre-1.0 API can change between minor releases. Workflow schema changes will use the
top-level `schema_version` field and be documented in the changelog.

Expand Down
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ this boundary.
- [x] Exact-output and expected-error assertions.
- [x] Human and machine-readable reports with CI exit behavior.
- [x] A realistic release-policy example.
- [ ] Publish JSON Schemas for workflows and suites.
- [ ] Emit JUnit XML for native CI test reporting.
- [x] Publish JSON Schemas for workflows and suites.
- [x] Emit JUnit XML for native CI test reporting.

### 0.3 — Useful deterministic shaping

Expand Down
23 changes: 23 additions & 0 deletions docs/WORKFLOW_FORMAT.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,26 @@ Unknown fields are rejected. Suite files use the same 1 MiB, UTF-8, unique-key,
finite-number, nesting, string, collection, and total-value limits as workflow and input
documents. A suite runs all cases even after a mismatch. Human and JSON reports avoid
echoing fixture values.

### Published structural schemas

The distribution bundles JSON Schema Draft 2020-12 documents for workflow and suite
version `1`. Retrieve the exact installed versions with:

```console
samsarix-spirals schema workflow
samsarix-spirals schema suite
```

The schemas intentionally cover portable structural constraints. The runtime additionally
enforces requirements JSON Schema cannot express here, including the 1 MiB byte limit,
maximum nesting and total-value budgets, unique step IDs and case names, template syntax,
default existence, and prior-step reference ordering. Passing external schema validation
does not replace `samsarix-spirals validate` or `samsarix-spirals test`.

### JUnit reports

`samsarix-spirals test WORKFLOW SUITE --junit` emits deterministic JUnit XML on standard
output. It includes no timestamps, durations, fixture values, or workflow outputs. Invalid
XML 1.0 characters in user-provided suite, workflow, or case names are replaced with the
Unicode replacement character so the report always remains parseable.
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ dev = [
"build>=1.2,<2",
"mypy>=1.11,<2",
"pip-audit>=2.9,<3",
"jsonschema>=4.23,<5",
"pytest>=8,<9",
"pytest-cov>=5,<7",
"ruff>=0.8,<1",
Expand All @@ -58,7 +59,7 @@ package-dir = { "" = "src" }
where = ["src"]

[tool.setuptools.package-data]
samsarix_spirals = ["py.typed"]
samsarix_spirals = ["py.typed", "schemas/*.json"]

[tool.pytest.ini_options]
addopts = "--strict-config --strict-markers -ra --cov=samsarix_spirals --cov-branch --cov-report=term-missing --cov-fail-under=95"
Expand Down
5 changes: 4 additions & 1 deletion src/samsarix_spirals/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
from .errors import SamsarixSpiralsError, WorkflowExecutionError, WorkflowValidationError
from .model import Step, Workflow, load_workflow
from .runner import RunResult, StepResult, run_workflow
from .suite import SuiteResult, WorkflowSuite, load_suite, run_suite
from .schema import get_schema
from .suite import SuiteResult, WorkflowSuite, load_suite, run_suite, suite_result_to_junit_xml

__version__ = "0.1.0"

Expand All @@ -19,8 +20,10 @@
"WorkflowExecutionError",
"WorkflowSuite",
"WorkflowValidationError",
"get_schema",
"load_suite",
"load_workflow",
"run_suite",
"run_workflow",
"suite_result_to_junit_xml",
]
21 changes: 18 additions & 3 deletions src/samsarix_spirals/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
parse_json_object_bytes,
)
from .runner import run_workflow
from .suite import load_suite, run_suite
from .schema import SCHEMA_NAMES, get_schema
from .suite import load_suite, run_suite, suite_result_to_junit_xml

STARTER_WORKFLOW: dict[str, JsonValue] = {
"schema_version": 1,
Expand Down Expand Up @@ -86,9 +87,15 @@ def build_parser() -> argparse.ArgumentParser:
test = commands.add_parser("test", help="run a workflow regression suite")
test.add_argument("workflow", type=Path)
test.add_argument("suite", type=Path)
test.add_argument("--json", action="store_true", help="emit a machine-readable report")
report = test.add_mutually_exclusive_group()
report.add_argument("--json", action="store_true", help="emit a machine-readable report")
report.add_argument("--junit", action="store_true", help="emit deterministic JUnit XML")
test.add_argument("--compact", action="store_true", help="compact the JSON report")

schema = commands.add_parser("schema", help="print a bundled JSON Schema")
schema.add_argument("kind", choices=SCHEMA_NAMES)
schema.add_argument("--compact", action="store_true", help="emit compact JSON")

init = commands.add_parser("init", help="write a starter workflow without overwriting files")
init.add_argument("path", type=Path)
return parser
Expand All @@ -103,6 +110,12 @@ def main(argv: Sequence[str] | None = None) -> int:
return 0
if args.command == "init":
return _init_workflow(args.path)
if args.command == "schema":
indent = None if args.compact else 2
print(
json.dumps(get_schema(args.kind), ensure_ascii=False, indent=indent, sort_keys=True)
)
return 0
if args.command == "run":
workflow = load_workflow(args.workflow)
input_data = _load_input(args.input)
Expand All @@ -116,7 +129,9 @@ def main(argv: Sequence[str] | None = None) -> int:
workflow = load_workflow(args.workflow)
suite = load_suite(args.suite)
suite_result = run_suite(workflow, suite)
if args.json:
if args.junit:
print(suite_result_to_junit_xml(suite_result, workflow=workflow.name))
elif args.json:
indent = None if args.compact else 2
print(
json.dumps(
Expand Down
27 changes: 27 additions & 0 deletions src/samsarix_spirals/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# SPDX-License-Identifier: MPL-2.0
# Copyright (c) 2026 Samsarix LLC
"""Bundled JSON Schema discovery."""

from __future__ import annotations

import json
from importlib.resources import files
from typing import Literal, cast

from .model import JsonValue

SchemaName = Literal["suite", "workflow"]
SCHEMA_NAMES: tuple[SchemaName, ...] = ("suite", "workflow")
_SCHEMA_FILES: dict[SchemaName, str] = {
"suite": "suite-v1.schema.json",
"workflow": "workflow-v1.schema.json",
}


def get_schema(name: SchemaName) -> dict[str, JsonValue]:
"""Return a detached bundled Draft 2020-12 schema."""
resource = files("samsarix_spirals").joinpath("schemas", _SCHEMA_FILES[name])
document = json.loads(resource.read_text(encoding="utf-8"))
if not isinstance(document, dict): # pragma: no cover - packaged resource invariant
raise RuntimeError(f"bundled {name} schema root is not an object")
return cast(dict[str, JsonValue], document)
115 changes: 115 additions & 0 deletions src/samsarix_spirals/schemas/suite-v1.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://schemas.samsarix.com/spirals/suite-v1.schema.json",
"title": "Samsarix Spirals regression suite version 1",
"description": "Structural schema for deterministic workflow regression suites.",
"$comment": "Runtime validation remains authoritative for byte, depth, total-value, unique-case-name, and execution constraints.",
"type": "object",
"additionalProperties": false,
"required": ["suite_version", "name", "cases"],
"properties": {
"suite_version": {
"const": 1
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 100,
"pattern": "\\S"
},
"cases": {
"type": "array",
"minItems": 1,
"maxItems": 1000,
"items": {
"$ref": "#/$defs/case"
}
}
},
"$defs": {
"jsonValue": {
"oneOf": [
{
"type": ["string", "number", "boolean", "null"],
"maxLength": 100000
},
{
"type": "array",
"maxItems": 10000,
"items": {
"$ref": "#/$defs/jsonValue"
}
},
{
"type": "object",
"maxProperties": 10000,
"additionalProperties": {
"$ref": "#/$defs/jsonValue"
}
}
]
},
"case": {
"type": "object",
"additionalProperties": false,
"required": ["name", "expect"],
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"pattern": "\\S"
},
"input": {
"type": "object",
"maxProperties": 10000,
"additionalProperties": {
"$ref": "#/$defs/jsonValue"
}
},
"expect": {
"oneOf": [
{
"$ref": "#/$defs/outputExpectation"
},
{
"$ref": "#/$defs/errorExpectation"
}
]
}
}
},
"outputExpectation": {
"type": "object",
"additionalProperties": false,
"required": ["output"],
"properties": {
"output": {
"$ref": "#/$defs/jsonValue"
}
}
},
"errorExpectation": {
"type": "object",
"additionalProperties": false,
"required": ["error"],
"properties": {
"error": {
"type": "object",
"additionalProperties": false,
"properties": {
"step_id": {
"type": "string",
"minLength": 1
},
"message_contains": {
"type": "string",
"minLength": 1,
"maxLength": 100000
}
}
}
}
}
}
}
Loading
Loading