diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b7925f..aea3ad4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,12 +101,18 @@ jobs: cd /tmp printf '%s\n' 'Create and approve a customer request, then replicate it to the target system.' > process-description.txt /tmp/pac-wheel/bin/process-code schema -o process.schema.json + /tmp/pac-wheel/bin/process-code output-schema semantic-diff -o semantic-diff.schema.json + /tmp/pac-wheel/bin/process-code output-schema impact -o impact.schema.json /tmp/pac-wheel/bin/process-code draft-context process-description.txt --json -o draft-context.json /tmp/pac-wheel/bin/python - <<'PY' import json schema = json.load(open('/tmp/process.schema.json', encoding='utf-8')) + diff_schema = json.load(open('/tmp/semantic-diff.schema.json', encoding='utf-8')) + impact_schema = json.load(open('/tmp/impact.schema.json', encoding='utf-8')) draft = json.load(open('/tmp/draft-context.json', encoding='utf-8')) assert schema['title'] == 'Process as Code contract v0.2' + assert diff_schema['properties']['output']['properties']['format']['const'] == 'process-as-code.semantic-diff' + assert impact_schema['properties']['output']['properties']['format']['const'] == 'process-as-code.impact' assert draft['schema']['title'] == schema['title'] assert draft['schema_source'].startswith('package:') PY diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 7bac8ea..6433925 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -35,6 +35,8 @@ jobs: cp docs/product.html _site/index.html cp docs/agent-manifest.json _site/agent-manifest.json cp -R web/playground _site/playground + mkdir -p _site/schemas/outputs + cp schemas/outputs/*.json _site/schemas/outputs/ touch _site/.nojekyll - name: Detect Pages availability id: pages diff --git a/CHANGELOG.md b/CHANGELOG.md index e261c7f..e583b80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - BPMN supported-subset import and richer export with lanes and parallel gateways. - Process governance policy gates. - GitHub Action PR validation, semantic impact and marker-based PR comment reporting, including shallow-checkout support and deleted-contract status. +- Versioned machine-readable contracts and bundled JSON Schemas for semantic diff and impact output. - Portable/transitive external artifact resolver with GitHub and JSON Pointer support. - External artifact security hardening: local-reference sandboxing against traversal/symlink escapes, validated GitHub URI components and bounded network payloads. - MCP v2 process-context server. diff --git a/README.md b/README.md index 3ad5be7..ef10612 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ The source-tree schema is [`schemas/process.schema.json`](schemas/process.schema | Capability | Command | | --- | --- | | Bundled JSON Schema | `process-code schema` | +| Versioned machine-output schemas | `process-code output-schema` | | Contract validation | `process-code validate` | | v0.1 -> v0.2 migration | `process-code migrate` | | Mermaid | `process-code mermaid` | @@ -321,7 +322,7 @@ The deterministic graph keeps stable links across process, steps, systems, inter ## Status -`0.2.0` alpha reference implementation. The specification is intentionally explicit but not frozen until 1.0. See [`PRODUCT_BACKLOG.md`](PRODUCT_BACKLOG.md), [`ROADMAP.md`](ROADMAP.md), the [problem-oriented guides](docs/guides/README.md), and the public conformance suite. +`0.2.0` alpha reference implementation. The specification is intentionally explicit but not frozen until 1.0. See [`PRODUCT_BACKLOG.md`](PRODUCT_BACKLOG.md), [`ROADMAP.md`](ROADMAP.md), the [machine-readable output contracts](docs/machine-output-contracts.md), the [problem-oriented guides](docs/guides/README.md), and the public conformance suite. MIT License. diff --git a/ROADMAP.md b/ROADMAP.md index b4a46fc..7bad28a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -43,6 +43,7 @@ For product strategy and acceptance criteria, see [PRODUCT_BACKLOG.md](PRODUCT_B - [x] zero-backend browser playground - [x] adapter framework + BPMN/CSV reference adapters - [x] provider-neutral AI-assisted drafting bundle +- [x] versioned JSON output contracts for semantic diff and impact ## 1.0 — stable contract diff --git a/docs/agent-manifest.json b/docs/agent-manifest.json index c71c572..3d82e91 100644 --- a/docs/agent-manifest.json +++ b/docs/agent-manifest.json @@ -5,15 +5,20 @@ "human_url": "https://dkharlanau.github.io/process-as-code/", "repository": "https://github.com/dkharlanau/process-as-code", "boundaries": ["not a workflow engine", "not a replacement for full BPMN modeling suites", "do not invent process semantics or ownership", "validate contracts before diff/impact/policy"], - "capabilities": ["validate process contracts", "semantic and visual diff", "change impact analysis", "policy gates", "Mermaid/BPMN/RACI/docs generation", "test-scope generation", "searchable catalog", "external-reference resolution", "MCP process context", "JSON-LD export"], + "capabilities": ["validate process contracts", "semantic and visual diff", "change impact analysis", "policy gates", "Mermaid/BPMN/RACI/docs generation", "test-scope generation", "searchable catalog", "external-reference resolution", "MCP process context", "JSON-LD export", "versioned diff/impact JSON contracts"], "entrypoints": [ {"type":"cli","command":"process-code validate --strict"}, {"type":"cli","command":"process-code diff "}, {"type":"cli","command":"process-code impact "}, + {"type":"cli","command":"process-code output-schema impact"}, {"type":"cli","command":"process-code policy --policy "}, {"type":"cli","command":"process-code mcp"} ], "safe_agent_loop": ["read process contract and manifest", "validate", "inspect semantic diff for changes", "calculate impact/policy before proposing edits", "respect step-level agent.exposable/executable/allowed_actions", "return generated evidence and affected scope"], + "machine_outputs": { + "semantic_diff": {"format":"process-as-code.semantic-diff","version":"1.0","schema":"https://dkharlanau.github.io/process-as-code/schemas/outputs/semantic-diff-v1.schema.json"}, + "impact": {"format":"process-as-code.impact","version":"1.0","schema":"https://dkharlanau.github.io/process-as-code/schemas/outputs/impact-v1.schema.json"} + }, "related": [ {"product":"interface-as-code","relationship":"operational contracts referenced by process steps through sandboxed artifact resolution"}, {"product":"mapping-as-code","relationship":"mapping artifacts resolved for traceability without transform evaluation"}, diff --git a/docs/architecture.md b/docs/architecture.md index cf2d91a..dbeb645 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,6 +24,8 @@ YAML/JSON contract The v0.2 JSON Schema describes syntax; Python validation enforces graph and cross-reference invariants. Stable IDs are the semantic backbone for diff, impact, tests, external links and knowledge-graph export. +Semantic diff and impact also expose versioned machine-output contracts. Their JSON payloads carry explicit format/version/schema metadata while preserving the existing top-level data fields. This separates external automation compatibility from internal Python implementation changes. + Graph validation deliberately separates **reachability** from **liveness**. Forward traversal identifies what execution can enter. Reachable edge-less steps define the terminal set. A reverse traversal then determines which reachable steps can eventually reach a terminal. Strongly connected components identify trapped retry/rework cycles that have no terminating path without treating every intentional loop as an error. The validator remains a static contract analyzer: a liveness-clean graph means the declared model contains a terminating path from every reachable branch, not that external workers or systems are guaranteed to complete it. diff --git a/docs/cli.md b/docs/cli.md index fcdb7c0..c122afa 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -10,6 +10,7 @@ Core authoring and review: - `docs FILE` - `raci FILE` - `diff OLD NEW [--json]` +- `output-schema {semantic-diff,impact} [-o FILE]` - `diff-visual OLD NEW` - `impact OLD NEW [--resolve-external]` - `policy FILE --policy POLICY [--old OLD]` @@ -32,4 +33,6 @@ Adoption and AI tooling: - `adapter-import ADAPTER SOURCE` - `mcp --root ROOT` +`diff --json` and `impact --json` use versioned machine-output contracts with bundled JSON Schemas. See [Machine-readable output contracts](machine-output-contracts.md). + Commands that support `--json` emit deterministic machine-readable output suitable for CI/agents. The CLI returns non-zero for validation, policy, test or import validation failures. diff --git a/docs/machine-output-contracts.md b/docs/machine-output-contracts.md new file mode 100644 index 0000000..42948f9 --- /dev/null +++ b/docs/machine-output-contracts.md @@ -0,0 +1,82 @@ +# Machine-readable output contracts + +Process as Code exposes deterministic JSON for automation. The first versioned output contracts cover: + +- `process-code diff OLD NEW --json` +- `process-code impact OLD NEW --json` + +These JSON documents are used by CI pipelines, repository tooling, agent integrations and other as-code tools. Their structure therefore needs an explicit compatibility boundary. + +## Output metadata + +Each versioned JSON document contains an `output` object: + +```json +{ + "output": { + "format": "process-as-code.semantic-diff", + "version": "1.0", + "schema": "https://dkharlanau.github.io/process-as-code/schemas/outputs/semantic-diff-v1.schema.json" + }, + "process": {}, + "sections": {}, + "analysis": {} +} +``` + +The impact report uses `process-as-code.impact` and its own schema URI. Its nested `semantic_diff` object keeps the semantic-diff metadata as well. + +The output contract version is independent from: + +- the Process Contract version such as `0.2`; +- the Python package version such as `0.2.0`. + +## Compatibility rule + +Version `1.0` follows these rules: + +1. Existing documented fields keep their meaning and type. +2. New optional fields may be added. +3. Consumers should ignore fields they do not understand. +4. Removing, renaming or changing the type of an existing documented field requires a new major output contract. +5. Machine output stays deterministic. It does not include timestamps, random IDs or environment-specific values unless a command explicitly models them as business data. + +This policy lets a CI consumer depend on fields such as `sections.steps.changed` or `affected.interfaces` without depending on the internal Python implementation. + +## JSON Schemas + +The schemas are stored in: + +- `schemas/outputs/semantic-diff-v1.schema.json` +- `schemas/outputs/impact-v1.schema.json` + +They are bundled in the Python package and published with the project site. + +Retrieve the bundled copies without network access: + +```bash +process-code output-schema semantic-diff -o semantic-diff.schema.json +process-code output-schema impact -o impact.schema.json +``` + +## Consumer pattern + +A consumer should check the metadata before reading the payload: + +```python +result = json.loads(payload) + +output = result["output"] +if output["format"] != "process-as-code.impact": + raise ValueError("unexpected Process as Code output") +if output["version"] != "1.0": + raise ValueError("unsupported impact contract version") + +interfaces = result["affected"]["interfaces"] +``` + +For validation, use the schema from `output.schema` or a bundled copy retrieved with `process-code output-schema`. + +## Scope + +The first contract boundary is intentionally focused on semantic change control: diff and impact. Other JSON-producing commands can adopt the same pattern when their payloads become integration surfaces with external consumers. diff --git a/docs/product.html b/docs/product.html index 20beb87..89b1f60 100644 --- a/docs/product.html +++ b/docs/product.html @@ -10,5 +10,5 @@ process-code policy examples/customer-creation.process.yaml \ --policy examples/policy.yaml

Designed for coexistence

Process as Code complements BPMN editors, modeling suites, and process-mining tools. It adds a Git-native contract and review layer around the process instead of trying to become another authoring suite.

Not a workflow engine and not a replacement for full BPMN modeling. Use it where process intent has to participate in version control, CI, impact analysis, and agent context.
-
Agent entry point

Governed process context for agents.

The process contract can expose selected steps and allowed actions. MCP and drafting-context commands make the same reviewed process available to agents without turning documentation into an unrestricted tool surface.

DISCOVER

Manifest + schema

agent-manifest.json describes the product surface; the bundled schema defines the process contract.

INSPECT

Diff before acting

Agents can explain current flow and change impact before proposing modifications.

BOUND

Expose allowed actions

Step-level agent metadata distinguishes explainable context from executable capability.

+
Agent entry point

Governed process context for agents.

The process contract can expose selected steps and allowed actions. MCP and drafting-context commands make the same reviewed process available to agents without turning documentation into an unrestricted tool surface.

DISCOVER

Manifest + schemas

agent-manifest.json describes the product surface. The process schema defines input, while versioned output schemas define diff and impact JSON for automation.

INSPECT

Diff before acting

Agents can explain current flow and change impact before proposing modifications.

BOUND

Expose allowed actions

Step-level agent metadata distinguishes explainable context from executable capability.

Put process change where teams can review it.

Start with one process that changes often, version the intent, and let the catalog and impact views come from that source.

diff --git a/pyproject.toml b/pyproject.toml index b54941e..12557b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ Repository = "https://github.com/dkharlanau/process-as-code" Issues = "https://github.com/dkharlanau/process-as-code/issues" [project.optional-dependencies] -dev = ["pytest>=8.0"] +dev = ["pytest>=8.0", "jsonschema>=4.23"] mcp = ["mcp>=2,<3"] [project.scripts] @@ -43,4 +43,4 @@ package-dir = {"" = "src"} where = ["src"] [tool.setuptools.package-data] -process_as_code = ["resources/*.json"] +process_as_code = ["resources/*.json", "resources/output-schemas/*.json"] diff --git a/schemas/outputs/impact-v1.schema.json b/schemas/outputs/impact-v1.schema.json new file mode 100644 index 0000000..674db1f --- /dev/null +++ b/schemas/outputs/impact-v1.schema.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dkharlanau.github.io/process-as-code/schemas/outputs/impact-v1.schema.json", + "title": "Process as Code change impact output v1", + "type": "object", + "required": [ + "output", + "changed_steps", + "analysis_changes", + "analysis_affected_steps", + "affected", + "risk_flags", + "recommended_tests", + "resolved_artifacts", + "semantic_diff" + ], + "properties": { + "output": { + "type": "object", + "required": ["format", "version", "schema"], + "properties": { + "format": {"const": "process-as-code.impact"}, + "version": {"const": "1.0"}, + "schema": {"const": "https://dkharlanau.github.io/process-as-code/schemas/outputs/impact-v1.schema.json"} + }, + "additionalProperties": true + }, + "changed_steps": {"type": "array", "items": {"type": "string"}}, + "analysis_changes": { + "type": "object", + "required": ["variants", "pain_points", "data_flows"], + "properties": { + "variants": {"type": "array", "items": {"type": "string"}}, + "pain_points": {"type": "array", "items": {"type": "string"}}, + "data_flows": {"type": "array", "items": {"type": "string"}} + }, + "additionalProperties": true + }, + "analysis_affected_steps": {"type": "array", "items": {"type": "string"}}, + "affected": { + "type": "object", + "required": ["roles", "systems", "objects", "interfaces", "controls", "risks", "evidence", "artifacts", "subprocesses"], + "additionalProperties": { + "type": "array", + "items": {"type": "string"} + } + }, + "risk_flags": {"type": "array", "items": {"type": "string"}}, + "recommended_tests": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "step", "type", "scenario"], + "properties": { + "id": {"type": "string"}, + "step": {"type": "string"}, + "type": {"type": "string"}, + "scenario": {"type": "string"} + }, + "additionalProperties": true + } + }, + "resolved_artifacts": { + "type": "array", + "items": {"type": "object"} + }, + "semantic_diff": { + "type": "object", + "required": ["output", "process", "sections", "analysis"], + "properties": { + "output": { + "type": "object", + "required": ["format", "version", "schema"], + "properties": { + "format": {"const": "process-as-code.semantic-diff"}, + "version": {"const": "1.0"}, + "schema": {"const": "https://dkharlanau.github.io/process-as-code/schemas/outputs/semantic-diff-v1.schema.json"} + }, + "additionalProperties": true + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/schemas/outputs/semantic-diff-v1.schema.json b/schemas/outputs/semantic-diff-v1.schema.json new file mode 100644 index 0000000..4696371 --- /dev/null +++ b/schemas/outputs/semantic-diff-v1.schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dkharlanau.github.io/process-as-code/schemas/outputs/semantic-diff-v1.schema.json", + "title": "Process as Code semantic diff output v1", + "type": "object", + "required": ["output", "process", "sections", "analysis"], + "properties": { + "output": { + "type": "object", + "required": ["format", "version", "schema"], + "properties": { + "format": {"const": "process-as-code.semantic-diff"}, + "version": {"const": "1.0"}, + "schema": {"const": "https://dkharlanau.github.io/process-as-code/schemas/outputs/semantic-diff-v1.schema.json"} + }, + "additionalProperties": true + }, + "process": { + "type": "object", + "additionalProperties": {"$ref": "#/$defs/value_change"} + }, + "sections": { + "type": "object", + "required": ["steps", "roles", "systems", "objects", "interfaces", "controls", "risks", "evidence", "artifacts"], + "properties": { + "steps": {"$ref": "#/$defs/entity_diff"}, + "roles": {"$ref": "#/$defs/entity_diff"}, + "systems": {"$ref": "#/$defs/entity_diff"}, + "objects": {"$ref": "#/$defs/entity_diff"}, + "interfaces": {"$ref": "#/$defs/entity_diff"}, + "controls": {"$ref": "#/$defs/entity_diff"}, + "risks": {"$ref": "#/$defs/entity_diff"}, + "evidence": {"$ref": "#/$defs/entity_diff"}, + "artifacts": {"$ref": "#/$defs/entity_diff"} + }, + "additionalProperties": true + }, + "analysis": { + "type": "object", + "required": ["variants", "pain_points", "data_flows"], + "properties": { + "variants": {"$ref": "#/$defs/entity_diff"}, + "pain_points": {"$ref": "#/$defs/entity_diff"}, + "data_flows": {"$ref": "#/$defs/entity_diff"} + }, + "additionalProperties": true + } + }, + "$defs": { + "value_change": { + "type": "object", + "required": ["old", "new"], + "properties": { + "old": {}, + "new": {} + }, + "additionalProperties": true + }, + "field_changes": { + "type": "object", + "additionalProperties": {"$ref": "#/$defs/value_change"} + }, + "entity_diff": { + "type": "object", + "required": ["added", "removed", "changed"], + "properties": { + "added": {"type": "array", "items": {"type": "string"}}, + "removed": {"type": "array", "items": {"type": "string"}}, + "changed": { + "type": "object", + "additionalProperties": {"$ref": "#/$defs/field_changes"} + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/src/process_as_code/cli.py b/src/process_as_code/cli.py index 6329352..bd365aa 100644 --- a/src/process_as_code/cli.py +++ b/src/process_as_code/cli.py @@ -16,6 +16,7 @@ from .jsonld import to_jsonld from .migrate import migrate_process from .observed import compare_observed, load_event_traces, observed_markdown +from .output_contract import output_kinds, output_schema_text from .policy import evaluate_policy, policy_markdown from .process_tests import affected_process_tests, run_process_tests from .raci import extract_raci, raci_markdown @@ -61,6 +62,10 @@ def build_parser() -> argparse.ArgumentParser: if name == "jsonld": cmd.add_argument("--base-uri") + output_schema = sub.add_parser("output-schema", help="print a bundled machine-output JSON Schema") + output_schema.add_argument("kind", choices=output_kinds()) + output_schema.add_argument("-o", "--output") + diff = sub.add_parser("diff", help="semantic diff between two process versions") diff.add_argument("old") diff.add_argument("new") @@ -160,6 +165,9 @@ def main(argv: list[str] | None = None) -> int: return 0 return 1 + if args.command == "output-schema": + _write(output_schema_text(args.kind), args.output) + return 0 if args.command == "diff": change = semantic_diff(load_process(args.old), load_process(args.new)) _write(dump_json(change) if args.json else diff_markdown(change), args.output) diff --git a/src/process_as_code/diff.py b/src/process_as_code/diff.py index b2eca92..2e9b32b 100644 --- a/src/process_as_code/diff.py +++ b/src/process_as_code/diff.py @@ -2,6 +2,8 @@ from typing import Any +from .output_contract import attach_output_contract + SECTIONS = ("steps", "roles", "systems", "objects", "interfaces", "controls", "risks", "evidence", "artifacts") ANALYSIS_SECTIONS = ("variants", "pain_points", "data_flows") @@ -45,7 +47,7 @@ def semantic_diff(old: dict[str, Any], new: dict[str, Any]) -> dict[str, Any]: new_analysis = new.get("analysis", {}) if isinstance(new.get("analysis"), dict) else {} for section in ANALYSIS_SECTIONS: result["analysis"][section] = _diff_items(old_analysis.get(section), new_analysis.get(section)) - return result + return attach_output_contract("semantic-diff", result) def diff_markdown(diff: dict[str, Any]) -> str: diff --git a/src/process_as_code/impact.py b/src/process_as_code/impact.py index cf1e550..d3b66fb 100644 --- a/src/process_as_code/impact.py +++ b/src/process_as_code/impact.py @@ -4,6 +4,7 @@ from typing import Any from .diff import semantic_diff +from .output_contract import attach_output_contract from .refs import resolve_artifacts from .testgen import generate_test_scope @@ -121,7 +122,7 @@ def impact_analysis(old: dict[str, Any], new: dict[str, Any], *, base_dir: str | affected_artifacts = affected["artifacts"] resolved = [r for r in resolved_all if r.get("id") in affected_artifacts] - return { + return attach_output_contract("impact", { "changed_steps": sorted(changed_steps), "analysis_changes": analysis_changes, "analysis_affected_steps": sorted(analysis_affected_steps), @@ -130,7 +131,8 @@ def impact_analysis(old: dict[str, Any], new: dict[str, Any], *, base_dir: str | "recommended_tests": tests, "resolved_artifacts": resolved, "semantic_diff": diff, - } + }) + def impact_markdown(result: dict[str, Any]) -> str: lines = ["# Process change impact", "", "## Changed steps", ""] diff --git a/src/process_as_code/output_contract.py b/src/process_as_code/output_contract.py new file mode 100644 index 0000000..f1e9863 --- /dev/null +++ b/src/process_as_code/output_contract.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import json +from importlib.resources import files +from typing import Any + + +OUTPUT_FORMAT_VERSION = "1.0" + +_OUTPUT_FORMATS = { + "semantic-diff": "process-as-code.semantic-diff", + "impact": "process-as-code.impact", +} + +_OUTPUT_SCHEMA_FILES = { + "semantic-diff": "semantic-diff-v1.schema.json", + "impact": "impact-v1.schema.json", +} + +_OUTPUT_SCHEMA_URLS = { + "semantic-diff": "https://dkharlanau.github.io/process-as-code/schemas/outputs/semantic-diff-v1.schema.json", + "impact": "https://dkharlanau.github.io/process-as-code/schemas/outputs/impact-v1.schema.json", +} + +_RESOURCE_ROOT = files("process_as_code").joinpath("resources/output-schemas") + + +def output_kinds() -> tuple[str, ...]: + """Return stable machine-output contract kinds exposed by the CLI.""" + return tuple(_OUTPUT_FORMATS) + + +def output_metadata(kind: str) -> dict[str, str]: + """Return the compatibility metadata for one machine-readable output kind.""" + try: + format_name = _OUTPUT_FORMATS[kind] + except KeyError as exc: + raise ValueError(f"unknown output contract kind '{kind}'") from exc + return { + "format": format_name, + "version": OUTPUT_FORMAT_VERSION, + "schema": _OUTPUT_SCHEMA_URLS[kind], + } + + +def attach_output_contract(kind: str, payload: dict[str, Any]) -> dict[str, Any]: + """Attach additive output metadata without changing existing payload keys.""" + if "output" in payload: + raise ValueError("machine-readable payload already contains reserved 'output' metadata") + return {"output": output_metadata(kind), **payload} + + +def output_schema_text(kind: str) -> str: + """Return a bundled JSON Schema for a machine-readable output contract.""" + try: + filename = _OUTPUT_SCHEMA_FILES[kind] + except KeyError as exc: + raise ValueError(f"unknown output contract kind '{kind}'") from exc + return _RESOURCE_ROOT.joinpath(filename).read_text(encoding="utf-8") + + +def output_schema_dict(kind: str) -> dict[str, Any]: + """Return a bundled machine-output JSON Schema as a mapping.""" + data = json.loads(output_schema_text(kind)) + if not isinstance(data, dict): + raise ValueError(f"bundled output schema '{kind}' must be a JSON object") + return data diff --git a/src/process_as_code/resources/output-schemas/impact-v1.schema.json b/src/process_as_code/resources/output-schemas/impact-v1.schema.json new file mode 100644 index 0000000..674db1f --- /dev/null +++ b/src/process_as_code/resources/output-schemas/impact-v1.schema.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dkharlanau.github.io/process-as-code/schemas/outputs/impact-v1.schema.json", + "title": "Process as Code change impact output v1", + "type": "object", + "required": [ + "output", + "changed_steps", + "analysis_changes", + "analysis_affected_steps", + "affected", + "risk_flags", + "recommended_tests", + "resolved_artifacts", + "semantic_diff" + ], + "properties": { + "output": { + "type": "object", + "required": ["format", "version", "schema"], + "properties": { + "format": {"const": "process-as-code.impact"}, + "version": {"const": "1.0"}, + "schema": {"const": "https://dkharlanau.github.io/process-as-code/schemas/outputs/impact-v1.schema.json"} + }, + "additionalProperties": true + }, + "changed_steps": {"type": "array", "items": {"type": "string"}}, + "analysis_changes": { + "type": "object", + "required": ["variants", "pain_points", "data_flows"], + "properties": { + "variants": {"type": "array", "items": {"type": "string"}}, + "pain_points": {"type": "array", "items": {"type": "string"}}, + "data_flows": {"type": "array", "items": {"type": "string"}} + }, + "additionalProperties": true + }, + "analysis_affected_steps": {"type": "array", "items": {"type": "string"}}, + "affected": { + "type": "object", + "required": ["roles", "systems", "objects", "interfaces", "controls", "risks", "evidence", "artifacts", "subprocesses"], + "additionalProperties": { + "type": "array", + "items": {"type": "string"} + } + }, + "risk_flags": {"type": "array", "items": {"type": "string"}}, + "recommended_tests": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "step", "type", "scenario"], + "properties": { + "id": {"type": "string"}, + "step": {"type": "string"}, + "type": {"type": "string"}, + "scenario": {"type": "string"} + }, + "additionalProperties": true + } + }, + "resolved_artifacts": { + "type": "array", + "items": {"type": "object"} + }, + "semantic_diff": { + "type": "object", + "required": ["output", "process", "sections", "analysis"], + "properties": { + "output": { + "type": "object", + "required": ["format", "version", "schema"], + "properties": { + "format": {"const": "process-as-code.semantic-diff"}, + "version": {"const": "1.0"}, + "schema": {"const": "https://dkharlanau.github.io/process-as-code/schemas/outputs/semantic-diff-v1.schema.json"} + }, + "additionalProperties": true + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/src/process_as_code/resources/output-schemas/semantic-diff-v1.schema.json b/src/process_as_code/resources/output-schemas/semantic-diff-v1.schema.json new file mode 100644 index 0000000..4696371 --- /dev/null +++ b/src/process_as_code/resources/output-schemas/semantic-diff-v1.schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://dkharlanau.github.io/process-as-code/schemas/outputs/semantic-diff-v1.schema.json", + "title": "Process as Code semantic diff output v1", + "type": "object", + "required": ["output", "process", "sections", "analysis"], + "properties": { + "output": { + "type": "object", + "required": ["format", "version", "schema"], + "properties": { + "format": {"const": "process-as-code.semantic-diff"}, + "version": {"const": "1.0"}, + "schema": {"const": "https://dkharlanau.github.io/process-as-code/schemas/outputs/semantic-diff-v1.schema.json"} + }, + "additionalProperties": true + }, + "process": { + "type": "object", + "additionalProperties": {"$ref": "#/$defs/value_change"} + }, + "sections": { + "type": "object", + "required": ["steps", "roles", "systems", "objects", "interfaces", "controls", "risks", "evidence", "artifacts"], + "properties": { + "steps": {"$ref": "#/$defs/entity_diff"}, + "roles": {"$ref": "#/$defs/entity_diff"}, + "systems": {"$ref": "#/$defs/entity_diff"}, + "objects": {"$ref": "#/$defs/entity_diff"}, + "interfaces": {"$ref": "#/$defs/entity_diff"}, + "controls": {"$ref": "#/$defs/entity_diff"}, + "risks": {"$ref": "#/$defs/entity_diff"}, + "evidence": {"$ref": "#/$defs/entity_diff"}, + "artifacts": {"$ref": "#/$defs/entity_diff"} + }, + "additionalProperties": true + }, + "analysis": { + "type": "object", + "required": ["variants", "pain_points", "data_flows"], + "properties": { + "variants": {"$ref": "#/$defs/entity_diff"}, + "pain_points": {"$ref": "#/$defs/entity_diff"}, + "data_flows": {"$ref": "#/$defs/entity_diff"} + }, + "additionalProperties": true + } + }, + "$defs": { + "value_change": { + "type": "object", + "required": ["old", "new"], + "properties": { + "old": {}, + "new": {} + }, + "additionalProperties": true + }, + "field_changes": { + "type": "object", + "additionalProperties": {"$ref": "#/$defs/value_change"} + }, + "entity_diff": { + "type": "object", + "required": ["added", "removed", "changed"], + "properties": { + "added": {"type": "array", "items": {"type": "string"}}, + "removed": {"type": "array", "items": {"type": "string"}}, + "changed": { + "type": "object", + "additionalProperties": {"$ref": "#/$defs/field_changes"} + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/tests/test_output_contracts.py b/tests/test_output_contracts.py new file mode 100644 index 0000000..4a32780 --- /dev/null +++ b/tests/test_output_contracts.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from jsonschema import Draft202012Validator + +from process_as_code.cli import main +from process_as_code.diff import semantic_diff +from process_as_code.impact import impact_analysis +from process_as_code.io import load_process +from process_as_code.output_contract import ( + OUTPUT_FORMAT_VERSION, + output_metadata, + output_schema_dict, + output_schema_text, +) + + +ROOT = Path(__file__).resolve().parents[1] +OLD = ROOT / "examples/customer-creation.process.yaml" +NEW = ROOT / "examples/changes/customer-creation-v2.process.yaml" + +SCHEMAS = { + "semantic-diff": "semantic-diff-v1.schema.json", + "impact": "impact-v1.schema.json", +} + + +def test_semantic_diff_has_stable_output_contract() -> None: + result = semantic_diff(load_process(OLD), load_process(NEW)) + + assert result["output"] == { + "format": "process-as-code.semantic-diff", + "version": "1.0", + "schema": "https://dkharlanau.github.io/process-as-code/schemas/outputs/semantic-diff-v1.schema.json", + } + assert {"process", "sections", "analysis"} <= set(result) + Draft202012Validator(output_schema_dict("semantic-diff")).validate(result) + + +def test_impact_has_stable_output_contract_and_tagged_nested_diff() -> None: + result = impact_analysis(load_process(OLD), load_process(NEW)) + + assert result["output"] == { + "format": "process-as-code.impact", + "version": "1.0", + "schema": "https://dkharlanau.github.io/process-as-code/schemas/outputs/impact-v1.schema.json", + } + assert {"changed_steps", "affected", "risk_flags", "recommended_tests", "semantic_diff"} <= set(result) + assert result["semantic_diff"]["output"]["format"] == "process-as-code.semantic-diff" + Draft202012Validator(output_schema_dict("impact")).validate(result) + + +def test_output_schema_source_and_package_copies_match() -> None: + for kind, filename in SCHEMAS.items(): + source = (ROOT / "schemas" / "outputs" / filename).read_text(encoding="utf-8") + assert output_schema_text(kind) == source + parsed = json.loads(source) + assert parsed["properties"]["output"]["properties"]["version"]["const"] == OUTPUT_FORMAT_VERSION + + +def test_output_contract_metadata_is_explicit() -> None: + assert output_metadata("semantic-diff")["format"] != output_metadata("impact")["format"] + assert output_metadata("impact")["version"] == OUTPUT_FORMAT_VERSION + + +def test_cli_exports_bundled_output_schema(tmp_path: Path) -> None: + target = tmp_path / "impact.schema.json" + assert main(["output-schema", "impact", "-o", str(target)]) == 0 + assert target.read_text(encoding="utf-8") == output_schema_text("impact") + + +def test_cli_diff_and_impact_json_keep_contract_metadata(tmp_path: Path) -> None: + diff_target = tmp_path / "diff.json" + impact_target = tmp_path / "impact.json" + + assert main(["diff", str(OLD), str(NEW), "--json", "-o", str(diff_target)]) == 0 + assert main(["impact", str(OLD), str(NEW), "--json", "-o", str(impact_target)]) == 0 + + diff_payload = json.loads(diff_target.read_text(encoding="utf-8")) + impact_payload = json.loads(impact_target.read_text(encoding="utf-8")) + assert diff_payload["output"]["format"] == "process-as-code.semantic-diff" + assert impact_payload["output"]["format"] == "process-as-code.impact" + assert impact_payload["semantic_diff"]["output"] == diff_payload["output"]