@openwop/openwop-conformance 1.152.0 → 1.154.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +9 -0
  3. package/dist/cli.js +7 -2
  4. package/dist/lib/requirement-ids.js +100 -0
  5. package/dist/lib/scenario-disposition.js +22 -0
  6. package/package.json +33 -2
  7. package/requirement-aliases.json +4 -0
  8. package/requirements.json +23554 -0
  9. package/schemas/CORPUS-STAMP.json +99 -3
  10. package/src/cli.ts +7 -5
  11. package/src/global-setup.ts +13 -0
  12. package/src/lib/corpus-stamp.ts +125 -0
  13. package/src/lib/requirement-ids.ts +111 -0
  14. package/src/lib/scenario-disposition.ts +22 -0
  15. package/src/setup.ts +84 -5
  16. package/src/lib/capabilities-auth-subject-link.test.ts +0 -103
  17. package/src/lib/fork-availability.test.ts +0 -69
  18. package/src/lib/global-setup.test.ts +0 -76
  19. package/src/lib/grpc-framing.test.ts +0 -96
  20. package/src/lib/oidc-issuer.test.ts +0 -328
  21. package/src/lib/otel-collector-grpc.test.ts +0 -191
  22. package/src/lib/otel-collector.test.ts +0 -303
  23. package/src/lib/otlp-protobuf.test.ts +0 -461
  24. package/src/lib/polling.test.ts +0 -80
  25. package/src/lib/requirement-ledger.test.ts +0 -75
  26. package/src/lib/risk-disposition.test.ts +0 -91
  27. package/src/lib/saml-idp.test.ts +0 -127
  28. package/src/lib/spec-coherence-registry.test.ts +0 -155
  29. package/src/lib/webhook-receiver.test.ts +0 -144
  30. package/src/scenarios/artifact-schema-compile-bounded.test.ts +0 -126
  31. package/src/scenarios/artifact-type-legacy-ids.test.ts +0 -124
  32. package/src/scenarios/capability-example-root-layout.test.ts +0 -272
  33. package/src/scenarios/certification-floor-enforcement.test.ts +0 -204
  34. package/src/scenarios/chain-subchain-unsupported-refused.test.ts +0 -70
  35. package/src/scenarios/compensation-profile.test.ts +0 -340
  36. package/src/scenarios/core-manifest-and-extension-registry.test.ts +0 -250
  37. package/src/scenarios/discovery-canonical-family-no-shadow.test.ts +0 -219
  38. package/src/scenarios/edge-condition-truthy-falsy.test.ts +0 -108
  39. package/src/scenarios/effect-identity-composition.test.ts +0 -129
  40. package/src/scenarios/effect-identity-cross-scope.test.ts +0 -82
  41. package/src/scenarios/error-envelope-canonical-shape.test.ts +0 -64
  42. package/src/scenarios/form-content-packs.test.ts +0 -415
  43. package/src/scenarios/multi-region-effect-vocabulary.test.ts +0 -175
  44. package/src/scenarios/normative-example-extraction.test.ts +0 -242
  45. package/src/scenarios/openapi-asyncapi-sdk-parity.test.ts +0 -309
  46. package/src/scenarios/pack-manifest-extensions.test.ts +0 -203
  47. package/src/scenarios/protocol-version-grammar.test.ts +0 -119
  48. package/src/scenarios/registry-declarative-kinds.test.ts +0 -121
  49. package/src/scenarios/rfc-0147-self-audit.test.ts +0 -104
  50. package/src/scenarios/rfc-lifecycle-coherence.test.ts +0 -215
  51. package/src/scenarios/semantic-digest-v2.test.ts +0 -128
  52. package/src/scenarios/spec-corpus-validity.test.ts +0 -1727
  53. package/src/scenarios/spec-section-citations.test.ts +0 -132
  54. package/src/scenarios/tool-result-trust-monotone.test.ts +0 -168
  55. package/src/scenarios/versioned-composition-profiles.test.ts +0 -201
  56. package/src/scenarios/workflow-chain-internal-flag.test.ts +0 -84
  57. package/src/scenarios/workload-identity-profile.test.ts +0 -184
@@ -1,242 +0,0 @@
1
- /**
2
- * RFC 0149 §D — normative examples are extracted and validated against the
3
- * same schemas conformance uses.
4
- *
5
- * "Normative examples MUST be extracted into fixtures and validated against
6
- * the same schemas used by conformance" (§D). Until now a fenced example was
7
- * prose to every validator in the corpus: `capability-example-root-layout`
8
- * catches one wrong SHAPE (the `capabilities` wrapper), but an example could
9
- * carry a misspelled field, a wrong enum value, or a stale required property
10
- * and every gate stayed green — a green corpus teaching a non-conformant
11
- * document.
12
- *
13
- * Mechanism — a declaration, not a heuristic:
14
- *
15
- * <!-- normative-example: <name>.schema.json -->
16
- * ```json
17
- * { ... }
18
- * ```
19
- *
20
- * The HTML comment on the line immediately before a ```json / ```jsonc fence
21
- * declares that the fenced body is a WHOLE instance of the named schema. The
22
- * scenario extracts each declared example at test time and validates it with
23
- * Ajv against `schemas/<name>` — the identical schema files and registration
24
- * (`fixtures-valid.test.ts` enumerates the directory the same way) that every
25
- * other conformance leg uses. Extraction is in-process rather than committed
26
- * under `fixtures/`: a committed copy of a prose example is a second source
27
- * of truth that drifts, which is the defect §D exists to remove.
28
- *
29
- * Non-vacuity guards (RFC 0148 §A — an extractor that matches nothing must
30
- * not pass):
31
- * - the corpus MUST contain declared examples (floor asserted);
32
- * - every fenced JSON block in spec/v1 that parses and validates as a whole
33
- * instance of some non-permissive schema MUST be declared — an example
34
- * that IS a schema instance is a normative example, and the marker is how
35
- * it says so; a new whole-document example added without the marker fails
36
- * here rather than silently escaping validation;
37
- * - every declared example MUST parse as strict JSON (jsonc comments and
38
- * ellipses are prose, not instances) and MUST validate;
39
- * - every discovery-shaped example (root `protocolVersion` +
40
- * `supportedEnvelopes`) MUST be declared against `capabilities.schema.json`
41
- * ("all normative discovery examples ... validate" — RFC 0149 acceptance);
42
- * - §E: no declared discovery example carries credential- or tenant-shaped
43
- * keys.
44
- *
45
- * Server-free; always-on in the corpus layout (spec prose is not shipped in the
46
- * published package, so `V1_DIR === null` skips the describe there).
47
- */
48
-
49
- import { describe, it, expect } from 'vitest';
50
- import { readFileSync, readdirSync } from 'node:fs';
51
- import { join } from 'node:path';
52
- import Ajv2020 from 'ajv/dist/2020.js';
53
- import addFormats from 'ajv-formats';
54
- import type { ValidateFunction } from 'ajv';
55
- import { SCHEMAS_DIR, V1_DIR } from '../lib/paths.js';
56
-
57
- export const HOST_CALLBACK_NOT_REQUIRED = 'server-free: extracts fenced prose examples and validates them against the schema files on disk; no host is contacted';
58
-
59
- const MARKER = /^<!--\s*normative-example:\s*([a-z0-9-]+\.schema\.json)\s*-->\s*$/;
60
-
61
- interface Fenced {
62
- readonly file: string;
63
- /** 1-based line of the ``` opener. */
64
- readonly line: number;
65
- readonly info: string;
66
- readonly body: string;
67
- /** Schema declared by a marker on the preceding line, if any. */
68
- readonly declared: string | null;
69
- }
70
-
71
- function fencedBlocks(dir: string): Fenced[] {
72
- const out: Fenced[] = [];
73
- for (const name of readdirSync(dir).filter((f) => f.endsWith('.md')).sort()) {
74
- const lines = readFileSync(join(dir, name), 'utf8').split('\n');
75
- for (let i = 0; i < lines.length; i++) {
76
- const open = /^```(json|jsonc)(\s.*)?$/.exec(lines[i]!.trim());
77
- if (open === null) continue;
78
- const body: string[] = [];
79
- let j = i + 1;
80
- while (j < lines.length && lines[j]!.trim() !== '```') {
81
- body.push(lines[j]!);
82
- j++;
83
- }
84
- const prev = i > 0 ? (lines[i - 1] ?? '') : '';
85
- const m = MARKER.exec(prev.trim());
86
- out.push({ file: name, line: i + 1, info: open[1] ?? 'json', body: body.join('\n'), declared: m?.[1] ?? null });
87
- i = j;
88
- }
89
- }
90
- return out;
91
- }
92
-
93
- /** Stray markers: a marker line NOT immediately followed by a fence opener. */
94
- function strayMarkers(dir: string): string[] {
95
- const out: string[] = [];
96
- for (const name of readdirSync(dir).filter((f) => f.endsWith('.md')).sort()) {
97
- const lines = readFileSync(join(dir, name), 'utf8').split('\n');
98
- for (let i = 0; i < lines.length; i++) {
99
- if (!MARKER.test(lines[i]!.trim())) continue;
100
- const next = (lines[i + 1] ?? '').trim();
101
- if (!/^```(json|jsonc)(\s|$)/.test(next)) out.push(`spec/v1/${name}:${i + 1}`);
102
- }
103
- }
104
- return out;
105
- }
106
-
107
- function parseStrict(body: string): unknown | undefined {
108
- try {
109
- return JSON.parse(body) as unknown;
110
- } catch {
111
- return undefined;
112
- }
113
- }
114
-
115
- function isDiscoveryShaped(v: unknown): boolean {
116
- return (
117
- v !== null &&
118
- typeof v === 'object' &&
119
- !Array.isArray(v) &&
120
- typeof (v as Record<string, unknown>)['protocolVersion'] === 'string' &&
121
- Array.isArray((v as Record<string, unknown>)['supportedEnvelopes'])
122
- );
123
- }
124
-
125
- /** Register every schema in the directory (S14 pattern) and hand back validators by file name. */
126
- function validators(): { byFile: Map<string, ValidateFunction>; permissive: Set<string> } {
127
- const ajv = new Ajv2020({ allErrors: true, strict: false });
128
- addFormats(ajv);
129
- const files = readdirSync(SCHEMAS_DIR).filter((f) => f.endsWith('.schema.json')).sort();
130
- const docs = new Map<string, { $id?: string }>();
131
- for (const f of files) {
132
- const doc = JSON.parse(readFileSync(join(SCHEMAS_DIR, f), 'utf8')) as { $id?: string };
133
- docs.set(f, doc);
134
- ajv.addSchema(doc);
135
- }
136
- const byFile = new Map<string, ValidateFunction>();
137
- for (const [f, doc] of docs) {
138
- const v = doc.$id === undefined ? undefined : ajv.getSchema(doc.$id);
139
- if (v !== undefined) byFile.set(f, v);
140
- }
141
- // A schema that accepts `{}` AND `{x:1}` constrains nothing at the root; a
142
- // whole-document match against it says nothing about the example, so it is
143
- // excluded from the "must be declared" auto-detection (still usable as an
144
- // explicit declaration).
145
- const permissive = new Set([...byFile.entries()].filter(([, v]) => v({}) === true && v({ x: 1 }) === true).map(([f]) => f));
146
- return { byFile, permissive };
147
- }
148
-
149
- const CREDENTIAL_KEY = /^(apiKey|api_key|token|accessToken|refreshToken|secret|clientSecret|password|authorization|bearer|tenantId|tenant_id|organizationId)$/i;
150
- function credentialShapedKeys(v: unknown, path = '$'): string[] {
151
- const out: string[] = [];
152
- if (Array.isArray(v)) {
153
- v.forEach((x, i) => out.push(...credentialShapedKeys(x, `${path}[${i}]`)));
154
- } else if (v !== null && typeof v === 'object') {
155
- for (const [k, x] of Object.entries(v as Record<string, unknown>)) {
156
- if (CREDENTIAL_KEY.test(k)) out.push(`${path}.${k}`);
157
- out.push(...credentialShapedKeys(x, `${path}.${k}`));
158
- }
159
- }
160
- return out;
161
- }
162
-
163
- describe.skipIf(V1_DIR === null)('RFC 0149 §D — normative examples are extracted and validated against the conformance schemas', () => {
164
- // `describe.skipIf` still RUNS this factory in the published layout (vitest
165
- // collects skipped suites), so nothing here may touch the prose directory
166
- // when it is absent — the guards below keep collection green in the tarball.
167
- const v1Dir = V1_DIR ?? '';
168
- const blocks = V1_DIR === null ? [] : fencedBlocks(v1Dir);
169
- const declared = blocks.filter((b) => b.declared !== null);
170
- const { byFile, permissive } = V1_DIR === null ? { byFile: new Map<string, ValidateFunction>(), permissive: new Set<string>() } : validators();
171
-
172
- it('the extractor finds fenced examples and the corpus declares normative ones (non-vacuity floor)', () => {
173
- expect(blocks.length, 'spec/v1 MUST contain fenced json/jsonc examples').toBeGreaterThan(100);
174
- expect(declared.length, 'spec/v1 MUST declare normative examples with <!-- normative-example: … -->').toBeGreaterThanOrEqual(20);
175
- expect(byFile.size, 'schemas/ MUST register').toBeGreaterThan(50);
176
- });
177
-
178
- it('every marker names a schema that exists and sits immediately above a json/jsonc fence', () => {
179
- const unknown = declared.filter((b) => !byFile.has(b.declared as string)).map((b) => `spec/v1/${b.file}:${b.line} → ${String(b.declared)}`);
180
- expect(unknown, 'normative-example markers naming a schema that does not exist in schemas/').toEqual([]);
181
- expect(strayMarkers(v1Dir), 'normative-example markers not directly followed by a ```json fence').toEqual([]);
182
- });
183
-
184
- it('every declared example parses as strict JSON — jsonc comments and ellipses are prose, not instances', () => {
185
- const unparsable = declared.filter((b) => parseStrict(b.body) === undefined).map((b) => `spec/v1/${b.file}:${b.line} (${b.info}) declared ${String(b.declared)}`);
186
- expect(unparsable, 'declared normative examples MUST be strict JSON').toEqual([]);
187
- });
188
-
189
- it('every declared example VALIDATES against its declared schema — the same schema file conformance uses', () => {
190
- const failures: string[] = [];
191
- for (const b of declared) {
192
- const doc = parseStrict(b.body);
193
- if (doc === undefined) continue; // reported by the leg above
194
- const v = byFile.get(b.declared as string);
195
- if (v === undefined) continue; // reported by the leg above
196
- if (v(doc) !== true) {
197
- const errs = (v.errors ?? []).slice(0, 3).map((e) => `${e.instancePath || '/'} ${e.message ?? ''}`).join('; ');
198
- failures.push(`spec/v1/${b.file}:${b.line} ✗ ${String(b.declared)}: ${errs}`);
199
- }
200
- }
201
- expect(failures, 'RFC 0149 §D: a normative example that does not validate teaches a non-conformant document').toEqual([]);
202
- });
203
-
204
- it('every fenced JSON block that IS a whole instance of a (non-permissive) schema is declared — no undeclared normative example', () => {
205
- const undeclared: string[] = [];
206
- for (const b of blocks) {
207
- if (b.declared !== null) continue;
208
- const doc = parseStrict(b.body);
209
- if (doc === undefined) continue;
210
- const hits = [...byFile.entries()].filter(([f, v]) => !permissive.has(f) && v(doc) === true).map(([f]) => f);
211
- if (hits.length > 0) undeclared.push(`spec/v1/${b.file}:${b.line} validates as ${hits.join(' | ')} — add <!-- normative-example: <one of these> --> above the fence`);
212
- }
213
- expect(undeclared, 'whole-document examples MUST be declared so they are validated on every run').toEqual([]);
214
- });
215
-
216
- it('every discovery-shaped example is declared against capabilities.schema.json and validates (RFC 0149: "all normative discovery examples ... validate")', () => {
217
- const discovery = blocks.filter((b) => isDiscoveryShaped(parseStrict(b.body)));
218
- expect(discovery.length, 'spec/v1 MUST carry at least one whole discovery example').toBeGreaterThan(0);
219
- const wrong = discovery.filter((b) => b.declared !== 'capabilities.schema.json').map((b) => `spec/v1/${b.file}:${b.line} declared ${String(b.declared)}`);
220
- expect(wrong, 'discovery-shaped examples MUST be declared normative-example: capabilities.schema.json').toEqual([]);
221
- const v = byFile.get('capabilities.schema.json');
222
- expect(v).toBeDefined();
223
- for (const b of discovery) expect((v as ValidateFunction)(parseStrict(b.body)), `spec/v1/${b.file}:${b.line}`).toBe(true);
224
- });
225
-
226
- it('§E: no declared discovery example carries credential- or tenant-shaped keys', () => {
227
- const offenders: string[] = [];
228
- for (const b of declared.filter((x) => x.declared === 'capabilities.schema.json')) {
229
- for (const p of credentialShapedKeys(parseStrict(b.body))) offenders.push(`spec/v1/${b.file}:${b.line} ${p}`);
230
- }
231
- expect(offenders, 'RFC 0149 §E: no discovery example may contain credentials or tenant data').toEqual([]);
232
- });
233
-
234
- it('sabotage: a declared example with a wrong enum value is caught by the same validator path', () => {
235
- // Guard against a validator that compiles to `true` (strict:false + a
236
- // schema that failed to register would do that silently).
237
- const v = byFile.get('error-envelope.schema.json');
238
- expect(v).toBeDefined();
239
- expect((v as ValidateFunction)({ error: 'not_found', message: 'gone', retriable: 'yes' })).toBe(false);
240
- expect((v as ValidateFunction)({ error: 'not_found', message: 'gone' })).toBe(true);
241
- });
242
- });
@@ -1,309 +0,0 @@
1
- /**
2
- * RFC 0149 §A / §Conformance — `openapi-asyncapi-sdk-parity`.
3
- *
4
- * "SDK operation URLs and AsyncAPI bindings MUST match the resolved OpenAPI
5
- * path" (§A), and "SDK repositories consume a generated canonical
6
- * operation-path manifest" (§Conformance). `openapi-resolved-paths.test.ts`
7
- * landed the OpenAPI half (exactly one `/v1` per resolved operation); this
8
- * scenario lands the AsyncAPI half and the manifest, and witnesses the SDK
9
- * half when the SDK repository is reachable.
10
- *
11
- * Legs:
12
- * 1. `spec/v1/operation-path-manifest.json` — the canonical resolved
13
- * operation-path manifest — is in sync with `api/openapi.yaml`: this file
14
- * re-derives the rows from the contract with its own scanner and compares,
15
- * so the generator (`scripts/generate-operation-path-manifest.mjs`) and
16
- * the suite cannot silently disagree; every row carries exactly one `/v1`
17
- * (none for `/.well-known/*`); the digest matches the body.
18
- * 2. AsyncAPI bindings: every channel `address` (resolved under
19
- * `servers.*.pathname`) is an OpenAPI path key with a GET operation, with
20
- * exactly one `/v1`; the AsyncAPI server pathname carries the version
21
- * segment exactly once. The AsyncAPI document names the same wire
22
- * the OpenAPI document does — no second contract for the event stream.
23
- * 3. SDK parity (`openwop-sdks`): the SDK repository's
24
- * `sdk/parity-expectations.json` covers every CANONICAL manifest
25
- * operation with the same method + resolved path, declares no operation
26
- * the manifest lacks, and the TypeScript client issues every path an
27
- * operation declared `ts: typed` names. Reached through
28
- * `OPENWOP_SDKS_DIR` or the sibling checkout `../openwop-sdks`. When the
29
- * SDK tree is NOT reachable the requirement is recorded `blocked` with the
30
- * reason — RFC 0148 §A: an unwitnessed requirement is blocked, never a
31
- * pass — and the leg returns without asserting.
32
- *
33
- * Server-free (reads files only). Legs 1–2 always-on.
34
- */
35
-
36
- import { describe, it, expect } from 'vitest';
37
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
38
- import { createHash } from 'node:crypto';
39
- import { join, resolve } from 'node:path';
40
- import { API_DIR, V1_DIR } from '../lib/paths.js';
41
- import { recordRequirement } from '../lib/requirement-ledger.js';
42
-
43
- export const HOST_CALLBACK_NOT_REQUIRED = 'server-free: compares contract documents and (when reachable) SDK sources on disk; no host is contacted';
44
-
45
- /** RFC 0148 §A requirement id for the SDK half, recorded blocked when unwitnessable. */
46
- export const SDK_PATH_PARITY_REQUIREMENT = 'openwop.requirement.rfc0149.sdk-path-parity';
47
-
48
- const OPENAPI = readFileSync(join(API_DIR, 'openapi.yaml'), 'utf8');
49
- const ASYNCAPI = readFileSync(join(API_DIR, 'asyncapi.yaml'), 'utf8');
50
- const MANIFEST_PATH = V1_DIR === null ? null : join(V1_DIR, 'operation-path-manifest.json');
51
-
52
- const VERBS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options']);
53
-
54
- interface Op { operationId: string | null; method: string; pathKey: string; tag: string | null }
55
- interface Row { operationId: string; method: string; pathKey: string; resolvedPath: string; tag: string; class: string }
56
-
57
- function serverUrls(raw: string): string[] {
58
- const urls: string[] = [];
59
- let inServers = false;
60
- for (const line of raw.split('\n')) {
61
- if (/^servers:/.test(line)) { inServers = true; continue; }
62
- if (inServers && /^[^\s#]/.test(line)) break;
63
- if (!inServers) continue;
64
- const m = /^\s*-\s*url:\s*(\S+)\s*$/.exec(line);
65
- if (m?.[1] !== undefined) urls.push(m[1]);
66
- }
67
- return urls;
68
- }
69
- function resolvedPath(serverUrl: string, pathKey: string): string {
70
- const afterScheme = serverUrl.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
71
- const slash = afterScheme.indexOf('/');
72
- const basePath = slash === -1 ? '' : afterScheme.slice(slash);
73
- return `${basePath.replace(/\/$/, '')}${pathKey}`;
74
- }
75
- function operations(raw: string): Op[] {
76
- const rows: Op[] = [];
77
- let inPaths = false;
78
- let pathKey: string | null = null;
79
- let op: Op | null = null;
80
- for (const line of raw.split('\n')) {
81
- if (/^paths:/.test(line)) { inPaths = true; continue; }
82
- if (inPaths && /^[^\s#]/.test(line)) break;
83
- if (!inPaths) continue;
84
- const p = /^ {2}(\/\S*):\s*$/.exec(line);
85
- if (p?.[1] !== undefined) { pathKey = p[1]; op = null; continue; }
86
- const v = /^ {4}([a-z]+):\s*$/.exec(line);
87
- if (v?.[1] !== undefined && VERBS.has(v[1]) && pathKey !== null) {
88
- op = { operationId: null, method: v[1].toUpperCase(), pathKey, tag: null };
89
- rows.push(op);
90
- continue;
91
- }
92
- if (op === null) continue;
93
- const id = /^ {6}operationId:\s*([A-Za-z0-9_]+)\s*$/.exec(line);
94
- if (id?.[1] !== undefined) { op.operationId = id[1]; continue; }
95
- const t = /^ {6}tags:\s*\[([^\]]*)\]\s*$/.exec(line);
96
- if (t?.[1] !== undefined) { op.tag = (t[1].split(',')[0] ?? '').trim(); continue; }
97
- }
98
- return rows;
99
- }
100
- function classify(tag: string | null): string {
101
- if (tag === 'host') return 'host-extension';
102
- if (tag === 'packs-test') return 'test-catalog';
103
- return 'canonical';
104
- }
105
- function deriveRows(): { serverUrl: string; rows: Row[] } {
106
- const servers = serverUrls(OPENAPI);
107
- const base = servers[0] ?? '';
108
- const rows: Row[] = operations(OPENAPI).map((o) => ({
109
- operationId: o.operationId ?? '',
110
- method: o.method,
111
- pathKey: o.pathKey,
112
- resolvedPath: resolvedPath(base, o.pathKey),
113
- tag: o.tag ?? '',
114
- class: classify(o.tag),
115
- }));
116
- rows.sort((a, b) => a.resolvedPath.localeCompare(b.resolvedPath) || a.method.localeCompare(b.method));
117
- return { serverUrl: base, rows };
118
- }
119
- function v1Segments(p: string): number {
120
- return (p.match(/\/v1(?=\/|$)/g) ?? []).length;
121
- }
122
-
123
- /* ─── AsyncAPI scanning ─────────────────────────────────────────────────── */
124
-
125
- function asyncapiServerPathnames(raw: string): string[] {
126
- const out: string[] = [];
127
- let inServers = false;
128
- for (const line of raw.split('\n')) {
129
- if (/^servers:/.test(line)) { inServers = true; continue; }
130
- if (inServers && /^[^\s#]/.test(line)) break;
131
- if (!inServers) continue;
132
- const m = /^\s+pathname:\s*(\S+)\s*$/.exec(line);
133
- if (m?.[1] !== undefined) out.push(m[1]);
134
- }
135
- return out;
136
- }
137
- /** Channel `address:` values (a `null` address is a logical channel and is skipped). */
138
- function asyncapiChannelAddresses(raw: string): { channel: string; address: string | null }[] {
139
- const out: { channel: string; address: string | null }[] = [];
140
- let inChannels = false;
141
- let channel: string | null = null;
142
- for (const line of raw.split('\n')) {
143
- if (/^channels:/.test(line)) { inChannels = true; continue; }
144
- if (inChannels && /^[^\s#]/.test(line)) break;
145
- if (!inChannels) continue;
146
- const c = /^ {2}([A-Za-z][\w.-]*):\s*$/.exec(line);
147
- if (c?.[1] !== undefined) { channel = c[1]; continue; }
148
- const a = /^ {4}address:\s*(\S+)\s*$/.exec(line);
149
- if (a?.[1] !== undefined && channel !== null) out.push({ channel, address: a[1] === 'null' ? null : a[1] });
150
- }
151
- return out;
152
- }
153
-
154
- /* ─── SDK repository probing ────────────────────────────────────────────── */
155
-
156
- function sdksDir(): string | null {
157
- const env = process.env['OPENWOP_SDKS_DIR'];
158
- if (env !== undefined && env !== '' && existsSync(join(env, 'sdk', 'parity-expectations.json'))) return resolve(env);
159
- if (V1_DIR === null) return null;
160
- const sibling = resolve(V1_DIR, '..', '..', '..', 'openwop-sdks');
161
- return existsSync(join(sibling, 'sdk', 'parity-expectations.json')) ? sibling : null;
162
- }
163
- function collectTs(dir: string): string {
164
- let out = '';
165
- for (const name of readdirSync(dir)) {
166
- const p = join(dir, name);
167
- if (statSync(p).isDirectory()) { if (!/node_modules|__tests__/.test(name)) out += collectTs(p); continue; }
168
- if (name.endsWith('.ts') && !/\.test\./.test(name)) out += readFileSync(p, 'utf8') + '\n';
169
- }
170
- return out;
171
- }
172
- /**
173
- * The most distinctive STATIC fragment of a templated path: everything up to
174
- * the first `{param}` (an SDK builds the rest with `encodeURIComponent`).
175
- * `/v1/runs/{runId}:fork` → `/v1/runs/` … plus the tail `:fork` checked
176
- * separately when the template ends in a static suffix.
177
- */
178
- function staticFragments(pathTpl: string): string[] {
179
- const head = pathTpl.split('{')[0] ?? pathTpl;
180
- const tail = /\}([^{}]*)$/.exec(pathTpl)?.[1] ?? '';
181
- const frags = [head];
182
- if (tail !== '' && tail !== '/') frags.push(tail);
183
- return frags;
184
- }
185
-
186
- describe('RFC 0149 §A/§Conformance — the canonical operation-path manifest is in sync with the contract', () => {
187
- it('spec/v1/operation-path-manifest.json exists in the corpus layout and re-derives byte-for-byte from api/openapi.yaml', () => {
188
- if (MANIFEST_PATH === null) return; // published tarball: prose dir absent (see leg below)
189
- expect(existsSync(MANIFEST_PATH), 'RFC 0149 §Conformance: the manifest MUST be published (run scripts/generate-operation-path-manifest.mjs --write)').toBe(true);
190
- const doc = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as { serverUrl: string; operations: Row[]; digest: string; counts: Record<string, number> } & Record<string, unknown>;
191
- const { serverUrl, rows } = deriveRows();
192
- expect(doc.serverUrl).toBe(serverUrl);
193
- expect(doc.operations).toEqual(rows);
194
- expect(doc.counts['total']).toBe(rows.length);
195
- const { digest: _d, ...body } = doc;
196
- expect(doc.digest).toBe(createHash('sha256').update(JSON.stringify(body), 'utf8').digest('hex'));
197
- });
198
-
199
- it('every operation resolves to exactly one /v1 segment (none for /.well-known/*), has an operationId, and ids are unique', () => {
200
- const { rows } = deriveRows();
201
- expect(rows.length).toBeGreaterThan(40);
202
- const ids = new Set<string>();
203
- for (const r of rows) {
204
- expect(r.operationId, `${r.method} ${r.pathKey} has no operationId`).not.toBe('');
205
- expect(ids.has(r.operationId), `duplicate operationId ${r.operationId}`).toBe(false);
206
- ids.add(r.operationId);
207
- const n = v1Segments(r.resolvedPath);
208
- if (r.pathKey.startsWith('/.well-known/')) expect(n, `${r.resolvedPath} MUST be unversioned`).toBe(0);
209
- else expect(n, `${r.operationId} resolves to ${r.resolvedPath}`).toBe(1);
210
- }
211
- // the classes are exhaustive and the canonical set is the large majority
212
- expect(rows.every((r) => ['canonical', 'host-extension', 'test-catalog'].includes(r.class))).toBe(true);
213
- expect(rows.filter((r) => r.class === 'canonical').length).toBeGreaterThan(rows.length / 2);
214
- });
215
- });
216
-
217
- describe('RFC 0149 §A — AsyncAPI bindings name the same wire the OpenAPI document does', () => {
218
- it('the AsyncAPI server pathname carries the version segment exactly once', () => {
219
- const pathnames = asyncapiServerPathnames(ASYNCAPI);
220
- expect(pathnames.length).toBeGreaterThan(0);
221
- for (const p of pathnames) expect(v1Segments(p), `asyncapi servers pathname ${p}`).toBe(1);
222
- });
223
-
224
- it('every addressed channel resolves (server pathname + address) to an OpenAPI path key that has a GET operation, with exactly one /v1', () => {
225
- const pathnames = asyncapiServerPathnames(ASYNCAPI);
226
- const channels = asyncapiChannelAddresses(ASYNCAPI);
227
- const addressed = channels.filter((c) => c.address !== null);
228
- expect(addressed.length, 'at least one channel is bound to a wire address').toBeGreaterThan(0);
229
- const { rows } = deriveRows();
230
- const getPaths = new Set(rows.filter((r) => r.method === 'GET').map((r) => r.pathKey));
231
- for (const base of pathnames) {
232
- for (const c of addressed) {
233
- const resolved = `${base.replace(/\/$/, '')}${c.address as string}`;
234
- expect(v1Segments(resolved), `channel ${c.channel} resolves to ${resolved}`).toBe(1);
235
- expect(getPaths.has(resolved), `channel ${c.channel} → ${resolved} is not a GET path in api/openapi.yaml`).toBe(true);
236
- }
237
- }
238
- });
239
-
240
- it('logical (address: null) channels are the only ones without a wire address, and they are documented as logical', () => {
241
- const channels = asyncapiChannelAddresses(ASYNCAPI);
242
- const logical = channels.filter((c) => c.address === null).map((c) => c.channel);
243
- // heartbeat is the known logical channel (host-capabilities.md §host.heartbeat)
244
- for (const name of logical) expect(name, `logical channel ${name} must be documented as such`).toMatch(/heartbeat/i);
245
- });
246
- });
247
-
248
- describe('RFC 0149 §A — SDK operation URLs match the resolved OpenAPI paths (openwop-sdks parity manifest)', () => {
249
- it('sdk/parity-expectations.json covers every canonical operation with the same method + path, declares nothing the contract lacks, and the TypeScript client issues every ts-typed path', () => {
250
- const dir = sdksDir();
251
- if (dir === null) {
252
- recordRequirement(
253
- SDK_PATH_PARITY_REQUIREMENT,
254
- 'blocked',
255
- 'openwop-sdks is not reachable from this runner (set OPENWOP_SDKS_DIR or check the sibling repository out next to this one); the SDK half of RFC 0149 §A parity is unwitnessed here',
256
- );
257
- return;
258
- }
259
- const exp = JSON.parse(readFileSync(join(dir, 'sdk', 'parity-expectations.json'), 'utf8')) as {
260
- operations: { operationId: string; method: string; path: string; ts: string; py: string; go: string; note?: string }[];
261
- };
262
- const { rows } = deriveRows();
263
- const byId = new Map(rows.map((r) => [r.operationId, r] as const));
264
- const expById = new Map(exp.operations.map((e) => [e.operationId, e] as const));
265
-
266
- // (a) every canonical operation is declared, with the same method and RESOLVED path
267
- const missing = rows.filter((r) => r.class === 'canonical' && !expById.has(r.operationId)).map((r) => r.operationId);
268
- expect(missing, 'canonical operations with no SDK parity declaration').toEqual([]);
269
- const drift: string[] = [];
270
- for (const r of rows) {
271
- const e = expById.get(r.operationId);
272
- if (e === undefined) continue;
273
- if (e.method.toUpperCase() !== r.method || e.path !== r.resolvedPath) drift.push(`${r.operationId}: sdk says ${e.method} ${e.path}, contract resolves ${r.method} ${r.resolvedPath}`);
274
- }
275
- expect(drift, 'SDK-declared operation URLs MUST match the resolved OpenAPI path (RFC 0149 §A)').toEqual([]);
276
-
277
- // (b) nothing declared that the contract lacks (orphan drift)
278
- const orphans = exp.operations.filter((e) => !byId.has(e.operationId)).map((e) => e.operationId);
279
- expect(orphans, 'SDK parity entries for operationIds the contract no longer has').toEqual([]);
280
-
281
- // (c) the TypeScript client actually issues every ts-typed path
282
- const tsSrc = collectTs(join(dir, 'sdk', 'typescript', 'src'));
283
- const notIssued: string[] = [];
284
- for (const e of exp.operations) {
285
- if (e.ts !== 'typed') continue;
286
- const frags = staticFragments(e.path);
287
- if (!frags.every((f) => tsSrc.includes(f))) notIssued.push(`${e.operationId} (${e.path})`);
288
- }
289
- expect(notIssued, 'operations declared ts-typed whose path the TypeScript client never issues').toEqual([]);
290
-
291
- // (d) and no HOST-client-issued /v1 literal resolves outside the contract's
292
- // canonical+host set. Scoped to the OpenwopClient modules (`client.ts`,
293
- // `sse.ts`): the SDK also carries a pack-REGISTRY client
294
- // (`registry-helpers.ts` — `/v1/packs/*`, `/v1/index.json`, issued to a
295
- // registry base) and provider-shaped types (`/v1/chat/completions`, RFC
296
- // 0108, issued to a provider) that are not host operations.
297
- const hostClientSrc = ['client.ts', 'sse.ts']
298
- .map((f) => join(dir, 'sdk', 'typescript', 'src', f))
299
- .filter((f) => existsSync(f))
300
- .map((f) => readFileSync(f, 'utf8'))
301
- .join('\n');
302
- const issued = [...hostClientSrc.matchAll(/[`'"](\/v1\/[^`'"$?]*)/g)].map((m) => (m[1] ?? '').replace(/\/$/, ''));
303
- const known = new Set(rows.map((r) => r.pathKey.split('{')[0] ?? r.pathKey));
304
- const unknown = [...new Set(issued)].filter((p) => ![...known].some((k) => p.startsWith(k.replace(/\/$/, ''))) && !p.startsWith('/v1/host/'));
305
- expect(unknown, 'the host client issues /v1 paths the contract does not define (outside /v1/host/* extensions)').toEqual([]);
306
-
307
- recordRequirement(SDK_PATH_PARITY_REQUIREMENT, 'executed-pass', undefined, { assertionCount: 6 });
308
- });
309
- });