TEST_FORMAT_EFFECT_SCHEMA_PLAN

Rebase test-format validation onto Effect Schema

Replaces Ajv + JSON-Schema path introduced by the wf_test_schema PR. Motivation: @galaxy-tool-util/schema already standardizes on Effect Schema (parameter bundles, workflow formats, normalized/expanded shapes, stateful validation). Adding an Ajv runtime path is a separate validator stack with its own diagnostic shape, its own CJS/ESM interop hazards (see the Ajv2020Import as unknown as cast in validate.ts), and no shared idioms with the rest of the package. Effect Schema makes downstream consumers (VS Code plugin bundling, web server) uniform and drops the Ajv dep.

Source of truth

Python-side Pydantic galaxy.tool_util_models.Tests stays authoritative. We stop vendoring the JSON Schema as the runtime artifact and instead treat it as a sync input to an Effect Schema generator. Options below.

Strategy options (pick one)

A. Generate Effect Schema from the Pydantic-emitted JSON Schema

Pipeline: Tests.model_json_schema()tests.schema.json (keep, as sync input) → small codegen script → tests.generated.ts (Effect). Runtime imports the generated Effect module.

B. Generate Effect Schema from Pydantic models directly

Write a Python generator that walks Tests.model_fields and emits Effect TS. Similar to the existing schema-salad-plus-pydantic tool but pydantic-in instead of salad-in. Could live as a small script colocated with dump-test-format-schema.py, or upstreamed.

C. Hand-port to Effect Schema

Write test-format/tests.effect.ts by hand, mirroring the ~20 Pydantic classes in test_job.py + tool_outputs.py.

Recommendation: A. Keeps the Python-authoritative story (make sync-test-format-schema still runs Tests.model_json_schema()), adds a codegen step analogous to the generate-schemas target, and leaves a clean checksum/verify loop. The JSON Schema becomes an intermediate artifact, not a runtime dep.

Work items (assumes option A)

  1. Codegen script scripts/jsonschema-to-effect.mjs. Input: tests.schema.json. Output: packages/schema/src/test-format/tests.generated.ts. Handlers needed for the Pydantic-emitted subset:
    • type: string | number | integer | boolean | nullSchema.String / Schema.Number / Schema.Int / Schema.Boolean / Schema.Null
    • type: array + itemsSchema.Array(itemSchema)
    • type: object + properties + required + additionalProperties: falseSchema.Struct(...) with optional fields via Schema.optional
    • type: object + additionalProperties: <schema>Schema.Record
    • const: XSchema.Literal(X)
    • enum: [...]Schema.Literal(...values)
    • anyOf: [schema, {type: null}] (Pydantic’s Optional[T]) → Schema.NullOr
    • anyOf / oneOf general → Schema.Union
    • $ref: "#/$defs/X" → reference emitted symbol; for cycles use Schema.suspend(() => XSchema)
    • $defs → top-level const XSchema = Schema.Struct(...) exports, topologically sorted with suspend for cycles
    • title → JSDoc on emitted const
    • default: ... — ignore for validation; Effect’s decode-with-defaults is a separate concern we don’t need here (validator only flags typing/structural errors)
  2. validateTestsFile rewrite. Replace Ajv body with Schema.decodeUnknownEither(TestsSchema)(parsed) and map ParseResult.ParseError into the existing TestFormatDiagnostic shape.
    • Path format: Effect yields a ReadonlyArray<PropertyKey> path. Convert to json-pointer (/0/job/foo) to match what Ajv produced — keeps CLI snapshot tests stable.
    • Keyword mapping: Ajv’s keyword values (required, additionalProperties, type, const, enum) don’t map 1:1 to Effect’s ParseIssue tags (Missing, Unexpected, Type, Composite, Refinement, Pointer). Define a small issueToKeyword(issue) translation. Cross-check tests rely on keyword, so keep the vocabulary (required, unknown_property, type, literal).
    • Message: Effect gives structured issues; format to match the Ajv string where it’s cheap, diverge where it’s clearer. Snapshot tests get updated accordingly (one-time churn).
  3. Drop Ajv + ajv-formats from packages/schema/package.json. Remove the CJS-interop shim in the old validate.ts. Delete tests.schema.generated.ts (the as const TS wrapper around the JSON) — the JSON is now only a codegen input, not a runtime import. Keep tests.schema.json + its .sha256 on disk for debugging / external consumers.
  4. Make target. Add generate-test-format-schema (or fold into existing generate-schemas): runs the codegen script from the checked-in JSON. sync-test-format-schema continues to dump the JSON + sha; the codegen step runs after. make check runs the codegen in dry-run + diff mode so CI catches a desynced tests.generated.ts (same pattern as verify-test-format-schema today).
  5. Re-export surface. @galaxy-tool-util/schema exports:
    • validateTestsFile(parsed): { valid, errors: TestFormatDiagnostic[] } (unchanged API)
    • TestsSchema (Effect) — lets advanced consumers decode/encode directly
    • Keep testsSchema JSON export for plugin consumers that still want raw JSON Schema (VS Code plugin currently uses JSON Schema for the YAML-language-server association — we shouldn’t break that). testsSchema becomes a raw JSON import, not a .generated.ts re-export.
  6. Tests. Existing fixtures under packages/schema/test/fixtures/test-format/ keep their positive/negative roles. Port test-format.test.ts assertions:
    • Positive fixtures: assert valid === true
    • Negative fixtures: assert presence of a specific keyword + path — update expected keyword strings for the Effect vocabulary
    • New: a snapshot test pinning the emitted tests.generated.ts header + a few representative schema consts (catches codegen regressions)
  7. CLI. validate-tests / validate-tests-tree output format is determined by diagnostic shape. Keep the TestFormatDiagnostic fields (path, message, keyword, params). CLI tests get snapshot refreshes where messages differ; structure is unchanged.
  8. Changeset. Amend the existing test-format-validation.md changeset to describe the Effect-Schema path (same minor bump — this all lands on one PR before any release). Note removal of ajv + ajv-formats from the schema package’s dep list.

Cross-check (inputs/outputs) — implications

Plan item from TEST_JOB_VALIDATION_TS_FOLLOWUP_PLAN.md was going to share TestFormatDiagnostic shape. Still works: cross-check is still a plain programmatic walk, emits diagnostics with the same shape. Its keyword vocabulary (workflow_input_missing, etc.) stays orthogonal to the Effect issue vocab. No dependency on Ajv or Effect — pure TS. The follow-up plan’s “plugin avoids Ajv transitively” worry dissolves: schema package no longer pulls Ajv at all.

Risks / things to validate early

Test strategy (red-to-green)

  1. Add scripts/jsonschema-to-effect.mjs skeleton + tests that exercise each JSON Schema keyword on a small hand-written input (not the real schema yet). Green once every keyword handler works in isolation.
  2. Run the codegen against the real tests.schema.json, land output as tests.generated.ts (checked in). TypeScript compiles.
  3. Port test-format.test.ts positive fixtures — assert valid. Fix codegen bugs iteratively.
  4. Port negative fixtures — assert expected keyword/path. This is where the Ajv→Effect mapping shakes out; most churn lives in issueToKeyword + path conversion.
  5. Update CLI snapshot tests.
  6. Delete Ajv deps + shim. Full make test green.
  7. Wire make check’s codegen-drift guard.

Out of scope for this PR

Unresolved questions