@openwop/openwop-conformance 1.0.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 (175) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +241 -0
  3. package/api/asyncapi.yaml +481 -0
  4. package/api/openapi.yaml +830 -0
  5. package/api/redocly.yaml +8 -0
  6. package/coverage.md +80 -0
  7. package/dist/cli.js +161 -0
  8. package/fixtures/conformance-a2a-task-roundtrip.json +27 -0
  9. package/fixtures/conformance-agent-identity.json +27 -0
  10. package/fixtures/conformance-agent-low-confidence.json +29 -0
  11. package/fixtures/conformance-agent-memory-cross-tenant.json +28 -0
  12. package/fixtures/conformance-agent-memory-redaction.json +32 -0
  13. package/fixtures/conformance-agent-memory-roundtrip.json +32 -0
  14. package/fixtures/conformance-agent-memory-ttl.json +31 -0
  15. package/fixtures/conformance-agent-pack-export.json +26 -0
  16. package/fixtures/conformance-agent-pack-install.json +26 -0
  17. package/fixtures/conformance-agent-pack-provenance.json +31 -0
  18. package/fixtures/conformance-agent-reasoning.json +29 -0
  19. package/fixtures/conformance-approval.json +27 -0
  20. package/fixtures/conformance-cancellable.json +33 -0
  21. package/fixtures/conformance-cap-breach.json +27 -0
  22. package/fixtures/conformance-capability-missing.json +23 -0
  23. package/fixtures/conformance-channel-ttl.json +60 -0
  24. package/fixtures/conformance-clarification.json +30 -0
  25. package/fixtures/conformance-conversation-capability-negotiation.json +23 -0
  26. package/fixtures/conformance-conversation-lifecycle.json +32 -0
  27. package/fixtures/conformance-conversation-replay.json +33 -0
  28. package/fixtures/conformance-conversation-vs-clarification.json +26 -0
  29. package/fixtures/conformance-delay.json +33 -0
  30. package/fixtures/conformance-dispatch-loop.json +38 -0
  31. package/fixtures/conformance-failure.json +23 -0
  32. package/fixtures/conformance-idempotent.json +30 -0
  33. package/fixtures/conformance-identity.json +32 -0
  34. package/fixtures/conformance-interrupt-auth-required.json +28 -0
  35. package/fixtures/conformance-interrupt-external-event.json +33 -0
  36. package/fixtures/conformance-interrupt-parent-child-cancel-child.json +27 -0
  37. package/fixtures/conformance-interrupt-parent-child-cancel.json +26 -0
  38. package/fixtures/conformance-interrupt-quorum.json +30 -0
  39. package/fixtures/conformance-mcp-tool-roundtrip.json +32 -0
  40. package/fixtures/conformance-message-reducer.json +31 -0
  41. package/fixtures/conformance-multi-node.json +21 -0
  42. package/fixtures/conformance-noop.json +23 -0
  43. package/fixtures/conformance-orchestrator-dispatch.json +47 -0
  44. package/fixtures/conformance-orchestrator-low-confidence.json +41 -0
  45. package/fixtures/conformance-orchestrator-terminate.json +44 -0
  46. package/fixtures/conformance-stream-text.json +26 -0
  47. package/fixtures/conformance-subworkflow-child.json +21 -0
  48. package/fixtures/conformance-subworkflow-parent.json +49 -0
  49. package/fixtures/conformance-version-fold.json +23 -0
  50. package/fixtures/conformance-wasm-pack-roundtrip.json +25 -0
  51. package/fixtures/pack-manifests/pack-private-example.json +26 -0
  52. package/fixtures.md +404 -0
  53. package/package.json +48 -0
  54. package/schemas/README.md +75 -0
  55. package/schemas/agent-manifest.schema.json +107 -0
  56. package/schemas/agent-ref.schema.json +53 -0
  57. package/schemas/capabilities.schema.json +287 -0
  58. package/schemas/channel-written-payload.schema.json +55 -0
  59. package/schemas/conversation-event.schema.json +120 -0
  60. package/schemas/conversation-turn.schema.json +72 -0
  61. package/schemas/debug-bundle.schema.json +196 -0
  62. package/schemas/dispatch-config.schema.json +46 -0
  63. package/schemas/error-envelope.schema.json +25 -0
  64. package/schemas/memory-entry.schema.json +36 -0
  65. package/schemas/memory-list-options.schema.json +21 -0
  66. package/schemas/node-pack-manifest.schema.json +235 -0
  67. package/schemas/orchestrator-decision.schema.json +60 -0
  68. package/schemas/run-event-payloads.schema.json +663 -0
  69. package/schemas/run-event.schema.json +116 -0
  70. package/schemas/run-options.schema.json +81 -0
  71. package/schemas/run-orchestrator-decided-event.schema.json +20 -0
  72. package/schemas/run-snapshot.schema.json +121 -0
  73. package/schemas/suspend-request.schema.json +182 -0
  74. package/schemas/workflow-definition.schema.json +430 -0
  75. package/src/cli.ts +187 -0
  76. package/src/lib/a2a-fake-peer.ts +233 -0
  77. package/src/lib/canaries.ts +186 -0
  78. package/src/lib/driver.ts +96 -0
  79. package/src/lib/env.ts +49 -0
  80. package/src/lib/fixtures.ts +93 -0
  81. package/src/lib/mcp-fake-server.ts +185 -0
  82. package/src/lib/multi-agent-capabilities.ts +155 -0
  83. package/src/lib/multiProcess.ts +141 -0
  84. package/src/lib/otel-collector.ts +312 -0
  85. package/src/lib/paths.ts +198 -0
  86. package/src/lib/polling.ts +81 -0
  87. package/src/lib/profiles.ts +258 -0
  88. package/src/lib/sse.ts +172 -0
  89. package/src/scenarios/a2a-task-roundtrip.test.ts +149 -0
  90. package/src/scenarios/agentConfidenceEscalation.test.ts +61 -0
  91. package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +54 -0
  92. package/src/scenarios/agentMemoryRedactionContract.test.ts +46 -0
  93. package/src/scenarios/agentMemoryRoundTrip.test.ts +52 -0
  94. package/src/scenarios/agentMemoryTtlExpiry.test.ts +47 -0
  95. package/src/scenarios/agentMessageReducer.test.ts +57 -0
  96. package/src/scenarios/agentMetadata.test.ts +56 -0
  97. package/src/scenarios/agentPackExport.test.ts +45 -0
  98. package/src/scenarios/agentPackInstall.test.ts +50 -0
  99. package/src/scenarios/agentPackProvenance.test.ts +53 -0
  100. package/src/scenarios/agentReasoningEvents.test.ts +72 -0
  101. package/src/scenarios/append-ordering.test.ts +91 -0
  102. package/src/scenarios/approval-payload.test.ts +120 -0
  103. package/src/scenarios/audit-log-integrity.test.ts +106 -0
  104. package/src/scenarios/auth.test.ts +55 -0
  105. package/src/scenarios/byok-roundtrip.test.ts +166 -0
  106. package/src/scenarios/cancellation.test.ts +68 -0
  107. package/src/scenarios/cap-breach.test.ts +149 -0
  108. package/src/scenarios/channel-ttl.test.ts +70 -0
  109. package/src/scenarios/configurable-schema.test.ts +76 -0
  110. package/src/scenarios/conversationCapabilityNegotiation.test.ts +39 -0
  111. package/src/scenarios/conversationLifecycle.test.ts +64 -0
  112. package/src/scenarios/conversationReplayDeterminism.test.ts +52 -0
  113. package/src/scenarios/conversationVsLegacySuspend.test.ts +46 -0
  114. package/src/scenarios/cost-attribution.test.ts +207 -0
  115. package/src/scenarios/debugBundle.test.ts +222 -0
  116. package/src/scenarios/discovery.test.ts +147 -0
  117. package/src/scenarios/dispatchLoop.test.ts +52 -0
  118. package/src/scenarios/errors.test.ts +144 -0
  119. package/src/scenarios/eventOrdering.test.ts +144 -0
  120. package/src/scenarios/failure-path.test.ts +46 -0
  121. package/src/scenarios/fixtures-gating.test.ts +137 -0
  122. package/src/scenarios/fixtures-valid.test.ts +140 -0
  123. package/src/scenarios/highConcurrency.test.ts +263 -0
  124. package/src/scenarios/idempotency.test.ts +83 -0
  125. package/src/scenarios/idempotencyRetry.test.ts +130 -0
  126. package/src/scenarios/identity-passthrough.test.ts +54 -0
  127. package/src/scenarios/interrupt-approval.test.ts +97 -0
  128. package/src/scenarios/interrupt-auth-required-resume.test.ts +88 -0
  129. package/src/scenarios/interrupt-clarification.test.ts +45 -0
  130. package/src/scenarios/interrupt-external-event-correlation.test.ts +113 -0
  131. package/src/scenarios/interrupt-parent-child-cascade.test.ts +102 -0
  132. package/src/scenarios/interrupt-quorum-resolution.test.ts +97 -0
  133. package/src/scenarios/interruptRace.test.ts +176 -0
  134. package/src/scenarios/maliciousManifest.test.ts +154 -0
  135. package/src/scenarios/mcp-discoverability.test.ts +129 -0
  136. package/src/scenarios/mcp-tool-roundtrip.test.ts +149 -0
  137. package/src/scenarios/multi-node-ordering.test.ts +60 -0
  138. package/src/scenarios/multi-region-idempotency.test.ts +52 -0
  139. package/src/scenarios/orchestratorConservativePath.test.ts +63 -0
  140. package/src/scenarios/orchestratorDispatch.test.ts +66 -0
  141. package/src/scenarios/orchestratorTermination.test.ts +54 -0
  142. package/src/scenarios/otel-emission.test.ts +113 -0
  143. package/src/scenarios/otel-trace-propagation.test.ts +90 -0
  144. package/src/scenarios/pack-registry-publish.test.ts +93 -0
  145. package/src/scenarios/pack-registry.test.ts +328 -0
  146. package/src/scenarios/pause-resume.test.ts +109 -0
  147. package/src/scenarios/policies.test.ts +162 -0
  148. package/src/scenarios/profileDerivation.test.ts +335 -0
  149. package/src/scenarios/providerPolicyEnforcement.test.ts +132 -0
  150. package/src/scenarios/rate-limit-envelope.test.ts +97 -0
  151. package/src/scenarios/redaction.test.ts +254 -0
  152. package/src/scenarios/redactionAdversarial.test.ts +162 -0
  153. package/src/scenarios/replay-fork-arbitrary.test.ts +347 -0
  154. package/src/scenarios/replay-fork.test.ts +216 -0
  155. package/src/scenarios/replayDeterminism.test.ts +171 -0
  156. package/src/scenarios/route-coverage.test.ts +129 -0
  157. package/src/scenarios/runs-lifecycle.test.ts +65 -0
  158. package/src/scenarios/runtime-capabilities.test.ts +118 -0
  159. package/src/scenarios/spec-corpus-validity.test.ts +1257 -0
  160. package/src/scenarios/staleClaim.test.ts +223 -0
  161. package/src/scenarios/stream-modes-buffer.test.ts +148 -0
  162. package/src/scenarios/stream-modes-mixed.test.ts +149 -0
  163. package/src/scenarios/stream-modes.test.ts +139 -0
  164. package/src/scenarios/streamReconnect.test.ts +162 -0
  165. package/src/scenarios/subworkflow.test.ts +126 -0
  166. package/src/scenarios/version-negotiation.test.ts +157 -0
  167. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +47 -0
  168. package/src/scenarios/wasm-pack-invoke-completed.test.ts +69 -0
  169. package/src/scenarios/wasm-pack-invoke-suspended.test.ts +74 -0
  170. package/src/scenarios/wasm-pack-load.test.ts +75 -0
  171. package/src/scenarios/wasm-pack-memory-cap.test.ts +43 -0
  172. package/src/scenarios/wasm-pack-replay-determinism.test.ts +61 -0
  173. package/src/scenarios/webhook-sig-algorithm.test.ts +61 -0
  174. package/src/setup.ts +173 -0
  175. package/vitest.config.ts +17 -0
@@ -0,0 +1,1257 @@
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 { dirname, join, relative, resolve as pathResolve } from 'node:path';
39
+ import Ajv2020 from 'ajv/dist/2020.js';
40
+ import addFormats from 'ajv-formats';
41
+ import {
42
+ API_DIR,
43
+ CONFORMANCE_README_PATH,
44
+ COVERAGE_DOC_PATH,
45
+ FIXTURES_DIR,
46
+ FIXTURES_DOC_PATH,
47
+ GO_TYPES_PATH,
48
+ PYTHON_TYPES_PATH,
49
+ README_PATH,
50
+ SCENARIOS_DIR,
51
+ SCHEMAS_DIR,
52
+ TYPESCRIPT_RUN_HELPERS_PATH,
53
+ V1_DIR,
54
+ } from '../lib/paths.js';
55
+
56
+ // Layout-aware paths come from `lib/paths.ts`. Three layouts:
57
+ // - Repo (github.com/openwop/openwop): schemas/api at repo root,
58
+ // prose docs under spec/v1/, fixtures.md under conformance/.
59
+ // - In-tree mirror (openwop/openwop under ): same
60
+ // shape, just rooted differently.
61
+ // - Published tarball (`@openwop/openwop-conformance`): schemas/api
62
+ // vendored at the package root by `prepack`, prose docs not
63
+ // bundled, fixtures.md ships next to the fixtures directory.
64
+ //
65
+ // Tests that depend on prose docs or fixtures.md skip cleanly when the
66
+ // resolver returns null for those paths under the published layout.
67
+
68
+ // ── Helpers ─────────────────────────────────────────────────────────────
69
+
70
+ function listJsonFiles(dir: string): string[] {
71
+ return readdirSync(dir).filter((f) => f.endsWith('.json'));
72
+ }
73
+
74
+ function listScenarioTestFiles(dir: string): string[] {
75
+ return readdirSync(dir)
76
+ .filter((f) => f.endsWith('.test.ts'))
77
+ .sort();
78
+ }
79
+
80
+ function listTextFilesRecursive(dir: string, extensions: Set<string>): string[] {
81
+ if (!existsSync(dir)) return [];
82
+ const files: string[] = [];
83
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
84
+ if (entry.name === 'dist' || entry.name === 'node_modules') continue;
85
+ const fullPath = join(dir, entry.name);
86
+ if (entry.isDirectory()) {
87
+ files.push(...listTextFilesRecursive(fullPath, extensions));
88
+ } else if ([...extensions].some((ext) => entry.name.endsWith(ext))) {
89
+ files.push(fullPath);
90
+ }
91
+ }
92
+ return files.sort();
93
+ }
94
+
95
+ function readJson(path: string): unknown {
96
+ return JSON.parse(readFileSync(path, 'utf8'));
97
+ }
98
+
99
+ function collectJsonRefs(value: unknown): string[] {
100
+ const refs: string[] = [];
101
+ const visit = (node: unknown): void => {
102
+ if (node === null || typeof node !== 'object') return;
103
+ if (Array.isArray(node)) {
104
+ for (const child of node) visit(child);
105
+ return;
106
+ }
107
+ const obj = node as Record<string, unknown>;
108
+ if (typeof obj.$ref === 'string') refs.push(obj.$ref);
109
+ for (const child of Object.values(obj)) visit(child);
110
+ };
111
+ visit(value);
112
+ return refs;
113
+ }
114
+
115
+ /** Minimal YAML parser substitute — assert the file is parseable as
116
+ * YAML 1.2 by checking it's valid via the spec's structural fields.
117
+ * We don't pull in `js-yaml` to keep the conformance package's
118
+ * dep surface minimal; instead we read enough of the file to assert
119
+ * the openapi:/asyncapi: top-level keys are present.
120
+ */
121
+ function readYamlHeader(path: string): {
122
+ raw: string;
123
+ topLevelKeys: Set<string>;
124
+ } {
125
+ const raw = readFileSync(path, 'utf8');
126
+ const topLevelKeys = new Set<string>();
127
+ for (const line of raw.split('\n')) {
128
+ // Skip comments + indented lines + blanks.
129
+ if (line.startsWith('#') || line.startsWith(' ') || line.startsWith('\t') || line.trim() === '') {
130
+ continue;
131
+ }
132
+ const colon = line.indexOf(':');
133
+ if (colon > 0) {
134
+ topLevelKeys.add(line.slice(0, colon));
135
+ }
136
+ }
137
+ return { raw, topLevelKeys };
138
+ }
139
+
140
+ /** Extract every `$ref:` value from a YAML or JSON file (string scan). */
141
+ function extractRefs(raw: string): string[] {
142
+ const refs: string[] = [];
143
+ const re = /\$ref:\s*['"]?([^'"\s\n]+)['"]?/g;
144
+ let m: RegExpExecArray | null;
145
+ while ((m = re.exec(raw)) !== null) {
146
+ if (m[1]) refs.push(m[1]);
147
+ }
148
+ return refs;
149
+ }
150
+
151
+ function extractOpenApiOperationIds(raw: string): string[] {
152
+ const ids: string[] = [];
153
+ const re = /^\s+operationId:\s*([A-Za-z0-9_-]+)\s*$/gm;
154
+ let m: RegExpExecArray | null;
155
+ while ((m = re.exec(raw)) !== null) {
156
+ if (m[1]) ids.push(m[1]);
157
+ }
158
+ return ids;
159
+ }
160
+
161
+ interface OpenApiOperation {
162
+ readonly path: string;
163
+ readonly method: string;
164
+ readonly operationId: string;
165
+ readonly clearsSecurity: boolean;
166
+ readonly responseStatusCodes: readonly string[];
167
+ }
168
+
169
+ function extractOpenApiOperations(raw: string): OpenApiOperation[] {
170
+ const operations: OpenApiOperation[] = [];
171
+ let currentPath: string | null = null;
172
+ let currentMethod: string | null = null;
173
+ let currentOperationId: string | null = null;
174
+ let currentClearsSecurity = false;
175
+ let currentResponseStatusCodes: string[] = [];
176
+
177
+ function flush(): void {
178
+ if (currentPath && currentMethod && currentOperationId) {
179
+ operations.push({
180
+ path: currentPath,
181
+ method: currentMethod,
182
+ operationId: currentOperationId,
183
+ clearsSecurity: currentClearsSecurity,
184
+ responseStatusCodes: currentResponseStatusCodes,
185
+ });
186
+ }
187
+ currentMethod = null;
188
+ currentOperationId = null;
189
+ currentClearsSecurity = false;
190
+ currentResponseStatusCodes = [];
191
+ }
192
+
193
+ for (const line of raw.split('\n')) {
194
+ const pathMatch = line.match(/^ (\/.*):\s*$/);
195
+ if (pathMatch) {
196
+ flush();
197
+ currentPath = pathMatch[1] ?? null;
198
+ continue;
199
+ }
200
+
201
+ const methodMatch = line.match(/^ (get|post|put|patch|delete):\s*$/);
202
+ if (methodMatch) {
203
+ flush();
204
+ currentMethod = methodMatch[1] ?? null;
205
+ continue;
206
+ }
207
+
208
+ if (currentMethod) {
209
+ const operationIdMatch = line.match(/^\s{6}operationId:\s*([A-Za-z0-9_-]+)\s*$/);
210
+ if (operationIdMatch) {
211
+ currentOperationId = operationIdMatch[1] ?? null;
212
+ }
213
+ if (/^\s{6}security:\s*\[\]\s*(?:#.*)?$/.test(line)) {
214
+ currentClearsSecurity = true;
215
+ }
216
+ const responseCodeMatch = line.match(/^\s{8}'([0-9]{3})':/);
217
+ if (responseCodeMatch?.[1]) {
218
+ currentResponseStatusCodes.push(responseCodeMatch[1]);
219
+ }
220
+ }
221
+ }
222
+
223
+ flush();
224
+ return operations;
225
+ }
226
+
227
+ interface RestEndpointCatalogRow {
228
+ readonly method: string;
229
+ readonly path: string;
230
+ readonly auth: string;
231
+ readonly scope: string;
232
+ }
233
+
234
+ function extractRestEndpointCatalogRows(markdown: string): RestEndpointCatalogRow[] {
235
+ const rows: RestEndpointCatalogRow[] = [];
236
+ const re = /^\|\s*`([A-Z]+)`\s*\|\s*`([^`]+)`\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|/gm;
237
+ let m: RegExpExecArray | null;
238
+ while ((m = re.exec(markdown)) !== null) {
239
+ rows.push({
240
+ method: (m[1] ?? '').toLowerCase(),
241
+ path: (m[2] ?? '').trim(),
242
+ auth: (m[3] ?? '').trim(),
243
+ scope: (m[4] ?? '').trim(),
244
+ });
245
+ }
246
+ return rows;
247
+ }
248
+
249
+ function extractOpenApiComponentSchemaBlock(raw: string, schemaName: string): string {
250
+ const startRe = new RegExp(`^ ${schemaName}:\\s*$`, 'm');
251
+ const startMatch = startRe.exec(raw);
252
+ expect(startMatch, `OpenAPI components.schemas.${schemaName} MUST exist`).not.toBeNull();
253
+
254
+ const start = startMatch?.index ?? 0;
255
+ const nextSchemaRe = /^ [A-Za-z0-9_-]+:\s*$/gm;
256
+ nextSchemaRe.lastIndex = start + (startMatch?.[0].length ?? 0);
257
+ const nextMatch = nextSchemaRe.exec(raw);
258
+ return raw.slice(start, nextMatch?.index ?? raw.length);
259
+ }
260
+
261
+ function extractDeclaredOpenApiTags(raw: string): string[] {
262
+ const tagsStart = raw.indexOf('\ntags:\n');
263
+ const pathsStart = raw.indexOf('\n# ─────────────────────────────────────────────────────────────────────────────\n# PATHS', tagsStart);
264
+ expect(tagsStart, 'OpenAPI MUST include top-level tags').toBeGreaterThanOrEqual(0);
265
+ expect(pathsStart, 'OpenAPI tags block MUST precede paths block').toBeGreaterThan(tagsStart);
266
+
267
+ const tagsBlock = raw.slice(tagsStart, pathsStart);
268
+ const tags: string[] = [];
269
+ const re = /^\s+- name:\s*([A-Za-z0-9_-]+)\s*$/gm;
270
+ let m: RegExpExecArray | null;
271
+ while ((m = re.exec(tagsBlock)) !== null) {
272
+ if (m[1]) tags.push(m[1]);
273
+ }
274
+ return tags;
275
+ }
276
+
277
+ function extractOpenApiOperationTags(raw: string): string[] {
278
+ const tags: string[] = [];
279
+ const re = /^\s+tags:\s*\[([^\]]+)\]\s*$/gm;
280
+ let m: RegExpExecArray | null;
281
+ while ((m = re.exec(raw)) !== null) {
282
+ const names = (m[1] ?? '')
283
+ .split(',')
284
+ .map((name) => name.trim())
285
+ .filter((name) => name.length > 0);
286
+ tags.push(...names);
287
+ }
288
+ return tags;
289
+ }
290
+
291
+ function findRunEventTypeEnum(schema: unknown): string[] {
292
+ const visit = (value: unknown): string[] | null => {
293
+ if (value === null || typeof value !== 'object') return null;
294
+ const obj = value as Record<string, unknown>;
295
+ if (
296
+ Array.isArray(obj.enum) &&
297
+ obj.enum.every((entry) => typeof entry === 'string') &&
298
+ obj.enum.includes('run.started')
299
+ ) {
300
+ return obj.enum as string[];
301
+ }
302
+ for (const child of Object.values(obj)) {
303
+ const found = visit(child);
304
+ if (found !== null) return found;
305
+ }
306
+ return null;
307
+ };
308
+
309
+ const found = visit(schema);
310
+ expect(found, 'run-event.schema.json MUST contain the RunEventType enum').not.toBeNull();
311
+ return found ?? [];
312
+ }
313
+
314
+ function extractAsyncApiMessageNames(raw: string): string[] {
315
+ const messagesStart = raw.indexOf('\n messages:\n');
316
+ const schemasStart = raw.indexOf('\n # ── Schemas', messagesStart);
317
+ expect(messagesStart, 'AsyncAPI MUST include components.messages').toBeGreaterThanOrEqual(0);
318
+ expect(schemasStart, 'AsyncAPI messages block MUST precede schemas block').toBeGreaterThan(messagesStart);
319
+
320
+ const messagesBlock = raw.slice(messagesStart, schemasStart);
321
+ const names: string[] = [];
322
+ const re = /^\s{6}name:\s*([^\s#]+)\s*$/gm;
323
+ let m: RegExpExecArray | null;
324
+ while ((m = re.exec(messagesBlock)) !== null) {
325
+ if (m[1]) names.push(m[1]);
326
+ }
327
+ return names;
328
+ }
329
+
330
+ function extractTopLevelYamlKeysBetween(raw: string, startMarker: string, endMarker: string): string[] {
331
+ const start = raw.indexOf(startMarker);
332
+ const end = raw.indexOf(endMarker, start);
333
+ expect(start, `YAML block start marker not found: ${startMarker}`).toBeGreaterThanOrEqual(0);
334
+ expect(end, `YAML block end marker not found after ${startMarker}: ${endMarker}`).toBeGreaterThan(start);
335
+
336
+ const block = raw.slice(start + startMarker.length, end);
337
+ const keys: string[] = [];
338
+ const re = /^\s{2}([A-Za-z0-9_-]+):\s*$/gm;
339
+ let m: RegExpExecArray | null;
340
+ while ((m = re.exec(block)) !== null) {
341
+ if (m[1]) keys.push(m[1]);
342
+ }
343
+ return keys;
344
+ }
345
+
346
+ function extractAsyncApiOperationChannelRefs(raw: string): string[] {
347
+ const operationsStart = raw.indexOf('\noperations:\n');
348
+ const componentsStart = raw.indexOf('\n# ─────────────────────────────────────────────────────────────────────────────\n# COMPONENTS', operationsStart);
349
+ expect(operationsStart, 'AsyncAPI MUST include operations').toBeGreaterThanOrEqual(0);
350
+ expect(componentsStart, 'AsyncAPI operations block MUST precede components block').toBeGreaterThan(operationsStart);
351
+
352
+ const operationsBlock = raw.slice(operationsStart, componentsStart);
353
+ const refs: string[] = [];
354
+ const re = /^\s{6}\$ref:\s*'#\/channels\/([A-Za-z0-9_-]+)'\s*$/gm;
355
+ let m: RegExpExecArray | null;
356
+ while ((m = re.exec(operationsBlock)) !== null) {
357
+ if (m[1]) refs.push(m[1]);
358
+ }
359
+ return refs;
360
+ }
361
+
362
+ function extractReadmeDocumentIndex(readme: string): string {
363
+ const start = readme.indexOf('## Document index');
364
+ const end = readme.indexOf('## Quickstart', start);
365
+ expect(start, 'README.md MUST contain a "## Document index" section').toBeGreaterThanOrEqual(0);
366
+ expect(end, 'README.md Document index MUST be followed by "## Quickstart"').toBeGreaterThan(start);
367
+ return readme.slice(start, end);
368
+ }
369
+
370
+ function listMarkdownFilesRecursive(dir: string): string[] {
371
+ const ignoredDirs = new Set(['.git', 'node_modules', 'dist']);
372
+ const files: string[] = [];
373
+
374
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
375
+ if (entry.isDirectory()) {
376
+ if (ignoredDirs.has(entry.name)) continue;
377
+ const child = join(dir, entry.name);
378
+ if (relative(dir, child).startsWith('site/out')) continue;
379
+ files.push(...listMarkdownFilesRecursive(child));
380
+ continue;
381
+ }
382
+ if (entry.isFile() && entry.name.endsWith('.md')) {
383
+ files.push(join(dir, entry.name));
384
+ }
385
+ }
386
+
387
+ return files;
388
+ }
389
+
390
+ function stripFencedCodeBlocks(markdown: string): string {
391
+ return markdown.replace(/```[\s\S]*?```/g, '');
392
+ }
393
+
394
+ function extractLocalMarkdownLinks(markdown: string): string[] {
395
+ const links: string[] = [];
396
+ const re = /!?\[[^\]\n]*\]\(([^)\n]+)\)/g;
397
+ let m: RegExpExecArray | null;
398
+
399
+ while ((m = re.exec(stripFencedCodeBlocks(markdown))) !== null) {
400
+ let raw = (m[1] ?? '').trim();
401
+ raw = raw.replace(/\s+"[^"]*"$/, '').trim();
402
+ if (raw.startsWith('<') && raw.endsWith('>')) raw = raw.slice(1, -1);
403
+
404
+ if (
405
+ raw === '' ||
406
+ raw.startsWith('#') ||
407
+ raw.startsWith('/') ||
408
+ raw.startsWith('http://') ||
409
+ raw.startsWith('https://') ||
410
+ raw.startsWith('mailto:') ||
411
+ raw.startsWith('data:') ||
412
+ raw.includes('://')
413
+ ) {
414
+ continue;
415
+ }
416
+
417
+ links.push(raw);
418
+ }
419
+
420
+ return links;
421
+ }
422
+
423
+ // ── Scenarios ───────────────────────────────────────────────────────────
424
+
425
+ describe('spec-corpus: JSON Schemas compile under Ajv2020', () => {
426
+ const schemaFiles = listJsonFiles(SCHEMAS_DIR);
427
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
428
+ addFormats(ajv);
429
+
430
+ it('finds at least three schemas (workflow-definition, run-event, suspend-request)', () => {
431
+ expect(schemaFiles.length).toBeGreaterThanOrEqual(3);
432
+ expect(schemaFiles).toContain('workflow-definition.schema.json');
433
+ expect(schemaFiles).toContain('run-event.schema.json');
434
+ expect(schemaFiles).toContain('suspend-request.schema.json');
435
+ });
436
+
437
+ for (const file of schemaFiles) {
438
+ it(`${file} parses + compiles`, () => {
439
+ const schema = readJson(join(SCHEMAS_DIR, file)) as Record<string, unknown>;
440
+ expect(schema['$schema']).toBe('https://json-schema.org/draft/2020-12/schema');
441
+ expect(schema['$id'], `${file} $id MUST match its canonical openwop.dev URL`).toBe(
442
+ `https://openwop.dev/spec/v1/${file}`,
443
+ );
444
+ expect(typeof schema['title']).toBe('string');
445
+ // Compile — throws on structural issues.
446
+ const validate = ajv.compile(schema);
447
+ expect(typeof validate).toBe('function');
448
+ });
449
+ }
450
+ });
451
+
452
+ describe('spec-corpus: schemas/README.md index matches schema files', () => {
453
+ const schemaFiles = listJsonFiles(SCHEMAS_DIR).filter((f) => f.endsWith('.schema.json')).sort();
454
+ const schemasReadmePath = join(SCHEMAS_DIR, 'README.md');
455
+
456
+ it('schemas/README.md exists next to schema files', () => {
457
+ expect(existsSync(schemasReadmePath), 'schemas/README.md MUST exist').toBe(true);
458
+ });
459
+
460
+ it('schemas/README.md lists every *.schema.json exactly once', () => {
461
+ const readme = readFileSync(schemasReadmePath, 'utf8');
462
+ const tableStart = readme.indexOf('| Schema | Source spec | Coverage |');
463
+ const tableEnd = readme.indexOf('## Validating against the schemas', tableStart);
464
+
465
+ expect(tableStart, 'schemas/README.md MUST include the schema index table').toBeGreaterThanOrEqual(0);
466
+ expect(tableEnd, 'schemas/README.md schema index MUST precede validation instructions').toBeGreaterThan(tableStart);
467
+
468
+ const table = readme.slice(tableStart, tableEnd);
469
+ const mentioned = table
470
+ .split('\n')
471
+ .map((line) => line.match(/^\|\s+`([^`]+\.schema\.json)`\s+\|/)?.[1])
472
+ .filter((name): name is string => typeof name === 'string');
473
+
474
+ for (const file of schemaFiles) {
475
+ const occurrences = mentioned.filter((name) => name === file).length;
476
+ expect(
477
+ occurrences,
478
+ `schemas/README.md MUST list ${file} exactly once`,
479
+ ).toBe(1);
480
+ }
481
+
482
+ for (const file of mentioned) {
483
+ expect(
484
+ schemaFiles,
485
+ `schemas/README.md lists ${file}, but no matching schema file exists`,
486
+ ).toContain(file);
487
+ }
488
+ });
489
+ });
490
+
491
+ describe('spec-corpus: absolute JSON Schema refs resolve inside the corpus', () => {
492
+ const schemaFiles = listJsonFiles(SCHEMAS_DIR).filter((f) => f.endsWith('.schema.json')).sort();
493
+ const schemaIds = new Set(
494
+ schemaFiles.map((file) => {
495
+ const schema = readJson(join(SCHEMAS_DIR, file)) as Record<string, unknown>;
496
+ return schema.$id;
497
+ }),
498
+ );
499
+
500
+ for (const file of schemaFiles) {
501
+ it(`${file} absolute $refs point to known schema ids`, () => {
502
+ const schema = readJson(join(SCHEMAS_DIR, file));
503
+ const refs = collectJsonRefs(schema)
504
+ .filter((ref) => ref.startsWith('https://openwop.dev/spec/v1/'))
505
+ .map((ref) => ref.split('#')[0] ?? ref);
506
+
507
+ for (const ref of refs) {
508
+ expect(
509
+ schemaIds.has(ref),
510
+ `${file} has absolute $ref ${ref}, but no schema file declares that $id`,
511
+ ).toBe(true);
512
+ }
513
+ });
514
+ }
515
+ });
516
+
517
+ describe('spec-corpus: RunEventType payload index matches event enum', () => {
518
+ const runEventSchema = readJson(join(SCHEMAS_DIR, 'run-event.schema.json')) as Record<string, unknown>;
519
+ const payloadSchema = readJson(join(SCHEMAS_DIR, 'run-event-payloads.schema.json')) as Record<string, unknown>;
520
+
521
+ function typeIndexProperties(): Record<string, unknown> {
522
+ const defs = payloadSchema.$defs as Record<string, unknown> | undefined;
523
+ const typeIndex = defs?._typeIndex as Record<string, unknown> | undefined;
524
+ const properties = typeIndex?.properties as Record<string, unknown> | undefined;
525
+ expect(properties, 'run-event-payloads.schema.json MUST include $defs._typeIndex.properties').toBeDefined();
526
+ return properties ?? {};
527
+ }
528
+
529
+ it('payload type-index keys exactly match RunEventType enum values', () => {
530
+ const runEventTypes = findRunEventTypeEnum(runEventSchema).sort();
531
+ const indexedTypes = Object.keys(typeIndexProperties()).sort();
532
+
533
+ expect(indexedTypes, 'run-event-payloads.schema.json _typeIndex MUST cover every RunEventType').toEqual(
534
+ runEventTypes,
535
+ );
536
+ });
537
+
538
+ it('payload type-index refs point to declared payload $defs', () => {
539
+ const defs = payloadSchema.$defs as Record<string, unknown> | undefined;
540
+ expect(defs, 'run-event-payloads.schema.json MUST declare $defs').toBeDefined();
541
+
542
+ for (const [eventType, entry] of Object.entries(typeIndexProperties())) {
543
+ const ref = (entry as Record<string, unknown>).$ref;
544
+ expect(typeof ref, `_typeIndex.${eventType} MUST be a $ref`).toBe('string');
545
+ const defName = String(ref).match(/^#\/\$defs\/([A-Za-z0-9_-]+)$/)?.[1];
546
+ expect(defName, `_typeIndex.${eventType} MUST reference #/$defs/<name>`).toBeDefined();
547
+ expect(
548
+ Object.prototype.hasOwnProperty.call(defs ?? {}, defName ?? ''),
549
+ `_typeIndex.${eventType} references missing payload definition "${defName}"`,
550
+ ).toBe(true);
551
+ }
552
+ });
553
+
554
+ it('payload schema description states the current RunEventType variant count', () => {
555
+ const runEventTypes = findRunEventTypeEnum(runEventSchema);
556
+ const description = payloadSchema.description;
557
+
558
+ expect(typeof description, 'run-event-payloads.schema.json MUST carry a description').toBe('string');
559
+ expect(
560
+ description,
561
+ 'run-event-payloads.schema.json description MUST state the current RunEventType variant count',
562
+ ).toContain(`${runEventTypes.length} variants from \`run-event.schema.json#$defs.RunEventType\``);
563
+ });
564
+ });
565
+
566
+ describe('spec-corpus: OpenAPI 3.1 spec is structurally valid', () => {
567
+ const openapiPath = join(API_DIR, 'openapi.yaml');
568
+
569
+ it('exists', () => {
570
+ expect(existsSync(openapiPath)).toBe(true);
571
+ });
572
+
573
+ it('declares openapi: 3.1 + required top-level keys', () => {
574
+ const { topLevelKeys, raw } = readYamlHeader(openapiPath);
575
+ expect(topLevelKeys.has('openapi')).toBe(true);
576
+ expect(topLevelKeys.has('info')).toBe(true);
577
+ expect(topLevelKeys.has('paths')).toBe(true);
578
+ expect(topLevelKeys.has('components')).toBe(true);
579
+ expect(raw).toMatch(/^openapi:\s*3\.1(?:\.[0-9]+)?\s*$/m);
580
+ });
581
+
582
+ it('every $ref to ../schemas/*.json resolves to a real file', () => {
583
+ const { raw } = readYamlHeader(openapiPath);
584
+ const refs = extractRefs(raw).filter((r) => r.startsWith('../schemas/'));
585
+ expect(refs.length).toBeGreaterThan(0); // at least one schema reference
586
+ for (const ref of refs) {
587
+ const abs = pathResolve(API_DIR, ref.split('#')[0] ?? ref);
588
+ expect(existsSync(abs), `OpenAPI $ref points at missing file: ${ref}`).toBe(true);
589
+ }
590
+ });
591
+
592
+ it('operationIds are unique', () => {
593
+ const { raw } = readYamlHeader(openapiPath);
594
+ const operationIds = extractOpenApiOperationIds(raw);
595
+ const duplicates = operationIds.filter((id, index) => operationIds.indexOf(id) !== index);
596
+
597
+ expect(operationIds.length, 'OpenAPI MUST declare operationIds for public routes').toBeGreaterThan(0);
598
+ expect(duplicates, `OpenAPI operationIds MUST be unique; duplicates: ${duplicates.join(', ')}`).toEqual([]);
599
+ });
600
+
601
+ it('every operation tag is declared in the top-level tags list', () => {
602
+ const { raw } = readYamlHeader(openapiPath);
603
+ const declaredTags = new Set(extractDeclaredOpenApiTags(raw));
604
+ const operationTags = extractOpenApiOperationTags(raw);
605
+
606
+ expect(declaredTags.size, 'OpenAPI MUST declare at least one top-level tag').toBeGreaterThan(0);
607
+ expect(operationTags.length, 'OpenAPI operations MUST carry tags').toBeGreaterThan(0);
608
+
609
+ for (const tag of operationTags) {
610
+ expect(
611
+ declaredTags.has(tag),
612
+ `OpenAPI operation tag "${tag}" MUST be declared in the top-level tags list`,
613
+ ).toBe(true);
614
+ }
615
+ });
616
+
617
+ it('declares ApiKeyAuth as the global default security requirement', () => {
618
+ const { raw } = readYamlHeader(openapiPath);
619
+
620
+ expect(raw, 'OpenAPI MUST declare global ApiKeyAuth security').toMatch(
621
+ /^security:\n\s+- ApiKeyAuth:\s*\[\]\s*$/m,
622
+ );
623
+ expect(raw, 'OpenAPI MUST define ApiKeyAuth as an HTTP bearer security scheme').toMatch(
624
+ /^\s{4}ApiKeyAuth:\n\s{6}type:\s*http\n\s{6}scheme:\s*bearer\s*$/m,
625
+ );
626
+ });
627
+
628
+ it('only documented public or signed-token operations clear security', () => {
629
+ const { raw } = readYamlHeader(openapiPath);
630
+ const publicOperationIds = new Set([
631
+ 'getCapabilities',
632
+ 'getOpenApiSpec',
633
+ 'inspectInterruptByToken',
634
+ 'resolveInterruptByToken',
635
+ ]);
636
+
637
+ const operations = extractOpenApiOperations(raw);
638
+ expect(operations.length, 'OpenAPI MUST expose operations').toBeGreaterThan(0);
639
+
640
+ for (const operation of operations) {
641
+ expect(
642
+ operation.clearsSecurity,
643
+ `OpenAPI operation ${operation.operationId} security override MUST match its public/signed-token status`,
644
+ ).toBe(publicOperationIds.has(operation.operationId));
645
+ }
646
+ });
647
+
648
+ it('protected operations document canonical 401 and 403 auth failure responses', () => {
649
+ const { raw } = readYamlHeader(openapiPath);
650
+ const operations = extractOpenApiOperations(raw);
651
+
652
+ expect(operations.length, 'OpenAPI MUST expose operations').toBeGreaterThan(0);
653
+
654
+ for (const operation of operations) {
655
+ if (operation.clearsSecurity) continue;
656
+ expect(
657
+ operation.responseStatusCodes,
658
+ `Protected OpenAPI operation ${operation.operationId} MUST document 401 Unauthenticated`,
659
+ ).toContain('401');
660
+ expect(
661
+ operation.responseStatusCodes,
662
+ `Protected OpenAPI operation ${operation.operationId} MUST document 403 Forbidden`,
663
+ ).toContain('403');
664
+ }
665
+ });
666
+
667
+ it('typed error specializations compose the canonical Error schema', () => {
668
+ const { raw } = readYamlHeader(openapiPath);
669
+
670
+ for (const schemaName of ['RunClaimConflict', 'UnsupportedStreamMode']) {
671
+ const block = extractOpenApiComponentSchemaBlock(raw, schemaName);
672
+ expect(
673
+ block,
674
+ `OpenAPI ${schemaName} MUST compose the canonical Error schema`,
675
+ ).toContain("- $ref: '#/components/schemas/Error'");
676
+ expect(
677
+ block,
678
+ `OpenAPI ${schemaName} MUST keep typed metadata under details`,
679
+ ).toMatch(/^\s{12}details:\s*$/m);
680
+ expect(
681
+ block,
682
+ `OpenAPI ${schemaName} MUST require the canonical error/message/details top-level fields`,
683
+ ).toContain('required: [error, message, details]');
684
+ }
685
+ });
686
+ });
687
+
688
+ describe.skipIf(COVERAGE_DOC_PATH === null)('spec-corpus: OpenAPI operation coverage map', () => {
689
+ const openapiPath = join(API_DIR, 'openapi.yaml');
690
+ const coverageDocPath = COVERAGE_DOC_PATH as string;
691
+
692
+ it('every OpenAPI operationId is represented in conformance/coverage.md', () => {
693
+ const { raw } = readYamlHeader(openapiPath);
694
+ const operationIds = extractOpenApiOperationIds(raw);
695
+ const coverage = readFileSync(coverageDocPath, 'utf8');
696
+
697
+ expect(operationIds.length, 'OpenAPI MUST declare operationIds for public routes').toBeGreaterThan(0);
698
+
699
+ for (const operationId of operationIds) {
700
+ expect(
701
+ coverage,
702
+ `conformance/coverage.md MUST mention OpenAPI operationId "${operationId}"`,
703
+ ).toContain(`\`${operationId}\``);
704
+ }
705
+ });
706
+ });
707
+
708
+ describe.skipIf(V1_DIR === null)('spec-corpus: REST endpoint catalog matches OpenAPI paths', () => {
709
+ const openapiPath = join(API_DIR, 'openapi.yaml');
710
+ const restEndpointsDocPath = V1_DIR === null ? '' : join(V1_DIR, 'rest-endpoints.md');
711
+
712
+ it('every OpenAPI operation has a matching method/path row in rest-endpoints.md', () => {
713
+ const { raw } = readYamlHeader(openapiPath);
714
+ const operations = extractOpenApiOperations(raw);
715
+ const catalogRows = extractRestEndpointCatalogRows(readFileSync(restEndpointsDocPath, 'utf8'));
716
+ const catalogKeys = new Set(catalogRows.map((row) => `${row.method} ${row.path}`));
717
+
718
+ expect(operations.length, 'OpenAPI MUST expose operations').toBeGreaterThan(0);
719
+ expect(catalogRows.length, 'rest-endpoints.md MUST include endpoint catalog rows').toBeGreaterThan(0);
720
+
721
+ for (const operation of operations) {
722
+ const key = `${operation.method} ${operation.path}`;
723
+ expect(
724
+ catalogKeys.has(key),
725
+ `rest-endpoints.md MUST document OpenAPI operation ${operation.operationId} as ${key}`,
726
+ ).toBe(true);
727
+ }
728
+ });
729
+
730
+ it('REST catalog auth/scope columns match OpenAPI security overrides', () => {
731
+ const { raw } = readYamlHeader(openapiPath);
732
+ const operations = extractOpenApiOperations(raw);
733
+ const catalogRows = extractRestEndpointCatalogRows(readFileSync(restEndpointsDocPath, 'utf8'));
734
+ const catalogByKey = new Map(catalogRows.map((row) => [`${row.method} ${row.path}`, row]));
735
+
736
+ for (const operation of operations) {
737
+ const row = catalogByKey.get(`${operation.method} ${operation.path}`);
738
+ expect(row, `rest-endpoints.md MUST document ${operation.method.toUpperCase()} ${operation.path}`).toBeDefined();
739
+ if (!row) continue;
740
+
741
+ if (operation.clearsSecurity) {
742
+ expect(
743
+ ['None', 'Signed token'],
744
+ `${operation.operationId} clears OpenAPI security, so rest-endpoints.md MUST mark auth as None or Signed token`,
745
+ ).toContain(row.auth);
746
+ expect(
747
+ row.scope,
748
+ `${operation.operationId} clears OpenAPI security, so rest-endpoints.md MUST mark scope as None`,
749
+ ).toBe('None');
750
+ } else {
751
+ expect(
752
+ row.auth,
753
+ `${operation.operationId} inherits OpenAPI ApiKeyAuth, so rest-endpoints.md MUST mark auth as API key`,
754
+ ).toBe('API key');
755
+ expect(
756
+ row.scope,
757
+ `${operation.operationId} inherits OpenAPI ApiKeyAuth, so rest-endpoints.md MUST name a non-empty non-None scope`,
758
+ ).not.toBe('None');
759
+ }
760
+ }
761
+ });
762
+ });
763
+
764
+ describe.skipIf(V1_DIR === null)('spec-corpus: error examples use canonical details slot', () => {
765
+ const v1Dir = V1_DIR as string;
766
+ const docsToCheck = ['auth.md', 'idempotency.md', 'rest-endpoints.md'];
767
+
768
+ for (const file of docsToCheck) {
769
+ it(`${file} does not document retry/conflict metadata as top-level error fields`, () => {
770
+ const content = readFileSync(join(v1Dir, file), 'utf8');
771
+
772
+ expect(
773
+ content,
774
+ `${file} MUST NOT show retryAfter as a top-level error field; use details.retryAfter`,
775
+ ).not.toMatch(/\{\s*(?:[^{}]|\{[^{}]*\})*error:[^{}]*retryAfter[^{}]*\}/);
776
+ expect(
777
+ content,
778
+ `${file} MUST NOT show activeRunId/activeHost as top-level error fields; use details.{activeRunId,activeHost}`,
779
+ ).not.toMatch(/\{\s*(?:[^{}]|\{[^{}]*\})*error:[^{}]*(activeRunId|activeHost)[^{}]*\}/);
780
+ });
781
+ }
782
+ });
783
+
784
+ describe.skipIf(
785
+ TYPESCRIPT_RUN_HELPERS_PATH === null || PYTHON_TYPES_PATH === null || GO_TYPES_PATH === null,
786
+ )(
787
+ 'spec-corpus: SDK HTTP error helpers match canonical REST vocabulary',
788
+ () => {
789
+ const sdkSources = {
790
+ typescript: TYPESCRIPT_RUN_HELPERS_PATH as string,
791
+ python: PYTHON_TYPES_PATH as string,
792
+ go: GO_TYPES_PATH as string,
793
+ };
794
+ const sdkReadmes = {
795
+ typescript: pathResolve(dirname(sdkSources.typescript), '..', 'README.md'),
796
+ python: pathResolve(dirname(sdkSources.python), '..', '..', 'README.md'),
797
+ go: pathResolve(dirname(sdkSources.go), 'README.md'),
798
+ };
799
+ const typescriptDist = {
800
+ indexDts: pathResolve(dirname(sdkSources.typescript), '..', 'dist', 'index.d.ts'),
801
+ indexJs: pathResolve(dirname(sdkSources.typescript), '..', 'dist', 'index.js'),
802
+ runHelpersDts: pathResolve(dirname(sdkSources.typescript), '..', 'dist', 'run-helpers.d.ts'),
803
+ runHelpersJs: pathResolve(dirname(sdkSources.typescript), '..', 'dist', 'run-helpers.js'),
804
+ };
805
+ const typescriptDistMaps = {
806
+ indexDts: pathResolve(dirname(sdkSources.typescript), '..', 'dist', 'index.d.ts.map'),
807
+ indexJs: pathResolve(dirname(sdkSources.typescript), '..', 'dist', 'index.js.map'),
808
+ runHelpersDts: pathResolve(dirname(sdkSources.typescript), '..', 'dist', 'run-helpers.d.ts.map'),
809
+ runHelpersJs: pathResolve(dirname(sdkSources.typescript), '..', 'dist', 'run-helpers.js.map'),
810
+ };
811
+ const sdkChangelogs = {
812
+ typescript: pathResolve(dirname(sdkSources.typescript), '..', 'CHANGELOG.md'),
813
+ python: pathResolve(dirname(sdkSources.python), '..', '..', 'CHANGELOG.md'),
814
+ go: pathResolve(dirname(sdkSources.go), 'CHANGELOG.md'),
815
+ };
816
+
817
+ it('TypeScript exports HTTP_ERROR_CODES and isHttpErrorCode', () => {
818
+ const source = readFileSync(sdkSources.typescript, 'utf8');
819
+
820
+ expect(source, 'TypeScript SDK MUST export HTTP_ERROR_CODES').toContain('export const HTTP_ERROR_CODES');
821
+ expect(source, 'TypeScript SDK MUST export isHttpErrorCode').toContain('export function isHttpErrorCode');
822
+ });
823
+
824
+ it('Python exports HTTP_ERROR_CODES and is_http_error_code', () => {
825
+ const source = readFileSync(sdkSources.python, 'utf8');
826
+
827
+ expect(source, 'Python SDK MUST export HTTP_ERROR_CODES').toContain('HTTP_ERROR_CODES = frozenset');
828
+ expect(source, 'Python SDK MUST export is_http_error_code').toContain('def is_http_error_code');
829
+ });
830
+
831
+ it('Go exports HTTPErrorCodes and IsHTTPErrorCode', () => {
832
+ const source = readFileSync(sdkSources.go, 'utf8');
833
+
834
+ expect(source, 'Go SDK MUST export HTTPErrorCodes').toContain('var HTTPErrorCodes = []string');
835
+ expect(source, 'Go SDK MUST export IsHTTPErrorCode').toContain('func IsHTTPErrorCode');
836
+ });
837
+
838
+ it('SDK READMEs document the HTTP error helper surface', () => {
839
+ expect(readFileSync(sdkReadmes.typescript, 'utf8')).toContain('HTTP_ERROR_CODES');
840
+ expect(readFileSync(sdkReadmes.typescript, 'utf8')).toContain('isHttpErrorCode');
841
+ expect(readFileSync(sdkReadmes.python, 'utf8')).toContain('HTTP_ERROR_CODES');
842
+ expect(readFileSync(sdkReadmes.python, 'utf8')).toContain('is_http_error_code');
843
+ expect(readFileSync(sdkReadmes.go, 'utf8')).toContain('HTTPErrorCodes');
844
+ expect(readFileSync(sdkReadmes.go, 'utf8')).toContain('IsHTTPErrorCode');
845
+ });
846
+
847
+ it('SDK changelogs mention the HTTP error helper surface', () => {
848
+ expect(readFileSync(sdkChangelogs.typescript, 'utf8')).toContain('HTTP_ERROR_CODES');
849
+ expect(readFileSync(sdkChangelogs.typescript, 'utf8')).toContain('isHttpErrorCode');
850
+ expect(readFileSync(sdkChangelogs.python, 'utf8')).toContain('HTTP_ERROR_CODES');
851
+ expect(readFileSync(sdkChangelogs.python, 'utf8')).toContain('is_http_error_code');
852
+ expect(readFileSync(sdkChangelogs.go, 'utf8')).toContain('HTTPErrorCodes');
853
+ expect(readFileSync(sdkChangelogs.go, 'utf8')).toContain('IsHTTPErrorCode');
854
+ });
855
+
856
+ it('TypeScript dist exports the HTTP error helper surface', () => {
857
+ for (const [label, path] of Object.entries(typescriptDist)) {
858
+ expect(existsSync(path), `TypeScript dist artifact ${label} MUST exist`).toBe(true);
859
+ }
860
+
861
+ expect(readFileSync(typescriptDist.indexDts, 'utf8')).toContain('HTTP_ERROR_CODES');
862
+ expect(readFileSync(typescriptDist.indexDts, 'utf8')).toContain('HttpErrorCode');
863
+ expect(readFileSync(typescriptDist.indexDts, 'utf8')).toContain('isHttpErrorCode');
864
+ expect(readFileSync(typescriptDist.indexJs, 'utf8')).toContain('HTTP_ERROR_CODES');
865
+ expect(readFileSync(typescriptDist.indexJs, 'utf8')).toContain('isHttpErrorCode');
866
+ expect(readFileSync(typescriptDist.runHelpersDts, 'utf8')).toContain('HTTP_ERROR_CODES');
867
+ expect(readFileSync(typescriptDist.runHelpersDts, 'utf8')).toContain('HttpErrorCode');
868
+ expect(readFileSync(typescriptDist.runHelpersJs, 'utf8')).toContain('HTTP_ERROR_CODES');
869
+ expect(readFileSync(typescriptDist.runHelpersJs, 'utf8')).toContain('isHttpErrorCode');
870
+ });
871
+
872
+ it('TypeScript dist metadata uses openwop branding', () => {
873
+ for (const path of Object.values(typescriptDist)) {
874
+ const source = readFileSync(path, 'utf8');
875
+ expect(source, `${path} MUST NOT contain legacy MyndHyve package names`).not.toContain('@myndhyve');
876
+ expect(source, `${path} MUST use @openwop package naming`).toContain('@openwop');
877
+ }
878
+ });
879
+
880
+ it('TypeScript dist source maps point back to src and avoid legacy branding', () => {
881
+ for (const [label, path] of Object.entries(typescriptDistMaps)) {
882
+ expect(existsSync(path), `TypeScript dist source map ${label} MUST exist`).toBe(true);
883
+ const sourceMap = readJson(path) as { sources?: unknown };
884
+ const raw = readFileSync(path, 'utf8');
885
+
886
+ expect(raw, `${path} MUST NOT contain legacy MyndHyve package names`).not.toContain('@myndhyve');
887
+ expect(Array.isArray(sourceMap.sources), `${path} MUST declare source files`).toBe(true);
888
+ expect(
889
+ (sourceMap.sources as string[]).every((source) => source.startsWith('../src/')),
890
+ `${path} MUST map to TypeScript source files under ../src`,
891
+ ).toBe(true);
892
+ }
893
+ });
894
+
895
+ for (const code of [
896
+ 'unauthenticated',
897
+ 'forbidden',
898
+ 'key_expired',
899
+ 'key_revoked',
900
+ 'validation_error',
901
+ 'not_found',
902
+ 'rate_limited',
903
+ 'run_already_active',
904
+ 'idempotency_in_flight',
905
+ 'unsupported_stream_mode',
906
+ 'credential_forbidden',
907
+ 'internal_error',
908
+ ]) {
909
+ it(`all SDK HTTP error helpers include ${code}`, () => {
910
+ for (const [sdk, path] of Object.entries(sdkSources)) {
911
+ const source = readFileSync(path, 'utf8');
912
+ expect(source, `${sdk} SDK MUST include canonical REST code ${code}`).toContain(code);
913
+ }
914
+ });
915
+ }
916
+ },
917
+ );
918
+
919
+ describe.skipIf(SCENARIOS_DIR === null || CONFORMANCE_README_PATH === null)(
920
+ 'spec-corpus: conformance README scenario counts match source tree',
921
+ () => {
922
+ const scenariosDir = SCENARIOS_DIR as string;
923
+ const conformanceReadmePath = CONFORMANCE_README_PATH as string;
924
+
925
+ it('README suite count equals src/scenarios/*.test.ts count', () => {
926
+ const scenarioFiles = listScenarioTestFiles(scenariosDir);
927
+ const readme = readFileSync(conformanceReadmePath, 'utf8');
928
+
929
+ expect(scenarioFiles.length, 'conformance suite MUST contain scenario test files').toBeGreaterThan(0);
930
+ expect(
931
+ readme,
932
+ 'conformance/README.md MUST state the current scenario-file count in "What\'s Covered"',
933
+ ).toContain(`The current suite has ${scenarioFiles.length} scenario files under \`src/scenarios/\`.`);
934
+ expect(
935
+ readme,
936
+ 'conformance/README.md MUST state the current scenario-file count near historical notes',
937
+ ).toContain(`Current source tree: ${scenarioFiles.length} scenario files.`);
938
+ });
939
+ },
940
+ );
941
+
942
+ describe('spec-corpus: AsyncAPI 3.1 spec is structurally valid', () => {
943
+ const asyncapiPath = join(API_DIR, 'asyncapi.yaml');
944
+
945
+ it('exists', () => {
946
+ expect(existsSync(asyncapiPath)).toBe(true);
947
+ });
948
+
949
+ it('declares asyncapi: 3.1 + required top-level keys', () => {
950
+ const { topLevelKeys, raw } = readYamlHeader(asyncapiPath);
951
+ expect(topLevelKeys.has('asyncapi')).toBe(true);
952
+ expect(topLevelKeys.has('info')).toBe(true);
953
+ expect(topLevelKeys.has('channels')).toBe(true);
954
+ expect(topLevelKeys.has('operations')).toBe(true);
955
+ expect(raw).toMatch(/^asyncapi:\s*3\.1(?:\.[0-9]+)?\s*$/m);
956
+ });
957
+
958
+ it('every $ref to ../schemas/*.json resolves to a real file', () => {
959
+ const { raw } = readYamlHeader(asyncapiPath);
960
+ const refs = extractRefs(raw).filter((r) => r.startsWith('../schemas/'));
961
+ for (const ref of refs) {
962
+ const abs = pathResolve(API_DIR, ref.split('#')[0] ?? ref);
963
+ expect(existsSync(abs), `AsyncAPI $ref points at missing file: ${ref}`).toBe(true);
964
+ }
965
+ });
966
+
967
+ it('named RunEventDoc messages use event names from run-event.schema.json', () => {
968
+ const { raw } = readYamlHeader(asyncapiPath);
969
+ const messageNames = extractAsyncApiMessageNames(raw);
970
+ const runEventSchema = readJson(join(SCHEMAS_DIR, 'run-event.schema.json'));
971
+ const runEventTypes = new Set(findRunEventTypeEnum(runEventSchema));
972
+ const syntheticMessageNames = new Set(['state.snapshot', 'ai.message.chunk', 'any']);
973
+
974
+ expect(messageNames.length, 'AsyncAPI MUST declare named SSE messages').toBeGreaterThan(0);
975
+
976
+ for (const name of messageNames) {
977
+ if (syntheticMessageNames.has(name)) continue;
978
+ expect(
979
+ runEventTypes.has(name),
980
+ `AsyncAPI message name "${name}" MUST exist in run-event.schema.json RunEventType enum, or be documented as synthetic`,
981
+ ).toBe(true);
982
+ }
983
+ });
984
+
985
+ it('operation channel refs point to declared channels', () => {
986
+ const { raw } = readYamlHeader(asyncapiPath);
987
+ const channels = new Set(
988
+ extractTopLevelYamlKeysBetween(
989
+ raw,
990
+ '\nchannels:\n',
991
+ '\n# ─────────────────────────────────────────────────────────────────────────────\n# OPERATIONS',
992
+ ),
993
+ );
994
+ const channelRefs = extractAsyncApiOperationChannelRefs(raw);
995
+
996
+ expect(channels.size, 'AsyncAPI MUST declare channels').toBeGreaterThan(0);
997
+ expect(channelRefs.length, 'AsyncAPI operations MUST reference channels').toBeGreaterThan(0);
998
+
999
+ for (const ref of channelRefs) {
1000
+ expect(
1001
+ channels.has(ref),
1002
+ `AsyncAPI operation references missing channel "${ref}"`,
1003
+ ).toBe(true);
1004
+ }
1005
+ });
1006
+
1007
+ it('channel keys and message names are unique', () => {
1008
+ const { raw } = readYamlHeader(asyncapiPath);
1009
+ const channelKeys = extractTopLevelYamlKeysBetween(
1010
+ raw,
1011
+ '\nchannels:\n',
1012
+ '\n# ─────────────────────────────────────────────────────────────────────────────\n# OPERATIONS',
1013
+ );
1014
+ const messageNames = extractAsyncApiMessageNames(raw);
1015
+
1016
+ const duplicateChannels = channelKeys.filter((key, index) => channelKeys.indexOf(key) !== index);
1017
+ const duplicateMessages = messageNames.filter((name, index) => messageNames.indexOf(name) !== index);
1018
+
1019
+ expect(duplicateChannels, `AsyncAPI channel keys MUST be unique; duplicates: ${duplicateChannels.join(', ')}`).toEqual([]);
1020
+ expect(duplicateMessages, `AsyncAPI message names MUST be unique; duplicates: ${duplicateMessages.join(', ')}`).toEqual([]);
1021
+ });
1022
+ });
1023
+
1024
+ describe.skipIf(V1_DIR === null)('spec-corpus: prose docs carry a Status: legend tag', () => {
1025
+ // `describe.skipIf` skips test EXECUTION but still evaluates the
1026
+ // describe callback at registration time so vitest can discover the
1027
+ // `it()` calls inside. That means the readdirSync below runs even
1028
+ // when V1_DIR is null (published-tarball layout) — read from a
1029
+ // safe-and-empty fallback when V1_DIR isn't bundled.
1030
+
1031
+ // META_DOCS aren't normative spec docs and don't carry the
1032
+ // STUB / DRAFT / OUTLINE / FINAL maturity tag:
1033
+ // - README.md, CHANGELOG.md, CONTRIBUTING.md, QUICKSTART.md — entry/index docs
1034
+ // - CODE_OF_CONDUCT.md, GOVERNANCE.md, ROADMAP.md, SECURITY.md — project meta-docs
1035
+ // - PUBLISHING.md — operational/release docs
1036
+ const META_DOCS = new Set([
1037
+ 'README.md',
1038
+ 'CHANGELOG.md',
1039
+ 'CONTRIBUTING.md',
1040
+ 'CODE_OF_CONDUCT.md',
1041
+ 'GOVERNANCE.md',
1042
+ 'ROADMAP.md',
1043
+ 'SECURITY.md',
1044
+ 'PUBLISHING.md',
1045
+ 'QUICKSTART.md',
1046
+ ]);
1047
+ const proseFiles =
1048
+ V1_DIR === null
1049
+ ? []
1050
+ : readdirSync(V1_DIR)
1051
+ .filter((f) => f.endsWith('.md') && !META_DOCS.has(f))
1052
+ .sort();
1053
+
1054
+ it('finds the expected prose doc set', () => {
1055
+ // Spec README §Document index lists 11 prose docs. If this drifts,
1056
+ // the README needs updating in the same PR that adds/removes a doc.
1057
+ expect(proseFiles.length).toBeGreaterThanOrEqual(11);
1058
+ });
1059
+
1060
+ for (const file of proseFiles) {
1061
+ it(`${file} declares a Status: tag (STUB / DRAFT / OUTLINE / FINAL)`, () => {
1062
+ // V1_DIR is non-null here — proseFiles is empty when V1_DIR is null
1063
+ // so this loop body never runs in the published-tarball layout.
1064
+ const content = readFileSync(join(V1_DIR as string, file), 'utf8');
1065
+ // Match either ">**Status:" or "**Status:" near the top of file.
1066
+ expect(
1067
+ content,
1068
+ `${file} must include a "Status:" legend tag near its header`,
1069
+ ).toMatch(/\*\*Status:\s*(STUB|DRAFT|OUTLINE|FINAL)\b/);
1070
+ });
1071
+ }
1072
+ });
1073
+
1074
+ describe.skipIf(V1_DIR === null || README_PATH === null)('spec-corpus: README document index matches spec/v1', () => {
1075
+ const v1Dir = V1_DIR as string;
1076
+ const readmePath = README_PATH as string;
1077
+
1078
+ const proseFiles = readdirSync(v1Dir)
1079
+ .filter((f) => f.endsWith('.md'))
1080
+ .sort();
1081
+
1082
+ it('README Total count equals the number of spec/v1 prose docs', () => {
1083
+ const index = extractReadmeDocumentIndex(readFileSync(readmePath, 'utf8'));
1084
+ const totalMatch = index.match(/\*\*Total\*\*:\s+(\d+)\s+docs\./);
1085
+
1086
+ expect(totalMatch, 'README.md document index MUST include a "**Total**: N docs." line').not.toBeNull();
1087
+ expect(Number(totalMatch?.[1]), 'README.md document total MUST match spec/v1/*.md count').toBe(
1088
+ proseFiles.length,
1089
+ );
1090
+ });
1091
+
1092
+ it('README document index links every spec/v1 prose doc exactly once', () => {
1093
+ const index = extractReadmeDocumentIndex(readFileSync(readmePath, 'utf8'));
1094
+ const linkRegex = /\]\(\.\/spec\/v1\/([^)]+\.md)\)/g;
1095
+ const linkedDocs: string[] = [];
1096
+ let m: RegExpExecArray | null;
1097
+ while ((m = linkRegex.exec(index)) !== null) {
1098
+ if (m[1]) linkedDocs.push(m[1]);
1099
+ }
1100
+
1101
+ for (const file of proseFiles) {
1102
+ const occurrences = linkedDocs.filter((linked) => linked === file).length;
1103
+ expect(
1104
+ occurrences,
1105
+ `README.md document index MUST link ./spec/v1/${file} exactly once`,
1106
+ ).toBe(1);
1107
+ }
1108
+ });
1109
+ });
1110
+
1111
+ describe.skipIf(README_PATH === null)('spec-corpus: local Markdown links resolve', () => {
1112
+ const repoRoot = dirname(README_PATH as string);
1113
+ const markdownFiles = listMarkdownFilesRecursive(repoRoot);
1114
+
1115
+ it('finds Markdown files to check', () => {
1116
+ expect(markdownFiles.length, 'repo checkout should contain Markdown docs').toBeGreaterThan(0);
1117
+ });
1118
+
1119
+ for (const file of markdownFiles) {
1120
+ const relFile = relative(repoRoot, file);
1121
+ it(`${relFile} has no broken local Markdown file links`, () => {
1122
+ const links = extractLocalMarkdownLinks(readFileSync(file, 'utf8'));
1123
+ for (const link of links) {
1124
+ const filePart = link.split('#')[0] ?? link;
1125
+ if (filePart === '') continue;
1126
+
1127
+ let decoded = filePart;
1128
+ try {
1129
+ decoded = decodeURIComponent(filePart);
1130
+ } catch {
1131
+ // Keep the raw path; existence check below will fail with a useful message.
1132
+ }
1133
+
1134
+ const target = pathResolve(dirname(file), decoded);
1135
+ expect(
1136
+ existsSync(target),
1137
+ `${relFile} links to missing local target: ${link}`,
1138
+ ).toBe(true);
1139
+ }
1140
+ });
1141
+ }
1142
+ });
1143
+
1144
+ describe.skipIf(README_PATH === null)('spec-corpus: public docs avoid private implementation breadcrumbs', () => {
1145
+ const repoRoot = dirname(README_PATH as string);
1146
+ const publicTextFiles = [
1147
+ README_PATH as string,
1148
+ join(repoRoot, 'QUICKSTART.md'),
1149
+ join(repoRoot, 'QUICKSTART-10MIN.md'),
1150
+ join(repoRoot, 'sdk', 'typescript', 'README.md'),
1151
+ join(repoRoot, 'sdk', 'python', 'README.md'),
1152
+ join(repoRoot, 'sdk', 'go', 'README.md'),
1153
+ ...(CONFORMANCE_README_PATH ? [CONFORMANCE_README_PATH] : []),
1154
+ ...(FIXTURES_DOC_PATH ? [FIXTURES_DOC_PATH] : []),
1155
+ ...listTextFilesRecursive(join(repoRoot, 'examples'), new Set(['.md', 'package.json'])),
1156
+ ...readdirSync(join(repoRoot, 'SECURITY')).filter((f) => f.endsWith('.md') || f.endsWith('.yaml')).map((f) => join(repoRoot, 'SECURITY', f)),
1157
+ ...(V1_DIR !== null ? readdirSync(V1_DIR).filter((f) => f.endsWith('.md')).map((f) => join(V1_DIR as string, f)) : []),
1158
+ ...readdirSync(SCHEMAS_DIR).filter((f) => f.endsWith('.json')).map((f) => join(SCHEMAS_DIR, f)),
1159
+ ].filter((path) => existsSync(path));
1160
+
1161
+ const banned = [
1162
+ { label: 'private workflow-runtime paths', pattern: /services\/workflow-runtime/ },
1163
+ { label: 'private workflow-engine paths', pattern: /packages\/workflow-engine/ },
1164
+ { label: 'internal PRD references', pattern: /PRD §/ },
1165
+ { label: 'old openwop plan references', pattern: /openwop plan/i },
1166
+ { label: 'pre-v1 release markers', pattern: /\bv0\.(?:1|2|3)\b/i },
1167
+ { label: 'scaffold release wording', pattern: /\bscaffold\b/i },
1168
+ { label: 'incorrect OpenWOP article', pattern: /\b(?:A|a) OpenWOP\b/ },
1169
+ { label: 'lowercase compliance adjective', pattern: /\bopenwop-(?:compliant|conforming)\b/ },
1170
+ { label: 'lowercase OpenWOP phrase', pattern: /\bopenwop (?:host|node|workflow|runs|gives)\b/ },
1171
+ { label: 'private workflow-engine examples', pattern: /@your-org\/workflow-engine|workflow-engine implementation/ },
1172
+ { label: 'deployment-specific Cloud Run advice', pattern: /Cloud-Run-first|Cloud Run/ },
1173
+ { label: 'reference implementation breadcrumbs', pattern: /Reference impl:/ },
1174
+ { label: 'bootstrap governance breadcrumbs', pattern: /bootstrap-phase|lead-maintainer fiat|single-maintainer/i },
1175
+ { label: 'old gap-planning references', pattern: /openwop plan|G(?:10|12|22|23)|WOP-era|prior WOP/i },
1176
+ { label: 'private implementation source paths', pattern: /functions\/src|src\/core\/workflow/ },
1177
+ { label: 'reference implementation source breadcrumbs', pattern: /Reference implementation:/ },
1178
+ ];
1179
+
1180
+ it('scans public docs and schemas', () => {
1181
+ expect(publicTextFiles.length, 'public docs/schemas list MUST be non-empty').toBeGreaterThan(0);
1182
+ });
1183
+
1184
+ for (const file of publicTextFiles) {
1185
+ const relFile = relative(repoRoot, file);
1186
+ it(`${relFile} has no private implementation or pre-v1 breadcrumbs`, () => {
1187
+ const text = readFileSync(file, 'utf8');
1188
+ for (const { label, pattern } of banned) {
1189
+ expect(text, `${relFile} MUST NOT contain ${label}`).not.toMatch(pattern);
1190
+ }
1191
+ });
1192
+ }
1193
+ });
1194
+
1195
+ describe.skipIf(FIXTURES_DOC_PATH === null)('spec-corpus: fixtures.json catalog matches fixtures.md', () => {
1196
+ // FIXTURES_DOC_PATH is non-null here — assertion narrows for TS.
1197
+ const fixturesDocPath = FIXTURES_DOC_PATH as string;
1198
+ const PACK_MANIFEST_FIXTURES_DIR = join(FIXTURES_DIR, 'pack-manifests');
1199
+ // Top-level workflow fixtures + pack-manifest fixtures from the
1200
+ // sub-directory. Both are documented in fixtures.md so the regex scan
1201
+ // below MUST cover both.
1202
+ const fixtureJsonFiles = [
1203
+ ...readdirSync(FIXTURES_DIR)
1204
+ .filter((f) => f.endsWith('.json'))
1205
+ .map((f) => f.replace(/\.json$/, '')),
1206
+ ...readdirSync(PACK_MANIFEST_FIXTURES_DIR)
1207
+ .filter((f) => f.endsWith('.json'))
1208
+ .map((f) => f.replace(/\.json$/, '')),
1209
+ ].sort();
1210
+
1211
+ it('every fixture id mentioned in fixtures.md has a corresponding JSON', () => {
1212
+ const doc = readFileSync(fixturesDocPath, 'utf8');
1213
+ // Match `conformance-<word>` identifiers in the catalog table or
1214
+ // per-fixture sections. Use word-boundary so "conformance-noop"
1215
+ // captures cleanly without bleeding into adjacent text.
1216
+ //
1217
+ // PROPOSED-section IDs are intentionally documented without backing
1218
+ // JSONs (the fixture is blocked on a future spec/impl change). Two
1219
+ // markers indicate a section is documenting a future fixture:
1220
+ // 1. "(PROPOSED v..." in the heading — design proposal
1221
+ // 2. "impl pending" in the heading — spec firm, runtime not yet
1222
+ // shipped (e.g., F4's cap-breach fixture awaiting CC-1 counter)
1223
+ // We strip §sections matching either marker before scanning.
1224
+ // The catalog table also contains rows for PROPOSED / impl-pending
1225
+ // fixtures; strip those too.
1226
+ let docWithoutProposed = doc.replace(
1227
+ /^##\s+[^\n]*\((PROPOSED\s+v[^\n)]+|[^)]*impl pending)\)[\s\S]*?(?=^##\s+|^---\s*$)/gm,
1228
+ '',
1229
+ );
1230
+ docWithoutProposed = docWithoutProposed.replace(
1231
+ /^\|[^\n]*(PROPOSED|impl pending)[^\n]*\n/gm,
1232
+ '',
1233
+ );
1234
+ const idRegex = /\bconformance-[a-z][a-z0-9-]*\b/g;
1235
+ const cited = new Set<string>();
1236
+ let m: RegExpExecArray | null;
1237
+ while ((m = idRegex.exec(docWithoutProposed)) !== null) {
1238
+ cited.add(m[0]);
1239
+ }
1240
+ for (const cite of cited) {
1241
+ expect(
1242
+ fixtureJsonFiles,
1243
+ `fixtures.md cites fixture id "${cite}" but no matching ${cite}.json exists`,
1244
+ ).toContain(cite);
1245
+ }
1246
+ });
1247
+
1248
+ it('every fixture JSON file is referenced by fixtures.md', () => {
1249
+ const doc = readFileSync(fixturesDocPath, 'utf8');
1250
+ for (const id of fixtureJsonFiles) {
1251
+ expect(
1252
+ doc,
1253
+ `fixture ${id}.json exists but fixtures.md does not document it`,
1254
+ ).toContain(id);
1255
+ }
1256
+ });
1257
+ });