@openwop/openwop-conformance 1.18.1 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * openwop-conformance --offline # server-free subset only
12
12
  * openwop-conformance --filter discovery # category filter
13
13
  * openwop-conformance --base-url ... --api-key ... --filter "interrupt|cancellation"
14
+ * openwop-conformance --base-url ... --api-key ... --certify out.json # RFC 0089 bundle
14
15
  *
15
16
  * Environment variables override flags (per the conformance harness's
16
17
  * existing convention):
@@ -24,7 +25,14 @@
24
25
  */
25
26
  import { spawnSync } from 'node:child_process';
26
27
  import { fileURLToPath } from 'node:url';
27
- import { dirname, resolve as resolvePath } from 'node:path';
28
+ import { dirname, resolve as resolvePath, join } from 'node:path';
29
+ import { createHash } from 'node:crypto';
30
+ import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
31
+ import { tmpdir } from 'node:os';
32
+ import Ajv2020 from 'ajv/dist/2020.js';
33
+ import addFormats from 'ajv-formats';
34
+ import { SCHEMAS_DIR } from './lib/paths.js';
35
+ import { deriveProfiles, isCoreStandard, agentPlatformStatus, } from './lib/profiles.js';
28
36
  function parseArgs(argv) {
29
37
  let baseUrl;
30
38
  let apiKey;
@@ -33,6 +41,7 @@ function parseArgs(argv) {
33
41
  let help = false;
34
42
  let impl;
35
43
  let implVersion;
44
+ let certify;
36
45
  for (let i = 0; i < argv.length; i++) {
37
46
  const arg = argv[i] ?? '';
38
47
  if (arg === '-h' || arg === '--help') {
@@ -74,13 +83,16 @@ function parseArgs(argv) {
74
83
  case '--implementation-version':
75
84
  implVersion = nextValue();
76
85
  break;
86
+ case '--certify':
87
+ certify = nextValue();
88
+ break;
77
89
  default:
78
90
  if (arg.startsWith('-')) {
79
91
  // Unknown flag — pass through to vitest by ignoring here.
80
92
  }
81
93
  }
82
94
  }
83
- return { baseUrl, apiKey, offline, filter, help, impl, implVersion };
95
+ return { baseUrl, apiKey, offline, filter, help, impl, implVersion, certify };
84
96
  }
85
97
  const HELP_TEXT = `openwop-conformance — run the openwop conformance suite against a server
86
98
 
@@ -99,6 +111,14 @@ Implementation labels (cosmetic — surface in failure messages):
99
111
  --impl <name> Implementation name (env: OPENWOP_IMPLEMENTATION_NAME)
100
112
  --impl-version <version> Implementation version (env: OPENWOP_IMPLEMENTATION_VERSION)
101
113
 
114
+ Certification (RFC 0089):
115
+ --certify <out.json> Generate a machine-readable conformance certification
116
+ bundle: fetch /.well-known/openwop (captured verbatim +
117
+ SHA-256), derive claimedProfiles from it, run the suite
118
+ recording each scenario's terminal state, validate the
119
+ assembled bundle against the bundle schema, and write it
120
+ to <out.json>. Requires --base-url (and --api-key as usual).
121
+
102
122
  Other:
103
123
  --help, -h Show this message
104
124
 
@@ -106,8 +126,210 @@ Examples:
106
126
  openwop-conformance --offline
107
127
  openwop-conformance --base-url https://api.example.com --api-key hk_test_abc
108
128
  openwop-conformance --filter "discovery|errors"
129
+ openwop-conformance --base-url https://api.example.com --api-key hk_test_abc \\
130
+ --certify certification-bundle.json
109
131
  `;
110
- function main() {
132
+ /** This CLI package's own version — surfaced as `generator.version` + `suite.version`. */
133
+ function suiteVersion() {
134
+ const here = dirname(fileURLToPath(import.meta.url));
135
+ const pkgPath = resolvePath(here, '..', 'package.json');
136
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
137
+ return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
138
+ }
139
+ /**
140
+ * Deterministic canonical-JSON serialization (RFC 8785 spirit): object keys
141
+ * sorted lexicographically at every level, arrays preserved in order. Used to
142
+ * compute `discovery.sha256` so a verifier can re-derive the same digest from
143
+ * a live `/.well-known/openwop` fetch regardless of incidental key order.
144
+ */
145
+ function canonicalJSON(value) {
146
+ if (value === null || typeof value !== 'object')
147
+ return JSON.stringify(value);
148
+ if (Array.isArray(value))
149
+ return `[${value.map(canonicalJSON).join(',')}]`;
150
+ const obj = value;
151
+ const keys = Object.keys(obj).sort();
152
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJSON(obj[k])}`).join(',')}}`;
153
+ }
154
+ /**
155
+ * The full set of profiles a discovery document derives — the closed
156
+ * `deriveProfiles` catalog plus the two operational annexes
157
+ * (`openwop-core-standard`, `openwop-agent-platform`) when their discovery
158
+ * predicate holds. This is `claimedProfiles`: a generated bundle MUST NOT
159
+ * claim a profile its own discovery document does not derive (RFC 0089 §B(1)).
160
+ */
161
+ function claimedProfilesFor(doc) {
162
+ const profiles = [...deriveProfiles(doc)];
163
+ if (isCoreStandard(doc))
164
+ profiles.push('openwop-core-standard');
165
+ if (agentPlatformStatus(doc) !== 'none')
166
+ profiles.push('openwop-agent-platform');
167
+ return profiles;
168
+ }
169
+ /**
170
+ * Reduce a vitest JSON report into a per-scenario-file terminal state, keyed by
171
+ * the test-file basename (e.g. `discovery.test.ts`) to align with the basenames
172
+ * in `PROFILE_FLOOR_SCENARIOS`. A file is `passed` only if it ran AND had ≥1
173
+ * passing assertion AND zero failures (non-vacuous, per §C); a fully-skipped
174
+ * file is `skipped`; any failed assertion makes the file `failed`.
175
+ */
176
+ function scenarioStatesFromReport(report) {
177
+ const states = new Map();
178
+ for (const file of report.testResults ?? []) {
179
+ const name = file.name;
180
+ if (typeof name !== 'string')
181
+ continue;
182
+ const basename = name.split('/').pop() ?? name;
183
+ const assertions = file.assertionResults ?? [];
184
+ let passes = 0;
185
+ let failures = 0;
186
+ let nonSkipped = 0;
187
+ for (const a of assertions) {
188
+ if (a.status === 'passed') {
189
+ passes++;
190
+ nonSkipped++;
191
+ }
192
+ else if (a.status === 'failed') {
193
+ failures++;
194
+ nonSkipped++;
195
+ }
196
+ // `skipped` / `todo` / `pending` count toward neither pass nor fail.
197
+ }
198
+ let state;
199
+ if (failures > 0)
200
+ state = 'failed';
201
+ else if (passes > 0 && nonSkipped > 0)
202
+ state = 'passed';
203
+ else
204
+ state = 'skipped';
205
+ states.set(basename, state);
206
+ }
207
+ return states;
208
+ }
209
+ /** Generate + validate + write an RFC 0089 conformance certification bundle. */
210
+ async function runCertify(args, baseUrl, apiKey) {
211
+ const outPath = args.certify;
212
+ if (outPath === undefined)
213
+ process.exit(2);
214
+ // (a) Fetch /.well-known/openwop verbatim + its canonical-JSON SHA-256.
215
+ const discoveryUrl = `${baseUrl.replace(/\/$/, '')}/.well-known/openwop`;
216
+ let document;
217
+ try {
218
+ const resp = await fetch(discoveryUrl, { headers: { Accept: 'application/json' } });
219
+ if (!resp.ok) {
220
+ process.stderr.write(`openwop-conformance --certify: GET ${discoveryUrl} returned HTTP ${resp.status}.\n`);
221
+ process.exit(2);
222
+ }
223
+ document = (await resp.json());
224
+ }
225
+ catch (err) {
226
+ process.stderr.write(`openwop-conformance --certify: failed to fetch ${discoveryUrl}: ${String(err)}\n`);
227
+ process.exit(2);
228
+ }
229
+ const sha256 = createHash('sha256').update(canonicalJSON(document)).digest('hex');
230
+ // (b) Derive claimedProfiles from the captured document.
231
+ const claimedProfiles = claimedProfilesFor(document);
232
+ // (c) Run the suite, capturing per-scenario terminal state via the vitest
233
+ // JSON reporter. server-targeted scenarios live under src/scenarios/.
234
+ const here = dirname(fileURLToPath(import.meta.url));
235
+ const conformanceRoot = resolvePath(here, '..');
236
+ const reportDir = mkdtempSync(join(tmpdir(), 'owp-certify-'));
237
+ const reportFile = join(reportDir, 'vitest-report.json');
238
+ const env = { ...process.env };
239
+ env.OPENWOP_BASE_URL = baseUrl;
240
+ env.OPENWOP_API_KEY = apiKey;
241
+ if (args.impl)
242
+ env.OPENWOP_IMPLEMENTATION_NAME = args.impl;
243
+ if (args.implVersion)
244
+ env.OPENWOP_IMPLEMENTATION_VERSION = args.implVersion;
245
+ const vitestArgs = [
246
+ 'vitest',
247
+ 'run',
248
+ '--config',
249
+ resolvePath(conformanceRoot, 'vitest.config.ts'),
250
+ '--reporter=json',
251
+ `--outputFile=${reportFile}`,
252
+ ];
253
+ const runResult = spawnSync('npx', vitestArgs, { cwd: conformanceRoot, env, stdio: 'inherit' });
254
+ if (runResult.error) {
255
+ process.stderr.write(`openwop-conformance --certify: failed to spawn vitest: ${String(runResult.error)}\n`);
256
+ process.exit(2);
257
+ }
258
+ let report;
259
+ try {
260
+ report = JSON.parse(readFileSync(reportFile, 'utf8'));
261
+ }
262
+ catch (err) {
263
+ process.stderr.write(`openwop-conformance --certify: could not read vitest JSON report at ${reportFile}: ${String(err)}\n`);
264
+ process.exit(2);
265
+ }
266
+ finally {
267
+ rmSync(reportDir, { recursive: true, force: true });
268
+ }
269
+ const states = scenarioStatesFromReport(report);
270
+ const passed = [];
271
+ const failed = [];
272
+ const skipped = [];
273
+ for (const [basename, state] of [...states.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
274
+ if (state === 'passed')
275
+ passed.push(basename);
276
+ else if (state === 'failed')
277
+ failed.push(basename);
278
+ else
279
+ skipped.push(basename);
280
+ }
281
+ // (d) Assemble the bundle.
282
+ const version = suiteVersion();
283
+ const impl = document
284
+ .implementation;
285
+ const hostName = args.impl ?? (typeof impl?.name === 'string' ? impl.name : 'unknown-host');
286
+ const hostVersion = args.implVersion ?? (typeof impl?.version === 'string' ? impl.version : '0.0.0');
287
+ const host = {
288
+ name: hostName,
289
+ version: hostVersion,
290
+ };
291
+ if (typeof impl?.vendor === 'string')
292
+ host.vendor = impl.vendor;
293
+ const bundle = {
294
+ bundleVersion: '1',
295
+ generatedAt: new Date().toISOString(),
296
+ generator: { name: '@openwop/openwop-conformance --certify', version },
297
+ suite: { package: '@openwop/openwop-conformance', version },
298
+ host,
299
+ discovery: { url: discoveryUrl, sha256, document },
300
+ claimedProfiles,
301
+ results: {
302
+ totals: {
303
+ passed: passed.length,
304
+ failed: failed.length,
305
+ skipped: skipped.length,
306
+ total: passed.length + failed.length + skipped.length,
307
+ },
308
+ passed,
309
+ failed,
310
+ skipped,
311
+ },
312
+ };
313
+ // (e) Validate against the bundle schema BEFORE writing.
314
+ const schema = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'conformance-certification-bundle.schema.json'), 'utf8'));
315
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
316
+ addFormats(ajv);
317
+ const validate = ajv.compile(schema);
318
+ if (!validate(bundle)) {
319
+ process.stderr.write('openwop-conformance --certify: assembled bundle FAILED schema validation:\n' +
320
+ `${JSON.stringify(validate.errors, null, 2)}\n`);
321
+ process.exit(2);
322
+ }
323
+ writeFileSync(outPath, `${JSON.stringify(bundle, null, 2)}\n`);
324
+ process.stdout.write(`openwop-conformance --certify: wrote certification bundle to ${outPath}\n` +
325
+ ` host: ${host.name}@${host.version}\n` +
326
+ ` claimedProfiles: ${claimedProfiles.length > 0 ? claimedProfiles.join(', ') : '(none)'}\n` +
327
+ ` results: ${passed.length} passed / ${failed.length} failed / ${skipped.length} skipped\n`);
328
+ // Exit code mirrors the suite outcome: a failing run still produces a bundle
329
+ // (the failures are recorded), but the process exit reflects pass/fail.
330
+ process.exit(failed.length > 0 ? 1 : 0);
331
+ }
332
+ async function main() {
111
333
  const args = parseArgs(process.argv.slice(2));
112
334
  if (args.help) {
113
335
  process.stdout.write(HELP_TEXT);
@@ -124,6 +346,15 @@ function main() {
124
346
  env.OPENWOP_IMPLEMENTATION_NAME = args.impl;
125
347
  if (args.implVersion)
126
348
  env.OPENWOP_IMPLEMENTATION_VERSION = args.implVersion;
349
+ // RFC 0089 — certification-bundle generation requires a live host.
350
+ if (args.certify !== undefined) {
351
+ if (!env.OPENWOP_BASE_URL || !env.OPENWOP_API_KEY) {
352
+ process.stderr.write('openwop-conformance --certify: --base-url and --api-key are required.\n' +
353
+ 'Run `openwop-conformance --help` for usage.\n');
354
+ process.exit(2);
355
+ }
356
+ return runCertify(args, env.OPENWOP_BASE_URL, env.OPENWOP_API_KEY);
357
+ }
127
358
  if (!args.offline && (!env.OPENWOP_BASE_URL || !env.OPENWOP_API_KEY)) {
128
359
  process.stderr.write('openwop-conformance: --base-url and --api-key are required (or use --offline).\n' +
129
360
  'Run `openwop-conformance --help` for usage.\n');
@@ -158,4 +389,4 @@ function main() {
158
389
  }
159
390
  process.exit(result.status ?? 1);
160
391
  }
161
- main();
392
+ void main();
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Layout-aware path resolver for the offline subset.
3
+ *
4
+ * The same suite source runs in two layouts:
5
+ *
6
+ * 1. Repo checkout — `openwop/conformance/src/scenarios/X.test.ts`. Schemas,
7
+ * api/, and prose docs live one level above the conformance package
8
+ * at the repo root.
9
+ *
10
+ * 2. Published tarball — `node_modules/@openwop/openwop-conformance/src/...`.
11
+ * The `prepack` script vendors `api/` and `schemas/` INTO the package,
12
+ * so they resolve relative to the package root instead of a parent.
13
+ * Spec prose (`spec/v1/*.md`) is NOT bundled — those tests skip.
14
+ *
15
+ * Earlier offline scenarios computed `__dirname/../../..` to find
16
+ * the repo root. That works in a checkout but lands in `node_modules/@openwop/`
17
+ * after npx-style install, breaking `npx -y @openwop/openwop-conformance --offline`
18
+ * with `ENOENT: ... node_modules/@openwop/schemas/workflow-definition.schema.json`.
19
+ *
20
+ * This module centralises the resolution. Strategy:
21
+ *
22
+ * - If `OPENWOP_CONFORMANCE_ROOT` is set, treat its value as the layout root
23
+ * (the directory that contains `schemas/`, `api/`, and either
24
+ * `conformance/fixtures/` (repo) or `fixtures/` directly (vendored)).
25
+ * Used by integrators who put the suite in an unusual location.
26
+ *
27
+ * - Otherwise compute the package root from `import.meta.url` (= the
28
+ * directory containing `package.json`) and probe whether the schemas
29
+ * are vendored at the package root (published) or at the parent (repo).
30
+ *
31
+ * Exported paths are non-null for the materials always present in both
32
+ * layouts; the prose-doc and fixtures.md catalog dirs may resolve to
33
+ * `null` under the published layout, in which case the corresponding
34
+ * scenarios skip cleanly (see `spec-corpus-validity.test.ts`).
35
+ */
36
+ import { existsSync } from 'node:fs';
37
+ import { fileURLToPath } from 'node:url';
38
+ import { dirname, join, resolve as pathResolve } from 'node:path';
39
+ // `dirname(fileURLToPath(import.meta.url))` for an ESM module compiled or
40
+ // run from `src/lib/paths.ts` returns `<pkg>/src/lib/`. The conformance
41
+ // package root is therefore two directories above this file in BOTH the
42
+ // repo checkout and the published tarball — the source layout is
43
+ // identical between the two; only the parent of `<pkg>` differs.
44
+ const HERE = dirname(fileURLToPath(import.meta.url));
45
+ const PKG_ROOT = pathResolve(HERE, '..', '..');
46
+ function resolveFromRoot(root, layout) {
47
+ // Two on-disk shapes for the layout root:
48
+ // - Repo: <root>/schemas, <root>/api, <root>/conformance/fixtures,
49
+ // <root>/conformance/{fixtures.md,coverage.md}, <root>/spec/v1/*.md
50
+ // (Where `<root>` = the repo root, e.g. `openwop/`.)
51
+ // - Vendored / published: <root>/schemas, <root>/api, <root>/fixtures,
52
+ // <root>/fixtures.md (when bundled), no spec/v1.
53
+ // Probe by checking whether `schemas/` lives at the conformance pkg root
54
+ // (vendored) vs one level up (repo).
55
+ const schemasDir = join(root, 'schemas');
56
+ const apiDir = join(root, 'api');
57
+ const repoFixturesDir = join(root, 'conformance', 'fixtures');
58
+ const vendoredFixturesDir = join(root, 'fixtures');
59
+ const fixturesDir = existsSync(repoFixturesDir) ? repoFixturesDir : vendoredFixturesDir;
60
+ const repoScenariosDir = join(root, 'conformance', 'src', 'scenarios');
61
+ const vendoredScenariosDir = join(PKG_ROOT, 'src', 'scenarios');
62
+ const scenariosDir = existsSync(repoScenariosDir)
63
+ ? repoScenariosDir
64
+ : existsSync(vendoredScenariosDir)
65
+ ? vendoredScenariosDir
66
+ : null;
67
+ const repoConformanceReadme = join(root, 'conformance', 'README.md');
68
+ const vendoredConformanceReadme = join(PKG_ROOT, 'README.md');
69
+ const conformanceReadmePath = existsSync(repoConformanceReadme)
70
+ ? repoConformanceReadme
71
+ : existsSync(vendoredConformanceReadme)
72
+ ? vendoredConformanceReadme
73
+ : null;
74
+ const repoFixturesDoc = join(root, 'conformance', 'fixtures.md');
75
+ const vendoredFixturesDoc = join(root, 'fixtures.md');
76
+ const fixturesDocPath = existsSync(repoFixturesDoc)
77
+ ? repoFixturesDoc
78
+ : existsSync(vendoredFixturesDoc)
79
+ ? vendoredFixturesDoc
80
+ : null;
81
+ const repoCoverageDoc = join(root, 'conformance', 'coverage.md');
82
+ const vendoredCoverageDoc = join(root, 'coverage.md');
83
+ const coverageDocPath = existsSync(repoCoverageDoc)
84
+ ? repoCoverageDoc
85
+ : existsSync(vendoredCoverageDoc)
86
+ ? vendoredCoverageDoc
87
+ : null;
88
+ const v1Probe = join(root, 'spec', 'v1');
89
+ const v1Dir = existsSync(v1Probe) ? v1Probe : null;
90
+ const readmeProbe = join(root, 'README.md');
91
+ const readmePath = existsSync(readmeProbe) ? readmeProbe : null;
92
+ const typescriptRunHelpersProbe = join(root, 'sdk', 'typescript', 'src', 'run-helpers.ts');
93
+ const typescriptRunHelpersPath = existsSync(typescriptRunHelpersProbe) ? typescriptRunHelpersProbe : null;
94
+ const pythonTypesProbe = join(root, 'sdk', 'python', 'src', 'openwop_client', 'types.py');
95
+ const pythonTypesPath = existsSync(pythonTypesProbe) ? pythonTypesProbe : null;
96
+ const goTypesProbe = join(root, 'sdk', 'go', 'types.go');
97
+ const goTypesPath = existsSync(goTypesProbe) ? goTypesProbe : null;
98
+ return {
99
+ pkgRoot: PKG_ROOT,
100
+ schemasDir,
101
+ apiDir,
102
+ fixturesDir,
103
+ scenariosDir,
104
+ conformanceReadmePath,
105
+ fixturesDocPath,
106
+ coverageDocPath,
107
+ v1Dir,
108
+ readmePath,
109
+ typescriptRunHelpersPath,
110
+ pythonTypesPath,
111
+ goTypesPath,
112
+ layout,
113
+ };
114
+ }
115
+ function resolveLayout() {
116
+ const override = process.env.OPENWOP_CONFORMANCE_ROOT?.trim();
117
+ if (override && override.length > 0) {
118
+ return resolveFromRoot(pathResolve(override), 'env-override');
119
+ }
120
+ // Vendored / published-tarball layout: `prepack` copies `schemas/` +
121
+ // `api/` to the package root. Repo layout: schemas live one level
122
+ // above the conformance package.
123
+ //
124
+ // Edge case: a developer running `npm pack` locally without a
125
+ // postpack cleanup leaves schemas/ in BOTH places transiently. When
126
+ // both exist, prefer the parent (repo layout) so prose-doc tests
127
+ // continue to run — the parent is the canonical source.
128
+ const parent = pathResolve(PKG_ROOT, '..');
129
+ const parentHasSchemas = existsSync(join(parent, 'schemas'));
130
+ const pkgHasSchemas = existsSync(join(PKG_ROOT, 'schemas'));
131
+ if (parentHasSchemas) {
132
+ return resolveFromRoot(parent, 'repo');
133
+ }
134
+ if (pkgHasSchemas) {
135
+ return resolveFromRoot(PKG_ROOT, 'published');
136
+ }
137
+ // Neither — return the published-style resolution rooted at PKG_ROOT
138
+ // so error messages name a concrete directory rather than a
139
+ // computed-from-undefined path.
140
+ return resolveFromRoot(PKG_ROOT, 'published');
141
+ }
142
+ const _layout = resolveLayout();
143
+ export const PKG_ROOT_PATH = _layout.pkgRoot;
144
+ export const SCHEMAS_DIR = _layout.schemasDir;
145
+ export const API_DIR = _layout.apiDir;
146
+ export const FIXTURES_DIR = _layout.fixturesDir;
147
+ export const SCENARIOS_DIR = _layout.scenariosDir;
148
+ export const CONFORMANCE_README_PATH = _layout.conformanceReadmePath;
149
+ export const FIXTURES_DOC_PATH = _layout.fixturesDocPath;
150
+ export const COVERAGE_DOC_PATH = _layout.coverageDocPath;
151
+ export const V1_DIR = _layout.v1Dir;
152
+ export const README_PATH = _layout.readmePath;
153
+ export const TYPESCRIPT_RUN_HELPERS_PATH = _layout.typescriptRunHelpersPath;
154
+ export const PYTHON_TYPES_PATH = _layout.pythonTypesPath;
155
+ export const GO_TYPES_PATH = _layout.goTypesPath;
156
+ export const LAYOUT = _layout.layout;
157
+ /** Test-only — re-resolve in case env var or filesystem changed. */
158
+ export function __resolveLayoutForTests() {
159
+ return resolveLayout();
160
+ }