diff --git a/features/ir_interchange.feature b/features/ir_interchange.feature
index c65372e4..63128812 100644
--- a/features/ir_interchange.feature
+++ b/features/ir_interchange.feature
@@ -1,7 +1,8 @@
Feature: IR interchange
As a Morphir tooling user
I want to inspect and convert versioned morphir-ir.json files
- So that I can move models between IR format 3 (3.0.x and 3.1.x) and draft IR v4
+ So that I can move models between IR format 3 (3.0.x and 3.1.x) and draft IR v4,
+ and upgrade files in the legacy formats 1 and 2
Scenario: Inspect a morphir-elm format 3 file
Given the golden fixture "elm-simpleValueTree.json"
@@ -67,3 +68,21 @@ Feature: IR interchange
Then the command exits with code 1
And the errors contain "unsupported_v4_downgrade"
And no converted file exists
+
+ Scenario: Upgrade a legacy format 1 file to format 3
+ Given the legacy fixture "elm-rentals-format-1.json"
+ When I run "ir inspect" on it
+ Then the command exits with code 0
+ And the output contains "format: 1 (legacy)"
+ And the output contains "package: morphir/example/app"
+ When I convert it to "v3"
+ Then the command exits with code 0
+ And the converted file declares formatVersion 3
+ When I inspect the converted file
+ Then the output contains "format: 3.0.0"
+
+ Scenario: Upgrade a legacy format 2 file to draft v4
+ Given the legacy fixture "elm-rentals-format-2.json"
+ When I convert it to "v4"
+ Then the command exits with code 0
+ And the converted file declares formatVersion 4
diff --git a/features/steps/ir_interchange_steps.py b/features/steps/ir_interchange_steps.py
index 2e69beb3..81d4b57e 100644
--- a/features/steps/ir_interchange_steps.py
+++ b/features/steps/ir_interchange_steps.py
@@ -16,6 +16,7 @@
from behave.runner import Context
FIXTURES = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "ir" / "v3"
+LEGACY_FIXTURES = FIXTURES.parent / "legacy"
def _workdir(context: Context) -> Path:
@@ -35,6 +36,13 @@ def step_fixture(context: Context, name: str) -> None:
context.fixture = context.input
+@given('the legacy fixture "{name}"')
+def step_legacy_fixture(context: Context, name: str) -> None:
+ """Select a morphir-elm format-1 or format-2 fixture as input."""
+ context.input = LEGACY_FIXTURES / name
+ context.fixture = context.input
+
+
@given("a file with the JSON '{text}'")
def step_json_file(context: Context, text: str) -> None:
"""Write JSON text to an input file."""
diff --git a/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/.openspec.yaml b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/.openspec.yaml
new file mode 100644
index 00000000..dda53b8a
--- /dev/null
+++ b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: html-design
+created: 2026-10-27
diff --git a/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/design.html b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/design.html
new file mode 100644
index 00000000..2a6c17d4
--- /dev/null
+++ b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/design.html
@@ -0,0 +1,210 @@
+
+
+
See proposal.md for the motivation. Today morphir.ir.format_version normalizes the integers
+1 and 2 to releases 1.0.0 and 2.0.0 only so that check_supported can refuse
+them with unsupported_format_version_major ("formats 1 and 2 are follow-up work"). The support table is
+[3.0.0,3.2.0),[4.0.0,4.1.0) since bead morphir-ukvn.8.13 (IR 3.1.0, PR #34) merged; this
+change does not touch the table. That change also made the v3 distribution Library | Specs; the legacy
+formats only ever produce a Library, because Specs needs format 3.1.0.
+
The finos/morphir format-version contract (kb/bundles/morphir/morphir-ir/format-version-support.md)
+says the release grammar starts at major 3. A support table therefore cannot hold format 1 or 2.
+
morphir-elm at bc99af69 is the source. Its FormatVersion/Codec.elm reads format 3
+with Distribution.Codec, format 1 with Distribution.CodecV1, and refuses everything
+else, format 2 included, with "Please regenerate it!".
+
+
What formats 1 and 2 are
+
From the morphir-elm history:
+
+
Commit
Date
Effect
+
+
8e0e729d
2021-10-08
Copies the codecs to */CodecV1.elm, sets
+currentFormatVersion = 2, and decodes format 1 with CodecV1.
+
e742a620
2021-10-08
Format 2 shapes: TitleCase distribution, access, type and
+literal tags; {"access", "value"}; {"doc", "value"}; [path, module] package
+entries; {"name", "tpe"} fields.
+
02aa36af
2023-05-18
Format 3: value and pattern tags become TitleCase. The old
+value codec is kept as Value/CodecV2.elm with Distribution/CodecV2.elm.
+
7cb7b300
2023-05-18
Removes the V2 codecs. Format 2 is refused from then
+on.
+
+
+
git diff 02aa36af^ bc99af69 over src/Morphir/IR/*/Codec.elm shows that only the value
+codec changed shape between format 2 and today's format 3 (the Decoration, Component and Source codecs also
+changed, and the Library distribution uses none of them). So format 2 is format 3.0.0 with snake_case value and pattern tags.
+
The removed Distribution/CodecV2.elm called Package.Codec, which by then used the TitleCase
+value codec, so elm's own format-2 reader could not read real format-2 values. This design ports the format, not
+that reader.
+
+
Goals and non-goals
+
Goals: read formats 1 and 2 exactly as morphir-elm wrote them, into the existing v3 model; let
+the CLI upgrade them; keep the support table, the diagnostic codes, and the format 3.0.0 codec as they are.
+
Non-goals: writing formats 1 or 2; files with no formatVersion (still
+missing_format_version); the string spellings "1" and "2", which no tool
+wrote; a separate Python model for the legacy formats.
+
+
Decisions
+
+
FV-2. A legacy path beside the support table
+
format_version gains a LegacyFormat enum (V1, V2) and two
+readers:
Only a JSON integer 1 or 2 (not a boolean) is legacy. Every other value goes through
+read_format_version unchanged, so it fails with the same codes. read_format_version and
+read_document keep refusing 1 and 2 with unsupported_format_version_major; only the
+message changes, from "follow-up work" to the names of the legacy readers. read_legacy_document refuses
+any version but the one it is asked for with unsupported_format_version_major. No new diagnostic code
+is needed.
+
Alternatives. Adding [1.0.0,1.1.0) and [2.0.0,2.1.0) to the table breaks
+the finos grammar and changes the table spelling that tools and reports compare. A boolean flag on
+read_document would put the legacy rule inside the code path that bead morphir-ukvn.8.13
+edits. The new functions sit at the end of the module; the only shared edits are a message string, a refactor of
+read_document into a private root reader, and a guard in write_format_version.
+
+
FV-3. Writers never emit 1 or 2
+
write_format_version raises ValueError for a major below 3. A
+FormatVersion(1, 0, 0) can still come out of recognize_format_version, so the guard keeps a
+mistake from writing it. The legacy modules have no encoders.
+
+
CDC-1. Legacy decoders in their own modules, into the v3 model
+
As in elm, the legacy decoders produce the same model as format 3.0.0, so decoding is the upgrade and
+morphir.ir.migrate needs no new function. The modules are:
+
+
Module
Ports
Public decoders
+
+
morphir.ir.v3.codec_v1
morphir-elm */CodecV1.elm at bc99af69
+(decoders)
literal, type, type spec and definition, access control, documented, pattern, value, value spec
+and definition, module, package, distribution, versioned distribution
+
morphir.ir.v3.codec_v2
Value/CodecV2.elm at 02aa36af, with
+the format 3.0.0 codec for the rest
pattern, value, value definition, module and package definition,
+distribution, versioned distribution
+
morphir.ir.v3._legacy_value (private)
The snake_case value and pattern decoders that
+formats 1 and 2 share
Built from a literal decoder and a type decoder, because literals and types differ
+between the two formats
+
+
+
The format 3.0.0 codec is not edited: it still refuses 1 and 2, and bead morphir-ukvn.8.13 can change
+it without conflicts. The legacy modules import its primitive helpers (_tag, _field, and so on)
+rather than copy them.
+
Alternatives. A tag-renaming JSON rewrite cannot work, because "variable",
+"reference", "tuple", "record" and "unit" are both type and value
+tags and the shapes around them differ. Parameterizing the 3.0.0 codec by spelling would branch inside it.
+
+
CDC-2. Faithful to the elm decoders, including their quirks
+
+
Documented (format 1) is elm's Decode.oneOf: try [doc, x], then the bare x.
+When both fail, the DiagnosticError holds the diagnostics of both, as elm's oneOf error lists both.
+
Derived types: elm CodecV1.encodeSpecification writes "DerivedTypeSpecification" but
+decodeSpecification reads only "derived_type_specification". Derived types reached CodecV1
+in 2022, after format 1 stopped being written, so no real file has them; the port follows the decoder.
+
Format 1 module specifications and definitions decode with doc = None and ignore a
+doc member (Decode.succeed Nothing). Format 2 reads the optional doc, as format 3.0.0
+does.
+
Literal payloads are checked by the format 3.0.0 literal decoder after the tag is renamed, so the rules for
+characters, integers and decimals are the same.
+
+
+
CDC-3. morphir-elm wins over the finos v2 schema docs
+
finos/morphir docs/spec/ir/schemas/v2/whats-new.md says format 2 literals stay snake_case and names
+an integer literal whole_number_literal. morphir-elm wrote "StringLiteral" in format 2 (see the
+rentals fixture) and "int_literal" in format 1. As for format 3, morphir-elm is normative.
+
+
CLI-2. Dispatch in the CLI
+
ir inspect and ir convert read the root with read_document_with_legacy and
+choose codec_v1, codec_v2, the 3.0.0 codec or the v4 codec. Inspect prints
+format: 1 (legacy) and a note that ir convert upgrades the file. Convert writes format
+3.0.0 or, through v3_to_v4, draft v4. The Target enum stays v3 and
+v4: there is no way to ask for format 1 or 2.
+
+
TST-2. Real fixtures, one model in two formats
+
morphir-elm tests-integration/cli/test-data/rentals/ has the same example written as format 1
+(expected-morphir-ir-v1.json) and as format 2 (expected-morphir-ir.json). Both are copied
+byte for byte to tests/fixtures/ir/legacy/. Decoding both must give the same Library, which checks the
+two decoders against each other. Hand-written JSON covers every tag that the small fixtures do not use, with the
+spellings taken from the elm source.
+
+
Spelling table
+
+
Construct
Format 1
Format 2
Format 3.0.0
+
+
Distribution
"library"
"Library"
"Library"
+
Access control
["public", x]
{"access": "Public", "value": x}
same as 2
+
Documented
[doc, x] or x
{"doc", "value"} or x
same as 2
+
Package modules
{"name", "spec"}, {"name", "def"}
[path, module]
same as 2
+
Module doc
none
optional "doc"
optional "doc"
+
Type tags
"variable", "extensible_record", ...
"Variable", "ExtensibleRecord", ...
same as 2
+
Record field
[name, type]
{"name", "tpe"}
same as 2
+
Type spec and definition
"custom_type_definition", ...
"CustomTypeDefinition", ...
same as 2
+
Literals
"int_literal", "bool_literal", ...
"WholeNumberLiteral", "BoolLiteral", ...
same as 2
+
Value and pattern tags
"let_definition", "wildcard_pattern", ...
same as 1
"LetDefinition", "WildcardPattern", ...
+
+
+
+
Risks and trade-offs
+
+
Risk
Mitigation
+
+
Format-1 files written before later CodecV1 edits (value documentation in 2022, decimals) may differ from
+what the elm decoder reads today.
The port follows today's elm decoder, which is what morphir-elm users get.
+Documented entries accept the bare value, which covers files written before value documentation.
+
The fixtures are small and use few tags.
Per-tag unit tests with hand-written JSON for every
+type, value, pattern, literal and wrapper tag.
+
Importing private helpers from codec.py couples the modules.
The helpers are stable
+JSON-shape checks. Moving them to a shared module would edit codec.py, which bead
+morphir-ukvn.8.13 edits in parallel; that can be a later refactor.
+
Rebase conflicts with IR 3.1.0 in format_version.py, the CLI, and the specs.
The
+support table and the 3.1 message are not touched; the legacy code is appended in its own section. This branch
+was rebased on PR #34 and its spec deltas rewritten against the merged specs.
+
+
+
+
Migration plan
+
No data migration. Users who got unsupported_format_version_major for a format-1 or format-2 file
+can now run morphir-tools ir convert old.json --to v3 -o new.json. Rollback is a revert: no stored
+data or public name is removed.
+
+
+
+
diff --git a/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/proposal.md b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/proposal.md
new file mode 100644
index 00000000..e9e1c4d7
--- /dev/null
+++ b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/proposal.md
@@ -0,0 +1,37 @@
+# Proposal
+
+## Why
+
+morphir-elm wrote `"formatVersion": 1` until October 2021 and `"formatVersion": 2` until May 2023, and old `morphir-ir.json` files in those formats still exist. morphir-python refuses both with `unsupported_format_version_major`, so a user with an old file must find an old morphir-elm to regenerate it. morphir-elm still reads format 1 through its `CodecV1` modules; this change gives morphir-python the same ability, extends it to format 2, and upgrades both to format 3.0.0.
+
+## What Changes
+
+- Add read-only decoders for format 1 (port of morphir-elm `*/CodecV1.elm` at `bc99af69`) and format 2 (format 3.0.0 shapes with the snake_case value and pattern tags of morphir-elm `Value/CodecV2.elm` at `02aa36af`). Both decode into the existing format 3.0.0 model, as elm does, so encoding the result with the 3.0.0 codec is the upgrade.
+- Reading format 2 is a deliberate divergence from morphir-elm today, which refuses it ("Please regenerate it!").
+- Teach the shared format-version reader to recognize the legacy integers `1` and `2` through a separate legacy path. The support table, its spelling, and the existing diagnostic codes stay as they are: the finos release grammar starts at major 3, so formats 1 and 2 never enter a support table, and `read_format_version` still refuses them. No writer emits format 1 or 2.
+- `morphir-tools ir inspect` reports legacy files, and `ir convert` upgrades them to format 3.0.0 or draft v4. Today both commands refuse formats 1 and 2.
+- Add the morphir-elm rentals example in format 1 and format 2 as golden fixtures, with provenance.
+
+## Capabilities
+
+### New Capabilities
+
+- (none)
+
+### Modified Capabilities
+
+- `ir/format-version`: formats 1 and 2 are recognized as legacy, read-only formats outside the support table, instead of being refused as follow-up work; legacy formats are never written.
+- `ir/v3`: legacy decoders read formats 1 and 2 into the format 3.0.0 model.
+- `tools/ir-interchange`: inspect and convert accept formats 1 and 2 and upgrade them; later 3.x minors (3.2.0 on) stay refused.
+
+## Beads
+
+- **Tracked by**: `morphir-ukvn.8.12`
+- **Related**: `morphir-ukvn.8` (parent epic, first-class IR v3), `morphir-ukvn.8.13` (IR 3.1.0, merged first; it set the support table to `[3.0.0,3.2.0),[4.0.0,4.1.0)` and added the v3 `Specs` distribution, which legacy formats never produce)
+
+## Impact
+
+- `packages/morphir`: `ir.format_version` gains `LegacyFormat`, `recognize_legacy_format`, `read_document_with_legacy` and `read_legacy_document`; `write_format_version` refuses majors 1 and 2. New modules `ir.v3.codec_v1`, `ir.v3.codec_v2` and the private `ir.v3._legacy_value`. The 3.0.0 codec and the v4 package do not change. No new dependency.
+- `packages/morphir-tools`: `ir inspect` and `ir convert` dispatch on legacy formats.
+- Tests and fixtures: `tests/fixtures/ir/legacy/` with two morphir-elm files; unit, CLI and BDD tests. The CLI tests that expected formats 1 and 2 to be refused change.
+- Documentation: the `morphir` and `morphir-tools` READMEs.
diff --git a/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/specs/ir/format-version/spec.md b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/specs/ir/format-version/spec.md
new file mode 100644
index 00000000..8ed4ca94
--- /dev/null
+++ b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/specs/ir/format-version/spec.md
@@ -0,0 +1,53 @@
+# Spec Delta
+
+## MODIFIED Requirements
+
+### Requirement: Support table with IR 3.1 is checked before decoding
+The reader SHALL declare the support table `[3.0.0,3.2.0),[4.0.0,4.1.0)` and MUST finish recognition and the support check before invoking any distribution decoder or migration. A release outside the table SHALL fail with `unsupported_format_version_major` or `unsupported_format_version_minor`. (bead: morphir-ukvn.8.13)
+
+#### Scenario: IR 3.1.0 is supported
+- **WHEN** a document declares `formatVersion` `"3.1.0"` or `"3.1.4"`
+- **THEN** the support check passes and the distribution is decoded
+
+#### Scenario: A later 3.x minor is refused
+- **WHEN** a document declares `formatVersion` `"3.2.0"`
+- **THEN** reading fails with `unsupported_format_version_minor` before the distribution is decoded, and the message names the accepted table `[3.0.0,3.2.0),[4.0.0,4.1.0)`
+
+#### Scenario: Historical versions are refused
+- **WHEN** a document declares `formatVersion` `1` or `2` and is read against a support table
+- **THEN** reading fails with `unsupported_format_version_major` and a message that names the legacy readers for formats 1 and 2
+
+### Requirement: Canonical format version output
+Writers SHALL emit `formatVersion` first, as the integer `N` for a baseline release `N.0.0` and as the exact release string otherwise, regardless of the spelling that was read. Writers MUST NOT emit the legacy formats 1 and 2. (bead: morphir-ukvn.8)
+
+#### Scenario: Read string, write integer
+- **WHEN** a document read with `formatVersion` `"3.0.0"` is written again
+- **THEN** the output declares `formatVersion` as the integer `3`
+
+#### Scenario: Legacy versions are never written
+- **WHEN** a caller asks for the spelling of format 1 or 2
+- **THEN** the writer refuses instead of returning `1` or `2`
+
+## ADDED Requirements
+
+### Requirement: Legacy formats are recognized outside the support table
+A legacy-aware read SHALL recognize the JSON integers `1` and `2` as legacy formats 1 and 2 and return them without a support check, because the finos release grammar starts at major 3. Every other value SHALL be read and checked as before, with the same diagnostics. The strings `"1"`, `"2"` and `"1.0.0"` MUST stay invalid, because morphir-elm wrote legacy formats only as integers. (bead: morphir-ukvn.8.12)
+
+#### Scenario: Integer legacy version
+- **WHEN** a legacy-aware read sees `formatVersion` `1` or `2`
+- **THEN** it reports legacy format 1 or 2 and does not consult the support table
+
+#### Scenario: Other versions keep their diagnostics
+- **WHEN** a legacy-aware read sees `formatVersion` `"1"`, `true`, `5`, or no `formatVersion`
+- **THEN** it fails with `invalid_format_version_syntax`, `invalid_format_version_type`, `unsupported_format_version_major`, or `missing_format_version`, as the table reader does
+
+#### Scenario: Support table keeps the release grammar
+- **WHEN** a caller reads the support table
+- **THEN** every interval starts at major 3 or above and its spelling is unchanged
+
+### Requirement: A legacy reader accepts exactly one legacy format
+A reader for one legacy format SHALL accept a document only when its `formatVersion` is that legacy integer. Any other recognized version SHALL fail with `unsupported_format_version_major`, naming the version found and the one legacy format accepted. (bead: morphir-ukvn.8.12)
+
+#### Scenario: Format 3 at the format-1 reader
+- **WHEN** a document with `formatVersion` 3 is passed to the format-1 reader
+- **THEN** reading fails with `unsupported_format_version_major` and says that only legacy format 1 is accepted
diff --git a/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/specs/ir/v3/spec.md b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/specs/ir/v3/spec.md
new file mode 100644
index 00000000..e10c75bf
--- /dev/null
+++ b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/specs/ir/v3/spec.md
@@ -0,0 +1,48 @@
+# Spec Delta
+
+## ADDED Requirements
+
+### Requirement: Format 1 is read into the format 3.0.0 model
+The library SHALL decode `"formatVersion": 1` documents with the spellings of the morphir-elm `*/CodecV1.elm` decoders at `bc99af69` into a v3 `Library`, never a `Specs`: snake_case tags, `["public", x]` access control, `[doc, x]` or bare documentation, `[name, type]` record fields, `{"name", "spec"}` and `{"name", "def"}` package modules, and no module documentation. (bead: morphir-ukvn.8.12)
+
+#### Scenario: morphir-elm format-1 output decodes
+- **WHEN** the morphir-elm rentals example written as format 1 is decoded
+- **THEN** decoding succeeds and gives a v3 Library with the package name `morphir/example/app`
+
+#### Scenario: Format-3 tags are refused in format 1
+- **WHEN** a format-1 type, value, literal, or distribution uses a TitleCase tag such as `"Variable"` or `"Library"`
+- **THEN** decoding fails with `unknown_node`
+
+#### Scenario: Documentation pair or bare value
+- **WHEN** a format-1 documented entry is `["a doc", x]` or the bare `x`
+- **THEN** it decodes with doc `"a doc"` or `""`, and when both readings fail the error carries the diagnostics of both
+
+#### Scenario: Derived types use the elm decoder spelling
+- **WHEN** a format-1 type specification is tagged `"derived_type_specification"`
+- **THEN** it decodes, while `"DerivedTypeSpecification"`, which only the elm encoder wrote, fails with `unknown_node`
+
+### Requirement: Format 2 is read into the format 3.0.0 model
+The library SHALL decode `"formatVersion": 2` documents whose shapes are those of format 3.0.0 except that value and pattern tags are snake_case, as in morphir-elm `Value/CodecV2.elm` at `02aa36af`. Literals and types SHALL use their format-3 TitleCase tags, as morphir-elm wrote them. Reading format 2 is a deliberate divergence from morphir-elm, which now refuses it. (bead: morphir-ukvn.8.12)
+
+#### Scenario: morphir-elm format-2 output decodes
+- **WHEN** the morphir-elm rentals example written as format 2 is decoded
+- **THEN** decoding succeeds and gives a v3 Library
+
+#### Scenario: Mixed spellings are refused
+- **WHEN** a format-2 value uses the tag `"Literal"`, or a literal inside it uses `"bool_literal"`
+- **THEN** decoding fails with `unknown_node`
+
+### Requirement: Legacy formats upgrade to format 3.0.0
+Formats 1 and 2 SHALL decode into the same v3 model as format 3.0.0, so encoding the result with the format 3.0.0 codec is the upgrade, and the v3-to-v4 migration applies unchanged. The library MUST NOT provide encoders for formats 1 and 2, and the format 3.0.0 decoder SHALL keep refusing them. (bead: morphir-ukvn.8.12)
+
+#### Scenario: Both legacy formats give one model
+- **WHEN** the rentals example is decoded from its format-1 file and from its format-2 file
+- **THEN** both give the same v3 Library
+
+#### Scenario: Upgrade writes format 3
+- **WHEN** a decoded legacy document is encoded with the format 3.0.0 codec
+- **THEN** the output declares `formatVersion` 3 and decodes back to the same Library
+
+#### Scenario: The format 3.0.0 entry point stays strict
+- **WHEN** a format-1 or format-2 document is passed to the format 3.0.0 decoder
+- **THEN** decoding fails with `unsupported_format_version_major`
diff --git a/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/specs/tools/ir-interchange/spec.md b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/specs/tools/ir-interchange/spec.md
new file mode 100644
index 00000000..9360ff5c
--- /dev/null
+++ b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/specs/tools/ir-interchange/spec.md
@@ -0,0 +1,56 @@
+# Spec Delta
+
+## MODIFIED Requirements
+
+### Requirement: Inspect versioned morphir-ir.json
+The `ir inspect` command SHALL read a file with the shared format-version reader, decode it with the matching v3 or v4 codec, and report the normalized format version, the distribution kind, and the package name. A legacy format 1 or 2 file SHALL be decoded with its legacy decoder and reported as legacy. (bead: morphir-ukvn.8.13)
+
+#### Scenario: Report format version 3
+- **WHEN** the user inspects a morphir-elm `morphir-ir.json` with `formatVersion` 3
+- **THEN** the tool reports format 3.0.0, kind Library, and the package name, and exits 0
+
+#### Scenario: Report a v3 Specs
+- **WHEN** the user inspects a file with `formatVersion` `"3.1.0"` and a `Specs` distribution
+- **THEN** the tool reports format 3.1.0, kind Specs, and the package name, and exits 0
+
+#### Scenario: Unversioned IR is rejected clearly
+- **WHEN** the input JSON has no `formatVersion` member
+- **THEN** the tool exits non-zero, reports `missing_format_version`, and says the IR should be regenerated
+
+#### Scenario: Draft v4 is labelled
+- **WHEN** the user inspects a v4 file
+- **THEN** the report states that v4 support is draft
+
+#### Scenario: Legacy format is labelled
+- **WHEN** the user inspects a morphir-elm file with `formatVersion` 1 or 2
+- **THEN** the tool reports the legacy format, kind Library, and the package name, says that `ir convert` upgrades it to format 3.0.0, and exits 0
+
+## ADDED Requirements
+
+### Requirement: Legacy files are upgraded, never written
+The `ir convert` command SHALL read format 1 and format 2 files with the library legacy decoders and write them as a format 3.0.0 `Library` (`--to v3`) or draft v4 (`--to v4`, through the v3-to-v4 migration). A legacy file SHALL never become a `Specs` distribution, and no target SHALL write format 1 or 2. (bead: morphir-ukvn.8.12)
+
+#### Scenario: Upgrade a legacy file to format 3
+- **WHEN** the user converts the morphir-elm rentals example in format 1 or format 2 with `--to v3`
+- **THEN** the tool writes a `formatVersion` 3 Library file, the same file for both inputs, and exits 0
+
+#### Scenario: Upgrade a legacy file to v4
+- **WHEN** the user converts a format-1 file with `--to v4`
+- **THEN** the tool writes a `formatVersion` 4 file and exits 0
+
+#### Scenario: A broken legacy file leaves no output
+- **WHEN** the user converts a format-1 file whose distribution uses a format-3 tag
+- **THEN** the tool exits non-zero with `unknown_node` and does not create the output file
+
+### Requirement: Scope excludes later 3.x minors
+The tools MUST reject any 3.x release from 3.2.0 on, and any major outside the support table other than the legacy formats 1 and 2, rather than load or convert them. (bead: morphir-ukvn.8.12)
+
+#### Scenario: Unsupported format version is named
+- **WHEN** the user supplies a file whose `formatVersion` is `"3.2.0"` or 5
+- **THEN** the tool exits non-zero with the format-version diagnostic code and says only 3.0.x, 3.1.x and draft 4.0.x are supported
+
+## REMOVED Requirements
+
+### Requirement: Scope excludes formats 1 and 2 and later 3.x minors
+**Reason**: Formats 1 and 2 are now read and upgraded. The refusal of later 3.x minors moves to the requirement "Scope excludes later 3.x minors".
+**Migration**: Convert format 1 and 2 files with `ir convert --to v3`.
diff --git a/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/tasks.md b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/tasks.md
new file mode 100644
index 00000000..493b638b
--- /dev/null
+++ b/openspec/changes/archive/2026-10-27-add-ir-formats-1-2/tasks.md
@@ -0,0 +1,30 @@
+# Tasks
+
+## 1. Legacy format-version reading (morphir-ukvn.8.12.1)
+
+- [x] 1.1 Add `LegacyFormat`, `recognize_legacy_format`, `read_document_with_legacy` and `read_legacy_document` to `morphir.ir.format_version` without changing the support table; verify with `tests/unit/ir/test_format_version.py::TestLegacy`
+- [x] 1.2 Name the legacy readers in the `unsupported_format_version_major` message for 1 and 2, and make `write_format_version` refuse majors 1 and 2; verify with the updated `TestSupport` and `TestWriting` tests
+- [x] 1.3 Document the legacy path in the `format_version` module docstring; verify that the doctests in `tests/unit/ir/test_ir_docs.py` pass
+
+## 2. Format 1 and format 2 decoders (morphir-ukvn.8.12.2)
+
+- [x] 2.1 Copy the morphir-elm rentals example in format 1 and format 2 to `tests/fixtures/ir/legacy/` with a `PROVENANCE.md`, and point the v3 provenance note at it; verify that the files match the morphir-elm `bc99af69` files byte for byte
+- [x] 2.2 Add the shared snake_case value and pattern decoders in `morphir.ir.v3._legacy_value`; verify through the value and pattern tests of 2.3 and 2.4
+- [x] 2.3 Port the CodecV1 decoders to `morphir.ir.v3.codec_v1`; verify every tag and wrapper with `tests/unit/ir/test_v3_legacy_codec.py` (format-1 classes)
+- [x] 2.4 Add the format-2 decoders in `morphir.ir.v3.codec_v2`; verify with the `TestFormat2` tests
+- [x] 2.5 Check both rentals fixtures decode to the same Library, upgrade to format 3 and v4, and stay refused by the 3.0.0 decoder; verify with `TestElmFixtures`
+
+## 3. CLI upgrade of legacy files (morphir-ukvn.8.12.3)
+
+- [x] 3.1 Make `ir inspect` report legacy files and `ir convert` upgrade them to v3 and v4; verify with the legacy tests in `tests/tools/test_cli.py`, which replace the tests that expected 1 and 2 to be refused
+- [x] 3.2 Add BDD scenarios for inspecting and upgrading a legacy file to `features/ir_interchange.feature`; verify with `mise run test-bdd`
+
+## 4. Docs and integration checks (morphir-ukvn.8.12.4)
+
+- [x] 4.1 Describe legacy formats 1 and 2 in `packages/morphir/README.md` and `packages/morphir-tools/README.md`, and list the new modules in the `morphir.ir.v3` docstring; verify with `tests/unit/ir/test_ir_docs.py`
+- [x] 4.2 Run `mise run check` and `mise run test-bdd`; verify lint, format, mypy, pyright, tests and coverage of at least 80% pass
+
+## Workflow follow-up
+
+- Archive the change in the same branch with the `openspec-archive-change` skill; fix the design.html asset prefix and set the epic spec id to the archived path.
+- Open the pull request, watch CI and review, and close the group beads.
diff --git a/openspec/specs/ir/format-version/spec.md b/openspec/specs/ir/format-version/spec.md
index b9b20b79..a9a475d6 100644
--- a/openspec/specs/ir/format-version/spec.md
+++ b/openspec/specs/ir/format-version/spec.md
@@ -39,12 +39,16 @@ Format-version failures SHALL use the finos/morphir contract codes: `missing_for
- **THEN** reading fails with `duplicate_format_version` rather than keeping the last value
### Requirement: Canonical format version output
-Writers SHALL emit `formatVersion` first, as the integer `N` for a baseline release `N.0.0` and as the exact release string otherwise, regardless of the spelling that was read. (bead: morphir-ukvn.8)
+Writers SHALL emit `formatVersion` first, as the integer `N` for a baseline release `N.0.0` and as the exact release string otherwise, regardless of the spelling that was read. Writers MUST NOT emit the legacy formats 1 and 2. (bead: morphir-ukvn.8)
#### Scenario: Read string, write integer
- **WHEN** a document read with `formatVersion` `"3.0.0"` is written again
- **THEN** the output declares `formatVersion` as the integer `3`
+#### Scenario: Legacy versions are never written
+- **WHEN** a caller asks for the spelling of format 1 or 2
+- **THEN** the writer refuses instead of returning `1` or `2`
+
### Requirement: Support table with IR 3.1 is checked before decoding
The reader SHALL declare the support table `[3.0.0,3.2.0),[4.0.0,4.1.0)` and MUST finish recognition and the support check before invoking any distribution decoder or migration. A release outside the table SHALL fail with `unsupported_format_version_major` or `unsupported_format_version_minor`. (bead: morphir-ukvn.8.13)
@@ -57,5 +61,27 @@ The reader SHALL declare the support table `[3.0.0,3.2.0),[4.0.0,4.1.0)` and MUS
- **THEN** reading fails with `unsupported_format_version_minor` before the distribution is decoded, and the message names the accepted table `[3.0.0,3.2.0),[4.0.0,4.1.0)`
#### Scenario: Historical versions are refused
-- **WHEN** a document declares `formatVersion` `1` or `2`
-- **THEN** reading fails with `unsupported_format_version_major` and a message that formats 1 and 2 are follow-up work
+- **WHEN** a document declares `formatVersion` `1` or `2` and is read against a support table
+- **THEN** reading fails with `unsupported_format_version_major` and a message that names the legacy readers for formats 1 and 2
+
+### Requirement: Legacy formats are recognized outside the support table
+A legacy-aware read SHALL recognize the JSON integers `1` and `2` as legacy formats 1 and 2 and return them without a support check, because the finos release grammar starts at major 3. Every other value SHALL be read and checked as before, with the same diagnostics. The strings `"1"`, `"2"` and `"1.0.0"` MUST stay invalid, because morphir-elm wrote legacy formats only as integers. (bead: morphir-ukvn.8.12)
+
+#### Scenario: Integer legacy version
+- **WHEN** a legacy-aware read sees `formatVersion` `1` or `2`
+- **THEN** it reports legacy format 1 or 2 and does not consult the support table
+
+#### Scenario: Other versions keep their diagnostics
+- **WHEN** a legacy-aware read sees `formatVersion` `"1"`, `true`, `5`, or no `formatVersion`
+- **THEN** it fails with `invalid_format_version_syntax`, `invalid_format_version_type`, `unsupported_format_version_major`, or `missing_format_version`, as the table reader does
+
+#### Scenario: Support table keeps the release grammar
+- **WHEN** a caller reads the support table
+- **THEN** every interval starts at major 3 or above and its spelling is unchanged
+
+### Requirement: A legacy reader accepts exactly one legacy format
+A reader for one legacy format SHALL accept a document only when its `formatVersion` is that legacy integer. Any other recognized version SHALL fail with `unsupported_format_version_major`, naming the version found and the one legacy format accepted. (bead: morphir-ukvn.8.12)
+
+#### Scenario: Format 3 at the format-1 reader
+- **WHEN** a document with `formatVersion` 3 is passed to the format-1 reader
+- **THEN** reading fails with `unsupported_format_version_major` and says that only legacy format 1 is accepted
diff --git a/openspec/specs/ir/v3/spec.md b/openspec/specs/ir/v3/spec.md
index c0ff87c9..99032f71 100644
--- a/openspec/specs/ir/v3/spec.md
+++ b/openspec/specs/ir/v3/spec.md
@@ -100,3 +100,48 @@ The v3 writer SHALL emit `formatVersion` `3` for a `Library` and `"3.1.0"` for a
#### Scenario: Specs is written as 3.1.0
- **WHEN** a v3 `Specs` is encoded
- **THEN** the output declares `formatVersion` `"3.1.0"`
+
+### Requirement: Format 1 is read into the format 3.0.0 model
+The library SHALL decode `"formatVersion": 1` documents with the spellings of the morphir-elm `*/CodecV1.elm` decoders at `bc99af69` into a v3 `Library`, never a `Specs`: snake_case tags, `["public", x]` access control, `[doc, x]` or bare documentation, `[name, type]` record fields, `{"name", "spec"}` and `{"name", "def"}` package modules, and no module documentation. (bead: morphir-ukvn.8.12)
+
+#### Scenario: morphir-elm format-1 output decodes
+- **WHEN** the morphir-elm rentals example written as format 1 is decoded
+- **THEN** decoding succeeds and gives a v3 Library with the package name `morphir/example/app`
+
+#### Scenario: Format-3 tags are refused in format 1
+- **WHEN** a format-1 type, value, literal, or distribution uses a TitleCase tag such as `"Variable"` or `"Library"`
+- **THEN** decoding fails with `unknown_node`
+
+#### Scenario: Documentation pair or bare value
+- **WHEN** a format-1 documented entry is `["a doc", x]` or the bare `x`
+- **THEN** it decodes with doc `"a doc"` or `""`, and when both readings fail the error carries the diagnostics of both
+
+#### Scenario: Derived types use the elm decoder spelling
+- **WHEN** a format-1 type specification is tagged `"derived_type_specification"`
+- **THEN** it decodes, while `"DerivedTypeSpecification"`, which only the elm encoder wrote, fails with `unknown_node`
+
+### Requirement: Format 2 is read into the format 3.0.0 model
+The library SHALL decode `"formatVersion": 2` documents whose shapes are those of format 3.0.0 except that value and pattern tags are snake_case, as in morphir-elm `Value/CodecV2.elm` at `02aa36af`. Literals and types SHALL use their format-3 TitleCase tags, as morphir-elm wrote them. Reading format 2 is a deliberate divergence from morphir-elm, which now refuses it. (bead: morphir-ukvn.8.12)
+
+#### Scenario: morphir-elm format-2 output decodes
+- **WHEN** the morphir-elm rentals example written as format 2 is decoded
+- **THEN** decoding succeeds and gives a v3 Library
+
+#### Scenario: Mixed spellings are refused
+- **WHEN** a format-2 value uses the tag `"Literal"`, or a literal inside it uses `"bool_literal"`
+- **THEN** decoding fails with `unknown_node`
+
+### Requirement: Legacy formats upgrade to format 3.0.0
+Formats 1 and 2 SHALL decode into the same v3 model as format 3.0.0, so encoding the result with the format 3.0.0 codec is the upgrade, and the v3-to-v4 migration applies unchanged. The library MUST NOT provide encoders for formats 1 and 2, and the format 3.0.0 decoder SHALL keep refusing them. (bead: morphir-ukvn.8.12)
+
+#### Scenario: Both legacy formats give one model
+- **WHEN** the rentals example is decoded from its format-1 file and from its format-2 file
+- **THEN** both give the same v3 Library
+
+#### Scenario: Upgrade writes format 3
+- **WHEN** a decoded legacy document is encoded with the format 3.0.0 codec
+- **THEN** the output declares `formatVersion` 3 and decodes back to the same Library
+
+#### Scenario: The format 3.0.0 entry point stays strict
+- **WHEN** a format-1 or format-2 document is passed to the format 3.0.0 decoder
+- **THEN** decoding fails with `unsupported_format_version_major`
diff --git a/openspec/specs/tools/ir-interchange/spec.md b/openspec/specs/tools/ir-interchange/spec.md
index c686c1e7..1e3cc50c 100644
--- a/openspec/specs/tools/ir-interchange/spec.md
+++ b/openspec/specs/tools/ir-interchange/spec.md
@@ -6,7 +6,7 @@ Gives morphir-tools users commands to inspect versioned `morphir-ir.json` files
## Requirements
### Requirement: Inspect versioned morphir-ir.json
-The `ir inspect` command SHALL read a file with the shared format-version reader, decode it with the matching v3 or v4 codec, and report the normalized format version, the distribution kind, and the package name. (bead: morphir-ukvn.8.13)
+The `ir inspect` command SHALL read a file with the shared format-version reader, decode it with the matching v3 or v4 codec, and report the normalized format version, the distribution kind, and the package name. A legacy format 1 or 2 file SHALL be decoded with its legacy decoder and reported as legacy. (bead: morphir-ukvn.8.13)
#### Scenario: Report format version 3
- **WHEN** the user inspects a morphir-elm `morphir-ir.json` with `formatVersion` 3
@@ -24,6 +24,10 @@ The `ir inspect` command SHALL read a file with the shared format-version reader
- **WHEN** the user inspects a v4 file
- **THEN** the report states that v4 support is draft
+#### Scenario: Legacy format is labelled
+- **WHEN** the user inspects a morphir-elm file with `formatVersion` 1 or 2
+- **THEN** the tool reports the legacy format, kind Library, and the package name, says that `ir convert` upgrades it to format 3.0.0, and exits 0
+
### Requirement: CLI uses library code paths
The tools MUST NOT parse IR JSON with ad-hoc CLI code; reading, decoding, encoding, and migration SHALL go through the library APIs. (bead: morphir-ukvn.8)
@@ -58,9 +62,24 @@ The `ir convert` command SHALL convert format 3 (3.0.x and 3.1.x) to v4 and v4 t
- **WHEN** the user converts the same file with `--drop-input-attributes`
- **THEN** the tool writes a `formatVersion` 4 file, prints each `input_attributes_dropped` warning, and exits 0
-### Requirement: Scope excludes formats 1 and 2 and later 3.x minors
-The tools MUST reject formats 1 and 2 and any 3.x release from 3.2.0 on, rather than load or convert them. This differs from morphir-elm, which still reads format 1. (bead: morphir-ukvn.8.13)
+### Requirement: Legacy files are upgraded, never written
+The `ir convert` command SHALL read format 1 and format 2 files with the library legacy decoders and write them as a format 3.0.0 `Library` (`--to v3`) or draft v4 (`--to v4`, through the v3-to-v4 migration). A legacy file SHALL never become a `Specs` distribution, and no target SHALL write format 1 or 2. (bead: morphir-ukvn.8.12)
+
+#### Scenario: Upgrade a legacy file to format 3
+- **WHEN** the user converts the morphir-elm rentals example in format 1 or format 2 with `--to v3`
+- **THEN** the tool writes a `formatVersion` 3 Library file, the same file for both inputs, and exits 0
+
+#### Scenario: Upgrade a legacy file to v4
+- **WHEN** the user converts a format-1 file with `--to v4`
+- **THEN** the tool writes a `formatVersion` 4 file and exits 0
+
+#### Scenario: A broken legacy file leaves no output
+- **WHEN** the user converts a format-1 file whose distribution uses a format-3 tag
+- **THEN** the tool exits non-zero with `unknown_node` and does not create the output file
+
+### Requirement: Scope excludes later 3.x minors
+The tools MUST reject any 3.x release from 3.2.0 on, and any major outside the support table other than the legacy formats 1 and 2, rather than load or convert them. (bead: morphir-ukvn.8.12)
#### Scenario: Unsupported format version is named
-- **WHEN** the user supplies a file whose `formatVersion` is 1, 2, or `"3.2.0"`
+- **WHEN** the user supplies a file whose `formatVersion` is `"3.2.0"` or 5
- **THEN** the tool exits non-zero with the format-version diagnostic code and says only 3.0.x, 3.1.x and draft 4.0.x are supported
diff --git a/packages/morphir-tools/README.md b/packages/morphir-tools/README.md
index 495cc08f..6fae6017 100644
--- a/packages/morphir-tools/README.md
+++ b/packages/morphir-tools/README.md
@@ -27,25 +27,29 @@ This will also install the `morphir` core library as a dependency.
The `morphir-tools` command inspects and converts versioned `morphir-ir.json`
files. It reads format 3.0.x (what morphir-elm writes), format 3.1.x (which adds
-the v3 `Specs` distribution) and draft IR 4.0.x, and uses the `morphir` library
-for every read, write, and migration.
+the v3 `Specs` distribution) and draft IR 4.0.x, reads the legacy formats 1 and 2
+to upgrade them, and uses the `morphir` library for every read, write, and
+migration.
```bash
morphir-tools ir inspect morphir-ir.json
morphir-tools ir convert morphir-ir.json --to v4 -o out.json --package-version 1.2.0
morphir-tools ir convert v4.json --to v3 -o out.json --drop-source-locations
+morphir-tools ir convert old-format-1.json --to v3 -o out.json
```
`inspect` prints the normalized format version, the distribution kind
(`Library`, `Specs` or `Application`), and the package name, and labels v4 as
-draft. `convert` handles `Library` and `Specs` in both directions. It writes a
-v3 `Library` as format `3` and a v3 `Specs` as `"3.1.0"`, the lowest version that
-holds each. `convert` writes through a temporary file
-and renames it only on success, so a failed conversion leaves no output file.
+draft and formats 1 and 2 as legacy. `convert` handles `Library` and `Specs` in
+both directions. It writes a v3 `Library` as format `3` and a v3 `Specs` as
+`"3.1.0"`, the lowest version that holds each. A legacy format 1 or 2 file
+converts to a format 3.0.0 `Library` or draft v4; nothing writes format 1 or 2.
+`convert` writes through a temporary file and renames it only on success, so a
+failed conversion leaves no output file.
Diagnostics go to standard error as `code at location: message`.
Exit codes are 0 for success (also with warnings), 1 for a diagnostic, and 2 for
-a usage error. Formats 1 and 2 and 3.x releases from 3.2.0 on are rejected; see the
+a usage error. 3.x releases from 3.2.0 on are rejected; see the
[morphir package README](../morphir/README.md#morphir-ir-versions) for the
format-version rules and the draft v4 status.
diff --git a/packages/morphir-tools/src/morphir_tools/cli.py b/packages/morphir-tools/src/morphir_tools/cli.py
index 7aa857d5..f3338177 100644
--- a/packages/morphir-tools/src/morphir_tools/cli.py
+++ b/packages/morphir-tools/src/morphir_tools/cli.py
@@ -23,6 +23,7 @@
from morphir.ir.migrate import Err, Ok, v3_to_v4, v4_to_v3
from morphir.ir.migrate import drop_input_attributes as drop_inputs
from morphir.ir.v3 import codec as v3_codec
+from morphir.ir.v3 import codec_v1, codec_v2
from morphir.ir.v3 import distribution as v3_dist
from morphir.ir.v4 import codec as v4_codec
from morphir.ir.v4 import distribution as v4_dist
@@ -36,7 +37,8 @@
)
ir_app = typer.Typer(
help="Inspect and convert versioned morphir-ir.json files "
- "(format 3.0.x and 3.1.x, and draft 4.0.x).",
+ "(format 3.0.x and 3.1.x, and draft 4.0.x; legacy formats 1 and 2 are read "
+ "and upgraded).",
no_args_is_help=True,
)
app.add_typer(ir_app, name="ir")
@@ -91,6 +93,26 @@ def _kind(dist: v4_dist.Distribution | v3_dist.Distribution) -> str:
return "Application"
+def _read_v3_family(
+ text: str, version: fv.FormatVersion | fv.LegacyFormat
+) -> v3_dist.Distribution:
+ """Decode a format 1, 2, or 3.x document into the v3 model.
+
+ Legacy formats 1 and 2 always give a format 3.0.0 Library.
+ """
+ match version:
+ case fv.LegacyFormat.V1:
+ return codec_v1.decode_versioned_distribution(text)
+ case fv.LegacyFormat.V2:
+ return codec_v2.decode_versioned_distribution(text)
+ case _:
+ return v3_codec.decode_versioned_distribution(text)
+
+
+def _is_v4(version: fv.FormatVersion | fv.LegacyFormat) -> bool:
+ return isinstance(version, fv.FormatVersion) and version.major == 4
+
+
@ir_app.command()
def inspect(
file: Annotated[Path, typer.Argument(help="A versioned morphir-ir.json file.")],
@@ -98,25 +120,31 @@ def inspect(
"""Report the format version, distribution kind, and package name."""
text = _read(file)
try:
- version, _ = fv.read_document(text)
- if version.major == 3:
- classic = v3_codec.decode_versioned_distribution(text)
+ version, _ = fv.read_document_with_legacy(text)
+ if _is_v4(version):
+ dist = v4_codec.decode_versioned_distribution(text)
+ kind = _kind(dist)
+ package = v4_codec.encode_path(dist.package.name)
+ else:
+ classic = _read_v3_family(text, version)
kind = _kind(classic)
package = v4_codec.encode_path(
Path4(tuple(Name4(tuple(n)) for n in classic.package_name))
)
- else:
- dist = v4_codec.decode_versioned_distribution(text)
- kind = _kind(dist)
- package = v4_codec.encode_path(dist.package.name)
except DiagnosticError as error:
_report(error.diagnostics)
raise typer.Exit(1) from None
- typer.echo(f"format: {version}")
+ legacy = isinstance(version, fv.LegacyFormat)
+ typer.echo(f"format: {version}{' (legacy)' if legacy else ''}")
typer.echo(f"kind: {kind}")
typer.echo(f"package: {package}")
- if version.major == 4:
+ if _is_v4(version):
typer.echo(f"note: {v4_codec.DRAFT_NOTICE}")
+ if legacy:
+ typer.echo(
+ f"note: legacy format {version} is read-only; "
+ "ir convert upgrades it to format 3.0.0 or draft v4"
+ )
@ir_app.command()
@@ -145,21 +173,22 @@ def convert(
"""Convert between format 3 (3.0.x and 3.1.x) and draft IR v4.
A v3 Library is written as format 3 and a v3 Specs as format 3.1.0, the lowest
- version that holds each.
+ version that holds each. Legacy formats 1 and 2 are read as a format 3.0.0
+ Library and never written.
The output is written to a temporary file and renamed only on success, so a
failed conversion leaves no output file.
"""
text = _read(file)
try:
- version, _ = fv.read_document(text)
+ version, _ = fv.read_document_with_legacy(text)
if to is Target.V4:
upgraded: Ok[v4_dist.Distribution]
- if version.major == 4:
+ if _is_v4(version):
upgraded = Ok(v4_codec.decode_versioned_distribution(text))
else:
migrated = v3_to_v4(
- v3_codec.decode_versioned_distribution(text),
+ _read_v3_family(text, version),
package_version=package_version,
)
upgraded = Ok(migrated.value, migrated.warnings)
@@ -168,8 +197,8 @@ def convert(
document = v4_codec.encode_versioned_distribution(upgraded.value)
warnings: tuple[Diagnostic, ...] = upgraded.warnings
else:
- if version.major == 3:
- classic = v3_codec.decode_versioned_distribution(text)
+ if not _is_v4(version):
+ classic = _read_v3_family(text, version)
warnings = ()
else:
result = v4_to_v3(
diff --git a/packages/morphir/README.md b/packages/morphir/README.md
index d1ccf028..22aa052d 100644
--- a/packages/morphir/README.md
+++ b/packages/morphir/README.md
@@ -36,6 +36,7 @@ from morphir.ir import Type, Value
| Package | IR | Source of truth | JSON codec |
|---------|----|-----------------|------------|
| `morphir.ir.v3` | format 3.0.0, what morphir-elm writes today, and its 3.1.0 revision | [morphir-elm](https://github.com/finos/morphir-elm) at `bc99af69` for 3.0.0; [finos/morphir](https://github.com/finos/morphir) at `efa36060` for what 3.1.0 adds | `morphir.ir.v3.codec`, matches morphir-elm byte for byte for 3.0.0 |
+| `morphir.ir.v3` (legacy) | formats 1 and 2, read-only | morphir-elm `*/CodecV1.elm` at `bc99af69`; `Value/CodecV2.elm` at `02aa36af` | `morphir.ir.v3.codec_v1`, `morphir.ir.v3.codec_v2`, decoders only |
| `morphir.ir.v4` | IR v4, **draft upstream** | [finos/morphir](https://github.com/finos/morphir) at `efa36060` | `morphir.ir.v4.codec`, **draft** |
`morphir.ir` and its old submodule paths (`morphir.ir.name`, `morphir.ir.value`,
@@ -122,11 +123,47 @@ files written by other tools:
accepts `3` and `"3.0.0"` but rejects `"3"`.
The support table is `[3.0.0,3.2.0),[4.0.0,4.1.0)`, the finos reference table.
-Formats 1 and 2 fail with `unsupported_format_version_major`; a later 3.x minor
-such as `"3.2.0"` fails with `unsupported_format_version_minor`.
+Formats 1 and 2 are not in it (see below) and fail there with
+`unsupported_format_version_major`; a later 3.x minor such as `"3.2.0"` fails
+with `unsupported_format_version_minor`.
Failures raise `morphir.ir.diagnostics.DiagnosticError`, whose diagnostics carry
the stable codes of the finos contract.
+### Legacy formats 1 and 2
+
+morphir-elm wrote `"formatVersion": 1` until October 2021 and `2` until May 2023.
+The finos release grammar starts at major 3, so these legacy formats are outside
+every support table. `read_document_with_legacy` and `read_legacy_document`
+recognize them, only as the integers `1` and `2`, and `morphir.ir.v3.codec_v1` and
+`morphir.ir.v3.codec_v2` decode them into the format 3.0.0 model, as morphir-elm
+does. Encoding the result with `morphir.ir.v3.codec` writes format 3, so decoding
+is the upgrade. No writer emits format 1 or 2.
+
+Format 2 is format 3.0.0 with snake_case value and pattern tags
+(`"let_definition"`, `"wildcard_pattern"`). Format 1 is snake_case throughout and
+also differs in access control, documentation, record fields, and package modules;
+the `codec_v1` docstring lists the differences. morphir-elm reads format 1 but no
+longer reads format 2; reading format 2 here is a deliberate divergence.
+
+```python
+from pathlib import Path
+
+from morphir.ir.format_version import LegacyFormat, read_document_with_legacy
+from morphir.ir.v3 import codec, codec_v1, codec_v2
+
+legacy = Path("tests/fixtures/ir/legacy")
+text = (legacy / "elm-rentals-format-1.json").read_text()
+version, _ = read_document_with_legacy(text)
+assert version is LegacyFormat.V1
+
+library = codec_v1.decode_versioned_distribution(text)
+same = codec_v2.decode_versioned_distribution(
+ (legacy / "elm-rentals-format-2.json").read_text()
+)
+assert library == same
+assert codec.encode_versioned_distribution(library)["formatVersion"] == 3
+```
+
### Draft IR v4
IR v4 is not frozen upstream. `morphir.ir.v4.codec` follows the finos/morphir v4
diff --git a/packages/morphir/src/morphir/ir/format_version.py b/packages/morphir/src/morphir/ir/format_version.py
index 4a0d4034..437f4a93 100644
--- a/packages/morphir/src/morphir/ir/format_version.py
+++ b/packages/morphir/src/morphir/ir/format_version.py
@@ -16,6 +16,13 @@
(finos/morphir decision record 0018). IR 3.1.0 added the v3 ``Specs``
distribution; `V3_1` names that release. v4 support is draft.
+Formats 1 and 2 are legacy: the finos release grammar starts at major 3, so they
+are in no support table, and `read_format_version` refuses them with
+``unsupported_format_version_major``. `read_document_with_legacy` and
+`read_legacy_document` recognize them, only as the integers morphir-elm wrote, for
+the read-only decoders in `morphir.ir.v3.codec_v1` and `morphir.ir.v3.codec_v2`.
+`write_format_version` never writes them.
+
Example:
>>> read_format_version(3)
FormatVersion(major=3, minor=0, patch=0)
@@ -30,6 +37,7 @@
import json
import re
from dataclasses import dataclass
+from enum import Enum
from . import diagnostics as d
from .diagnostics import DiagnosticError, fail
@@ -195,7 +203,7 @@ def check_supported(
loc: d.Location = (_CONSTRUCT,)
if not any(interval.lower.major == version.major for interval in table):
if version.major in (1, 2):
- detail = "formats 1 and 2 are follow-up work"
+ detail = _LEGACY_DETAIL
else:
detail = f"format family {version.major} is not supported"
raise fail(
@@ -250,7 +258,13 @@ def read_format_version(
def write_format_version(version: FormatVersion) -> int | str:
- """Return the canonical spelling: integer N for N.0.0, else the release string."""
+ """Return the canonical spelling: integer N for N.0.0, else the release string.
+
+ Raises:
+ ValueError: For a legacy major (1 or 2); legacy formats are never written.
+ """
+ if version.major < 3:
+ raise ValueError(f"format {version.major} is legacy and is never written")
if version.minor == 0 and version.patch == 0:
return version.major
return str(version)
@@ -327,6 +341,11 @@ def read_document(
Raises:
DiagnosticError: With a format-version or JSON diagnostic.
"""
+ obj = _versioned_root(document)
+ return read_format_version(obj[_CONSTRUCT], table), obj
+
+
+def _versioned_root(document: str | object) -> dict[str, object]:
root = parse_json(document) if isinstance(document, str) else document
if not isinstance(root, dict):
raise fail(
@@ -343,8 +362,7 @@ def read_document(
"the IR has no formatVersion member; it is in an old format. "
"Please regenerate it!",
)
- obj: dict[str, object] = dict(root)
- return read_format_version(obj[_CONSTRUCT], table), obj
+ return dict(root)
def dump_json(value: object) -> str:
@@ -352,6 +370,93 @@ def dump_json(value: object) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
+# --- Legacy formats 1 and 2 (read-only) ---
+
+_LEGACY_DETAIL = (
+ "formats 1 and 2 are legacy formats outside the support table; "
+ "morphir.ir.v3.codec_v1 and codec_v2 read them as format 3.0.0"
+)
+
+
+class LegacyFormat(Enum):
+ """A morphir-elm format from before the release grammar, which starts at 3.
+
+ morphir-elm wrote formats 1 and 2 only as the JSON integers ``1`` and ``2``.
+ They are in no support table: the legacy readers decode them into the
+ format 3.0.0 model, and no writer emits them.
+ """
+
+ V1 = 1
+ V2 = 2
+
+ def __str__(self) -> str:
+ return str(self.value)
+
+
+def recognize_legacy_format(value: object) -> LegacyFormat | None:
+ """Return the legacy format for the JSON integer ``1`` or ``2``, else ``None``.
+
+ Example:
+ >>> recognize_legacy_format(1)
+
+ >>> recognize_legacy_format("1") is None
+ True
+ """
+ if isinstance(value, bool) or not isinstance(value, int):
+ return None
+ return {1: LegacyFormat.V1, 2: LegacyFormat.V2}.get(value)
+
+
+def read_document_with_legacy(
+ document: str | object, table: SupportTable = SUPPORT_TABLE
+) -> tuple[FormatVersion | LegacyFormat, dict[str, object]]:
+ """Read a document root like `read_document`, but report legacy formats.
+
+ The integers ``1`` and ``2`` give a `LegacyFormat`; every other value goes
+ through `read_format_version` with ``table`` and fails as it does there.
+
+ Raises:
+ DiagnosticError: With a format-version or JSON diagnostic.
+ """
+ obj = _versioned_root(document)
+ legacy = recognize_legacy_format(obj[_CONSTRUCT])
+ if legacy is not None:
+ return legacy, obj
+ return read_format_version(obj[_CONSTRUCT], table), obj
+
+
+def read_legacy_document(
+ document: str | object, accepted: LegacyFormat
+) -> dict[str, object]:
+ """Read the root of a legacy document and check that it is ``accepted``.
+
+ Args:
+ document: JSON text, or an already-decoded JSON value.
+ accepted: The one legacy format the caller decodes.
+
+ Returns:
+ The root object.
+
+ Raises:
+ DiagnosticError: With ``unsupported_format_version_major`` for any other
+ format, or with the recognition diagnostic for a malformed version.
+ """
+ obj = _versioned_root(document)
+ value = obj[_CONSTRUCT]
+ found: FormatVersion | LegacyFormat | None = recognize_legacy_format(value)
+ if found is accepted:
+ return obj
+ if found is None:
+ found = recognize_format_version(value)
+ raise fail(
+ d.UNSUPPORTED_FORMAT_VERSION_MAJOR,
+ _CONSTRUCT,
+ (_CONSTRUCT,),
+ f"format version {found} is not supported; "
+ f"this reader accepts only legacy format {accepted}",
+ )
+
+
__all__ = [
"SUPPORT_TABLE",
"V3",
@@ -362,13 +467,17 @@ def dump_json(value: object) -> str:
"DiagnosticError",
"FormatVersion",
"Interval",
+ "LegacyFormat",
"SupportTable",
"check_supported",
"dump_json",
"parse_json",
"read_document",
+ "read_document_with_legacy",
"read_format_version",
+ "read_legacy_document",
"recognize_format_version",
+ "recognize_legacy_format",
"table_to_string",
"write_format_version",
]
diff --git a/packages/morphir/src/morphir/ir/v3/__init__.py b/packages/morphir/src/morphir/ir/v3/__init__.py
index 87d7f0ac..0ec42b7c 100644
--- a/packages/morphir/src/morphir/ir/v3/__init__.py
+++ b/packages/morphir/src/morphir/ir/v3/__init__.py
@@ -4,11 +4,17 @@
shapes. What 3.1.0 adds, the `Specs` distribution, follows the finos/morphir v3
specification (decision record 0018). The classes here are distinct from
`morphir.ir.v4`; use `morphir.ir.migrate` to move between versions.
+
+`morphir.ir.v3.codec_v1` and `morphir.ir.v3.codec_v2` read the legacy formats 1
+and 2 into the 3.0.0 `Library` model, which upgrades them to format 3.0.0.
+Nothing writes them.
"""
from . import (
access_controlled,
codec,
+ codec_v1,
+ codec_v2,
distribution,
documented,
fqname,
@@ -42,6 +48,8 @@
"Specs",
"access_controlled",
"codec",
+ "codec_v1",
+ "codec_v2",
"distribution",
"documented",
"fqname",
diff --git a/packages/morphir/src/morphir/ir/v3/_legacy_value.py b/packages/morphir/src/morphir/ir/v3/_legacy_value.py
new file mode 100644
index 00000000..175b7e5e
--- /dev/null
+++ b/packages/morphir/src/morphir/ir/v3/_legacy_value.py
@@ -0,0 +1,201 @@
+"""Value and pattern decoders with the snake_case tags of formats 1 and 2.
+
+morphir-elm wrote value and pattern tags in snake_case (``"let_definition"``,
+``"wildcard_pattern"``) in formats 1 and 2, and in TitleCase from format 3
+(commit 02aa36af). The node shapes did not change. Literals and types inside
+values did change between format 1 and format 2, so each decoder here takes the
+literal decoder and the type decoder of its format.
+
+Port of the decoders in ``Value/CodecV1.elm`` (morphir-elm bc99af69) and in
+``Value/CodecV2.elm`` (morphir-elm 02aa36af, removed in 7cb7b300).
+"""
+
+from __future__ import annotations
+
+from ..diagnostics import Location
+from . import literal as lit
+from . import type as t
+from . import value as v
+from .codec import (
+ Decoder,
+ _expect_list,
+ _expect_object,
+ _field,
+ _index,
+ _list_of,
+ _pair_list_to_dict,
+ _tag,
+ _unknown,
+ decode_fqname,
+ decode_name,
+)
+from .name import Name
+
+
+def decode_pattern[A](
+ decode_literal: Decoder[lit.Literal], decode_attributes: Decoder[A]
+) -> Decoder[v.Pattern[A]]:
+ """Build a snake_case pattern decoder (elm ``decodePattern``)."""
+
+ def decode(j: object, loc: Location = ()) -> v.Pattern[A]:
+ tag, items = _tag(j, loc, "Pattern")
+
+ def at(i: int) -> tuple[object, Location]:
+ return _index(items, i, loc, tag), (*loc, i)
+
+ match tag:
+ case "wildcard_pattern":
+ return v.WildcardPattern(decode_attributes(*at(1)))
+ case "as_pattern":
+ return v.AsPattern(
+ decode_attributes(*at(1)), decode(*at(2)), decode_name(*at(3))
+ )
+ case "tuple_pattern":
+ return v.TuplePattern(
+ decode_attributes(*at(1)), _list_of(decode, *at(2), "Pattern")
+ )
+ case "constructor_pattern":
+ return v.ConstructorPattern(
+ decode_attributes(*at(1)),
+ decode_fqname(*at(2)),
+ _list_of(decode, *at(3), "Pattern"),
+ )
+ case "empty_list_pattern":
+ return v.EmptyListPattern(decode_attributes(*at(1)))
+ case "head_tail_pattern":
+ return v.HeadTailPattern(
+ decode_attributes(*at(1)), decode(*at(2)), decode(*at(3))
+ )
+ case "literal_pattern":
+ return v.LiteralPattern(
+ decode_attributes(*at(1)), decode_literal(*at(2))
+ )
+ case "unit_pattern":
+ return v.UnitPattern(decode_attributes(*at(1)))
+ case _:
+ raise _unknown(tag, loc, "Pattern")
+
+ return decode
+
+
+def decode_value_definition[TA, VA](
+ decode_type: Decoder[t.Type[TA]],
+ decode_value_attributes: Decoder[VA],
+ decode_body: Decoder[v.Value[TA, VA]],
+) -> Decoder[v.Definition[TA, VA]]:
+ """Build a decoder for ``{"inputTypes", "outputType", "body"}``."""
+ construct = "ValueDefinition"
+
+ def decode_input(j: object, loc: Location) -> tuple[Name, VA, t.Type[TA]]:
+ triple = _expect_list(j, loc, "Input")
+ return (
+ decode_name(_index(triple, 0, loc, "Input"), (*loc, 0)),
+ decode_value_attributes(_index(triple, 1, loc, "Input"), (*loc, 1)),
+ decode_type(_index(triple, 2, loc, "Input"), (*loc, 2)),
+ )
+
+ def decode(j: object, loc: Location = ()) -> v.Definition[TA, VA]:
+ obj = _expect_object(j, loc, construct)
+ return v.Definition(
+ _list_of(
+ decode_input,
+ _field(obj, "inputTypes", loc, construct),
+ (*loc, "inputTypes"),
+ "Input",
+ ),
+ decode_type(
+ _field(obj, "outputType", loc, construct), (*loc, "outputType")
+ ),
+ decode_body(_field(obj, "body", loc, construct), (*loc, "body")),
+ )
+
+ return decode
+
+
+def decode_value[TA, VA](
+ decode_literal: Decoder[lit.Literal],
+ decode_type: Decoder[t.Type[TA]],
+ decode_value_attributes: Decoder[VA],
+) -> Decoder[v.Value[TA, VA]]:
+ """Build a snake_case value decoder (elm ``decodeValue``).
+
+ Args:
+ decode_literal: The literal decoder of the format.
+ decode_type: The decoder for the types inside value definitions.
+ decode_value_attributes: The decoder for value and pattern attributes.
+ """
+ decode_p = decode_pattern(decode_literal, decode_value_attributes)
+
+ def decode_named(j: object, loc: Location) -> dict[Name, v.Value[TA, VA]]:
+ return _pair_list_to_dict(decode_name, decode, j, loc, "Value")
+
+ def decode_case(j: object, loc: Location) -> tuple[v.Pattern[VA], v.Value[TA, VA]]:
+ pair = _expect_list(j, loc, "Case")
+ return (
+ decode_p(_index(pair, 0, loc, "Case"), (*loc, 0)),
+ decode(_index(pair, 1, loc, "Case"), (*loc, 1)),
+ )
+
+ def decode(j: object, loc: Location = ()) -> v.Value[TA, VA]:
+ tag, items = _tag(j, loc, "Value")
+
+ def at(i: int) -> tuple[object, Location]:
+ return _index(items, i, loc, tag), (*loc, i)
+
+ def attrs() -> VA:
+ return decode_value_attributes(*at(1))
+
+ match tag:
+ case "literal":
+ return v.LiteralValue(attrs(), decode_literal(*at(2)))
+ case "constructor":
+ return v.Constructor(attrs(), decode_fqname(*at(2)))
+ case "tuple":
+ return v.Tuple(attrs(), _list_of(decode, *at(2), "Value"))
+ case "list":
+ return v.List(attrs(), _list_of(decode, *at(2), "Value"))
+ case "record":
+ return v.Record(attrs(), decode_named(*at(2)))
+ case "variable":
+ return v.Variable(attrs(), decode_name(*at(2)))
+ case "reference":
+ return v.Reference(attrs(), decode_fqname(*at(2)))
+ case "field":
+ return v.Field(attrs(), decode(*at(2)), decode_name(*at(3)))
+ case "field_function":
+ return v.FieldFunction(attrs(), decode_name(*at(2)))
+ case "apply":
+ return v.Apply(attrs(), decode(*at(2)), decode(*at(3)))
+ case "lambda":
+ return v.Lambda(attrs(), decode_p(*at(2)), decode(*at(3)))
+ case "let_definition":
+ return v.LetDefinition(
+ attrs(), decode_name(*at(2)), decode_def(*at(3)), decode(*at(4))
+ )
+ case "let_recursion":
+ return v.LetRecursion(
+ attrs(),
+ _pair_list_to_dict(decode_name, decode_def, *at(2), "Definition"),
+ decode(*at(3)),
+ )
+ case "destructure":
+ return v.Destructure(
+ attrs(), decode_p(*at(2)), decode(*at(3)), decode(*at(4))
+ )
+ case "if_then_else":
+ return v.IfThenElse(
+ attrs(), decode(*at(2)), decode(*at(3)), decode(*at(4))
+ )
+ case "pattern_match":
+ return v.PatternMatch(
+ attrs(), decode(*at(2)), _list_of(decode_case, *at(3), "Case")
+ )
+ case "update_record":
+ return v.UpdateRecord(attrs(), decode(*at(2)), decode_named(*at(3)))
+ case "unit":
+ return v.Unit(attrs())
+ case _:
+ raise _unknown(tag, loc, "Value")
+
+ decode_def = decode_value_definition(decode_type, decode_value_attributes, decode)
+ return decode
diff --git a/packages/morphir/src/morphir/ir/v3/codec_v1.py b/packages/morphir/src/morphir/ir/v3/codec_v1.py
new file mode 100644
index 00000000..89ffc2de
--- /dev/null
+++ b/packages/morphir/src/morphir/ir/v3/codec_v1.py
@@ -0,0 +1,491 @@
+"""Read-only JSON decoders for morphir-elm IR format 1, into the 3.0.0 model.
+
+Port of the decoders in morphir-elm ``src/Morphir/IR/*/CodecV1.elm`` at commit
+bc99af69, which morphir-elm still uses to read ``"formatVersion": 1``. As in elm,
+format 1 decodes into the same model as format 3.0.0, so decoding is the upgrade:
+encode the result with `morphir.ir.v3.codec` to write format 3. The encoders are
+not ported, because no writer emits format 1.
+
+Format 1 differs from format 3.0.0 only in these spellings:
+
+- every tag is snake_case: ``"library"``, ``"public"``, ``"variable"``,
+ ``"custom_type_definition"``, ``"let_definition"``, ``"wildcard_pattern"``;
+- literal tags are ``"bool_literal"``, ``"char_literal"``, ``"string_literal"``,
+ ``"int_literal"``, ``"float_literal"`` and ``"decimal_literal"``;
+- access control is ``["public", x]``, documentation is ``[doc, x]`` or the bare
+ ``x``, and a record field type is ``[name, type]``;
+- package modules are ``{"name", "spec"}`` and ``{"name", "def"}`` objects;
+- modules have no ``doc``; a ``doc`` member is ignored, as in elm.
+
+Names, paths, and qualified names are spelled as in format 3.0.0; use the
+`morphir.ir.v3.codec` decoders for them. elm's ``encodeSpecification`` writes a
+derived type as ``"DerivedTypeSpecification"`` but ``decodeSpecification`` reads
+only ``"derived_type_specification"``; this decoder follows the elm decoder.
+"""
+
+from __future__ import annotations
+
+from .. import diagnostics as d
+from ..diagnostics import DiagnosticError, Location, fail
+from ..format_version import LegacyFormat, read_legacy_document
+from . import _legacy_value, module, package
+from . import literal as lit
+from . import type as t
+from . import value as v
+from .access_controlled import Access, AccessControlled
+from .codec import (
+ Decoder,
+ _expect_list,
+ _expect_object,
+ _field,
+ _index,
+ _list_of,
+ _pair_list_to_dict,
+ _tag,
+ _unknown,
+ decode_fqname,
+ decode_name,
+ decode_path,
+ decode_unit,
+)
+from .codec import decode_literal as decode_literal_3
+from .distribution import Library
+from .documented import Documented
+from .name import Name
+from .path import Path
+
+# --- Literal ---
+
+_LITERAL_TAGS = {
+ "bool_literal": "BoolLiteral",
+ "char_literal": "CharLiteral",
+ "string_literal": "StringLiteral",
+ "int_literal": "WholeNumberLiteral",
+ "float_literal": "FloatLiteral",
+ "decimal_literal": "DecimalLiteral",
+}
+
+
+def decode_literal(j: object, loc: Location = ()) -> lit.Literal:
+ """Decode a format-1 literal; the payloads are checked as in format 3.0.0."""
+ tag, items = _tag(j, loc, "Literal")
+ title = _LITERAL_TAGS.get(tag)
+ if title is None:
+ raise _unknown(tag, loc, "Literal")
+ return decode_literal_3([title, *items[1:]], loc)
+
+
+# --- AccessControlled, Documented ---
+
+
+_ACCESS = {"public": Access.PUBLIC, "private": Access.PRIVATE}
+
+
+def decode_access_controlled[A](
+ decode_value: Decoder[A],
+) -> Decoder[AccessControlled[A]]:
+ """Decode ``["public", x]`` or ``["private", x]``."""
+
+ def decode(j: object, loc: Location = ()) -> AccessControlled[A]:
+ tag, items = _tag(j, loc, "AccessControlled")
+ access = _ACCESS.get(tag)
+ if access is None:
+ raise _unknown(tag, loc, "AccessControlled")
+ return AccessControlled(
+ access, decode_value(_index(items, 1, loc, tag), (*loc, 1))
+ )
+
+ return decode
+
+
+def decode_documented[A](decode_value: Decoder[A]) -> Decoder[Documented[A]]:
+ """Decode ``[doc, x]``, or an undocumented ``x`` with doc ``""``.
+
+ As elm's ``Decode.oneOf``, the pair is tried first. When both readings fail,
+ the error holds the diagnostics of both.
+ """
+
+ def decode(j: object, loc: Location = ()) -> Documented[A]:
+ if isinstance(j, list) and len(j) > 1 and isinstance(j[0], str):
+ try:
+ return Documented(j[0], decode_value(j[1], (*loc, 1)))
+ except DiagnosticError as pair_error:
+ try:
+ return Documented("", decode_value(j, loc))
+ except DiagnosticError as bare_error:
+ raise DiagnosticError(
+ *pair_error.diagnostics, *bare_error.diagnostics
+ ) from None
+ return Documented("", decode_value(j, loc))
+
+ return decode
+
+
+# --- Type ---
+
+
+def decode_type[A](decode_attributes: Decoder[A]) -> Decoder[t.Type[A]]:
+ """Build a format-1 type decoder (elm ``decodeType``)."""
+
+ def decode(j: object, loc: Location = ()) -> t.Type[A]:
+ tag, items = _tag(j, loc, "Type")
+
+ def at(i: int) -> tuple[object, Location]:
+ return _index(items, i, loc, tag), (*loc, i)
+
+ def attrs() -> A:
+ return decode_attributes(*at(1))
+
+ match tag:
+ case "variable":
+ return t.Variable(attrs(), decode_name(*at(2)))
+ case "reference":
+ return t.Reference(
+ attrs(), decode_fqname(*at(2)), _list_of(decode, *at(3), "Type")
+ )
+ case "tuple":
+ return t.Tuple(attrs(), _list_of(decode, *at(2), "Type"))
+ case "record":
+ return t.Record(attrs(), _list_of(decode_field, *at(2), "Field"))
+ case "extensible_record":
+ return t.ExtensibleRecord(
+ attrs(),
+ decode_name(*at(2)),
+ _list_of(decode_field, *at(3), "Field"),
+ )
+ case "function":
+ return t.Function(attrs(), decode(*at(2)), decode(*at(3)))
+ case "unit":
+ return t.Unit(attrs())
+ case _:
+ raise _unknown(tag, loc, "Type")
+
+ def decode_field(j: object, loc: Location = ()) -> t.Field[A]:
+ pair = _expect_list(j, loc, "Field")
+ return t.Field(
+ decode_name(_index(pair, 0, loc, "Field"), (*loc, 0)),
+ decode(_index(pair, 1, loc, "Field"), (*loc, 1)),
+ )
+
+ return decode
+
+
+def _decode_constructors[A](
+ decode_t: Decoder[t.Type[A]],
+) -> Decoder[t.Constructors[A]]:
+ def decode_arg(j: object, loc: Location) -> tuple[Name, t.Type[A]]:
+ pair = _expect_list(j, loc, "ConstructorArg")
+ return (
+ decode_name(_index(pair, 0, loc, "ConstructorArg"), (*loc, 0)),
+ decode_t(_index(pair, 1, loc, "ConstructorArg"), (*loc, 1)),
+ )
+
+ def decode_args(j: object, loc: Location) -> t.ConstructorArgs[A]:
+ return _list_of(decode_arg, j, loc, "Constructor")
+
+ def decode(j: object, loc: Location = ()) -> t.Constructors[A]:
+ return _pair_list_to_dict(decode_name, decode_args, j, loc, "Constructors")
+
+ return decode
+
+
+def decode_type_specification[A](
+ decode_attributes: Decoder[A],
+) -> Decoder[t.Specification[A]]:
+ """Build a format-1 type specification decoder."""
+ decode_t = decode_type(decode_attributes)
+ decode_ctors = _decode_constructors(decode_t)
+
+ def decode(j: object, loc: Location = ()) -> t.Specification[A]:
+ tag, items = _tag(j, loc, "TypeSpecification")
+
+ def at(i: int) -> tuple[object, Location]:
+ return _index(items, i, loc, tag), (*loc, i)
+
+ def params() -> tuple[Name, ...]:
+ return _list_of(decode_name, *at(1), "Name")
+
+ match tag:
+ case "type_alias_specification":
+ return t.TypeAliasSpecification(params(), decode_t(*at(2)))
+ case "opaque_type_specification":
+ return t.OpaqueTypeSpecification(params())
+ case "custom_type_specification":
+ return t.CustomTypeSpecification(params(), decode_ctors(*at(2)))
+ case "derived_type_specification":
+ config_json, config_loc = at(2)
+ config = _expect_object(config_json, config_loc, tag)
+
+ def member(key: str) -> tuple[object, Location]:
+ return _field(config, key, config_loc, tag), (*config_loc, key)
+
+ return t.DerivedTypeSpecification(
+ params(),
+ decode_t(*member("baseType")),
+ decode_fqname(*member("fromBaseType")),
+ decode_fqname(*member("toBaseType")),
+ )
+ case _:
+ raise _unknown(tag, loc, "TypeSpecification")
+
+ return decode
+
+
+def decode_type_definition[A](
+ decode_attributes: Decoder[A],
+) -> Decoder[t.Definition[A]]:
+ """Build a format-1 type definition decoder."""
+ decode_t = decode_type(decode_attributes)
+ decode_ctors = decode_access_controlled(_decode_constructors(decode_t))
+
+ def decode(j: object, loc: Location = ()) -> t.Definition[A]:
+ tag, items = _tag(j, loc, "TypeDefinition")
+
+ def at(i: int) -> tuple[object, Location]:
+ return _index(items, i, loc, tag), (*loc, i)
+
+ match tag:
+ case "type_alias_definition":
+ return t.TypeAliasDefinition(
+ _list_of(decode_name, *at(1), "Name"), decode_t(*at(2))
+ )
+ case "custom_type_definition":
+ return t.CustomTypeDefinition(
+ _list_of(decode_name, *at(1), "Name"), decode_ctors(*at(2))
+ )
+ case _:
+ raise _unknown(tag, loc, "TypeDefinition")
+
+ return decode
+
+
+# --- Value ---
+
+
+def decode_pattern[A](decode_attributes: Decoder[A]) -> Decoder[v.Pattern[A]]:
+ """Build a format-1 pattern decoder."""
+ return _legacy_value.decode_pattern(decode_literal, decode_attributes)
+
+
+def decode_value[TA, VA](
+ decode_type_attributes: Decoder[TA], decode_value_attributes: Decoder[VA]
+) -> Decoder[v.Value[TA, VA]]:
+ """Build a format-1 value decoder."""
+ return _legacy_value.decode_value(
+ decode_literal, decode_type(decode_type_attributes), decode_value_attributes
+ )
+
+
+def decode_value_specification[A](
+ decode_attributes: Decoder[A],
+) -> Decoder[v.Specification[A]]:
+ """Build a decoder for ``{"inputs", "output"}`` with format-1 types."""
+ decode_t = decode_type(decode_attributes)
+ construct = "ValueSpecification"
+
+ def decode_input(j: object, loc: Location) -> tuple[Name, t.Type[A]]:
+ pair = _expect_list(j, loc, "Input")
+ return (
+ decode_name(_index(pair, 0, loc, "Input"), (*loc, 0)),
+ decode_t(_index(pair, 1, loc, "Input"), (*loc, 1)),
+ )
+
+ def decode(j: object, loc: Location = ()) -> v.Specification[A]:
+ obj = _expect_object(j, loc, construct)
+ return v.Specification(
+ _list_of(
+ decode_input,
+ _field(obj, "inputs", loc, construct),
+ (*loc, "inputs"),
+ "Input",
+ ),
+ decode_t(_field(obj, "output", loc, construct), (*loc, "output")),
+ )
+
+ return decode
+
+
+def decode_value_definition[TA, VA](
+ decode_type_attributes: Decoder[TA], decode_value_attributes: Decoder[VA]
+) -> Decoder[v.Definition[TA, VA]]:
+ """Build a format-1 value definition decoder."""
+ return _legacy_value.decode_value_definition(
+ decode_type(decode_type_attributes),
+ decode_value_attributes,
+ decode_value(decode_type_attributes, decode_value_attributes),
+ )
+
+
+# --- Module ---
+
+
+def _named[A](
+ decode_entry: Decoder[A],
+ obj: dict[str, object],
+ key: str,
+ loc: Location,
+ construct: str,
+) -> dict[Name, A]:
+ return _pair_list_to_dict(
+ decode_name,
+ decode_entry,
+ _field(obj, key, loc, construct),
+ (*loc, key),
+ key.capitalize(),
+ )
+
+
+def decode_module_specification[TA](
+ decode_type_attributes: Decoder[TA],
+) -> Decoder[module.Specification[TA]]:
+ """Build a format-1 module specification decoder; ``doc`` is always None."""
+ decode_ts = decode_documented(decode_type_specification(decode_type_attributes))
+ decode_vs = decode_documented(decode_value_specification(decode_type_attributes))
+
+ def decode(j: object, loc: Location = ()) -> module.Specification[TA]:
+ construct = "ModuleSpecification"
+ obj = _expect_object(j, loc, construct)
+ return module.Specification(
+ _named(decode_ts, obj, "types", loc, construct),
+ _named(decode_vs, obj, "values", loc, construct),
+ )
+
+ return decode
+
+
+def decode_module_definition[TA, VA](
+ decode_type_attributes: Decoder[TA], decode_value_attributes: Decoder[VA]
+) -> Decoder[module.Definition[TA, VA]]:
+ """Build a format-1 module definition decoder; ``doc`` is always None."""
+ decode_td = decode_access_controlled(
+ decode_documented(decode_type_definition(decode_type_attributes))
+ )
+ decode_vd = decode_access_controlled(
+ decode_documented(
+ decode_value_definition(decode_type_attributes, decode_value_attributes)
+ )
+ )
+
+ def decode(j: object, loc: Location = ()) -> module.Definition[TA, VA]:
+ construct = "ModuleDefinition"
+ obj = _expect_object(j, loc, construct)
+ return module.Definition(
+ _named(decode_td, obj, "types", loc, construct),
+ _named(decode_vd, obj, "values", loc, construct),
+ )
+
+ return decode
+
+
+# --- Package ---
+
+
+def _modules[A](
+ decode_module: Decoder[A], member: str, j: object, loc: Location, construct: str
+) -> dict[Path, A]:
+ obj = _expect_object(j, loc, construct)
+ result: dict[Path, A] = {}
+ entries = _field(obj, "modules", loc, construct)
+ for i, entry in enumerate(_expect_list(entries, (*loc, "modules"), "Modules")):
+ here = (*loc, "modules", i)
+ named = _expect_object(entry, here, "Module")
+ name = decode_path(_field(named, "name", here, "Module"), (*here, "name"))
+ result[name] = decode_module(
+ _field(named, member, here, "Module"), (*here, member)
+ )
+ return result
+
+
+def decode_package_specification[TA](
+ decode_type_attributes: Decoder[TA],
+) -> Decoder[package.Specification[TA]]:
+ """Build a decoder for ``{"modules": [{"name", "spec"}]}``."""
+ decode_m = decode_module_specification(decode_type_attributes)
+
+ def decode(j: object, loc: Location = ()) -> package.Specification[TA]:
+ return package.Specification(
+ _modules(decode_m, "spec", j, loc, "PackageSpecification")
+ )
+
+ return decode
+
+
+def decode_package_definition[TA, VA](
+ decode_type_attributes: Decoder[TA], decode_value_attributes: Decoder[VA]
+) -> Decoder[package.Definition[TA, VA]]:
+ """Build a decoder for ``{"modules": [{"name", "def": ["public", ...]}]}``."""
+ decode_m = decode_access_controlled(
+ decode_module_definition(decode_type_attributes, decode_value_attributes)
+ )
+
+ def decode(j: object, loc: Location = ()) -> package.Definition[TA, VA]:
+ return package.Definition(
+ _modules(decode_m, "def", j, loc, "PackageDefinition")
+ )
+
+ return decode
+
+
+# --- Distribution ---
+
+_decode_dependency = decode_package_specification(decode_unit)
+_decode_library_def = decode_package_definition(decode_unit, decode_type(decode_unit))
+
+
+def decode_distribution(j: object, loc: Location = ()) -> Library:
+ """Decode ``["library", name, dependencies, definition]``."""
+ tag, items = _tag(j, loc, "Distribution")
+ if tag != "library":
+ raise fail(
+ d.UNKNOWN_NODE,
+ "Distribution",
+ (*loc, 0),
+ f"unknown distribution kind {tag!r}; format 1 has only library",
+ )
+
+ def at(i: int) -> tuple[object, Location]:
+ return _index(items, i, loc, tag), (*loc, i)
+
+ return Library(
+ package_name=decode_path(*at(1)),
+ dependencies=_pair_list_to_dict(
+ decode_path, _decode_dependency, *at(2), "Dependencies"
+ ),
+ definition=_decode_library_def(*at(3)),
+ )
+
+
+def decode_versioned_distribution(document: str | object) -> Library:
+ """Decode a ``"formatVersion": 1`` document into the format 3.0.0 model.
+
+ Args:
+ document: JSON text or an already-decoded JSON value.
+
+ Raises:
+ DiagnosticError: With ``unsupported_format_version_major`` for any format
+ other than 1, or with a decoding diagnostic.
+ """
+ root = read_legacy_document(document, LegacyFormat.V1)
+ return decode_distribution(
+ _field(root, "distribution", (), "document"), ("distribution",)
+ )
+
+
+__all__ = [
+ "decode_access_controlled",
+ "decode_distribution",
+ "decode_documented",
+ "decode_literal",
+ "decode_module_definition",
+ "decode_module_specification",
+ "decode_package_definition",
+ "decode_package_specification",
+ "decode_pattern",
+ "decode_type",
+ "decode_type_definition",
+ "decode_type_specification",
+ "decode_value",
+ "decode_value_definition",
+ "decode_value_specification",
+ "decode_versioned_distribution",
+]
diff --git a/packages/morphir/src/morphir/ir/v3/codec_v2.py b/packages/morphir/src/morphir/ir/v3/codec_v2.py
new file mode 100644
index 00000000..2e2779c1
--- /dev/null
+++ b/packages/morphir/src/morphir/ir/v3/codec_v2.py
@@ -0,0 +1,187 @@
+"""Read-only JSON decoders for morphir-elm IR format 2, into the 3.0.0 model.
+
+morphir-elm wrote format 2 from commit 8e0e729d (2021-10-08) to commit 02aa36af
+(2023-05-18). Format 3 changed one thing: value and pattern tags went from
+snake_case (``"let_definition"``, ``"wildcard_pattern"``) to TitleCase. Every
+other shape, including TitleCase literals and types, ``{"access", "value"}``,
+``{"doc", "value"}``, and ``[path, module]`` package entries, is the format 3.0.0
+shape, so this module reuses `morphir.ir.v3.codec` for everything except values,
+patterns, and the containers that hold them.
+
+The value and pattern decoders port ``Value/CodecV2.elm`` from morphir-elm commit
+02aa36af. morphir-elm removed that module in commit 7cb7b300, and today refuses
+format 2; reading it here is a deliberate divergence, so that old files can be
+upgraded. As in elm, format 2 decodes into the format 3.0.0 model; encode the
+result with `morphir.ir.v3.codec` to write format 3. No writer emits format 2.
+
+The finos/morphir v2 schema documentation says that format 2 literals stay
+snake_case. morphir-elm wrote them in TitleCase from the first format-2 commit
+(e742a620), and morphir-elm is normative.
+"""
+
+from __future__ import annotations
+
+from .. import diagnostics as d
+from ..diagnostics import Location, fail
+from ..format_version import LegacyFormat, read_legacy_document
+from . import _legacy_value, module, package
+from . import value as v
+from .codec import (
+ Decoder,
+ _expect_object,
+ _field,
+ _index,
+ _pair_list_to_dict,
+ _tag,
+ decode_access_controlled,
+ decode_documented,
+ decode_literal,
+ decode_name,
+ decode_package_specification,
+ decode_path,
+ decode_type,
+ decode_type_definition,
+ decode_unit,
+)
+from .codec import _decode_doc as decode_module_doc
+from .distribution import Library
+
+
+def decode_pattern[A](decode_attributes: Decoder[A]) -> Decoder[v.Pattern[A]]:
+ """Build a format-2 pattern decoder: snake_case tags, format-3 literals."""
+ return _legacy_value.decode_pattern(decode_literal, decode_attributes)
+
+
+def decode_value[TA, VA](
+ decode_type_attributes: Decoder[TA], decode_value_attributes: Decoder[VA]
+) -> Decoder[v.Value[TA, VA]]:
+ """Build a format-2 value decoder: snake_case tags, format-3 types and literals."""
+ return _legacy_value.decode_value(
+ decode_literal, decode_type(decode_type_attributes), decode_value_attributes
+ )
+
+
+def decode_value_definition[TA, VA](
+ decode_type_attributes: Decoder[TA], decode_value_attributes: Decoder[VA]
+) -> Decoder[v.Definition[TA, VA]]:
+ """Build a format-2 value definition decoder."""
+ return _legacy_value.decode_value_definition(
+ decode_type(decode_type_attributes),
+ decode_value_attributes,
+ decode_value(decode_type_attributes, decode_value_attributes),
+ )
+
+
+def decode_module_definition[TA, VA](
+ decode_type_attributes: Decoder[TA], decode_value_attributes: Decoder[VA]
+) -> Decoder[module.Definition[TA, VA]]:
+ """Build a format-2 module definition decoder, with the optional ``doc``."""
+ decode_td = decode_access_controlled(
+ decode_documented(decode_type_definition(decode_type_attributes))
+ )
+ decode_vd = decode_access_controlled(
+ decode_documented(
+ decode_value_definition(decode_type_attributes, decode_value_attributes)
+ )
+ )
+ construct = "ModuleDefinition"
+
+ def decode(j: object, loc: Location = ()) -> module.Definition[TA, VA]:
+ obj = _expect_object(j, loc, construct)
+ return module.Definition(
+ _pair_list_to_dict(
+ decode_name,
+ decode_td,
+ _field(obj, "types", loc, construct),
+ (*loc, "types"),
+ "Types",
+ ),
+ _pair_list_to_dict(
+ decode_name,
+ decode_vd,
+ _field(obj, "values", loc, construct),
+ (*loc, "values"),
+ "Values",
+ ),
+ decode_module_doc(obj),
+ )
+
+ return decode
+
+
+def decode_package_definition[TA, VA](
+ decode_type_attributes: Decoder[TA], decode_value_attributes: Decoder[VA]
+) -> Decoder[package.Definition[TA, VA]]:
+ """Build a decoder for ``{"modules": [[path, {"access", "value"}]]}``."""
+ decode_m = decode_access_controlled(
+ decode_module_definition(decode_type_attributes, decode_value_attributes)
+ )
+ construct = "PackageDefinition"
+
+ def decode(j: object, loc: Location = ()) -> package.Definition[TA, VA]:
+ obj = _expect_object(j, loc, construct)
+ return package.Definition(
+ _pair_list_to_dict(
+ decode_path,
+ decode_m,
+ _field(obj, "modules", loc, construct),
+ (*loc, "modules"),
+ "Modules",
+ )
+ )
+
+ return decode
+
+
+_decode_dependency = decode_package_specification(decode_unit)
+_decode_library_def = decode_package_definition(decode_unit, decode_type(decode_unit))
+
+
+def decode_distribution(j: object, loc: Location = ()) -> Library:
+ """Decode ``["Library", name, dependencies, definition]``."""
+ tag, items = _tag(j, loc, "Distribution")
+ if tag != "Library":
+ raise fail(
+ d.UNKNOWN_NODE,
+ "Distribution",
+ (*loc, 0),
+ f"unknown distribution kind {tag!r}; format 2 has only Library",
+ )
+
+ def at(i: int) -> tuple[object, Location]:
+ return _index(items, i, loc, tag), (*loc, i)
+
+ return Library(
+ package_name=decode_path(*at(1)),
+ dependencies=_pair_list_to_dict(
+ decode_path, _decode_dependency, *at(2), "Dependencies"
+ ),
+ definition=_decode_library_def(*at(3)),
+ )
+
+
+def decode_versioned_distribution(document: str | object) -> Library:
+ """Decode a ``"formatVersion": 2`` document into the format 3.0.0 model.
+
+ Args:
+ document: JSON text or an already-decoded JSON value.
+
+ Raises:
+ DiagnosticError: With ``unsupported_format_version_major`` for any format
+ other than 2, or with a decoding diagnostic.
+ """
+ root = read_legacy_document(document, LegacyFormat.V2)
+ return decode_distribution(
+ _field(root, "distribution", (), "document"), ("distribution",)
+ )
+
+
+__all__ = [
+ "decode_distribution",
+ "decode_module_definition",
+ "decode_package_definition",
+ "decode_pattern",
+ "decode_value",
+ "decode_value_definition",
+ "decode_versioned_distribution",
+]
diff --git a/tests/fixtures/ir/legacy/PROVENANCE.md b/tests/fixtures/ir/legacy/PROVENANCE.md
new file mode 100644
index 00000000..a4fc2ed9
--- /dev/null
+++ b/tests/fixtures/ir/legacy/PROVENANCE.md
@@ -0,0 +1,18 @@
+# Legacy format 1 and 2 golden fixtures
+
+These files are Morphir IR format 1 (`"formatVersion": 1`) and format 2
+(`"formatVersion": 2`) as written by morphir-elm. They are the same rentals
+example, written once in each format, so both must decode to the same model. They
+are copied byte-for-byte and are not edited. Tests read them directly; morphir-elm
+is not a test-time dependency.
+
+The source repository is licensed under the Apache License, Version 2.0.
+Copyright 2020 Morgan Stanley and the FINOS Morphir contributors.
+
+| Fixture | Format | Repository | Commit | Path |
+| --- | --- | --- | --- | --- |
+| `elm-rentals-format-1.json` | 1 | [finos/morphir-elm](https://github.com/finos/morphir-elm) | `bc99af69a8b24d391311fae3822a87eafef3c334` | `tests-integration/cli/test-data/rentals/expected-morphir-ir-v1.json` |
+| `elm-rentals-format-2.json` | 2 | finos/morphir-elm | `bc99af69` | `tests-integration/cli/test-data/rentals/expected-morphir-ir.json` |
+
+Both files were added to morphir-elm in commit `9497ed82` and have not changed
+since.
diff --git a/tests/fixtures/ir/legacy/elm-rentals-format-1.json b/tests/fixtures/ir/legacy/elm-rentals-format-1.json
new file mode 100644
index 00000000..7f26211e
--- /dev/null
+++ b/tests/fixtures/ir/legacy/elm-rentals-format-1.json
@@ -0,0 +1,1050 @@
+{
+ "formatVersion": 1,
+ "distribution": [
+ "library",
+ [
+ [
+ "morphir"
+ ],
+ [
+ "example"
+ ],
+ [
+ "app"
+ ]
+ ],
+ [],
+ {
+ "modules": [
+ {
+ "name": [
+ [
+ "rentals"
+ ]
+ ],
+ "def": [
+ "public",
+ {
+ "types": [],
+ "values": [
+ [
+ [
+ "request"
+ ],
+ [
+ "public",
+ {
+ "inputTypes": [
+ [
+ [
+ "availability"
+ ],
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ],
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ]
+ ],
+ [
+ [
+ "requested",
+ "quantity"
+ ],
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ],
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ]
+ ]
+ ],
+ "outputType": [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "result"
+ ]
+ ],
+ [
+ "result"
+ ]
+ ],
+ [
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "string"
+ ]
+ ],
+ [
+ "string"
+ ]
+ ],
+ []
+ ],
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ]
+ ]
+ ],
+ "body": [
+ "if_then_else",
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "result"
+ ]
+ ],
+ [
+ "result"
+ ]
+ ],
+ [
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "string"
+ ]
+ ],
+ [
+ "string"
+ ]
+ ],
+ []
+ ],
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ]
+ ]
+ ],
+ [
+ "apply",
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "bool"
+ ]
+ ],
+ []
+ ],
+ [
+ "apply",
+ [
+ "function",
+ {},
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ],
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "bool"
+ ]
+ ],
+ []
+ ]
+ ],
+ [
+ "reference",
+ [
+ "function",
+ {},
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ],
+ [
+ "function",
+ {},
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ],
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "bool"
+ ]
+ ],
+ []
+ ]
+ ]
+ ],
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "less",
+ "than",
+ "or",
+ "equal"
+ ]
+ ]
+ ],
+ [
+ "variable",
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ],
+ [
+ "requested",
+ "quantity"
+ ]
+ ]
+ ],
+ [
+ "variable",
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ],
+ [
+ "availability"
+ ]
+ ]
+ ],
+ [
+ "apply",
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "result"
+ ]
+ ],
+ [
+ "result"
+ ]
+ ],
+ [
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "string"
+ ]
+ ],
+ [
+ "string"
+ ]
+ ],
+ []
+ ],
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ]
+ ]
+ ],
+ [
+ "constructor",
+ [
+ "function",
+ {},
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ],
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "result"
+ ]
+ ],
+ [
+ "result"
+ ]
+ ],
+ [
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "string"
+ ]
+ ],
+ [
+ "string"
+ ]
+ ],
+ []
+ ],
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ]
+ ]
+ ]
+ ],
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "result"
+ ]
+ ],
+ [
+ "ok"
+ ]
+ ]
+ ],
+ [
+ "variable",
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ],
+ [
+ "requested",
+ "quantity"
+ ]
+ ]
+ ],
+ [
+ "apply",
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "result"
+ ]
+ ],
+ [
+ "result"
+ ]
+ ],
+ [
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "string"
+ ]
+ ],
+ [
+ "string"
+ ]
+ ],
+ []
+ ],
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ]
+ ]
+ ],
+ [
+ "constructor",
+ [
+ "function",
+ {},
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "string"
+ ]
+ ],
+ [
+ "string"
+ ]
+ ],
+ []
+ ],
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "result"
+ ]
+ ],
+ [
+ "result"
+ ]
+ ],
+ [
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "string"
+ ]
+ ],
+ [
+ "string"
+ ]
+ ],
+ []
+ ],
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ]
+ ]
+ ]
+ ],
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "result"
+ ]
+ ],
+ [
+ "err"
+ ]
+ ]
+ ],
+ [
+ "literal",
+ [
+ "reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "string"
+ ]
+ ],
+ [
+ "string"
+ ]
+ ],
+ []
+ ],
+ [
+ "string_literal",
+ "Insufficient availability"
+ ]
+ ]
+ ]
+ ]
+ }
+ ]
+ ]
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/ir/legacy/elm-rentals-format-2.json b/tests/fixtures/ir/legacy/elm-rentals-format-2.json
new file mode 100644
index 00000000..3bb852d7
--- /dev/null
+++ b/tests/fixtures/ir/legacy/elm-rentals-format-2.json
@@ -0,0 +1,1050 @@
+{
+ "formatVersion": 2,
+ "distribution": [
+ "Library",
+ [
+ [
+ "morphir"
+ ],
+ [
+ "example"
+ ],
+ [
+ "app"
+ ]
+ ],
+ [],
+ {
+ "modules": [
+ [
+ [
+ [
+ "rentals"
+ ]
+ ],
+ {
+ "access": "Public",
+ "value": {
+ "types": [],
+ "values": [
+ [
+ [
+ "request"
+ ],
+ {
+ "access": "Public",
+ "value": {
+ "inputTypes": [
+ [
+ [
+ "availability"
+ ],
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ],
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ]
+ ],
+ [
+ [
+ "requested",
+ "quantity"
+ ],
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ],
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ]
+ ]
+ ],
+ "outputType": [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "result"
+ ]
+ ],
+ [
+ "result"
+ ]
+ ],
+ [
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "string"
+ ]
+ ],
+ [
+ "string"
+ ]
+ ],
+ []
+ ],
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ]
+ ]
+ ],
+ "body": [
+ "if_then_else",
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "result"
+ ]
+ ],
+ [
+ "result"
+ ]
+ ],
+ [
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "string"
+ ]
+ ],
+ [
+ "string"
+ ]
+ ],
+ []
+ ],
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ]
+ ]
+ ],
+ [
+ "apply",
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "bool"
+ ]
+ ],
+ []
+ ],
+ [
+ "apply",
+ [
+ "Function",
+ {},
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ],
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "bool"
+ ]
+ ],
+ []
+ ]
+ ],
+ [
+ "reference",
+ [
+ "Function",
+ {},
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ],
+ [
+ "Function",
+ {},
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ],
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "bool"
+ ]
+ ],
+ []
+ ]
+ ]
+ ],
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "less",
+ "than",
+ "or",
+ "equal"
+ ]
+ ]
+ ],
+ [
+ "variable",
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ],
+ [
+ "requested",
+ "quantity"
+ ]
+ ]
+ ],
+ [
+ "variable",
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ],
+ [
+ "availability"
+ ]
+ ]
+ ],
+ [
+ "apply",
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "result"
+ ]
+ ],
+ [
+ "result"
+ ]
+ ],
+ [
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "string"
+ ]
+ ],
+ [
+ "string"
+ ]
+ ],
+ []
+ ],
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ]
+ ]
+ ],
+ [
+ "constructor",
+ [
+ "Function",
+ {},
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ],
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "result"
+ ]
+ ],
+ [
+ "result"
+ ]
+ ],
+ [
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "string"
+ ]
+ ],
+ [
+ "string"
+ ]
+ ],
+ []
+ ],
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ]
+ ]
+ ]
+ ],
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "result"
+ ]
+ ],
+ [
+ "ok"
+ ]
+ ]
+ ],
+ [
+ "variable",
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ],
+ [
+ "requested",
+ "quantity"
+ ]
+ ]
+ ],
+ [
+ "apply",
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "result"
+ ]
+ ],
+ [
+ "result"
+ ]
+ ],
+ [
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "string"
+ ]
+ ],
+ [
+ "string"
+ ]
+ ],
+ []
+ ],
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ]
+ ]
+ ],
+ [
+ "constructor",
+ [
+ "Function",
+ {},
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "string"
+ ]
+ ],
+ [
+ "string"
+ ]
+ ],
+ []
+ ],
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "result"
+ ]
+ ],
+ [
+ "result"
+ ]
+ ],
+ [
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "string"
+ ]
+ ],
+ [
+ "string"
+ ]
+ ],
+ []
+ ],
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "basics"
+ ]
+ ],
+ [
+ "int"
+ ]
+ ],
+ []
+ ]
+ ]
+ ]
+ ],
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "result"
+ ]
+ ],
+ [
+ "err"
+ ]
+ ]
+ ],
+ [
+ "literal",
+ [
+ "Reference",
+ {},
+ [
+ [
+ [
+ "morphir"
+ ],
+ [
+ "s",
+ "d",
+ "k"
+ ]
+ ],
+ [
+ [
+ "string"
+ ]
+ ],
+ [
+ "string"
+ ]
+ ],
+ []
+ ],
+ [
+ "StringLiteral",
+ "Insufficient availability"
+ ]
+ ]
+ ]
+ ]
+ }
+ }
+ ]
+ ]
+ }
+ }
+ ]
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/ir/v3/PROVENANCE.md b/tests/fixtures/ir/v3/PROVENANCE.md
index e36f130f..c66be84b 100644
--- a/tests/fixtures/ir/v3/PROVENANCE.md
+++ b/tests/fixtures/ir/v3/PROVENANCE.md
@@ -20,7 +20,8 @@ Copyright 2020 Morgan Stanley and the FINOS Morphir contributors.
| `finos-greeting-example.json` | [finos/morphir](https://github.com/finos/morphir) | `efa36060` | `website/static/ir/examples/v3/greeting-example.json` |
| `finos-rule-set-example.json` | finos/morphir | `efa36060` | `website/static/ir/examples/v3/rule-set-example.json` |
-The rentals example is format 2 and is deliberately not included.
+The rentals example is format 2, so it is in [`../legacy/`](../legacy/PROVENANCE.md)
+with its format-1 twin, not here.
## Format 3.1.0 fixtures
diff --git a/tests/tools/test_cli.py b/tests/tools/test_cli.py
index d4294f14..9914728c 100644
--- a/tests/tools/test_cli.py
+++ b/tests/tools/test_cli.py
@@ -14,6 +14,9 @@
REFERENCE = FIXTURES / "elm-reference-model.json"
KIT_SPECS = FIXTURES / "mck-distributions-0011-specs.json"
RUST_SPECS = FIXTURES / "rust-classic-specs-transport.json"
+LEGACY = FIXTURES.parent / "legacy"
+RENTALS_1 = LEGACY / "elm-rentals-format-1.json"
+RENTALS_2 = LEGACY / "elm-rentals-format-2.json"
runner = CliRunner()
@@ -98,9 +101,8 @@ def test_missing_version(self, tmp_path: Path) -> None:
@pytest.mark.parametrize(
("version", "code"),
[
- (1, d.UNSUPPORTED_FORMAT_VERSION_MAJOR),
- (2, d.UNSUPPORTED_FORMAT_VERSION_MAJOR),
("3.2.0", d.UNSUPPORTED_FORMAT_VERSION_MINOR),
+ (5, d.UNSUPPORTED_FORMAT_VERSION_MAJOR),
],
)
def test_unsupported_versions(
@@ -276,3 +278,46 @@ def test_downgrade_warnings_exit_zero(self, tmp_path: Path) -> None:
def test_usage_error(self) -> None:
assert run("ir", "convert", VALUE_TREE, "--to", "v9", "-o", "x").exit_code == 2
+
+
+class TestLegacy:
+ @pytest.mark.parametrize(("fixture", "label"), [(RENTALS_1, "1"), (RENTALS_2, "2")])
+ def test_inspect(self, fixture: Path, label: str) -> None:
+ result = run("ir", "inspect", fixture)
+ assert result.exit_code == 0
+ assert f"format: {label} (legacy)" in result.stdout
+ assert "kind: Library" in result.stdout
+ assert "package: morphir/example/app" in result.stdout
+ assert "ir convert" in result.stdout
+
+ def test_both_formats_upgrade_to_one_format_3_file(self, tmp_path: Path) -> None:
+ out1, out2 = tmp_path / "from1.json", tmp_path / "from2.json"
+ assert run("ir", "convert", RENTALS_1, "--to", "v3", "-o", out1).exit_code == 0
+ assert run("ir", "convert", RENTALS_2, "--to", "v3", "-o", out2).exit_code == 0
+ assert out1.read_text() == out2.read_text()
+ assert json.loads(out1.read_text())["formatVersion"] == 3
+ inspected = run("ir", "inspect", out1)
+ assert "format: 3.0.0" in inspected.stdout
+
+ @pytest.mark.parametrize("fixture", [RENTALS_1, RENTALS_2])
+ def test_upgrade_to_v4(self, tmp_path: Path, fixture: Path) -> None:
+ out = tmp_path / "v4.json"
+ result = run("ir", "convert", fixture, "--to", "v4", "-o", out)
+ assert result.exit_code == 0
+ assert json.loads(out.read_text())["formatVersion"] == 4
+
+ def test_broken_legacy_file_leaves_no_output(self, tmp_path: Path) -> None:
+ doc = {"formatVersion": 1, "distribution": ["Library", [], [], {}]}
+ out = tmp_path / "out.json"
+ src = write(tmp_path, "x.json", doc)
+ result = run("ir", "convert", src, "--to", "v3", "-o", out)
+ assert result.exit_code == 1
+ assert d.UNKNOWN_NODE in result.stderr
+ assert not out.exists()
+ assert run("ir", "inspect", src).exit_code == 1
+
+ def test_string_legacy_version_is_invalid(self, tmp_path: Path) -> None:
+ doc = {"formatVersion": "1", "distribution": []}
+ result = run("ir", "inspect", write(tmp_path, "x.json", doc))
+ assert result.exit_code == 1
+ assert d.INVALID_FORMAT_VERSION_SYNTAX in result.stderr
diff --git a/tests/unit/ir/test_format_version.py b/tests/unit/ir/test_format_version.py
index e3328d01..cb205487 100644
--- a/tests/unit/ir/test_format_version.py
+++ b/tests/unit/ir/test_format_version.py
@@ -10,11 +10,15 @@
V3_TABLE,
FormatVersion,
Interval,
+ LegacyFormat,
dump_json,
parse_json,
read_document,
+ read_document_with_legacy,
read_format_version,
+ read_legacy_document,
recognize_format_version,
+ recognize_legacy_format,
table_to_string,
write_format_version,
)
@@ -72,11 +76,12 @@ def test_historical_integers_are_recognized(self) -> None:
class TestSupport:
@pytest.mark.parametrize("value", [1, 2])
- def test_historical_versions_are_follow_up_work(self, value: int) -> None:
+ def test_legacy_versions_are_outside_the_table(self, value: int) -> None:
with pytest.raises(DiagnosticError) as info:
read_format_version(value)
assert info.value.codes == (d.UNSUPPORTED_FORMAT_VERSION_MAJOR,)
- assert "formats 1 and 2 are follow-up work" in str(info.value)
+ assert "legacy" in str(info.value)
+ assert "codec_v1" in str(info.value)
@pytest.mark.parametrize("value", ["3.1.0", "3.1.4"])
def test_ir_3_1_is_supported(self, value: str) -> None:
@@ -125,6 +130,11 @@ class TestWriting:
def test_baseline_is_integer(self) -> None:
assert write_format_version(read_format_version("3.0.0")) == 3
+ @pytest.mark.parametrize("major", [1, 2])
+ def test_legacy_formats_are_never_written(self, major: int) -> None:
+ with pytest.raises(ValueError, match="never written"):
+ write_format_version(FormatVersion(major, 0, 0))
+
def test_patch_is_string(self) -> None:
assert write_format_version(FormatVersion(4, 0, 2)) == "4.0.2"
@@ -177,3 +187,78 @@ def test_diagnostic_render(self) -> None:
diagnostic = d.Diagnostic("c", "X", ("a", 1), "msg")
assert diagnostic.render() == "c at a/1: msg"
assert d.Diagnostic("c", "X", (), "msg").render() == "c at : msg"
+
+
+class TestLegacy:
+ @pytest.mark.parametrize(
+ ("value", "expected"), [(1, LegacyFormat.V1), (2, LegacyFormat.V2)]
+ )
+ def test_integers_are_legacy(self, value: int, expected: LegacyFormat) -> None:
+ assert recognize_legacy_format(value) is expected
+ assert str(expected) == str(value)
+
+ @pytest.mark.parametrize("value", [True, "1", "2", "1.0.0", 1.0, 3, 0, None])
+ def test_other_spellings_are_not_legacy(self, value: object) -> None:
+ assert recognize_legacy_format(value) is None
+
+ @pytest.mark.parametrize(
+ ("value", "expected"),
+ [
+ (1, LegacyFormat.V1),
+ (2, LegacyFormat.V2),
+ (3, FormatVersion(3, 0, 0)),
+ ("4.0.0", FormatVersion(4, 0, 0)),
+ ],
+ )
+ def test_document_with_legacy(
+ self, value: object, expected: FormatVersion | LegacyFormat
+ ) -> None:
+ version, root = read_document_with_legacy({"formatVersion": value, "x": 1})
+ assert version == expected
+ assert root["x"] == 1
+
+ @pytest.mark.parametrize(
+ ("document", "code"),
+ [
+ ('{"distribution": []}', d.MISSING_FORMAT_VERSION),
+ ('{"formatVersion": "1"}', d.INVALID_FORMAT_VERSION_SYNTAX),
+ ('{"formatVersion": true}', d.INVALID_FORMAT_VERSION_TYPE),
+ ('{"formatVersion": 5}', d.UNSUPPORTED_FORMAT_VERSION_MAJOR),
+ ('{"formatVersion": 1, "formatVersion": 3}', d.DUPLICATE_FORMAT_VERSION),
+ ],
+ )
+ def test_document_with_legacy_keeps_diagnostics(
+ self, document: str, code: str
+ ) -> None:
+ with pytest.raises(DiagnosticError) as info:
+ read_document_with_legacy(document)
+ assert info.value.codes == (code,)
+
+ def test_legacy_document(self) -> None:
+ root = read_legacy_document('{"formatVersion": 2, "x": 1}', LegacyFormat.V2)
+ assert root["x"] == 1
+
+ @pytest.mark.parametrize(
+ ("value", "found"), [(2, "2"), (3, "3.0.0"), ("3.1.0", "3.1.0"), (4, "4.0.0")]
+ )
+ def test_legacy_document_refuses_other_formats(
+ self, value: object, found: str
+ ) -> None:
+ with pytest.raises(DiagnosticError) as info:
+ read_legacy_document({"formatVersion": value}, LegacyFormat.V1)
+ assert info.value.codes == (d.UNSUPPORTED_FORMAT_VERSION_MAJOR,)
+ assert f"format version {found} " in str(info.value)
+ assert "only legacy format 1" in str(info.value)
+
+ def test_legacy_document_keeps_recognition_errors(self) -> None:
+ with pytest.raises(DiagnosticError) as info:
+ read_legacy_document({"formatVersion": "1"}, LegacyFormat.V1)
+ assert info.value.codes == (d.INVALID_FORMAT_VERSION_SYNTAX,)
+
+ def test_legacy_document_missing_version(self) -> None:
+ with pytest.raises(DiagnosticError) as info:
+ read_legacy_document("{}", LegacyFormat.V1)
+ assert info.value.codes == (d.MISSING_FORMAT_VERSION,)
+
+ def test_support_table_stays_in_the_release_grammar(self) -> None:
+ assert all(interval.lower.major >= 3 for interval in SUPPORT_TABLE)
diff --git a/tests/unit/ir/test_v3_legacy_codec.py b/tests/unit/ir/test_v3_legacy_codec.py
new file mode 100644
index 00000000..a62fce7e
--- /dev/null
+++ b/tests/unit/ir/test_v3_legacy_codec.py
@@ -0,0 +1,537 @@
+"""Tests for the legacy format 1 and 2 decoders (capability ir/v3).
+
+The JSON spellings come from morphir-elm at bc99af69: ``*/CodecV1.elm`` for
+format 1, and for format 2 the codecs before commit 02aa36af, whose value and
+pattern tags survive as ``Value/CodecV2.elm`` in that commit.
+"""
+
+import json
+from decimal import Decimal
+from pathlib import Path
+
+import pytest
+
+from morphir.ir import diagnostics as d
+from morphir.ir import v3
+from morphir.ir.diagnostics import DiagnosticError
+from morphir.ir.migrate import v3_to_v4
+from morphir.ir.v3 import codec, codec_v1, codec_v2, module, package
+from morphir.ir.v3 import literal as lit
+from morphir.ir.v3 import type as t
+from morphir.ir.v3 import value as v
+from morphir.ir.v3.access_controlled import private, public
+from morphir.ir.v3.documented import Documented
+from morphir.ir.v3.fqname import fqn
+from morphir.ir.v3.name import Name, from_string
+from morphir.ir.v3.path import from_string as path_from_string
+
+FIXTURES = Path(__file__).resolve().parents[2] / "fixtures" / "ir" / "legacy"
+RENTALS_1 = FIXTURES / "elm-rentals-format-1.json"
+RENTALS_2 = FIXTURES / "elm-rentals-format-2.json"
+
+FQ = [[["morphir"], ["s", "d", "k"]], [["basics"]], ["int"]]
+INT_FQN = fqn("Morphir.SDK", "Basics", "Int")
+INT = t.Reference(None, INT_FQN)
+INT_1 = ["reference", {}, FQ, []]
+INT_3 = ["Reference", {}, FQ, []]
+UNIT_V = ["unit", {}]
+WILD = ["wildcard_pattern", {}]
+DEF_1 = {
+ "inputTypes": [[["a"], {}, ["unit", {}]]],
+ "outputType": ["unit", {}],
+ "body": UNIT_V,
+}
+DEF_2 = {
+ "inputTypes": [[["a"], {}, ["Unit", {}]]],
+ "outputType": ["Unit", {}],
+ "body": UNIT_V,
+}
+DEF = v.Definition(((("a",), None, t.Unit(None)),), t.Unit(None), v.Unit(None))
+
+
+def n(text: str) -> Name:
+ return from_string(text)
+
+
+def codes(fn, j) -> tuple[str, ...]: # type: ignore[no-untyped-def]
+ with pytest.raises(DiagnosticError) as info:
+ fn(j)
+ return info.value.codes
+
+
+decode_type_1 = codec_v1.decode_type(codec.decode_unit)
+decode_pattern_1 = codec_v1.decode_pattern(codec.decode_unit)
+decode_value_1 = codec_v1.decode_value(codec.decode_unit, codec.decode_unit)
+decode_pattern_2 = codec_v2.decode_pattern(codec.decode_unit)
+decode_value_2 = codec_v2.decode_value(codec.decode_unit, codec.decode_unit)
+
+
+class TestFormat1Literals:
+ @pytest.mark.parametrize(
+ ("j", "expected"),
+ [
+ (["bool_literal", True], lit.BoolLiteral(True)),
+ (["char_literal", "x"], lit.CharLiteral("x")),
+ (["string_literal", "s"], lit.StringLiteral("s")),
+ (["int_literal", 42], lit.WholeNumberLiteral(42)),
+ (["float_literal", 1.5], lit.FloatLiteral(1.5)),
+ (["decimal_literal", "1.25"], lit.DecimalLiteral(Decimal("1.25"))),
+ ],
+ )
+ def test_tags(self, j: object, expected: lit.Literal) -> None:
+ assert codec_v1.decode_literal(j) == expected
+
+ @pytest.mark.parametrize(
+ ("j", "code"),
+ [
+ (["BoolLiteral", True], d.UNKNOWN_NODE),
+ (["whole_number_literal", 1], d.UNKNOWN_NODE),
+ (["int_literal", "1"], d.INVALID_LITERAL),
+ ([], d.MISSING_MEMBER),
+ ],
+ )
+ def test_errors(self, j: object, code: str) -> None:
+ assert codes(codec_v1.decode_literal, j) == (code,)
+
+
+class TestFormat1Types:
+ @pytest.mark.parametrize(
+ ("j", "expected"),
+ [
+ (["variable", {}, ["a"]], t.Variable(None, ("a",))),
+ (
+ ["reference", {}, FQ, [["variable", {}, ["a"]]]],
+ t.Reference(None, INT_FQN, (t.Variable(None, ("a",)),)),
+ ),
+ (["tuple", {}, [INT_1, INT_1]], t.Tuple(None, (INT, INT))),
+ (["record", {}, [[["a"], INT_1]]], t.Record(None, (t.Field(("a",), INT),))),
+ (
+ ["extensible_record", {}, ["r"], [[["a"], INT_1]]],
+ t.ExtensibleRecord(None, ("r",), (t.Field(("a",), INT),)),
+ ),
+ (["function", {}, INT_1, INT_1], t.Function(None, INT, INT)),
+ (["unit", {}], t.Unit(None)),
+ ],
+ )
+ def test_tags(self, j: object, expected: t.Type[None]) -> None:
+ assert decode_type_1(j) == expected
+
+ @pytest.mark.parametrize(
+ ("j", "code"),
+ [
+ (["Variable", {}, ["a"]], d.UNKNOWN_NODE),
+ (["record", {}, [{"name": ["a"], "tpe": INT_1}]], d.INVALID_JSON),
+ (["variable", {}], d.MISSING_MEMBER),
+ ],
+ )
+ def test_errors(self, j: object, code: str) -> None:
+ assert codes(decode_type_1, j) == (code,)
+
+ def test_specifications(self) -> None:
+ decode = codec_v1.decode_type_specification(codec.decode_unit)
+ assert decode(
+ ["type_alias_specification", [["a"]], INT_1]
+ ) == t.TypeAliasSpecification((("a",),), INT)
+ assert decode(["opaque_type_specification", []]) == t.OpaqueTypeSpecification(
+ ()
+ )
+ assert decode(
+ ["custom_type_specification", [], [[["foo"], [[["x"], INT_1]]]]]
+ ) == t.CustomTypeSpecification((), {("foo",): ((("x",), INT),)})
+ config = {"baseType": INT_1, "fromBaseType": FQ, "toBaseType": FQ}
+ assert decode(
+ ["derived_type_specification", [], config]
+ ) == t.DerivedTypeSpecification((), INT, INT_FQN, INT_FQN)
+
+ def test_derived_type_uses_the_decoder_spelling(self) -> None:
+ # elm CodecV1 encodes "DerivedTypeSpecification" but decodes only
+ # "derived_type_specification"; the decoder is what reads files.
+ decode = codec_v1.decode_type_specification(codec.decode_unit)
+ config = {"baseType": INT_1, "fromBaseType": FQ, "toBaseType": FQ}
+ assert codes(decode, ["DerivedTypeSpecification", [], config]) == (
+ d.UNKNOWN_NODE,
+ )
+ assert codes(decode, ["derived_type_specification", [], {}]) == (
+ d.MISSING_MEMBER,
+ )
+
+ def test_definitions(self) -> None:
+ decode = codec_v1.decode_type_definition(codec.decode_unit)
+ assert decode(["type_alias_definition", [], INT_1]) == t.TypeAliasDefinition(
+ (), INT
+ )
+ assert decode(
+ ["custom_type_definition", [], ["private", [[["foo"], []]]]]
+ ) == t.CustomTypeDefinition((), private({("foo",): ()}))
+ assert codes(decode, ["TypeAliasDefinition", [], INT_1]) == (d.UNKNOWN_NODE,)
+
+
+class TestFormat1Wrappers:
+ def test_access_controlled_is_a_tagged_array(self) -> None:
+ decode = codec_v1.decode_access_controlled(decode_type_1)
+ assert decode(["public", INT_1]) == public(INT)
+ assert decode(["private", INT_1]) == private(INT)
+
+ @pytest.mark.parametrize(
+ ("j", "code"),
+ [
+ ({"access": "Public", "value": INT_1}, d.INVALID_JSON),
+ (["Public", INT_1], d.UNKNOWN_NODE),
+ (["public"], d.MISSING_MEMBER),
+ ],
+ )
+ def test_access_controlled_errors(self, j: object, code: str) -> None:
+ decode = codec_v1.decode_access_controlled(decode_type_1)
+ assert codes(decode, j) == (code,)
+
+ def test_documented_is_a_pair_or_bare(self) -> None:
+ decode = codec_v1.decode_documented(decode_type_1)
+ assert decode(["doc", INT_1]) == Documented("doc", INT)
+ assert decode(INT_1) == Documented("", INT)
+ assert decode(UNIT_V) == Documented("", t.Unit(None))
+
+ def test_documented_reports_both_alternatives(self) -> None:
+ decode = codec_v1.decode_documented(decode_type_1)
+ assert codes(decode, ["doc", ["bogus", {}]]) == (
+ d.UNKNOWN_NODE,
+ d.UNKNOWN_NODE,
+ )
+ assert codes(decode, {"doc": "x"}) == (d.INVALID_JSON,)
+
+
+PATTERNS: list[tuple[object, v.Pattern[None]]] = [
+ (WILD, v.WildcardPattern(None)),
+ (
+ ["as_pattern", {}, WILD, ["x"]],
+ v.AsPattern(None, v.WildcardPattern(None), n("x")),
+ ),
+ (["tuple_pattern", {}, [WILD]], v.TuplePattern(None, (v.WildcardPattern(None),))),
+ (
+ ["constructor_pattern", {}, FQ, [WILD]],
+ v.ConstructorPattern(None, INT_FQN, (v.WildcardPattern(None),)),
+ ),
+ (["empty_list_pattern", {}], v.EmptyListPattern(None)),
+ (
+ ["head_tail_pattern", {}, WILD, WILD],
+ v.HeadTailPattern(None, v.WildcardPattern(None), v.WildcardPattern(None)),
+ ),
+ (["unit_pattern", {}], v.UnitPattern(None)),
+]
+
+U = v.Unit(None)
+VALUES: list[tuple[object, v.Value[None, None]]] = [
+ (["constructor", {}, FQ], v.Constructor(None, INT_FQN)),
+ (["tuple", {}, [UNIT_V]], v.Tuple(None, (U,))),
+ (["list", {}, [UNIT_V]], v.List(None, (U,))),
+ (["record", {}, [[["a"], UNIT_V]]], v.Record(None, {("a",): U})),
+ (["variable", {}, ["x"]], v.Variable(None, ("x",))),
+ (["reference", {}, FQ], v.Reference(None, INT_FQN)),
+ (["field", {}, UNIT_V, ["a"]], v.Field(None, U, ("a",))),
+ (["field_function", {}, ["a"]], v.FieldFunction(None, ("a",))),
+ (["apply", {}, UNIT_V, UNIT_V], v.Apply(None, U, U)),
+ (["lambda", {}, WILD, UNIT_V], v.Lambda(None, v.WildcardPattern(None), U)),
+ (
+ ["destructure", {}, WILD, UNIT_V, UNIT_V],
+ v.Destructure(None, v.WildcardPattern(None), U, U),
+ ),
+ (["if_then_else", {}, UNIT_V, UNIT_V, UNIT_V], v.IfThenElse(None, U, U, U)),
+ (
+ ["pattern_match", {}, UNIT_V, [[WILD, UNIT_V]]],
+ v.PatternMatch(None, U, ((v.WildcardPattern(None), U),)),
+ ),
+ (
+ ["update_record", {}, UNIT_V, [[["a"], UNIT_V]]],
+ v.UpdateRecord(None, U, {("a",): U}),
+ ),
+ (UNIT_V, U),
+]
+
+
+class TestFormat1Values:
+ @pytest.mark.parametrize(("j", "expected"), PATTERNS)
+ def test_pattern_tags(self, j: object, expected: v.Pattern[None]) -> None:
+ assert decode_pattern_1(j) == expected
+
+ def test_literal_pattern_uses_format_1_literals(self) -> None:
+ j = ["literal_pattern", {}, ["int_literal", 1]]
+ assert decode_pattern_1(j) == v.LiteralPattern(None, lit.WholeNumberLiteral(1))
+
+ @pytest.mark.parametrize("j", [["WildcardPattern", {}], ["bogus_pattern", {}]])
+ def test_unknown_pattern(self, j: object) -> None:
+ assert codes(decode_pattern_1, j) == (d.UNKNOWN_NODE,)
+
+ @pytest.mark.parametrize(("j", "expected"), VALUES)
+ def test_value_tags(self, j: object, expected: v.Value[None, None]) -> None:
+ assert decode_value_1(j) == expected
+
+ def test_literal_value_uses_format_1_literals(self) -> None:
+ j = ["literal", {}, ["bool_literal", True]]
+ assert decode_value_1(j) == v.LiteralValue(None, lit.BoolLiteral(True))
+
+ def test_let_definitions_use_format_1_types(self) -> None:
+ assert decode_value_1(
+ ["let_definition", {}, ["x"], DEF_1, UNIT_V]
+ ) == v.LetDefinition(None, ("x",), DEF, U)
+ assert decode_value_1(
+ ["let_recursion", {}, [[["x"], DEF_1]], UNIT_V]
+ ) == v.LetRecursion(None, {("x",): DEF}, U)
+
+ @pytest.mark.parametrize(
+ ("j", "code"),
+ [
+ (["Literal", {}, ["bool_literal", True]], d.UNKNOWN_NODE),
+ (["literal", {}, ["BoolLiteral", True]], d.UNKNOWN_NODE),
+ (["let_definition", {}, ["x"], DEF_2, UNIT_V], d.UNKNOWN_NODE),
+ (["pattern_match", {}, UNIT_V, [[WILD]]], d.MISSING_MEMBER),
+ (["apply", {}, UNIT_V], d.MISSING_MEMBER),
+ ],
+ )
+ def test_value_errors(self, j: object, code: str) -> None:
+ assert codes(decode_value_1, j) == (code,)
+
+ def test_typed_value_attributes(self) -> None:
+ decode = codec_v1.decode_value(codec.decode_unit, decode_type_1)
+ assert decode(["variable", INT_1, ["x"]]) == v.Variable(INT, ("x",))
+
+ def test_value_specification_and_definition(self) -> None:
+ spec = codec_v1.decode_value_specification(codec.decode_unit)
+ assert spec({"inputs": [[["a"], INT_1]], "output": INT_1}) == (
+ v.Specification(((("a",), INT),), INT)
+ )
+ defn = codec_v1.decode_value_definition(codec.decode_unit, codec.decode_unit)
+ assert defn(DEF_1) == DEF
+ assert codes(defn, {"inputTypes": [], "outputType": INT_1}) == (
+ d.MISSING_MEMBER,
+ )
+
+
+MODULE_SPEC_1 = {
+ "types": [[["t"], ["a type", ["opaque_type_specification", []]]]],
+ "values": [[["f"], {"inputs": [], "output": INT_1}]],
+}
+MODULE_DEF_1 = {
+ "types": [[["t"], ["public", ["a type", ["type_alias_definition", [], INT_1]]]]],
+ "values": [[["f"], ["private", DEF_1]]],
+}
+MODULE_SPEC = module.Specification(
+ {("t",): Documented("a type", t.OpaqueTypeSpecification(()))},
+ {("f",): Documented("", v.Specification((), INT))},
+)
+MODULE_DEF: module.Definition[None, None] = module.Definition(
+ {("t",): public(Documented("a type", t.TypeAliasDefinition((), INT)))},
+ {("f",): private(Documented("", DEF))},
+)
+
+
+class TestFormat1Packages:
+ def test_module_specification(self) -> None:
+ decode = codec_v1.decode_module_specification(codec.decode_unit)
+ assert decode(MODULE_SPEC_1) == MODULE_SPEC
+
+ def test_module_doc_is_not_read(self) -> None:
+ decode = codec_v1.decode_module_specification(codec.decode_unit)
+ assert decode({**MODULE_SPEC_1, "doc": "ignored"}).doc is None
+ decode_def = codec_v1.decode_module_definition(
+ codec.decode_unit, codec.decode_unit
+ )
+ assert decode_def({**MODULE_DEF_1, "doc": "ignored"}).doc is None
+
+ def test_module_definition(self) -> None:
+ decode = codec_v1.decode_module_definition(codec.decode_unit, codec.decode_unit)
+ assert decode(MODULE_DEF_1) == MODULE_DEF
+ assert codes(decode, {"types": []}) == (d.MISSING_MEMBER,)
+
+ def test_package_specification_uses_name_and_spec(self) -> None:
+ decode = codec_v1.decode_package_specification(codec.decode_unit)
+ j = {"modules": [{"name": [["a"]], "spec": MODULE_SPEC_1}]}
+ assert decode(j) == package.Specification({(("a",),): MODULE_SPEC})
+ assert codes(decode, {"modules": [[[["a"]], MODULE_SPEC_1]]}) == (
+ d.INVALID_JSON,
+ )
+
+ def test_package_definition_uses_name_and_def(self) -> None:
+ decode = codec_v1.decode_package_definition(
+ codec.decode_unit, codec.decode_unit
+ )
+ j = {"modules": [{"name": [["a"]], "def": ["public", MODULE_DEF_1]}]}
+ assert decode(j) == package.Definition({(("a",),): public(MODULE_DEF)})
+ assert codes(decode, {"modules": [{"name": [["a"]]}]}) == (d.MISSING_MEMBER,)
+
+ def test_distribution(self) -> None:
+ j = ["library", [["p"]], [], {"modules": []}]
+ assert codec_v1.decode_distribution(j) == v3.Library(
+ path_from_string("P"), package.Definition()
+ )
+ assert codes(codec_v1.decode_distribution, ["Library", [], [], {}]) == (
+ d.UNKNOWN_NODE,
+ )
+
+ def test_distribution_with_dependency(self) -> None:
+ spec = {"modules": [{"name": [["a"]], "spec": MODULE_SPEC_1}]}
+ j = ["library", [["p"]], [[[["dep"]], spec]], {"modules": []}]
+ lib = codec_v1.decode_distribution(j)
+ assert lib.dependencies == {
+ path_from_string("Dep"): package.Specification({(("a",),): MODULE_SPEC})
+ }
+
+ def test_versioned_distribution(self) -> None:
+ j = {"formatVersion": 1, "distribution": ["library", [], [], {"modules": []}]}
+ assert codec_v1.decode_versioned_distribution(j) == v3.Library(
+ path_from_string(""), package.Definition()
+ )
+
+ @pytest.mark.parametrize("version", [2, 3, "3.0.0", 4])
+ def test_versioned_distribution_refuses_other_formats(
+ self, version: object
+ ) -> None:
+ j = {"formatVersion": version, "distribution": []}
+ assert codes(codec_v1.decode_versioned_distribution, j) == (
+ d.UNSUPPORTED_FORMAT_VERSION_MAJOR,
+ )
+
+ def test_versioned_distribution_needs_a_distribution(self) -> None:
+ assert codes(codec_v1.decode_versioned_distribution, {"formatVersion": 1}) == (
+ d.MISSING_MEMBER,
+ )
+
+
+class TestFormat2:
+ @pytest.mark.parametrize(("j", "expected"), PATTERNS)
+ def test_pattern_tags_are_snake_case(
+ self, j: object, expected: v.Pattern[None]
+ ) -> None:
+ assert decode_pattern_2(j) == expected
+
+ @pytest.mark.parametrize(("j", "expected"), VALUES)
+ def test_value_tags_are_snake_case(
+ self, j: object, expected: v.Value[None, None]
+ ) -> None:
+ assert decode_value_2(j) == expected
+
+ def test_literals_and_types_are_format_3(self) -> None:
+ assert decode_value_2(["literal", {}, ["BoolLiteral", True]]) == v.LiteralValue(
+ None, lit.BoolLiteral(True)
+ )
+ assert decode_pattern_2(
+ ["literal_pattern", {}, ["WholeNumberLiteral", 1]]
+ ) == v.LiteralPattern(None, lit.WholeNumberLiteral(1))
+ assert decode_value_2(
+ ["let_definition", {}, ["x"], DEF_2, UNIT_V]
+ ) == v.LetDefinition(None, ("x",), DEF, U)
+ decode = codec_v2.decode_value(
+ codec.decode_unit, codec.decode_type(codec.decode_unit)
+ )
+ assert decode(["variable", INT_3, ["x"]]) == v.Variable(INT, ("x",))
+
+ @pytest.mark.parametrize(
+ "j",
+ [
+ ["Literal", {}, ["BoolLiteral", True]],
+ ["literal", {}, ["bool_literal", True]],
+ ["let_definition", {}, ["x"], DEF_1, UNIT_V],
+ ],
+ )
+ def test_other_spellings_are_refused(self, j: object) -> None:
+ assert codes(decode_value_2, j) == (d.UNKNOWN_NODE,)
+
+ def test_value_definition(self) -> None:
+ decode = codec_v2.decode_value_definition(codec.decode_unit, codec.decode_unit)
+ assert decode(DEF_2) == DEF
+
+ def test_module_and_package_definition(self) -> None:
+ module_def = {
+ "types": [
+ [
+ ["t"],
+ {
+ "access": "Public",
+ "value": {
+ "doc": "a type",
+ "value": ["TypeAliasDefinition", [], INT_3],
+ },
+ },
+ ]
+ ],
+ "values": [[["f"], {"access": "Private", "value": DEF_2}]],
+ "doc": "a module",
+ }
+ decode_module = codec_v2.decode_module_definition(
+ codec.decode_unit, codec.decode_unit
+ )
+ expected = module.Definition(MODULE_DEF.types, MODULE_DEF.values, "a module")
+ assert decode_module(module_def) == expected
+ decode_package = codec_v2.decode_package_definition(
+ codec.decode_unit, codec.decode_unit
+ )
+ j = {"modules": [[[["a"]], {"access": "Public", "value": module_def}]]}
+ assert decode_package(j) == package.Definition({(("a",),): public(expected)})
+
+ def test_distribution(self) -> None:
+ j = ["Library", [["p"]], [], {"modules": []}]
+ assert codec_v2.decode_distribution(j) == v3.Library(
+ path_from_string("P"), package.Definition()
+ )
+ assert codes(codec_v2.decode_distribution, ["library", [], [], {}]) == (
+ d.UNKNOWN_NODE,
+ )
+
+ def test_versioned_distribution(self) -> None:
+ j = {"formatVersion": 2, "distribution": ["Library", [], [], {"modules": []}]}
+ assert codec_v2.decode_versioned_distribution(json.dumps(j)) == v3.Library(
+ path_from_string(""), package.Definition()
+ )
+
+ @pytest.mark.parametrize("version", [1, 3, 4])
+ def test_versioned_distribution_refuses_other_formats(self, version: int) -> None:
+ j = {"formatVersion": version, "distribution": []}
+ assert codes(codec_v2.decode_versioned_distribution, j) == (
+ d.UNSUPPORTED_FORMAT_VERSION_MAJOR,
+ )
+
+
+class TestElmFixtures:
+ """The morphir-elm rentals example, written once as format 1 and once as 2."""
+
+ def test_format_1_decodes(self) -> None:
+ lib = codec_v1.decode_versioned_distribution(RENTALS_1.read_text())
+ assert lib.package_name == path_from_string("Morphir.Example.App")
+ assert list(lib.definition.modules) == [path_from_string("Rentals")]
+
+ def test_format_2_decodes(self) -> None:
+ lib = codec_v2.decode_versioned_distribution(RENTALS_2.read_text())
+ assert lib.package_name == path_from_string("Morphir.Example.App")
+
+ def test_both_formats_hold_the_same_model(self) -> None:
+ assert codec_v1.decode_versioned_distribution(
+ RENTALS_1.read_text()
+ ) == codec_v2.decode_versioned_distribution(RENTALS_2.read_text())
+
+ @pytest.mark.parametrize(
+ ("reader", "fixture"),
+ [
+ (codec_v1.decode_versioned_distribution, RENTALS_1),
+ (codec_v2.decode_versioned_distribution, RENTALS_2),
+ ],
+ )
+ def test_legacy_gives_a_3_0_0_library_never_specs(self, reader, fixture) -> None: # type: ignore[no-untyped-def]
+ lib = reader(fixture.read_text())
+ assert isinstance(lib, v3.Library)
+ assert not isinstance(lib, v3.Specs)
+ document = codec.encode_versioned_distribution(lib)
+ assert document["formatVersion"] == 3
+
+ def test_upgrade_writes_format_3(self) -> None:
+ lib = codec_v1.decode_versioned_distribution(RENTALS_1.read_text())
+ document = codec.encode_versioned_distribution(lib)
+ assert document["formatVersion"] == 3
+ assert codec.decode_versioned_distribution(document) == lib
+
+ def test_upgrade_continues_to_v4(self) -> None:
+ lib = codec_v2.decode_versioned_distribution(RENTALS_2.read_text())
+ assert v3_to_v4(lib).value.package.name is not None
+
+ @pytest.mark.parametrize("fixture", [RENTALS_1, RENTALS_2])
+ def test_format_3_reader_still_refuses_legacy(self, fixture: Path) -> None:
+ assert codes(codec.decode_versioned_distribution, fixture.read_text()) == (
+ d.UNSUPPORTED_FORMAT_VERSION_MAJOR,
+ )