@openwop/openwop-conformance 1.153.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 (51) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +9 -0
  3. package/dist/cli.js +7 -2
  4. package/package.json +31 -2
  5. package/schemas/CORPUS-STAMP.json +99 -3
  6. package/src/cli.ts +7 -5
  7. package/src/global-setup.ts +13 -0
  8. package/src/lib/corpus-stamp.ts +125 -0
  9. package/src/lib/capabilities-auth-subject-link.test.ts +0 -103
  10. package/src/lib/fork-availability.test.ts +0 -69
  11. package/src/lib/global-setup.test.ts +0 -76
  12. package/src/lib/grpc-framing.test.ts +0 -96
  13. package/src/lib/oidc-issuer.test.ts +0 -328
  14. package/src/lib/otel-collector-grpc.test.ts +0 -191
  15. package/src/lib/otel-collector.test.ts +0 -303
  16. package/src/lib/otlp-protobuf.test.ts +0 -461
  17. package/src/lib/polling.test.ts +0 -80
  18. package/src/lib/requirement-ids.test.ts +0 -83
  19. package/src/lib/requirement-ledger.test.ts +0 -75
  20. package/src/lib/risk-disposition.test.ts +0 -91
  21. package/src/lib/saml-idp.test.ts +0 -127
  22. package/src/lib/spec-coherence-registry.test.ts +0 -155
  23. package/src/lib/webhook-receiver.test.ts +0 -144
  24. package/src/scenarios/artifact-schema-compile-bounded.test.ts +0 -126
  25. package/src/scenarios/artifact-type-legacy-ids.test.ts +0 -124
  26. package/src/scenarios/capability-example-root-layout.test.ts +0 -272
  27. package/src/scenarios/certification-floor-enforcement.test.ts +0 -204
  28. package/src/scenarios/chain-subchain-unsupported-refused.test.ts +0 -70
  29. package/src/scenarios/compensation-profile.test.ts +0 -340
  30. package/src/scenarios/core-manifest-and-extension-registry.test.ts +0 -250
  31. package/src/scenarios/discovery-canonical-family-no-shadow.test.ts +0 -219
  32. package/src/scenarios/edge-condition-truthy-falsy.test.ts +0 -108
  33. package/src/scenarios/effect-identity-composition.test.ts +0 -129
  34. package/src/scenarios/effect-identity-cross-scope.test.ts +0 -82
  35. package/src/scenarios/error-envelope-canonical-shape.test.ts +0 -64
  36. package/src/scenarios/form-content-packs.test.ts +0 -415
  37. package/src/scenarios/multi-region-effect-vocabulary.test.ts +0 -175
  38. package/src/scenarios/normative-example-extraction.test.ts +0 -242
  39. package/src/scenarios/openapi-asyncapi-sdk-parity.test.ts +0 -309
  40. package/src/scenarios/pack-manifest-extensions.test.ts +0 -203
  41. package/src/scenarios/protocol-version-grammar.test.ts +0 -119
  42. package/src/scenarios/registry-declarative-kinds.test.ts +0 -121
  43. package/src/scenarios/rfc-0147-self-audit.test.ts +0 -104
  44. package/src/scenarios/rfc-lifecycle-coherence.test.ts +0 -215
  45. package/src/scenarios/semantic-digest-v2.test.ts +0 -128
  46. package/src/scenarios/spec-corpus-validity.test.ts +0 -1727
  47. package/src/scenarios/spec-section-citations.test.ts +0 -132
  48. package/src/scenarios/tool-result-trust-monotone.test.ts +0 -168
  49. package/src/scenarios/versioned-composition-profiles.test.ts +0 -201
  50. package/src/scenarios/workflow-chain-internal-flag.test.ts +0 -84
  51. package/src/scenarios/workload-identity-profile.test.ts +0 -184
@@ -1,1727 +0,0 @@
1
- /**
2
- * Spec-corpus validity — server-free check that the openwop spec artifacts
3
- * are internally consistent. Catches drift between prose docs, JSON
4
- * Schemas, OpenAPI, AsyncAPI, and the fixture catalog.
5
- *
6
- * Runs purely against on-disk files. Designed for CI gating: any
7
- * structural break in the spec fails this scenario before reaching the
8
- * server-required suite.
9
- *
10
- * Coverage:
11
- * 1. Every JSON Schema in `../../schemas/` parses + compiles (Ajv2020).
12
- * 2. Every fixture JSON validates against workflow-definition schema.
13
- * (delegated to fixtures-valid.test.ts; cross-referenced here)
14
- * 3. OpenAPI 3.1 YAML parses + has required top-level fields.
15
- * 4. AsyncAPI 3.1 YAML parses + has required top-level fields.
16
- * 5. Every prose .md doc carries a `Status:` legend tag.
17
- * 6. Every $ref in OpenAPI/AsyncAPI to ../schemas/*.json resolves to a
18
- * file that exists on disk.
19
- * 7. Every OpenAPI operationId is represented in conformance/coverage.md.
20
- * 8. README.md's spec/v1 document index matches the on-disk docs.
21
- * 9. Local Markdown links resolve to files in the repo checkout.
22
- * 10. schemas/README.md lists every `*.schema.json` file.
23
- * 11. AsyncAPI message names stay aligned with RunEventType enum values.
24
- * 12. JSON Schema `$id` values match their canonical openwop.dev URLs.
25
- * 13. Absolute JSON Schema `$ref`s point at schema `$id`s in this corpus.
26
- * 14. OpenAPI operationIds are unique and operation tags are declared.
27
- * 15. AsyncAPI operations, channels, and message names are internally consistent.
28
- * 16. conformance/README.md scenario counts match `src/scenarios/*.test.ts`.
29
- * 17. run-event-payloads.schema.json covers every RunEventType exactly once.
30
- * 18. OpenAPI security/public-route declarations and REST endpoint catalog agree.
31
- * 19. OpenAPI error specializations compose the canonical ErrorEnvelope.
32
- * 20. REST/auth/idempotency prose examples keep contextual error metadata under details.
33
- * 21. SDK error-code helpers expose canonical HTTP envelope codes.
34
- */
35
-
36
- import { describe, it, expect } from 'vitest';
37
- import { readFileSync, readdirSync, existsSync } from 'node:fs';
38
- import { spawnSync } from 'node:child_process';
39
- import { createHash } from 'node:crypto';
40
- import { dirname, join, relative, resolve as pathResolve } from 'node:path';
41
- import Ajv2020 from 'ajv/dist/2020.js';
42
- import addFormats from 'ajv-formats';
43
- import {
44
- API_DIR,
45
- CONFORMANCE_README_PATH,
46
- COVERAGE_DOC_PATH,
47
- FIXTURES_DIR,
48
- FIXTURES_DOC_PATH,
49
- GO_TYPES_PATH,
50
- LAYOUT,
51
- PYTHON_TYPES_PATH,
52
- README_PATH,
53
- SCENARIOS_DIR,
54
- SCHEMAS_DIR,
55
- TYPESCRIPT_RUN_HELPERS_PATH,
56
- V1_DIR,
57
- } from '../lib/paths.js';
58
- import { verifyBundle, PROFILE_FLOOR_SCENARIOS } from '../lib/profiles.js';
59
-
60
- // Layout-aware paths come from `lib/paths.ts`. Three layouts:
61
- // - Repo (github.com/openwop/openwop): schemas/api at repo root,
62
- // prose docs under spec/v1/, fixtures.md under conformance/.
63
- // - In-tree mirror (openwop/openwop under ): same
64
- // shape, just rooted differently.
65
- // - Published tarball (`@openwop/openwop-conformance`): schemas/api
66
- // vendored at the package root by `prepack`, prose docs not
67
- // bundled, fixtures.md ships next to the fixtures directory.
68
- //
69
- // Tests that depend on prose docs or fixtures.md skip cleanly when the
70
- // resolver returns null for those paths under the published layout.
71
-
72
- // ── Helpers ─────────────────────────────────────────────────────────────
73
-
74
- function listJsonFiles(dir: string): string[] {
75
- // Recurse into subdirectories so e.g. `schemas/envelopes/*.schema.json`
76
- // appears as `envelopes/<file>` to match the README's path-prefixed
77
- // table entries. Preserves the non-recursive-relative-output contract
78
- // for files directly under `dir`.
79
- const out: string[] = [];
80
- const walk = (subPath: string): void => {
81
- const fullPath = subPath === '' ? dir : `${dir}/${subPath}`;
82
- for (const entry of readdirSync(fullPath, { withFileTypes: true })) {
83
- if (entry.isDirectory()) {
84
- walk(subPath === '' ? entry.name : `${subPath}/${entry.name}`);
85
- } else if (entry.isFile() && entry.name.endsWith('.json')) {
86
- out.push(subPath === '' ? entry.name : `${subPath}/${entry.name}`);
87
- }
88
- }
89
- };
90
- walk('');
91
- return out;
92
- }
93
-
94
- function listScenarioTestFiles(dir: string): string[] {
95
- return readdirSync(dir)
96
- .filter((f) => f.endsWith('.test.ts'))
97
- .sort();
98
- }
99
-
100
- function listTextFilesRecursive(dir: string, extensions: Set<string>): string[] {
101
- if (!existsSync(dir)) return [];
102
- const files: string[] = [];
103
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
104
- if (entry.name === 'dist' || entry.name === 'node_modules') continue;
105
- const fullPath = join(dir, entry.name);
106
- if (entry.isDirectory()) {
107
- files.push(...listTextFilesRecursive(fullPath, extensions));
108
- } else if ([...extensions].some((ext) => entry.name.endsWith(ext))) {
109
- files.push(fullPath);
110
- }
111
- }
112
- return files.sort();
113
- }
114
-
115
- function readJson(path: string): unknown {
116
- return JSON.parse(readFileSync(path, 'utf8'));
117
- }
118
-
119
- function collectJsonRefs(value: unknown): string[] {
120
- const refs: string[] = [];
121
- const visit = (node: unknown): void => {
122
- if (node === null || typeof node !== 'object') return;
123
- if (Array.isArray(node)) {
124
- for (const child of node) visit(child);
125
- return;
126
- }
127
- const obj = node as Record<string, unknown>;
128
- if (typeof obj.$ref === 'string') refs.push(obj.$ref);
129
- for (const child of Object.values(obj)) visit(child);
130
- };
131
- visit(value);
132
- return refs;
133
- }
134
-
135
- /** Minimal YAML parser substitute — assert the file is parseable as
136
- * YAML 1.2 by checking it's valid via the spec's structural fields.
137
- * We don't pull in `js-yaml` to keep the conformance package's
138
- * dep surface minimal; instead we read enough of the file to assert
139
- * the openapi:/asyncapi: top-level keys are present.
140
- */
141
- function readYamlHeader(path: string): {
142
- raw: string;
143
- topLevelKeys: Set<string>;
144
- } {
145
- const raw = readFileSync(path, 'utf8');
146
- const topLevelKeys = new Set<string>();
147
- for (const line of raw.split('\n')) {
148
- // Skip comments + indented lines + blanks.
149
- if (line.startsWith('#') || line.startsWith(' ') || line.startsWith('\t') || line.trim() === '') {
150
- continue;
151
- }
152
- const colon = line.indexOf(':');
153
- if (colon > 0) {
154
- topLevelKeys.add(line.slice(0, colon));
155
- }
156
- }
157
- return { raw, topLevelKeys };
158
- }
159
-
160
- /** Extract every `$ref:` value from a YAML or JSON file (string scan). */
161
- function extractRefs(raw: string): string[] {
162
- const refs: string[] = [];
163
- const re = /\$ref:\s*['"]?([^'"\s\n]+)['"]?/g;
164
- let m: RegExpExecArray | null;
165
- while ((m = re.exec(raw)) !== null) {
166
- if (m[1]) refs.push(m[1]);
167
- }
168
- return refs;
169
- }
170
-
171
- function extractOpenApiOperationIds(raw: string): string[] {
172
- const ids: string[] = [];
173
- const re = /^\s+operationId:\s*([A-Za-z0-9_-]+)\s*$/gm;
174
- let m: RegExpExecArray | null;
175
- while ((m = re.exec(raw)) !== null) {
176
- if (m[1]) ids.push(m[1]);
177
- }
178
- return ids;
179
- }
180
-
181
- interface OpenApiOperation {
182
- readonly path: string;
183
- readonly method: string;
184
- readonly operationId: string;
185
- readonly clearsSecurity: boolean;
186
- readonly responseStatusCodes: readonly string[];
187
- }
188
-
189
- function extractOpenApiOperations(raw: string): OpenApiOperation[] {
190
- const operations: OpenApiOperation[] = [];
191
- let currentPath: string | null = null;
192
- let currentMethod: string | null = null;
193
- let currentOperationId: string | null = null;
194
- let currentClearsSecurity = false;
195
- let currentResponseStatusCodes: string[] = [];
196
-
197
- function flush(): void {
198
- if (currentPath && currentMethod && currentOperationId) {
199
- operations.push({
200
- path: currentPath,
201
- method: currentMethod,
202
- operationId: currentOperationId,
203
- clearsSecurity: currentClearsSecurity,
204
- responseStatusCodes: currentResponseStatusCodes,
205
- });
206
- }
207
- currentMethod = null;
208
- currentOperationId = null;
209
- currentClearsSecurity = false;
210
- currentResponseStatusCodes = [];
211
- }
212
-
213
- for (const line of raw.split('\n')) {
214
- const pathMatch = line.match(/^ (\/.*):\s*$/);
215
- if (pathMatch) {
216
- flush();
217
- currentPath = pathMatch[1] ?? null;
218
- continue;
219
- }
220
-
221
- const methodMatch = line.match(/^ (get|post|put|patch|delete):\s*$/);
222
- if (methodMatch) {
223
- flush();
224
- currentMethod = methodMatch[1] ?? null;
225
- continue;
226
- }
227
-
228
- if (currentMethod) {
229
- const operationIdMatch = line.match(/^\s{6}operationId:\s*([A-Za-z0-9_-]+)\s*$/);
230
- if (operationIdMatch) {
231
- currentOperationId = operationIdMatch[1] ?? null;
232
- }
233
- if (/^\s{6}security:\s*\[\]\s*(?:#.*)?$/.test(line)) {
234
- currentClearsSecurity = true;
235
- }
236
- const responseCodeMatch = line.match(/^\s{8}'([0-9]{3})':/);
237
- if (responseCodeMatch?.[1]) {
238
- currentResponseStatusCodes.push(responseCodeMatch[1]);
239
- }
240
- }
241
- }
242
-
243
- flush();
244
- return operations;
245
- }
246
-
247
- interface RestEndpointCatalogRow {
248
- readonly method: string;
249
- readonly path: string;
250
- readonly auth: string;
251
- readonly scope: string;
252
- }
253
-
254
- function extractRestEndpointCatalogRows(markdown: string): RestEndpointCatalogRow[] {
255
- const rows: RestEndpointCatalogRow[] = [];
256
- const re = /^\|\s*`([A-Z]+)`\s*\|\s*`([^`]+)`\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|/gm;
257
- let m: RegExpExecArray | null;
258
- while ((m = re.exec(markdown)) !== null) {
259
- rows.push({
260
- method: (m[1] ?? '').toLowerCase(),
261
- path: (m[2] ?? '').trim(),
262
- auth: (m[3] ?? '').trim(),
263
- scope: (m[4] ?? '').trim(),
264
- });
265
- }
266
- return rows;
267
- }
268
-
269
- function extractOpenApiComponentSchemaBlock(raw: string, schemaName: string): string {
270
- const startRe = new RegExp(`^ ${schemaName}:\\s*$`, 'm');
271
- const startMatch = startRe.exec(raw);
272
- expect(startMatch, `OpenAPI components.schemas.${schemaName} MUST exist`).not.toBeNull();
273
-
274
- const start = startMatch?.index ?? 0;
275
- const nextSchemaRe = /^ [A-Za-z0-9_-]+:\s*$/gm;
276
- nextSchemaRe.lastIndex = start + (startMatch?.[0].length ?? 0);
277
- const nextMatch = nextSchemaRe.exec(raw);
278
- return raw.slice(start, nextMatch?.index ?? raw.length);
279
- }
280
-
281
- function extractDeclaredOpenApiTags(raw: string): string[] {
282
- const tagsStart = raw.indexOf('\ntags:\n');
283
- const pathsStart = raw.indexOf('\n# ─────────────────────────────────────────────────────────────────────────────\n# PATHS', tagsStart);
284
- expect(tagsStart, 'OpenAPI MUST include top-level tags').toBeGreaterThanOrEqual(0);
285
- expect(pathsStart, 'OpenAPI tags block MUST precede paths block').toBeGreaterThan(tagsStart);
286
-
287
- const tagsBlock = raw.slice(tagsStart, pathsStart);
288
- const tags: string[] = [];
289
- const re = /^\s+- name:\s*([A-Za-z0-9_-]+)\s*$/gm;
290
- let m: RegExpExecArray | null;
291
- while ((m = re.exec(tagsBlock)) !== null) {
292
- if (m[1]) tags.push(m[1]);
293
- }
294
- return tags;
295
- }
296
-
297
- function extractOpenApiOperationTags(raw: string): string[] {
298
- const tags: string[] = [];
299
- const re = /^\s+tags:\s*\[([^\]]+)\]\s*$/gm;
300
- let m: RegExpExecArray | null;
301
- while ((m = re.exec(raw)) !== null) {
302
- const names = (m[1] ?? '')
303
- .split(',')
304
- .map((name) => name.trim())
305
- .filter((name) => name.length > 0);
306
- tags.push(...names);
307
- }
308
- return tags;
309
- }
310
-
311
- function findRunEventTypeEnum(schema: unknown): string[] {
312
- const visit = (value: unknown): string[] | null => {
313
- if (value === null || typeof value !== 'object') return null;
314
- const obj = value as Record<string, unknown>;
315
- if (
316
- Array.isArray(obj.enum) &&
317
- obj.enum.every((entry) => typeof entry === 'string') &&
318
- obj.enum.includes('run.started')
319
- ) {
320
- return obj.enum as string[];
321
- }
322
- for (const child of Object.values(obj)) {
323
- const found = visit(child);
324
- if (found !== null) return found;
325
- }
326
- return null;
327
- };
328
-
329
- const found = visit(schema);
330
- expect(found, 'run-event.schema.json MUST contain the RunEventType enum').not.toBeNull();
331
- return found ?? [];
332
- }
333
-
334
- function extractAsyncApiMessageNames(raw: string): string[] {
335
- const messagesStart = raw.indexOf('\n messages:\n');
336
- const schemasStart = raw.indexOf('\n # ── Schemas', messagesStart);
337
- expect(messagesStart, 'AsyncAPI MUST include components.messages').toBeGreaterThanOrEqual(0);
338
- expect(schemasStart, 'AsyncAPI messages block MUST precede schemas block').toBeGreaterThan(messagesStart);
339
-
340
- const messagesBlock = raw.slice(messagesStart, schemasStart);
341
- const names: string[] = [];
342
- const re = /^\s{6}name:\s*([^\s#]+)\s*$/gm;
343
- let m: RegExpExecArray | null;
344
- while ((m = re.exec(messagesBlock)) !== null) {
345
- if (m[1]) names.push(m[1]);
346
- }
347
- return names;
348
- }
349
-
350
- function extractTopLevelYamlKeysBetween(raw: string, startMarker: string, endMarker: string): string[] {
351
- const start = raw.indexOf(startMarker);
352
- const end = raw.indexOf(endMarker, start);
353
- expect(start, `YAML block start marker not found: ${startMarker}`).toBeGreaterThanOrEqual(0);
354
- expect(end, `YAML block end marker not found after ${startMarker}: ${endMarker}`).toBeGreaterThan(start);
355
-
356
- const block = raw.slice(start + startMarker.length, end);
357
- const keys: string[] = [];
358
- const re = /^\s{2}([A-Za-z0-9_-]+):\s*$/gm;
359
- let m: RegExpExecArray | null;
360
- while ((m = re.exec(block)) !== null) {
361
- if (m[1]) keys.push(m[1]);
362
- }
363
- return keys;
364
- }
365
-
366
- function extractAsyncApiOperationChannelRefs(raw: string): string[] {
367
- const operationsStart = raw.indexOf('\noperations:\n');
368
- const componentsStart = raw.indexOf('\n# ─────────────────────────────────────────────────────────────────────────────\n# COMPONENTS', operationsStart);
369
- expect(operationsStart, 'AsyncAPI MUST include operations').toBeGreaterThanOrEqual(0);
370
- expect(componentsStart, 'AsyncAPI operations block MUST precede components block').toBeGreaterThan(operationsStart);
371
-
372
- const operationsBlock = raw.slice(operationsStart, componentsStart);
373
- const refs: string[] = [];
374
- const re = /^\s{6}\$ref:\s*'#\/channels\/([A-Za-z0-9_-]+)'\s*$/gm;
375
- let m: RegExpExecArray | null;
376
- while ((m = re.exec(operationsBlock)) !== null) {
377
- if (m[1]) refs.push(m[1]);
378
- }
379
- return refs;
380
- }
381
-
382
- function extractReadmeDocumentIndex(readme: string): string {
383
- const start = readme.indexOf('## Document index');
384
- const end = readme.indexOf('## Quickstart', start);
385
- expect(start, 'README.md MUST contain a "## Document index" section').toBeGreaterThanOrEqual(0);
386
- expect(end, 'README.md Document index MUST be followed by "## Quickstart"').toBeGreaterThan(start);
387
- return readme.slice(start, end);
388
- }
389
-
390
- /**
391
- * The set of `.md` paths git TRACKS under `repoRoot`, or `null` when git can't answer
392
- * (no repo, no git binary — the published-tarball layout, a vendored corpus, a Docker
393
- * stage without git).
394
- *
395
- * WHY THIS EXISTS. The link checker used to walk the filesystem, so its verdict depended
396
- * on whatever untracked residue a working tree happened to carry. A real instance: a peer
397
- * host's conformance run reported a broken link in `plans/…` — a directory DELETED in
398
- * `937a9d85` and since gitignored, whose files survive as untracked leftovers in any tree
399
- * that predates the removal. CI (a clean checkout) has never seen it and never could.
400
- *
401
- * A gate that passes in CI and fails on a developer's machine for reasons invisible to
402
- * both is a gate people learn to discount, which is how a gate stops being run. Tracked
403
- * files are the corpus; everything else is the developer's business.
404
- */
405
- function listTrackedMarkdown(repoRoot: string): Set<string> | null {
406
- const res = spawnSync('git', ['-C', repoRoot, 'ls-files', '-z', '--', '*.md'], {
407
- encoding: 'utf8',
408
- maxBuffer: 32 * 1024 * 1024,
409
- });
410
- if (res.error !== undefined || res.status !== 0 || typeof res.stdout !== 'string') return null;
411
- const rels = res.stdout.split('\0').filter((r) => r !== '');
412
- // An empty tracked set is indistinguishable from "git answered about the wrong tree";
413
- // treat it as unknown rather than as "the corpus has no Markdown", which would silently
414
- // turn the whole link check into a no-op.
415
- if (rels.length === 0) return null;
416
- return new Set(rels.map((r) => pathResolve(repoRoot, r)));
417
- }
418
-
419
- function listMarkdownFilesRecursive(
420
- dir: string,
421
- repoRoot: string = dir,
422
- tracked: Set<string> | null = null,
423
- ): string[] {
424
- const ignoredDirs = new Set([
425
- '.git',
426
- 'node_modules',
427
- 'dist',
428
- // CI cross-repo checkouts: the host-conformance workflows (conformance-soak,
429
- // postgres-host-conformance) check out openwop-examples + openwop-registry into
430
- // examples-ext/ + registry-ext/ inside the workspace. Those carry their own
431
- // READMEs whose links are relative to THEIR repo root (../../spec, ../../RFCS,
432
- // ../../conformance, …) and don't resolve from this corpus. They're link-checked
433
- // in their own repos; do not scan a vendored sibling-repo checkout here.
434
- 'examples-ext',
435
- 'registry-ext',
436
- ]);
437
- // Repo-relative directory paths to prune. These are subtrees whose
438
- // content shouldn't be link-checked because either (a) they're
439
- // generated build output (`site/out`) or (b) they're a vendored
440
- // mirror of a canonical source whose READMEs use links relative to
441
- // the canonical path, not the vendored path:
442
- //
443
- // - `apps/workflow-engine/packs/` mirrors repo-root `packs/`, synced
444
- // via `apps/workflow-engine/scripts/sync-packs.sh` so the Cloud
445
- // Run image's `apps/workflow-engine/` build context can ship them.
446
- // Pack READMEs use `../../RFCS/...` / `../../spec/v1/...` links
447
- // that resolve from the canonical location (which this walker
448
- // DOES check) but break from the deeper vendored path. The
449
- // canonical copies are authoritative; the vendored copies are
450
- // byte-for-byte identical via cp -R.
451
- const prunedRepoRelative = new Set(['site/out', 'apps/workflow-engine/packs']);
452
- const files: string[] = [];
453
-
454
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
455
- if (entry.isDirectory()) {
456
- if (ignoredDirs.has(entry.name)) continue;
457
- const child = join(dir, entry.name);
458
- const repoRelChild = relative(repoRoot, child);
459
- if (prunedRepoRelative.has(repoRelChild)) continue;
460
- files.push(...listMarkdownFilesRecursive(child, repoRoot, tracked));
461
- continue;
462
- }
463
- if (entry.isFile() && entry.name.endsWith('.md')) {
464
- const full = join(dir, entry.name);
465
- // `tracked === null` ⇒ git couldn't answer; fall back to the filesystem walk rather
466
- // than skipping the check entirely. A noisier gate beats a silently absent one.
467
- if (tracked !== null && !tracked.has(pathResolve(full))) continue;
468
- files.push(full);
469
- }
470
- }
471
-
472
- return files;
473
- }
474
-
475
- function stripFencedCodeBlocks(markdown: string): string {
476
- return markdown.replace(/```[\s\S]*?```/g, '');
477
- }
478
-
479
- function stripInlineCodeSpans(markdown: string): string {
480
- // Strip double-backtick spans first so the inner segment of a span
481
- // containing a literal backtick (``foo `bar` baz``) doesn't get
482
- // mis-stripped by the single-backtick pass, leaving stray openers
483
- // that could pair with later backticks elsewhere in the file.
484
- return markdown.replace(/``[^`\n]+``/g, '').replace(/`[^`\n]*`/g, '');
485
- }
486
-
487
- function extractLocalMarkdownLinks(markdown: string): string[] {
488
- const links: string[] = [];
489
- const re = /!?\[[^\]\n]*\]\(([^)\n]+)\)/g;
490
- let m: RegExpExecArray | null;
491
-
492
- while ((m = re.exec(stripInlineCodeSpans(stripFencedCodeBlocks(markdown)))) !== null) {
493
- let raw = (m[1] ?? '').trim();
494
- raw = raw.replace(/\s+"[^"]*"$/, '').trim();
495
- if (raw.startsWith('<') && raw.endsWith('>')) raw = raw.slice(1, -1);
496
-
497
- if (
498
- raw === '' ||
499
- raw.startsWith('#') ||
500
- raw.startsWith('/') ||
501
- raw.startsWith('http://') ||
502
- raw.startsWith('https://') ||
503
- raw.startsWith('mailto:') ||
504
- raw.startsWith('data:') ||
505
- raw.includes('://')
506
- ) {
507
- continue;
508
- }
509
-
510
- links.push(raw);
511
- }
512
-
513
- return links;
514
- }
515
-
516
- // ── Scenarios ───────────────────────────────────────────────────────────
517
-
518
- /**
519
- * Provenance stamp written into the VENDORED `schemas/` at prepack (RFC 0145 G2).
520
- * It never exists in the repo tree, but it DOES exist when this suite runs from the
521
- * published package — where `SCHEMAS_DIR` points at the vendored copy that carries it.
522
- * "Written only into the tarball" and "invisible to this gate" are therefore NOT the
523
- * same claim, and the difference only shows up for a consumer, never locally.
524
- * It is data about the contract, not a schema, so it is excluded by name rather than
525
- * by extension — the `.json` suffix is what makes it readable, and what would
526
- * otherwise feed it to Ajv.
527
- */
528
- const CORPUS_STAMP = 'CORPUS-STAMP.json';
529
-
530
- describe('spec-corpus: JSON Schemas compile under Ajv2020', () => {
531
- const schemaFiles = listJsonFiles(SCHEMAS_DIR).filter((f) => f !== CORPUS_STAMP);
532
- const ajv = new Ajv2020({ allErrors: true, strict: false });
533
- addFormats(ajv);
534
-
535
- // Pre-register every schema with the Ajv instance so cross-file `$ref`s
536
- // resolve regardless of compile order. Without this, a cross-ref from
537
- // an alphabetically-earlier file (e.g. capabilities.schema.json) to a
538
- // later one (e.g. prompt-kind.schema.json) fails with "can't resolve
539
- // reference." `addSchema` only registers — it doesn't compile — so
540
- // per-file compilation errors still surface in their own `it()` below.
541
- for (const file of schemaFiles) {
542
- try {
543
- ajv.addSchema(readJson(join(SCHEMAS_DIR, file)) as Record<string, unknown>);
544
- } catch {
545
- // Bad schemas surface in the per-file `compile()` below; swallow
546
- // here so registration order doesn't short-circuit reporting.
547
- }
548
- }
549
-
550
- it('finds at least three schemas (workflow-definition, run-event, suspend-request)', () => {
551
- expect(schemaFiles.length).toBeGreaterThanOrEqual(3);
552
- expect(schemaFiles).toContain('workflow-definition.schema.json');
553
- expect(schemaFiles).toContain('run-event.schema.json');
554
- expect(schemaFiles).toContain('suspend-request.schema.json');
555
- });
556
-
557
- for (const file of schemaFiles) {
558
- it(`${file} parses + compiles`, () => {
559
- const schema = readJson(join(SCHEMAS_DIR, file)) as Record<string, unknown>;
560
- expect(schema['$schema']).toBe('https://json-schema.org/draft/2020-12/schema');
561
- expect(schema['$id'], `${file} $id MUST match its canonical openwop.dev URL`).toBe(
562
- `https://openwop.dev/spec/v1/${file}`,
563
- );
564
- expect(typeof schema['title']).toBe('string');
565
- // `compile` uses the schemas registered by `addSchema` above to
566
- // resolve cross-file `$ref`s — throws on structural issues.
567
- const validate = ajv.getSchema(schema['$id'] as string) ?? ajv.compile(schema);
568
- expect(typeof validate).toBe('function');
569
- });
570
- }
571
- });
572
-
573
- describe('spec-corpus: schemas/README.md index matches schema files', () => {
574
- const schemaFiles = listJsonFiles(SCHEMAS_DIR).filter((f) => f.endsWith('.schema.json')).sort();
575
- const schemasReadmePath = join(SCHEMAS_DIR, 'README.md');
576
-
577
- it('schemas/README.md exists next to schema files', () => {
578
- expect(existsSync(schemasReadmePath), 'schemas/README.md MUST exist').toBe(true);
579
- });
580
-
581
- it('schemas/README.md lists every *.schema.json exactly once', () => {
582
- const readme = readFileSync(schemasReadmePath, 'utf8');
583
- const tableStart = readme.indexOf('| Schema | Source spec | Coverage |');
584
- const tableEnd = readme.indexOf('## Validating against the schemas', tableStart);
585
-
586
- expect(tableStart, 'schemas/README.md MUST include the schema index table').toBeGreaterThanOrEqual(0);
587
- expect(tableEnd, 'schemas/README.md schema index MUST precede validation instructions').toBeGreaterThan(tableStart);
588
-
589
- const table = readme.slice(tableStart, tableEnd);
590
- const mentioned = table
591
- .split('\n')
592
- .map((line) => line.match(/^\|\s+`([^`]+\.schema\.json)`\s+\|/)?.[1])
593
- .filter((name): name is string => typeof name === 'string');
594
-
595
- for (const file of schemaFiles) {
596
- const occurrences = mentioned.filter((name) => name === file).length;
597
- expect(
598
- occurrences,
599
- `schemas/README.md MUST list ${file} exactly once`,
600
- ).toBe(1);
601
- }
602
-
603
- for (const file of mentioned) {
604
- expect(
605
- schemaFiles,
606
- `schemas/README.md lists ${file}, but no matching schema file exists`,
607
- ).toContain(file);
608
- }
609
- });
610
- });
611
-
612
- describe('spec-corpus: absolute JSON Schema refs resolve inside the corpus', () => {
613
- const schemaFiles = listJsonFiles(SCHEMAS_DIR).filter((f) => f.endsWith('.schema.json')).sort();
614
- const schemaIds = new Set(
615
- schemaFiles.map((file) => {
616
- const schema = readJson(join(SCHEMAS_DIR, file)) as Record<string, unknown>;
617
- return schema.$id;
618
- }),
619
- );
620
-
621
- for (const file of schemaFiles) {
622
- it(`${file} absolute $refs point to known schema ids`, () => {
623
- const schema = readJson(join(SCHEMAS_DIR, file));
624
- const refs = collectJsonRefs(schema)
625
- .filter((ref) => ref.startsWith('https://openwop.dev/spec/v1/'))
626
- .map((ref) => ref.split('#')[0] ?? ref);
627
-
628
- for (const ref of refs) {
629
- expect(
630
- schemaIds.has(ref),
631
- `${file} has absolute $ref ${ref}, but no schema file declares that $id`,
632
- ).toBe(true);
633
- }
634
- });
635
- }
636
- });
637
-
638
- describe('spec-corpus: RunEventType payload index matches event enum', () => {
639
- const runEventSchema = readJson(join(SCHEMAS_DIR, 'run-event.schema.json')) as Record<string, unknown>;
640
- const payloadSchema = readJson(join(SCHEMAS_DIR, 'run-event-payloads.schema.json')) as Record<string, unknown>;
641
-
642
- function typeIndexProperties(): Record<string, unknown> {
643
- const defs = payloadSchema.$defs as Record<string, unknown> | undefined;
644
- const typeIndex = defs?._typeIndex as Record<string, unknown> | undefined;
645
- const properties = typeIndex?.properties as Record<string, unknown> | undefined;
646
- expect(properties, 'run-event-payloads.schema.json MUST include $defs._typeIndex.properties').toBeDefined();
647
- return properties ?? {};
648
- }
649
-
650
- it('payload type-index keys exactly match RunEventType enum values', () => {
651
- const runEventTypes = findRunEventTypeEnum(runEventSchema).sort();
652
- const indexedTypes = Object.keys(typeIndexProperties()).sort();
653
-
654
- expect(indexedTypes, 'run-event-payloads.schema.json _typeIndex MUST cover every RunEventType').toEqual(
655
- runEventTypes,
656
- );
657
- });
658
-
659
- it('payload type-index refs point to declared payload $defs', () => {
660
- const defs = payloadSchema.$defs as Record<string, unknown> | undefined;
661
- expect(defs, 'run-event-payloads.schema.json MUST declare $defs').toBeDefined();
662
-
663
- for (const [eventType, entry] of Object.entries(typeIndexProperties())) {
664
- const ref = (entry as Record<string, unknown>).$ref;
665
- expect(typeof ref, `_typeIndex.${eventType} MUST be a $ref`).toBe('string');
666
- const defName = String(ref).match(/^#\/\$defs\/([A-Za-z0-9_-]+)$/)?.[1];
667
- expect(defName, `_typeIndex.${eventType} MUST reference #/$defs/<name>`).toBeDefined();
668
- expect(
669
- Object.prototype.hasOwnProperty.call(defs ?? {}, defName ?? ''),
670
- `_typeIndex.${eventType} references missing payload definition "${defName}"`,
671
- ).toBe(true);
672
- }
673
- });
674
-
675
- it('payload schema description states the current RunEventType variant count', () => {
676
- const runEventTypes = findRunEventTypeEnum(runEventSchema);
677
- const description = payloadSchema.description;
678
-
679
- expect(typeof description, 'run-event-payloads.schema.json MUST carry a description').toBe('string');
680
- expect(
681
- description,
682
- 'run-event-payloads.schema.json description MUST state the current RunEventType variant count',
683
- ).toContain(`${runEventTypes.length} variants from \`run-event.schema.json#$defs.RunEventType\``);
684
- });
685
- });
686
-
687
- describe('spec-corpus: OpenAPI 3.1 spec is structurally valid', () => {
688
- const openapiPath = join(API_DIR, 'openapi.yaml');
689
-
690
- it('exists', () => {
691
- expect(existsSync(openapiPath)).toBe(true);
692
- });
693
-
694
- it('declares openapi: 3.1 + required top-level keys', () => {
695
- const { topLevelKeys, raw } = readYamlHeader(openapiPath);
696
- expect(topLevelKeys.has('openapi')).toBe(true);
697
- expect(topLevelKeys.has('info')).toBe(true);
698
- expect(topLevelKeys.has('paths')).toBe(true);
699
- expect(topLevelKeys.has('components')).toBe(true);
700
- expect(raw).toMatch(/^openapi:\s*3\.1(?:\.[0-9]+)?\s*$/m);
701
- });
702
-
703
- it('every $ref to ../schemas/*.json resolves to a real file', () => {
704
- const { raw } = readYamlHeader(openapiPath);
705
- const refs = extractRefs(raw).filter((r) => r.startsWith('../schemas/'));
706
- expect(refs.length).toBeGreaterThan(0); // at least one schema reference
707
- for (const ref of refs) {
708
- const abs = pathResolve(API_DIR, ref.split('#')[0] ?? ref);
709
- expect(existsSync(abs), `OpenAPI $ref points at missing file: ${ref}`).toBe(true);
710
- }
711
- });
712
-
713
- it('operationIds are unique', () => {
714
- const { raw } = readYamlHeader(openapiPath);
715
- const operationIds = extractOpenApiOperationIds(raw);
716
- const duplicates = operationIds.filter((id, index) => operationIds.indexOf(id) !== index);
717
-
718
- expect(operationIds.length, 'OpenAPI MUST declare operationIds for public routes').toBeGreaterThan(0);
719
- expect(duplicates, `OpenAPI operationIds MUST be unique; duplicates: ${duplicates.join(', ')}`).toEqual([]);
720
- });
721
-
722
- it('every operation tag is declared in the top-level tags list', () => {
723
- const { raw } = readYamlHeader(openapiPath);
724
- const declaredTags = new Set(extractDeclaredOpenApiTags(raw));
725
- const operationTags = extractOpenApiOperationTags(raw);
726
-
727
- expect(declaredTags.size, 'OpenAPI MUST declare at least one top-level tag').toBeGreaterThan(0);
728
- expect(operationTags.length, 'OpenAPI operations MUST carry tags').toBeGreaterThan(0);
729
-
730
- for (const tag of operationTags) {
731
- expect(
732
- declaredTags.has(tag),
733
- `OpenAPI operation tag "${tag}" MUST be declared in the top-level tags list`,
734
- ).toBe(true);
735
- }
736
- });
737
-
738
- it('declares ApiKeyAuth as the global default security requirement', () => {
739
- const { raw } = readYamlHeader(openapiPath);
740
-
741
- expect(raw, 'OpenAPI MUST declare global ApiKeyAuth security').toMatch(
742
- /^security:\n\s+- ApiKeyAuth:\s*\[\]\s*$/m,
743
- );
744
- expect(raw, 'OpenAPI MUST define ApiKeyAuth as an HTTP bearer security scheme').toMatch(
745
- /^\s{4}ApiKeyAuth:\n\s{6}type:\s*http\n\s{6}scheme:\s*bearer\s*$/m,
746
- );
747
- });
748
-
749
- it('only documented public or signed-token operations clear security', () => {
750
- const { raw } = readYamlHeader(openapiPath);
751
- const publicOperationIds = new Set([
752
- 'getCapabilities',
753
- 'getOpenApiSpec',
754
- 'inspectInterruptByToken',
755
- 'resolveInterruptByToken',
756
- 'getContentPage',
757
- ]);
758
-
759
- const operations = extractOpenApiOperations(raw);
760
- expect(operations.length, 'OpenAPI MUST expose operations').toBeGreaterThan(0);
761
-
762
- for (const operation of operations) {
763
- expect(
764
- operation.clearsSecurity,
765
- `OpenAPI operation ${operation.operationId} security override MUST match its public/signed-token status`,
766
- ).toBe(publicOperationIds.has(operation.operationId));
767
- }
768
- });
769
-
770
- it('protected operations document canonical 401 and 403 auth failure responses', () => {
771
- const { raw } = readYamlHeader(openapiPath);
772
- const operations = extractOpenApiOperations(raw);
773
-
774
- expect(operations.length, 'OpenAPI MUST expose operations').toBeGreaterThan(0);
775
-
776
- for (const operation of operations) {
777
- if (operation.clearsSecurity) continue;
778
- expect(
779
- operation.responseStatusCodes,
780
- `Protected OpenAPI operation ${operation.operationId} MUST document 401 Unauthenticated`,
781
- ).toContain('401');
782
- expect(
783
- operation.responseStatusCodes,
784
- `Protected OpenAPI operation ${operation.operationId} MUST document 403 Forbidden`,
785
- ).toContain('403');
786
- }
787
- });
788
-
789
- // ── Reserved-route disambiguation (RFC 0086/0087 + audit PR #495) ──────────
790
- // The literal collection routes /v1/agents/roster and /v1/agents/org-chart
791
- // share a prefix with the parameterized /v1/agents/{agentId}. The mitigation
792
- // excludes the reserved literals from the {agentId} path param via a
793
- // negative-lookahead pattern, AND the agent-manifest agentId pattern requires
794
- // a dotted-tier form the bare literals can't satisfy. These guard both halves
795
- // from silently regressing (the external standards-readiness audit asked the
796
- // reserved-route mitigation be bound to a test).
797
- it('declares both the literal /v1/agents/{roster,org-chart} routes and the {agentId} param route', () => {
798
- const { raw } = readYamlHeader(openapiPath);
799
- expect(raw).toContain('/v1/agents/{agentId}:');
800
- expect(raw).toContain('/v1/agents/roster:');
801
- expect(raw).toContain('/v1/agents/org-chart:');
802
- });
803
-
804
- it('every /v1/agents/{agentId} param excludes the reserved literals (roster, org-chart)', () => {
805
- const { raw } = readYamlHeader(openapiPath);
806
- const paramRoutes = (raw.match(/\/v1\/agents\/\{agentId\}/g) ?? []).length;
807
- const exclusions = (raw.match(/\(\?!roster\$\|org-chart\$\)/g) ?? []).length;
808
- expect(paramRoutes, 'expected ≥2 /v1/agents/{agentId...} routes (base + /deployments)').toBeGreaterThanOrEqual(2);
809
- expect(
810
- exclusions,
811
- 'each /v1/agents/{agentId} param schema MUST exclude the reserved literals via a (?!roster$|org-chart$) lookahead',
812
- ).toBeGreaterThanOrEqual(2);
813
- });
814
-
815
- it('the reserved-literal exclusion pattern rejects roster/org-chart and accepts a real agentId', () => {
816
- const re = /^(?!roster$|org-chart$).+$/;
817
- expect(re.test('roster'), 'roster MUST NOT match the {agentId} param').toBe(false);
818
- expect(re.test('org-chart'), 'org-chart MUST NOT match the {agentId} param').toBe(false);
819
- expect(re.test('core.example.pack.agent')).toBe(true);
820
- });
821
-
822
- it('the agent-manifest agentId pattern can never produce a reserved literal (defense in depth)', () => {
823
- const manifest = readJson(join(SCHEMAS_DIR, 'agent-manifest.schema.json')) as {
824
- properties?: { agentId?: { pattern?: string } };
825
- };
826
- const pattern = manifest.properties?.agentId?.pattern;
827
- expect(typeof pattern, 'agent-manifest.schema.json MUST constrain agentId with a pattern').toBe('string');
828
- const re = new RegExp(pattern as string);
829
- expect(re.test('roster'), 'manifest agentId MUST NOT permit the reserved literal `roster`').toBe(false);
830
- expect(re.test('org-chart'), 'manifest agentId MUST NOT permit the reserved literal `org-chart`').toBe(false);
831
- expect(re.test('core.example.pack.agent')).toBe(true);
832
- });
833
-
834
- it('typed error specializations compose the canonical Error schema', () => {
835
- const { raw } = readYamlHeader(openapiPath);
836
-
837
- for (const schemaName of ['RunClaimConflict', 'UnsupportedStreamMode']) {
838
- const block = extractOpenApiComponentSchemaBlock(raw, schemaName);
839
- expect(
840
- block,
841
- `OpenAPI ${schemaName} MUST compose the canonical Error schema`,
842
- ).toContain("- $ref: '#/components/schemas/Error'");
843
- expect(
844
- block,
845
- `OpenAPI ${schemaName} MUST keep typed metadata under details`,
846
- ).toMatch(/^\s{12}details:\s*$/m);
847
- expect(
848
- block,
849
- `OpenAPI ${schemaName} MUST require the canonical error/message/details top-level fields`,
850
- ).toContain('required: [error, message, details]');
851
- }
852
- });
853
- });
854
-
855
- describe.skipIf(COVERAGE_DOC_PATH === null)('spec-corpus: OpenAPI operation coverage map', () => {
856
- const openapiPath = join(API_DIR, 'openapi.yaml');
857
- const coverageDocPath = COVERAGE_DOC_PATH as string;
858
-
859
- it('every OpenAPI operationId is represented in conformance/coverage.md', () => {
860
- const { raw } = readYamlHeader(openapiPath);
861
- const operationIds = extractOpenApiOperationIds(raw);
862
- const coverage = readFileSync(coverageDocPath, 'utf8');
863
-
864
- expect(operationIds.length, 'OpenAPI MUST declare operationIds for public routes').toBeGreaterThan(0);
865
-
866
- for (const operationId of operationIds) {
867
- expect(
868
- coverage,
869
- `conformance/coverage.md MUST mention OpenAPI operationId "${operationId}"`,
870
- ).toContain(`\`${operationId}\``);
871
- }
872
- });
873
- });
874
-
875
- describe.skipIf(V1_DIR === null)('spec-corpus: REST endpoint catalog matches OpenAPI paths', () => {
876
- const openapiPath = join(API_DIR, 'openapi.yaml');
877
- const restEndpointsDocPath = V1_DIR === null ? '' : join(V1_DIR, 'rest-endpoints.md');
878
-
879
- it('every OpenAPI operation has a matching method/path row in rest-endpoints.md', () => {
880
- const { raw } = readYamlHeader(openapiPath);
881
- const operations = extractOpenApiOperations(raw);
882
- const catalogRows = extractRestEndpointCatalogRows(readFileSync(restEndpointsDocPath, 'utf8'));
883
- const catalogKeys = new Set(catalogRows.map((row) => `${row.method} ${row.path}`));
884
-
885
- expect(operations.length, 'OpenAPI MUST expose operations').toBeGreaterThan(0);
886
- expect(catalogRows.length, 'rest-endpoints.md MUST include endpoint catalog rows').toBeGreaterThan(0);
887
-
888
- for (const operation of operations) {
889
- const key = `${operation.method} ${operation.path}`;
890
- expect(
891
- catalogKeys.has(key),
892
- `rest-endpoints.md MUST document OpenAPI operation ${operation.operationId} as ${key}`,
893
- ).toBe(true);
894
- }
895
- });
896
-
897
- it('REST catalog auth/scope columns match OpenAPI security overrides', () => {
898
- const { raw } = readYamlHeader(openapiPath);
899
- const operations = extractOpenApiOperations(raw);
900
- const catalogRows = extractRestEndpointCatalogRows(readFileSync(restEndpointsDocPath, 'utf8'));
901
- const catalogByKey = new Map(catalogRows.map((row) => [`${row.method} ${row.path}`, row]));
902
-
903
- for (const operation of operations) {
904
- const row = catalogByKey.get(`${operation.method} ${operation.path}`);
905
- expect(row, `rest-endpoints.md MUST document ${operation.method.toUpperCase()} ${operation.path}`).toBeDefined();
906
- if (!row) continue;
907
-
908
- if (operation.clearsSecurity) {
909
- expect(
910
- ['None', 'Signed token'],
911
- `${operation.operationId} clears OpenAPI security, so rest-endpoints.md MUST mark auth as None or Signed token`,
912
- ).toContain(row.auth);
913
- expect(
914
- row.scope,
915
- `${operation.operationId} clears OpenAPI security, so rest-endpoints.md MUST mark scope as None`,
916
- ).toBe('None');
917
- } else {
918
- expect(
919
- row.auth,
920
- `${operation.operationId} inherits OpenAPI ApiKeyAuth, so rest-endpoints.md MUST mark auth as API key`,
921
- ).toBe('API key');
922
- expect(
923
- row.scope,
924
- `${operation.operationId} inherits OpenAPI ApiKeyAuth, so rest-endpoints.md MUST name a non-empty non-None scope`,
925
- ).not.toBe('None');
926
- }
927
- }
928
- });
929
- });
930
-
931
- describe.skipIf(V1_DIR === null)('spec-corpus: error examples use canonical details slot', () => {
932
- const v1Dir = V1_DIR as string;
933
- const docsToCheck = ['auth.md', 'idempotency.md', 'rest-endpoints.md'];
934
-
935
- for (const file of docsToCheck) {
936
- it(`${file} does not document retry/conflict metadata as top-level error fields`, () => {
937
- const content = readFileSync(join(v1Dir, file), 'utf8');
938
-
939
- expect(
940
- content,
941
- `${file} MUST NOT show retryAfter as a top-level error field; use details.retryAfter`,
942
- ).not.toMatch(/\{\s*(?:[^{}]|\{[^{}]*\})*error:[^{}]*retryAfter[^{}]*\}/);
943
- expect(
944
- content,
945
- `${file} MUST NOT show activeRunId/activeHost as top-level error fields; use details.{activeRunId,activeHost}`,
946
- ).not.toMatch(/\{\s*(?:[^{}]|\{[^{}]*\})*error:[^{}]*(activeRunId|activeHost)[^{}]*\}/);
947
- });
948
- }
949
- });
950
-
951
- describe.skipIf(
952
- TYPESCRIPT_RUN_HELPERS_PATH === null || PYTHON_TYPES_PATH === null || GO_TYPES_PATH === null,
953
- )(
954
- 'spec-corpus: SDK HTTP error helpers match canonical REST vocabulary',
955
- () => {
956
- // describe.skipIf still evaluates the body for test registration; defaults guard against null
957
- // dirname() when sources are missing under the published-tarball layout. it() blocks below are
958
- // skipped at run time, so the path values are never actually read. The sentinel is
959
- // intentionally an obviously-invalid path so a stack trace from any future code that DOES
960
- // dereference it points the reader at this comment.
961
- const UNUSED_IN_PUBLISHED_LAYOUT = '/__sdk_paths_unused_in_published_layout__';
962
- const sdkSources = {
963
- typescript: TYPESCRIPT_RUN_HELPERS_PATH ?? UNUSED_IN_PUBLISHED_LAYOUT,
964
- python: PYTHON_TYPES_PATH ?? UNUSED_IN_PUBLISHED_LAYOUT,
965
- go: GO_TYPES_PATH ?? UNUSED_IN_PUBLISHED_LAYOUT,
966
- };
967
- const sdkReadmes = {
968
- typescript: pathResolve(dirname(sdkSources.typescript), '..', 'README.md'),
969
- python: pathResolve(dirname(sdkSources.python), '..', '..', 'README.md'),
970
- go: pathResolve(dirname(sdkSources.go), 'README.md'),
971
- };
972
- const typescriptDist = {
973
- indexDts: pathResolve(dirname(sdkSources.typescript), '..', 'dist', 'index.d.ts'),
974
- indexJs: pathResolve(dirname(sdkSources.typescript), '..', 'dist', 'index.js'),
975
- runHelpersDts: pathResolve(dirname(sdkSources.typescript), '..', 'dist', 'run-helpers.d.ts'),
976
- runHelpersJs: pathResolve(dirname(sdkSources.typescript), '..', 'dist', 'run-helpers.js'),
977
- };
978
- const typescriptDistMaps = {
979
- indexDts: pathResolve(dirname(sdkSources.typescript), '..', 'dist', 'index.d.ts.map'),
980
- indexJs: pathResolve(dirname(sdkSources.typescript), '..', 'dist', 'index.js.map'),
981
- runHelpersDts: pathResolve(dirname(sdkSources.typescript), '..', 'dist', 'run-helpers.d.ts.map'),
982
- runHelpersJs: pathResolve(dirname(sdkSources.typescript), '..', 'dist', 'run-helpers.js.map'),
983
- };
984
- const sdkChangelogs = {
985
- typescript: pathResolve(dirname(sdkSources.typescript), '..', 'CHANGELOG.md'),
986
- python: pathResolve(dirname(sdkSources.python), '..', '..', 'CHANGELOG.md'),
987
- go: pathResolve(dirname(sdkSources.go), 'CHANGELOG.md'),
988
- };
989
-
990
- it('TypeScript exports HTTP_ERROR_CODES and isHttpErrorCode', () => {
991
- const source = readFileSync(sdkSources.typescript, 'utf8');
992
-
993
- expect(source, 'TypeScript SDK MUST export HTTP_ERROR_CODES').toContain('export const HTTP_ERROR_CODES');
994
- expect(source, 'TypeScript SDK MUST export isHttpErrorCode').toContain('export function isHttpErrorCode');
995
- });
996
-
997
- it('Python exports HTTP_ERROR_CODES and is_http_error_code', () => {
998
- const source = readFileSync(sdkSources.python, 'utf8');
999
-
1000
- expect(source, 'Python SDK MUST export HTTP_ERROR_CODES').toContain('HTTP_ERROR_CODES = frozenset');
1001
- expect(source, 'Python SDK MUST export is_http_error_code').toContain('def is_http_error_code');
1002
- });
1003
-
1004
- it('Go exports HTTPErrorCodes and IsHTTPErrorCode', () => {
1005
- const source = readFileSync(sdkSources.go, 'utf8');
1006
-
1007
- expect(source, 'Go SDK MUST export HTTPErrorCodes').toContain('var HTTPErrorCodes = []string');
1008
- expect(source, 'Go SDK MUST export IsHTTPErrorCode').toContain('func IsHTTPErrorCode');
1009
- });
1010
-
1011
- it('SDK READMEs document the HTTP error helper surface', () => {
1012
- expect(readFileSync(sdkReadmes.typescript, 'utf8')).toContain('HTTP_ERROR_CODES');
1013
- expect(readFileSync(sdkReadmes.typescript, 'utf8')).toContain('isHttpErrorCode');
1014
- expect(readFileSync(sdkReadmes.python, 'utf8')).toContain('HTTP_ERROR_CODES');
1015
- expect(readFileSync(sdkReadmes.python, 'utf8')).toContain('is_http_error_code');
1016
- expect(readFileSync(sdkReadmes.go, 'utf8')).toContain('HTTPErrorCodes');
1017
- expect(readFileSync(sdkReadmes.go, 'utf8')).toContain('IsHTTPErrorCode');
1018
- });
1019
-
1020
- it('SDK changelogs mention the HTTP error helper surface', () => {
1021
- expect(readFileSync(sdkChangelogs.typescript, 'utf8')).toContain('HTTP_ERROR_CODES');
1022
- expect(readFileSync(sdkChangelogs.typescript, 'utf8')).toContain('isHttpErrorCode');
1023
- expect(readFileSync(sdkChangelogs.python, 'utf8')).toContain('HTTP_ERROR_CODES');
1024
- expect(readFileSync(sdkChangelogs.python, 'utf8')).toContain('is_http_error_code');
1025
- expect(readFileSync(sdkChangelogs.go, 'utf8')).toContain('HTTPErrorCodes');
1026
- expect(readFileSync(sdkChangelogs.go, 'utf8')).toContain('IsHTTPErrorCode');
1027
- });
1028
-
1029
- it('TypeScript dist exports the HTTP error helper surface', () => {
1030
- for (const [label, path] of Object.entries(typescriptDist)) {
1031
- expect(existsSync(path), `TypeScript dist artifact ${label} MUST exist`).toBe(true);
1032
- }
1033
-
1034
- expect(readFileSync(typescriptDist.indexDts, 'utf8')).toContain('HTTP_ERROR_CODES');
1035
- expect(readFileSync(typescriptDist.indexDts, 'utf8')).toContain('HttpErrorCode');
1036
- expect(readFileSync(typescriptDist.indexDts, 'utf8')).toContain('isHttpErrorCode');
1037
- expect(readFileSync(typescriptDist.indexJs, 'utf8')).toContain('HTTP_ERROR_CODES');
1038
- expect(readFileSync(typescriptDist.indexJs, 'utf8')).toContain('isHttpErrorCode');
1039
- expect(readFileSync(typescriptDist.runHelpersDts, 'utf8')).toContain('HTTP_ERROR_CODES');
1040
- expect(readFileSync(typescriptDist.runHelpersDts, 'utf8')).toContain('HttpErrorCode');
1041
- expect(readFileSync(typescriptDist.runHelpersJs, 'utf8')).toContain('HTTP_ERROR_CODES');
1042
- expect(readFileSync(typescriptDist.runHelpersJs, 'utf8')).toContain('isHttpErrorCode');
1043
- });
1044
-
1045
- it('TypeScript dist metadata uses openwop branding', () => {
1046
- for (const path of Object.values(typescriptDist)) {
1047
- const source = readFileSync(path, 'utf8');
1048
- expect(source, `${path} MUST NOT contain legacy MyndHyve package names`).not.toContain('@myndhyve');
1049
- expect(source, `${path} MUST use @openwop package naming`).toContain('@openwop');
1050
- }
1051
- });
1052
-
1053
- it('TypeScript dist source maps point back to src and avoid legacy branding', () => {
1054
- for (const [label, path] of Object.entries(typescriptDistMaps)) {
1055
- expect(existsSync(path), `TypeScript dist source map ${label} MUST exist`).toBe(true);
1056
- const sourceMap = readJson(path) as { sources?: unknown };
1057
- const raw = readFileSync(path, 'utf8');
1058
-
1059
- expect(raw, `${path} MUST NOT contain legacy MyndHyve package names`).not.toContain('@myndhyve');
1060
- expect(Array.isArray(sourceMap.sources), `${path} MUST declare source files`).toBe(true);
1061
- expect(
1062
- (sourceMap.sources as string[]).every((source) => source.startsWith('../src/')),
1063
- `${path} MUST map to TypeScript source files under ../src`,
1064
- ).toBe(true);
1065
- }
1066
- });
1067
-
1068
- for (const code of [
1069
- 'unauthenticated',
1070
- 'forbidden',
1071
- 'key_expired',
1072
- 'key_revoked',
1073
- 'validation_error',
1074
- 'not_found',
1075
- 'rate_limited',
1076
- 'run_already_active',
1077
- 'idempotency_in_flight',
1078
- 'unsupported_stream_mode',
1079
- 'credential_forbidden',
1080
- 'internal_error',
1081
- ]) {
1082
- it(`all SDK HTTP error helpers include ${code}`, () => {
1083
- for (const [sdk, path] of Object.entries(sdkSources)) {
1084
- const source = readFileSync(path, 'utf8');
1085
- expect(source, `${sdk} SDK MUST include canonical REST code ${code}`).toContain(code);
1086
- }
1087
- });
1088
- }
1089
- },
1090
- );
1091
-
1092
- describe.skipIf(SCENARIOS_DIR === null || CONFORMANCE_README_PATH === null)(
1093
- 'spec-corpus: conformance README scenario counts match source tree',
1094
- () => {
1095
- const scenariosDir = SCENARIOS_DIR as string;
1096
- const conformanceReadmePath = CONFORMANCE_README_PATH as string;
1097
-
1098
- it('README suite count equals src/scenarios/*.test.ts count', () => {
1099
- const scenarioFiles = listScenarioTestFiles(scenariosDir);
1100
- const readme = readFileSync(conformanceReadmePath, 'utf8');
1101
-
1102
- expect(scenarioFiles.length, 'conformance suite MUST contain scenario test files').toBeGreaterThan(0);
1103
- expect(
1104
- readme,
1105
- 'conformance/README.md MUST state the current scenario-file count in "What\'s Covered"',
1106
- ).toContain(`The current suite has ${scenarioFiles.length} scenario files under \`src/scenarios/\`.`);
1107
- expect(
1108
- readme,
1109
- 'conformance/README.md MUST state the current scenario-file count near historical notes',
1110
- ).toContain(`Current source tree: ${scenarioFiles.length} scenario files.`);
1111
- });
1112
- },
1113
- );
1114
-
1115
- describe('spec-corpus: AsyncAPI 3.1 spec is structurally valid', () => {
1116
- const asyncapiPath = join(API_DIR, 'asyncapi.yaml');
1117
-
1118
- it('exists', () => {
1119
- expect(existsSync(asyncapiPath)).toBe(true);
1120
- });
1121
-
1122
- it('declares asyncapi: 3.1 + required top-level keys', () => {
1123
- const { topLevelKeys, raw } = readYamlHeader(asyncapiPath);
1124
- expect(topLevelKeys.has('asyncapi')).toBe(true);
1125
- expect(topLevelKeys.has('info')).toBe(true);
1126
- expect(topLevelKeys.has('channels')).toBe(true);
1127
- expect(topLevelKeys.has('operations')).toBe(true);
1128
- expect(raw).toMatch(/^asyncapi:\s*3\.1(?:\.[0-9]+)?\s*$/m);
1129
- });
1130
-
1131
- it('every $ref to ../schemas/*.json resolves to a real file', () => {
1132
- const { raw } = readYamlHeader(asyncapiPath);
1133
- const refs = extractRefs(raw).filter((r) => r.startsWith('../schemas/'));
1134
- for (const ref of refs) {
1135
- const abs = pathResolve(API_DIR, ref.split('#')[0] ?? ref);
1136
- expect(existsSync(abs), `AsyncAPI $ref points at missing file: ${ref}`).toBe(true);
1137
- }
1138
- });
1139
-
1140
- it('named RunEventDoc messages use event names from run-event.schema.json', () => {
1141
- const { raw } = readYamlHeader(asyncapiPath);
1142
- const messageNames = extractAsyncApiMessageNames(raw);
1143
- const runEventSchema = readJson(join(SCHEMAS_DIR, 'run-event.schema.json'));
1144
- const runEventTypes = new Set(findRunEventTypeEnum(runEventSchema));
1145
- // `run.annotated` (RFC 0056) is a live SSE notification carrying an
1146
- // Annotation — NOT a RunEventDoc and deliberately NOT in the RunEventType
1147
- // enum (annotations are a side-resource, excluded from fork/replay).
1148
- const syntheticMessageNames = new Set(['state.snapshot', 'ai.message.chunk', 'any', 'run.annotated', 'heartbeat.evaluated', 'heartbeat.stateChanged']);
1149
-
1150
- expect(messageNames.length, 'AsyncAPI MUST declare named SSE messages').toBeGreaterThan(0);
1151
-
1152
- for (const name of messageNames) {
1153
- if (syntheticMessageNames.has(name)) continue;
1154
- expect(
1155
- runEventTypes.has(name),
1156
- `AsyncAPI message name "${name}" MUST exist in run-event.schema.json RunEventType enum, or be documented as synthetic`,
1157
- ).toBe(true);
1158
- }
1159
- });
1160
-
1161
- it('operation channel refs point to declared channels', () => {
1162
- const { raw } = readYamlHeader(asyncapiPath);
1163
- const channels = new Set(
1164
- extractTopLevelYamlKeysBetween(
1165
- raw,
1166
- '\nchannels:\n',
1167
- '\n# ─────────────────────────────────────────────────────────────────────────────\n# OPERATIONS',
1168
- ),
1169
- );
1170
- const channelRefs = extractAsyncApiOperationChannelRefs(raw);
1171
-
1172
- expect(channels.size, 'AsyncAPI MUST declare channels').toBeGreaterThan(0);
1173
- expect(channelRefs.length, 'AsyncAPI operations MUST reference channels').toBeGreaterThan(0);
1174
-
1175
- for (const ref of channelRefs) {
1176
- expect(
1177
- channels.has(ref),
1178
- `AsyncAPI operation references missing channel "${ref}"`,
1179
- ).toBe(true);
1180
- }
1181
- });
1182
-
1183
- it('channel keys and message names are unique', () => {
1184
- const { raw } = readYamlHeader(asyncapiPath);
1185
- const channelKeys = extractTopLevelYamlKeysBetween(
1186
- raw,
1187
- '\nchannels:\n',
1188
- '\n# ─────────────────────────────────────────────────────────────────────────────\n# OPERATIONS',
1189
- );
1190
- const messageNames = extractAsyncApiMessageNames(raw);
1191
-
1192
- const duplicateChannels = channelKeys.filter((key, index) => channelKeys.indexOf(key) !== index);
1193
- const duplicateMessages = messageNames.filter((name, index) => messageNames.indexOf(name) !== index);
1194
-
1195
- expect(duplicateChannels, `AsyncAPI channel keys MUST be unique; duplicates: ${duplicateChannels.join(', ')}`).toEqual([]);
1196
- expect(duplicateMessages, `AsyncAPI message names MUST be unique; duplicates: ${duplicateMessages.join(', ')}`).toEqual([]);
1197
- });
1198
- });
1199
-
1200
- describe.skipIf(V1_DIR === null)('spec-corpus: prose docs carry a Status: legend tag', () => {
1201
- // `describe.skipIf` skips test EXECUTION but still evaluates the
1202
- // describe callback at registration time so vitest can discover the
1203
- // `it()` calls inside. That means the readdirSync below runs even
1204
- // when V1_DIR is null (published-tarball layout) — read from a
1205
- // safe-and-empty fallback when V1_DIR isn't bundled.
1206
-
1207
- // META_DOCS aren't normative spec docs and don't carry the
1208
- // STUB / DRAFT / OUTLINE / FINAL maturity tag:
1209
- // - README.md, CHANGELOG.md, CONTRIBUTING.md, QUICKSTART.md — entry/index docs
1210
- // - CODE_OF_CONDUCT.md, GOVERNANCE.md, ROADMAP.md, SECURITY.md — project meta-docs
1211
- // - PUBLISHING.md — operational/release docs
1212
- const META_DOCS = new Set([
1213
- 'README.md',
1214
- 'CHANGELOG.md',
1215
- 'CONTRIBUTING.md',
1216
- 'CODE_OF_CONDUCT.md',
1217
- 'GOVERNANCE.md',
1218
- 'ROADMAP.md',
1219
- 'SECURITY.md',
1220
- 'PUBLISHING.md',
1221
- 'QUICKSTART.md',
1222
- ]);
1223
- const proseFiles =
1224
- V1_DIR === null
1225
- ? []
1226
- : readdirSync(V1_DIR)
1227
- .filter((f) => f.endsWith('.md') && !META_DOCS.has(f))
1228
- .sort();
1229
-
1230
- it('finds the expected prose doc set', () => {
1231
- // Spec README §Document index lists 11 prose docs. If this drifts,
1232
- // the README needs updating in the same PR that adds/removes a doc.
1233
- expect(proseFiles.length).toBeGreaterThanOrEqual(11);
1234
- });
1235
-
1236
- for (const file of proseFiles) {
1237
- it(`${file} declares a Status: tag (STUB / DRAFT / OUTLINE / FINAL | Stable / Stabilizing / Draft / Experimental)`, () => {
1238
- // V1_DIR is non-null here — proseFiles is empty when V1_DIR is null
1239
- // so this loop body never runs in the published-tarball layout.
1240
- const content = readFileSync(join(V1_DIR as string, file), 'utf8');
1241
- // Match either ">**Status:" or "**Status:" near the top of file.
1242
- expect(
1243
- content,
1244
- `${file} must include a "Status:" legend tag near its header`,
1245
- ).toMatch(/\*\*Status:\s*(STUB|DRAFT|OUTLINE|FINAL|Stable|Stabilizing|Draft|Experimental)\b/);
1246
- });
1247
- }
1248
- });
1249
-
1250
- describe.skipIf(V1_DIR === null || README_PATH === null)('spec-corpus: README document index matches spec/v1', () => {
1251
- // describe.skipIf skips test execution but still evaluates the describe callback at registration
1252
- // time. Guard each side-effecting read against null so the body registers cleanly under the
1253
- // published-tarball layout where V1_DIR / README_PATH resolve to null.
1254
- const v1Dir = V1_DIR;
1255
- const readmePath = README_PATH ?? '';
1256
-
1257
- const proseFiles =
1258
- v1Dir === null
1259
- ? []
1260
- : readdirSync(v1Dir)
1261
- .filter((f) => f.endsWith('.md'))
1262
- .sort();
1263
-
1264
- it('README Total count equals the number of spec/v1 prose docs', () => {
1265
- const index = extractReadmeDocumentIndex(readFileSync(readmePath, 'utf8'));
1266
- const totalMatch = index.match(/\*\*Total\*\*:\s+(\d+)\s+docs\./);
1267
-
1268
- expect(totalMatch, 'README.md document index MUST include a "**Total**: N docs." line').not.toBeNull();
1269
- expect(Number(totalMatch?.[1]), 'README.md document total MUST match spec/v1/*.md count').toBe(
1270
- proseFiles.length,
1271
- );
1272
- });
1273
-
1274
- it('README document index links every spec/v1 prose doc exactly once', () => {
1275
- const index = extractReadmeDocumentIndex(readFileSync(readmePath, 'utf8'));
1276
- const linkRegex = /\]\(\.\/spec\/v1\/([^)]+\.md)\)/g;
1277
- const linkedDocs: string[] = [];
1278
- let m: RegExpExecArray | null;
1279
- while ((m = linkRegex.exec(index)) !== null) {
1280
- if (m[1]) linkedDocs.push(m[1]);
1281
- }
1282
-
1283
- for (const file of proseFiles) {
1284
- const occurrences = linkedDocs.filter((linked) => linked === file).length;
1285
- expect(
1286
- occurrences,
1287
- `README.md document index MUST link ./spec/v1/${file} exactly once`,
1288
- ).toBe(1);
1289
- }
1290
- });
1291
- });
1292
-
1293
- describe.skipIf(README_PATH === null)('spec-corpus: local Markdown links resolve', () => {
1294
- // describe.skipIf skips test execution but still evaluates the body for registration; default
1295
- // to '.' so dirname() never receives null in the published-tarball layout.
1296
- const repoRoot = README_PATH === null ? '.' : dirname(README_PATH);
1297
- const markdownFiles =
1298
- README_PATH === null ? [] : listMarkdownFilesRecursive(repoRoot, repoRoot, listTrackedMarkdown(repoRoot));
1299
-
1300
- it('finds Markdown files to check', () => {
1301
- expect(markdownFiles.length, 'repo checkout should contain Markdown docs').toBeGreaterThan(0);
1302
- });
1303
-
1304
- for (const file of markdownFiles) {
1305
- const relFile = relative(repoRoot, file);
1306
- it(`${relFile} has no broken local Markdown file links`, () => {
1307
- const links = extractLocalMarkdownLinks(readFileSync(file, 'utf8'));
1308
- for (const link of links) {
1309
- const filePart = link.split('#')[0] ?? link;
1310
- if (filePart === '') continue;
1311
-
1312
- let decoded = filePart;
1313
- try {
1314
- decoded = decodeURIComponent(filePart);
1315
- } catch {
1316
- // Keep the raw path; existence check below will fail with a useful message.
1317
- }
1318
-
1319
- const target = pathResolve(dirname(file), decoded);
1320
- // Published-tarball layout: the conformance README references ../spec/v1/... and other paths
1321
- // that resolve OUTSIDE the package boundary. Repo layout has the full tree available. The
1322
- // `target === repoRoot || target.startsWith(repoRoot + sep)` form avoids a sibling-path
1323
- // false-negative when repoRoot=/foo/bar and target=/foo/barbaz.
1324
- if (LAYOUT === 'published' && target !== repoRoot && !target.startsWith(repoRoot + '/')) continue;
1325
- expect(
1326
- existsSync(target),
1327
- `${relFile} links to missing local target: ${link}`,
1328
- ).toBe(true);
1329
- }
1330
- });
1331
- }
1332
- });
1333
-
1334
- describe.skipIf(README_PATH === null)('spec-corpus: public docs avoid private implementation breadcrumbs', () => {
1335
- // describe.skipIf skips test execution but still evaluates the body for registration; guard each
1336
- // path read against null/missing-dir so the body never throws under the published-tarball layout.
1337
- const repoRoot = README_PATH === null ? '.' : dirname(README_PATH);
1338
- const securityDir = join(repoRoot, 'SECURITY');
1339
- const publicTextFiles =
1340
- README_PATH === null
1341
- ? []
1342
- : [
1343
- README_PATH,
1344
- join(repoRoot, 'QUICKSTART.md'),
1345
- join(repoRoot, 'QUICKSTART-10MIN.md'),
1346
- join(repoRoot, 'sdk', 'typescript', 'README.md'),
1347
- join(repoRoot, 'sdk', 'python', 'README.md'),
1348
- join(repoRoot, 'sdk', 'go', 'README.md'),
1349
- ...(CONFORMANCE_README_PATH ? [CONFORMANCE_README_PATH] : []),
1350
- ...(FIXTURES_DOC_PATH ? [FIXTURES_DOC_PATH] : []),
1351
- ...listTextFilesRecursive(join(repoRoot, 'examples'), new Set(['.md', 'package.json'])),
1352
- ...(existsSync(securityDir)
1353
- ? readdirSync(securityDir).filter((f) => f.endsWith('.md') || f.endsWith('.yaml')).map((f) => join(securityDir, f))
1354
- : []),
1355
- ...(V1_DIR !== null ? ((v1Dir: string) => readdirSync(v1Dir).filter((f) => f.endsWith('.md')).map((f) => join(v1Dir, f)))(V1_DIR) : []),
1356
- ...readdirSync(SCHEMAS_DIR).filter((f) => f.endsWith('.json')).map((f) => join(SCHEMAS_DIR, f)),
1357
- ].filter((path) => existsSync(path));
1358
-
1359
- const banned = [
1360
- { label: 'private workflow-runtime paths', pattern: /services\/workflow-runtime/ },
1361
- { label: 'private workflow-engine paths', pattern: /packages\/workflow-engine/ },
1362
- { label: 'internal PRD references', pattern: /PRD §/ },
1363
- { label: 'old openwop plan references', pattern: /openwop plan/i },
1364
- { label: 'pre-v1 release markers', pattern: /\bv0\.(?:1|2|3)\b/i },
1365
- { label: 'scaffold release wording', pattern: /\bscaffold\b/i },
1366
- { label: 'incorrect OpenWOP article', pattern: /\b(?:A|a) OpenWOP\b/ },
1367
- { label: 'lowercase compliance adjective', pattern: /\bopenwop-(?:compliant|conforming)\b/ },
1368
- { label: 'lowercase OpenWOP phrase', pattern: /\bopenwop (?:host|node|workflow|runs|gives)\b/ },
1369
- { label: 'private workflow-engine examples', pattern: /@your-org\/workflow-engine|workflow-engine implementation/ },
1370
- { label: 'deployment-specific Cloud Run advice', pattern: /Cloud-Run-first|Cloud Run/ },
1371
- { label: 'reference implementation breadcrumbs', pattern: /Reference impl:/ },
1372
- { label: 'bootstrap governance breadcrumbs', pattern: /bootstrap-phase|lead-maintainer fiat|single-maintainer/i },
1373
- { label: 'old gap-planning references', pattern: /openwop plan|G(?:10|12|22|23)|WOP-era|prior WOP/i },
1374
- { label: 'private implementation source paths', pattern: /functions\/src|src\/core\/workflow/ },
1375
- { label: 'reference implementation source breadcrumbs', pattern: /Reference implementation:/ },
1376
- ];
1377
-
1378
- it('scans public docs and schemas', () => {
1379
- expect(publicTextFiles.length, 'public docs/schemas list MUST be non-empty').toBeGreaterThan(0);
1380
- });
1381
-
1382
- for (const file of publicTextFiles) {
1383
- const relFile = relative(repoRoot, file);
1384
- it(`${relFile} has no private implementation or pre-v1 breadcrumbs`, () => {
1385
- const text = readFileSync(file, 'utf8');
1386
- for (const { label, pattern } of banned) {
1387
- expect(text, `${relFile} MUST NOT contain ${label}`).not.toMatch(pattern);
1388
- }
1389
- });
1390
- }
1391
- });
1392
-
1393
- describe.skipIf(FIXTURES_DOC_PATH === null)('spec-corpus: fixtures.json catalog matches fixtures.md', () => {
1394
- // FIXTURES_DOC_PATH is non-null here — assertion narrows for TS.
1395
- const fixturesDocPath = FIXTURES_DOC_PATH as string;
1396
- const PACK_MANIFEST_FIXTURES_DIR = join(FIXTURES_DIR, 'pack-manifests');
1397
- const PROMPT_TEMPLATE_FIXTURES_DIR = join(FIXTURES_DIR, 'prompt-templates');
1398
- // Top-level workflow fixtures + pack-manifest fixtures + prompt-
1399
- // template fixtures from their respective sub-directories. All are
1400
- // documented in fixtures.md so the regex scan below MUST cover them.
1401
- const fixtureJsonFiles = [
1402
- ...readdirSync(FIXTURES_DIR)
1403
- .filter((f) => f.endsWith('.json'))
1404
- .map((f) => f.replace(/\.json$/, '')),
1405
- ...readdirSync(PACK_MANIFEST_FIXTURES_DIR)
1406
- .filter((f) => f.endsWith('.json'))
1407
- .map((f) => f.replace(/\.json$/, '')),
1408
- ...readdirSync(PROMPT_TEMPLATE_FIXTURES_DIR)
1409
- .filter((f) => f.endsWith('.json'))
1410
- .map((f) => f.replace(/\.json$/, '')),
1411
- ].sort();
1412
-
1413
- it('every fixture id mentioned in fixtures.md has a corresponding JSON', () => {
1414
- const doc = readFileSync(fixturesDocPath, 'utf8');
1415
- // Match `conformance-<word>` identifiers in the catalog table or
1416
- // per-fixture sections. Use word-boundary so "conformance-noop"
1417
- // captures cleanly without bleeding into adjacent text.
1418
- //
1419
- // PROPOSED-section IDs are intentionally documented without backing
1420
- // JSONs (the fixture is blocked on a future spec/impl change). Two
1421
- // markers indicate a section is documenting a future fixture:
1422
- // 1. "(PROPOSED v..." in the heading — design proposal
1423
- // 2. "impl pending" in the heading — spec firm, runtime not yet
1424
- // shipped (e.g., F4's cap-breach fixture awaiting CC-1 counter)
1425
- // We strip §sections matching either marker before scanning.
1426
- // The catalog table also contains rows for PROPOSED / impl-pending
1427
- // fixtures; strip those too.
1428
- let docWithoutProposed = doc.replace(
1429
- /^##\s+[^\n]*\((PROPOSED\s+v[^\n)]+|[^)]*impl pending)\)[\s\S]*?(?=^##\s+|^---\s*$)/gm,
1430
- '',
1431
- );
1432
- docWithoutProposed = docWithoutProposed.replace(
1433
- /^\|[^\n]*(PROPOSED|impl pending)[^\n]*\n/gm,
1434
- '',
1435
- );
1436
- // Match `conformance-<id>` only at a real fixture-id boundary —
1437
- // require the preceding character to NOT be `[a-z0-9-]`, so that
1438
- // longer strings like `openwop-conformance-canary-secret` do NOT
1439
- // false-match `conformance-canary-secret` as a fixture id. The
1440
- // negative lookbehind keeps the regex JS-compatible.
1441
- const idRegex = /(?<![a-z0-9-])conformance-[a-z][a-z0-9-]*\b/g;
1442
- const cited = new Set<string>();
1443
- let m: RegExpExecArray | null;
1444
- while ((m = idRegex.exec(docWithoutProposed)) !== null) {
1445
- cited.add(m[0]);
1446
- }
1447
- for (const cite of cited) {
1448
- expect(
1449
- fixtureJsonFiles,
1450
- `fixtures.md cites fixture id "${cite}" but no matching ${cite}.json exists`,
1451
- ).toContain(cite);
1452
- }
1453
- });
1454
-
1455
- it('every fixture JSON file is referenced by fixtures.md', () => {
1456
- const doc = readFileSync(fixturesDocPath, 'utf8');
1457
- for (const id of fixtureJsonFiles) {
1458
- expect(
1459
- doc,
1460
- `fixture ${id}.json exists but fixtures.md does not document it`,
1461
- ).toContain(id);
1462
- }
1463
- });
1464
- });
1465
-
1466
- // RFC 0089 — conformance certification bundle. The schema itself is compiled +
1467
- // $id-checked by the "JSON Schemas compile under Ajv2020" block above; here we
1468
- // assert a sample bundle validates AND that the §B binding rule (verifyBundle)
1469
- // correctly accepts a valid claim and rejects both a not-derivable claim and a
1470
- // missing-floor-scenario one.
1471
- describe('spec-corpus: RFC 0089 conformance certification bundle + binding rule', () => {
1472
- // A discovery document that derives `openwop-core-standard`
1473
- // (isCore ∧ isInterrupts ∧ a transport — supportedTransports omitted ⇒ rest).
1474
- const coreStandardDiscovery = {
1475
- protocolVersion: '1.0',
1476
- supportedEnvelopes: ['final', 'clarification.request'],
1477
- schemaVersions: { 'workflow-definition': '1.0' },
1478
- limits: { clarificationRounds: 3, schemaRounds: 2, envelopesPerTurn: 8 },
1479
- };
1480
- const coreStandardFloorPassed = [
1481
- ...PROFILE_FLOOR_SCENARIOS['openwop-core-standard']!.required,
1482
- 'interrupt-resume.test.ts',
1483
- ];
1484
- const sampleBundle = {
1485
- bundleVersion: '1',
1486
- generatedAt: '2026-06-02T00:00:00Z',
1487
- generator: { name: '@openwop/openwop-conformance --certify', version: '1.18.1' },
1488
- suite: { package: '@openwop/openwop-conformance', version: '1.18.1' },
1489
- host: { name: 'openwop-host-sqlite', version: '1.0.0' },
1490
- discovery: {
1491
- url: 'https://example.test/.well-known/openwop',
1492
- sha256: 'a'.repeat(64),
1493
- document: coreStandardDiscovery,
1494
- },
1495
- claimedProfiles: ['openwop-core-standard'],
1496
- results: {
1497
- totals: { passed: coreStandardFloorPassed.length, failed: 0, skipped: 0, total: coreStandardFloorPassed.length },
1498
- passed: coreStandardFloorPassed,
1499
- failed: [],
1500
- skipped: [],
1501
- },
1502
- };
1503
-
1504
- const ajv = new Ajv2020({ allErrors: true, strict: false });
1505
- addFormats(ajv);
1506
- const bundleSchema = readJson(join(SCHEMAS_DIR, 'conformance-certification-bundle.schema.json')) as Record<string, unknown>;
1507
-
1508
- it('a sample bundle validates against conformance-certification-bundle.schema.json', () => {
1509
- const validate = ajv.compile(bundleSchema);
1510
- const ok = validate(sampleBundle);
1511
- expect(ok, JSON.stringify(validate.errors)).toBe(true);
1512
- });
1513
-
1514
- it('verifyBundle ACCEPTS a claim that is discovery-derivable AND floor-proven (§B)', () => {
1515
- const r = verifyBundle(sampleBundle);
1516
- expect(r.valid).toBe(true);
1517
- expect(r.verdicts[0]?.derivable).toBe(true);
1518
- expect(r.verdicts[0]?.floorProven).toBe(true);
1519
- });
1520
-
1521
- it('verifyBundle REJECTS a profile its discovery document does not derive (§B(1))', () => {
1522
- const notDerivable = {
1523
- ...sampleBundle,
1524
- discovery: { ...sampleBundle.discovery, document: { ...coreStandardDiscovery, supportedEnvelopes: ['final'] } },
1525
- };
1526
- const r = verifyBundle(notDerivable);
1527
- expect(r.valid).toBe(false);
1528
- expect(r.verdicts[0]?.derivable).toBe(false);
1529
- });
1530
-
1531
- it('verifyBundle REJECTS a bundle missing a floor scenario (§B(2))', () => {
1532
- const missingFloor = {
1533
- ...sampleBundle,
1534
- results: { ...sampleBundle.results, passed: coreStandardFloorPassed.filter((s) => s !== 'auth.test.ts') },
1535
- };
1536
- const r = verifyBundle(missingFloor);
1537
- expect(r.valid).toBe(false);
1538
- expect(r.verdicts[0]?.floorProven).toBe(false);
1539
- expect(r.verdicts[0]?.missingFloor).toContain('auth.test.ts');
1540
- });
1541
- });
1542
-
1543
- // RFC 0089 — the committed REAL reference-host bundle, generated by
1544
- // `openwop-conformance --certify` against the in-memory reference host
1545
- // (examples/hosts/in-memory). This is the at-`Accepted` "reference host commits
1546
- // a real generated bundle" evidence: it must (a) validate against the schema and
1547
- // (b) pass the §B binding rule — every profile it CLAIMS must re-derive from its
1548
- // own captured discovery document AND be floor-proven. The bundle lives in
1549
- // `examples/`, which is NOT bundled into the published tarball, so this skips
1550
- // cleanly under the published layout (V1_DIR === null).
1551
- // The committed reference-host certification bundle lives with the in-memory host,
1552
- // which moved to the openwop-examples repo (2026-06). When the host tree is absent
1553
- // (the spec corpus on its own), this committed-bundle check self-skips — it is
1554
- // validated in openwop-examples CI against the published @openwop/openwop-conformance
1555
- // verifyBundle. The sample-bundle schema + binding-rule checks above still run here.
1556
- const RFC0089_BUNDLE_PATH =
1557
- V1_DIR === null ? null : join(pathResolve(V1_DIR, '..', '..'), 'examples', 'hosts', 'in-memory', 'certification-bundle.json');
1558
- describe.skipIf(RFC0089_BUNDLE_PATH === null || !existsSync(RFC0089_BUNDLE_PATH))('spec-corpus: RFC 0089 committed reference-host certification bundle', () => {
1559
- const bundlePath = RFC0089_BUNDLE_PATH as string;
1560
-
1561
- const ajv = new Ajv2020({ allErrors: true, strict: false });
1562
- addFormats(ajv);
1563
- const bundleSchema = readJson(join(SCHEMAS_DIR, 'conformance-certification-bundle.schema.json')) as Record<
1564
- string,
1565
- unknown
1566
- >;
1567
-
1568
- it('the committed bundle file exists (generated by --certify)', () => {
1569
- expect(existsSync(bundlePath), `expected a committed reference bundle at ${bundlePath}`).toBe(true);
1570
- });
1571
-
1572
- it('the committed reference bundle validates against the bundle schema (§A)', () => {
1573
- const bundle = readJson(bundlePath);
1574
- const validate = ajv.compile(bundleSchema);
1575
- const ok = validate(bundle);
1576
- expect(ok, JSON.stringify(validate.errors)).toBe(true);
1577
- });
1578
-
1579
- it('verifyBundle REJECTS the committed reference bundle — it is `invalidated` (RFC 0148 §D)', () => {
1580
- // This assertion was inverted on 2026-08-12. It previously asserted the
1581
- // bundle was ACCEPTED, commented "the host honestly claims ONLY profiles its
1582
- // discovery document derives, none of which it fails a floor scenario for."
1583
- // That comment was false: the bundle claims `openwop-stream-sse` while all
1584
- // three `stream-modes*` scenarios sit in its own `results.failed`. The old
1585
- // assertion passed only because those profiles had no floor definition, so
1586
- // `floorProven` came out vacuously true — a test defending a claim the
1587
- // bundle's own failure list contradicts.
1588
- //
1589
- // The bundle is now marked `invalidated` in
1590
- // `docs/CERTIFICATION-BUNDLE-INVENTORY.md`; reissue requires bundle v2.
1591
- // Until then the correct expectation is rejection, and the reasons are
1592
- // asserted individually so a future reissue cannot turn this green for the
1593
- // wrong cause.
1594
- const bundle = readJson(bundlePath) as Parameters<typeof verifyBundle>[0];
1595
- const r = verifyBundle(bundle);
1596
- expect(r.valid, 'the committed v1 bundle is invalidated, not merely historical').toBe(false);
1597
-
1598
- const sse = r.verdicts.find((v) => v.profile === 'openwop-stream-sse');
1599
- expect(sse?.floorProven, 'profiles.md §openwop-stream-sse: predicate AND those scenarios pass').toBe(false);
1600
- expect(sse?.missingFloor, 'its own results.failed lists the stream-modes scenarios').toContain(
1601
- 'stream-modes.test.ts',
1602
- );
1603
- });
1604
-
1605
- it('discovery.sha256 is the canonical-JSON SHA-256 of the captured discovery.document', () => {
1606
- const bundle = readJson(bundlePath) as {
1607
- discovery: { sha256: string; document: unknown };
1608
- };
1609
- // Mirror the generator's canonical serialization (sorted keys at every level).
1610
- const canonical = (value: unknown): string => {
1611
- if (value === null || typeof value !== 'object') return JSON.stringify(value);
1612
- if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
1613
- const obj = value as Record<string, unknown>;
1614
- const keys = Object.keys(obj).sort();
1615
- return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(obj[k])}`).join(',')}}`;
1616
- };
1617
- const recomputed = createHash('sha256').update(canonical(bundle.discovery.document)).digest('hex');
1618
- expect(bundle.discovery.sha256).toBe(recomputed);
1619
- });
1620
- });
1621
-
1622
- describe('spec-corpus: createRun composed request schema is satisfiable (RFC 0094 §A)', () => {
1623
- // The 2026-06-11 corpus review found the published createRun requestBody
1624
- // unsatisfiable: BOTH allOf branches (the inline request object and
1625
- // run-options.schema.json) carried `additionalProperties: false`, so every
1626
- // documented body failed one branch or the other. RFC 0094 §A moves the
1627
- // closure to the composition site (`unevaluatedProperties: false`, JSON
1628
- // Schema 2020-12) and opens both branches. These probes pin the repaired
1629
- // contract so the defect class cannot silently return:
1630
- // 1. (structural) the YAML composition closes at the composed level,
1631
- // never at a branch;
1632
- // 2. (semantic, ajv-2020) the canonical documented bodies PASS the
1633
- // composition of the on-disk run-options.schema.json with the inline
1634
- // branch's declared properties, while an undeclared property FAILS.
1635
- const openapiPath = join(API_DIR, 'openapi.yaml');
1636
-
1637
- function extractCreateRunRequestBlock(raw: string): string {
1638
- const opStart = raw.indexOf('operationId: createRun');
1639
- expect(opStart, 'OpenAPI MUST declare operationId createRun').toBeGreaterThanOrEqual(0);
1640
- const bodyStart = raw.indexOf('requestBody:', opStart);
1641
- const responsesStart = raw.indexOf('\n responses:', bodyStart);
1642
- expect(bodyStart, 'createRun MUST declare a requestBody').toBeGreaterThan(opStart);
1643
- expect(responsesStart, 'createRun requestBody MUST precede its responses').toBeGreaterThan(bodyStart);
1644
- return raw.slice(bodyStart, responsesStart);
1645
- }
1646
-
1647
- /** Property names declared on the inline (non-$ref) allOf branch of the
1648
- * createRun requestBody — the 20-space-indented keys, minus JSON Schema
1649
- * keywords that can appear at the same indent inside if/then/else. */
1650
- function extractInlineBranchPropertyNames(block: string): string[] {
1651
- const keywords = new Set([
1652
- 'type', 'properties', 'required', 'description', 'enum', 'format',
1653
- 'items', 'minLength', 'if', 'then', 'else', 'allOf', 'additionalProperties',
1654
- 'unevaluatedProperties',
1655
- ]);
1656
- const names: string[] = [];
1657
- const re = /^ {20}([A-Za-z][A-Za-z0-9]*):/gm;
1658
- let m: RegExpExecArray | null;
1659
- while ((m = re.exec(block)) !== null) {
1660
- const name = m[1];
1661
- if (name && !keywords.has(name) && !names.includes(name)) names.push(name);
1662
- }
1663
- return names;
1664
- }
1665
-
1666
- it('the requestBody closes at the composition (unevaluatedProperties), not inside a branch', () => {
1667
- const { raw } = readYamlHeader(openapiPath);
1668
- const block = extractCreateRunRequestBlock(raw);
1669
-
1670
- expect(
1671
- block,
1672
- 'RFC 0094 §A: the composed createRun request schema MUST be closed with `unevaluatedProperties: false`',
1673
- ).toContain('unevaluatedProperties: false');
1674
- expect(
1675
- block,
1676
- 'RFC 0094 §A: no allOf branch of the createRun requestBody may carry `additionalProperties: false` ' +
1677
- '(a closed branch inside an allOf re-creates the unsatisfiable composition)',
1678
- ).not.toContain('additionalProperties: false');
1679
- });
1680
-
1681
- it('canonical createRun bodies PASS the composed schema; an undeclared property FAILS', () => {
1682
- const { raw } = readYamlHeader(openapiPath);
1683
- const inlineProps = extractInlineBranchPropertyNames(extractCreateRunRequestBlock(raw));
1684
- expect(inlineProps, 'the inline branch MUST declare workflowId').toContain('workflowId');
1685
-
1686
- // Compose exactly what RFC 0094 §A specifies: the inline branch's
1687
- // declared properties + the REAL on-disk run-options.schema.json
1688
- // (embedded with its $id so its internal #/$defs refs keep resolving),
1689
- // closed at the composition with unevaluatedProperties.
1690
- const runOptionsSchema = readJson(join(SCHEMAS_DIR, 'run-options.schema.json')) as Record<string, unknown>;
1691
- delete runOptionsSchema['$schema']; // embedded subschema; the parent declares the dialect
1692
- const composed = {
1693
- $schema: 'https://json-schema.org/draft/2020-12/schema',
1694
- type: 'object',
1695
- allOf: [
1696
- {
1697
- type: 'object',
1698
- properties: Object.fromEntries(inlineProps.map((p) => [p, true])),
1699
- },
1700
- runOptionsSchema,
1701
- ],
1702
- unevaluatedProperties: false,
1703
- };
1704
-
1705
- const ajv = new Ajv2020({ allErrors: true, strict: false });
1706
- addFormats(ajv);
1707
- const validate = ajv.compile(composed);
1708
-
1709
- const canonicalBodies: Array<Record<string, unknown>> = [
1710
- { workflowId: 'wf-1' },
1711
- { workflowId: 'wf-1', configurable: {} },
1712
- { workflowId: 'wf-1', inputs: {}, configurable: {}, tags: ['conformance'], metadata: {} },
1713
- ];
1714
- for (const body of canonicalBodies) {
1715
- expect(
1716
- validate(body),
1717
- `RFC 0094 §A: documented body ${JSON.stringify(body)} MUST satisfy the composed createRun ` +
1718
- `request schema; ajv said: ${JSON.stringify(validate.errors)}`,
1719
- ).toBe(true);
1720
- }
1721
-
1722
- expect(
1723
- validate({ workflowId: 'wf-1', definitelyNotASpecField: true }),
1724
- 'RFC 0094 §A: an undeclared property MUST still fail at the composed level (unevaluatedProperties: false)',
1725
- ).toBe(false);
1726
- });
1727
- });