@curly-message/conformance 1.0.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 G.A.W.Group, s.r.o.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,266 @@
1
+ # @curly-message/conformance
2
+
3
+ The conformance set of the [Curly Message Format](../SPEC.md): fixtures that
4
+ an implementation in any language is measured against, and a JavaScript runner
5
+ that drives one through the adapter of section 14.3.
6
+
7
+ A fixture is a resolution written out — the inputs section 4 lists, and the
8
+ output and the reports they must produce. The set is derived from the
9
+ specification, and each case names the section it pins, so a failure points at
10
+ the sentence the implementation disagrees with.
11
+
12
+ ```json
13
+ {
14
+ "id": "fallback/payload-outranks-inline",
15
+ "section": "10",
16
+ "description": "The payload's own default takes precedence over the inline one.",
17
+ "message": "Hello, {{name; default:Guest;}}!",
18
+ "payload": { "default": "Friend" },
19
+ "expected": { "output": "Hello, Friend!" }
20
+ }
21
+ ```
22
+
23
+ ## Status
24
+
25
+ **Unpublished.** The set lives in the specification's repository because its
26
+ fixtures are artifacts of the format, not of any one implementation, and it is
27
+ versioned against the specification: version 1 of the set targets version 1 of
28
+ the format. Until it is published, an implementation is assessed against the
29
+ document alone (section 2).
30
+
31
+ ## The fixture files
32
+
33
+ `fixtures/*.json` groups the cases by the section of the specification they
34
+ pin, one file per group, and `index.json` lists the files with the level and
35
+ section each covers. `schema/fixture.schema.json` is the JSON Schema every file
36
+ validates against, so a runner in another language checks the set before it
37
+ reads it.
38
+
39
+ A file has a `format`, the versioned identifier of the format it targets; a
40
+ `level`, the conformance level of section 2 that requires every case in it;
41
+ a `section`, the heading of the specification the file pins; and its `cases`.
42
+
43
+ A case is either written out or generated. A written-out case has:
44
+
45
+ | Field | Meaning |
46
+ | --- | --- |
47
+ | `id` | Unique across the set: the file's group, a slash, and a slug. |
48
+ | `description` | One sentence naming what the case pins, for the failure it would print. |
49
+ | `section` | A more specific heading than the file's, where the case pins one. |
50
+ | `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 (section 10). |
51
+ | `payload` | The payload (section 3). Entries hold values, wrappers (section 4.1) or tagged values. |
52
+ | `props` | The caller's formatting properties, grouped by modifier name (section 11.2). |
53
+ | `locale` | The locale. |
54
+ | `key` | The message's key (section 4). Any JSON value: a key a host wrote as something other than text is echoed as the text it converts to (section 10). |
55
+ | `modifiers` | Host-defined modifiers to register (section 11.3): a name to a behaviour from the catalogue below. |
56
+ | `defaults` | The implementation-configured defaults, the bottom formatting layer of section 11.2, grouped by modifier name. |
57
+ | `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. |
58
+
59
+ A generated case has an `id`, a `description`, optionally a `section`, and a
60
+ `generate` naming one of the constructions under *Generated cases* below. The
61
+ runner builds its inputs and its expectation from the limits the adapter
62
+ declares.
63
+
64
+ ### Tagged values
65
+
66
+ A fixture is JSON, and the format takes inputs JSON cannot spell. Three tagged
67
+ objects stand in for them, anywhere in a message, a payload, props, a key or
68
+ the defaults. A runner decodes each into the host value it names before the
69
+ adapter sees it; nothing else is decoded, so a payload entry of any other shape
70
+ reaches the implementation as the plain data JSON describes.
71
+
72
+ | Tag | Stands for |
73
+ | --- | --- |
74
+ | `{ "$curly": "undefined" }` | The host's undefined (sections 4.1, 9.2, 10). A message so tagged is one the caller did not supply. |
75
+ | `{ "$curly": "unserializable" }` | A value that no conversion can describe (section 4). The JavaScript runner builds a plain object that references itself. |
76
+ | `{ "$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`. |
77
+
78
+ ### Host-defined modifiers
79
+
80
+ A case at the Extensions level may register modifiers. It cannot ship code, so
81
+ it names behaviours from a catalogue every runner implements in its own
82
+ language and hands to the adapter as functions of section 11's inputs: the
83
+ value and the default as text, the options as the placeholder wrote them, the
84
+ props composed under the modifier's own name, and the locale where one is
85
+ available. The adapter wraps each into its implementation's own modifier
86
+ signature.
87
+
88
+ | Behaviour | Answers with |
89
+ | --- | --- |
90
+ | `upper` | The value with its ASCII letters uppercased. |
91
+ | `echo` | The value, unchanged. |
92
+ | `empty` | The empty string — an answer, not the absence of one. |
93
+ | `nothing` | The host's nothing: no answer, so the placeholder takes the fallback chain (section 11). |
94
+ | `raise` | Nothing; it raises, and the failure must be contained (section 11.3). |
95
+ | `default` | The default, read through the chain (section 10). |
96
+ | `options` | The options in the order they were handed over, each `key=value`, joined by commas: `a=A,b=,c=c` (section 9.4). |
97
+ | `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). |
98
+ | `locale` | The locale it received, or `none` where it received none. |
99
+ | `object` | A plain object holding the value under `answer`, so the answer serializes (section 11): `{"answer":"X"}`. |
100
+
101
+ ### Locale-dependent expectations
102
+
103
+ The formatting modifiers of section 11.2 delegate to the host's
104
+ internationalization facilities, whose output varies with the host's locale
105
+ data. A case for one therefore states the formatting request rather than its
106
+ result: which facility, the options it is constructed with, and the input the
107
+ modifier hands it. The options are the properties the facility reads, as the
108
+ layers of section 11.2 compose them and the modifier pins them — `number`'s
109
+ default maximum, `currency`'s style, `ago`'s `numeric` — and not the format's
110
+ own `ratio` and `format`, which the input already reflects. The runner performs
111
+ that request on the host it runs on and expects the implementation's output to
112
+ match it, so the case pins what the specification pins — the request — and the
113
+ locale data stays the host's.
114
+
115
+ | `api` | `input` | The request |
116
+ | --- | --- | --- |
117
+ | `NumberFormat` | a number | `Intl.NumberFormat(locale, options).format(input)` |
118
+ | `DateTimeFormat` | milliseconds since the epoch | `Intl.DateTimeFormat(locale, options).format(input)` |
119
+ | `RelativeTimeFormat` | `[value, unit]` | `Intl.RelativeTimeFormat(locale, options).format(value, unit)` |
120
+
121
+ The locale is the case's own. The message of such a case is the placeholder
122
+ alone, so that the whole output is the request's result. A date case names a
123
+ `timeZone` in its props, because a request without one formats in the host's,
124
+ which the fixture cannot know.
125
+
126
+ ### Generated cases
127
+
128
+ Section 13 lets an implementation permit more than its minima and requires it
129
+ to document what it permits, so a case at a limit cannot be written out: it is
130
+ built from the limits the adapter declares, and exercises the implementation at
131
+ the bounds it documents. `P` is the declared pass limit, `L` the output limit,
132
+ `C` the conversion limit, and every generated case reports through the key
133
+ `limits`.
134
+
135
+ | `generate` | Message and payload | Expected |
136
+ | --- | --- | --- |
137
+ | `passes-at-limit` | `{{p1}}`, with `p1` … `p<P-1>` each holding the placeholder of the next, and `p<P>` holding `settled`. Settling takes exactly `P` passes. | `settled`, no reports. |
138
+ | `passes-over-limit` | The same chain one link longer: `p<P>` holds `{{p<P+1>}}` and `p<P+1>` holds `settled`. | `{{p<P+1>}}` — the last settled text, its placeholder unresolved — and one `pass-limit` report of origin `limit`. |
139
+ | `output-at-limit` | `{{v}}`, with `v` holding `x` repeated `L` times. | That text, no reports. |
140
+ | `output-over-limit` | `{{v}}`, with `v` holding `x` repeated `L + 1` times. | `{{v}}` — the pass was discarded whole, so the message as it reached the first pass is what settled — and one `output-limit` report of origin `limit`. |
141
+ | `output-over-limit-stops` | `{{v}}{{w:raise}}`, with `v` as above, `w` holding `w`, and `raise` registered under that name. | `{{v}}{{w:raise}}` and one `output-limit` report: a placeholder past the limit is neither resolved nor reported, and the modifier it names is not called. This case is at the Extensions level. |
142
+ | `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. |
143
+
144
+ An adapter whose reports carry a `limit` is held to the declared limit on the
145
+ `pass-limit` and `output-limit` reports.
146
+
147
+ ## The adapter
148
+
149
+ Section 14.3 has the conformance set observe an implementation through an
150
+ adapter the implementation supplies. It is three things:
151
+
152
+ ```ts
153
+ import type { Adapter } from '@curly-message/conformance';
154
+
155
+ export const adapter: Adapter = {
156
+ levels: ['core', 'intl', 'extensions'],
157
+ limits: { passes: 10, output: 100000, conversion: 100000 },
158
+ resolve: ({ message, payload, props, locale, key, modifiers, defaults }) => {
159
+ // Call the implementation and answer with what it produced.
160
+ return { output, reports };
161
+ },
162
+ };
163
+ ```
164
+
165
+ `levels` is the statement section 2 requires: the levels the implementation
166
+ satisfies. The runner selects the fixtures those levels require and skips the
167
+ rest, and the skipped cases are listed, not hidden. `limits` is the statement
168
+ section 13 requires, and is what the generated cases are built from.
169
+
170
+ `resolve` is handed one resolution's inputs, decoded into host values, and
171
+ answers with the `output` and the `reports` the implementation produced. The
172
+ output is compared exactly. A report is compared by its `code`; its `origin`,
173
+ `key` and `limit` are compared where the adapter's reports carry them, since
174
+ the specification prescribes no shape for a report, only what a code names and
175
+ which origin it declares. Reports are compared in order, because section 14.3
176
+ has an implementation report in the pass where the condition was met and
177
+ section 9 resolves a pass in source order. An adapter that leaves `reports`
178
+ undefined says the implementation does not report at all — reporting is a
179
+ SHOULD — and every expectation about reports is then skipped, and said to be:
180
+ such a case passes on its output alone with an outcome of
181
+ `{ ok: true, unobserved: 'reports' }`, `run` lists it under `unobserved`
182
+ beside `passed`, and the command counts those cases in its summary.
183
+
184
+ ## Running the set
185
+
186
+ From a test:
187
+
188
+ ```ts
189
+ import { check, plan, run } from '@curly-message/conformance';
190
+
191
+ check(adapter); // throws with the list of failures, if any
192
+
193
+ const result = run(adapter); // { passed, failed, skipped, unobserved }
194
+
195
+ for (const planned of plan(adapter).cases) {
196
+ test(planned.id, () => {
197
+ const outcome = planned.execute();
198
+
199
+ expect(outcome).toMatchObject({ ok: true });
200
+ });
201
+ }
202
+ ```
203
+
204
+ `plan` is for a test framework: one entry per case the adapter's levels
205
+ require, each carrying its `id`, `file`, `level`, `section`, `description` and
206
+ an `execute` that runs it and answers with an outcome — `{ ok: true }`, or the
207
+ reason it failed beside what was expected and what came back. `run` executes a
208
+ plan and sorts the outcomes. Both take options: `fixtures`, to run a set other
209
+ than the shipped one, and `levels`, to run a subset of the levels the adapter
210
+ claims. An adapter must claim `core`, and `levels` must name only levels it
211
+ claims; anything else is an error rather than a skip. A case at a level that
212
+ does not run is skipped with a reason naming the level, and so is
213
+ `output-over-limit-stops` wherever `extensions` does not run, whatever the
214
+ level of its file.
215
+
216
+ The package also exports what those are built from: `fixtures()` reads the
217
+ shipped set and `load(directory)` any directory of fixture files, both sorted
218
+ by file name, which is what the `fixtures` option takes; `summarize(result)`
219
+ renders what the command prints; and `decode` and `behaviours` are the tagged
220
+ values and the catalogue as this runner implements them, for an adapter's own
221
+ tests to reuse.
222
+
223
+ From the command line, with a module that exports the adapter as `adapter` or
224
+ as its default export:
225
+
226
+ ```bash
227
+ npx curly-message-conformance ./adapter.mjs
228
+ npx curly-message-conformance ./adapter.mjs --levels core,intl
229
+ npx curly-message-conformance ./adapter.mjs --fixtures ./my-fixtures
230
+ ```
231
+
232
+ The command prints one line per failure and a summary, and exits non-zero where
233
+ anything failed. `--fixtures` points at a directory of fixture files, so a set
234
+ under development runs against an implementation before it ships.
235
+
236
+ ## Writing a fixture
237
+
238
+ A case pins a sentence of the specification, and states which: the `section`
239
+ is what a failure points at. Its expectation follows from the text, not from
240
+ what an implementation happens to do, so a behaviour the specification leaves
241
+ to the host — how its numeric conversion reads a literal, which text its date
242
+ parsing accepts, what a host type converts to — is not a case. Where the
243
+ specification lets two conforming implementations differ, the set does not
244
+ choose between them.
245
+
246
+ The reference implementation is what the set is checked against before it
247
+ lands, and the set is what the reference is checked against in turn. Where
248
+ the two disagree, the specification decides which is wrong.
249
+
250
+ `npm run manifest` regenerates `index.json` from the files; the tests fail
251
+ where it is stale, where an `id` repeats, where a file does not validate
252
+ against the schema, or where a `section` names no heading of `SPEC.md`.
253
+
254
+ ## Development
255
+
256
+ ```bash
257
+ npm install
258
+ npm test # builds, typechecks, lints, then runs vitest
259
+ npm run lint:fix # applies what the lint step only reports
260
+ ```
261
+
262
+ Requires Node.js 22 or newer.
263
+
264
+ ## License
265
+
266
+ [MIT](./LICENSE)
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { parseArgs } from 'node:util';
5
+ import { load, run, summarize } from '../dist/index.js';
6
+
7
+ const USAGE = 'Usage: curly-message-conformance <adapter module> [--levels core,intl,extensions] [--fixtures <directory>]';
8
+
9
+ const main = async () => {
10
+ const { positionals: [path, ...rest], values } = parseArgs({
11
+ allowPositionals: true,
12
+ options: { levels: { type: 'string' }, fixtures: { type: 'string' } },
13
+ });
14
+
15
+ if (!path || rest.length) throw new Error(USAGE);
16
+
17
+ const module = await import(pathToFileURL(resolve(path)).href);
18
+ const adapter = module.adapter ?? module.default;
19
+
20
+ if (!adapter) throw new Error(`${path} exports no adapter: export it as "adapter" or as the default export.`);
21
+
22
+ const result = run(adapter, {
23
+ ...(values.levels === undefined ? {} : { levels: values.levels.split(',') }),
24
+ ...(values.fixtures === undefined ? {} : { fixtures: load(resolve(values.fixtures)) }),
25
+ });
26
+
27
+ console.log(summarize(result));
28
+
29
+ return result.failed.length ? 1 : 0;
30
+ };
31
+
32
+ try {
33
+ process.exitCode = await main();
34
+ } catch (error) {
35
+ console.error(error instanceof Error ? error.message : error);
36
+ process.exitCode = 2;
37
+ }
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ // Regenerates index.json from the fixture files. An output path may be given
3
+ // to write the manifest elsewhere, which is how the tests compare the shipped
4
+ // one with a fresh one.
5
+ import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const root = fileURLToPath(new URL('..', import.meta.url));
10
+ const directory = join(root, 'fixtures');
11
+
12
+ const read = (path) => JSON.parse(readFileSync(path, 'utf8'));
13
+
14
+ const { version } = read(join(root, 'package.json'));
15
+
16
+ const files = readdirSync(directory).filter((name) => name.endsWith('.json')).sort().map((name) => {
17
+ const { level, section, cases } = read(join(directory, name));
18
+
19
+ return { path: `fixtures/${name}`, level, section, cases: cases.length };
20
+ });
21
+
22
+ const manifest = { format: 'curly-message-1', version, files };
23
+
24
+ writeFileSync(process.argv[2] ?? join(root, 'index.json'), `${JSON.stringify(manifest, null, 2)}\n`);
@@ -0,0 +1,225 @@
1
+ /**
2
+ * The contract between the conformance set and an implementation under test.
3
+ * Everything here is spelled in the vocabulary of the specification — the
4
+ * inputs of section 4, the levels of section 2, the limits of section 13, the
5
+ * report codes of section 14.2 — and nothing in it names a host or an
6
+ * implementation's own API. An implementation meets the set through an
7
+ * `Adapter` (section 14.3), which is where its own calling convention is
8
+ * translated into this one.
9
+ */
10
+ /** A conformance level of section 2. */
11
+ type Level = 'core' | 'intl' | 'extensions';
12
+ /**
13
+ * The limits of section 13 an implementation permits, as it documents them:
14
+ * the passes it performs, the output it holds in the host's own string unit,
15
+ * and the nodes one conversion may visit. Section 13 states the minima.
16
+ */
17
+ type Limits = {
18
+ passes: number;
19
+ output: number;
20
+ conversion: number;
21
+ };
22
+ /**
23
+ * What a host-defined modifier receives (sections 11, 11.3), as the
24
+ * specification lists it: the value and the default as text, the options as
25
+ * the placeholder wrote them, the props composed under the modifier's own
26
+ * name, and the locale where one is available. The default is read through a
27
+ * call, because section 10 walks the chain only where a modifier asks for it.
28
+ */
29
+ type ModifierInput = {
30
+ value: string;
31
+ options: {
32
+ key: string;
33
+ value: string;
34
+ }[];
35
+ props: Record<string, unknown>;
36
+ locale?: string;
37
+ default: () => string;
38
+ };
39
+ /** A behaviour of the catalogue, as a function an adapter wraps into its own modifier signature. */
40
+ type ModifierBehaviour = (input: ModifierInput) => unknown;
41
+ /** The names of the catalogue's behaviours; README.md states each. */
42
+ type Behaviour = 'upper' | 'echo' | 'empty' | 'nothing' | 'raise' | 'default' | 'options' | 'props' | 'locale' | 'object';
43
+ /**
44
+ * One resolution's inputs, decoded into host values: the four inputs of
45
+ * section 4 and the key, plus the two pieces of configuration a case may ask
46
+ * for — host-defined modifiers to register (section 11.3) and the
47
+ * implementation-configured defaults of section 11.2.
48
+ */
49
+ type Resolution = {
50
+ message: unknown;
51
+ payload?: unknown;
52
+ props?: unknown;
53
+ locale?: string;
54
+ key?: unknown;
55
+ modifiers?: Record<string, ModifierBehaviour>;
56
+ defaults?: unknown;
57
+ };
58
+ type ReportCode = 'unknown-modifier' | 'failed-modifier' | 'missing-options' | 'unserializable-value' | 'missing-locale' | 'pass-limit' | 'output-limit';
59
+ type ReportOrigin = 'message' | 'payload' | 'limit';
60
+ /**
61
+ * A report as the adapter observed it. Only the code is required; a field the
62
+ * adapter supplies is held to what the case expects of it, and one it leaves
63
+ * out is not checked, because section 14.3 prescribes no channel and no shape.
64
+ */
65
+ type Report = {
66
+ code: ReportCode;
67
+ origin?: ReportOrigin;
68
+ key?: unknown;
69
+ limit?: number;
70
+ };
71
+ /**
72
+ * What a resolution produced. `reports` left undefined says the adapter does
73
+ * not observe reports at all, and every expectation about them is skipped.
74
+ */
75
+ type Resolved = {
76
+ output: string;
77
+ reports?: Report[];
78
+ };
79
+ /**
80
+ * The adapter of section 14.3: what an implementation supplies so the set can
81
+ * drive it. `levels` selects the fixtures the set runs, and `limits` derives
82
+ * the cases that sit at a boundary, so both are the implementation's own
83
+ * statements about itself made observable.
84
+ */
85
+ type Adapter = {
86
+ levels: readonly Level[];
87
+ limits: Limits;
88
+ resolve: (input: Resolution) => Resolved;
89
+ };
90
+ /** A section reference: a heading number of SPEC.md, such as `9.2` or `A.4`. */
91
+ type Section = string;
92
+ /** A formatting request whose result on the running host is the expected output. */
93
+ type FormatRequest = {
94
+ api: 'NumberFormat' | 'DateTimeFormat' | 'RelativeTimeFormat';
95
+ options?: Record<string, unknown>;
96
+ input: unknown;
97
+ };
98
+ type ExpectedReport = {
99
+ code: ReportCode;
100
+ origin: ReportOrigin;
101
+ key?: unknown;
102
+ };
103
+ type Expected = {
104
+ output?: string;
105
+ format?: FormatRequest;
106
+ reports?: ExpectedReport[];
107
+ };
108
+ /** A case written out in a fixture file, its inputs still JSON: tagged values not yet decoded. */
109
+ type ConcreteCase = {
110
+ id: string;
111
+ description: string;
112
+ section?: Section;
113
+ message: unknown;
114
+ payload?: Record<string, unknown>;
115
+ props?: Record<string, unknown>;
116
+ locale?: string;
117
+ key?: unknown;
118
+ modifiers?: Record<string, Behaviour>;
119
+ defaults?: Record<string, unknown>;
120
+ expected: Expected;
121
+ };
122
+ type Generator = 'passes-at-limit' | 'passes-over-limit' | 'output-at-limit' | 'output-over-limit' | 'output-over-limit-stops' | 'conversion-over-limit';
123
+ /** A case the runner builds from the adapter's limits. */
124
+ type GeneratedCase = {
125
+ id: string;
126
+ description: string;
127
+ section?: Section;
128
+ generate: Generator;
129
+ };
130
+ type Case = ConcreteCase | GeneratedCase;
131
+ type FixtureFile = {
132
+ format: 'curly-message-1';
133
+ level: Level;
134
+ section: Section;
135
+ cases: Case[];
136
+ };
137
+ /** A fixture file together with the name it is shipped under. */
138
+ type Fixture = {
139
+ name: string;
140
+ file: FixtureFile;
141
+ };
142
+ /** The manifest shipped as `index.json`. */
143
+ type Manifest = {
144
+ format: 'curly-message-1';
145
+ version: string;
146
+ files: {
147
+ path: string;
148
+ level: Level;
149
+ section: Section;
150
+ cases: number;
151
+ }[];
152
+ };
153
+ type Failure = {
154
+ ok: false;
155
+ reason: string;
156
+ expected: unknown;
157
+ actual: unknown;
158
+ };
159
+ /**
160
+ * What running a case answered. A passing outcome carries `unobserved` where
161
+ * the adapter left `reports` undefined: the case passed on its output alone,
162
+ * and its report expectation was not checked.
163
+ */
164
+ type Outcome = {
165
+ ok: true;
166
+ unobserved?: 'reports';
167
+ } | Failure;
168
+ /** A case ready to run: what identifies it, and the call that runs it. */
169
+ type Planned = {
170
+ id: string;
171
+ file: string;
172
+ level: Level;
173
+ section: Section;
174
+ description: string;
175
+ execute: () => Outcome;
176
+ };
177
+ /** A case the plan left out, and why. */
178
+ type Skipped = {
179
+ id: string;
180
+ file: string;
181
+ level: Level;
182
+ section: Section;
183
+ description: string;
184
+ reason: string;
185
+ };
186
+ type Plan = {
187
+ cases: Planned[];
188
+ skipped: Skipped[];
189
+ };
190
+ type Options = {
191
+ /** The fixtures to run; the shipped set when omitted. */
192
+ fixtures?: Fixture[];
193
+ /** Run only these levels, among those the adapter claims. */
194
+ levels?: readonly Level[];
195
+ };
196
+ type Result = {
197
+ passed: Planned[];
198
+ failed: (Planned & {
199
+ outcome: Failure;
200
+ })[];
201
+ skipped: Skipped[];
202
+ /** The cases among `passed` whose report expectation went unchecked, because the adapter observes no reports. */
203
+ unobserved: Planned[];
204
+ };
205
+
206
+ /** The catalogue of README.md, each behaviour a function of section 11's inputs. */
207
+ declare const behaviours: Record<Behaviour, ModifierBehaviour>;
208
+
209
+ /** The host value a fixture's JSON stands for: tagged objects replaced, everything else as JSON describes it. */
210
+ declare const decode: (value: unknown) => unknown;
211
+
212
+ /** The fixture files of a directory, sorted by name. */
213
+ declare const load: (directory: string) => Fixture[];
214
+ /** The set shipped with this package. */
215
+ declare const fixtures: () => Fixture[];
216
+ /** One entry per case the adapter's levels require, each ready to run, beside the cases left out and why. */
217
+ declare const plan: (adapter: Adapter, options?: Options) => Plan;
218
+ /** Executes a plan and sorts the outcomes. */
219
+ declare const run: (adapter: Adapter, options?: Options) => Result;
220
+ /** The text the command prints: one line per failure and per skipped case, then the counts. */
221
+ declare const summarize: (result: Result) => string;
222
+ /** Runs the set and throws where anything failed, listing every failure. */
223
+ declare const check: (adapter: Adapter, options?: Options) => void;
224
+
225
+ export { type Adapter, type Behaviour, type Case, type ConcreteCase, type Expected, type ExpectedReport, type Failure, type Fixture, type FixtureFile, type FormatRequest, type GeneratedCase, type Generator, type Level, type Limits, type Manifest, type ModifierBehaviour, type ModifierInput, type Options, type Outcome, type Plan, type Planned, type Report, type ReportCode, type ReportOrigin, type Resolution, type Resolved, type Result, type Section, type Skipped, behaviours, check, decode, fixtures, load, plan, run, summarize };
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ import{readdirSync as M,readFileSync as z}from"fs";import{join as D}from"path";import{fileURLToPath as U}from"url";var d={upper:({value:e})=>e.replace(/[a-z]/g,t=>t.toUpperCase()),echo:({value:e})=>e,empty:()=>"",nothing:()=>{},raise:()=>{throw new Error("The raise behaviour raised.")},default:({default:e})=>e(),options:({options:e})=>e.map(({key:t,value:r})=>`${t}=${r}`).join(","),props:({props:e})=>Object.keys(e).sort().map(t=>`${t}=${JSON.stringify(e[t])}`).join(","),locale:({locale:e})=>e??"none",object:({value:e})=>({answer:e})};var c="$curly",f=e=>{let t=[];for(let r=1;r<e;r=r*2+1)t=[t,t];return t},R=()=>{let e={};return e.self=e,e},j=e=>Object.hasOwn(e,c),F=e=>{switch(e[c]){case"undefined":return;case"unserializable":return R();case"nodes":if(typeof e.count!="number")throw new Error("A nodes tag needs a numeric count.");return f(e.count);default:throw new Error(`Unknown tag ${JSON.stringify(e[c])}.`)}},u=e=>Array.isArray(e)?e.map(u):e===null||typeof e!="object"?e:j(e)?F(e):Object.fromEntries(Object.entries(e).map(([t,r])=>[t,u(r)]));var p=(e,t,r)=>({ok:!1,reason:e,expected:t,actual:r}),h=e=>e!==null&&typeof e=="object",y=(e,t)=>{if(e===t)return!0;if(Array.isArray(e)&&Array.isArray(t))return e.length===t.length&&e.every((n,s)=>y(n,t[s]));if(!h(e)||!h(t)||Array.isArray(e)||Array.isArray(t))return!1;let r=Object.keys(e);return r.length===Object.keys(t).length&&r.every(n=>Object.hasOwn(t,n)&&y(e[n],t[n]))},S=({api:e,options:t,input:r},n)=>{switch(e){case"NumberFormat":return new Intl.NumberFormat(n,t).format(r);case"DateTimeFormat":return new Intl.DateTimeFormat(n,t).format(r);case"RelativeTimeFormat":{let[s,o]=r;return new Intl.RelativeTimeFormat(n,t).format(s,o)}}},C=e=>e&&Object.fromEntries(Object.entries(e).map(([t,r])=>[t,d[r]])),L=e=>{if(e.expected.format)return S(e.expected.format,e.locale);if(e.expected.output!==void 0)return e.expected.output;throw new Error(`The case ${e.id} expects neither an output nor a format.`)},P=e=>({input:{message:u(e.message),payload:u(e.payload),props:u(e.props),locale:e.locale,key:u(e.key),defaults:u(e.defaults),modifiers:C(e.modifiers)},expected:{output:L(e),reports:e.expected.reports??[]}}),l="limits",v=e=>Object.fromEntries(Array.from({length:e},(t,r)=>[`p${r+1}`,r+1<e?`{{p${r+2}}}`:"settled"])),m=(e,t)=>({code:e,origin:"limit",key:l,limit:t}),g={"passes-at-limit":({passes:e})=>({input:{message:"{{p1}}",payload:v(e),key:l},expected:{output:"settled",reports:[]}}),"passes-over-limit":({passes:e})=>({input:{message:"{{p1}}",payload:v(e+1),key:l},expected:{output:`{{p${e+1}}}`,reports:[m("pass-limit",e)]}}),"output-at-limit":({output:e})=>({input:{message:"{{v}}",payload:{v:"x".repeat(e)},key:l},expected:{output:"x".repeat(e),reports:[]}}),"output-over-limit":({output:e})=>({input:{message:"{{v}}",payload:{v:"x".repeat(e+1)},key:l},expected:{output:"{{v}}",reports:[m("output-limit",e)]}}),"output-over-limit-stops":({output:e})=>{let t=!1,r=n=>(t=!0,d.raise(n));return{input:{message:"{{v}}{{w:raise}}",payload:{v:"x".repeat(e+1),w:"w"},key:l,modifiers:{raise:r}},expected:{output:"{{v}}{{w:raise}}",reports:[m("output-limit",e)]},verify:()=>t?p("The modifier past the output limit was called.","not called","called"):void 0}},"conversion-over-limit":({conversion:e})=>({input:{message:"{{v; default:D}}",payload:{v:f(e+1)},key:l},expected:{output:"D",reports:[{code:"unserializable-value",origin:"payload",key:l}]}})},N=["origin","key","limit"],w=e=>`${e.length} ${e.length===1?"report":"reports"}`,I=(e,t)=>{if(e.length!==t.length)return p(`Expected ${w(e)}, got ${w(t)}.`,e,t);for(let[r,n]of t.entries()){let s=e[r],o=`report ${r+1}`;if(n.code!==s.code)return p(`The code of ${o} differs.`,s,n);let i=N.find(a=>n[a]!==void 0&&s[a]!==void 0&&!y(n[a],s[a]));if(i)return p(`The ${i} of ${o} differs.`,s,n)}},G=e=>{if(e instanceof Error)return e.message;try{return JSON.stringify(e)??String(e)}catch{return String(e)}},J=(e,t,r)=>{let n;try{n=e.resolve(t)}catch(s){return p("The adapter raised.",r,G(s))}return h(n)?typeof n.then=="function"?p("The adapter answered asynchronously; resolve must answer at once.",r,"a promise"):n:p("The adapter answered with no resolution.",r,n)},x=(e,{input:t,expected:r,verify:n})=>{let s=J(e,t,r.output);if("ok"in s)return s;if(s.output!==r.output)return p("The output differs.",r.output,s.output);let o=n?.();return o||(s.reports===void 0?{ok:!0,unobserved:"reports"}:Array.isArray(s.reports)?I(r.reports,s.reports)??{ok:!0}:p("The reports are not a list.",r.reports,s.reports))},k=(e,t)=>x(e,P(t)),$=(e,t)=>{if(!Object.hasOwn(g,t.generate))throw new Error(`The case ${t.id} names no construction: ${JSON.stringify(t.generate)}.`);return x(e,g[t.generate](e.limits))};var B=e=>M(e).filter(t=>t.endsWith(".json")).sort().map(t=>({name:t,file:JSON.parse(z(D(e,t),"utf8"))})),q=()=>B(U(new URL("../fixtures/",import.meta.url))),K="output-over-limit-stops",W=["core","intl","extensions"],H=["passes","output","conversion"],V=(e,t)=>{let r=(o,i)=>{let a=o.find(E=>!W.includes(E));if(a!==void 0)throw new Error(`${i} names the level ${JSON.stringify(a)}; the levels are core, intl and extensions.`)};if(!Array.isArray(e.levels))throw new Error("The adapter must list the levels it claims.");if(r(e.levels,"The adapter"),!e.levels.includes("core"))throw new Error("The adapter must claim the core level.");if(t.levels){r(t.levels,"The levels option");let o=t.levels.find(i=>!e.levels.includes(i));if(o!==void 0)throw new Error(`The adapter does not claim the ${o} level.`)}let n=e.limits,s=H.find(o=>!Number.isInteger(n?.[o])||n?.[o]<1);if(s)throw new Error(`The adapter must declare its ${s} limit as a positive integer.`)},b=(e,t,r)=>{if(!t.levels.includes(e))return`The adapter does not claim the ${e} level.`;if(r.levels&&!r.levels.includes(e))return`The ${e} level is not among the levels being run.`},Y=(e,t,r,n)=>{let s=b(t,r,n);if(s)return s;if("generate"in e&&e.generate===K&&b("extensions",r,n))return"The case registers a host-defined modifier, which needs the extensions level."},_=(e,t={})=>{V(e,t);let r=(t.fixtures??q()).flatMap(({name:o,file:i})=>i.cases.map(a=>({c:a,identity:{id:a.id,file:o,level:i.level,section:a.section??i.section,description:a.description},skip:Y(a,i.level,e,t)}))),n=r.filter(({skip:o})=>!o).map(({c:o,identity:i})=>({...i,execute:()=>"generate"in o?$(e,o):k(e,o)})),s=r.flatMap(({identity:o,skip:i})=>i?[{...o,reason:i}]:[]);return{cases:n,skipped:s}},Q=(e,t={})=>{let{cases:r,skipped:n}=_(e,t),s=r.map(o=>[o,o.execute()]);return{passed:s.filter(([,o])=>o.ok).map(([o])=>o),failed:s.flatMap(([o,i])=>i.ok?[]:[{...o,outcome:i}]),skipped:n,unobserved:s.filter(([,o])=>o.ok&&o.unobserved).map(([o])=>o)}},T=200,O=e=>{let t;try{t=JSON.stringify(e)??String(e)}catch{t=String(e)}return t.length>T?`${t.slice(0,T)}...`:t},A=({id:e,section:t,description:r,outcome:n})=>`FAIL ${e} (section ${t}): ${n.reason} Expected ${O(n.expected)}, actual ${O(n.actual)}. ${r}`,X=({id:e,section:t,reason:r})=>`SKIP ${e} (section ${t}): ${r}`,ue=e=>{let t=`${e.passed.length} passed, ${e.failed.length} failed, ${e.skipped.length} skipped`,r=e.unobserved.length?`, ${e.unobserved.length} passed with reports unobserved`:"";return[...e.failed.map(A),...e.skipped.map(X),`${t}${r}`].join(`
2
+ `)},pe=(e,t={})=>{let r=Q(e,t);if(r.failed.length)throw new Error(`${r.failed.length} of ${r.passed.length+r.failed.length} conformance cases failed:
3
+ ${r.failed.map(A).join(`
4
+ `)}`)};export{d as behaviours,pe as check,u as decode,q as fixtures,B as load,_ as plan,Q as run,ue as summarize};