Message
format
On this page

@curly-message/conformance

The conformance set of the Curly Message Format: fixtures that an implementation in any language is measured against, and a JavaScript runner that drives one through the adapter of section 14.3. RUNNER.md states what a runner in another language is held to.

A fixture is a resolution written out — the inputs section 4 lists, and the output and the reports they must produce. The set is derived from the specification, and each case names the section it pins, so a failure points at the sentence the implementation disagrees with.

{
  "id": "fallback/payload-outranks-inline",
  "section": "10",
  "description": "The payload's own default takes precedence over the inline one.",
  "message": "Hello, {{name; default:Guest;}}!",
  "payload": { "default": "Friend" },
  "expected": { "output": "Hello, Friend!" }
}

A second kind of file pins the concrete syntax tree of CST.md rather than a resolution: the same message, and the nodes an implementation that offers a tree describes it with. An implementation conforms without offering one, and those cases are then left out.

Status

Stable, on npm as @curly-message/conformance. The set lives in the specification's repository because its fixtures are artifacts of the format, not of any one implementation, and every file states the format version it targets: this set targets curly-message-3. The set carries a version line of its own, because a case may be corrected without the format changing.

The fixture files

fixtures/*.json groups the cases by the section of the specification they pin, one file per group, and index.json lists the files with the level or the kind, and the section, each covers. schema/fixture.schema.json is the JSON Schema every file validates against, so a runner in another language checks the set before it reads it.

Those files are the set. Nothing in them is JavaScript, so an implementation in another language needs nothing else from this package: every release carries them as conformance-<version>.zip — the fixtures, the manifest, the defect catalogue, both schemas and the two documents that state what they hold, under one directory, with its digest in the release notes — attached to the release that named it.

A file has a format, the versioned identifier of the format it targets; a level, the conformance level of section 2 that requires every case in it; a section, the heading of the specification the file pins; and its cases. A file that pins the tree declares kind as tree and no level — CST.md is not one of the levels — and its section is a heading of that document.

A case is either written out or generated. A written-out case has:

Field Meaning
id Unique across the set: the file's group, a slash, and a slug.
description One sentence naming what the case pins, for the failure it would print.
section A more specific heading than the file's, where the case pins one.
message The message. Usually text; any other JSON value is a message a host wrote as something else (section 4), and the undefined tag below is a message the caller did not supply, which resolves to the empty string (section 4).
payload The payload (section 3). Entries hold values, wrappers (section 4.1) or tagged values.
props The caller's formatting properties, grouped by modifier name (section 11.2).
locale The locale.
messageId The message's id (section 4), spelled out because a case's own id names the case. Any JSON value: no step of resolution reads it, and reports name it (section 14.3).
modifiers Host-defined modifiers to register (section 11.3): a name to a behaviour from the catalogue below.
defaults The implementation-configured defaults, the bottom formatting layer of section 11.2, grouped by modifier name.
expected What the resolution must produce: an output, or for a locale-dependent one a format request; and the reports, in the order they are emitted, none where the field is omitted.

A generated case has an id, a description, optionally a section, and a generate naming one of the constructions under Generated cases below. The runner builds its inputs and its expectation from the limits the adapter declares.

Tagged values

A fixture is JSON, and the format takes inputs JSON cannot spell. Three tagged objects stand in for them, anywhere in a message, a payload, props, an id or the defaults. A runner decodes each into the host value it names before the adapter sees it; nothing else is decoded, so a payload entry of any other shape reaches the implementation as the plain data JSON describes.

A tag is read on a case's inputs and nowhere else. An expectation is compared as written, so a tag in one would be compared as the object it is spelled as, which no implementation answers with; the schema therefore holds the id of an expected report to text. A case whose id is not text leaves that id out of its expectation and the id goes unobserved.

Tag Stands for
{ "$curly": "undefined" } The host's undefined (sections 4.1, 9.2, 10). A message so tagged is one the caller did not supply.
{ "$curly": "unserializable" } A value that no conversion can describe (section 4). The JavaScript runner builds a plain object that references itself.
{ "$curly": "nodes", "count": N } A value whose serialization visits at least N nodes (section 13). The runner builds a tree of shared references — each level an array naming the level below twice — deep enough to visit N.

Host-defined modifiers

A case at the Extensions level may register modifiers. It cannot ship code, so it names behaviours from a catalogue every runner implements in its own language and hands to the adapter as functions of section 11's inputs: the value and the default as text, the options as the placeholder wrote them, the props composed under the modifier's own name, and the locale the caller supplied, as it supplied it (section 11.3). The adapter wraps each into its implementation's own modifier signature.

Behaviour Answers with
upper The value with its ASCII letters uppercased.
echo The value, unchanged.
empty The empty string — an answer, not the absence of one.
nothing The host's nothing: no answer, so the placeholder takes the fallback chain (section 11).
raise Nothing; it raises, and the failure must be contained (section 11.3).
default The default, read through the chain (section 10).
options The options in the order they were handed over, each key=value, joined by commas: a=A,b=,c=c (section 9.4).
props The props it received, each own property name=value with the value as JSON, sorted by name and joined by commas: maximumFractionDigits=1,useGrouping=true (sections 11.2, 11.3).
locale The locale it received, or none where it received none. An empty locale answers empty.
object A plain object holding the value under answer, so the answer serializes (section 11): {"answer":"X"}.

Locale-dependent expectations

The formatting modifiers of section 11.2 delegate to the host's internationalization facilities, whose output varies with the host's locale data. A case for one therefore states the formatting request rather than its result: which facility, the options it is constructed with, and the input the modifier hands it. The options are the properties the facility reads, as the layers of section 11.2 compose them and the modifier pins them — number's default maximum, currency's style, ago's numeric — and not the format's own ratio and format, which the input already reflects. The runner performs that request on the host it runs on and expects the implementation's output to match it, so the case pins what the specification pins — the request — and the locale data stays the host's.

api input The request
NumberFormat a number Intl.NumberFormat(locale, options).format(input)
DateTimeFormat milliseconds since the epoch Intl.DateTimeFormat(locale, options).format(input)
RelativeTimeFormat [value, unit] Intl.RelativeTimeFormat(locale, options).format(value, unit)

The locale is the case's own. The message of such a case is the placeholder alone, so that the whole output is the request's result. A date case names a timeZone in its props, because a request without one formats in the host's, which the fixture cannot know.

An implementation whose locale data is not the runner's cannot be held to the text, and section 11.2 lets it expose the request it made instead. An adapter that answers with formats is measured on that: such a case compares the facility, the properties and the input, and the locale data on each side stays its own. One that leaves formats undefined is measured on the text, which is what an implementation sharing the runner's host can be held to.

A property the host's facility cannot express is a different matter, and section 11.2 has such an implementation document what it cannot express. The adapter states it as unexpressible, and every case whose request reads one of those properties is left out — listed among the skipped, the way an unclaimed level's cases are.

Generated cases

Section 13 lets an implementation permit more than its minima and requires it to document what it permits, so a case at a limit cannot be written out: it is built from the limits the adapter declares, and exercises the implementation at the bounds it documents. L is the declared output limit, R the read limit, C the conversion limit, N the nesting limit, and every generated case reports under the id limits.

Each construction spends one budget and leaves the others room, so a case built at one limit cannot be failed by another: the output constructions write from an option value, which is the message's own text and costs no reading, and the read construction selects nothing, so the value it spends the read budget on never reaches the output.

generate Message and payload Expected
output-at-limit {{v; a:<x × L>;}}, with v holding a. x repeated L times, no reports.
output-over-limit A{{v; a:<x × (L + 1)>;}}B, with v holding a. AB — the placeholder resolved to the empty string, and the message's own text around it is the caller's and counts against nothing — and one output-limit report of origin limit.
output-over-limit-continues The same placeholder followed by {{w}}, with w holding tail. tail and one output-limit report: a result the output has no room for spends nothing, so what follows it still resolves.
read-at-limit {{v:eq; nomatch:X; default:ok;}}, with v holding x repeated R times. ok, no reports.
read-over-limit The same placeholder followed by {{w}}, with v one character longer and w holding tail. ok and one read-limit report of origin limit: the budget is tested before a placeholder reads, so the one that spent it past the limit still resolves and the one after it pays.
conversion-over-limit {{v; default:D}}, with v a nodes value of C + 1. D and one unserializable-value report of origin payload: a serialization that reaches the limit describes nothing, so the placeholder takes the chain.
nesting-at-limit N placeholders, each selecting the option that holds the next; the innermost is {{v; a:settled; default:fallback;}}. v holds a. settled, no reports.
nesting-over-limit The same nesting one level deeper. fallback and one nesting-limit report of origin message: the innermost placeholder is refused and takes its fallback chain, and every level around it selects the option that holds it.

An adapter whose reports carry a limit is held to the declared limit on the output-limit, read-limit and nesting-limit reports.

Tree cases

A file whose kind is tree pins the concrete syntax tree of CST.md: what an implementation that offers one answers for a message. Such a case carries no input beyond the message, because a tree is a function of the message alone.

Field Meaning
id, description, section As above, except that the section is a heading of CST.md.
message The message, as text.
expected The children of the root node, in order.
resolves What the same message resolves to over no payload, where the case pins that the tree and the resolution read the same placeholders (section 5, property 4). The message then names no modifier, so the expectation holds at the Core level.

A node of expected states what it is and what it spells, never where it is:

Field Meaning
type The kind, as section 6 of CST.md spells it.
text The text the node spans.
name For a name kind: the span unescaped.
cancels For an escape: which of the two readings of section 7 the sequence takes.
nodes The children, in order, wherever the case reads into the node.

The span is the one thing a case does not write. Section 4 of CST.md lets an implementation count its spans in whatever unit its strings are indexed by, so a case that wrote numbers would pin one implementation's unit on every other. A case writes the text instead, and the runner reads that text off the span the implementation answered with, in the unit the adapter declared. The declaration is thereby observable: an implementation that counts UTF-16 code units and states it counts code points fails the case whose message holds a character outside the basic multilingual plane.

The properties of section 5 are checked on every tree case before its own expectation is compared, because a tree that is wrong about the message is wrong whatever the case says of its nodes: the leaves tile the message in order and spell it back, no span boundary falls inside a code point, no node lies outside the one that holds it, and two parses of one message agree.

The adapter

Section 14.3 has the conformance set observe an implementation through an adapter the implementation supplies. It is three things:

import type { Adapter } from '@curly-message/conformance';

export const adapter: Adapter = {
  levels: ['core', 'intl', 'extensions'],
  limits: { output: 100000, read: 100000, conversion: 100000, nesting: 8 },
  resolve: ({ message, payload, props, locale, id, modifiers, defaults }) => {
    // Call the implementation and answer with what it produced.
    return { output, reports };
  },
};

levels is the statement section 2 requires: the levels the implementation satisfies. The runner selects the fixtures those levels require and skips the rest, and the skipped cases are listed, not hidden. limits is the statement section 13 requires, and is what the generated cases are built from. An adapter MAY make a third statement, unexpressible: the formatting properties the host's facility cannot express (section 11.2), under the request that reads them, which leaves out the cases that would measure the host rather than the implementation.

An adapter MAY offer a fourth thing, cst: the concrete syntax tree of CST.md, as the unit its spans are counted in — utf-8, utf-16 or code-point — and the parse that produces one. An adapter that leaves it out has every tree case left out with that reason rather than failed, because an implementation conforms without offering a tree. A cst whose unit is none of the three is an error rather than a skip, the way an unknown level is: a tree whose unit is unstated says nothing about where anything is.

resolve is handed one resolution's inputs, decoded into host values, and answers with the output and the reports the implementation produced. The output is compared exactly. A report is compared by its code; its origin, id and limit are compared where the adapter's reports carry them, since the specification prescribes no shape for a report, only what a code names and which origin it declares. Reports are compared in order, because section 14.3 has an implementation report in the pass where the condition was met and section 9 resolves a pass in source order. An adapter that leaves reports undefined says the implementation does not report at all — reporting is a SHOULD — and every expectation about reports is then skipped, and said to be: such a case passes on its output alone with an outcome of { ok: true, unobserved: 'reports' }, run lists it under unobserved beside passed, and the command counts those cases in its summary.

resolve MAY answer with formats as well, the formatting requests the resolution made, in the order it made them. A case that states a request is then compared on that request rather than on the output, as the section above describes; a case that states an output is compared on its output whether the adapter answers with formats or not.

Running the set

The set is a development dependency of the implementation it assesses:

npm install --save-dev @curly-message/conformance

From a test:

import { check, plan, run } from '@curly-message/conformance';

check(adapter);          // throws with the list of failures, if any

const result = run(adapter);   // { passed, failed, skipped, unobserved }

for (const planned of plan(adapter).cases) {
  test(planned.id, () => {
    const outcome = planned.execute();

    expect(outcome).toMatchObject({ ok: true });
  });
}

plan is for a test framework: one entry per case the adapter's levels require, each carrying its id, file, section, description, the level and the document where the case has them, and an execute that runs it and answers with an outcome — { ok: true }, or the reason it failed beside what was expected and what came back. run executes a plan and sorts the outcomes. Both take options: fixtures, to run a set other than the shipped one, and levels, to run a subset of the levels the adapter claims. An adapter must claim core, and levels must name only levels it claims; anything else is an error rather than a skip. A case at a level that does not run is skipped with a reason naming the level, and a tree case is skipped wherever the adapter offers no cst.

The package also exports what those are built from: fixtures() reads the shipped set and load(directory) any directory of fixture files, both sorted by file name, which is what the fixtures option takes; summarize(result) renders what the command prints; and decode and behaviours are the tagged values and the catalogue as this runner implements them, for an adapter's own tests to reuse.

From the command line, with a module that exports the adapter as adapter or as its default export:

npx curly-message-conformance ./adapter.mjs
npx curly-message-conformance ./adapter.mjs --levels core,intl
npx curly-message-conformance ./adapter.mjs --fixtures ./my-fixtures

The command prints one line per failure and a summary, and exits non-zero where anything failed. --fixtures points at a directory of fixture files, so a set under development runs against an implementation before it ships.

Auditing a runner

A runner is trusted with a verdict, so it needs one of its own: a runner that compares nothing passes every implementation, including the ones that are wrong, and reports the same summary either way. defects.json is the catalogue of ways an adapter can be wrong, each with what a correct runner answers where it is present, and schema/defect.schema.json describes the file.

import { audit } from '@curly-message/conformance';

const missed = audit(adapter).filter(({ outcome }) => outcome !== 'caught');

audit takes an adapter that passes the set, applies each defect to it in turn, and answers one entry per defect: what the catalogue expected, what this runner observed, and whether that counts as caught, missed or unreachable. The last is for a defect the adapter gives the runner nothing to catch — what an implementation that observes no reports does to every defect of its reports, one that claims Core alone to every defect of a level it does not claim, and one that offers no tree to every defect of the tree. A defect names the section it pins and, where that is a heading of CST.md rather than of the specification, the document it is a heading of. defects() reads the catalogue and mutations carries it, so a runner's own tests can reach one defect without running them all.

RUNNER.md states what each defect pins.

Writing a fixture

A case pins a sentence of the specification, and states which: the section is what a failure points at. Its expectation follows from the text, not from what an implementation happens to do, so a behaviour the specification leaves to the host — how its numeric conversion reads a literal, which text its date parsing accepts, what a host type converts to — is not a case. Where the specification lets two conforming implementations differ, the set does not choose between them.

The narrow reading of section 4 falls there as well. That a value of a type an application declared or derived converts as a string rather than as JSON is normative, but the string is the host's own, and a case that stated one would pin it on every other host. So the set pins the values that do serialize, and an implementation that reads section 4 too widely passes it.

The reference implementation is what the set is checked against before it lands, and the set is what the reference is checked against in turn. Where the two disagree, the specification decides which is wrong.

npm run manifest regenerates index.json from the files, and a version bump runs it; the tests fail where it is stale, where an id repeats, where a file does not validate against the schema, or where a section names no heading of the document its file reads against.

Development

npm install
npm test         # builds, typechecks, lints, then runs vitest
npm run lint:fix # applies what the lint step only reports

Requires Node.js 22 or newer.

License

MIT