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