@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/dist/index.js ADDED
@@ -0,0 +1,168 @@
1
+ // src/index.ts
2
+ import { readFileSync, readdirSync, statSync } from "fs";
3
+ import { dirname, join, relative, resolve, sep } from "path";
4
+ import { fileURLToPath } from "url";
5
+ function packageRoot() {
6
+ let dir = dirname(fileURLToPath(import.meta.url));
7
+ for (let i = 0; i < 6; i++) {
8
+ try {
9
+ if (statSync(join(dir, "suites")).isDirectory()) {
10
+ return dir;
11
+ }
12
+ } catch {
13
+ }
14
+ const parent = dirname(dir);
15
+ if (parent === dir) break;
16
+ dir = parent;
17
+ }
18
+ throw new Error(
19
+ "fancy-conformance: could not locate the suites/ directory. If you vendored this package, keep suites/ next to dist/."
20
+ );
21
+ }
22
+ function suiteVersion() {
23
+ return readFileSync(join(packageRoot(), "VERSION"), "utf8").trim();
24
+ }
25
+ function listSuites() {
26
+ const root = join(packageRoot(), "suites");
27
+ const found = [];
28
+ const walk = (dir) => {
29
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
30
+ if (!entry.isDirectory()) continue;
31
+ const child = join(dir, entry.name);
32
+ try {
33
+ statSync(join(child, "manifest.json"));
34
+ found.push(relative(root, child).split(sep).join("/"));
35
+ } catch {
36
+ walk(child);
37
+ }
38
+ }
39
+ };
40
+ walk(root);
41
+ return found.sort();
42
+ }
43
+ function loadSuite(id) {
44
+ return loadSuiteFrom(packageRoot(), id);
45
+ }
46
+ function loadSuiteFrom(root, id) {
47
+ const dir = join(root, "suites", ...id.split("/"));
48
+ const manifest = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
49
+ if (manifest.caseFormat !== "table") {
50
+ throw new Error(
51
+ `fancy-conformance: suite "${id}" uses caseFormat "${manifest.caseFormat}", which loadSuite() does not read. Use the artifact runner in runners/.`
52
+ );
53
+ }
54
+ const table = JSON.parse(
55
+ readFileSync(join(dir, manifest.cases ?? "cases.json"), "utf8")
56
+ );
57
+ assertUsableCases(id, table.cases);
58
+ return { manifest, cases: table.cases };
59
+ }
60
+ function assertUsableCases(id, cases) {
61
+ const seen = /* @__PURE__ */ new Set();
62
+ for (const c of cases) {
63
+ if (seen.has(c.id)) {
64
+ throw new Error(`fancy-conformance: suite "${id}" has duplicate case id "${c.id}".`);
65
+ }
66
+ seen.add(c.id);
67
+ for (const [lang, reason] of Object.entries(c.skip ?? {})) {
68
+ if (typeof reason !== "string" || reason.trim() === "") {
69
+ throw new Error(
70
+ `fancy-conformance: case "${id}/${c.id}" skips ${lang} with no reason. A skip must say why, because every runner prints it.`
71
+ );
72
+ }
73
+ }
74
+ }
75
+ }
76
+ function runTable(suiteId, impl, options) {
77
+ const { manifest, cases } = loadSuite(suiteId);
78
+ const equals = options.equals ?? deepEquals;
79
+ const results = [];
80
+ for (const c of cases) {
81
+ const reason = c.skip?.[options.language];
82
+ if (reason !== void 0) {
83
+ results.push({ id: c.id, title: c.title, status: "skip", reason });
84
+ continue;
85
+ }
86
+ let actual;
87
+ try {
88
+ actual = impl(c);
89
+ } catch (error) {
90
+ results.push({
91
+ id: c.id,
92
+ title: c.title,
93
+ status: "fail",
94
+ expected: c.expected,
95
+ actual: `threw: ${error instanceof Error ? error.message : String(error)}`
96
+ });
97
+ continue;
98
+ }
99
+ results.push(
100
+ equals(actual, c.expected) ? { id: c.id, title: c.title, status: "pass" } : { id: c.id, title: c.title, status: "fail", expected: c.expected, actual }
101
+ );
102
+ }
103
+ const failed = results.filter((r) => r.status === "fail").length;
104
+ return {
105
+ suite: manifest.suite,
106
+ language: options.language,
107
+ suiteVersion: suiteVersion(),
108
+ passed: results.filter((r) => r.status === "pass").length,
109
+ failed,
110
+ skipped: results.filter((r) => r.status === "skip").length,
111
+ results,
112
+ ok: failed === 0
113
+ };
114
+ }
115
+ function formatSummary(summary) {
116
+ const lines = [
117
+ `${summary.suite} [${summary.language}] \u2014 fancy-conformance ${summary.suiteVersion}`,
118
+ ` ${summary.passed} passed, ${summary.failed} failed, ${summary.skipped} skipped`
119
+ ];
120
+ for (const r of summary.results) {
121
+ if (r.status === "skip") {
122
+ lines.push(` SKIP ${r.id} \u2014 ${r.reason}`);
123
+ }
124
+ if (r.status === "fail") {
125
+ lines.push(` FAIL ${r.id} ${r.title}`);
126
+ lines.push(` expected: ${preview(r.expected)}`);
127
+ lines.push(` actual: ${preview(r.actual)}`);
128
+ }
129
+ }
130
+ return lines.join("\n");
131
+ }
132
+ function preview(value) {
133
+ const s = typeof value === "string" ? value : JSON.stringify(value);
134
+ if (s === void 0) return String(value);
135
+ return s.length > 120 ? `${s.slice(0, 60)}\u2026${s.slice(-40)} (len ${s.length})` : s;
136
+ }
137
+ function deepEquals(a, b) {
138
+ if (Object.is(a, b)) return true;
139
+ if (typeof a !== typeof b) return false;
140
+ if (a === null || b === null) return false;
141
+ if (Array.isArray(a) || Array.isArray(b)) {
142
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
143
+ return a.every((v, i) => deepEquals(v, b[i]));
144
+ }
145
+ if (typeof a === "object") {
146
+ const ka = Object.keys(a).sort();
147
+ const kb = Object.keys(b).sort();
148
+ if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i])) return false;
149
+ return ka.every(
150
+ (k) => deepEquals(a[k], b[k])
151
+ );
152
+ }
153
+ return false;
154
+ }
155
+ function suitePath(id) {
156
+ return resolve(join(packageRoot(), "suites", ...id.split("/")));
157
+ }
158
+ export {
159
+ deepEquals,
160
+ formatSummary,
161
+ listSuites,
162
+ loadSuite,
163
+ loadSuiteFrom,
164
+ runTable,
165
+ suitePath,
166
+ suiteVersion
167
+ };
168
+ //# sourceMappingURL=index.js.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":";AAAA,SAAS,cAAc,aAAa,gBAAgB;AACpD,SAAS,SAAS,MAAM,UAAU,SAAS,WAAW;AACtD,SAAS,qBAAqB;AAuB9B,SAAS,cAAsB;AAC7B,MAAI,MAAM,QAAQ,cAAc,YAAY,GAAG,CAAC;AAEhD,WAAS,IAAI,GAAG,IAAI,GAAG,KAAK;AAC1B,QAAI;AACF,UAAI,SAAS,KAAK,KAAK,QAAQ,CAAC,EAAE,YAAY,GAAG;AAC/C,eAAO;AAAA,MACT;AAAA,IACF,QAAQ;AAAA,IAER;AACA,UAAM,SAAS,QAAQ,GAAG;AAC1B,QAAI,WAAW,IAAK;AACpB,UAAM;AAAA,EACR;AAEA,QAAM,IAAI;AAAA,IACR;AAAA,EAEF;AACF;AAGO,SAAS,eAAuB;AACrC,SAAO,aAAa,KAAK,YAAY,GAAG,SAAS,GAAG,MAAM,EAAE,KAAK;AACnE;AAGO,SAAS,aAAuB;AACrC,QAAM,OAAO,KAAK,YAAY,GAAG,QAAQ;AACzC,QAAM,QAAkB,CAAC;AAEzB,QAAM,OAAO,CAAC,QAAsB;AAClC,eAAW,SAAS,YAAY,KAAK,EAAE,eAAe,KAAK,CAAC,GAAG;AAC7D,UAAI,CAAC,MAAM,YAAY,EAAG;AAC1B,YAAM,QAAQ,KAAK,KAAK,MAAM,IAAI;AAClC,UAAI;AACF,iBAAS,KAAK,OAAO,eAAe,CAAC;AACrC,cAAM,KAAK,SAAS,MAAM,KAAK,EAAE,MAAM,GAAG,EAAE,KAAK,GAAG,CAAC;AAAA,MACvD,QAAQ;AACN,aAAK,KAAK;AAAA,MACZ;AAAA,IACF;AAAA,EACF;AAEA,OAAK,IAAI;AACT,SAAO,MAAM,KAAK;AACpB;AAGO,SAAS,UAAU,IAAmB;AAC3C,SAAO,cAAc,YAAY,GAAG,EAAE;AACxC;AAUO,SAAS,cAAc,MAAc,IAAmB;AAC7D,QAAM,MAAM,KAAK,MAAM,UAAU,GAAG,GAAG,MAAM,GAAG,CAAC;AACjD,QAAM,WAAW,KAAK,MAAM,aAAa,KAAK,KAAK,eAAe,GAAG,MAAM,CAAC;AAE5E,MAAI,SAAS,eAAe,SAAS;AACnC,UAAM,IAAI;AAAA,MACR,6BAA6B,EAAE,sBAAsB,SAAS,UAAU;AAAA,IAE1E;AAAA,EACF;AAEA,QAAM,QAAQ,KAAK;AAAA,IACjB,aAAa,KAAK,KAAK,SAAS,SAAS,YAAY,GAAG,MAAM;AAAA,EAChE;AAEA,oBAAkB,IAAI,MAAM,KAAK;AAEjC,SAAO,EAAE,UAAU,OAAO,MAAM,MAAM;AACxC;AAUA,SAAS,kBAAkB,IAAY,OAAgC;AACrE,QAAM,OAAO,oBAAI,IAAY;AAE7B,aAAW,KAAK,OAAO;AACrB,QAAI,KAAK,IAAI,EAAE,EAAE,GAAG;AAClB,YAAM,IAAI,MAAM,6BAA6B,EAAE,4BAA4B,EAAE,EAAE,IAAI;AAAA,IACrF;AACA,SAAK,IAAI,EAAE,EAAE;AAEb,eAAW,CAAC,MAAM,MAAM,KAAK,OAAO,QAAQ,EAAE,QAAQ,CAAC,CAAC,GAAG;AACzD,UAAI,OAAO,WAAW,YAAY,OAAO,KAAK,MAAM,IAAI;AACtD,cAAM,IAAI;AAAA,UACR,4BAA4B,EAAE,IAAI,EAAE,EAAE,WAAW,IAAI;AAAA,QAEvD;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;AAkBO,SAAS,SACd,SACA,MACA,SACY;AACZ,QAAM,EAAE,UAAU,MAAM,IAAI,UAAU,OAAO;AAC7C,QAAM,SAAS,QAAQ,UAAU;AACjC,QAAM,UAAwB,CAAC;AAE/B,aAAW,KAAK,OAAO;AACrB,UAAM,SAAS,EAAE,OAAO,QAAQ,QAAQ;AACxC,QAAI,WAAW,QAAW;AACxB,cAAQ,KAAK,EAAE,IAAI,EAAE,IAAI,OAAO,EAAE,OAAO,QAAQ,QAAQ,OAAO,CAAC;AACjE;AAAA,IACF;AAEA,QAAI;AACJ,QAAI;AACF,eAAS,KAAK,CAAC;AAAA,IACjB,SAAS,OAAO;AACd,cAAQ,KAAK;AAAA,QACX,IAAI,EAAE;AAAA,QACN,OAAO,EAAE;AAAA,QACT,QAAQ;AAAA,QACR,UAAU,EAAE;AAAA,QACZ,QAAQ,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,CAAC;AAAA,MAC1E,CAAC;AACD;AAAA,IACF;AAEA,YAAQ;AAAA,MACN,OAAO,QAAQ,EAAE,QAAQ,IACrB,EAAE,IAAI,EAAE,IAAI,OAAO,EAAE,OAAO,QAAQ,OAAO,IAC3C,EAAE,IAAI,EAAE,IAAI,OAAO,EAAE,OAAO,QAAQ,QAAQ,UAAU,EAAE,UAAU,OAAO;AAAA,IAC/E;AAAA,EACF;AAEA,QAAM,SAAS,QAAQ,OAAO,CAAC,MAAM,EAAE,WAAW,MAAM,EAAE;AAE1D,SAAO;AAAA,IACL,OAAO,SAAS;AAAA,IAChB,UAAU,QAAQ;AAAA,IAClB,cAAc,aAAa;AAAA,IAC3B,QAAQ,QAAQ,OAAO,CAAC,MAAM,EAAE,WAAW,MAAM,EAAE;AAAA,IACnD;AAAA,IACA,SAAS,QAAQ,OAAO,CAAC,MAAM,EAAE,WAAW,MAAM,EAAE;AAAA,IACpD;AAAA,IACA,IAAI,WAAW;AAAA,EACjB;AACF;AAUO,SAAS,cAAc,SAA6B;AACzD,QAAM,QAAkB;AAAA,IACtB,GAAG,QAAQ,KAAK,KAAK,QAAQ,QAAQ,8BAAyB,QAAQ,YAAY;AAAA,IAClF,KAAK,QAAQ,MAAM,YAAY,QAAQ,MAAM,YAAY,QAAQ,OAAO;AAAA,EAC1E;AAEA,aAAW,KAAK,QAAQ,SAAS;AAC/B,QAAI,EAAE,WAAW,QAAQ;AACvB,YAAM,KAAK,UAAU,EAAE,EAAE,WAAM,EAAE,MAAM,EAAE;AAAA,IAC3C;AACA,QAAI,EAAE,WAAW,QAAQ;AACvB,YAAM,KAAK,UAAU,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE;AACtC,YAAM,KAAK,oBAAoB,QAAQ,EAAE,QAAQ,CAAC,EAAE;AACpD,YAAM,KAAK,oBAAoB,QAAQ,EAAE,MAAM,CAAC,EAAE;AAAA,IACpD;AAAA,EACF;AAEA,SAAO,MAAM,KAAK,IAAI;AACxB;AAEA,SAAS,QAAQ,OAAwB;AACvC,QAAM,IAAI,OAAO,UAAU,WAAW,QAAQ,KAAK,UAAU,KAAK;AAClE,MAAI,MAAM,OAAW,QAAO,OAAO,KAAK;AACxC,SAAO,EAAE,SAAS,MAAM,GAAG,EAAE,MAAM,GAAG,EAAE,CAAC,SAAI,EAAE,MAAM,GAAG,CAAC,SAAS,EAAE,MAAM,MAAM;AAClF;AAGO,SAAS,WAAW,GAAY,GAAqB;AAC1D,MAAI,OAAO,GAAG,GAAG,CAAC,EAAG,QAAO;AAC5B,MAAI,OAAO,MAAM,OAAO,EAAG,QAAO;AAClC,MAAI,MAAM,QAAQ,MAAM,KAAM,QAAO;AAErC,MAAI,MAAM,QAAQ,CAAC,KAAK,MAAM,QAAQ,CAAC,GAAG;AACxC,QAAI,CAAC,MAAM,QAAQ,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,KAAK,EAAE,WAAW,EAAE,OAAQ,QAAO;AAC5E,WAAO,EAAE,MAAM,CAAC,GAAG,MAAM,WAAW,GAAG,EAAE,CAAC,CAAC,CAAC;AAAA,EAC9C;AAEA,MAAI,OAAO,MAAM,UAAU;AACzB,UAAM,KAAK,OAAO,KAAK,CAAW,EAAE,KAAK;AACzC,UAAM,KAAK,OAAO,KAAK,CAAW,EAAE,KAAK;AACzC,QAAI,GAAG,WAAW,GAAG,UAAU,GAAG,KAAK,CAAC,GAAG,MAAM,MAAM,GAAG,CAAC,CAAC,EAAG,QAAO;AACtE,WAAO,GAAG;AAAA,MAAM,CAAC,MACf,WAAY,EAA8B,CAAC,GAAI,EAA8B,CAAC,CAAC;AAAA,IACjF;AAAA,EACF;AAEA,SAAO;AACT;AAGO,SAAS,UAAU,IAAoB;AAC5C,SAAO,QAAQ,KAAK,YAAY,GAAG,UAAU,GAAG,GAAG,MAAM,GAAG,CAAC,CAAC;AAChE;","names":[]}
package/package.json CHANGED
@@ -1,13 +1,79 @@
1
1
  {
2
2
  "name": "@particle-academy/fancy-conformance",
3
- "version": "0.0.0",
4
- "description": "Placeholder to claim the name. The real package is published from CI with provenance see https://github.com/Particle-Academy/fancy-conformance",
5
- "license": "MIT",
3
+ "version": "0.2.0",
4
+ "description": "Shared cross-language conformance fixtures for the Fancy suite. One contract, N implementations, and a single table that every implementation asserts in its own CI \u2014 so 'parity' is a test result rather than a claim. Ships the fixture data itself, so a Rust, Go or Python runner can consume it without a JavaScript toolchain.",
6
5
  "repository": {
7
6
  "type": "git",
8
7
  "url": "git+https://github.com/Particle-Academy/fancy-conformance.git"
9
8
  },
9
+ "homepage": "https://github.com/Particle-Academy/fancy-conformance#readme",
10
+ "bugs": "https://github.com/Particle-Academy/fancy-conformance/issues",
11
+ "type": "module",
12
+ "main": "./dist/index.cjs",
13
+ "module": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "import": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ },
21
+ "require": {
22
+ "types": "./dist/index.d.cts",
23
+ "default": "./dist/index.cjs"
24
+ }
25
+ },
26
+ "./suites/*": "./suites/*",
27
+ "./schema/*": "./schema/*",
28
+ "./parity/*": "./parity/*",
29
+ "./VERSION": "./VERSION",
30
+ "./package.json": "./package.json"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "suites",
35
+ "schema",
36
+ "parity",
37
+ "runners",
38
+ "VERSION",
39
+ "README.md",
40
+ "LICENSE"
41
+ ],
42
+ "scripts": {
43
+ "build": "tsup",
44
+ "dev": "tsup --watch",
45
+ "lint": "tsc --noEmit",
46
+ "test": "node --import tsx --test tests/*.test.ts",
47
+ "cross-check": "node --import tsx scripts/cross-check.mjs",
48
+ "clean": "rm -rf dist",
49
+ "prepublishOnly": "tsup"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^26.2.0",
53
+ "tsup": "^8.5.0",
54
+ "tsx": "^4.19.0",
55
+ "typescript": "^5.8.0"
56
+ },
10
57
  "publishConfig": {
11
58
  "access": "public"
59
+ },
60
+ "keywords": [
61
+ "conformance",
62
+ "fixtures",
63
+ "parity",
64
+ "cross-language",
65
+ "polyglot",
66
+ "golden-files",
67
+ "contract-testing",
68
+ "fancy-ui",
69
+ "particle-academy"
70
+ ],
71
+ "license": "MIT",
72
+ "sideEffects": false,
73
+ "overrides": {
74
+ "esbuild": "^0.28.1"
75
+ },
76
+ "engines": {
77
+ "node": ">=22"
12
78
  }
13
79
  }
@@ -0,0 +1,41 @@
1
+ # The release-parity ledger
2
+
3
+ The fixtures in `suites/` answer one question: **does this implementation
4
+ satisfy contract X?**
5
+
6
+ They cannot answer a second, separate one: **has this implementation evaluated
7
+ the newest canonical PHP release?** An implementation can pass every fixture it
8
+ has and still be a release behind, because a PHP release that changed nothing
9
+ about a contract does not change a single fixture — and a stale mirror looks
10
+ exactly like a current one from the outside.
11
+
12
+ That is what the ledger is for. It lives here, next to the fixtures, because the
13
+ two are read together and a claim of "current" needs both.
14
+
15
+ ## The rules
16
+
17
+ - A PHP release with **no contract change** requires no fixture churn and no
18
+ mirror release. It still **invalidates every stale attestation** until each
19
+ mirror runs its pinned suite against the newly declared PHP release.
20
+ - A PHP release with a **compatible or breaking contract change** must publish
21
+ the new fixtures **before** any mirror can attest `current`.
22
+ - `status: current` requires an `implementation`, a `conformance_ref`, an
23
+ `attestation_url`, an `owner` and an `updated_at`. Four of those five are
24
+ things a human has to be able to click, which is the point.
25
+ - `status: pending` or `blocked` requires a `tracking_issue`. A mirror that is
26
+ behind with nowhere to look is indistinguishable from a mirror nobody owns.
27
+
28
+ `ledger.schema.json` enforces all of the above, including the conditional
29
+ requirements, so a hand-edited entry that omits its evidence fails validation
30
+ rather than reading as a claim.
31
+
32
+ ## Why there is no `ledger.json` yet
33
+
34
+ Because nothing has attested yet, and a ledger seeded with plausible-looking
35
+ version numbers and commit hashes would be worse than an absent one — it would
36
+ assert parity that no run has demonstrated, which is precisely the failure this
37
+ repository exists to end.
38
+
39
+ The first entry lands when the first mirror runs its pinned suite in its own CI
40
+ and has an attestation URL to point at. Until then the schema is the contract
41
+ and this file is the procedure.
@@ -0,0 +1,176 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://ui.particle.academy/schemas/parity-ledger.schema.json",
4
+ "title": "Fancy capability release parity ledger",
5
+ "type": "object",
6
+ "required": ["schema_version", "generated_at", "capabilities"],
7
+ "properties": {
8
+ "schema_version": {
9
+ "const": 1
10
+ },
11
+ "generated_at": {
12
+ "type": "string",
13
+ "format": "date-time"
14
+ },
15
+ "capabilities": {
16
+ "type": "array",
17
+ "items": {
18
+ "$ref": "#/$defs/capability"
19
+ }
20
+ }
21
+ },
22
+ "additionalProperties": false,
23
+ "$defs": {
24
+ "version": {
25
+ "type": "string",
26
+ "pattern": "^v?[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$"
27
+ },
28
+ "packageRelease": {
29
+ "type": "object",
30
+ "required": ["package", "version", "commit"],
31
+ "properties": {
32
+ "package": {
33
+ "type": "string",
34
+ "minLength": 1
35
+ },
36
+ "version": {
37
+ "$ref": "#/$defs/version"
38
+ },
39
+ "commit": {
40
+ "type": "string",
41
+ "pattern": "^[0-9a-f]{40}$"
42
+ }
43
+ },
44
+ "additionalProperties": false
45
+ },
46
+ "mirror": {
47
+ "type": "object",
48
+ "required": [
49
+ "language",
50
+ "status",
51
+ "canonical_php_version",
52
+ "contract_version"
53
+ ],
54
+ "properties": {
55
+ "language": {
56
+ "enum": ["node", "rust", "python", "go"]
57
+ },
58
+ "status": {
59
+ "enum": ["current", "pending", "blocked", "unsupported", "deprecated"]
60
+ },
61
+ "canonical_php_version": {
62
+ "$ref": "#/$defs/version"
63
+ },
64
+ "contract_version": {
65
+ "$ref": "#/$defs/version"
66
+ },
67
+ "implementation": {
68
+ "$ref": "#/$defs/packageRelease"
69
+ },
70
+ "conformance_ref": {
71
+ "type": "string",
72
+ "minLength": 1
73
+ },
74
+ "attestation_url": {
75
+ "type": "string",
76
+ "format": "uri"
77
+ },
78
+ "tracking_issue": {
79
+ "type": "string",
80
+ "format": "uri"
81
+ },
82
+ "reason": {
83
+ "type": "string",
84
+ "minLength": 1
85
+ },
86
+ "owner": {
87
+ "type": "string",
88
+ "minLength": 1
89
+ },
90
+ "updated_at": {
91
+ "type": "string",
92
+ "format": "date-time"
93
+ }
94
+ },
95
+ "allOf": [
96
+ {
97
+ "if": {
98
+ "properties": {
99
+ "status": {
100
+ "const": "current"
101
+ }
102
+ }
103
+ },
104
+ "then": {
105
+ "required": [
106
+ "implementation",
107
+ "conformance_ref",
108
+ "attestation_url",
109
+ "owner",
110
+ "updated_at"
111
+ ]
112
+ }
113
+ },
114
+ {
115
+ "if": {
116
+ "properties": {
117
+ "status": {
118
+ "enum": ["blocked", "unsupported", "deprecated"]
119
+ }
120
+ }
121
+ },
122
+ "then": {
123
+ "required": ["reason", "owner", "updated_at"]
124
+ }
125
+ },
126
+ {
127
+ "if": {
128
+ "properties": {
129
+ "status": {
130
+ "enum": ["pending", "blocked"]
131
+ }
132
+ }
133
+ },
134
+ "then": {
135
+ "required": ["tracking_issue"]
136
+ }
137
+ }
138
+ ],
139
+ "additionalProperties": false
140
+ },
141
+ "capability": {
142
+ "type": "object",
143
+ "required": [
144
+ "capability",
145
+ "contract_version",
146
+ "conformance_ref",
147
+ "canonical_php",
148
+ "mirrors"
149
+ ],
150
+ "properties": {
151
+ "capability": {
152
+ "type": "string",
153
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
154
+ },
155
+ "contract_version": {
156
+ "$ref": "#/$defs/version"
157
+ },
158
+ "conformance_ref": {
159
+ "type": "string",
160
+ "minLength": 1
161
+ },
162
+ "canonical_php": {
163
+ "$ref": "#/$defs/packageRelease"
164
+ },
165
+ "mirrors": {
166
+ "type": "array",
167
+ "items": {
168
+ "$ref": "#/$defs/mirror"
169
+ },
170
+ "minItems": 1
171
+ }
172
+ },
173
+ "additionalProperties": false
174
+ }
175
+ }
176
+ }
@@ -0,0 +1,93 @@
1
+ # Writing a runner
2
+
3
+ A runner connects one implementation to the fixtures. There are two kinds,
4
+ because there are two kinds of suite.
5
+
6
+ ## Table suites (`caseFormat: "table"`)
7
+
8
+ The whole suite is one JSON file of rows. A runner loads it, calls the function
9
+ under test once per row, and compares. No subprocess, no temp files.
10
+
11
+ Both loaders shipped here — `src/index.ts` for Node, `php/src/Conformance.php`
12
+ for PHP — do this for you and produce an identical summary shape:
13
+
14
+ ```ts
15
+ import { runTable, formatSummary } from "@particle-academy/fancy-conformance";
16
+ import { satisfiesRange } from "../src/marketplace/manifest";
17
+
18
+ const summary = runTable(
19
+ "shared/satisfies-range",
20
+ (c) => satisfiesRange(c.input.version, c.input.range),
21
+ { language: "node" },
22
+ );
23
+
24
+ console.log(formatSummary(summary)); // ALWAYS print it — see below
25
+ if (!summary.ok) process.exit(1);
26
+ ```
27
+
28
+ ```php
29
+ use ParticleAcademy\Conformance\Conformance;
30
+
31
+ $summary = Conformance::runTable(
32
+ 'shared/satisfies-range',
33
+ fn (array $c) => NodeManifest::satisfiesRange($c['input']['version'], $c['input']['range']),
34
+ );
35
+
36
+ echo Conformance::formatSummary($summary), "\n";
37
+ exit($summary['ok'] ? 0 : 1);
38
+ ```
39
+
40
+ For a language with no loader yet, the file format is small enough to read
41
+ directly: `suites/<id>/manifest.json` names the contract, `cases.json` holds
42
+ `{id, title, since, tags?, fn?, input, expected, skip?, notes?}` rows.
43
+
44
+ ## Artifact suites (`caseFormat: "directory"`)
45
+
46
+ For capabilities that emit a document rather than return a value. Each case is a
47
+ directory holding `input.json`, `expected/`, and `meta.json`, and the
48
+ implementation is driven as a subprocess through a fixed CLI:
49
+
50
+ ```
51
+ <impl-cli> <suite> <case-dir> --out <dir> [--now <iso8601>] [--profile canonical]
52
+
53
+ exit 0 — produced output
54
+ exit 2 — case not supported (MUST match a `skip` entry in the case's meta)
55
+ ```
56
+
57
+ One neutral runner drives every implementation through that CLI. This is
58
+ deliberately *not* "each language writes its own harness": a per-language
59
+ harness is how the `holy-sheet` and `dark-slide` parity suites became
60
+ directory-layout-dependent and silently skippable.
61
+
62
+ Binary containers are **never** compared byte-for-byte as shipped. PHP writes
63
+ via `ZipArchive` (DEFLATE, real mtimes); the JS ports write STORE with a fixed
64
+ 1980-01-01 DOS date. Those files can never match. The comparison is two-tier —
65
+ normalised parts first, then an optional rezip to a single canonical profile —
66
+ and the normalisation is declared once in the suite manifest so it cannot
67
+ quietly loosen per case.
68
+
69
+ Every writer therefore needs a determinism flag (`--now` or equivalent).
70
+ Without one, a case cannot be a golden fixture at all.
71
+
72
+ ## Four rules a runner must follow
73
+
74
+ These are not style preferences. Each one is traceable to a suite in this org
75
+ that reported green while covering nothing.
76
+
77
+ 1. **Run on every push and PR.** Not nightly, not at release.
78
+ 2. **A missing toolchain is a FAILURE, not a skip.** `skipIf(!HAS_PHP)`
79
+ returning green is the exact mechanism that hid two-way drift for months.
80
+ If the suite cannot run, the job goes red.
81
+ 3. **Print the summary unconditionally, including every skip and its reason.**
82
+ Both `formatSummary` helpers do this. A bare "3 skipped" in a log reads
83
+ identically to full coverage at a glance.
84
+ 4. **Print and assert the pinned suite version.** `suiteVersion()` /
85
+ `Conformance::version()`. "We're on an old fixture set" should be visible in
86
+ the log rather than inferred months later.
87
+
88
+ ## Adding a case
89
+
90
+ A new case lands **here first, red**, then in each implementation. Where an
91
+ implementation cannot pass it yet, it gets a `skip` entry with a real reason and
92
+ a tracking issue — and the skip shows up in that repo's CI log every run until
93
+ it is gone.
@@ -0,0 +1,55 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://ui.particle.academy/schemas/conformance-case-table.schema.json",
4
+ "title": "Fancy conformance case table",
5
+ "type": "object",
6
+ "required": ["suite", "cases"],
7
+ "properties": {
8
+ "$schema": { "type": "string" },
9
+ "suite": { "type": "string", "minLength": 1 },
10
+ "cases": {
11
+ "type": "array",
12
+ "minItems": 1,
13
+ "items": { "$ref": "#/$defs/case" }
14
+ }
15
+ },
16
+ "additionalProperties": false,
17
+ "$defs": {
18
+ "case": {
19
+ "type": "object",
20
+ "required": ["id", "title", "since", "input", "expected"],
21
+ "properties": {
22
+ "id": {
23
+ "description": "Stable, ordered, unique within the suite. Never renumber — an id appears in changelogs and in other repos' skip lists.",
24
+ "type": "string",
25
+ "pattern": "^[0-9]{4}-[a-z0-9]+(?:[-.][a-z0-9]+)*$"
26
+ },
27
+ "title": { "type": "string", "minLength": 1 },
28
+ "since": {
29
+ "type": "string",
30
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
31
+ },
32
+ "tags": {
33
+ "type": "array",
34
+ "items": { "type": "string", "minLength": 1 }
35
+ },
36
+ "fn": {
37
+ "description": "Which of the suite's functions this case exercises. Required when the suite declares contract.functions.",
38
+ "type": "string",
39
+ "minLength": 1
40
+ },
41
+ "input": { "type": "object" },
42
+ "expected": {},
43
+ "skip": {
44
+ "description": "The ONLY sanctioned way not to run a case. Keyed by language; the value is a REASON and may not be empty. A runner prints every skip in its summary — a silent skip is what turned two existing parity suites into decoration.",
45
+ "type": "object",
46
+ "propertyNames": { "enum": ["php", "node", "rust", "python", "go"] },
47
+ "additionalProperties": { "type": "string", "minLength": 1 },
48
+ "minProperties": 1
49
+ },
50
+ "notes": { "type": "string", "minLength": 1 }
51
+ },
52
+ "additionalProperties": false
53
+ }
54
+ }
55
+ }