Morphir Elm
Purpose
finos/morphir-elm generates Elm source from current Morphir IR. The package finos/morphir-elm/backend lowers
IR to the elm-syntax AST of
moonrockz/krueger and prints it in the elm-format layout.
The backend has no engine, host, filesystem, process or network imports. It takes an IRFile and options, and it
returns the generated files as text. The same input and options give the same output bytes. The engine registers
the backend as the generation backend elm.
Use from the engine
Set the backend in morphir.toml. The table [backends.elm] holds the options. All keys are optional.
[pipeline]
backend = "elm"
[backends.elm]
profile = "model" # "model" (default) | "literal"
placeholders = "refuse" # "refuse" (default) | "debug-todo"
width = 120
elm_package = "finos/morphir-elm 22.0.2"
[backends.elm.mapping]
"morphir/SDK:basics#add" = { operator = "+" }
"morphir/SDK:list#map" = { function = "List.map" }
"morphir/SDK:decimal#decimal" = { type = "Decimal.Decimal", import = "MyCo.Decimal as Decimal" }
"morphir/SDK:basics#max" = {}
[backends.elm.packages]
"acme/common" = { package = "acme/common 1.0.0" }
profileselects the profile. See Profiles.placeholderstells the backend what to do with a value that has no body.refusestops generation withelm.placeholder.debug-todowritesDebug.todo "<reason>".widthis the line width of the printer. It must be an integer from 1 to 1000.elm_packageis the Elm package that supplies unmappedMorphir.SDKmodules. The value is"author/name major.minor.patch". The default isfinos/morphir-elm 22.0.2.mappingadds, replaces or removes mapping entries. See Mapping.packagesmaps a Morphir dependency package to an Elm package. An entry has only the form{ package = "author/name 1.2.3" }.
The engine checks the table before it runs the backend. An unknown key or a bad value gives elm.invalid_option.
A bad mapping entry gives elm.invalid_mapping.
The elm backend declares no build targets. The engine uses pipeline.validation = "source-only" for it, and it
refuses "required" with build.unsupported_target.
Profiles
There are two profiles.
The model profile (the default) writes idiomatic Elm. A reference that has a mapping entry uses that entry. The
IR for a + b becomes:
add : Int -> Int -> Int
add a b =
a + b
The literal profile ignores the mapping. Every reference stays qualified. Use it as a debug view of the IR. The
same IR becomes:
add : Morphir.SDK.Basics.Int -> Morphir.SDK.Basics.Int -> Morphir.SDK.Basics.Int
add a b =
Morphir.SDK.Basics.add a b
If you set mapping with the literal profile, the backend gives a warning and ignores the entries.
Mapping
A mapping entry tells the model profile how to write a Morphir reference in Elm. An entry is one of these:
{ operator = "+" }: an Elm operator. With two arguments it becomesa + b. With fewer arguments it becomes a prefix operator, for example(+) a. An operator entry takes noimport.{ function = "List.map" }: an Elm value or constructor, qualified or not.{ type = "Dict.Dict" }: an Elm type, qualified or not.{}: removes the entry. The reference falls back to the qualified form.
A function or type entry can have an import, for example import = "MyCo.Decimal as Decimal". The backend
adds that line to the imports of the module. With no import, the backend imports the qualifier of the name, unless
Elm imports that module by default.
The built-in table
scripts/generate-builtin-mapping.py writes backend/mapping_builtin.mbt from
pkgs/morphir-sdk/conformance/bindings.json. The script maps every binding whose semanticSource is
elm/core 1.0.5 to the elm/core function of the same name. It adds a hand-written list of types, constructors and
operators. The table has elm/core semantics only. Other SDK references stay qualified, for example
Morphir.SDK.LocalDate.addDays. Do not edit mapping_builtin.mbt by hand. Run the script again.
Every built-in entry is fully qualified, for example Basics.max, Maybe.Just or Basics.Int. The backend writes
the name bare (max, Just, Int) only when a default import exposes it and nothing can capture it. A lower-case
name must not be a top-level value of the module or a binder in the declaration. An upper-case name must not be a type
or constructor of the module. In Elm, a module's own names win over a default import. A user entry keeps its text as
written, so a bare entry such as { function = "max" } always prints bare.
The script writes each key in the canonical form that migration gives for legacy IR. It splits the Elm name at
camelCase boundaries and digit runs, and joins a run of single letters into one initialism. For example, map2 has the
key morphir/SDK:list#map-2 and LT has the key morphir/SDK:basics#LT.
Namespaces
Types and values have separate namespaces in the mapping. One FQName can be a type and a function, for example
morphir/SDK:basics#never. A type reference looks only at type entries. A value or constructor reference looks only
at value entries.
An override goes to the namespace of its kind. A type entry replaces the type entry for its key. An operator or
function entry replaces the value entry for its key. An empty entry {} removes the key from both namespaces.
Keys
A key is any IR FQName in canonical text form, for example morphir/SDK:basics#add. The key does not have to be an
SDK reference. You can map references in your own packages to existing Elm code. A key that is not a canonical FQName gives
elm.invalid_mapping. If the key parses but has a different spelling, the detail gives the canonical spelling.
Output
The backend writes one Elm package project:
elm.json
morphir.json
src/<Package>/<Module>.elm
- Each IR module becomes one file in
src/. The Elm module name is the package path followed by the module path, for exampleAcme.Shapes.Geometryfor packageacme/shapesand modulegeometry. elm.jsonhas"type": "package". Its name is the first part of the Morphir package path,/, and the other parts joined with-, for exampleacme/shapes. It exposes the public modules. If no module is public, it exposes all modules.morphir.jsonhas the Morphir package name,"sourceDirectory": "src"and the exposed modules.
The elm.json is a package, not an application. An application elm.json must list every indirect dependency with
an exact version, and that needs a dependency solver. A package elm.json takes version ranges. The dependencies
are:
elm/core1.0.5 <= v < 2.0.0, always.- The
elm_package, only when the code uses an unmappedMorphir.SDKreference. - One entry for each dependency package that has an entry in
packages. A dependency package with no entry gives a warning, not an error.elm makethen reports the missing modules.
Declarations
- Declarations keep the IR order. Imports are sorted.
- Public types and values are exposed. Private ones are not. A custom type with public constructors is exposed as
T(..). A custom type with private constructors is exposed asT. - An opaque type specification becomes a custom type with one hidden constructor,
<Type>Opaque. - Module documentation becomes the module doc comment. Member documentation becomes the doc comment of the declaration. An empty doc gives no doc comment.
- An Elm reserved word as a name gets the suffix
_, for exampletype_. - Elm updates only a record with a bare name. A record update of a local or of a value that prints without a
qualifier names it:
{ origin | x = 1 }. Any other record is first bound in alet, asrecord_<n>. - A type variable with a
typeclassconstraint gets the Elm prefix of that class:number,comparable,appendableorcompappend. An unconstrained variable whose name starts with one of these prefixes gets the prefixt, so Elm does not read it as constrained.
Attributes
Elm has no place for some IR data. The backend writes that data as attributes in an attributes code block in the
doc comment. The attribute value is Elm data, so krueger can read it back.
@morphir.entry { kind = "main", name = "run" }
@morphir.annotation { fqname = "acme/api:tags#stable" }
@morphir.external { platform = "js", name = "Math.max" }
@morphir.entrymarks the target value of an entry point in anApplication.@morphir.annotationholds an annotation of a value specification. A compact annotation hasfqnameand an optionaltext. A structured annotation hasfqnameandarguments.@morphir.externalholds one binding of anExternalBody.
The arguments of a structured annotation must be data: literals, unit, lists, tuples, records, constructors and
constructor applications of data. Any other value, for example a function application or a variable, gives
elm.annotation_argument.
Local names
Elm rejects a local name that shadows another name in scope. The backend renames such a name with a numeric suffix,
for example x becomes x_1.
Before each top-level value, the backend resets the set of used local names to the top-level value names of the module. Locals in different top-level values do not affect each other. A local goes out of scope at the end of its lambda, case branch, function or let block, so two branches or two lambdas can both use a name. Elm sees every name of a let block in each definition of the block, so a name bound inside a definition that is also a name of the block gets the suffix, and the let name keeps its name. The suffix counter does not reset, so the numbers increase through the module.
A let function takes the patterns of the leading lambdas of its body as arguments: loop n = loop n, not
loop = \n -> loop n, which Elm refuses as a cyclic value.
A let function has a type signature only when its type has no type variable. Elm reads type variables in a
let signature as new rigid variables, so a signature with one could make valid code fail.
Refusals
The backend raises ElmGenerationError. Refused holds one Problem. Failed holds the problems of all failing
modules. A Problem has a code, a node_path and a detail. The node_path is the canonical FQName of the IR
node, or the option key.
Generation of a module stops at its first problem. The backend tries all modules, so one run reports every failing module.
| Code | Cause |
|---|---|
elm.tuple_arity |
A tuple with fewer than two or more than three elements. Elm tuples have two or three. |
elm.derived_type |
A derived type. |
elm.decimal_literal |
A decimal literal. There is no decimal construction mapping yet. |
elm.placeholder |
A construct with no body while placeholders = "refuse", a custom type with no constructors, or an incomplete type definition. |
elm.invalid_mapping |
A mapping entry that is not valid, for example a bad key or a constructor that maps to an operator. |
elm.print |
krueger could not print the AST. The path names the IR declaration. This is a backend defect. |
elm.int_literal |
An integer lexeme outside the Int64 range. |
elm.document_literal |
A document literal. Elm has no document literal. |
elm.literal_pattern |
A float or decimal pattern. Elm rejects these patterns. |
elm.unsupported_constraint |
A typeclass constraint that has no Elm class, or that is not text or a list of text. |
elm.name_collision |
Two IR names in one module give the same Elm name. |
elm.annotation_argument |
A structured annotation argument that is not data. |
elm.invalid_option |
An unknown option key or a bad option value. |
elm.cancelled |
The caller cancelled the generation. |
Limits
- The backend does not write regular comments. krueger prints doc comments only.
- Derived types and decimal literals are refused (
elm.derived_typeandelm.decimal_literal). elm make --optimizerejectsDebug.todo. Code generated withplaceholders = "debug-todo"compiles only without--optimize.- The
finos/morphir-elmElm package has noMorphir.SDK.Maybemodule. An unmappedmorphir/SDK:maybereference, for examplemorphir/SDK:maybe#has-value, gives code thatelm makerejects. Add a mapping entry for it. packagestakes only Elm packages. Dependency source directories are not supported.
Frontend
The frontend reads an Elm project and gives the v4 Morphir IR of it. The output is the same as the output of
morphir-elm make 2.100.0, with these differences: the shared IR inference (finos/morphir-typing) gives the
types, so some types differ from those of morphir-elm (rule 22), and top-level mutual recursion is accepted
(rule 21).
pub fn compile_project(
sources : Array[ElmSource], // every .elm file: path relative to the source directory, and content
package_name : String, // the dotted package name, for example "My.Pkg"
exposed : Array[String], // the exposed modules relative to the package, as in morphir.json
output? : OutputModules = Parity, // Parity: the modules of morphir-elm; Referenced: also every module the output names
) -> @ir.IRFile raise ElmFailed
The result is a Library with no dependencies. ElmFailed holds the problems of the first pass that failed. Each
pass reports all of its problems together. An empty exposed list stops the compile before any source is parsed.
Then the passes are: parse, module rules (module name, port and effect modules), import cycles, exposed modules,
exports and imports, name references, lowering and inference. As in morphir-elm,
the parse and import cycle passes check every source. The other passes check only the modules that morphir-elm
processes (rule 2), also when such a module is not in the output. A problem of these kinds in a module that nothing
imports does not stop the compile. An ElmProblem has a code, a message, the source path and the location
in the file when it is known. An empty exposed list gives a problem with an empty path. A missing exposed module
gives a problem at the source path where the module must be.
output chooses the output modules. Parity, the default, keeps the modules that morphir-elm keeps (rule 2), so the
output can name a type of a module that it drops: a module named only in a value signature or used only as a
constructor. Referenced also keeps every processed module that an output module names, transitively, in type
definitions, value signatures, value bodies, patterns and inferred types. An added module keeps the access of
rule 2. The engine's Elm provider uses Referenced, because generated Elm and MoonBit must not name a missing module.
There are four packages:
finos/morphir-elm/mapping: Elm names to Morphir names, and the elm/core table.finos/morphir-elm/frontend: parsing, module rules, module selection, access, docs andcompile_project.finos/morphir-elm/frontend/resolve: the project symbol table, exports, imports and name resolution.finos/morphir-elm/frontend/lower: type definitions and value definitions (signatures and bodies) to IR.
| Code | Cause |
|---|---|
elm.parse |
A file does not parse. |
elm.module_name |
The module name does not match the file path, or does not start with the package name. |
elm.import_cycle |
The imports form a cycle. The problem is at the import that closes the cycle. |
elm.exposed_module |
The exposed list is empty, or an exposed module has no source file. |
elm.unresolved_name |
A name, or an entry of an exposing list, that does not resolve. |
elm.ambiguous_name |
A name used in the code that two imports give with different meanings. |
elm.unsupported_module |
An import of a module that is not a project, elm/core, platform or Morphir.SDK module. |
elm.unsupported_value |
A reference to an elm/core value or operator that has no Morphir SDK binding, or an operator that is not in the operator table (rule 13). |
elm.unsupported |
A port, an effect module, an infix declaration, top-level destructuring, a record pattern, a recursive destructuring in a let block, GLSL, an operator in the exposing list of a module, or a name from Debug, Platform, Platform.Cmd or Platform.Sub (for example Debug.log, Cmd or Program). |
elm.unbound_type_variable |
A type variable that the type alias or custom type does not declare. |
elm.type |
A type error that inference finds. The message is the inference problem (<code> at <logical path>: <message>), the path is the source of its module and the location is the source of its node, when it is known. |
The frontend follows these morphir-elm rules (probes of morphir-elm make 2.100.0):
-
The
morphir.jsonexposedModulesentries are module names relative to the package name ("Types"forMy.Pkg.Types). -
morphir-elm processes the exposed modules and the project modules that they import, transitively. The output keeps the exposed modules and the processed modules that they reach in the module graph, transitively. A module depends on the modules of the names in its type definitions (the constructors of a custom type count, whatever their access) and of the value references in its value bodies: values, operators other than
<|and|>, andBasics.negatefor a negation of anything but a number literal. A module that is named only in value signatures, used only through constructors in bodies, imported and not used, or not imported, is not in the output. The graph compares module paths without the package, so a project module with the path of an SDK module that is used (for exampleMy.Pkg.BasicsandInt) is kept. Every output module keeps all of its declarations. -
The exposed modules are
Public. A module that is not exposed isPublicwhen an exposed module shows one of its types: as the target of a public type alias, as a constructor argument of a public type with public constructors, or in the signature of a public value. This continues through the public definitions of the types that it reaches. morphir-elm takes the names in Elm order and follows only the first name that it finds in each module. All other output modules arePrivate. -
A type exported as
TisPublicwithPrivateconstructors.T(..)givesPublicconstructors. A type or value that the module does not export isPrivate. -
The module path is the module name without the package prefix (
My.Pkg.Typesin packageMy.Pkgis["types"]). -
A declaration doc keeps the raw text between
{-|and-}:{-| A point. -}gives" A point. "and{-| D.-}gives" D.". A declaration without a doc gets"". The module doc is the first{-|comment in the file that is not a declaration doc, without its first 3 and its last 3 characters. Thus the character before-}goes too, whatever it is:{-| Doc. -}gives" Doc.",{-| Doc.-}gives" Doc",{-| Doc. -}gives" Doc. "and{-| Doc.\n\n-}gives" Doc.\n". A module without one has no doc. -
A
{-|comment is the doc of a declaration only when the declaration starts on the row directly after the row where the comment ends. A blank line between them makes the comment an ordinary comment. Thus a{-|comment directly above the first declaration is the doc of that declaration, not the module doc. -
The arguments of a top-level value are the declaration arguments, then the arguments of the leading lambdas in the body, looking through parentheses. Inputs are lifted from the front of this list while the lowered pattern is a variable (
x,(x)and_ as x) and, when the value has an annotation, the rest of the annotation is a function type (also in parentheses). Anything else, also a type alias of a function type, stops the inputs. An unannotated value lifts every leading variable, each with a placeholder type. The output type is the rest of the annotation. The arguments that are not lifted stay in the body as oneLambdaeach. Thuswild _ y = yhas no inputs and the bodyLambda(_, Lambda(y, y)).f : Handlerwithtype alias Handler = Int -> Intandf x = xhas no inputs, the outputHandlerand the bodyLambda(x, x).h : Int -> Handlerwithh a b = ahas the inputa : Int, the outputHandlerand the bodyLambda(b, a). -
A record pattern fails in morphir-elm, in every position. The frontend gives
elm.unsupportedfor it. -
Literals:
1and0xFFareIntegerLiteral("1","255").-1and-2.5fold into the literal.-(1)and-xareApply(Reference basics#negate, …).1.5isFloatLiteral. Char and string literals are decoded ("\u{0041}"is"A"). UnqualifiedTrueandFalseareBoolLiteral. -
Names: a local name is
Variable. A lower-case global name isReference. An upper-case global name isConstructor, also a record alias constructor such asPoint 1 2, with the alias name. -
f a bisApply(Apply(f, a), b).a |> fandf <| aareApply(f, a).(+)is theReferenceof the operator function. -
Every other binary operator
a op bisApply(Apply(Reference op, a), b), with this fixed table (all inbasics, except::). An operator that is not in the table giveselm.unsupported_value.Operator Function Operator Function Operator Function \|\|or<=lessThanOrEqual/divide&&and>=greaterThanOrEqual//integerDivide==equal++append^power/=notEqual+add<<composeLeft<lessThan-subtract>>composeRight>greaterThan*multiply::list#cons -
ifisIfThenElse.caseisPatternMatch, with the cases in source order. -
Patterns:
_isWildcardPattern.xisAsPattern(WildcardPattern, x). Integer, hex, char and string literal patterns areLiteralPattern.TrueandFalseareLiteralPattern(BoolLiteral). Tuples areTuplePattern.[]isEmptyListPattern.[a, b]isHeadTailPattern(a, HeadTailPattern(b, EmptyListPattern)).x :: xsisHeadTailPattern. A constructor has its argument patterns.p as nisAsPattern.()isUnitPattern. A pattern in parentheses is its inner pattern. -
\x y -> bisLambda(x, Lambda(y, b)), oneLambdafor each argument. -
Records:
{ y = 2, x = 1 }isRecord.p.xisField..xisFieldFunction.{ p | x = 1 }isUpdateRecord, with the target asVariable(a local name) orReference(a global name). Tuples, lists and unit areTuple,ListandUnit. -
Let: a let function or value is
LetDefinition(name, definition, in). The definition has no inputs. Its output is the let annotation or, when there is no annotation, a placeholder. Its body is theLambdachain of its arguments. Let definitions are never lifted. A destructuring let isDestructure(pattern, value, in). A group of let definitions that refer to each other (or one that refers to itself) isLetRecursion. A recursive destructuring giveselm.unsupported. -
The let chain is in dependency order, not in source order, as morphir-elm builds it with elm-community/graph 6.0.0. The dependencies are the unqualified names that each right-hand side mentions. When there is no cycle, the chain is the depth-first post-order of the declarations, outermost first. When there is a cycle, the chain is the reverse of the Kosaraju components, outermost first.
-
Each value node and each pattern node has the source location of its Elm code.
-
morphir-elm refuses top-level mutual recursion (
ValueCycleDetected). Elm accepts it, and the frontend accepts it too. -
The last pass is
@typing.infer(file, sdk, Fill)with the Morphir SDK specification. It gives every value and pattern node aninferred_type, and it replaces each placeholder of an unannotated value or let definition with its solution. It sees every processed module, also one that the output drops (rule 2), because a value signature can name a type of such a module. The types can differ from those of morphir-elm: inference generalizes a let definition (morphir-elm does not), and morphir-elm types an unannotated top-level value in a first pass without the other values. -
After inference, a whole-number literal of type
Floatbecomes aFloatLiteral, as morphir-elmInfer.fixNumberLiteralsdoes:fi : Floatwithfi = 2givesFloatLiteral(2.0). An alias ofFloatcounts, with one alias resolved only (Distribution.resolveType): an alias of an alias ofFloatkeeps the whole number. The rewrite reaches the values of every body and let definition, not the patterns. A literal that nothing fixes keeps itsnumbertype variable. Because inference generalizes a let definition (rule 22), a let value can keep a whole number where morphir-elm writes a float: inscaled price = let rate = 2 in price * ratewithscaled : Float -> Float,ratestaysIntegerLiteral "2"of typenumber. The parity test then finds a body difference, and a body difference hides the type comparison of that value (bead morphir-ukvn.5.22.7.1).
Tuple.* values are not supported yet, because the Morphir SDK spec has no Tuple module (bead
morphir-ukvn.5.22.3.4).
Tests
moon test pkgs/morphir-elm/backend # unit and generation tests
moon test pkgs/morphir-elm/conformance # frontend parity with morphir-elm make and the IR round trip
mise run test:elm # elm make on the generated fixture projects
mise run elm:parity-refresh # regenerate the parity corpus after a project change
The parity test compiles each Elm project in pkgs/morphir-elm/conformance/projects with compile_project and
compares the result with expected/morphir-ir.json, the output of the pinned npm morphir-elm 2.100.0. It
compares the modules, types and values, with their access and docs, the type definitions, the value inputs and
output, and the shape of each value body. The declared input, output and let signature types compare exactly,
without alias expansion, because morphir-elm writes them from the source (value <module>#<name>: inputs,
output or let signatures). A position where morphir-elm wrote a placeholder (t<n>) is not compared. The shape is the body with every value attribute removed and every let
signature type set to (); a Record and a LetRecursion compare as maps. A body with another shape is one
value <module>#<name>: body line. When the shapes are the same, @typing.compare_types compares every node type
and signature type of the value, after alias expansion and with one variable renaming per declaration. Each
difference is one type <path>: expected <t1>, found <t2> line. The comparison masks the morphir-elm placeholders
(t<n>) in signatures (mask_signatures=true), because morphir-elm writes the signature of an unannotated value
from a first inference pass; the node types are compared everywhere. Type attributes do not count, and a doc of
None is the same as "". When the frontend fails, each problem is a compile: <code> <path> <message> line.
A known difference goes in parity_gaps (in parity_gaps_wbtest.mbt) with its reason and a bead. The test fails
when a pinned difference goes away, so remove the line when the gap closes. The pinned inference differences today:
@typing generalizes let definitions and morphir-elm does not (morphir-ukvn.5.22.7.1); morphir-elm prints the row
variable of an open record as t<n> (morphir-ukvn.5.22.7.2); morphir-elm writes node types with aliases expanded,
which shows when the alias's module is not in the output (morphir-ukvn.5.22.7.3); morphir-elm prints a number-class
variable as t<n>, without its class (morphir-ukvn.5.22.7.4); morphir-elm types a reference to an unannotated value
with its first-pass declared type (morphir-ukvn.5.22.7.5); the definitions of a morphir-elm LetRecursion do not see
each other, so their nodes can keep free variables (morphir-ukvn.5.22.7.6).
The round-trip test (round_trip_wbtest.mbt) checks IR to Elm to IR. It generates Elm from an IR with the backend
and Options::default(), compiles the generated src/ files with compile_project, with the package name and the
exposed modules of the generated morphir.json, and compares the result with the input IR by the same rules as the
parity test. The inputs are the expected IR of every parity project (parity-<project>) and the three classic
fixtures of finos/morphir-elm/fixtures (classic-greeting, classic-rentals-v1, classic-rentals-v2). When the
backend refuses an input, each problem is a generate: <code> <detail> line; when the frontend fails, a compile:
line. A known difference goes in round_trip_gaps (in round_trip_gaps_wbtest.mbt) with its reason and a bead, and
the test fails when a pinned difference goes away. The pinned differences today: the inference gaps of the parity
test come back, because the frontend infers the generated Elm as it infers the project source; the printer trims the
whitespace at the ends of a doc (morphir-ukvn.5.22.3.7); the morphir-elm output of modules names modules that it
drops, so the generated Elm imports modules that do not exist (morphir-ukvn.5.22.3.8); and the morphir-elm
first-pass signatures of unannotated values (un : t0) are annotations that Elm and the frontend refuse
(morphir-ukvn.5.22.3.9).
After you change a project, run mise run elm:parity-refresh from the repository root. It needs network access
for npm ci. It runs morphir-elm make on every project and writes expected/morphir-ir.json and
parity_data_wbtest.mbt, both with the IR as compact JSON. Commit both. CI runs the refresh in the native build job and fails when pkgs/morphir-elm/conformance changes.
mise run test:elm installs the pinned npm elm 0.19.1 package in scripts/ and runs
scripts/test-elm-make.mjs. The script runs pkgs/morphir-elm/acceptance, which prints the generated projects for
the fixtures rentals, shapes and specs as JSON. It writes each project to a temporary directory and runs
elm make on its modules. The specs project uses placeholders = "debug-todo". The first run needs network access,
because elm make downloads its packages. CI runs this task in the native build job.