Metadata-Version: 2.4
Name: validate-spine
Version: 0.2.0
Summary: Comprehensive validator for Esoteric Software Spine atlas + JSON exports. Built for AI spine-generation pipelines.
Author-email: pypisl <pypisl@broit.io>
License: MIT
Keywords: ai-pipeline,animation,atlas,esoteric,spine,validator
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Multimedia :: Graphics
Classifier: Topic :: Software Development :: Quality Assurance
Requires-Python: >=3.10
Requires-Dist: pillow>=10.0.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == 'dev'
Description-Content-Type: text/markdown

# validate-spine

Comprehensive validator for **Esoteric Software Spine** atlas + JSON exports. Built so a Claude agent inside an AI spine-building pipeline can `uvx`-invoke it on any generated `(skeleton.json, skeleton.atlas, image.{png,webp})` triple and get back **233 atomic pass/fail checks** with actionable error messages.

> If a check fails, the agent has all it needs to fix the file and re-run. If everything passes, the spine is structurally and cross-referentially correct.

## Install / invoke

The intended invocation, from inside the agent loop, is `uvx`:

```bash
# From a local clone (most common during pipeline development):
uvx --from /path/to/validate-spine validate-spine path/to/skeleton.json

# With explicit atlas (skip auto-discovery):
uvx --from /path/to/validate-spine validate-spine path/to/skel.json --atlas path/to/skel.atlas

# Validate every JSON+atlas pair under a directory:
uvx --from /path/to/validate-spine validate-spine --dir path/to/spine_outputs/

# Machine-readable JSON output (pipe to jq, feed back to the agent, etc.):
uvx --from /path/to/validate-spine validate-spine path/to/skel.json --json

# Errors-only output — suppress passing/skipped checks (works with both text and --json):
uvx --from /path/to/validate-spine validate-spine path/to/skel.json --errors-only --json

# List every registered check (useful for prompt engineering):
uvx --from /path/to/validate-spine validate-spine --list-checks
```

The `--errors-only` flag is the recommended invocation for AI agents: a clean spine produces an empty `checks` array (or just a header in text mode), so the agent immediately sees only what needs fixing.

Atlas auto-discovery: if you only pass a `.json`, the validator looks for a matching `.atlas` (a) in the same directory, (b) in `1/`, `0.75/`, `0.5/`, `0.25/` scale subdirectories, (c) any `.atlas` in the same dir, (d) any `.atlas` in any subdir.

## Exit codes

| code | meaning |
|------|---------|
| `0`  | every check passed (warnings allowed) |
| `1`  | at least one check produced an `ERROR` |
| `2`  | only with `--warnings-as-errors`: warnings present |

The agent should treat exit `1` as "fix and re-validate" and exit `0` as "ship".

## What it checks (233 atomic tests)

Each check is independent, has a stable `code` (e.g. `bones.names_unique`), and is referenced by a sequential `test_id` (`T001`..`T233`). Categories:

| category    | # | examples |
|-------------|---|----------|
| `files`     |  9 | JSON exists, UTF-8, no BOM, atlas line endings consistent, image file present |
| `json`      |  9 | parses, no trailing commas, root is object, required top-level keys present, no typos |
| `skeleton`  | 13 | spine version format, hash present, finite x/y/w/h, fps positive, no `\\` in paths |
| `bones`     | 15 | root present, names unique, parents resolve, no cycles, transform mode valid, finite numerics |
| `slots`     | 10 | names unique, bone resolves, blend mode valid, color hex format, default attachment exists, unused warning |
| `skins`     | 35 | default skin, attachment slot resolves, mesh uvs even, triangle/hull/edge indices in range, **weighted mesh vertex format / bone-index range / weight non-negativity / weight-sum ≈ 1**, **linkedmesh parent + cross-skin + inherit flags**, **Spine 4.2 sequence attachments**, **hallucinated nested keys**, **suspicious-range warnings** |
| `animations`| 38 | timeline target resolution, monotonic times, bezier length/finite/**unit-range/monotonic-handles**, stepped curve, color formats, **IK/transform/path/physics constraint timelines**, event override types, **unknown timeline category warning**, zero-duration & no-zero-keyframe warnings |
| `events`    |  7 | int/float/string default types, audio path slashes, volume range |
| `constraints`| 18| IK/transform/path: target & bones resolve, mix in [0,1], modes valid |
| `atlas`     | 28 | size/filter/format/repeat/scale/pma valid, region bounds in page, offsets consistent, names unique, no path separators, **nine-patch split/pad format & bounds**, **rotate dimension consistency**, **no BOM, no tab indentation** |
| `images`    |  8 | image loads via Pillow, dims match `size:`, format known, extension matches format, POT warning, **alpha-channel-when-pma**, **PMA pixel-invariant sampling**, **at-least-one-visible-pixel** |
| `crossref`  | 13 | JSON ↔ atlas region resolution, attachment dims match atlas (scale-aware), basename consistency, scale factor matches dir, **sequence frame resolution**, **rotate-aware dim swap**, **skin.bones / skin.{ik,transform,path,physics} constraint refs** |
| `types`     | 30 | **type-coercion (string-where-number, color-as-int, bool-as-string)**, **hallucinated keys per skeleton/bone/slot/skin/constraint/event/physics**, **value-range warnings (rotation, scale, position)**, **Y-up coordinate-system heuristic**, **constraint-order uniqueness**, **scientific-notation warning**, **Spine 4.2 physics constraints (bone, strength, damping, mass)** |

The new categories (and bolded items above) target the failure modes AI generators actually produce.

Run `validate-spine --list-checks` to enumerate them all with descriptions.

## Output

### Human (default)

```
validate-spine v0.1.0
  json:  /tmp/skel.json
  atlas: /tmp/skel.atlas

running 163 checks...

  [✓] T001  files.json_path_exists                            JSON file exists
  [✓] T002  files.atlas_path_exists                           Atlas file exists
  ...
  [✗] T087  atlas.region_bounds_in_page                       Region bounds fit inside page size
        ↳ ERROR  region 'sparkle': bounds (998,138,200,200) exceed page 1024x1024
  [!] T100  crossref.atlas_regions_used_by_json               Every atlas region is referenced from the JSON
        ↳ WARN   atlas region 'unused_thing' is not referenced by any attachment

161 passed, 1 failed, 1 with warnings, 0 skipped (163 total)
```

### Machine (`--json`)

```json
{
  "json_path": "/tmp/skel.json",
  "atlas_path": "/tmp/skel.atlas",
  "summary": {
    "total": 163, "passed": 161, "failed": 1,
    "warnings": 1, "errors": 1, "skipped": 0
  },
  "checks": [
    {
      "test_id": "T087",
      "code": "atlas.region_bounds_in_page",
      "name": "Region bounds fit inside page size",
      "description": "x+width <= page width, y+height <= page height.",
      "category": "atlas",
      "passed": false,
      "duration_ms": 0.041,
      "issues": [
        {
          "severity": "error",
          "message": "region 'sparkle': bounds (998,138,200,200) exceed page 1024x1024",
          "path": ""
        }
      ]
    }
  ]
}
```

The `code` field is the agent's primary handle: it stays stable across releases even if `test_id` numbers shift when checks are added.

## Recommended agent loop

```python
import json, subprocess

def validate(skel_path: str) -> dict:
    out = subprocess.run(
        ["uvx", "--from", "/path/to/validate-spine",
         "validate-spine", skel_path, "--json"],
        capture_output=True, text=True
    )
    return json.loads(out.stdout)

report = validate("./generated/skel.json")
if report["summary"]["failed"] == 0:
    return  # ship
for chk in report["checks"]:
    if not chk["passed"] and not chk["skipped"]:
        # The 'code' tells the agent which check; messages are actionable
        for issue in chk["issues"]:
            if issue["severity"] == "error":
                fix(skel_path, chk["code"], issue["message"])
```

## Severity

* `ERROR` — runtime would fail or render incorrectly (atlas bounds off-page, mesh triangle indices out of range, JSON unparseable). Causes exit `1`.
* `WARNING` — best-practice issue (BOM in JSON, atlas regions unused by JSON, animation has zero duration, image isn't power-of-two). Doesn't fail unless `--warnings-as-errors`.
* `INFO` — currently unused, reserved for future advisory checks.

## Dev: running the test suite

One-time setup:

```bash
uv venv && uv pip install -e '.[dev]'
```

Each mutation test takes the clean fixture (`tests/fixtures/animation_anticipation.{json,atlas,webp}`), breaks one specific aspect, and asserts the corresponding check produces an error or warning. A meta-test (`tests/test_coverage_matrix.py`) asserts every registered check is exercised by at least one mutation — adding a new check without a corresponding mutation test fails the build.

### Pre-push smoke test

Run this every time before pushing — three commands chained, ~3 seconds:

```bash
.venv/bin/pytest tests/ -q && \
.venv/bin/validate-spine tests/fixtures/animation_anticipation.json --no-color | tail -1 && \
uvx --refresh --from . validate-spine --version
```

Expected output (in order):
- `241 passed`
- `233 passed, 0 failed, 0 with warnings, 0 skipped (233 total)`
- `validate-spine 0.2.0`

If any of those three lines doesn't appear, **don't push** — investigate first.

### Detailed local validation commands

**1. Mutation pytest suite (verifies every check fires correctly):**

```bash
.venv/bin/pytest tests/ -q
```

**2. Validate a known-clean spine — should pass 233/233:**

```bash
.venv/bin/validate-spine tests/fixtures/animation_anticipation.json --no-color | tail -1
```

**3. Validate a known-broken spine and confirm exit code is 1:**

```bash
.venv/bin/validate-spine \
  sample_spines/clover-hoard-develop-game-assets-main.bundle-spine/game/assets/main.bundle/spine/symbols/animation_a.json \
  --errors-only --no-color
echo "exit code: $?"   # should be 1
```

The `sample_spines/` corpus (unpacked from the upstream bundle for testing) is committed to the repo but **excluded from the PyPI sdist + wheel** via `pyproject.toml` so installs stay tiny.

**4. Simulate the AI-agent loop (machine-readable JSON, errors-only):**

```bash
# Clean spine: empty checks array, summary.failed == 0
.venv/bin/validate-spine tests/fixtures/animation_anticipation.json --errors-only --json \
  | jq '.summary, (.checks | length)'

# Broken spine: list of {code, severity, message} per failure
.venv/bin/validate-spine \
  sample_spines/clover-hoard-develop-game-assets-main.bundle-spine/game/assets/main.bundle/spine/symbols/animation_a.json \
  --errors-only --json \
  | jq '[.checks[] | {code, severity: .issues[0].severity, msg: .issues[0].message}]'
```

**5. Validate every pair under the unpacked sample tree (catches regressions across all formats):**

```bash
.venv/bin/validate-spine --dir sample_spines --no-color | tail -1
```

Expected: `26 pairs validated, 21 failures, 51 with warnings`. The lower-scale atlases in the sample corpus are *genuinely malformed* (region bounds exceed page size); the same numbers must repeat each run. **If they change, you broke or regressed something.**

**6. End-to-end via `uvx` — the production invocation the agent uses:**

```bash
uvx --refresh --from . validate-spine tests/fixtures/animation_anticipation.json --no-color | tail -1
```

`--refresh` forces a wheel rebuild — required after any source change because uvx caches the build per project path.

**7. Sanity-check check registration:**

```bash
.venv/bin/validate-spine --list-checks | wc -l   # should print: 233
```

### Verifying PyPI build excludes spine files

Before publishing (see [Publishing](#publishing)), confirm the build artifacts contain no spine binaries:

```bash
rm -rf dist && uv build
unzip -l dist/validate_spine-*.whl | grep -E '\.(atlas|webp|png|json)$' || echo "wheel: no spine files ✓"
tar tzf dist/validate_spine-*.tar.gz | grep -E '\.(atlas|webp|png)$' || echo "sdist: no spine files ✓"
```

Both lines should print `... no spine files ✓`. If either prints filenames instead, fix `[tool.hatch.build.targets.sdist]` `exclude` in `pyproject.toml` before publishing.

## Publishing

See [How does one publish it?](#) — short version:

```bash
# GitHub-only (no PyPI account):
git push origin main
# Agent then uses: uvx --from git+https://github.com/USER/validate-spine validate-spine ...

# PyPI:
uv build && uv publish    # needs UV_PUBLISH_TOKEN env var or interactive token
```

Bump `version` in `pyproject.toml` AND `__version__` in `src/validate_spine/__init__.py` for each release. PyPI version numbers are permanent — they cannot be re-uploaded even after yanking.

## Format coverage

Built and verified against Spine **4.2.43** exports (Esoteric's libgdx-format atlas + JSON skeleton). Older 3.x exports parse but some 4.x-only fields (e.g. `skins` as array, `bounds:`/`offsets:` atlas keys) won't appear. Tested attachment types: `region`, `mesh`, `linkedmesh`, `boundingbox`, `path`, `point`, `clipping`. Tested constraints: `ik`, `transform`, `path`. Tested timeline categories: `slots.attachment/rgba/rgba2/color`, `bones.rotate/translate/scale/shear`, `events`, `drawOrder`, `deform`.
