@particle-academy/fancy-conformance 0.0.0 → 0.2.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 Particle Academy
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 CHANGED
@@ -1,10 +1,165 @@
1
- # Placeholder
1
+ # fancy-conformance
2
2
 
3
- This `0.0.0` exists only to claim the name so a **Trusted Publisher** can be
4
- configured — npm has no way to configure OIDC for a package that does not exist
5
- yet, and staged publishing explicitly cannot create a new name either.
3
+ Shared cross-language conformance fixtures for the [Fancy](https://ui.particle.academy) suite.
6
4
 
7
- Do not install this version. The real releases are published from GitHub Actions
8
- with provenance, starting at `0.1.0`.
5
+ One contract, N implementations, and a single fixture table that **every**
6
+ implementation asserts in **its own CI, on every push** — so "parity" is a test
7
+ result rather than a claim.
9
8
 
10
- https://github.com/Particle-Academy/fancy-conformance
9
+ ```bash
10
+ npm install --save-dev @particle-academy/fancy-conformance
11
+ composer require --dev particle-academy/fancy-conformance
12
+ ```
13
+
14
+ For a language with no package yet, every release attaches a plain
15
+ `fancy-conformance-<version>.tar.gz` containing just the fixtures — no npm, no
16
+ Composer, no JavaScript toolchain.
17
+
18
+ ## The policy
19
+
20
+ > **N implementations of one contract are acceptable if and only if a shared
21
+ > fixture table is asserted by every implementation, in that implementation's
22
+ > own CI, on every push.**
23
+
24
+ Anything less is not parity. It is a transliteration with a README.
25
+
26
+ ## Why this exists
27
+
28
+ Most Fancy server capabilities ship as a matched PHP + Node pair, so the same
29
+ feature works behind the same UI whichever backend runs it. Keeping two
30
+ implementations honest by hand does not work, and we have the receipts:
31
+
32
+ - `mcp-relay-client` ships four single-file clients. All four were
33
+ hand-transliterated in one commit and none was touched again while the
34
+ TypeScript source took four substantive changes. Result: **9 divergences**,
35
+ including a wrong default relay path in all four. CI never compiled the Go
36
+ one, never imported the Python one, never executed the shell one.
37
+ - `holy-sheet`'s PHP↔JS parity suite — the strongest guarantee in that pair —
38
+ **never ran in CI**. `describe.skipIf(!HAS_PHP)` skipped silently and the
39
+ workflow installed Node only. It also hard-coded `../../holy-sheet/src/`, so
40
+ it worked in exactly one directory layout.
41
+ - `dark-slide`'s harness had the same shape, with the same green-on-zero-coverage
42
+ outcome.
43
+ - The `FeatureSource` contract is byte-identical in two packages and maintained
44
+ by hand. It survives only because TypeScript's structural typing does the
45
+ checking — a mechanism that does not exist in Rust or Go.
46
+
47
+ **The counter-example is in the same org and is the whole argument.**
48
+ `satisfiesRange` is *also* a three-way duplicate — `fancy-ui-cli`, `fancy-flow`,
49
+ `fancy-flow-php` — and it has **not** drifted. The only thing it does
50
+ differently: each repo carries the identical case table in its own CI, the PHP
51
+ one literally titled *"matches the TypeScript satisfiesRange, clause for
52
+ clause."*
53
+
54
+ Same organisation, same problem shape, opposite outcome. The variable is the
55
+ shared table. That table is now `suites/shared/satisfies-range/`, promoted here
56
+ verbatim.
57
+
58
+ ## What is in here
59
+
60
+ | Suite | Cases | Pins |
61
+ |---|---|---|
62
+ | `shared/satisfies-range` | 17 | Minimal semver range matching, including **two rows that deliberately disagree with standard semver** |
63
+ | `shared/decimal` | 18 | Float formatting, numeric-string coercion, and money rounding |
64
+ | `shared/strings` | 8 | Inline-markdown segmentation across CJK, emoji, combining marks and accented Latin |
65
+
66
+ Every case carries an `id`, a `title`, the suite version it arrived in, and —
67
+ where it exists to catch something specific — a `notes` field saying what.
68
+
69
+ ## Using it
70
+
71
+ ```ts
72
+ import { runTable, formatSummary } from "@particle-academy/fancy-conformance";
73
+ import { satisfiesRange } from "../src/marketplace/manifest";
74
+
75
+ const summary = runTable(
76
+ "shared/satisfies-range",
77
+ (c) => satisfiesRange(c.input.version, c.input.range),
78
+ { language: "node" },
79
+ );
80
+
81
+ console.log(formatSummary(summary)); // always — see rule 3
82
+ if (!summary.ok) process.exit(1);
83
+ ```
84
+
85
+ ```php
86
+ use ParticleAcademy\Conformance\Conformance;
87
+
88
+ $summary = Conformance::runTable(
89
+ 'shared/satisfies-range',
90
+ fn (array $c) => NodeManifest::satisfiesRange($c['input']['version'], $c['input']['range']),
91
+ );
92
+
93
+ echo Conformance::formatSummary($summary), "\n";
94
+ exit($summary['ok'] ? 0 : 1);
95
+ ```
96
+
97
+ Full runner contract, including the subprocess CLI for suites that emit
98
+ documents rather than return values: [`runners/README.md`](./runners/README.md).
99
+
100
+ ## The four rules for a consuming repo
101
+
102
+ Each one is traceable to a suite in this org that reported green while covering
103
+ nothing.
104
+
105
+ 1. **Run the suite on every push and PR.** Not nightly, not at release.
106
+ 2. **A missing toolchain is a FAILURE, not a skip.** `skipIf(!HAS_PHP)`
107
+ returning green is the specific mechanism that hid two-way drift for months.
108
+ If the suite cannot run, the job goes red.
109
+ 3. **Print the summary unconditionally, including every skip and its reason.**
110
+ A bare "3 skipped" reads identically to full coverage at a glance.
111
+ 4. **Print and assert the pinned suite version**, so "we're on an old fixture
112
+ set" is visible rather than inferred.
113
+
114
+ ## Skipping a case
115
+
116
+ `skip` is the only sanctioned way not to run a case, it is keyed by language,
117
+ and **the reason may not be empty** — an empty one is a load error, in both
118
+ loaders, asserted on both sides:
119
+
120
+ ```json
121
+ {
122
+ "id": "0014-round-negative-half",
123
+ "skip": { "go": "no decimal type yet — tracking Particle-Academy/…#12" }
124
+ }
125
+ ```
126
+
127
+ Every runner prints every skip. A silent skip is what turned two existing parity
128
+ suites into decoration, so it is not representable here.
129
+
130
+ ## This repository holds itself to the same standard
131
+
132
+ It ships two loaders for one fixture format, which is itself a duplicated
133
+ contract. So:
134
+
135
+ - Each language's test suite proves that language reads the fixtures correctly.
136
+ - `npm run cross-check` runs **both** loaders over the same suites and requires
137
+ identical verdicts case by case — the claim neither suite can make about
138
+ itself. It is a required CI job, and it exits non-zero if zero cases were
139
+ compared.
140
+ - `tests/discrimination.test.ts` runs deliberately **wrong** implementations and
141
+ requires each to fail the exact cases that exist to catch it. A golden table
142
+ that every plausible implementation passes is decoration; this is what proves
143
+ it is not.
144
+
145
+ ## Adding a case
146
+
147
+ A new case lands **here first, red**, then in each implementation. Where an
148
+ implementation cannot pass it yet, it gets a `skip` with a real reason and a
149
+ tracking issue — and that skip appears in that repo's CI log every run until it
150
+ is gone.
151
+
152
+ Never renumber an id. Ids appear in changelogs and in other repos' skip lists.
153
+
154
+ ## Versioning
155
+
156
+ `VERSION` is the fixture set's own semver, and the publish workflow refuses a
157
+ tag unless the tag, `VERSION` and `package.json` all agree. Every implementation
158
+ pins a version and states it in its README; raising the pin is a deliberate act
159
+ with a changelog entry.
160
+
161
+ Pre-1.0, breaking changes land in minor releases.
162
+
163
+ ## License
164
+
165
+ MIT
package/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.2.0
package/dist/index.cjs ADDED
@@ -0,0 +1,201 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var src_exports = {};
22
+ __export(src_exports, {
23
+ deepEquals: () => deepEquals,
24
+ formatSummary: () => formatSummary,
25
+ listSuites: () => listSuites,
26
+ loadSuite: () => loadSuite,
27
+ loadSuiteFrom: () => loadSuiteFrom,
28
+ runTable: () => runTable,
29
+ suitePath: () => suitePath,
30
+ suiteVersion: () => suiteVersion
31
+ });
32
+ module.exports = __toCommonJS(src_exports);
33
+ var import_node_fs = require("fs");
34
+ var import_node_path = require("path");
35
+ var import_node_url = require("url");
36
+ var import_meta = {};
37
+ function packageRoot() {
38
+ let dir = (0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(import_meta.url));
39
+ for (let i = 0; i < 6; i++) {
40
+ try {
41
+ if ((0, import_node_fs.statSync)((0, import_node_path.join)(dir, "suites")).isDirectory()) {
42
+ return dir;
43
+ }
44
+ } catch {
45
+ }
46
+ const parent = (0, import_node_path.dirname)(dir);
47
+ if (parent === dir) break;
48
+ dir = parent;
49
+ }
50
+ throw new Error(
51
+ "fancy-conformance: could not locate the suites/ directory. If you vendored this package, keep suites/ next to dist/."
52
+ );
53
+ }
54
+ function suiteVersion() {
55
+ return (0, import_node_fs.readFileSync)((0, import_node_path.join)(packageRoot(), "VERSION"), "utf8").trim();
56
+ }
57
+ function listSuites() {
58
+ const root = (0, import_node_path.join)(packageRoot(), "suites");
59
+ const found = [];
60
+ const walk = (dir) => {
61
+ for (const entry of (0, import_node_fs.readdirSync)(dir, { withFileTypes: true })) {
62
+ if (!entry.isDirectory()) continue;
63
+ const child = (0, import_node_path.join)(dir, entry.name);
64
+ try {
65
+ (0, import_node_fs.statSync)((0, import_node_path.join)(child, "manifest.json"));
66
+ found.push((0, import_node_path.relative)(root, child).split(import_node_path.sep).join("/"));
67
+ } catch {
68
+ walk(child);
69
+ }
70
+ }
71
+ };
72
+ walk(root);
73
+ return found.sort();
74
+ }
75
+ function loadSuite(id) {
76
+ return loadSuiteFrom(packageRoot(), id);
77
+ }
78
+ function loadSuiteFrom(root, id) {
79
+ const dir = (0, import_node_path.join)(root, "suites", ...id.split("/"));
80
+ const manifest = JSON.parse((0, import_node_fs.readFileSync)((0, import_node_path.join)(dir, "manifest.json"), "utf8"));
81
+ if (manifest.caseFormat !== "table") {
82
+ throw new Error(
83
+ `fancy-conformance: suite "${id}" uses caseFormat "${manifest.caseFormat}", which loadSuite() does not read. Use the artifact runner in runners/.`
84
+ );
85
+ }
86
+ const table = JSON.parse(
87
+ (0, import_node_fs.readFileSync)((0, import_node_path.join)(dir, manifest.cases ?? "cases.json"), "utf8")
88
+ );
89
+ assertUsableCases(id, table.cases);
90
+ return { manifest, cases: table.cases };
91
+ }
92
+ function assertUsableCases(id, cases) {
93
+ const seen = /* @__PURE__ */ new Set();
94
+ for (const c of cases) {
95
+ if (seen.has(c.id)) {
96
+ throw new Error(`fancy-conformance: suite "${id}" has duplicate case id "${c.id}".`);
97
+ }
98
+ seen.add(c.id);
99
+ for (const [lang, reason] of Object.entries(c.skip ?? {})) {
100
+ if (typeof reason !== "string" || reason.trim() === "") {
101
+ throw new Error(
102
+ `fancy-conformance: case "${id}/${c.id}" skips ${lang} with no reason. A skip must say why, because every runner prints it.`
103
+ );
104
+ }
105
+ }
106
+ }
107
+ }
108
+ function runTable(suiteId, impl, options) {
109
+ const { manifest, cases } = loadSuite(suiteId);
110
+ const equals = options.equals ?? deepEquals;
111
+ const results = [];
112
+ for (const c of cases) {
113
+ const reason = c.skip?.[options.language];
114
+ if (reason !== void 0) {
115
+ results.push({ id: c.id, title: c.title, status: "skip", reason });
116
+ continue;
117
+ }
118
+ let actual;
119
+ try {
120
+ actual = impl(c);
121
+ } catch (error) {
122
+ results.push({
123
+ id: c.id,
124
+ title: c.title,
125
+ status: "fail",
126
+ expected: c.expected,
127
+ actual: `threw: ${error instanceof Error ? error.message : String(error)}`
128
+ });
129
+ continue;
130
+ }
131
+ results.push(
132
+ equals(actual, c.expected) ? { id: c.id, title: c.title, status: "pass" } : { id: c.id, title: c.title, status: "fail", expected: c.expected, actual }
133
+ );
134
+ }
135
+ const failed = results.filter((r) => r.status === "fail").length;
136
+ return {
137
+ suite: manifest.suite,
138
+ language: options.language,
139
+ suiteVersion: suiteVersion(),
140
+ passed: results.filter((r) => r.status === "pass").length,
141
+ failed,
142
+ skipped: results.filter((r) => r.status === "skip").length,
143
+ results,
144
+ ok: failed === 0
145
+ };
146
+ }
147
+ function formatSummary(summary) {
148
+ const lines = [
149
+ `${summary.suite} [${summary.language}] \u2014 fancy-conformance ${summary.suiteVersion}`,
150
+ ` ${summary.passed} passed, ${summary.failed} failed, ${summary.skipped} skipped`
151
+ ];
152
+ for (const r of summary.results) {
153
+ if (r.status === "skip") {
154
+ lines.push(` SKIP ${r.id} \u2014 ${r.reason}`);
155
+ }
156
+ if (r.status === "fail") {
157
+ lines.push(` FAIL ${r.id} ${r.title}`);
158
+ lines.push(` expected: ${preview(r.expected)}`);
159
+ lines.push(` actual: ${preview(r.actual)}`);
160
+ }
161
+ }
162
+ return lines.join("\n");
163
+ }
164
+ function preview(value) {
165
+ const s = typeof value === "string" ? value : JSON.stringify(value);
166
+ if (s === void 0) return String(value);
167
+ return s.length > 120 ? `${s.slice(0, 60)}\u2026${s.slice(-40)} (len ${s.length})` : s;
168
+ }
169
+ function deepEquals(a, b) {
170
+ if (Object.is(a, b)) return true;
171
+ if (typeof a !== typeof b) return false;
172
+ if (a === null || b === null) return false;
173
+ if (Array.isArray(a) || Array.isArray(b)) {
174
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
175
+ return a.every((v, i) => deepEquals(v, b[i]));
176
+ }
177
+ if (typeof a === "object") {
178
+ const ka = Object.keys(a).sort();
179
+ const kb = Object.keys(b).sort();
180
+ if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i])) return false;
181
+ return ka.every(
182
+ (k) => deepEquals(a[k], b[k])
183
+ );
184
+ }
185
+ return false;
186
+ }
187
+ function suitePath(id) {
188
+ return (0, import_node_path.resolve)((0, import_node_path.join)(packageRoot(), "suites", ...id.split("/")));
189
+ }
190
+ // Annotate the CommonJS export names for ESM import in node:
191
+ 0 && (module.exports = {
192
+ deepEquals,
193
+ formatSummary,
194
+ listSuites,
195
+ loadSuite,
196
+ loadSuiteFrom,
197
+ runTable,
198
+ suitePath,
199
+ suiteVersion
200
+ });
201
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { readFileSync, readdirSync, statSync } from \"node:fs\";\nimport { dirname, join, relative, resolve, sep } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nimport type {\n CaseResult,\n ConformanceCase,\n Language,\n RunSummary,\n Suite,\n SuiteManifest,\n} from \"./types\";\n\nexport * from \"./types\";\n\n/**\n * The repository root, whether this is running from `dist/` in an installed\n * package or from `src/` in a checkout.\n *\n * Resolved by walking up to the directory that holds `suites/`, rather than by\n * a fixed `../..`. The two existing parity harnesses in this suite both\n * hard-coded a relative path to a sibling checkout (`../../holy-sheet/src/`),\n * which is why they work in exactly one directory layout and silently no-op\n * everywhere else. This package must not repeat that.\n */\nfunction packageRoot(): string {\n let dir = dirname(fileURLToPath(import.meta.url));\n\n for (let i = 0; i < 6; i++) {\n try {\n if (statSync(join(dir, \"suites\")).isDirectory()) {\n return dir;\n }\n } catch {\n // keep walking\n }\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n\n throw new Error(\n \"fancy-conformance: could not locate the suites/ directory. \" +\n \"If you vendored this package, keep suites/ next to dist/.\",\n );\n}\n\n/** The suite collection's own version — the thing a runner must print. */\nexport function suiteVersion(): string {\n return readFileSync(join(packageRoot(), \"VERSION\"), \"utf8\").trim();\n}\n\n/** Every suite id present, e.g. `[\"shared/decimal\", \"shared/satisfies-range\", …]`. */\nexport function listSuites(): string[] {\n const root = join(packageRoot(), \"suites\");\n const found: string[] = [];\n\n const walk = (dir: string): void => {\n for (const entry of readdirSync(dir, { withFileTypes: true })) {\n if (!entry.isDirectory()) continue;\n const child = join(dir, entry.name);\n try {\n statSync(join(child, \"manifest.json\"));\n found.push(relative(root, child).split(sep).join(\"/\"));\n } catch {\n walk(child);\n }\n }\n };\n\n walk(root);\n return found.sort();\n}\n\n/** Load one suite's manifest and cases. Throws rather than returning a partial. */\nexport function loadSuite(id: string): Suite {\n return loadSuiteFrom(packageRoot(), id);\n}\n\n/**\n * Load a suite from an explicit root.\n *\n * Exported so the load-time guards below can be tested against a throwaway\n * fixture tree, rather than a test re-implementing them. A guard asserted by a\n * copy of itself is the failure mode this whole repository exists to stop, and\n * it would be an embarrassing one to ship here.\n */\nexport function loadSuiteFrom(root: string, id: string): Suite {\n const dir = join(root, \"suites\", ...id.split(\"/\"));\n const manifest = JSON.parse(readFileSync(join(dir, \"manifest.json\"), \"utf8\")) as SuiteManifest;\n\n if (manifest.caseFormat !== \"table\") {\n throw new Error(\n `fancy-conformance: suite \"${id}\" uses caseFormat \"${manifest.caseFormat}\", ` +\n \"which loadSuite() does not read. Use the artifact runner in runners/.\",\n );\n }\n\n const table = JSON.parse(\n readFileSync(join(dir, manifest.cases ?? \"cases.json\"), \"utf8\"),\n ) as { cases: ConformanceCase[] };\n\n assertUsableCases(id, table.cases);\n\n return { manifest, cases: table.cases };\n}\n\n/**\n * Reject a case table that cannot do its job, at LOAD time.\n *\n * A skip with no reason, and a duplicate id, are both silent in every other\n * respect: the suite still loads, still reports green, and still covers less\n * than it appears to. That is the exact failure this repository exists to stop,\n * so it is a hard error here rather than a lint somewhere else.\n */\nfunction assertUsableCases(id: string, cases: ConformanceCase[]): void {\n const seen = new Set<string>();\n\n for (const c of cases) {\n if (seen.has(c.id)) {\n throw new Error(`fancy-conformance: suite \"${id}\" has duplicate case id \"${c.id}\".`);\n }\n seen.add(c.id);\n\n for (const [lang, reason] of Object.entries(c.skip ?? {})) {\n if (typeof reason !== \"string\" || reason.trim() === \"\") {\n throw new Error(\n `fancy-conformance: case \"${id}/${c.id}\" skips ${lang} with no reason. ` +\n \"A skip must say why, because every runner prints it.\",\n );\n }\n }\n }\n}\n\nexport interface RunOptions {\n /** Which language is under test — decides which `skip` entries apply. */\n language: Language;\n /**\n * Compare a produced value with the expected one. Defaults to a\n * canonicalising deep equality: object keys sorted, arrays order-sensitive.\n */\n equals?: (actual: unknown, expected: unknown) => boolean;\n}\n\n/**\n * Run one implementation against a table suite.\n *\n * `impl` receives the case and returns the value to compare. Throwing is a\n * failure, not a crash — a case that blows up is data about the implementation.\n */\nexport function runTable(\n suiteId: string,\n impl: (c: ConformanceCase) => unknown,\n options: RunOptions,\n): RunSummary {\n const { manifest, cases } = loadSuite(suiteId);\n const equals = options.equals ?? deepEquals;\n const results: CaseResult[] = [];\n\n for (const c of cases) {\n const reason = c.skip?.[options.language];\n if (reason !== undefined) {\n results.push({ id: c.id, title: c.title, status: \"skip\", reason });\n continue;\n }\n\n let actual: unknown;\n try {\n actual = impl(c);\n } catch (error) {\n results.push({\n id: c.id,\n title: c.title,\n status: \"fail\",\n expected: c.expected,\n actual: `threw: ${error instanceof Error ? error.message : String(error)}`,\n });\n continue;\n }\n\n results.push(\n equals(actual, c.expected)\n ? { id: c.id, title: c.title, status: \"pass\" }\n : { id: c.id, title: c.title, status: \"fail\", expected: c.expected, actual },\n );\n }\n\n const failed = results.filter((r) => r.status === \"fail\").length;\n\n return {\n suite: manifest.suite,\n language: options.language,\n suiteVersion: suiteVersion(),\n passed: results.filter((r) => r.status === \"pass\").length,\n failed,\n skipped: results.filter((r) => r.status === \"skip\").length,\n results,\n ok: failed === 0,\n };\n}\n\n/**\n * A summary a CI log can be read from — including every skip, by name and\n * reason.\n *\n * Skips are printed unconditionally and never folded into a count. \"3 skipped\"\n * in a log is indistinguishable from full coverage at a glance, which is how a\n * suite stops meaning anything without anyone deciding that it should.\n */\nexport function formatSummary(summary: RunSummary): string {\n const lines: string[] = [\n `${summary.suite} [${summary.language}] — fancy-conformance ${summary.suiteVersion}`,\n ` ${summary.passed} passed, ${summary.failed} failed, ${summary.skipped} skipped`,\n ];\n\n for (const r of summary.results) {\n if (r.status === \"skip\") {\n lines.push(` SKIP ${r.id} — ${r.reason}`);\n }\n if (r.status === \"fail\") {\n lines.push(` FAIL ${r.id} ${r.title}`);\n lines.push(` expected: ${preview(r.expected)}`);\n lines.push(` actual: ${preview(r.actual)}`);\n }\n }\n\n return lines.join(\"\\n\");\n}\n\nfunction preview(value: unknown): string {\n const s = typeof value === \"string\" ? value : JSON.stringify(value);\n if (s === undefined) return String(value);\n return s.length > 120 ? `${s.slice(0, 60)}…${s.slice(-40)} (len ${s.length})` : s;\n}\n\n/** Order-sensitive for arrays, order-insensitive for object keys. */\nexport function deepEquals(a: unknown, b: unknown): boolean {\n if (Object.is(a, b)) return true;\n if (typeof a !== typeof b) return false;\n if (a === null || b === null) return false;\n\n if (Array.isArray(a) || Array.isArray(b)) {\n if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;\n return a.every((v, i) => deepEquals(v, b[i]));\n }\n\n if (typeof a === \"object\") {\n const ka = Object.keys(a as object).sort();\n const kb = Object.keys(b as object).sort();\n if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i])) return false;\n return ka.every((k) =>\n deepEquals((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]),\n );\n }\n\n return false;\n}\n\n/** Absolute path to a suite's directory — for runners that read artifacts. */\nexport function suitePath(id: string): string {\n return resolve(join(packageRoot(), \"suites\", ...id.split(\"/\")));\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,125 @@
1
+ /** The languages a case may be skipped for, and that a runner may report. */
2
+ type Language = "php" | "node" | "rust" | "python" | "go";
3
+ interface ImplementationRef {
4
+ language: Language;
5
+ package: string;
6
+ symbol?: string;
7
+ }
8
+ interface SuiteContract {
9
+ summary: string;
10
+ function?: string;
11
+ functions?: Record<string, string>;
12
+ runShape?: Record<string, unknown>;
13
+ /** Whose behaviour the goldens were taken from. */
14
+ reference?: Language;
15
+ referenceNote?: string;
16
+ implementations: ImplementationRef[];
17
+ }
18
+ interface SuiteManifest {
19
+ suite: string;
20
+ title: string;
21
+ since: string;
22
+ /**
23
+ * `table` — one `cases.json` whose rows are the cases. For pure functions.
24
+ * `directory` — `cases/<id>/` holding input and expected artifacts. For
25
+ * capabilities that emit files, where an expectation is a tree of bytes and
26
+ * cannot live in a JSON cell.
27
+ */
28
+ caseFormat: "table" | "directory";
29
+ cases?: string;
30
+ contract: SuiteContract;
31
+ normalisation?: {
32
+ ignoreAttributes?: string[];
33
+ orderInsensitive?: string[];
34
+ whitespace?: "preserve" | "collapse-between-elements";
35
+ newline?: "lf" | "crlf" | "preserve";
36
+ };
37
+ notes?: string[];
38
+ }
39
+ interface ConformanceCase {
40
+ id: string;
41
+ title: string;
42
+ since: string;
43
+ tags?: string[];
44
+ /** Which of the suite's functions this case exercises. */
45
+ fn?: string;
46
+ input: Record<string, unknown>;
47
+ expected: unknown;
48
+ /**
49
+ * The only sanctioned way not to run a case. Keyed by language, valued with a
50
+ * REASON that may not be empty — and every runner prints these.
51
+ */
52
+ skip?: Partial<Record<Language, string>>;
53
+ notes?: string;
54
+ }
55
+ interface Suite {
56
+ manifest: SuiteManifest;
57
+ cases: ConformanceCase[];
58
+ }
59
+ interface CaseResult {
60
+ id: string;
61
+ title: string;
62
+ status: "pass" | "fail" | "skip";
63
+ /** Present when skipped — always non-empty, because an empty reason is a load error. */
64
+ reason?: string;
65
+ expected?: unknown;
66
+ actual?: unknown;
67
+ }
68
+ interface RunSummary {
69
+ suite: string;
70
+ language: Language;
71
+ /** The suite version the results were produced against. Printed, not inferred. */
72
+ suiteVersion: string;
73
+ passed: number;
74
+ failed: number;
75
+ skipped: number;
76
+ results: CaseResult[];
77
+ ok: boolean;
78
+ }
79
+
80
+ /** The suite collection's own version — the thing a runner must print. */
81
+ declare function suiteVersion(): string;
82
+ /** Every suite id present, e.g. `["shared/decimal", "shared/satisfies-range", …]`. */
83
+ declare function listSuites(): string[];
84
+ /** Load one suite's manifest and cases. Throws rather than returning a partial. */
85
+ declare function loadSuite(id: string): Suite;
86
+ /**
87
+ * Load a suite from an explicit root.
88
+ *
89
+ * Exported so the load-time guards below can be tested against a throwaway
90
+ * fixture tree, rather than a test re-implementing them. A guard asserted by a
91
+ * copy of itself is the failure mode this whole repository exists to stop, and
92
+ * it would be an embarrassing one to ship here.
93
+ */
94
+ declare function loadSuiteFrom(root: string, id: string): Suite;
95
+ interface RunOptions {
96
+ /** Which language is under test — decides which `skip` entries apply. */
97
+ language: Language;
98
+ /**
99
+ * Compare a produced value with the expected one. Defaults to a
100
+ * canonicalising deep equality: object keys sorted, arrays order-sensitive.
101
+ */
102
+ equals?: (actual: unknown, expected: unknown) => boolean;
103
+ }
104
+ /**
105
+ * Run one implementation against a table suite.
106
+ *
107
+ * `impl` receives the case and returns the value to compare. Throwing is a
108
+ * failure, not a crash — a case that blows up is data about the implementation.
109
+ */
110
+ declare function runTable(suiteId: string, impl: (c: ConformanceCase) => unknown, options: RunOptions): RunSummary;
111
+ /**
112
+ * A summary a CI log can be read from — including every skip, by name and
113
+ * reason.
114
+ *
115
+ * Skips are printed unconditionally and never folded into a count. "3 skipped"
116
+ * in a log is indistinguishable from full coverage at a glance, which is how a
117
+ * suite stops meaning anything without anyone deciding that it should.
118
+ */
119
+ declare function formatSummary(summary: RunSummary): string;
120
+ /** Order-sensitive for arrays, order-insensitive for object keys. */
121
+ declare function deepEquals(a: unknown, b: unknown): boolean;
122
+ /** Absolute path to a suite's directory — for runners that read artifacts. */
123
+ declare function suitePath(id: string): string;
124
+
125
+ export { type CaseResult, type ConformanceCase, type ImplementationRef, type Language, type RunOptions, type RunSummary, type Suite, type SuiteContract, type SuiteManifest, deepEquals, formatSummary, listSuites, loadSuite, loadSuiteFrom, runTable, suitePath, suiteVersion };
@@ -0,0 +1,125 @@
1
+ /** The languages a case may be skipped for, and that a runner may report. */
2
+ type Language = "php" | "node" | "rust" | "python" | "go";
3
+ interface ImplementationRef {
4
+ language: Language;
5
+ package: string;
6
+ symbol?: string;
7
+ }
8
+ interface SuiteContract {
9
+ summary: string;
10
+ function?: string;
11
+ functions?: Record<string, string>;
12
+ runShape?: Record<string, unknown>;
13
+ /** Whose behaviour the goldens were taken from. */
14
+ reference?: Language;
15
+ referenceNote?: string;
16
+ implementations: ImplementationRef[];
17
+ }
18
+ interface SuiteManifest {
19
+ suite: string;
20
+ title: string;
21
+ since: string;
22
+ /**
23
+ * `table` — one `cases.json` whose rows are the cases. For pure functions.
24
+ * `directory` — `cases/<id>/` holding input and expected artifacts. For
25
+ * capabilities that emit files, where an expectation is a tree of bytes and
26
+ * cannot live in a JSON cell.
27
+ */
28
+ caseFormat: "table" | "directory";
29
+ cases?: string;
30
+ contract: SuiteContract;
31
+ normalisation?: {
32
+ ignoreAttributes?: string[];
33
+ orderInsensitive?: string[];
34
+ whitespace?: "preserve" | "collapse-between-elements";
35
+ newline?: "lf" | "crlf" | "preserve";
36
+ };
37
+ notes?: string[];
38
+ }
39
+ interface ConformanceCase {
40
+ id: string;
41
+ title: string;
42
+ since: string;
43
+ tags?: string[];
44
+ /** Which of the suite's functions this case exercises. */
45
+ fn?: string;
46
+ input: Record<string, unknown>;
47
+ expected: unknown;
48
+ /**
49
+ * The only sanctioned way not to run a case. Keyed by language, valued with a
50
+ * REASON that may not be empty — and every runner prints these.
51
+ */
52
+ skip?: Partial<Record<Language, string>>;
53
+ notes?: string;
54
+ }
55
+ interface Suite {
56
+ manifest: SuiteManifest;
57
+ cases: ConformanceCase[];
58
+ }
59
+ interface CaseResult {
60
+ id: string;
61
+ title: string;
62
+ status: "pass" | "fail" | "skip";
63
+ /** Present when skipped — always non-empty, because an empty reason is a load error. */
64
+ reason?: string;
65
+ expected?: unknown;
66
+ actual?: unknown;
67
+ }
68
+ interface RunSummary {
69
+ suite: string;
70
+ language: Language;
71
+ /** The suite version the results were produced against. Printed, not inferred. */
72
+ suiteVersion: string;
73
+ passed: number;
74
+ failed: number;
75
+ skipped: number;
76
+ results: CaseResult[];
77
+ ok: boolean;
78
+ }
79
+
80
+ /** The suite collection's own version — the thing a runner must print. */
81
+ declare function suiteVersion(): string;
82
+ /** Every suite id present, e.g. `["shared/decimal", "shared/satisfies-range", …]`. */
83
+ declare function listSuites(): string[];
84
+ /** Load one suite's manifest and cases. Throws rather than returning a partial. */
85
+ declare function loadSuite(id: string): Suite;
86
+ /**
87
+ * Load a suite from an explicit root.
88
+ *
89
+ * Exported so the load-time guards below can be tested against a throwaway
90
+ * fixture tree, rather than a test re-implementing them. A guard asserted by a
91
+ * copy of itself is the failure mode this whole repository exists to stop, and
92
+ * it would be an embarrassing one to ship here.
93
+ */
94
+ declare function loadSuiteFrom(root: string, id: string): Suite;
95
+ interface RunOptions {
96
+ /** Which language is under test — decides which `skip` entries apply. */
97
+ language: Language;
98
+ /**
99
+ * Compare a produced value with the expected one. Defaults to a
100
+ * canonicalising deep equality: object keys sorted, arrays order-sensitive.
101
+ */
102
+ equals?: (actual: unknown, expected: unknown) => boolean;
103
+ }
104
+ /**
105
+ * Run one implementation against a table suite.
106
+ *
107
+ * `impl` receives the case and returns the value to compare. Throwing is a
108
+ * failure, not a crash — a case that blows up is data about the implementation.
109
+ */
110
+ declare function runTable(suiteId: string, impl: (c: ConformanceCase) => unknown, options: RunOptions): RunSummary;
111
+ /**
112
+ * A summary a CI log can be read from — including every skip, by name and
113
+ * reason.
114
+ *
115
+ * Skips are printed unconditionally and never folded into a count. "3 skipped"
116
+ * in a log is indistinguishable from full coverage at a glance, which is how a
117
+ * suite stops meaning anything without anyone deciding that it should.
118
+ */
119
+ declare function formatSummary(summary: RunSummary): string;
120
+ /** Order-sensitive for arrays, order-insensitive for object keys. */
121
+ declare function deepEquals(a: unknown, b: unknown): boolean;
122
+ /** Absolute path to a suite's directory — for runners that read artifacts. */
123
+ declare function suitePath(id: string): string;
124
+
125
+ export { type CaseResult, type ConformanceCase, type ImplementationRef, type Language, type RunOptions, type RunSummary, type Suite, type SuiteContract, type SuiteManifest, deepEquals, formatSummary, listSuites, loadSuite, loadSuiteFrom, runTable, suitePath, suiteVersion };