From c526b9fbbea7d028335be6ae845da5443e1e7faa Mon Sep 17 00:00:00 2001 From: Stathis Gennatas Date: Tue, 31 Mar 2026 05:16:12 -0700 Subject: [PATCH 01/13] use schema.rtemis.org/a3/v1/schema.json --- Makefile | 80 ++++++++++++++ examples/mapt.a3.json | 2 + julia/RtemisA3/src/api.jl | 7 +- julia/RtemisA3/src/io.jl | 2 + julia/RtemisA3/src/validate.jl | 21 +++- julia/RtemisA3/test/runtests.jl | 2 +- python/rtemis_a3/src/rtemis/a3/api.py | 29 ++++- python/rtemis_a3/tests/test_api.py | 16 ++- r/R/a3.R | 24 ++++ r/tests/testthat/test_A3.R | 6 + rust/src/lib.rs | 26 ++++- rust/src/types.rs | 22 ++++ rust/src/validation.rs | 24 +++- schema/a3.schema.json | 152 ++++++++++++++++++++++++++ specs/A3.md | 23 +++- specs/A3_Rust.md | 4 +- typescript/src/a3.ts | 7 +- typescript/src/schemas.ts | 17 +++ typescript/tests/a3.test.ts | 6 +- typescript/tests/schemas.test.ts | 8 +- 20 files changed, 459 insertions(+), 19 deletions(-) create mode 100644 Makefile create mode 100644 schema/a3.schema.json diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cbc835f --- /dev/null +++ b/Makefile @@ -0,0 +1,80 @@ +.PHONY: test test-r test-python test-typescript test-julia test-rust \ + docs docs-r docs-python docs-typescript docs-julia docs-rust + +# ── Tests ────────────────────────────────────────────────────────────────────── + +test-r: + @echo "==> R" + cd r && Rscript -e "devtools::test(stop_on_failure = TRUE)" + +test-python: + @echo "==> Python" + cd python/rtemis_a3 && uv run pytest + +test-typescript: + @echo "==> TypeScript" + cd typescript && pnpm test + +test-julia: + @echo "==> Julia" + cd julia/RtemisA3 && julia --project=. -e "using Pkg; Pkg.test()" + +test-rust: + @echo "==> Rust" + cd rust && cargo test + +test: + @r=0; p=0; ts=0; jl=0; rs=0; \ + $(MAKE) test-r || r=1; \ + $(MAKE) test-python || p=1; \ + $(MAKE) test-julia || jl=1; \ + $(MAKE) test-typescript || ts=1; \ + $(MAKE) test-rust || rs=1; \ + echo ""; \ + echo "── Test Summary ──────────────────────────────────────"; \ + [ $$r -eq 0 ] && echo " R: passed" || echo " R: FAILED"; \ + [ $$p -eq 0 ] && echo " Python: passed" || echo " Python: FAILED"; \ + [ $$jl -eq 0 ] && echo " Julia: passed" || echo " Julia: FAILED"; \ + [ $$ts -eq 0 ] && echo " TypeScript: passed" || echo " TypeScript: FAILED"; \ + [ $$rs -eq 0 ] && echo " Rust: passed" || echo " Rust: FAILED"; \ + echo "─────────────────────────────────────────────────────"; \ + [ $$((r+p+ts+jl+rs)) -eq 0 ] + +# ── Docs ─────────────────────────────────────────────────────────────────────── + +docs-r: + @echo "==> R" + cd r && Rscript -e "devtools::document()" + +docs-python: + @echo "==> Python" + cd python && bash build-docs.sh + +docs-julia: + @echo "==> Julia" + cd julia && bash build-docs.sh + +docs-typescript: + @echo "==> TypeScript" + cd typescript && pnpm build + +docs-rust: + @echo "==> Rust" + cd rust && bash build-docs.sh + +docs: + @r=0; p=0; ts=0; jl=0; rs=0; \ + $(MAKE) docs-r || r=1; \ + $(MAKE) docs-python || p=1; \ + $(MAKE) docs-julia || jl=1; \ + $(MAKE) docs-typescript || ts=1; \ + $(MAKE) docs-rust || rs=1; \ + echo ""; \ + echo "── Docs Summary ──────────────────────────────────────"; \ + [ $$r -eq 0 ] && echo " R: done" || echo " R: FAILED"; \ + [ $$p -eq 0 ] && echo " Python: done" || echo " Python: FAILED"; \ + [ $$jl -eq 0 ] && echo " Julia: done" || echo " Julia: FAILED"; \ + [ $$ts -eq 0 ] && echo " TypeScript: done" || echo " TypeScript: FAILED"; \ + [ $$rs -eq 0 ] && echo " Rust: done" || echo " Rust: FAILED"; \ + echo "─────────────────────────────────────────────────────"; \ + [ $$((r+p+ts+jl+rs)) -eq 0 ] diff --git a/examples/mapt.a3.json b/examples/mapt.a3.json index 3196c8a..68ef1be 100644 --- a/examples/mapt.a3.json +++ b/examples/mapt.a3.json @@ -1,4 +1,6 @@ { + "$schema": "https://schema.rtemis.org/a3/v1/schema.json", + "a3_version": "1.0.0", "sequence": "MAEPRQEFEVMEDHAGTYGLGDRKDQGGYTMHQDQEGDTDAGLKESPLQTPTEDGSEEPGSETSDAKSTPTAEDVTAPLVDEGAPGKQAAAQPHTEIPEGTTAEEAGIGDTPSLEDEAAGHVTQARMVSKSKDGTGSDDKKAKGADGKTKIATPRGAAPPGQKGQANATRIPAKTPPAPKTPPSSGEPPKSGDRSGYSSPGSPGTPGSRSRTPSLPTPPTREPKKVAVVRTPPKSPSSAKSRLQTAPVPMPDLKNVKSKIGSTENLKHQPGGGKVQIINKKLDLSNVQSKCGSKDNIKHVPGGGSVQIVYKPVDLSKVTSKCGSLGNIHHKPGGGQVEVKSEKLDFKDRVQSKIGSLDNITHVPGGGNKKIETHKLTFRENAKAKTDHGAEIVYKSPVVSGDTSPRHLSNVSSTGSIDMVDSPQLATLADEVSASLAKQGL", "annotations": { "site": { diff --git a/julia/RtemisA3/src/api.jl b/julia/RtemisA3/src/api.jl index fdaa553..c578b98 100644 --- a/julia/RtemisA3/src/api.jl +++ b/julia/RtemisA3/src/api.jl @@ -21,7 +21,12 @@ function create_a3( processing !== nothing && (annot["processing"] = processing) variant !== nothing && (annot["variant"] = variant) - raw = Dict{String,Any}("sequence" => sequence, "annotations" => annot) + raw = Dict{String,Any}( + "\$schema" => _A3_SCHEMA_URI, + "a3_version" => _A3_VERSION, + "sequence" => sequence, + "annotations" => annot, + ) metadata !== nothing && (raw["metadata"] = metadata) A3(raw) end diff --git a/julia/RtemisA3/src/io.jl b/julia/RtemisA3/src/io.jl index 88c597e..56b6523 100644 --- a/julia/RtemisA3/src/io.jl +++ b/julia/RtemisA3/src/io.jl @@ -15,6 +15,8 @@ end function to_dict(a3::A3)::Dict{String,Any} Dict{String,Any}( + "\$schema" => _A3_SCHEMA_URI, + "a3_version" => _A3_VERSION, "sequence" => a3.sequence, "annotations" => Dict{String,Any}( "site" => Dict(k => _to_dict(v) for (k, v) in a3.annotations.site), diff --git a/julia/RtemisA3/src/validate.jl b/julia/RtemisA3/src/validate.jl index 729eb9c..490807c 100644 --- a/julia/RtemisA3/src/validate.jl +++ b/julia/RtemisA3/src/validate.jl @@ -211,11 +211,28 @@ end # ─── A3 outer constructor ───────────────────────────────────────────────────── +const _A3_SCHEMA_URI = "https://schema.rtemis.org/a3/v1/schema.json" +const _A3_VERSION = "1.0.0" +const _A3_ENVELOPE = ("\$schema", "a3_version") +const _A3_DATA_KEYS = ("sequence", "annotations", "metadata") + function A3(raw::AbstractDict) + schema_val = get(raw, "\$schema", nothing) + schema_val !== nothing || + throw(A3ValidationError("missing required field '\$schema'")) + schema_val == _A3_SCHEMA_URI || + throw(A3ValidationError("'\$schema' must be '$_A3_SCHEMA_URI', got '$schema_val'")) + + version_val = get(raw, "a3_version", nothing) + version_val !== nothing || + throw(A3ValidationError("missing required field 'a3_version'")) + version_val == _A3_VERSION || + throw(A3ValidationError("'a3_version' must be '$_A3_VERSION', got '$version_val'")) + for k in keys(raw) - k in ("sequence", "annotations", "metadata") || + k in _A3_DATA_KEYS || k in _A3_ENVELOPE || throw(A3ValidationError("unknown top-level field '$k' " * - "(must be one of: sequence, annotations, metadata)")) + "(must be one of: \$schema, a3_version, sequence, annotations, metadata)")) end haskey(raw, "sequence") || throw(A3ValidationError("missing required field 'sequence'")) diff --git a/julia/RtemisA3/test/runtests.jl b/julia/RtemisA3/test/runtests.jl index faa00ac..1f36fb9 100644 --- a/julia/RtemisA3/test/runtests.jl +++ b/julia/RtemisA3/test/runtests.jl @@ -189,7 +189,7 @@ end # ─── Unknown keys rejected ──────────────────────────────────────────────────── @testset "unknown keys" begin - val_err(() -> A3(Dict("sequence" => "MAEPRQ", "extra" => "bad"))) + val_err(() -> A3(Dict("\$schema" => "https://schema.rtemis.org/a3/v1/schema.json", "a3_version" => "1.0.0", "sequence" => "MAEPRQ", "extra" => "bad"))) val_err(() -> create_a3("MAEPRQ"; site = Dict("x" => Dict("index" => [1], "type" => "", "extra" => "bad")) )) diff --git a/python/rtemis_a3/src/rtemis/a3/api.py b/python/rtemis_a3/src/rtemis/a3/api.py index 7a4a9fd..fcd2e89 100644 --- a/python/rtemis_a3/src/rtemis/a3/api.py +++ b/python/rtemis_a3/src/rtemis/a3/api.py @@ -14,6 +14,10 @@ from ._models import A3, VariantRecord from .errors import A3ParseError, A3ValidationError +_A3_SCHEMA_URI = "https://schema.rtemis.org/a3/v1/schema.json" +_A3_VERSION = "1.0.0" +_ENVELOPE_KEYS = frozenset({"$schema", "a3_version"}) + def create_a3( sequence: str, @@ -104,6 +108,25 @@ def a3_from_json(text: str) -> A3: except (json.JSONDecodeError, TypeError) as exc: raise A3ParseError(f"invalid JSON: {exc}") from exc + if not isinstance(data, dict): + raise A3ParseError("JSON root must be an object") + schema_val = data.get("$schema") + if schema_val is None: + raise A3ParseError("missing required field '$schema'") + if schema_val != _A3_SCHEMA_URI: + raise A3ParseError( + f"'$schema' must be '{_A3_SCHEMA_URI}', got '{schema_val}'" + ) + version_val = data.get("a3_version") + if version_val is None: + raise A3ParseError("missing required field 'a3_version'") + if version_val != _A3_VERSION: + raise A3ParseError( + f"'a3_version' must be '{_A3_VERSION}', got '{version_val}'" + ) + # Strip envelope keys before passing to the data model + data = {k: v for k, v in data.items() if k not in _ENVELOPE_KEYS} + try: return A3.model_validate(data) except ValidationError as exc: @@ -125,7 +148,11 @@ def a3_to_json(a3: A3, *, indent: int | None = None) -> str: str JSON string. """ - data = a3.model_dump(mode="json") + data: dict[str, Any] = { + "$schema": _A3_SCHEMA_URI, + "a3_version": _A3_VERSION, + **a3.model_dump(mode="json"), + } return json.dumps(data, indent=indent, ensure_ascii=False) diff --git a/python/rtemis_a3/tests/test_api.py b/python/rtemis_a3/tests/test_api.py index 694b125..9fda01e 100644 --- a/python/rtemis_a3/tests/test_api.py +++ b/python/rtemis_a3/tests/test_api.py @@ -56,9 +56,11 @@ def test_bounds_error(self): # a3_from_json / a3_to_json # --------------------------------------------------------------------------- -MINIMAL_JSON = '{"sequence": "MAEPRQ"}' +MINIMAL_JSON = '{"$schema": "https://schema.rtemis.org/a3/v1/schema.json", "a3_version": "1.0.0", "sequence": "MAEPRQ"}' FULL_JSON = """{ + "$schema": "https://schema.rtemis.org/a3/v1/schema.json", + "a3_version": "1.0.0", "sequence": "MAEPRQFV", "annotations": { "site": { @@ -101,7 +103,17 @@ def test_invalid_json(self): def test_valid_json_invalid_a3(self): with pytest.raises(A3ValidationError): - a3_from_json('{"sequence": "M"}') # too short + a3_from_json( + '{"$schema": "https://schema.rtemis.org/a3/v1/schema.json", "a3_version": "1.0.0", "sequence": "M"}' + ) # too short + + def test_missing_schema_field(self): + with pytest.raises(A3ParseError, match=r"\$schema"): + a3_from_json('{"a3_version": "1.0.0", "sequence": "MAEPRQ"}') + + def test_missing_version_field(self): + with pytest.raises(A3ParseError, match="a3_version"): + a3_from_json('{"$schema": "https://schema.rtemis.org/a3/v1/schema.json", "sequence": "MAEPRQ"}') class TestA3ToJson: diff --git a/r/R/a3.R b/r/R/a3.R index 6653697..bfc2741 100644 --- a/r/R/a3.R +++ b/r/R/a3.R @@ -884,6 +884,8 @@ method(to_json, A3) <- function(x, pretty = TRUE, ...) { } lst <- list( + `$schema` = jsonlite::unbox("https://schema.rtemis.org/a3/v1/schema.json"), + a3_version = jsonlite::unbox("1.0.0"), sequence = jsonlite::unbox(x@sequence@data), annotations = list( site = force_named(lapply(x@annotations@site, feature_to_list)), @@ -931,6 +933,28 @@ A3from_json <- function(x, ...) { ) } + # Validate required envelope fields + .a3_schema_uri <- "https://schema.rtemis.org/a3/v1/schema.json" + .a3_version <- "1.0.0" + schema_field <- x[["$schema"]] + if (is.null(schema_field)) { + cli::cli_abort("JSON input missing required field {.field $schema}.") + } + if (schema_field != .a3_schema_uri) { + cli::cli_abort( + "Field {.field $schema} must be {.val {.a3_schema_uri}}, got {.val {schema_field}}." + ) + } + version_field <- x[["a3_version"]] + if (is.null(version_field)) { + cli::cli_abort("JSON input missing required field {.field a3_version}.") + } + if (version_field != .a3_version) { + cli::cli_abort( + "Field {.field a3_version} must be {.val {.a3_version}}, got {.val {version_field}}." + ) + } + sequence <- x[["sequence"]] annotations <- x[["annotations"]] diff --git a/r/tests/testthat/test_A3.R b/r/tests/testthat/test_A3.R index d067454..b1922f5 100644 --- a/r/tests/testthat/test_A3.R +++ b/r/tests/testthat/test_A3.R @@ -581,6 +581,8 @@ test_that("A3from_json round-trips to_json with zero loss", { test_that("A3from_json rejects legacy bare-array format", { legacy_json <- '{ + "$schema": "https://schema.rtemis.org/a3/v1/schema.json", + "a3_version": "1.0.0", "sequence": "MKTAYIAKQRQISFVK", "annotations": { "site": { @@ -597,6 +599,8 @@ test_that("A3from_json rejects legacy bare-array format", { test_that("A3from_json handles missing metadata gracefully", { json <- '{ + "$schema": "https://schema.rtemis.org/a3/v1/schema.json", + "a3_version": "1.0.0", "sequence": "MKTAYIAKQRQISFVK", "annotations": { "site": {}, @@ -614,6 +618,8 @@ test_that("A3from_json handles missing metadata gracefully", { test_that("A3from_json accepts pre-parsed list", { lst <- list( + `$schema` = "https://schema.rtemis.org/a3/v1/schema.json", + a3_version = "1.0.0", sequence = "MKTAYIAKQRQISFVK", annotations = list( site = list( diff --git a/rust/src/lib.rs b/rust/src/lib.rs index a44c28a..0402fdc 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -11,6 +11,8 @@ //! use rtemis_a3::{a3_from_json, a3_to_json}; //! //! let json = r#"{ +//! "$schema": "https://schema.rtemis.org/a3/v1/schema.json", +//! "a3_version": "1.0.0", //! "sequence": "MAEPRQ", //! "annotations": { "site": {}, "region": {}, "ptm": {}, "processing": {}, "variant": [] }, //! "metadata": { "uniprot_id": "", "description": "", "reference": "", "organism": "" } @@ -170,6 +172,8 @@ mod tests { // The minimal JSON the spec requires all five families to be present. const MINIMAL_JSON: &str = r#"{ + "$schema": "https://schema.rtemis.org/a3/v1/schema.json", + "a3_version": "1.0.0", "sequence": "MAEPRQ", "annotations": { "site": {}, @@ -216,15 +220,33 @@ mod tests { assert_eq!(residue_at(&a3, 99), None); } + #[test] + fn rejects_missing_schema() { + let json = r#"{"a3_version":"1.0.0","sequence":"MAEPRQ","annotations":{"site":{},"region":{},"ptm":{},"processing":{},"variant":[]},"metadata":{}}"#; + assert!(a3_from_json(json).is_err()); + } + + #[test] + fn rejects_wrong_schema_uri() { + let json = r#"{"$schema":"https://example.com/wrong","a3_version":"1.0.0","sequence":"MAEPRQ","annotations":{"site":{},"region":{},"ptm":{},"processing":{},"variant":[]},"metadata":{}}"#; + assert!(a3_from_json(json).is_err()); + } + + #[test] + fn rejects_missing_version() { + let json = r#"{"$schema":"https://schema.rtemis.org/a3/v1/schema.json","sequence":"MAEPRQ","annotations":{"site":{},"region":{},"ptm":{},"processing":{},"variant":[]},"metadata":{}}"#; + assert!(a3_from_json(json).is_err()); + } + #[test] fn rejects_unknown_top_level_key() { - let json = r#"{"sequence":"MAEPRQ","foo":"bar"}"#; + let json = r#"{"$schema":"https://schema.rtemis.org/a3/v1/schema.json","a3_version":"1.0.0","sequence":"MAEPRQ","foo":"bar"}"#; assert!(a3_from_json(json).is_err()); } #[test] fn rejects_unknown_metadata_key() { - let json = r#"{"sequence":"MAEPRQ","metadata":{"gene":"MAPT"}}"#; + let json = r#"{"$schema":"https://schema.rtemis.org/a3/v1/schema.json","a3_version":"1.0.0","sequence":"MAEPRQ","metadata":{"gene":"MAPT"}}"#; assert!(a3_from_json(json).is_err()); } diff --git a/rust/src/types.rs b/rust/src/types.rs index 983c78e..9b4a126 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -281,6 +281,11 @@ impl Metadata { // A3 — root type // --------------------------------------------------------------------------- +/// Expected value for the `$schema` envelope field. +pub(crate) const A3_SCHEMA_URI: &str = "https://schema.rtemis.org/a3/v1/schema.json"; +/// Expected value for the `a3_version` envelope field. +pub(crate) const A3_VERSION: &str = "1.0.0"; + /// The root A3 object. /// /// Fields are `pub(crate)` — only [`crate::validate()`] (in [`crate::validation`]) may construct an `A3`, @@ -289,6 +294,13 @@ impl Metadata { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct A3 { + /// JSON Schema URI — must equal [`A3_SCHEMA_URI`]. Required on input. + #[serde(rename = "$schema")] + pub(crate) schema: String, + + /// A3 spec version — must equal [`A3_VERSION`]. Required on input. + pub(crate) a3_version: String, + /// The amino acid sequence. Non-empty, ≥ 2 characters, `[A-Z*]` only. /// Lowercase input is normalized to uppercase during validation. pub(crate) sequence: String, @@ -303,6 +315,16 @@ pub struct A3 { } impl A3 { + /// JSON Schema URI. + pub fn schema(&self) -> &str { + &self.schema + } + + /// A3 spec version string. + pub fn a3_version(&self) -> &str { + &self.a3_version + } + /// The amino acid sequence, normalized to uppercase. pub fn sequence(&self) -> &str { &self.sequence diff --git a/rust/src/validation.rs b/rust/src/validation.rs index 4539e57..416de75 100644 --- a/rust/src/validation.rs +++ b/rust/src/validation.rs @@ -12,7 +12,8 @@ use std::collections::HashMap; use crate::error::A3Error; use crate::normalization::{normalize_positions, normalize_ranges, normalize_sequence}; use crate::types::{ - A3, A3Index, Annotations, FlexEntry, Metadata, RegionEntry, SiteEntry, VariantRecord, + A3, A3Index, A3_SCHEMA_URI, A3_VERSION, Annotations, FlexEntry, Metadata, RegionEntry, + SiteEntry, VariantRecord, }; /// Validate and normalize a raw-deserialized [`A3`] value. @@ -31,6 +32,23 @@ pub fn validate(raw: A3) -> Result { // `mut` is required because we will call `.push()` on it. let mut errors: Vec = Vec::new(); + // ----------------------------------------------------------------------- + // Envelope field validation + // ----------------------------------------------------------------------- + + if raw.schema != A3_SCHEMA_URI { + errors.push(format!( + "'$schema' must be '{A3_SCHEMA_URI}', got '{}'", + raw.schema + )); + } + if raw.a3_version != A3_VERSION { + errors.push(format!( + "'a3_version' must be '{A3_VERSION}', got '{}'", + raw.a3_version + )); + } + // ----------------------------------------------------------------------- // Stage 1 — Structural validation and normalization // ----------------------------------------------------------------------- @@ -211,6 +229,8 @@ pub fn validate(raw: A3) -> Result { } Ok(A3 { + schema: raw.schema, + a3_version: raw.a3_version, sequence, annotations: Annotations { site, @@ -334,6 +354,8 @@ mod tests { /// does not need `self` for functions that are not methods on a type. fn minimal_raw() -> A3 { A3 { + schema: A3_SCHEMA_URI.to_string(), + a3_version: A3_VERSION.to_string(), sequence: "MAEPRQ".to_string(), annotations: Annotations::default(), metadata: Metadata::default(), diff --git a/schema/a3.schema.json b/schema/a3.schema.json new file mode 100644 index 0000000..5a78922 --- /dev/null +++ b/schema/a3.schema.json @@ -0,0 +1,152 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schema.rtemis.org/a3/v1/schema.json", + "title": "A3", + "description": "Amino Acid Annotation (A3) format — structured annotation of amino acid sequences.", + "type": "object", + "required": ["$schema", "a3_version", "sequence", "annotations", "metadata"], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "const": "https://schema.rtemis.org/a3/v1/schema.json", + "description": "JSON Schema URI for this A3 document." + }, + "a3_version": { + "type": "string", + "const": "1.0.0", + "description": "A3 specification version." + }, + "sequence": { + "type": "string", + "minLength": 2, + "pattern": "^[A-Za-z*]+$", + "description": "Amino acid sequence using IUPAC single-letter codes plus '*' (stop codon). Lowercase is accepted and uppercased on parse." + }, + "annotations": { + "type": "object", + "required": ["site", "region", "ptm", "processing", "variant"], + "additionalProperties": false, + "properties": { + "site": { + "type": "object", + "propertyNames": { "minLength": 1 }, + "additionalProperties": { "$ref": "#/$defs/positionAnnotationEntry" }, + "description": "Named sets of individual residue positions." + }, + "region": { + "type": "object", + "propertyNames": { "minLength": 1 }, + "additionalProperties": { "$ref": "#/$defs/rangeAnnotationEntry" }, + "description": "Named sets of contiguous sequence spans." + }, + "ptm": { + "type": "object", + "propertyNames": { "minLength": 1 }, + "additionalProperties": { "$ref": "#/$defs/mixedAnnotationEntry" }, + "description": "Post-translational modifications (positions or ranges)." + }, + "processing": { + "type": "object", + "propertyNames": { "minLength": 1 }, + "additionalProperties": { "$ref": "#/$defs/mixedAnnotationEntry" }, + "description": "Signal peptides, cleavage sites, maturation events (positions or ranges)." + }, + "variant": { + "type": "array", + "items": { "$ref": "#/$defs/variantRecord" }, + "description": "Ordered list of sequence variant records." + } + } + }, + "metadata": { + "type": "object", + "additionalProperties": false, + "description": "Protein metadata.", + "properties": { + "uniprot_id": { + "type": "string", + "description": "UniProt accession." + }, + "description": { + "type": "string", + "description": "Human-readable protein description." + }, + "reference": { + "type": "string", + "description": "Citation or URL." + }, + "organism": { + "type": "string", + "description": "Species name." + } + } + } + }, + "$defs": { + "position": { + "type": "integer", + "minimum": 1, + "description": "1-based residue position." + }, + "range": { + "type": "array", + "items": { "type": "integer", "minimum": 1 }, + "minItems": 2, + "maxItems": 2, + "description": "Inclusive [start, end] range pair; start < end is enforced at validation time." + }, + "positionIndex": { + "type": "array", + "items": { "$ref": "#/$defs/position" }, + "description": "Ordered array of 1-based residue positions." + }, + "rangeIndex": { + "type": "array", + "items": { "$ref": "#/$defs/range" }, + "description": "Ordered array of [start, end] range pairs." + }, + "mixedIndex": { + "anyOf": [ + { "$ref": "#/$defs/positionIndex" }, + { "$ref": "#/$defs/rangeIndex" } + ], + "description": "Either an array of positions or a homogeneous array of [start, end] range pairs." + }, + "positionAnnotationEntry": { + "type": "object", + "required": ["index"], + "additionalProperties": false, + "properties": { + "index": { "$ref": "#/$defs/positionIndex" }, + "type": { "type": "string", "default": "" } + } + }, + "rangeAnnotationEntry": { + "type": "object", + "required": ["index"], + "additionalProperties": false, + "properties": { + "index": { "$ref": "#/$defs/rangeIndex" }, + "type": { "type": "string", "default": "" } + } + }, + "mixedAnnotationEntry": { + "type": "object", + "required": ["index"], + "additionalProperties": false, + "properties": { + "index": { "$ref": "#/$defs/mixedIndex" }, + "type": { "type": "string", "default": "" } + } + }, + "variantRecord": { + "type": "object", + "required": ["position"], + "properties": { + "position": { "$ref": "#/$defs/position" } + }, + "description": "Sequence variant record. 'position' is required; all other fields are open (must be JSON-compatible)." + } + } +} diff --git a/specs/A3.md b/specs/A3.md index cae1aae..8ae0b9d 100644 --- a/specs/A3.md +++ b/specs/A3.md @@ -3,7 +3,8 @@ Amino Acid Annotation (A3) format — language-agnostic specification. Language-specific implementation notes live alongside this file: -`A3_S7.md` (R), `A3_zod.md` (TypeScript), `A3_Pydantic.md` (Python). +`A3_S7.md` (R), `A3_Zod.md` (TypeScript), `A3_Pydantic.md` (Python), `A3_Julia.md` (Julia), +`A3_Rust.md` (Rust). ## Purpose @@ -20,12 +21,16 @@ information, alongside sequence metadata. It is designed for: JSON is the canonical serialization format. TOML is a secondary target for human-authoring workflows. +The canonical file extension for A3 JSON files is **`.a3.json`**. + All five annotation families are always present in serialized output, even when empty. The `type` field is always present on annotation entries (empty string when unset). ```json { + "$schema": "https://schema.rtemis.org/a3/v1/schema.json", + "a3_version": "1.0.0", "sequence": "MAEPRQ...", "annotations": { "site": { @@ -56,6 +61,8 @@ entries (empty string when unset). ``` A3 + ├── $schema: string (URI) + ├── a3_version: string (semver) ├── sequence: string ├── annotations: │ ├── site: map @@ -72,6 +79,18 @@ A3 ## Field Definitions +### $schema + +- URI pointing to the JSON Schema for this version of A3 +- Fixed value: `"https://schema.rtemis.org/a3/v1/schema.json"` +- Required on JSON input; always present in serialized output + +### a3_version + +- Semantic version string identifying the A3 spec version used +- Fixed value for this spec: `"1.0.0"` +- Required on JSON input; always present in serialized output + ### sequence - Non-empty string; minimum 2 characters @@ -146,6 +165,8 @@ Unknown metadata fields are rejected. Performed field-by-field on raw input: +- `$schema`: required string; must equal `"https://schema.rtemis.org/a3/v1/schema.json"` +- `a3_version`: required string; must equal `"1.0.0"` - `sequence`: non-empty, `[A-Za-z*]+` (uppercased on parse), ≥ 2 characters - Positions: positive integers, sorted, deduplicated - Ranges: positive integers, `start < end`, sorted, overlaps merged diff --git a/specs/A3_Rust.md b/specs/A3_Rust.md index b457275..72610ac 100644 --- a/specs/A3_Rust.md +++ b/specs/A3_Rust.md @@ -1,6 +1,6 @@ -# A3 Serde Specification +# A3 Rust Specification -Amino Acid Annotation (A3) format — Rust/Serde implementation design. +Amino Acid Annotation (A3) format — Rust implementation design. ## Requirements diff --git a/typescript/src/a3.ts b/typescript/src/a3.ts index 4fb4894..26f4254 100644 --- a/typescript/src/a3.ts +++ b/typescript/src/a3.ts @@ -1,5 +1,5 @@ import type { ZodError } from "zod"; -import { type A3Data, A3InputSchema, type VariantData } from "./schemas"; +import { A3_SCHEMA_URI, A3_VERSION, type A3Data, A3InputSchema, type VariantData } from "./schemas"; // ── Error classes ───────────────────────────────────────────────────────────── @@ -89,9 +89,10 @@ export class A3 { /** * Return the canonical data object for JSON serialization. * Called automatically by JSON.stringify — do not return a string here. + * Envelope fields ($schema, a3_version) are always emitted first. */ - toJSON(): A3Data { - return this.#data; + toJSON(): { $schema: string; a3_version: string } & A3Data { + return { $schema: A3_SCHEMA_URI, a3_version: A3_VERSION, ...this.#data }; } /** diff --git a/typescript/src/schemas.ts b/typescript/src/schemas.ts index 84c831a..e157d2d 100644 --- a/typescript/src/schemas.ts +++ b/typescript/src/schemas.ts @@ -84,10 +84,25 @@ const MetadataSchema = z }) .strict(); +// ── Envelope constants ──────────────────────────────────────────────────────── + +const A3_SCHEMA_URI = "https://schema.rtemis.org/a3/v1/schema.json"; +const A3_VERSION = "1.0.0"; + // ── Root schema ─────────────────────────────────────────────────────────────── export const A3InputSchema = z .object({ + $schema: z.literal(A3_SCHEMA_URI, { + errorMap: () => ({ + message: `'$schema' must be '${A3_SCHEMA_URI}'`, + }), + }), + a3_version: z.literal(A3_VERSION, { + errorMap: () => ({ + message: `'a3_version' must be '${A3_VERSION}'`, + }), + }), sequence: z .string() .min(2, "sequence must be at least 2 characters") @@ -149,6 +164,8 @@ export const A3InputSchema = z }); }); +export { A3_SCHEMA_URI, A3_VERSION }; + // ── Exported types (inferred from schemas) ──────────────────────────────────── export type A3Data = z.infer; diff --git a/typescript/tests/a3.test.ts b/typescript/tests/a3.test.ts index 9e000df..5cc5a36 100644 --- a/typescript/tests/a3.test.ts +++ b/typescript/tests/a3.test.ts @@ -4,6 +4,8 @@ import { A3, A3ParseError, A3ValidationError } from "../src/a3"; const MINI_SEQ = "MKTAYIAKQR"; const SIMPLE_INPUT = { + $schema: "https://schema.rtemis.org/a3/v1/schema.json", + a3_version: "1.0.0", sequence: MINI_SEQ, annotations: { site: { "Active site": { index: [3, 5], type: "activeSite" } }, @@ -155,7 +157,7 @@ describe("A3.toJSON and JSON.stringify", () => { }); it("serialized JSON contains all annotation families", () => { - const a3 = new A3({ sequence: "MKTAY" }); + const a3 = new A3({ $schema: "https://schema.rtemis.org/a3/v1/schema.json", a3_version: "1.0.0", sequence: "MKTAY" }); const parsed = JSON.parse(a3.toJSONString()) as { annotations: Record }; expect(parsed.annotations).toHaveProperty("site"); expect(parsed.annotations).toHaveProperty("region"); @@ -166,6 +168,8 @@ describe("A3.toJSON and JSON.stringify", () => { it("type field is always present on annotation entries", () => { const a3 = new A3({ + $schema: "https://schema.rtemis.org/a3/v1/schema.json", + a3_version: "1.0.0", sequence: "MKTAY", annotations: { site: { A: { index: [1, 2] } }, // type omitted — defaults to "" diff --git a/typescript/tests/schemas.test.ts b/typescript/tests/schemas.test.ts index 4fca72f..fa71240 100644 --- a/typescript/tests/schemas.test.ts +++ b/typescript/tests/schemas.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { A3InputSchema } from "../src/schemas"; const MINIMAL_VALID = { + $schema: "https://schema.rtemis.org/a3/v1/schema.json", + a3_version: "1.0.0", sequence: "MKTAYIAKQR", annotations: { site: {}, region: {}, ptm: {}, processing: {}, variant: [] }, metadata: { uniprot_id: "", description: "", reference: "", organism: "" }, @@ -62,7 +64,7 @@ describe("annotation validation", () => { }); it("defaults missing annotations families to empty", () => { - const result = A3InputSchema.safeParse({ sequence: "MKTAYIAKQR" }); + const result = A3InputSchema.safeParse({ $schema: "https://schema.rtemis.org/a3/v1/schema.json", a3_version: "1.0.0", sequence: "MKTAYIAKQR" }); expect(result.success).toBe(true); if (result.success) { expect(result.data.annotations.site).toEqual({}); @@ -284,7 +286,7 @@ describe("variant validation", () => { describe("metadata validation", () => { it("defaults all metadata fields to empty string", () => { - const result = A3InputSchema.safeParse({ sequence: "MKTAY" }); + const result = A3InputSchema.safeParse({ $schema: "https://schema.rtemis.org/a3/v1/schema.json", a3_version: "1.0.0", sequence: "MKTAY" }); expect(result.success).toBe(true); if (result.success) { expect(result.data.metadata).toEqual({ @@ -298,6 +300,8 @@ describe("metadata validation", () => { it("accepts partial metadata", () => { const result = A3InputSchema.safeParse({ + $schema: "https://schema.rtemis.org/a3/v1/schema.json", + a3_version: "1.0.0", sequence: "MKTAY", metadata: { uniprot_id: "P10636" }, }); From 45933a5bfe6416854e6fb1255340b5076b2f920f Mon Sep 17 00:00:00 2001 From: Stathis Gennatas Date: Tue, 31 Mar 2026 05:22:48 -0700 Subject: [PATCH 02/13] cargo fmt --- rust/src/validation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/src/validation.rs b/rust/src/validation.rs index 416de75..c675d8e 100644 --- a/rust/src/validation.rs +++ b/rust/src/validation.rs @@ -12,7 +12,7 @@ use std::collections::HashMap; use crate::error::A3Error; use crate::normalization::{normalize_positions, normalize_ranges, normalize_sequence}; use crate::types::{ - A3, A3Index, A3_SCHEMA_URI, A3_VERSION, Annotations, FlexEntry, Metadata, RegionEntry, + A3, A3_SCHEMA_URI, A3_VERSION, A3Index, Annotations, FlexEntry, Metadata, RegionEntry, SiteEntry, VariantRecord, }; From 7ebb222ceff465eeb8818d2afb3a6bb31a99f6b5 Mon Sep 17 00:00:00 2001 From: Stathis Gennatas Date: Tue, 31 Mar 2026 05:25:16 -0700 Subject: [PATCH 03/13] biome --- typescript/src/a3.ts | 2 +- typescript/tests/a3.test.ts | 6 +++++- typescript/tests/schemas.test.ts | 12 ++++++++++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/typescript/src/a3.ts b/typescript/src/a3.ts index 26f4254..d70d848 100644 --- a/typescript/src/a3.ts +++ b/typescript/src/a3.ts @@ -1,5 +1,5 @@ import type { ZodError } from "zod"; -import { A3_SCHEMA_URI, A3_VERSION, type A3Data, A3InputSchema, type VariantData } from "./schemas"; +import { type A3Data, A3InputSchema, A3_SCHEMA_URI, A3_VERSION, type VariantData } from "./schemas"; // ── Error classes ───────────────────────────────────────────────────────────── diff --git a/typescript/tests/a3.test.ts b/typescript/tests/a3.test.ts index 5cc5a36..d2e8fe6 100644 --- a/typescript/tests/a3.test.ts +++ b/typescript/tests/a3.test.ts @@ -157,7 +157,11 @@ describe("A3.toJSON and JSON.stringify", () => { }); it("serialized JSON contains all annotation families", () => { - const a3 = new A3({ $schema: "https://schema.rtemis.org/a3/v1/schema.json", a3_version: "1.0.0", sequence: "MKTAY" }); + const a3 = new A3({ + $schema: "https://schema.rtemis.org/a3/v1/schema.json", + a3_version: "1.0.0", + sequence: "MKTAY", + }); const parsed = JSON.parse(a3.toJSONString()) as { annotations: Record }; expect(parsed.annotations).toHaveProperty("site"); expect(parsed.annotations).toHaveProperty("region"); diff --git a/typescript/tests/schemas.test.ts b/typescript/tests/schemas.test.ts index fa71240..778aae7 100644 --- a/typescript/tests/schemas.test.ts +++ b/typescript/tests/schemas.test.ts @@ -64,7 +64,11 @@ describe("annotation validation", () => { }); it("defaults missing annotations families to empty", () => { - const result = A3InputSchema.safeParse({ $schema: "https://schema.rtemis.org/a3/v1/schema.json", a3_version: "1.0.0", sequence: "MKTAYIAKQR" }); + const result = A3InputSchema.safeParse({ + $schema: "https://schema.rtemis.org/a3/v1/schema.json", + a3_version: "1.0.0", + sequence: "MKTAYIAKQR", + }); expect(result.success).toBe(true); if (result.success) { expect(result.data.annotations.site).toEqual({}); @@ -286,7 +290,11 @@ describe("variant validation", () => { describe("metadata validation", () => { it("defaults all metadata fields to empty string", () => { - const result = A3InputSchema.safeParse({ $schema: "https://schema.rtemis.org/a3/v1/schema.json", a3_version: "1.0.0", sequence: "MKTAY" }); + const result = A3InputSchema.safeParse({ + $schema: "https://schema.rtemis.org/a3/v1/schema.json", + a3_version: "1.0.0", + sequence: "MKTAY", + }); expect(result.success).toBe(true); if (result.success) { expect(result.data.metadata).toEqual({ From a20046fd1bf6f396ac100f22ba3aace9863b61a1 Mon Sep 17 00:00:00 2001 From: Stathis Gennatas Date: Tue, 31 Mar 2026 06:14:56 -0700 Subject: [PATCH 04/13] refactor: update toJSON method to return A3Data with schema fields last --- typescript/src/a3.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/typescript/src/a3.ts b/typescript/src/a3.ts index d70d848..3198ec2 100644 --- a/typescript/src/a3.ts +++ b/typescript/src/a3.ts @@ -91,8 +91,8 @@ export class A3 { * Called automatically by JSON.stringify — do not return a string here. * Envelope fields ($schema, a3_version) are always emitted first. */ - toJSON(): { $schema: string; a3_version: string } & A3Data { - return { $schema: A3_SCHEMA_URI, a3_version: A3_VERSION, ...this.#data }; + toJSON(): A3Data { + return { ...this.#data, $schema: A3_SCHEMA_URI, a3_version: A3_VERSION }; } /** From 50358937a13dfee7ecd39ff9cca81c4930c9d89a Mon Sep 17 00:00:00 2001 From: Stathis Gennatas Date: Tue, 31 Mar 2026 06:15:12 -0700 Subject: [PATCH 05/13] fix: update test script to include type checking before running vitest --- typescript/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/typescript/package.json b/typescript/package.json index c170e55..5be3251 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -35,7 +35,8 @@ }, "scripts": { "build": "tsc -p tsconfig.build.json", - "test": "vitest run", + "typecheck": "tsc --noEmit -p tsconfig.build.json", + "test": "pnpm typecheck && vitest run", "check": "biome check src tests", "lint": "biome lint src tests", "format": "biome format src tests", From 97799f98accf8a9e039c905219d8bfcb6b64a815 Mon Sep 17 00:00:00 2001 From: Stathis Gennatas Date: Tue, 31 Mar 2026 06:25:57 -0700 Subject: [PATCH 06/13] fix: optimize envelope key removal in a3_from_json function --- python/rtemis_a3/src/rtemis/a3/api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python/rtemis_a3/src/rtemis/a3/api.py b/python/rtemis_a3/src/rtemis/a3/api.py index fcd2e89..b7dc394 100644 --- a/python/rtemis_a3/src/rtemis/a3/api.py +++ b/python/rtemis_a3/src/rtemis/a3/api.py @@ -125,7 +125,8 @@ def a3_from_json(text: str) -> A3: f"'a3_version' must be '{_A3_VERSION}', got '{version_val}'" ) # Strip envelope keys before passing to the data model - data = {k: v for k, v in data.items() if k not in _ENVELOPE_KEYS} + for key in _ENVELOPE_KEYS: + data.pop(key, None) try: return A3.model_validate(data) From 289018a6bab567a7bb99f2d40dbe518aef068ac5 Mon Sep 17 00:00:00 2001 From: Stathis Gennatas Date: Tue, 31 Mar 2026 06:26:04 -0700 Subject: [PATCH 07/13] fix: centralize schema URI and version constants in A3 implementation --- r/R/a3.R | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/r/R/a3.R b/r/R/a3.R index bfc2741..ccd9f75 100644 --- a/r/R/a3.R +++ b/r/R/a3.R @@ -14,6 +14,9 @@ # └── organism: character(1) # 2026- EDG rtemis.org +.A3_SCHEMA_URI <- "https://schema.rtemis.org/a3/v1/schema.json" +.A3_VERSION <- "1.0.0" + # %% Level 1: A3Sequence ---- A3Sequence <- new_class( "A3Sequence", @@ -884,8 +887,8 @@ method(to_json, A3) <- function(x, pretty = TRUE, ...) { } lst <- list( - `$schema` = jsonlite::unbox("https://schema.rtemis.org/a3/v1/schema.json"), - a3_version = jsonlite::unbox("1.0.0"), + `$schema` = jsonlite::unbox(.A3_SCHEMA_URI), + a3_version = jsonlite::unbox(.A3_VERSION), sequence = jsonlite::unbox(x@sequence@data), annotations = list( site = force_named(lapply(x@annotations@site, feature_to_list)), @@ -934,24 +937,22 @@ A3from_json <- function(x, ...) { } # Validate required envelope fields - .a3_schema_uri <- "https://schema.rtemis.org/a3/v1/schema.json" - .a3_version <- "1.0.0" schema_field <- x[["$schema"]] if (is.null(schema_field)) { cli::cli_abort("JSON input missing required field {.field $schema}.") } - if (schema_field != .a3_schema_uri) { + if (schema_field != .A3_SCHEMA_URI) { cli::cli_abort( - "Field {.field $schema} must be {.val {.a3_schema_uri}}, got {.val {schema_field}}." + "Field {.field $schema} must be {.val {.A3_SCHEMA_URI}}, got {.val {schema_field}}." ) } version_field <- x[["a3_version"]] if (is.null(version_field)) { cli::cli_abort("JSON input missing required field {.field a3_version}.") } - if (version_field != .a3_version) { + if (version_field != .A3_VERSION) { cli::cli_abort( - "Field {.field a3_version} must be {.val {.a3_version}}, got {.val {version_field}}." + "Field {.field a3_version} must be {.val {.A3_VERSION}}, got {.val {version_field}}." ) } From e627d027a16074b7271e6bfbb9c7a76654d235a8 Mon Sep 17 00:00:00 2001 From: Stathis Gennatas Date: Tue, 31 Mar 2026 06:29:38 -0700 Subject: [PATCH 08/13] fix: remove unnecessary required fields from A3 schema --- schema/a3.schema.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/schema/a3.schema.json b/schema/a3.schema.json index 5a78922..8edc0f4 100644 --- a/schema/a3.schema.json +++ b/schema/a3.schema.json @@ -4,7 +4,7 @@ "title": "A3", "description": "Amino Acid Annotation (A3) format — structured annotation of amino acid sequences.", "type": "object", - "required": ["$schema", "a3_version", "sequence", "annotations", "metadata"], + "required": ["$schema", "a3_version", "sequence"], "additionalProperties": false, "properties": { "$schema": { @@ -25,7 +25,6 @@ }, "annotations": { "type": "object", - "required": ["site", "region", "ptm", "processing", "variant"], "additionalProperties": false, "properties": { "site": { From f94fc76548152ac3d81fa00878e62fe950db5c73 Mon Sep 17 00:00:00 2001 From: Stathis Gennatas Date: Tue, 31 Mar 2026 06:29:58 -0700 Subject: [PATCH 09/13] fix: replace hardcoded schema URI and version with constants in tests --- typescript/tests/schemas.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/typescript/tests/schemas.test.ts b/typescript/tests/schemas.test.ts index 778aae7..44c7a16 100644 --- a/typescript/tests/schemas.test.ts +++ b/typescript/tests/schemas.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; -import { A3InputSchema } from "../src/schemas"; +import { A3InputSchema, A3_SCHEMA_URI, A3_VERSION } from "../src/schemas"; const MINIMAL_VALID = { - $schema: "https://schema.rtemis.org/a3/v1/schema.json", - a3_version: "1.0.0", + $schema: A3_SCHEMA_URI, + a3_version: A3_VERSION, sequence: "MKTAYIAKQR", annotations: { site: {}, region: {}, ptm: {}, processing: {}, variant: [] }, metadata: { uniprot_id: "", description: "", reference: "", organism: "" }, @@ -65,8 +65,8 @@ describe("annotation validation", () => { it("defaults missing annotations families to empty", () => { const result = A3InputSchema.safeParse({ - $schema: "https://schema.rtemis.org/a3/v1/schema.json", - a3_version: "1.0.0", + $schema: A3_SCHEMA_URI, + a3_version: A3_VERSION, sequence: "MKTAYIAKQR", }); expect(result.success).toBe(true); @@ -291,8 +291,8 @@ describe("variant validation", () => { describe("metadata validation", () => { it("defaults all metadata fields to empty string", () => { const result = A3InputSchema.safeParse({ - $schema: "https://schema.rtemis.org/a3/v1/schema.json", - a3_version: "1.0.0", + $schema: A3_SCHEMA_URI, + a3_version: A3_VERSION, sequence: "MKTAY", }); expect(result.success).toBe(true); @@ -308,8 +308,8 @@ describe("metadata validation", () => { it("accepts partial metadata", () => { const result = A3InputSchema.safeParse({ - $schema: "https://schema.rtemis.org/a3/v1/schema.json", - a3_version: "1.0.0", + $schema: A3_SCHEMA_URI, + a3_version: A3_VERSION, sequence: "MKTAY", metadata: { uniprot_id: "P10636" }, }); From e5712344946d275c3ee5d368177585a7b445c03a Mon Sep 17 00:00:00 2001 From: Stathis Gennatas Date: Tue, 31 Mar 2026 06:30:04 -0700 Subject: [PATCH 10/13] fix: update A3 specification to clarify handling of overlapping ranges --- specs/A3.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/A3.md b/specs/A3.md index 8ae0b9d..95f1ec8 100644 --- a/specs/A3.md +++ b/specs/A3.md @@ -169,7 +169,7 @@ Performed field-by-field on raw input: - `a3_version`: required string; must equal `"1.0.0"` - `sequence`: non-empty, `[A-Za-z*]+` (uppercased on parse), ≥ 2 characters - Positions: positive integers, sorted, deduplicated -- Ranges: positive integers, `start < end`, sorted, overlaps merged +- Ranges: positive integers, `start < end`, sorted, overlapping ranges rejected - Annotation entries: `{ index, type }` objects — bare arrays rejected - Annotation names: non-empty strings - Unknown annotation families: rejected From 8daf5b013b303178cfc9f7d89686ed3ca2b8c0db Mon Sep 17 00:00:00 2001 From: Stathis Gennatas Date: Tue, 31 Mar 2026 06:30:31 -0700 Subject: [PATCH 11/13] fix: replace hardcoded schema URI and version with constants in A3 tests --- typescript/tests/a3.test.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/typescript/tests/a3.test.ts b/typescript/tests/a3.test.ts index d2e8fe6..431d4c7 100644 --- a/typescript/tests/a3.test.ts +++ b/typescript/tests/a3.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from "vitest"; import { A3, A3ParseError, A3ValidationError } from "../src/a3"; +import { A3_SCHEMA_URI, A3_VERSION } from "../src/schemas"; const MINI_SEQ = "MKTAYIAKQR"; const SIMPLE_INPUT = { - $schema: "https://schema.rtemis.org/a3/v1/schema.json", - a3_version: "1.0.0", + $schema: A3_SCHEMA_URI, + a3_version: A3_VERSION, sequence: MINI_SEQ, annotations: { site: { "Active site": { index: [3, 5], type: "activeSite" } }, @@ -29,12 +30,12 @@ describe("A3 constructor", () => { }); it("throws A3ValidationError for invalid input", () => { - expect(() => new A3({ sequence: "M" })).toThrow(A3ValidationError); + expect(() => new A3({ $schema: A3_SCHEMA_URI, a3_version: A3_VERSION, sequence: "M" })).toThrow(A3ValidationError); }); it("throws A3ValidationError with issues array", () => { try { - new A3({ sequence: "M" }); + new A3({ $schema: A3_SCHEMA_URI, a3_version: A3_VERSION, sequence: "M" }); } catch (e) { expect(e).toBeInstanceOf(A3ValidationError); expect((e as A3ValidationError).issues.length).toBeGreaterThan(0); @@ -45,6 +46,8 @@ describe("A3 constructor", () => { expect( () => new A3({ + $schema: A3_SCHEMA_URI, + a3_version: A3_VERSION, sequence: "MKTAY", annotations: { site: { A: { index: [99], type: "" } } }, }), @@ -158,8 +161,8 @@ describe("A3.toJSON and JSON.stringify", () => { it("serialized JSON contains all annotation families", () => { const a3 = new A3({ - $schema: "https://schema.rtemis.org/a3/v1/schema.json", - a3_version: "1.0.0", + $schema: A3_SCHEMA_URI, + a3_version: A3_VERSION, sequence: "MKTAY", }); const parsed = JSON.parse(a3.toJSONString()) as { annotations: Record }; @@ -172,8 +175,8 @@ describe("A3.toJSON and JSON.stringify", () => { it("type field is always present on annotation entries", () => { const a3 = new A3({ - $schema: "https://schema.rtemis.org/a3/v1/schema.json", - a3_version: "1.0.0", + $schema: A3_SCHEMA_URI, + a3_version: A3_VERSION, sequence: "MKTAY", annotations: { site: { A: { index: [1, 2] } }, // type omitted — defaults to "" From 89431748cc253e295501b346e392d104714f76b2 Mon Sep 17 00:00:00 2001 From: Stathis Gennatas Date: Tue, 31 Mar 2026 06:33:45 -0700 Subject: [PATCH 12/13] fix: format error expectation for A3ValidationError in constructor tests --- typescript/tests/a3.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/typescript/tests/a3.test.ts b/typescript/tests/a3.test.ts index 431d4c7..ed63779 100644 --- a/typescript/tests/a3.test.ts +++ b/typescript/tests/a3.test.ts @@ -30,7 +30,9 @@ describe("A3 constructor", () => { }); it("throws A3ValidationError for invalid input", () => { - expect(() => new A3({ $schema: A3_SCHEMA_URI, a3_version: A3_VERSION, sequence: "M" })).toThrow(A3ValidationError); + expect(() => new A3({ $schema: A3_SCHEMA_URI, a3_version: A3_VERSION, sequence: "M" })).toThrow( + A3ValidationError, + ); }); it("throws A3ValidationError with issues array", () => { From 2c6ac08e14472d7c6eb3592175f8a96be58e3482 Mon Sep 17 00:00:00 2001 From: Stathis Gennatas Date: Tue, 31 Mar 2026 06:34:00 -0700 Subject: [PATCH 13/13] fix: update test script to include pnpm check before running vitest --- typescript/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typescript/package.json b/typescript/package.json index 5be3251..fc0dac1 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -36,7 +36,7 @@ "scripts": { "build": "tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit -p tsconfig.build.json", - "test": "pnpm typecheck && vitest run", + "test": "pnpm typecheck && pnpm check && vitest run", "check": "biome check src tests", "lint": "biome lint src tests", "format": "biome format src tests",