@acid-cat/cli 0.8.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/README.md CHANGED
@@ -6,7 +6,9 @@ The AcidCat command line. npm package: `@acid-cat/cli`. It gives the `acid-cat`
6
6
  acid-cat check [--strict] <catalog.json ...> # check catalogs
7
7
  acid-cat format [--write] <catalog.json ...> # write catalogs in canonical form
8
8
  acid-cat view <catalog.json> <target> # write the view of one target
9
- acid-cat conform --mcp <url> --http <url> --cli "<command>" --webmcp <url> [--deadline <ms>] # test an implementation
9
+ acid-cat conform --mcp <url> [--mcp-handshake] --mcp-stdio "<command>" --http <url> --cli "<command>" --webmcp <url> [--deadline <ms>] # test an implementation
10
+ [--poll-buffer <n>] [--poll-lease-ms <ms>] [--webhook-principal <name> --webhook-cert <file> --webhook-key <file>]
11
+ [--webhook-other-principal <name>] [--webhook-min-ttl-ms <ms>] [--webhook-max-ttl-ms <ms>] [--webhook-max-subscriptions <n>]
10
12
  ```
11
13
 
12
14
  Install it as a development dependency, and run it with `pnpm exec acid-cat …`:
@@ -21,7 +23,7 @@ It needs Node.js 24 or later. `acid-cat conform --webmcp <url>` also needs [Play
21
23
 
22
24
  `check` tests a catalog against [`schemas/catalog.schema.json`](../../schemas/catalog.schema.json) and against the [catalog rules](../../spec/conformance.md#catalog) that the schema cannot express.
23
25
 
24
- - An **error** is a broken rule. Example: an audience that names an unknown target.
26
+ - An **error** is a broken rule. Examples: an audience that names an unknown target, an event payload that is not an object, or an id that is both a capability and an event.
25
27
  - A **warning** is a broken recommendation. Examples: a file that is not in [canonical form](../../spec/catalog.md#canonical-form), or a member with no `x-` prefix.
26
28
 
27
29
  The exit code is 0 if there are no errors. It is 1 if there are errors. With `--strict`, warnings also give the exit code 1.
@@ -30,18 +32,29 @@ The exit code is 0 if there are no errors. It is 1 if there are errors. With `--
30
32
 
31
33
  `format` writes a catalog in canonical form. Use it for catalogs that a catalog library did not write: Pkl output, generated files, or files that you edit by hand.
32
34
 
35
+ ## view
36
+
37
+ `view` writes the [view](../../spec/catalog.md#views) of one target: the capabilities and the events that the target can use, and the types that they use. A view with no events has no `events` member.
38
+
33
39
  ## conform
34
40
 
35
41
  `conform` tests a running implementation of the [conformance fixture](conformance/fixture.pkl) through its bindings (see [Conformance](../../spec/conformance.md#acid-cat-conform)):
36
42
 
37
- - **MCP:** the official MCP client, with revision `2026-07-28`, for tools and outcomes; raw requests for rejections.
38
- - **HTTP** and **CLI:** each outcome, each kind of call failure, availability, and rejections. For the CLI, also each kind of flag, including a repeated flag that gives one item of an array for each occurrence, or the whole array as JSON text, and the usage line of each command. An interrupt (`SIGINT`) during a call that holds gives exit code 130.
43
+ - **MCP through HTTP:** `--mcp <url>`. The official MCP client, with revision `2026-07-28`, for tools and outcomes; raw requests for rejections. With `--mcp-handshake`, or `mcpHandshake: true` in code, the checks also test the [`initialize` handshake of earlier revisions](../../spec/bindings/mcp.md#earlier-revisions-through-http): the client of revision `2025-11-25` tests the tools and outcomes again, and raw requests test `initialize` with no session and a notification.
44
+ - **MCP through stdio:** `--mcp-stdio "<command>"`, or `mcpStdio` in code. The checks start the server as a process. The official MCP client tests the tools and outcomes two times: with revision `2026-07-28`, and with the `initialize` handshake of revision `2025-11-25`. It also tests `notifications/tools/list_changed` and `notifications/cancelled`. Raw lines test the version negotiation, a line that is not JSON, a standard output with only JSON-RPC messages, and the end of standard input.
45
+ - **HTTP** and **CLI:** each outcome, each kind of call failure, availability, and rejections. For the CLI, also each kind of flag, including a repeated flag that gives one item of an array for each occurrence, or the whole array as JSON text, and the usage line of each command and of each event, with `[--cursor <string>] [--max-age-ms <integer>]`. An interrupt (`SIGINT`) during a call that holds gives exit code 130. `watch --cursor` starts after the cursor, and a `--max-age-ms` that is not an integer gives exit code 3.
39
46
  - **WebMCP:** the checks act as the WebMCP host. `--webmcp <url>` opens a page that serves the `app` target in Chromium, through [Playwright](https://playwright.dev), with a test `modelContext`. Install Playwright to use it: `pnpm add -D playwright`, then `pnpm exec playwright install chromium`. In code, `webmcp` can also be a function that installs the implementation on a host in the same process.
40
- - **Properties:** with fast-check, through each remote binding: concurrent calls each get exactly their own outcome, and a cancellation changes only its own call.
47
+ - **Events:** through HTTP, MCP with each transport, and the CLI. The checks examine the list of the events of the view, the call failures before the first occurrence (an event outside the view, and invalid input), each occurrence against `schemas/occurrence.schema.json` and the payload schema, the order of the occurrences, an occurrence with `truncated: true` (`fixture.ticks` with `gap`: `truncated` in the occurrence through HTTP and the CLI, `notifications/events/active` with `truncated: true` and the cursor before the event through MCP push, and a result with `truncated: true` through MCP poll), the cursor, each end of a subscription (complete and `internal`), and cancellation. Through MCP, they also examine `server/discover`, `events/list` with the delivery modes of the server, `notifications/events/active`, and the subscription id of each message. With `--mcp-handshake`, `initialize` must not declare the events extension. Through WebMCP, no event is a tool.
48
+ - **Poll delivery:** through MCP with each transport, when `events/list` gives `poll`. If not, the checks give a warning, and `events/poll` must answer `Unsupported`. The checks test each row of the table of [Poll](../../spec/bindings/mcp.md#poll): no cursor with no lease and with a lease, a cursor of the lease, a cursor of another lease with and without a lease of the key, a cursor of push delivery, and a cursor of another lease that has a cursor of the provider, which resumes after it. They also test the form of each result, `maxEvents` and `hasMore`, a `maxEvents` that is not valid, the failures before the first occurrence, which start no lease, and the error `-32603` after a defect. With `--poll-buffer <n>`, or `poll.bufferLimit` in code, they test a cursor older than the buffer: `n` is the buffer size of the deployment, from 1 to 97. With `--poll-lease-ms <ms>`, or `poll.leaseMs`, they test that a lease with no poll ends, and cancels its subscription. The lease time must be shorter than the deadline. Each check uses a new key in its input, so the leases of an earlier run do not change the result.
49
+ - **Webhook delivery:** through MCP over HTTP, with `--webhook-principal <name>`, `--webhook-cert <file>` and `--webhook-key <file>` together, or `webhook: { principal, cert, key }` in code, with the PEM text. The checks run an HTTPS receiver on `127.0.0.1` with this certificate. Configure the deployment under test for the test: it trusts the certificate, allows deliveries to private addresses, and gives the principal `<name>` to a request with the header `Authorization: Bearer <name>`. The checks test `Forbidden` with no principal, `-32602` for a URL that is not `https` and for a secret that is not valid, the verification and `challenge_failed`, the form of the result of `events/subscribe` and its subscription id, the headers, the signature and the body of each delivery, the order of the deliveries, a renewal that signs with the old and the new secret, `events/unsubscribe` and a second `events/unsubscribe`, a retry with the same `webhook-id` after a status 500, a 410 that abandons a delivery, the control message `gap` with the watermark before an occurrence with `truncated: true`, the control message `terminated`, and the lifetime that the server grants for a `ttlMs` below the minimum, above the maximum, `null` and absent.
50
+ - `--webhook-min-ttl-ms <ms>` and `--webhook-max-ttl-ms <ms>`, or `webhook.minTtlMs` and `webhook.maxTtlMs`, give the lifetimes of the deployment. The defaults are those of the specification: 60 000 and 3 600 000. With `--webhook-min-ttl-ms`, which must be shorter than the deadline, the checks also test that a subscription with no renewal ends at the end of its lifetime, and gets no more deliveries.
51
+ - `--webhook-max-subscriptions <n>`, or `webhook.maxSubscriptions`, gives the limit of subscriptions of one principal. With it, the checks subscribe until the server answers `ResourceExhausted` (`-32025`, with `data: { limit: "subscriptions", max }`), then renew one subscription.
52
+ - `--webhook-other-principal <name>`, or `webhook.otherPrincipal`, gives a second principal, for `Authorization: Bearer <name>`. With it, the check of the limit fills the subscriptions of this principal, which no other check uses, so it expects exactly `n` subscriptions, and then tests that the first principal can still subscribe. Without it, the check uses the first principal, which can still have subscriptions of other checks. The retry check waits for the first retry of the deployment, so the deadline must be longer than that wait. Without these options, a server that does not list `webhook` must answer `events/subscribe` and `events/unsubscribe` with `Unsupported`. Through stdio, it must always do so.
53
+ - **Properties:** with fast-check, through MCP with each transport and through HTTP: concurrent calls each get exactly their own outcome, and a cancellation changes only its own call.
41
54
 
42
- The checks wait for states that they can observe, not for times. Before a check cancels a call to `fixture.hold`, it waits until `fixture.held` gives the key of the call, or, for the CLI, until the marker file of the call exists. Each check has one deadline: `--deadline`, or `deadline` in code, in milliseconds. The default is 30 000. A check that does not settle before its deadline fails with "did not settle", and the check stops what it started. This cleanup has a deadline of the same length, so `conform` always returns. The deadline never makes a check pass.
55
+ The checks wait for states that they can observe, not for times. Before a check cancels a call to `fixture.hold`, it waits until `fixture.held` gives the key of the call, or, for the CLI, until the marker file of the call exists. In the same way, before a check signals or cancels a subscription to `fixture.watch`, it waits until `fixture.watching` gives its key, or until its marker file exists. After it cancels the subscription, it waits until the key leaves `fixture.watching`. Each check has one deadline: `--deadline`, or `deadline` in code, in milliseconds. The default is 30 000. A check that does not settle before its deadline fails with "did not settle", and the check stops what it started. This cleanup has a deadline of the same length, so `conform` always returns. The deadline never makes a check pass.
43
56
 
44
- The exit code is 1 if a check fails. Both AcidCat cores run `conform` in their tests. In code, use `conform()` and `serve()` from `@acid-cat/cli`. `createTestHost()` gives the WebMCP host of the checks.
57
+ A check of a recommendation (SHOULD) that fails gives a warning, marked `!`, and passes. The exit code is 1 if a check fails. Both AcidCat cores run `conform` in their tests. In code, use `conform()` and `serve()` from `@acid-cat/cli`. This `serve()` is for tests: it listens on `127.0.0.1`, on a free port, with plain HTTP. To serve an application, use the server of its core: `serve` from `@acid-cat/core-ts/node`, or `NodeServer` from `@acid-cat/core-effect`. `createTestHost()` gives the WebMCP host of the checks.
45
58
 
46
59
  ## Implementation
47
60
 
@@ -51,9 +64,10 @@ The command line is an [Effect](https://effect.website) program that uses `effec
51
64
  | --- | --- |
52
65
  | `src/catalog.ts` | The catalog rules, views and bundling |
53
66
  | `src/main.ts` | The commands |
54
- | `src/conform/` | The conformance checks, and `serve`, which serves web handlers on a local port |
67
+ | `src/conform/` | The conformance checks, and `serve`, which serves web handlers on a local port for the tests |
55
68
  | `conformance/` | The conformance fixture, in Pkl and in JSON |
56
69
  | `test/` | Tests. Run them with `moon run cli:test` |
57
- | `test/fixtures/` | The catalog of the tests: a bakery, in canonical form |
70
+ | `test/fixtures/` | The catalog of the tests: a bakery, in canonical form, with capabilities and an event |
71
+ | `test/fixtures/rejected/` | Catalogs that `check` must reject, written by hand |
58
72
 
59
73
  The canonical writer comes from [`@acid-cat/catalog`](../catalog/typescript).
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "acidcat": "0.1",
3
3
  "title": "AcidCat conformance fixture",
4
- "description": "One capability for each behavior that acid-cat conform tests.",
4
+ "description": "One capability or event for each behavior that acid-cat conform tests.",
5
5
  "targets": {
6
6
  "api": {
7
7
  "bindings": [
@@ -268,6 +268,41 @@
268
268
  "type": "object"
269
269
  }
270
270
  },
271
+ "fixture.signal": {
272
+ "description": "Give an occurrence of fixture.watch to each subscription that watches the key. Return the number of these subscriptions.",
273
+ "audience": [
274
+ "app",
275
+ "mcp",
276
+ "cli",
277
+ "api"
278
+ ],
279
+ "input": {
280
+ "properties": {
281
+ "key": {
282
+ "type": "string"
283
+ },
284
+ "value": {
285
+ "type": "string"
286
+ }
287
+ },
288
+ "required": [
289
+ "key",
290
+ "value"
291
+ ],
292
+ "type": "object"
293
+ },
294
+ "output": {
295
+ "properties": {
296
+ "delivered": {
297
+ "type": "integer"
298
+ }
299
+ },
300
+ "required": [
301
+ "delivered"
302
+ ],
303
+ "type": "object"
304
+ }
305
+ },
271
306
  "fixture.switch.set": {
272
307
  "description": "Turn the switch on or off. The switch controls the availability of fixture.toggle.",
273
308
  "audience": [
@@ -300,6 +335,137 @@
300
335
  "properties": {},
301
336
  "type": "object"
302
337
  }
338
+ },
339
+ "fixture.watching": {
340
+ "description": "Return the keys of the subscriptions to fixture.watch that are active now.",
341
+ "audience": [
342
+ "app",
343
+ "mcp",
344
+ "cli",
345
+ "api"
346
+ ],
347
+ "output": {
348
+ "properties": {
349
+ "keys": {
350
+ "items": {
351
+ "type": "string"
352
+ },
353
+ "type": "array"
354
+ }
355
+ },
356
+ "required": [
357
+ "keys"
358
+ ],
359
+ "type": "object"
360
+ },
361
+ "readOnly": true
362
+ }
363
+ },
364
+ "events": {
365
+ "fixture.broken": {
366
+ "description": "Give the occurrence n = 1, then fail with a defect.",
367
+ "audience": [
368
+ "app",
369
+ "mcp",
370
+ "cli",
371
+ "api"
372
+ ],
373
+ "payload": {
374
+ "properties": {
375
+ "n": {
376
+ "type": "integer"
377
+ }
378
+ },
379
+ "required": [
380
+ "n"
381
+ ],
382
+ "type": "object"
383
+ }
384
+ },
385
+ "fixture.secret": {
386
+ "description": "Only the app target can follow this event. It never gives an occurrence.",
387
+ "audience": [
388
+ "app"
389
+ ]
390
+ },
391
+ "fixture.ticks": {
392
+ "description": "Give the occurrences n = 1 to count, with the eventId and the cursor String(n), then end. With a cursor c, start at c + 1. With a gap g, the occurrence n = g has truncated: true, as if the source skipped occurrences before it. The key changes nothing: it gives the subscriptions of each test their own input.",
393
+ "audience": [
394
+ "app",
395
+ "mcp",
396
+ "cli",
397
+ "api"
398
+ ],
399
+ "input": {
400
+ "properties": {
401
+ "count": {
402
+ "maximum": 100,
403
+ "minimum": 0,
404
+ "type": "integer"
405
+ },
406
+ "gap": {
407
+ "maximum": 100,
408
+ "minimum": 1,
409
+ "type": "integer"
410
+ },
411
+ "key": {
412
+ "type": "string"
413
+ }
414
+ },
415
+ "required": [
416
+ "count"
417
+ ],
418
+ "type": "object"
419
+ },
420
+ "payload": {
421
+ "properties": {
422
+ "n": {
423
+ "type": "integer"
424
+ }
425
+ },
426
+ "required": [
427
+ "n"
428
+ ],
429
+ "type": "object"
430
+ }
431
+ },
432
+ "fixture.watch": {
433
+ "description": "Give an occurrence each time that fixture.signal gets the key. Never end. With a marker, write a file at that path when the subscription starts.",
434
+ "audience": [
435
+ "app",
436
+ "mcp",
437
+ "cli",
438
+ "api"
439
+ ],
440
+ "input": {
441
+ "properties": {
442
+ "key": {
443
+ "type": "string"
444
+ },
445
+ "marker": {
446
+ "type": "string"
447
+ }
448
+ },
449
+ "required": [
450
+ "key"
451
+ ],
452
+ "type": "object"
453
+ },
454
+ "payload": {
455
+ "properties": {
456
+ "key": {
457
+ "type": "string"
458
+ },
459
+ "value": {
460
+ "type": "string"
461
+ }
462
+ },
463
+ "required": [
464
+ "key",
465
+ "value"
466
+ ],
467
+ "type": "object"
468
+ }
303
469
  }
304
470
  }
305
471
  }
package/dist/catalog.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import catalog_schema_default from "./json/catalog.schema.json.js";
2
2
  import { isObject, isRecordOf, isStringArray } from "./json.js";
3
3
  import { canonicalText } from "@acid-cat/catalog";
4
- import { REFERENCE, bundle, schemasOf, viewOf as viewOf$1, visitObjects } from "@acid-cat/spec-ts";
4
+ import { REFERENCE, bundle, eventSchemasOf, schemasOf, viewOf as viewOf$1, visitObjects } from "@acid-cat/spec-ts";
5
5
  import { Result, Schema, SchemaIssue, SchemaRepresentation } from "effect";
6
6
  //#region src/catalog.ts
7
7
  /**
@@ -15,7 +15,8 @@ const KNOWN_MEMBERS = {
15
15
  "description",
16
16
  "targets",
17
17
  "$defs",
18
- "capabilities"
18
+ "capabilities",
19
+ "events"
19
20
  ],
20
21
  target: [
21
22
  "bindings",
@@ -30,6 +31,12 @@ const KNOWN_MEMBERS = {
30
31
  "failures",
31
32
  "readOnly",
32
33
  "state"
34
+ ],
35
+ event: [
36
+ "description",
37
+ "audience",
38
+ "input",
39
+ "payload"
33
40
  ]
34
41
  };
35
42
  /** The catalog schema (schemas/catalog.schema.json), imported as an Effect schema. */
@@ -48,11 +55,12 @@ const optional = (object, key, isMember) => !(key in object) || isMember(object[
48
55
  const isString = (value) => typeof value === "string";
49
56
  const isTarget = (value) => isObject(value) && isStringArray(value.bindings);
50
57
  const isCapability = (value) => isObject(value) && typeof value.description === "string" && optional(value, "audience", isStringArray) && optional(value, "input", isObject) && optional(value, "output", isObject) && optional(value, "state", isObject) && optional(value, "failures", (failures) => isRecordOf(failures, isObject)) && optional(value, "readOnly", (readOnly) => typeof readOnly === "boolean");
58
+ const isEvent = (value) => isObject(value) && typeof value.description === "string" && optional(value, "audience", isStringArray) && optional(value, "input", isObject) && optional(value, "payload", isObject);
51
59
  /**
52
60
  * True if the value has the members and the member types of a catalog. It does not check the catalog rules: `check`
53
61
  * does that.
54
62
  */
55
- const isCatalog = (value) => isObject(value) && typeof value.acidcat === "string" && optional(value, "title", isString) && optional(value, "description", isString) && isRecordOf(value.targets, isTarget) && optional(value, "$defs", (types) => isRecordOf(types, isObject)) && isRecordOf(value.capabilities, isCapability);
63
+ const isCatalog = (value) => isObject(value) && typeof value.acidcat === "string" && optional(value, "title", isString) && optional(value, "description", isString) && isRecordOf(value.targets, isTarget) && optional(value, "$defs", (types) => isRecordOf(types, isObject)) && isRecordOf(value.capabilities, isCapability) && optional(value, "events", (events) => isRecordOf(events, isEvent));
56
64
  const formatIssues = SchemaIssue.makeFormatterStandardSchemaV1();
57
65
  /** Check a catalog. When `text` is given, the file must also be in canonical form. */
58
66
  function check(document, text) {
@@ -79,30 +87,53 @@ function ruleErrors(catalog) {
79
87
  const errors = [];
80
88
  const types = catalog.$defs ?? {};
81
89
  for (const [name, type] of Object.entries(types)) errors.push(...referenceErrors(type, types).map((error) => `type ${name} ${error}`));
82
- for (const [id, capability] of Object.entries(catalog.capabilities)) {
83
- for (const target of capability.audience ?? []) if (!(target in catalog.targets)) errors.push(`${id}: the audience names ${target}, which is not a target`);
84
- for (const [member, schema] of schemasOf(capability)) {
90
+ const entries = [...Object.entries(catalog.capabilities).map(([id, capability]) => ({
91
+ id,
92
+ entry: capability,
93
+ schemas: schemasOf(capability)
94
+ })), ...Object.entries(catalog.events ?? {}).map(([id, event]) => ({
95
+ id,
96
+ entry: event,
97
+ schemas: eventSchemasOf(event)
98
+ }))];
99
+ for (const id of Object.keys(catalog.events ?? {})) if (id in catalog.capabilities) errors.push(`${id}: the id is both a capability and an event`);
100
+ for (const { id, entry, schemas } of entries) {
101
+ for (const target of entry.audience ?? []) if (!(target in catalog.targets)) errors.push(`${id}: the audience names ${target}, which is not a target`);
102
+ for (const [member, schema] of schemas) {
85
103
  const references = referenceErrors(schema, types);
86
104
  errors.push(...references.map((error) => `${id} ${member} ${error}`));
87
- if (member === "input" && references.length === 0 && bundle(schema, types).type !== "object") errors.push(`${id}: the input is not an object`);
105
+ if ((member === "input" || member === "payload") && references.length === 0 && bundle(schema, types).type !== "object") errors.push(`${id}: the ${member} is not an object`);
88
106
  }
89
107
  }
90
108
  return errors;
91
109
  }
110
+ /** The schemas of each capability and each event, with the id that holds them. */
111
+ function catalogSchemas(catalog) {
112
+ return [...Object.entries(catalog.capabilities).flatMap(([id, capability]) => schemasOf(capability).map(([member, schema]) => [
113
+ id,
114
+ member,
115
+ schema
116
+ ])), ...Object.entries(catalog.events ?? {}).flatMap(([id, event]) => eventSchemasOf(event).map(([member, schema]) => [
117
+ id,
118
+ member,
119
+ schema
120
+ ]))];
121
+ }
92
122
  /** Members that the specification does not define and that have no `x-` prefix. */
93
123
  function unknownMembers(catalog) {
94
124
  const unknown = (where, value, known) => Object.keys(value).filter((key) => !known.includes(key) && !key.startsWith("x-")).map((key) => `${where}: unknown member ${key}; give extensions an x- prefix`);
95
125
  return [
96
126
  ...unknown("the catalog", catalog, KNOWN_MEMBERS.catalog),
97
127
  ...Object.entries(catalog.targets).flatMap(([name, target]) => unknown(`target ${name}`, target, KNOWN_MEMBERS.target)),
98
- ...Object.entries(catalog.capabilities).flatMap(([id, capability]) => unknown(id, capability, KNOWN_MEMBERS.capability))
128
+ ...Object.entries(catalog.capabilities).flatMap(([id, capability]) => unknown(id, capability, KNOWN_MEMBERS.capability)),
129
+ ...Object.entries(catalog.events ?? {}).flatMap(([id, event]) => unknown(id, event, KNOWN_MEMBERS.event))
99
130
  ];
100
131
  }
101
132
  /** Schemas that cannot be imported as Effect schemas. They can still be valid JSON Schema, so this is a warning. */
102
133
  function schemaWarnings(catalog) {
103
134
  const definitions = catalog.$defs ?? {};
104
135
  const warnings = [];
105
- for (const [id, capability] of Object.entries(catalog.capabilities)) for (const [member, schema] of schemasOf(capability)) try {
136
+ for (const [id, member, schema] of catalogSchemas(catalog)) try {
106
137
  SchemaRepresentation.fromJsonSchemaMultiDocument({
107
138
  dialect: "draft-2020-12",
108
139
  schemas: [schema],
@@ -1,10 +1,12 @@
1
- import type { CatalogDocument, Outcome } from "@acid-cat/spec-ts";
1
+ import type { CatalogDocument, Occurrence, Outcome } from "@acid-cat/spec-ts";
2
2
  export interface Check {
3
3
  readonly binding: string;
4
4
  readonly name: string;
5
5
  /** The part of the specification that the check tests, for example `spec/bindings/mcp.md#tools`. */
6
6
  readonly rule: string;
7
7
  readonly passed: boolean;
8
+ /** `true` when the check tests a recommendation (SHOULD) that the implementation does not follow. The check still passes. */
9
+ readonly warning?: boolean | undefined;
8
10
  readonly detail?: string | undefined;
9
11
  }
10
12
  /** The conformance fixture: the catalog that each implementation serves. */
@@ -16,6 +18,13 @@ export declare const fixture: CatalogDocument;
16
18
  export declare const isOutcome: (value: unknown) => value is Outcome<unknown, unknown>;
17
19
  /** The ids of the capabilities in the view of a target of the fixture. */
18
20
  export declare const idsOf: (target: string) => string[];
21
+ /** The ids of the events in the view of a target of the fixture. */
22
+ export declare const eventIdsOf: (target: string) => string[];
23
+ /**
24
+ * The value as an occurrence of the event `id`: an occurrence in its JSON form (schemas/occurrence.schema.json), with
25
+ * the id as its name, and data that agrees with the payload schema. The check fails when the value is not one.
26
+ */
27
+ export declare function expectOccurrence(value: unknown, id: string): Occurrence;
19
28
  /** The time, in milliseconds, that each check has to settle when `conform` gets no other time. */
20
29
  export declare const DEADLINE = 30000;
21
30
  /**
@@ -25,16 +34,20 @@ export declare const DEADLINE = 30000;
25
34
  * same time, so that `conform` always returns.
26
35
  */
27
36
  export declare class Checks {
37
+ #private;
28
38
  readonly results: Check[];
29
39
  readonly binding: string;
30
40
  readonly deadline: number;
31
41
  constructor(binding: string, deadline: number);
32
42
  run(name: string, rule: string, body: (signal: AbortSignal) => Promise<void> | void): Promise<void>;
43
+ /** Run a check of a recommendation (SHOULD). When it fails, the result is a warning, and the check passes. */
44
+ should(name: string, rule: string, body: (signal: AbortSignal) => Promise<void> | void): Promise<void>;
33
45
  }
34
46
  /**
35
47
  * Read a state until `done` is true for it, and give that value. The read and the pause between two reads stop when the
36
48
  * signal of the check aborts.
37
49
  */
50
+ export declare function until<T, Done extends T>(what: string, read: () => Promise<T>, done: (value: T) => value is Done, signal: AbortSignal): Promise<Done>;
38
51
  export declare function until<T>(what: string, read: () => Promise<T>, done: (value: T) => boolean, signal: AbortSignal): Promise<T>;
39
52
  /** Settle as the promise does, or reject with the reason of the signal when it aborts first. */
40
53
  export declare function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T>;
@@ -1,6 +1,7 @@
1
1
  import { isObject } from "../json.js";
2
2
  import fixture_acidcat_default from "../json/fixture.acidcat.json.js";
3
- import { CALL_FAILURE_KINDS, viewOf } from "@acid-cat/spec-ts";
3
+ import { CALL_FAILURE_KINDS, isOccurrence, viewOf } from "@acid-cat/spec-ts";
4
+ import { Schema, SchemaRepresentation } from "effect";
4
5
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
5
6
  //#region src/conform/check.ts
6
7
  /** The result of one conformance check, and helpers to write checks. */
@@ -18,6 +19,39 @@ const isOutcome = (value) => {
18
19
  };
19
20
  /** The ids of the capabilities in the view of a target of the fixture. */
20
21
  const idsOf = (target) => Object.keys(viewOf(fixture, target).capabilities).sort();
22
+ /** The ids of the events in the view of a target of the fixture. */
23
+ const eventIdsOf = (target) => Object.keys(viewOf(fixture, target).events ?? {}).sort();
24
+ /** A check of a payload against the payload schema of an event of the fixture, made once for each event. */
25
+ const payloadChecks = /* @__PURE__ */ new Map();
26
+ /** True if the data agrees with the payload schema of the event. With no payload schema, the data is an empty object. */
27
+ function agreesWithPayload(id, data) {
28
+ let agrees = payloadChecks.get(id);
29
+ if (agrees === void 0) {
30
+ const payload = fixture.events?.[id]?.payload;
31
+ if (payload === void 0) agrees = (value) => isObject(value) && Object.keys(value).length === 0;
32
+ else {
33
+ const definitions = fixture.$defs ?? {};
34
+ const schema = SchemaRepresentation.fromJsonSchemaDocument({
35
+ dialect: "draft-2020-12",
36
+ schema: payload,
37
+ definitions
38
+ }, { patterns: "apply" });
39
+ agrees = Schema.is(Schema.toType(schema));
40
+ }
41
+ payloadChecks.set(id, agrees);
42
+ }
43
+ return agrees(data);
44
+ }
45
+ /**
46
+ * The value as an occurrence of the event `id`: an occurrence in its JSON form (schemas/occurrence.schema.json), with
47
+ * the id as its name, and data that agrees with the payload schema. The check fails when the value is not one.
48
+ */
49
+ function expectOccurrence(value, id) {
50
+ expect(isOccurrence(value), `expected an occurrence, got ${JSON.stringify(value)}`);
51
+ expectEqual(value.name, id, "the name of the occurrence");
52
+ expect(agreesWithPayload(id, value.data), `the data of the occurrence must agree with the payload schema of ${id}, got ${JSON.stringify(value.data)}`);
53
+ return value;
54
+ }
21
55
  /** The time, in milliseconds, that each check has to settle when `conform` gets no other time. */
22
56
  const DEADLINE = 3e4;
23
57
  /**
@@ -35,35 +69,52 @@ var Checks = class {
35
69
  this.deadline = deadline;
36
70
  }
37
71
  async run(name, rule, body) {
72
+ const failure = await this.#settle(body);
73
+ this.results.push(failure === void 0 ? {
74
+ binding: this.binding,
75
+ name,
76
+ rule,
77
+ passed: true
78
+ } : {
79
+ binding: this.binding,
80
+ name,
81
+ rule,
82
+ passed: false,
83
+ detail: failure
84
+ });
85
+ }
86
+ /** Run a check of a recommendation (SHOULD). When it fails, the result is a warning, and the check passes. */
87
+ async should(name, rule, body) {
88
+ const failure = await this.#settle(body);
89
+ this.results.push(failure === void 0 ? {
90
+ binding: this.binding,
91
+ name,
92
+ rule,
93
+ passed: true
94
+ } : {
95
+ binding: this.binding,
96
+ name,
97
+ rule,
98
+ passed: true,
99
+ warning: true,
100
+ detail: failure
101
+ });
102
+ }
103
+ /** Run the body of a check with its deadline. The result is the detail of the failure, or `undefined`. */
104
+ async #settle(body) {
38
105
  const controller = new AbortController();
39
106
  const timer = setTimeout(() => controller.abort(), this.deadline);
40
107
  try {
41
108
  await body(controller.signal);
42
- this.results.push({
43
- binding: this.binding,
44
- name,
45
- rule,
46
- passed: true
47
- });
109
+ return;
48
110
  } catch (error) {
49
111
  const message = error instanceof Error ? error.message : String(error);
50
- const detail = controller.signal.aborted ? `did not settle within ${this.deadline} ms: ${message}` : message;
51
- this.results.push({
52
- binding: this.binding,
53
- name,
54
- rule,
55
- passed: false,
56
- detail
57
- });
112
+ return controller.signal.aborted ? `did not settle within ${this.deadline} ms: ${message}` : message;
58
113
  } finally {
59
114
  clearTimeout(timer);
60
115
  }
61
116
  }
62
117
  };
63
- /**
64
- * Read a state until `done` is true for it, and give that value. The read and the pause between two reads stop when the
65
- * signal of the check aborts.
66
- */
67
118
  async function until(what, read, done, signal) {
68
119
  let last;
69
120
  try {
@@ -99,4 +150,4 @@ const sorted = (values) => [...values].sort();
99
150
  /** An origin that no deployment allows. */
100
151
  const FORBIDDEN_ORIGIN = "https://not-allowed.acid-cat.invalid";
101
152
  //#endregion
102
- export { Checks, DEADLINE, FORBIDDEN_ORIGIN, abortable, expect, expectEqual, fixture, idsOf, isOutcome, sorted, until };
153
+ export { Checks, DEADLINE, FORBIDDEN_ORIGIN, abortable, eventIdsOf, expect, expectEqual, expectOccurrence, fixture, idsOf, isOutcome, sorted, until };