diff --git a/.github/workflows/oci.yml b/.github/workflows/oci.yml index e26dc11..9801dbb 100644 --- a/.github/workflows/oci.yml +++ b/.github/workflows/oci.yml @@ -43,3 +43,19 @@ jobs: push: ${{ github.event_name == 'push' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + + + qualify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build qualification image + run: docker build -t storyforge:qualification . + - name: CLI starts as packaged + run: docker run --rm storyforge:qualification --help + - name: Validate Lighthouse in container + run: docker run --rm -v "$PWD:/work:ro" storyforge:qualification validate examples/lighthouse/story.yaml + - name: Solve Lighthouse in container + run: docker run --rm -v "$PWD:/work:ro" storyforge:qualification solve examples/lighthouse/story.yaml + - name: Analyze Lighthouse in container + run: docker run --rm -v "$PWD:/work:ro" storyforge:qualification analyze examples/lighthouse/story.yaml diff --git a/docs/oci-qualification.md b/docs/oci-qualification.md new file mode 100644 index 0000000..d6e226a --- /dev/null +++ b/docs/oci-qualification.md @@ -0,0 +1,15 @@ +# OCI qualification + +The OCI workflow does more than prove that the image can be assembled. A separate qualification job builds the packaged image and executes StoryForge inside it. + +The qualification currently proves: + +- the installed CLI starts through the image entrypoint; +- the Lighthouse reference story validates; +- the deterministic solver completes against Lighthouse; +- deterministic analysis completes against Lighthouse; +- the repository is mounted read-only during reference-story checks. + +This catches packaging errors that Python unit tests cannot see, including missing package data, broken console entrypoints, dependency omissions, and container-only path problems. + +AI-backed network providers are intentionally excluded from OCI qualification. Their nondeterminism, credentials, and external availability should not decide whether the deterministic StoryForge runtime image is healthy. diff --git a/pyproject.toml b/pyproject.toml index c87ed03..07067f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,3 +19,6 @@ storyforge = "storyforge.cli:main" [tool.pytest.ini_options] pythonpath = ["src"] testpaths = ["tests"] + +[tool.setuptools.package-data] +storyforge = ["schemas/*.json"] diff --git a/src/storyforge/schema.py b/src/storyforge/schema.py index 9f13e98..b0ef188 100644 --- a/src/storyforge/schema.py +++ b/src/storyforge/schema.py @@ -1,15 +1,16 @@ import json -from pathlib import Path +from importlib.resources import files + from jsonschema import Draft202012Validator -def _schema_path() -> Path: - return Path(__file__).resolve().parents[2] / "schemas" / "storyforge-v0.schema.json" +def _load_schema() -> dict: + resource = files("storyforge").joinpath("schemas/storyforge-v0.schema.json") + return json.loads(resource.read_text(encoding="utf-8")) def validate_schema(story: dict) -> list[str]: - schema = json.loads(_schema_path().read_text(encoding="utf-8")) - validator = Draft202012Validator(schema) + validator = Draft202012Validator(_load_schema()) errors = sorted(validator.iter_errors(story), key=lambda error: list(error.path)) return [ "schema: " + (".".join(map(str, error.path)) + ": " if error.path else "") + error.message diff --git a/src/storyforge/schemas/storyforge-v0.schema.json b/src/storyforge/schemas/storyforge-v0.schema.json new file mode 100644 index 0000000..545cf41 --- /dev/null +++ b/src/storyforge/schemas/storyforge-v0.schema.json @@ -0,0 +1,1030 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ploos.no/storyforge/schema/v0", + "title": "StoryForge IR v0", + "type": "object", + "required": [ + "storyforge", + "start", + "scenes", + "endings" + ], + "properties": { + "storyforge": { + "const": "0" + }, + "metadata": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1 + }, + "profile": { + "enum": [ + "visual-novel", + "point-and-click", + "text-adventure" + ] + }, + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$" + } + }, + "additionalProperties": true + }, + "start": { + "type": "string", + "minLength": 1 + }, + "characters": { + "type": "object" + }, + "locations": { + "type": "object" + }, + "items": { + "type": "object" + }, + "variables": { + "type": "object", + "additionalProperties": { + "type": [ + "boolean", + "integer", + "number", + "string" + ] + } + }, + "scenes": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "choices": { + "type": "array", + "items": { + "type": "object", + "required": [ + "text" + ], + "properties": { + "text": { + "type": "string", + "minLength": 1 + }, + "goto": { + "type": "string", + "minLength": 1 + }, + "ending": { + "type": "string", + "minLength": 1 + }, + "requires": { + "type": "array", + "items": { + "type": "object", + "properties": { + "variable": { + "type": "string", + "minLength": 1 + }, + "equals": { + "type": [ + "boolean", + "integer", + "number", + "string" + ] + }, + "has_item": { + "type": "string", + "minLength": 1 + }, + "lacks_item": { + "type": "string", + "minLength": 1 + }, + "owned_by": { + "type": "object", + "required": [ + "item", + "character" + ], + "properties": { + "item": { + "type": "string", + "minLength": 1 + }, + "character": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "character": { + "type": "object", + "required": [ + "id", + "attribute", + "equals" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "attribute": { + "type": "string", + "minLength": 1 + }, + "equals": { + "type": [ + "boolean", + "integer", + "number", + "string" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "oneOf": [ + { + "required": [ + "variable", + "equals" + ], + "not": { + "anyOf": [ + { + "required": [ + "has_item" + ] + }, + { + "required": [ + "lacks_item" + ] + } + ] + } + }, + { + "required": [ + "has_item" + ], + "not": { + "anyOf": [ + { + "required": [ + "variable" + ] + }, + { + "required": [ + "equals" + ] + }, + { + "required": [ + "lacks_item" + ] + } + ] + } + }, + { + "required": [ + "lacks_item" + ], + "not": { + "anyOf": [ + { + "required": [ + "variable" + ] + }, + { + "required": [ + "equals" + ] + }, + { + "required": [ + "has_item" + ] + } + ] + } + }, + { + "required": [ + "owned_by" + ], + "not": { + "anyOf": [ + { + "required": [ + "variable" + ] + }, + { + "required": [ + "equals" + ] + }, + { + "required": [ + "has_item" + ] + }, + { + "required": [ + "lacks_item" + ] + } + ] + } + }, + { + "required": [ + "character" + ], + "not": { + "anyOf": [ + { + "required": [ + "variable" + ] + }, + { + "required": [ + "equals" + ] + }, + { + "required": [ + "has_item" + ] + }, + { + "required": [ + "lacks_item" + ] + }, + { + "required": [ + "owned_by" + ] + } + ] + } + } + ] + } + }, + "effects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "variable": { + "type": "string", + "minLength": 1 + }, + "set": { + "type": [ + "boolean", + "integer", + "number", + "string" + ] + }, + "increment": { + "type": "number" + }, + "decrement": { + "type": "number" + }, + "take_item": { + "type": "string", + "minLength": 1 + }, + "drop_item": { + "type": "string", + "minLength": 1 + }, + "give_item": { + "type": "object", + "required": [ + "item", + "character" + ], + "properties": { + "item": { + "type": "string", + "minLength": 1 + }, + "character": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "character": { + "type": "object", + "required": [ + "id", + "attribute" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "attribute": { + "type": "string", + "minLength": 1 + }, + "set": { + "type": [ + "boolean", + "integer", + "number", + "string" + ] + }, + "increment": { + "type": "number" + }, + "decrement": { + "type": "number" + } + }, + "oneOf": [ + { + "required": [ + "set" + ] + }, + { + "required": [ + "increment" + ] + }, + { + "required": [ + "decrement" + ] + } + ], + "additionalProperties": false + } + }, + "oneOf": [ + { + "required": [ + "variable", + "set" + ], + "not": { + "anyOf": [ + { + "required": [ + "increment" + ] + }, + { + "required": [ + "decrement" + ] + }, + { + "required": [ + "take_item" + ] + }, + { + "required": [ + "drop_item" + ] + } + ] + } + }, + { + "required": [ + "variable", + "increment" + ], + "not": { + "anyOf": [ + { + "required": [ + "set" + ] + }, + { + "required": [ + "decrement" + ] + }, + { + "required": [ + "take_item" + ] + }, + { + "required": [ + "drop_item" + ] + } + ] + } + }, + { + "required": [ + "variable", + "decrement" + ], + "not": { + "anyOf": [ + { + "required": [ + "set" + ] + }, + { + "required": [ + "increment" + ] + }, + { + "required": [ + "take_item" + ] + }, + { + "required": [ + "drop_item" + ] + } + ] + } + }, + { + "required": [ + "take_item" + ], + "not": { + "anyOf": [ + { + "required": [ + "variable" + ] + }, + { + "required": [ + "set" + ] + }, + { + "required": [ + "increment" + ] + }, + { + "required": [ + "decrement" + ] + }, + { + "required": [ + "drop_item" + ] + } + ] + } + }, + { + "required": [ + "drop_item" + ], + "not": { + "anyOf": [ + { + "required": [ + "variable" + ] + }, + { + "required": [ + "set" + ] + }, + { + "required": [ + "increment" + ] + }, + { + "required": [ + "decrement" + ] + }, + { + "required": [ + "take_item" + ] + } + ] + } + }, + { + "required": [ + "give_item" + ], + "not": { + "anyOf": [ + { + "required": [ + "variable" + ] + }, + { + "required": [ + "set" + ] + }, + { + "required": [ + "increment" + ] + }, + { + "required": [ + "decrement" + ] + }, + { + "required": [ + "take_item" + ] + }, + { + "required": [ + "drop_item" + ] + } + ] + } + }, + { + "required": [ + "character" + ], + "not": { + "anyOf": [ + { + "required": [ + "variable" + ] + }, + { + "required": [ + "set" + ] + }, + { + "required": [ + "increment" + ] + }, + { + "required": [ + "decrement" + ] + }, + { + "required": [ + "take_item" + ] + }, + { + "required": [ + "drop_item" + ] + }, + { + "required": [ + "give_item" + ] + } + ] + } + } + ], + "additionalProperties": false + } + }, + "action": { + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "item" + ], + "properties": { + "type": { + "const": "take" + }, + "item": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "item" + ], + "properties": { + "type": { + "const": "drop" + }, + "item": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "item" + ], + "properties": { + "type": { + "const": "use" + }, + "item": { + "type": "string", + "minLength": 1 + }, + "effects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "variable": { + "type": "string", + "minLength": 1 + }, + "set": { + "type": [ + "boolean", + "integer", + "number", + "string" + ] + }, + "increment": { + "type": "number" + }, + "decrement": { + "type": "number" + }, + "take_item": { + "type": "string", + "minLength": 1 + }, + "drop_item": { + "type": "string", + "minLength": 1 + } + }, + "oneOf": [ + { + "required": [ + "variable", + "set" + ], + "not": { + "anyOf": [ + { + "required": [ + "increment" + ] + }, + { + "required": [ + "decrement" + ] + }, + { + "required": [ + "take_item" + ] + }, + { + "required": [ + "drop_item" + ] + } + ] + } + }, + { + "required": [ + "variable", + "increment" + ], + "not": { + "anyOf": [ + { + "required": [ + "set" + ] + }, + { + "required": [ + "decrement" + ] + }, + { + "required": [ + "take_item" + ] + }, + { + "required": [ + "drop_item" + ] + } + ] + } + }, + { + "required": [ + "variable", + "decrement" + ], + "not": { + "anyOf": [ + { + "required": [ + "set" + ] + }, + { + "required": [ + "increment" + ] + }, + { + "required": [ + "take_item" + ] + }, + { + "required": [ + "drop_item" + ] + } + ] + } + }, + { + "required": [ + "take_item" + ], + "not": { + "anyOf": [ + { + "required": [ + "variable" + ] + }, + { + "required": [ + "set" + ] + }, + { + "required": [ + "increment" + ] + }, + { + "required": [ + "decrement" + ] + }, + { + "required": [ + "drop_item" + ] + } + ] + } + }, + { + "required": [ + "drop_item" + ], + "not": { + "anyOf": [ + { + "required": [ + "variable" + ] + }, + { + "required": [ + "set" + ] + }, + { + "required": [ + "increment" + ] + }, + { + "required": [ + "decrement" + ] + }, + { + "required": [ + "take_item" + ] + } + ] + } + } + ], + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "item", + "character" + ], + "properties": { + "type": { + "const": "give" + }, + "item": { + "type": "string", + "minLength": 1 + }, + "character": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "type", + "left", + "right", + "result" + ], + "properties": { + "type": { + "const": "combine" + }, + "left": { + "type": "string", + "minLength": 1 + }, + "right": { + "type": "string", + "minLength": 1 + }, + "result": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + } + ] + }, + "commands": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "id": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9_-]*$" + } + }, + "oneOf": [ + { + "required": [ + "goto" + ], + "not": { + "required": [ + "ending" + ] + } + }, + { + "required": [ + "ending" + ], + "not": { + "required": [ + "goto" + ] + } + } + ], + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + }, + "endings": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "inventory": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "ownership": { + "type": "object", + "additionalProperties": { + "type": "string", + "minLength": 1 + } + }, + "character_state": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": [ + "boolean", + "integer", + "number", + "string" + ] + } + } + } + }, + "additionalProperties": false +}