@yolk-sdk/conformance 0.1.0-canary.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yolk SDK contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,316 @@
1
+ # @yolk-sdk/conformance
2
+
3
+ > **EXPERIMENTAL.** This package is new and its API may change in any canary release, beyond the
4
+ > usual canary instability.
5
+
6
+ A small Effect-only toolkit for recording real HTTP exchanges with outside services, replaying them
7
+ in tests (offline, fail closed), and injecting wire faults such as mid-stream drops, truncation,
8
+ and rate-limit responses. It also defines conformance cases (small Effect programs that each prove
9
+ one claim about how a service really behaves on the wire) and a runner that decides which cases may
10
+ run where and reports the results.
11
+
12
+ Canary APIs are unstable. Keep all `@yolk-sdk/*` packages on the same version.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pnpm add -D @yolk-sdk/conformance@canary effect@4.0.0-rc.115
18
+ ```
19
+
20
+ ## Subpaths
21
+
22
+ There is no root export. Import an explicit subpath:
23
+
24
+ | Subpath | Purpose |
25
+ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
26
+ | `@yolk-sdk/conformance/fixture` | `WireFixture` / `PortFixture` schemas and types, decoders, staleness helpers, secret scans, `redactPortPayload`, `syntheticPortCredentialParams` |
27
+ | `@yolk-sdk/conformance/replay` | `ReplayHttpClient.layer`, `makeReplayHttpClient`, `ReplayLedger`, `WireFault` |
28
+ | `@yolk-sdk/conformance/record` | `WireRecorder.layer`, `makeRecordingHttpClient`, `makeWireFixture` |
29
+ | `@yolk-sdk/conformance/case` | `defineConformanceCase`, `ConformanceCase`, `ConformanceSafety`, `expectConformance`, `expectEqual`, `ConformanceMismatch` |
30
+ | `@yolk-sdk/conformance/runner` | `runConformance`, `ConformanceTarget`, `ConformanceReport`, `conformanceSkipReason`, `formatConformanceReport` |
31
+
32
+ ## Fixtures
33
+
34
+ A `WireFixture` is plain data: an id, a `caseId`, `evidence` (`verified` for a live recording,
35
+ `unverified` for a synthetic placeholder), a `recordedAt` date (`YYYY-MM-DD`), a synthetic
36
+ `account` label, the `endpoint`, optional `model` / `note`, and a non-empty list of exchanges.
37
+ Each exchange has a request (method, absolute URL, allowlisted headers, JSON or text body) and a
38
+ response recorded losslessly in exactly one of three shapes:
39
+
40
+ - `body`: the whole body as a string, when it is valid UTF-8.
41
+ - `bodyBase64`: the whole body's exact bytes in base64, for anything else (for example a PDF).
42
+ - `chunks`: streamed network chunks with their original boundaries. Each entry is a string when
43
+ that chunk is valid UTF-8 on its own (empty chunks are `""`), or `{ base64 }` holding its exact
44
+ bytes (for example half of a multi-byte character). Plain strings stay the common, readable case.
45
+
46
+ **Fixtures must contain synthetic or scrubbed data only.** Never commit credentials, cookies,
47
+ customer names, or real account identifiers. Run `scanFixtureForSecrets` (or build fixtures with
48
+ `makeWireFixture`, which fails on any finding) before committing. The scan flags credential
49
+ headers (including `x-*-token` / `x-*-key` style names), bearer tokens, common API-key prefixes,
50
+ JWTs, private keys, credential query and form parameters, and non-empty string credential JSON
51
+ fields (`access_token`, `client_secret`, `password`, `api_key`, `token`, ...; numeric usage
52
+ counters such as `max_tokens` are not flagged). It covers metadata strings, URLs, headers, request
53
+ bodies, response bodies (including decodable `bodyBase64` text), each chunk, and the reassembled
54
+ stream, so a secret split across chunks is still found; JSON bodies and SSE `data:` payloads get
55
+ the credential-field scan. SSE framing with CRLF, LF, or bare CR line endings is recognized. It
56
+ reports locations, never the secret itself. The scan errs toward flagging: a field named exactly
57
+ `token` (for example an OpenAI `logprobs` entry) is flagged, so avoid recording logprobs. The scan
58
+ is a safety net, not a guarantee: review fixtures before publishing them.
59
+
60
+ `fixtureAgeDays(fixture, now)` and `isFixtureStale(fixture, now, maxAgeDays = 30)` help hosts
61
+ decide when a recording should be refreshed.
62
+
63
+ ### Port fixtures
64
+
65
+ Some outside services are reached through a host-provided port rather than HTTP (for example the
66
+ generic `EmailClient` port of `@yolk-sdk/connectors/email`, where the host speaks IMAP and SMTP).
67
+ A `PortFixture` records one such call as plain JSON:
68
+
69
+ ```ts
70
+ import type { PortFixture } from '@yolk-sdk/conformance/fixture'
71
+
72
+ const listed: PortFixture = {
73
+ id: 'example.list.synthetic',
74
+ port: 'ExampleClient',
75
+ method: 'listItems',
76
+ request: { folder: 'INBOX', limit: 50 },
77
+ response: { items: [] }
78
+ }
79
+ ```
80
+
81
+ It carries exactly one of `response` (any JSON value) or `failure` (`{ kind: 'expected' | 'error',
82
+ code, message, status? }`: `expected` is the port's value-level failure, `error` its typed error
83
+ channel). Without `observed` it is a synthetic placeholder (`unverified`); `observed: { account,
84
+ date }` records the live observation and makes it `verified` as of that date
85
+ (`conformanceFixtureEvidence`). Record requests through `redactPortPayload`, which drops
86
+ `credential(s)` and every credential field name at any depth (AWS-style `accessKeyId`,
87
+ `secretAccessKey`, and `sessionToken`, camelCase or snake_case, included), and run
88
+ `scanPortFixtureForSecrets` before committing: it applies the same token patterns and
89
+ credential-field scan as `scanFixtureForSecrets`, flags any non-null `credential(s)` field, and
90
+ flags credential query or form parameters inside every JSON string value, the note, and the failure
91
+ message (a signed URL such as an S3 presigned URL). Every `name=` is found on its own, so no later
92
+ parameter is skipped. The port scan's only exemption: the raw value, up to a structural boundary
93
+ (`&`, `#`, whitespace, `"`, `<`, `>`, or the end; a raw `?` or `'` and encoded delimiters such as
94
+ `%3F`, `%26`, `%23`, `%20` stay inside), percent-decoded, exactly equals that parameter's entry in
95
+ the frozen `syntheticPortCredentialParams` (the SigV4 `x-amz-signature` and `x-amz-credential`
96
+ placeholders). Anything else inside the value, another parameter name, or another scope is flagged;
97
+ `scanFixtureForSecrets` exempts nothing. A placeholder written by hand in prose and followed by `.`,
98
+ `,`, `)`, or `'` (a URL quoted in single quotes) is flagged too (fail-closed). Parameters with
99
+ escaped names (`&amp;` before them, percent-encoded names) are not found: port owners that can meet
100
+ them check them themselves. Replaying port fixtures is up to the port's owner (the email bridge
101
+ ships `makeEmailReplayBackend`); the runner accepts `PortFixture`s next to `WireFixture`s for its
102
+ fixture warnings.
103
+
104
+ ## Replay
105
+
106
+ ```ts
107
+ import { Effect } from 'effect'
108
+ import { HttpClient } from 'effect/unstable/http'
109
+ import { ReplayHttpClient, ReplayLedger, WireFault } from '@yolk-sdk/conformance/replay'
110
+
111
+ const program = Effect.gen(function* () {
112
+ const client = yield* HttpClient.HttpClient
113
+ // ... run the code under test against `client` ...
114
+ const entries = yield* (yield* ReplayLedger).entries
115
+ return entries
116
+ }).pipe(
117
+ Effect.provide(
118
+ ReplayHttpClient.layer([myFixture], {
119
+ faults: [WireFault.StatusOnAttempt({ attempt: 1, status: 500 })]
120
+ })
121
+ )
122
+ )
123
+ ```
124
+
125
+ - Requests match by method and normalized absolute URL (hash removed, query parameters sorted).
126
+ - Each recorded exchange is consumed once, in recorded order among exchanges with the same method
127
+ and URL, so create → update → read-back flows and repeated requests replay deterministically.
128
+ - A request with no remaining match fails closed with an `HttpClientError` (`TransportError`)
129
+ whose message names the method and URL, never headers or body. It is still written to the ledger
130
+ as `unmatched`. Faults never answer such requests.
131
+ - Replay emits exactly the recorded bytes: one `Uint8Array` per recorded chunk (including empty
132
+ ones), each produced only when the consumer pulls it; `bodyBase64` replays as the original bytes.
133
+ - `ReplayLedger.entries` records every request: method, URL, headers with credentials redacted,
134
+ body text and parsed JSON, attempt number per method + URL, how it was answered (`matched`,
135
+ `injected`, `unmatched`, or `invalid`), and the tag of any fault that applied.
136
+ `ReplayLedger.remaining` lists recorded exchanges not consumed yet.
137
+
138
+ Faults (`WireFault.*`) accept an optional `match: { method?, url? }`; `url` matches exactly after
139
+ normalization, or as a prefix when it ends with `*`:
140
+
141
+ | Fault | Effect |
142
+ | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
143
+ | `StatusOnAttempt` | Answer attempt N with a status/headers/body instead of consuming the recording (500-then-success, 429 + retry). Fires only when an unconsumed recorded exchange exists for the method + URL. |
144
+ | `FailAfterChunks` | Emit N chunks, then fail the body stream like a dropped connection under `FetchHttpClient`. |
145
+ | `TruncateAfterChunks` | Emit N chunks, then end the body cleanly (for example without a final event). |
146
+ | `HoldAfterChunks` | Emit N chunks, run `release`, then continue (observe progressive delivery). |
147
+
148
+ Chunk faults take an optional 1-based `attempt`. A chunk fault that cannot take effect fails the
149
+ request with an `HttpClientError` whose cause is `WireReplayInvalid` (ledger outcome `invalid`, no
150
+ fault tag, exchange left unconsumed) instead of silently doing nothing: any chunk fault matched
151
+ against a whole-body response, `TruncateAfterChunks` / `HoldAfterChunks` with `chunks` at or beyond
152
+ the recorded chunk count, or `FailAfterChunks` with `chunks` beyond it. The error reason is a
153
+ `TransportError`, which generic transient-retry policies retry; when a test runs through a retrying
154
+ client, also assert the ledger has no `invalid` outcome so a misconfigured fault cannot be retried
155
+ into a silent success.
156
+
157
+ ## Record
158
+
159
+ This package **never performs network I/O itself**. For live recording, the host supplies the real
160
+ `HttpClient` and the recorder wraps it:
161
+
162
+ ```ts
163
+ import { Effect, Layer } from 'effect'
164
+ import { FetchHttpClient } from 'effect/unstable/http'
165
+ import { makeWireFixture, WireRecorder } from '@yolk-sdk/conformance/record'
166
+
167
+ const recording = WireRecorder.layer().pipe(Layer.provide(FetchHttpClient.layer))
168
+
169
+ const fixture = Effect.gen(function* () {
170
+ // ... run the real client code once ...
171
+ const exchanges = yield* (yield* WireRecorder).drain
172
+ return yield* makeWireFixture({
173
+ id: 'example.case',
174
+ caseId: 'example.case',
175
+ evidence: 'verified',
176
+ recordedAt: '2026-01-01',
177
+ account: 'synthetic',
178
+ endpoint: 'https://api.example.test/v1/chat',
179
+ exchanges
180
+ })
181
+ })
182
+ ```
183
+
184
+ The recorder keeps only allowlisted headers (request: `content-type`, `accept`; response:
185
+ `content-type`, `retry-after`, `retry-after-ms`, and rate-limit headers). Credential headers such as
186
+ `authorization`, `cookie`, `set-cookie`, and token/key-bearing names such as `x-auth-token`,
187
+ `private-token`, or `x-*-key` are always dropped. `text/event-stream` responses are teed chunk by
188
+ chunk without changing what the caller receives; each network chunk is recorded standalone (text
189
+ when valid UTF-8, otherwise `{ base64 }`). Other bodies are recorded as `body` or `bodyBase64`.
190
+ `drain` fails with `WireRecordingIncomplete` if a request failed or a body was not read to the end.
191
+ A request still pending at `drain` time is reported by that drain and then dropped: if it finishes
192
+ later, it never appears in, or overwrites an entry of, a later drain.
193
+
194
+ Only record against accounts and data you are allowed to publish, and keep live recording out of CI.
195
+
196
+ ## Cases
197
+
198
+ A conformance case is a small, named, pure Effect program that proves one claim about how an outside
199
+ service really behaves on the wire, next to what its docs say. It states only what it needs (its
200
+ `R`), so the same case runs unchanged against replayed fixtures, an in-process emulator, a local
201
+ emulator process, or a real practice account: only the layers change.
202
+
203
+ ```ts
204
+ import { Effect } from 'effect'
205
+ import { HttpClient, HttpClientRequest } from 'effect/unstable/http'
206
+ import { defineConformanceCase, expectEqual } from '@yolk-sdk/conformance/case'
207
+
208
+ export const missingItemCase = defineConformanceCase({
209
+ id: 'example.items.missing',
210
+ safety: 'read',
211
+ docs: 'The docs say a missing item returns 404.',
212
+ wire: 'A missing item returns 404 with an empty JSON object.',
213
+ fixtures: ['example.items.missing.synthetic'],
214
+ run: Effect.gen(function* () {
215
+ const client = yield* HttpClient.HttpClient
216
+ const response = yield* client.execute(
217
+ HttpClientRequest.get('https://api.example.test/items/0')
218
+ )
219
+
220
+ yield* expectEqual(response.status, 404, 'expected 404 for a missing item')
221
+ })
222
+ })
223
+ ```
224
+
225
+ - `id` is dotted lower-case (`a-z`, `0-9`, inner hyphens; two or more segments).
226
+ - `safety` is `read` (only reads), `write-reversible` (writes but leaves the account as it found
227
+ it: it creates its own records and cleans up, or the write is rejected and changes nothing), or
228
+ `write-irreversible` (for example sending an email; never automated against a live account).
229
+ - `docs` is what the documentation claims; `wire` is what the wire actually does.
230
+ - `observed` (`{ account, date }`, a synthetic account label and `YYYY-MM-DD`) records the last time
231
+ a person watched the claim hold against the real service. Absent means unverified.
232
+ - `fixtures` lists the `WireFixture` ids that back replay of the case.
233
+ - `defineConformanceCase` validates this metadata and **throws** `ConformanceCaseInvalid` for an
234
+ invalid definition. Cases are module-level constants, so a bad one fails when its module loads
235
+ rather than mid-run.
236
+
237
+ `expectConformance(condition, message, details?)` and `expectEqual(actual, expected, message)`
238
+ fail with a `ConformanceMismatch` (`message`, optional JSON `expected` / `actual`). `expectEqual`
239
+ compares JSON values structurally (effect `Equal.equals`: array order matters, object key order
240
+ does not). Cases may also fail with their own errors or with the errors of the ports they use.
241
+
242
+ ## Runner
243
+
244
+ ```ts
245
+ import { Effect } from 'effect'
246
+ import { ReplayHttpClient } from '@yolk-sdk/conformance/replay'
247
+ import { formatConformanceReport, runConformance } from '@yolk-sdk/conformance/runner'
248
+
249
+ const text = await Effect.runPromise(
250
+ runConformance([missingItemCase], {
251
+ target: { kind: 'replay' },
252
+ fixtures: hostFixtures,
253
+ layer: testCase =>
254
+ ReplayHttpClient.layer(hostFixtures.filter(fixture => testCase.fixtures.includes(fixture.id)))
255
+ }).pipe(Effect.map(formatConformanceReport))
256
+ )
257
+ ```
258
+
259
+ `hostFixtures` is a placeholder for your own fixtures.
260
+
261
+ Safety policy (`conformanceSkipReason`):
262
+
263
+ | Target | `read` | `write-reversible` | `write-irreversible` |
264
+ | ---------------------------------- | ------ | --------------------------------------------------------------------- | ----------------------------------------------------------------------- |
265
+ | `replay`, `in-process`, `emulated` | runs | runs | runs |
266
+ | `live` (`account` required) | runs | runs only with `allowWrites: 'reversible'`, else `writes-not-allowed` | runs only if its exact id is in `allowIrreversible`, else `manual-only` |
267
+
268
+ `allowWrites` defaults to `'none'`. `allowIrreversible` lists the exact case ids a person
269
+ explicitly started and applies regardless of `allowWrites`. Nothing real happens on the other
270
+ targets, so every case runs there.
271
+
272
+ - `layer(testCase)` is called once per case that runs (never for skipped cases) and must provide
273
+ what that case needs. It is called inside the case's failure boundary and built with a fresh
274
+ memo map in its own scope, so state the layer allocates when it is built (replay consumption,
275
+ ledgers, emulator state) never leaks between cases, even if the factory returns the same `Layer`
276
+ value. Isolation therefore needs layers that allocate mutable state when built: a service
277
+ captured in a shared value (one `Layer.succeed(service, instance)` reused across cases) or
278
+ provided by the caller's environment is not rebuilt and stays shared. Whatever the layers still
279
+ need (for example a host's live `HttpClient`) becomes the requirement of the run.
280
+ - Cases with different error and requirement types can share a run; the case type is inferred as
281
+ their union.
282
+ - Each case runs under `Effect.exit`: typed failures, layer build failures, a throwing `layer`
283
+ factory, and defects become `failed` results and the run continues. Interruption is not captured:
284
+ interrupting the run, or a case interrupting itself, interrupts the whole run.
285
+ - `concurrency` defaults to 1 (live accounts are shared); results keep case order.
286
+ - `now` defaults to the Effect `Clock`; `maxFixtureAgeDays` defaults to 30.
287
+
288
+ A `ConformanceReport` has the `target` (kind, plus `account` for live), `startedAt` (ISO), one
289
+ result per case (`id`, `safety`, `status` `passed` / `failed` / `skipped`, `skipReason`, `failure`,
290
+ `durationMs`, `warnings`), and a `summary` count. `failure` is `{ kind, tag?, message }` with `kind`
291
+ `failure` or `defect`. `tag` is the error's `_tag` only when it is identifier-like
292
+ (`^[A-Za-z][A-Za-z0-9_]*$`) and not credential-shaped. A `ConformanceMismatch` keeps its
293
+ case-authored message with credential patterns redacted; every other failure, layer failure, and
294
+ defect message goes through `sanitizeConformanceMessage`, a best-effort sanitizer that redacts the
295
+ credential patterns shared with the fixture secret scan (bearer tokens, API-key prefixes, JWTs,
296
+ credential query/form parameters and field pairs, and credential header lines such as `Cookie:`,
297
+ `X-Api-Key:`, or `Proxy-Authorization:` to the end of the line), replaces JSON-looking spans
298
+ (balanced `{...}` / `[...]`) with `[json]`, collapses whitespace, and caps the length at 300
299
+ characters. Request bodies, headers, and mismatch `expected` / `actual` details are never copied
300
+ into the report; still keep secrets out of error messages.
301
+
302
+ Warnings are non-fatal and listed per case:
303
+
304
+ | Warning | When |
305
+ | -------------------- | ------------------------------------------------------------------------------------ |
306
+ | `unverified-case` | The case has no `observed` |
307
+ | `stale-observation` | `observed.date` is older than `maxFixtureAgeDays` (`ageDays`) |
308
+ | `unverified-fixture` | A referenced fixture has `evidence: 'unverified'` |
309
+ | `stale-fixture` | A referenced fixture is older than `maxFixtureAgeDays` (`ageDays`) |
310
+ | `missing-fixture` | A referenced fixture id is not in `fixtures` (only checked when `fixtures` is given) |
311
+
312
+ Live targets omit all fixture-level warnings (`unverified-fixture`, `stale-fixture`, and
313
+ `missing-fixture`) because fixtures are not used live; elsewhere fixture warnings need `fixtures`.
314
+ Case-level warnings always apply. `formatConformanceReport` prints one plain-text line per
315
+ case (status, id, safety, skip reason or failure, warnings) and a summary line, without colors.
316
+ `conformanceReportFailed` is true when any case failed; skipped cases never fail a report.
@@ -0,0 +1,89 @@
1
+ import { Effect } from "effect";
2
+ import * as Schema from "effect/Schema";
3
+
4
+ //#region src/case.d.ts
5
+ /**
6
+ * What a case does to the account it runs against:
7
+ *
8
+ * - `read`: only reads.
9
+ * - `write-reversible`: writes but leaves the account as it found it (it
10
+ * creates its own records and cleans them up, or the write is rejected and
11
+ * changes nothing).
12
+ * - `write-irreversible`: a write that cannot be undone (for example sending
13
+ * an email). Never automated against a live account.
14
+ */
15
+ declare const ConformanceSafety: Schema.Literals<readonly ["read", "write-reversible", "write-irreversible"]>;
16
+ type ConformanceSafety = typeof ConformanceSafety.Type;
17
+ /**
18
+ * Dotted lower-case case id: two or more segments of `a-z`, `0-9`, and inner
19
+ * hyphens, for example `vendor.stream.plain-text`.
20
+ */
21
+ declare const ConformanceCaseId: Schema.String;
22
+ /** When and where a person last watched the claim hold against the real service. */
23
+ declare const ConformanceObservation: Schema.Struct<{
24
+ /** Synthetic account label, for example `synthetic`; never a real account name. */readonly account: Schema.NonEmptyString; /** Calendar date of the observation (`YYYY-MM-DD`, UTC). */
25
+ readonly date: Schema.String;
26
+ }>;
27
+ type ConformanceObservation = typeof ConformanceObservation.Type;
28
+ /**
29
+ * One wire claim. `run` succeeds when the claim holds and fails (with a
30
+ * `ConformanceMismatch`, its own error, or a port's error) when it does not.
31
+ */
32
+ type ConformanceCase<E = never, R = never> = {
33
+ /** Dotted lower-case id (see `ConformanceCaseId`). */readonly id: string;
34
+ readonly title?: string;
35
+ readonly safety: ConformanceSafety; /** What the service's documentation claims. */
36
+ readonly docs: string; /** What the wire actually does (the claim this case proves). */
37
+ readonly wire: string; /** Last live observation. Absent means the claim is unverified against the real service. */
38
+ readonly observed?: ConformanceObservation; /** Ids of the fixtures (`WireFixture`s or `PortFixture`s) that back replay of this case. */
39
+ readonly fixtures: ReadonlyArray<string>;
40
+ readonly run: Effect.Effect<void, E, R>;
41
+ };
42
+ declare const ConformanceCaseInvalid_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }>) => import("effect/Cause").YieldableError & {
43
+ readonly _tag: "ConformanceCaseInvalid";
44
+ } & Readonly<A>;
45
+ /**
46
+ * Thrown by `defineConformanceCase` for an invalid definition. A programmer
47
+ * error, surfaced when the defining module loads (like `makeTool`).
48
+ */
49
+ declare class ConformanceCaseInvalid extends ConformanceCaseInvalid_base<{
50
+ readonly caseId: string;
51
+ readonly reason: string;
52
+ }> {
53
+ get message(): string;
54
+ }
55
+ /**
56
+ * Define a conformance case. The metadata (id format, safety, non-empty
57
+ * `docs`/`wire`, observation date, fixture ids) is validated here and an
58
+ * invalid definition throws `ConformanceCaseInvalid`: definitions are
59
+ * module-level constants, so a bad one fails fast when the module loads
60
+ * instead of surfacing mid-run.
61
+ */
62
+ declare const defineConformanceCase: <E = never, R = never>(spec: ConformanceCase<E, R>) => ConformanceCase<E, R>;
63
+ declare const ConformanceMismatch_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P] }>) => import("effect/Cause").YieldableError & {
64
+ readonly _tag: "ConformanceMismatch";
65
+ } & Readonly<A>;
66
+ /**
67
+ * A wire claim did not hold. Model-free: `expected` / `actual` are optional
68
+ * JSON values chosen by the case author; keep them small and synthetic.
69
+ */
70
+ declare class ConformanceMismatch extends ConformanceMismatch_base<{
71
+ readonly message: string;
72
+ readonly expected?: Schema.Json;
73
+ readonly actual?: Schema.Json;
74
+ }> {}
75
+ type ConformanceMismatchDetails = {
76
+ readonly expected?: Schema.Json;
77
+ readonly actual?: Schema.Json;
78
+ };
79
+ /** Succeed when `condition` holds; otherwise fail with a `ConformanceMismatch`. */
80
+ declare const expectConformance: (condition: boolean, message: string, details?: ConformanceMismatchDetails) => Effect.Effect<void, ConformanceMismatch>;
81
+ /**
82
+ * Succeed when `actual` and `expected` are structurally equal JSON values
83
+ * (effect `Equal.equals`: same primitives, same array order, same object keys
84
+ * and values); otherwise fail with a `ConformanceMismatch` carrying both.
85
+ */
86
+ declare const expectEqual: (actual: Schema.Json, expected: Schema.Json, message: string) => Effect.Effect<void, ConformanceMismatch>;
87
+ //#endregion
88
+ export { ConformanceCase, ConformanceCaseId, ConformanceCaseInvalid, ConformanceMismatch, ConformanceMismatchDetails, ConformanceObservation, ConformanceSafety, defineConformanceCase, expectConformance, expectEqual };
89
+ //# sourceMappingURL=case.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"case.d.mts","names":[],"sources":["../src/case.ts"],"mappings":";;;;AA0B6D;AAM7D;;;;AAEC;AAKD;;;;AAb6D,cAFhD,iBAAA,EAAiB,MAAA,CAAA,QAAA;AAAA,KAElB,iBAAA,UAA2B,iBAAA,CAAkB,IAAI;;;;;cAMhD,iBAAA,EAAiB,MAAA,CAAA,MAE7B;;cAKY,sBAAA,EAAsB,MAAA,CAAA,MAAA;;;;KAOvB,sBAAA,UAAgC,sBAAA,CAAuB,IAAI;;;;AAAA;KAM3D,eAAA;EAAe,+DAEhB,EAAA;EAAA,SACA,KAAA;EAAA,SACA,MAAA,EAAQ,iBAAA,EAQE;EAAA,SANV,IAAA,UAO4B;EAAA,SAL5B,IAAA,UAKkB;EAAA,SAHlB,QAAA,GAAW,sBAAA,EAVM;EAAA,SAYjB,QAAA,EAAU,aAAA;EAAA,SACV,GAAA,EAAK,MAAA,CAAO,MAAA,OAAa,CAAA,EAAG,CAAA;AAAA;AAAA,cACtC,2BAAA;;;;;;;cAkBY,sBAAA,SAA+B,2BAAA;EAAA,SACjC,MAAA;EAAA,SACA,MAAA;AAAA;EAAA,IAEI,OAAA,CAAA;AAAA;;AAvByB;AACvC;;;;;cAkCY,qBAAA,yBACX,IAAA,EAAM,eAAA,CAAgB,CAAA,EAAG,CAAA,MACxB,eAAA,CAAgB,CAAA,EAAG,CAAA;AAAA,cAYrB,wBAAA;;;;;;;cAMY,mBAAA,SAA4B,wBAAA;EAAA,SAC9B,OAAA;EAAA,SACA,QAAA,GAAW,MAAA,CAAO,IAAA;EAAA,SAClB,MAAA,GAAS,MAAA,CAAO,IAAA;AAAA;AAAA,KAGf,0BAAA;EAAA,SACD,QAAA,GAAW,MAAA,CAAO,IAAA;EAAA,SAClB,MAAA,GAAS,MAAA,CAAO,IAAI;AAAA;;cAOlB,iBAAA,GACX,SAAA,WACA,OAAA,UACA,OAAA,GAAU,0BAAA,KACT,MAAA,CAAO,MAAA,OAAa,mBAAA;;;;;AAvDvB;cA+Da,WAAA,GACX,MAAA,EAAQ,MAAA,CAAO,IAAA,EACf,QAAA,EAAU,MAAA,CAAO,IAAA,EACjB,OAAA,aACC,MAAA,CAAO,MAAA,OAAa,mBAAA"}
package/dist/case.mjs ADDED
@@ -0,0 +1,101 @@
1
+ import { Data, Effect, Equal, Result } from "effect";
2
+ import * as Schema from "effect/Schema";
3
+ //#region src/case.ts
4
+ /**
5
+ * Conformance cases: small, named, pure Effect programs that each prove one
6
+ * claim about how an outside service really behaves on the wire.
7
+ *
8
+ * A case only states what it needs (its `R`); the same case runs unchanged
9
+ * against replayed fixtures, an in-process emulator, a local emulator
10
+ * process, or a real practice account. Only the layers a host provides
11
+ * change. See `@yolk-sdk/conformance/runner` for the safety policy.
12
+ *
13
+ * @experimental
14
+ */
15
+ /**
16
+ * What a case does to the account it runs against:
17
+ *
18
+ * - `read`: only reads.
19
+ * - `write-reversible`: writes but leaves the account as it found it (it
20
+ * creates its own records and cleans them up, or the write is rejected and
21
+ * changes nothing).
22
+ * - `write-irreversible`: a write that cannot be undone (for example sending
23
+ * an email). Never automated against a live account.
24
+ */
25
+ const ConformanceSafety = Schema.Literals([
26
+ "read",
27
+ "write-reversible",
28
+ "write-irreversible"
29
+ ]);
30
+ /**
31
+ * Dotted lower-case case id: two or more segments of `a-z`, `0-9`, and inner
32
+ * hyphens, for example `vendor.stream.plain-text`.
33
+ */
34
+ const ConformanceCaseId = Schema.String.check(Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)+$/));
35
+ const CalendarDate = Schema.String.check(Schema.isPattern(/^\d{4}-\d{2}-\d{2}$/));
36
+ /** When and where a person last watched the claim hold against the real service. */
37
+ const ConformanceObservation = Schema.Struct({
38
+ /** Synthetic account label, for example `synthetic`; never a real account name. */
39
+ account: Schema.NonEmptyString,
40
+ /** Calendar date of the observation (`YYYY-MM-DD`, UTC). */
41
+ date: CalendarDate
42
+ });
43
+ const ConformanceCaseMetadata = Schema.Struct({
44
+ id: ConformanceCaseId,
45
+ title: Schema.optionalKey(Schema.NonEmptyString),
46
+ safety: ConformanceSafety,
47
+ docs: Schema.NonEmptyString,
48
+ wire: Schema.NonEmptyString,
49
+ observed: Schema.optionalKey(ConformanceObservation),
50
+ fixtures: Schema.Array(Schema.NonEmptyString)
51
+ });
52
+ const validateMetadata = Schema.decodeUnknownResult(ConformanceCaseMetadata);
53
+ /**
54
+ * Thrown by `defineConformanceCase` for an invalid definition. A programmer
55
+ * error, surfaced when the defining module loads (like `makeTool`).
56
+ */
57
+ var ConformanceCaseInvalid = class extends Data.TaggedError("ConformanceCaseInvalid") {
58
+ get message() {
59
+ return `Invalid conformance case ${JSON.stringify(this.caseId)}: ${this.reason}`;
60
+ }
61
+ };
62
+ /**
63
+ * Define a conformance case. The metadata (id format, safety, non-empty
64
+ * `docs`/`wire`, observation date, fixture ids) is validated here and an
65
+ * invalid definition throws `ConformanceCaseInvalid`: definitions are
66
+ * module-level constants, so a bad one fails fast when the module loads
67
+ * instead of surfacing mid-run.
68
+ */
69
+ const defineConformanceCase = (spec) => {
70
+ const { run: _run, ...metadata } = spec;
71
+ const result = validateMetadata(metadata);
72
+ if (Result.isFailure(result)) throw new ConformanceCaseInvalid({
73
+ caseId: spec.id,
74
+ reason: new Schema.SchemaError(result.failure.issue).message
75
+ });
76
+ return spec;
77
+ };
78
+ /**
79
+ * A wire claim did not hold. Model-free: `expected` / `actual` are optional
80
+ * JSON values chosen by the case author; keep them small and synthetic.
81
+ */
82
+ var ConformanceMismatch = class extends Data.TaggedError("ConformanceMismatch") {};
83
+ const mismatch = (message, details = {}) => new ConformanceMismatch({
84
+ message,
85
+ ...details
86
+ });
87
+ /** Succeed when `condition` holds; otherwise fail with a `ConformanceMismatch`. */
88
+ const expectConformance = (condition, message, details) => condition ? Effect.void : Effect.fail(mismatch(message, details));
89
+ /**
90
+ * Succeed when `actual` and `expected` are structurally equal JSON values
91
+ * (effect `Equal.equals`: same primitives, same array order, same object keys
92
+ * and values); otherwise fail with a `ConformanceMismatch` carrying both.
93
+ */
94
+ const expectEqual = (actual, expected, message) => expectConformance(Equal.equals(actual, expected), message, {
95
+ expected,
96
+ actual
97
+ });
98
+ //#endregion
99
+ export { ConformanceCaseId, ConformanceCaseInvalid, ConformanceMismatch, ConformanceObservation, ConformanceSafety, defineConformanceCase, expectConformance, expectEqual };
100
+
101
+ //# sourceMappingURL=case.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"case.mjs","names":[],"sources":["../src/case.ts"],"sourcesContent":["/**\n * Conformance cases: small, named, pure Effect programs that each prove one\n * claim about how an outside service really behaves on the wire.\n *\n * A case only states what it needs (its `R`); the same case runs unchanged\n * against replayed fixtures, an in-process emulator, a local emulator\n * process, or a real practice account. Only the layers a host provides\n * change. See `@yolk-sdk/conformance/runner` for the safety policy.\n *\n * @experimental\n */\nimport { Data, Effect, Equal, Result } from 'effect'\nimport * as Schema from 'effect/Schema'\n\n/**\n * What a case does to the account it runs against:\n *\n * - `read`: only reads.\n * - `write-reversible`: writes but leaves the account as it found it (it\n * creates its own records and cleans them up, or the write is rejected and\n * changes nothing).\n * - `write-irreversible`: a write that cannot be undone (for example sending\n * an email). Never automated against a live account.\n */\nexport const ConformanceSafety = Schema.Literals(['read', 'write-reversible', 'write-irreversible'])\n\nexport type ConformanceSafety = typeof ConformanceSafety.Type\n\n/**\n * Dotted lower-case case id: two or more segments of `a-z`, `0-9`, and inner\n * hyphens, for example `vendor.stream.plain-text`.\n */\nexport const ConformanceCaseId = Schema.String.check(\n Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*(?:\\.[a-z0-9]+(?:-[a-z0-9]+)*)+$/)\n)\n\nconst CalendarDate = Schema.String.check(Schema.isPattern(/^\\d{4}-\\d{2}-\\d{2}$/))\n\n/** When and where a person last watched the claim hold against the real service. */\nexport const ConformanceObservation = Schema.Struct({\n /** Synthetic account label, for example `synthetic`; never a real account name. */\n account: Schema.NonEmptyString,\n /** Calendar date of the observation (`YYYY-MM-DD`, UTC). */\n date: CalendarDate\n})\n\nexport type ConformanceObservation = typeof ConformanceObservation.Type\n\n/**\n * One wire claim. `run` succeeds when the claim holds and fails (with a\n * `ConformanceMismatch`, its own error, or a port's error) when it does not.\n */\nexport type ConformanceCase<E = never, R = never> = {\n /** Dotted lower-case id (see `ConformanceCaseId`). */\n readonly id: string\n readonly title?: string\n readonly safety: ConformanceSafety\n /** What the service's documentation claims. */\n readonly docs: string\n /** What the wire actually does (the claim this case proves). */\n readonly wire: string\n /** Last live observation. Absent means the claim is unverified against the real service. */\n readonly observed?: ConformanceObservation\n /** Ids of the fixtures (`WireFixture`s or `PortFixture`s) that back replay of this case. */\n readonly fixtures: ReadonlyArray<string>\n readonly run: Effect.Effect<void, E, R>\n}\n\nconst ConformanceCaseMetadata = Schema.Struct({\n id: ConformanceCaseId,\n title: Schema.optionalKey(Schema.NonEmptyString),\n safety: ConformanceSafety,\n docs: Schema.NonEmptyString,\n wire: Schema.NonEmptyString,\n observed: Schema.optionalKey(ConformanceObservation),\n fixtures: Schema.Array(Schema.NonEmptyString)\n})\n\nconst validateMetadata = Schema.decodeUnknownResult(ConformanceCaseMetadata)\n\n/**\n * Thrown by `defineConformanceCase` for an invalid definition. A programmer\n * error, surfaced when the defining module loads (like `makeTool`).\n */\nexport class ConformanceCaseInvalid extends Data.TaggedError('ConformanceCaseInvalid')<{\n readonly caseId: string\n readonly reason: string\n}> {\n override get message(): string {\n return `Invalid conformance case ${JSON.stringify(this.caseId)}: ${this.reason}`\n }\n}\n\n/**\n * Define a conformance case. The metadata (id format, safety, non-empty\n * `docs`/`wire`, observation date, fixture ids) is validated here and an\n * invalid definition throws `ConformanceCaseInvalid`: definitions are\n * module-level constants, so a bad one fails fast when the module loads\n * instead of surfacing mid-run.\n */\nexport const defineConformanceCase = <E = never, R = never>(\n spec: ConformanceCase<E, R>\n): ConformanceCase<E, R> => {\n const { run: _run, ...metadata } = spec\n const result = validateMetadata(metadata)\n\n if (Result.isFailure(result)) {\n throw new ConformanceCaseInvalid({\n caseId: spec.id,\n reason: new Schema.SchemaError(result.failure.issue).message\n })\n }\n\n return spec\n}\n\n/**\n * A wire claim did not hold. Model-free: `expected` / `actual` are optional\n * JSON values chosen by the case author; keep them small and synthetic.\n */\nexport class ConformanceMismatch extends Data.TaggedError('ConformanceMismatch')<{\n readonly message: string\n readonly expected?: Schema.Json\n readonly actual?: Schema.Json\n}> {}\n\nexport type ConformanceMismatchDetails = {\n readonly expected?: Schema.Json\n readonly actual?: Schema.Json\n}\n\nconst mismatch = (message: string, details: ConformanceMismatchDetails = {}) =>\n new ConformanceMismatch({ message, ...details })\n\n/** Succeed when `condition` holds; otherwise fail with a `ConformanceMismatch`. */\nexport const expectConformance = (\n condition: boolean,\n message: string,\n details?: ConformanceMismatchDetails\n): Effect.Effect<void, ConformanceMismatch> =>\n condition ? Effect.void : Effect.fail(mismatch(message, details))\n\n/**\n * Succeed when `actual` and `expected` are structurally equal JSON values\n * (effect `Equal.equals`: same primitives, same array order, same object keys\n * and values); otherwise fail with a `ConformanceMismatch` carrying both.\n */\nexport const expectEqual = (\n actual: Schema.Json,\n expected: Schema.Json,\n message: string\n): Effect.Effect<void, ConformanceMismatch> =>\n expectConformance(Equal.equals(actual, expected), message, { expected, actual })\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAwBA,MAAa,oBAAoB,OAAO,SAAS;CAAC;CAAQ;CAAoB;AAAoB,CAAC;;;;;AAQnG,MAAa,oBAAoB,OAAO,OAAO,MAC7C,OAAO,UAAU,2DAA2D,CAC9E;AAEA,MAAM,eAAe,OAAO,OAAO,MAAM,OAAO,UAAU,qBAAqB,CAAC;;AAGhF,MAAa,yBAAyB,OAAO,OAAO;;CAElD,SAAS,OAAO;;CAEhB,MAAM;AACR,CAAC;AAwBD,MAAM,0BAA0B,OAAO,OAAO;CAC5C,IAAI;CACJ,OAAO,OAAO,YAAY,OAAO,cAAc;CAC/C,QAAQ;CACR,MAAM,OAAO;CACb,MAAM,OAAO;CACb,UAAU,OAAO,YAAY,sBAAsB;CACnD,UAAU,OAAO,MAAM,OAAO,cAAc;AAC9C,CAAC;AAED,MAAM,mBAAmB,OAAO,oBAAoB,uBAAuB;;;;;AAM3E,IAAa,yBAAb,cAA4C,KAAK,YAAY,wBAAwB,EAGlF;CACD,IAAa,UAAkB;EAC7B,OAAO,4BAA4B,KAAK,UAAU,KAAK,MAAM,EAAE,IAAI,KAAK;CAC1E;AACF;;;;;;;;AASA,MAAa,yBACX,SAC0B;CAC1B,MAAM,EAAE,KAAK,MAAM,GAAG,aAAa;CACnC,MAAM,SAAS,iBAAiB,QAAQ;CAExC,IAAI,OAAO,UAAU,MAAM,GACzB,MAAM,IAAI,uBAAuB;EAC/B,QAAQ,KAAK;EACb,QAAQ,IAAI,OAAO,YAAY,OAAO,QAAQ,KAAK,EAAE;CACvD,CAAC;CAGH,OAAO;AACT;;;;;AAMA,IAAa,sBAAb,cAAyC,KAAK,YAAY,qBAAqB,EAI5E,CAAC;AAOJ,MAAM,YAAY,SAAiB,UAAsC,CAAC,MACxE,IAAI,oBAAoB;CAAE;CAAS,GAAG;AAAQ,CAAC;;AAGjD,MAAa,qBACX,WACA,SACA,YAEA,YAAY,OAAO,OAAO,OAAO,KAAK,SAAS,SAAS,OAAO,CAAC;;;;;;AAOlE,MAAa,eACX,QACA,UACA,YAEA,kBAAkB,MAAM,OAAO,QAAQ,QAAQ,GAAG,SAAS;CAAE;CAAU;AAAO,CAAC"}