@particle-academy/fancy-conformance 0.9.0 → 0.10.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/VERSION CHANGED
@@ -1 +1 @@
1
- 0.9.0
1
+ 0.10.0
package/dist/index.cjs CHANGED
@@ -129,7 +129,7 @@ function runTable(suiteId, impl, options) {
129
129
  continue;
130
130
  }
131
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 }
132
+ equals(actual, c.expected, toleranceFor(c)) ? { id: c.id, title: c.title, status: "pass" } : { id: c.id, title: c.title, status: "fail", expected: c.expected, actual }
133
133
  );
134
134
  }
135
135
  const failed = results.filter((r) => r.status === "fail").length;
@@ -166,20 +166,29 @@ function preview(value) {
166
166
  if (s === void 0) return String(value);
167
167
  return s.length > 120 ? `${s.slice(0, 60)}\u2026${s.slice(-40)} (len ${s.length})` : s;
168
168
  }
169
- function deepEquals(a, b) {
169
+ function toleranceFor(c) {
170
+ const tolerance = c.tolerance;
171
+ return typeof tolerance === "number" && Number.isFinite(tolerance) ? tolerance : void 0;
172
+ }
173
+ function deepEquals(a, b, tolerance) {
170
174
  if (Object.is(a, b)) return true;
175
+ if (typeof a === "number" && typeof b === "number") {
176
+ if (tolerance === void 0) return false;
177
+ const scale = Math.max(1, Math.abs(a), Math.abs(b));
178
+ return Math.abs(a - b) <= tolerance * scale;
179
+ }
171
180
  if (typeof a !== typeof b) return false;
172
181
  if (a === null || b === null) return false;
173
182
  if (Array.isArray(a) || Array.isArray(b)) {
174
183
  if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
175
- return a.every((v, i) => deepEquals(v, b[i]));
184
+ return a.every((v, i) => deepEquals(v, b[i], tolerance));
176
185
  }
177
186
  if (typeof a === "object") {
178
187
  const ka = Object.keys(a).sort();
179
188
  const kb = Object.keys(b).sort();
180
189
  if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i])) return false;
181
190
  return ka.every(
182
- (k) => deepEquals(a[k], b[k])
191
+ (k) => deepEquals(a[k], b[k], tolerance)
183
192
  );
184
193
  }
185
194
  return false;
@@ -1 +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":[]}
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, tolerance?: number) => 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, toleranceFor(c))\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. */\n/**\n * A case's declared float tolerance, or `undefined` for exact comparison.\n *\n * Declared ON THE ROW so it is visible in the fixtures and in any diff of them.\n * A global epsilon is invisible: nobody reading a case can tell whether it is\n * asserting a value or a neighbourhood.\n *\n * A non-finite or boolean `tolerance` is ignored rather than trusted — a stray\n * `true` coerces to 1 in JavaScript, which would silently widen a row to accept\n * almost anything while still looking strict.\n */\nfunction toleranceFor(c: ConformanceCase): number | undefined {\n const tolerance = (c as { tolerance?: unknown }).tolerance;\n return typeof tolerance === \"number\" && Number.isFinite(tolerance) ? tolerance : undefined;\n}\n\nexport function deepEquals(a: unknown, b: unknown, tolerance?: number): boolean {\n if (Object.is(a, b)) return true;\n\n // Numbers compare EXACTLY unless the case declares a tolerance.\n //\n // This loader was already exact while PHP, Python and Rust used a scaled\n // 1e-12 epsilon -- a 3-1 split in the package whose product is agreement, and\n // recorded as such in AGENTS.md for months. The other three now match THIS\n // one, because the epsilon's stated justification turned out to be false:\n // every hard literal (0.002, 0.1, 1e300, DBL_MAX, the 5e-324 denormal)\n // parses to bit-identical doubles in all three languages.\n //\n // The tolerance is per-case and declared on the row, so it is visible in the\n // fixture rather than being a global behaviour a reader cannot see.\n if (typeof a === \"number\" && typeof b === \"number\") {\n if (tolerance === undefined) return false;\n const scale = Math.max(1, Math.abs(a), Math.abs(b));\n return Math.abs(a - b) <= tolerance * scale;\n }\n\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], tolerance));\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], tolerance),\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":[]}
package/dist/index.d.cts CHANGED
@@ -99,7 +99,7 @@ interface RunOptions {
99
99
  * Compare a produced value with the expected one. Defaults to a
100
100
  * canonicalising deep equality: object keys sorted, arrays order-sensitive.
101
101
  */
102
- equals?: (actual: unknown, expected: unknown) => boolean;
102
+ equals?: (actual: unknown, expected: unknown, tolerance?: number) => boolean;
103
103
  }
104
104
  /**
105
105
  * Run one implementation against a table suite.
@@ -117,8 +117,7 @@ declare function runTable(suiteId: string, impl: (c: ConformanceCase) => unknown
117
117
  * suite stops meaning anything without anyone deciding that it should.
118
118
  */
119
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;
120
+ declare function deepEquals(a: unknown, b: unknown, tolerance?: number): boolean;
122
121
  /** Absolute path to a suite's directory — for runners that read artifacts. */
123
122
  declare function suitePath(id: string): string;
124
123
 
package/dist/index.d.ts CHANGED
@@ -99,7 +99,7 @@ interface RunOptions {
99
99
  * Compare a produced value with the expected one. Defaults to a
100
100
  * canonicalising deep equality: object keys sorted, arrays order-sensitive.
101
101
  */
102
- equals?: (actual: unknown, expected: unknown) => boolean;
102
+ equals?: (actual: unknown, expected: unknown, tolerance?: number) => boolean;
103
103
  }
104
104
  /**
105
105
  * Run one implementation against a table suite.
@@ -117,8 +117,7 @@ declare function runTable(suiteId: string, impl: (c: ConformanceCase) => unknown
117
117
  * suite stops meaning anything without anyone deciding that it should.
118
118
  */
119
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;
120
+ declare function deepEquals(a: unknown, b: unknown, tolerance?: number): boolean;
122
121
  /** Absolute path to a suite's directory — for runners that read artifacts. */
123
122
  declare function suitePath(id: string): string;
124
123
 
package/dist/index.js CHANGED
@@ -97,7 +97,7 @@ function runTable(suiteId, impl, options) {
97
97
  continue;
98
98
  }
99
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 }
100
+ equals(actual, c.expected, toleranceFor(c)) ? { id: c.id, title: c.title, status: "pass" } : { id: c.id, title: c.title, status: "fail", expected: c.expected, actual }
101
101
  );
102
102
  }
103
103
  const failed = results.filter((r) => r.status === "fail").length;
@@ -134,20 +134,29 @@ function preview(value) {
134
134
  if (s === void 0) return String(value);
135
135
  return s.length > 120 ? `${s.slice(0, 60)}\u2026${s.slice(-40)} (len ${s.length})` : s;
136
136
  }
137
- function deepEquals(a, b) {
137
+ function toleranceFor(c) {
138
+ const tolerance = c.tolerance;
139
+ return typeof tolerance === "number" && Number.isFinite(tolerance) ? tolerance : void 0;
140
+ }
141
+ function deepEquals(a, b, tolerance) {
138
142
  if (Object.is(a, b)) return true;
143
+ if (typeof a === "number" && typeof b === "number") {
144
+ if (tolerance === void 0) return false;
145
+ const scale = Math.max(1, Math.abs(a), Math.abs(b));
146
+ return Math.abs(a - b) <= tolerance * scale;
147
+ }
139
148
  if (typeof a !== typeof b) return false;
140
149
  if (a === null || b === null) return false;
141
150
  if (Array.isArray(a) || Array.isArray(b)) {
142
151
  if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
143
- return a.every((v, i) => deepEquals(v, b[i]));
152
+ return a.every((v, i) => deepEquals(v, b[i], tolerance));
144
153
  }
145
154
  if (typeof a === "object") {
146
155
  const ka = Object.keys(a).sort();
147
156
  const kb = Object.keys(b).sort();
148
157
  if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i])) return false;
149
158
  return ka.every(
150
- (k) => deepEquals(a[k], b[k])
159
+ (k) => deepEquals(a[k], b[k], tolerance)
151
160
  );
152
161
  }
153
162
  return false;
package/dist/index.js.map CHANGED
@@ -1 +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":[]}
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, tolerance?: number) => 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, toleranceFor(c))\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. */\n/**\n * A case's declared float tolerance, or `undefined` for exact comparison.\n *\n * Declared ON THE ROW so it is visible in the fixtures and in any diff of them.\n * A global epsilon is invisible: nobody reading a case can tell whether it is\n * asserting a value or a neighbourhood.\n *\n * A non-finite or boolean `tolerance` is ignored rather than trusted — a stray\n * `true` coerces to 1 in JavaScript, which would silently widen a row to accept\n * almost anything while still looking strict.\n */\nfunction toleranceFor(c: ConformanceCase): number | undefined {\n const tolerance = (c as { tolerance?: unknown }).tolerance;\n return typeof tolerance === \"number\" && Number.isFinite(tolerance) ? tolerance : undefined;\n}\n\nexport function deepEquals(a: unknown, b: unknown, tolerance?: number): boolean {\n if (Object.is(a, b)) return true;\n\n // Numbers compare EXACTLY unless the case declares a tolerance.\n //\n // This loader was already exact while PHP, Python and Rust used a scaled\n // 1e-12 epsilon -- a 3-1 split in the package whose product is agreement, and\n // recorded as such in AGENTS.md for months. The other three now match THIS\n // one, because the epsilon's stated justification turned out to be false:\n // every hard literal (0.002, 0.1, 1e300, DBL_MAX, the 5e-324 denormal)\n // parses to bit-identical doubles in all three languages.\n //\n // The tolerance is per-case and declared on the row, so it is visible in the\n // fixture rather than being a global behaviour a reader cannot see.\n if (typeof a === \"number\" && typeof b === \"number\") {\n if (tolerance === undefined) return false;\n const scale = Math.max(1, Math.abs(a), Math.abs(b));\n return Math.abs(a - b) <= tolerance * scale;\n }\n\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], tolerance));\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], tolerance),\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":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@particle-academy/fancy-conformance",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
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.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,6 +47,11 @@
47
47
  "additionalProperties": { "type": "string", "minLength": 1 },
48
48
  "minProperties": 1
49
49
  },
50
+ "tolerance": {
51
+ "description": "Optional relative tolerance for FLOAT comparison in this case, scaled by the larger magnitude. Omit it and numbers compare EXACTLY, which is the default in all four loaders. Declared on the row rather than applied globally so a reader of the fixture can see whether it asserts a value or a neighbourhood — the same reason a skip must state its reason. A global 1e-12 epsilon lived in three loaders until 0.10.0 and let two runtimes that computed different values pass as equal.",
52
+ "type": "number",
53
+ "exclusiveMinimum": 0
54
+ },
50
55
  "notes": { "type": "string", "minLength": 1 }
51
56
  },
52
57
  "additionalProperties": false
@@ -6,221 +6,553 @@
6
6
  "id": "0001-supplied-value-passes-through",
7
7
  "title": "A declared, supplied value arrives in the resolved map unchanged.",
8
8
  "since": "0.9.0",
9
- "tags": ["happy-path"],
9
+ "tags": [
10
+ "happy-path"
11
+ ],
10
12
  "input": {
11
- "declared": [{ "name": "topic", "type": "string" }],
12
- "passed": { "topic": "otters" }
13
+ "declared": [
14
+ {
15
+ "name": "topic",
16
+ "type": "string"
17
+ }
18
+ ],
19
+ "passed": {
20
+ "topic": "otters"
21
+ }
13
22
  },
14
- "expected": { "ok": true, "props": { "topic": "otters" } }
23
+ "expected": {
24
+ "ok": true,
25
+ "props": {
26
+ "topic": "otters"
27
+ }
28
+ }
15
29
  },
16
30
  {
17
31
  "id": "0002-default-fills-an-omitted-value",
18
32
  "title": "An omitted input with a default resolves to the default.",
19
33
  "since": "0.9.0",
20
- "tags": ["defaults"],
34
+ "tags": [
35
+ "defaults"
36
+ ],
21
37
  "input": {
22
- "declared": [{ "name": "limit", "type": "number", "default": 10 }],
38
+ "declared": [
39
+ {
40
+ "name": "limit",
41
+ "type": "number",
42
+ "default": 10
43
+ }
44
+ ],
23
45
  "passed": {}
24
46
  },
25
- "expected": { "ok": true, "props": { "limit": 10 } }
47
+ "expected": {
48
+ "ok": true,
49
+ "props": {
50
+ "limit": 10
51
+ }
52
+ }
26
53
  },
27
54
  {
28
55
  "id": "0003-supplied-beats-default",
29
56
  "title": "An explicitly supplied value wins over the declared default.",
30
57
  "since": "0.9.0",
31
- "tags": ["defaults"],
58
+ "tags": [
59
+ "defaults"
60
+ ],
32
61
  "input": {
33
- "declared": [{ "name": "limit", "type": "number", "default": 10 }],
34
- "passed": { "limit": 25 }
62
+ "declared": [
63
+ {
64
+ "name": "limit",
65
+ "type": "number",
66
+ "default": 10
67
+ }
68
+ ],
69
+ "passed": {
70
+ "limit": 25
71
+ }
35
72
  },
36
- "expected": { "ok": true, "props": { "limit": 25 } }
73
+ "expected": {
74
+ "ok": true,
75
+ "props": {
76
+ "limit": 25
77
+ }
78
+ }
37
79
  },
38
80
  {
39
81
  "id": "0004-explicit-zero-is-not-replaced-by-a-default",
40
82
  "title": "A supplied 0 survives a non-zero default.",
41
83
  "since": "0.9.0",
42
- "tags": ["defaults", "falsy", "trap"],
84
+ "tags": [
85
+ "defaults",
86
+ "falsy",
87
+ "trap"
88
+ ],
43
89
  "input": {
44
- "declared": [{ "name": "limit", "type": "number", "default": 10 }],
45
- "passed": { "limit": 0 }
90
+ "declared": [
91
+ {
92
+ "name": "limit",
93
+ "type": "number",
94
+ "default": 10
95
+ }
96
+ ],
97
+ "passed": {
98
+ "limit": 0
99
+ }
46
100
  },
47
- "expected": { "ok": true, "props": { "limit": 0 } }
101
+ "expected": {
102
+ "ok": true,
103
+ "props": {
104
+ "limit": 0
105
+ }
106
+ }
48
107
  },
49
108
  {
50
109
  "id": "0005-explicit-false-is-not-replaced-by-a-default",
51
110
  "title": "A supplied false survives a true default.",
52
111
  "since": "0.9.0",
53
- "tags": ["defaults", "falsy", "trap"],
112
+ "tags": [
113
+ "defaults",
114
+ "falsy",
115
+ "trap"
116
+ ],
54
117
  "input": {
55
- "declared": [{ "name": "dryRun", "type": "boolean", "default": true }],
56
- "passed": { "dryRun": false }
118
+ "declared": [
119
+ {
120
+ "name": "dryRun",
121
+ "type": "boolean",
122
+ "default": true
123
+ }
124
+ ],
125
+ "passed": {
126
+ "dryRun": false
127
+ }
57
128
  },
58
- "expected": { "ok": true, "props": { "dryRun": false } }
129
+ "expected": {
130
+ "ok": true,
131
+ "props": {
132
+ "dryRun": false
133
+ }
134
+ }
59
135
  },
60
136
  {
61
137
  "id": "0006-explicit-empty-string-is-not-replaced-by-a-default",
62
138
  "title": "A supplied empty string survives a non-empty default.",
63
139
  "since": "0.9.0",
64
- "tags": ["defaults", "falsy", "trap"],
140
+ "tags": [
141
+ "defaults",
142
+ "falsy",
143
+ "trap"
144
+ ],
65
145
  "input": {
66
- "declared": [{ "name": "note", "type": "string", "default": "unset" }],
67
- "passed": { "note": "" }
146
+ "declared": [
147
+ {
148
+ "name": "note",
149
+ "type": "string",
150
+ "default": "unset"
151
+ }
152
+ ],
153
+ "passed": {
154
+ "note": ""
155
+ }
68
156
  },
69
- "expected": { "ok": true, "props": { "note": "" } }
157
+ "expected": {
158
+ "ok": true,
159
+ "props": {
160
+ "note": ""
161
+ }
162
+ }
70
163
  },
71
164
  {
72
165
  "id": "0007-absent-optional-is-absent-not-null",
73
166
  "title": "An optional input with no default and no value is missing from the map entirely.",
74
167
  "since": "0.9.0",
75
- "tags": ["absence"],
168
+ "tags": [
169
+ "absence"
170
+ ],
76
171
  "input": {
77
172
  "declared": [
78
- { "name": "topic", "type": "string" },
79
- { "name": "note", "type": "string" }
173
+ {
174
+ "name": "topic",
175
+ "type": "string"
176
+ },
177
+ {
178
+ "name": "note",
179
+ "type": "string"
180
+ }
80
181
  ],
81
- "passed": { "topic": "otters" }
182
+ "passed": {
183
+ "topic": "otters"
184
+ }
82
185
  },
83
- "expected": { "ok": true, "props": { "topic": "otters" } }
186
+ "expected": {
187
+ "ok": true,
188
+ "props": {
189
+ "topic": "otters"
190
+ }
191
+ }
84
192
  },
85
193
  {
86
194
  "id": "0008-untyped-declaration-accepts-anything",
87
195
  "title": "An input declaring no type accepts a nested object.",
88
196
  "since": "0.9.0",
89
- "tags": ["types"],
197
+ "tags": [
198
+ "types"
199
+ ],
90
200
  "input": {
91
- "declared": [{ "name": "payload" }],
92
- "passed": { "payload": { "nested": [1, 2] } }
201
+ "declared": [
202
+ {
203
+ "name": "payload"
204
+ }
205
+ ],
206
+ "passed": {
207
+ "payload": {
208
+ "nested": [
209
+ 1,
210
+ 2
211
+ ]
212
+ }
213
+ }
93
214
  },
94
- "expected": { "ok": true, "props": { "payload": { "nested": [1, 2] } } }
215
+ "expected": {
216
+ "ok": true,
217
+ "props": {
218
+ "payload": {
219
+ "nested": [
220
+ 1,
221
+ 2
222
+ ]
223
+ }
224
+ }
225
+ }
95
226
  },
96
227
  {
97
228
  "id": "0009-required-is-satisfied-by-its-default",
98
229
  "title": "A required input carrying a default does not need the caller to supply it.",
99
230
  "since": "0.9.0",
100
- "tags": ["required", "defaults"],
231
+ "tags": [
232
+ "required",
233
+ "defaults"
234
+ ],
101
235
  "input": {
102
- "declared": [{ "name": "limit", "type": "number", "required": true, "default": 5 }],
236
+ "declared": [
237
+ {
238
+ "name": "limit",
239
+ "type": "number",
240
+ "required": true,
241
+ "default": 5
242
+ }
243
+ ],
103
244
  "passed": {}
104
245
  },
105
- "expected": { "ok": true, "props": { "limit": 5 } }
246
+ "expected": {
247
+ "ok": true,
248
+ "props": {
249
+ "limit": 5
250
+ }
251
+ }
106
252
  },
107
253
  {
108
254
  "id": "0010-array-satisfies-array-not-object",
109
255
  "title": "A list satisfies a declared array.",
110
256
  "since": "0.9.0",
111
- "tags": ["types", "trap"],
257
+ "tags": [
258
+ "types",
259
+ "trap"
260
+ ],
112
261
  "input": {
113
- "declared": [{ "name": "tags", "type": "array" }],
114
- "passed": { "tags": ["a", "b"] }
262
+ "declared": [
263
+ {
264
+ "name": "tags",
265
+ "type": "array"
266
+ }
267
+ ],
268
+ "passed": {
269
+ "tags": [
270
+ "a",
271
+ "b"
272
+ ]
273
+ }
115
274
  },
116
- "expected": { "ok": true, "props": { "tags": ["a", "b"] } }
275
+ "expected": {
276
+ "ok": true,
277
+ "props": {
278
+ "tags": [
279
+ "a",
280
+ "b"
281
+ ]
282
+ }
283
+ }
117
284
  },
118
285
  {
119
286
  "id": "0011-object-satisfies-object",
120
287
  "title": "A map satisfies a declared object.",
121
288
  "since": "0.9.0",
122
- "tags": ["types"],
289
+ "tags": [
290
+ "types"
291
+ ],
123
292
  "input": {
124
- "declared": [{ "name": "meta", "type": "object" }],
125
- "passed": { "meta": { "k": "v" } }
293
+ "declared": [
294
+ {
295
+ "name": "meta",
296
+ "type": "object"
297
+ }
298
+ ],
299
+ "passed": {
300
+ "meta": {
301
+ "k": "v"
302
+ }
303
+ }
126
304
  },
127
- "expected": { "ok": true, "props": { "meta": { "k": "v" } } }
305
+ "expected": {
306
+ "ok": true,
307
+ "props": {
308
+ "meta": {
309
+ "k": "v"
310
+ }
311
+ }
312
+ }
128
313
  },
129
314
  {
130
315
  "id": "0012-no-declaration-and-no-props",
131
316
  "title": "A workflow that declares nothing, called with nothing, resolves to an empty map.",
132
317
  "since": "0.9.0",
133
- "tags": ["happy-path", "absence"],
134
- "input": { "declared": null, "passed": null },
135
- "expected": { "ok": true, "props": {} }
318
+ "tags": [
319
+ "happy-path",
320
+ "absence"
321
+ ],
322
+ "input": {
323
+ "declared": null,
324
+ "passed": null
325
+ },
326
+ "expected": {
327
+ "ok": true,
328
+ "props": {}
329
+ }
136
330
  },
137
331
  {
138
332
  "id": "0101-an-unknown-key-fails",
139
333
  "title": "A misspelled input name FAILS rather than sitting unread.",
140
334
  "since": "0.9.0",
141
- "tags": ["validation", "regression", "silent-failure"],
335
+ "tags": [
336
+ "validation",
337
+ "regression",
338
+ "silent-failure"
339
+ ],
142
340
  "input": {
143
- "declared": [{ "name": "topic", "type": "string" }],
144
- "passed": { "topik": "otters" }
341
+ "declared": [
342
+ {
343
+ "name": "topic",
344
+ "type": "string"
345
+ }
346
+ ],
347
+ "passed": {
348
+ "topik": "otters"
349
+ }
145
350
  },
146
- "expected": { "ok": false, "code": "unknown_input" }
351
+ "expected": {
352
+ "ok": false,
353
+ "code": "unknown_input"
354
+ }
147
355
  },
148
356
  {
149
357
  "id": "0102-props-passed-to-a-workflow-declaring-none-fails",
150
358
  "title": "Passing anything to a workflow that declares no inputs FAILS.",
151
359
  "since": "0.9.0",
152
- "tags": ["validation", "silent-failure"],
360
+ "tags": [
361
+ "validation",
362
+ "silent-failure"
363
+ ],
153
364
  "input": {
154
365
  "declared": null,
155
- "passed": { "topic": "otters" }
366
+ "passed": {
367
+ "topic": "otters"
368
+ }
156
369
  },
157
- "expected": { "ok": false, "code": "unknown_input" }
370
+ "expected": {
371
+ "ok": false,
372
+ "code": "unknown_input"
373
+ }
158
374
  },
159
375
  {
160
376
  "id": "0103-unknown-is-reported-before-missing-required",
161
377
  "title": "A caller who misspells a required input is told about the word they typed.",
162
378
  "since": "0.9.0",
163
- "tags": ["validation", "ordering"],
379
+ "tags": [
380
+ "validation",
381
+ "ordering"
382
+ ],
164
383
  "input": {
165
- "declared": [{ "name": "topic", "type": "string", "required": true }],
166
- "passed": { "topik": "otters" }
384
+ "declared": [
385
+ {
386
+ "name": "topic",
387
+ "type": "string",
388
+ "required": true
389
+ }
390
+ ],
391
+ "passed": {
392
+ "topik": "otters"
393
+ }
167
394
  },
168
- "expected": { "ok": false, "code": "unknown_input" }
395
+ "expected": {
396
+ "ok": false,
397
+ "code": "unknown_input"
398
+ }
169
399
  },
170
400
  {
171
401
  "id": "0104-missing-required-fails",
172
402
  "title": "A required input with no default and no value FAILS.",
173
403
  "since": "0.9.0",
174
- "tags": ["validation", "required"],
404
+ "tags": [
405
+ "validation",
406
+ "required"
407
+ ],
175
408
  "input": {
176
- "declared": [{ "name": "topic", "type": "string", "required": true }],
409
+ "declared": [
410
+ {
411
+ "name": "topic",
412
+ "type": "string",
413
+ "required": true
414
+ }
415
+ ],
177
416
  "passed": {}
178
417
  },
179
- "expected": { "ok": false, "code": "missing_required" }
418
+ "expected": {
419
+ "ok": false,
420
+ "code": "missing_required"
421
+ }
180
422
  },
181
423
  {
182
424
  "id": "0105-wrong-type-fails",
183
425
  "title": "A string supplied where a number is declared FAILS.",
184
426
  "since": "0.9.0",
185
- "tags": ["validation", "types"],
427
+ "tags": [
428
+ "validation",
429
+ "types"
430
+ ],
186
431
  "input": {
187
- "declared": [{ "name": "limit", "type": "number" }],
188
- "passed": { "limit": "ten" }
432
+ "declared": [
433
+ {
434
+ "name": "limit",
435
+ "type": "number"
436
+ }
437
+ ],
438
+ "passed": {
439
+ "limit": "ten"
440
+ }
189
441
  },
190
- "expected": { "ok": false, "code": "type_mismatch" }
442
+ "expected": {
443
+ "ok": false,
444
+ "code": "type_mismatch"
445
+ }
191
446
  },
192
447
  {
193
448
  "id": "0106-object-does-not-satisfy-array",
194
449
  "title": "A map supplied where an array is declared FAILS.",
195
450
  "since": "0.9.0",
196
- "tags": ["validation", "types", "trap"],
451
+ "tags": [
452
+ "validation",
453
+ "types",
454
+ "trap"
455
+ ],
197
456
  "input": {
198
- "declared": [{ "name": "tags", "type": "array" }],
199
- "passed": { "tags": { "0": "a" } }
457
+ "declared": [
458
+ {
459
+ "name": "tags",
460
+ "type": "array"
461
+ }
462
+ ],
463
+ "passed": {
464
+ "tags": {
465
+ "0": "a"
466
+ }
467
+ }
468
+ },
469
+ "expected": {
470
+ "ok": false,
471
+ "code": "type_mismatch"
200
472
  },
201
- "expected": { "ok": false, "code": "type_mismatch" }
473
+ "skip": {
474
+ "php": "Not representable in PHP. `json_decode('{\"0\":\"a\"}', true)` coerces the numeric STRING key to int 0, producing a list -- so the map this case describes cannot exist on that runtime and `array_is_list` correctly reports a list. The rule itself is pinned for every runtime by 0109, which uses a non-numeric key."
475
+ },
476
+ "notes": "Kept rather than rewritten because the divergence is worth recording: a JS host and a PHP host genuinely disagree about this value, and the reason is PHP's key coercion rather than either implementation being wrong."
202
477
  },
203
478
  {
204
479
  "id": "0107-array-does-not-satisfy-object",
205
480
  "title": "A list supplied where an object is declared FAILS.",
206
481
  "since": "0.9.0",
207
- "tags": ["validation", "types", "trap"],
482
+ "tags": [
483
+ "validation",
484
+ "types",
485
+ "trap"
486
+ ],
208
487
  "input": {
209
- "declared": [{ "name": "meta", "type": "object" }],
210
- "passed": { "meta": ["a"] }
488
+ "declared": [
489
+ {
490
+ "name": "meta",
491
+ "type": "object"
492
+ }
493
+ ],
494
+ "passed": {
495
+ "meta": [
496
+ "a"
497
+ ]
498
+ }
211
499
  },
212
- "expected": { "ok": false, "code": "type_mismatch" }
500
+ "expected": {
501
+ "ok": false,
502
+ "code": "type_mismatch"
503
+ }
213
504
  },
214
505
  {
215
506
  "id": "0108-null-does-not-satisfy-a-declared-type",
216
507
  "title": "An explicit null supplied where a string is declared FAILS.",
217
508
  "since": "0.9.0",
218
- "tags": ["validation", "types"],
509
+ "tags": [
510
+ "validation",
511
+ "types"
512
+ ],
219
513
  "input": {
220
- "declared": [{ "name": "topic", "type": "string" }],
221
- "passed": { "topic": null }
514
+ "declared": [
515
+ {
516
+ "name": "topic",
517
+ "type": "string"
518
+ }
519
+ ],
520
+ "passed": {
521
+ "topic": null
522
+ }
523
+ },
524
+ "expected": {
525
+ "ok": false,
526
+ "code": "type_mismatch"
527
+ }
528
+ },
529
+ {
530
+ "id": "0109-a-string-keyed-map-does-not-satisfy-array",
531
+ "title": "A map with a non-numeric key supplied where an array is declared FAILS.",
532
+ "since": "0.9.1",
533
+ "tags": [
534
+ "validation",
535
+ "types",
536
+ "trap"
537
+ ],
538
+ "notes": "The runnable half of 0106. A non-numeric key survives json_decode on PHP as a string-keyed array, so `array_is_list` reports false and every runtime agrees. This is the case that actually pins object-is-not-array across all three.",
539
+ "input": {
540
+ "declared": [
541
+ {
542
+ "name": "tags",
543
+ "type": "array"
544
+ }
545
+ ],
546
+ "passed": {
547
+ "tags": {
548
+ "a": 1
549
+ }
550
+ }
222
551
  },
223
- "expected": { "ok": false, "code": "type_mismatch" }
552
+ "expected": {
553
+ "ok": false,
554
+ "code": "type_mismatch"
555
+ }
224
556
  }
225
557
  ]
226
558
  }