@openwop/openwop-conformance 1.70.2 → 1.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -186,6 +186,26 @@ Current source tree: 411 scenario files. Use [`coverage.md`](./coverage.md) for
186
186
 
187
187
  ---
188
188
 
189
+ ## Resolving the contract: depend on this package, don't hand-copy it
190
+
191
+ The published tarball vendors the canonical `schemas/` and `api/` directories. **A host validating its own discovery document, events, or manifests should read them from the installed `@openwop/openwop-conformance` package rather than copying files into its own tree.**
192
+
193
+ The reason is not convenience — it is that **a hand-copied schema goes stale silently, in both directions**. A real instance: a host validated its `/.well-known/openwop` document against a vendored `capabilities.schema.json` carrying 81 properties while the corpus had 88. Its check was green, and it had been validating against a contract that predated the very declaration it was checking. Nothing warned it, and nothing could have — a file copy has no version.
194
+
195
+ Depending on the package instead makes staleness a **lockfile fact**. The suite version *is* the contract version: it appears in `package.json`, in the lockfile, in `npm outdated`, and in whatever dependency bot the host runs. Falling behind stops being invisible and becomes a diff.
196
+
197
+ ```jsonc
198
+ // stale and silent — the copy has no version
199
+ import caps from './vendor/capabilities.schema.json';
200
+
201
+ // stale and VISIBLE — the version is in your lockfile
202
+ import caps from '@openwop/openwop-conformance/schemas/capabilities.schema.json';
203
+ ```
204
+
205
+ **What this does not fix.** A host that keeps hand-copying gets no warning, and the corpus cannot see a host's local files — so this narrows the failure rather than eliminating it. Tracked as RFC 0145 G2.
206
+
207
+ The presence of these files at stable paths in the tarball is enforced by `scripts/check-npm-pack-contents.sh`, so a packaging change cannot quietly withdraw the contract copy a host depends on.
208
+
189
209
  ## Repo layout
190
210
 
191
211
  ```text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "1.70.2",
3
+ "version": "1.71.0",
4
4
  "description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -13,10 +13,14 @@
13
13
  * discovery document, and a third value is not a hint to ignore but a host claiming a
14
14
  * provenance the protocol does not define. Leg A2 pins that asymmetry.
15
15
  *
16
- * NOT BUILT (carried as RFC 0145 G1): a leg asserting a host's advertised
17
- * `registrationSource` matches what it emits on `artifact.created` (requirement 3). It needs
18
- * a host that advertises the facet AND emits a matching artifact; against a host advertising
19
- * nothing it would be vacuously green, which is the failure mode this suite keeps finding.
16
+ * LEG B (RFC 0145 G1) closes the gap this file originally carried open. It asserts a host's
17
+ * advertised `registrationSource` matches what it emits on `artifact.created` (requirement 3).
18
+ * It was deliberately NOT built at first: against a corpus where no host advertised the facet
19
+ * it would have gone green by finding nothing. A host now advertises it, so the leg has
20
+ * something real to compare and can no longer pass vacuously.
21
+ *
22
+ * PROFILE = 'openwop-artifact-type-store' is shared with RFC 0142 ON PURPOSE — see the gating
23
+ * note on leg B for why this leg deliberately does NOT add its own advertise-and-skip gate.
20
24
  *
21
25
  * @see schemas/capabilities.schema.json §artifactTypes.types
22
26
  * @see spec/v1/artifact-type-packs.md §"Per-type facets" + §"Schema distribution"
@@ -29,6 +33,9 @@ import { join } from 'node:path';
29
33
  import Ajv2020 from 'ajv/dist/2020.js';
30
34
  import addFormats from 'ajv-formats';
31
35
  import { SCHEMAS_DIR, V1_DIR } from '../lib/paths.js';
36
+ import { driver } from '../lib/driver.js';
37
+ import { behaviorGatePresent } from '../lib/behavior-gate.js';
38
+ import { readArtifactTypesCap } from '../lib/artifactTypes.js';
32
39
 
33
40
  const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
34
41
  const CAPS = join(SCHEMAS_DIR, 'capabilities.schema.json');
@@ -131,3 +138,83 @@ describe('artifact-type-registration-source (RFC 0145, always-on)', () => {
131
138
  ).toBe(true);
132
139
  });
133
140
  });
141
+
142
+ const PROFILE = 'openwop-artifact-type-store';
143
+
144
+ /** True when this type emits at all — `store` at per-type scope, else the capability default. */
145
+ function emitsForType(cap: Record<string, unknown> | null, id: string): boolean {
146
+ if (!cap) return false;
147
+ const entry = (cap['types'] as Record<string, unknown> | undefined)?.[id];
148
+ if (entry && typeof entry === 'object' && 'store' in (entry as Record<string, unknown>)) {
149
+ return (entry as Record<string, unknown>)['store'] === true;
150
+ }
151
+ return cap['store'] === true;
152
+ }
153
+
154
+ /**
155
+ * First per-type id advertising `registrationSource` AND emitting, else null.
156
+ *
157
+ * BOTH conditions are required, and the second is the subtle one: `registrationSource` is
158
+ * meaningful on a type the host never emits for (a consumer still learns which schema-resolution
159
+ * regime applies), but requirement 3 is a statement about AGREEMENT BETWEEN TWO SURFACES, and a
160
+ * type with no emission has only one. Asserting against it would red a host that is telling the
161
+ * truth on every surface it actually has.
162
+ */
163
+ function comparableType(cap: Record<string, unknown> | null): { id: string; advertised: string } | null {
164
+ const types = cap?.['types'];
165
+ if (!types || typeof types !== 'object') return null;
166
+ for (const [id, t] of Object.entries(types as Record<string, unknown>)) {
167
+ if (!t || typeof t !== 'object') continue;
168
+ const advertised = (t as Record<string, unknown>)['registrationSource'];
169
+ if (typeof advertised !== 'string') continue;
170
+ if (!emitsForType(cap, id)) continue;
171
+ return { id, advertised };
172
+ }
173
+ return null;
174
+ }
175
+
176
+ describe('artifact-type-registration-source: the advert agrees with the event (RFC 0145 leg B, requirement 3)', () => {
177
+ it('the emitted registrationSource equals the advertised one for that type', async () => {
178
+ const cap = await readArtifactTypesCap();
179
+ const target = comparableType(cap);
180
+ // INAPPLICABLE, not gated. `registrationSource` is OPTIONAL (requirement 2) and strict mode
181
+ // must not coerce a host into advertising it — the same call RFC 0142 makes for `store`.
182
+ if (target === null) return;
183
+
184
+ const started = await driver.post('/v1/host/sample/artifacttypes/runproduce', {
185
+ artifactTypeId: target.id,
186
+ });
187
+ // DELIBERATELY NOT a behaviorGate on seam presence. Reaching here means the type advertises
188
+ // `store`, so `store: true` + no seam is ALREADY strict-red under RFC 0142 leg B — the
189
+ // scenario that owns that enforcement. Gating again here would double-report one defect,
190
+ // and gating on the seam for a 0145 advert would coerce hosts into wiring 0142's host-sample
191
+ // surface in order to advertise a facet that has nothing to do with it.
192
+ if (started.status === 404 || started.status === 405) return; // seam absent — 0142 reports it
193
+ expect(
194
+ started.status >= 200 && started.status < 300,
195
+ driver.describe('coverage.md §"Open seams"', 'runproduce starts a real run producing one artifact of the requested registered type'),
196
+ ).toBe(true);
197
+ const runId = (started.json as Record<string, unknown> | undefined)?.['runId'];
198
+ if (!behaviorGatePresent(PROFILE, typeof runId === 'string' ? runId : null)) return;
199
+
200
+ const events = await driver.get(`/v1/runs/${runId}/events/poll?timeout=5`);
201
+ expect(
202
+ events.status,
203
+ driver.describe('run-events surface', 'the run event log is readable over the standard poll endpoint'),
204
+ ).toBe(200);
205
+ const list = ((events.json as Record<string, unknown>)?.['events'] ?? []) as Array<Record<string, unknown>>;
206
+ const created = list.filter((e) => e['type'] === 'artifact.created');
207
+ // Emission itself is RFC 0142's MUST, reported by its own leg. Reaching here without an
208
+ // event means that leg is already red; don't restate its finding as a 0145 failure.
209
+ if (created.length === 0) return;
210
+
211
+ const payload = (created[0]?.['payload'] ?? created[0]?.['data'] ?? {}) as Record<string, unknown>;
212
+ expect(
213
+ payload['registrationSource'],
214
+ driver.describe(
215
+ 'RFC 0145 requirement 3',
216
+ `the host advertises registrationSource: "${target.advertised}" for ${target.id}, so that is the value it MUST emit — an advert of one provenance against an event carrying another (or carrying none, which asserts UNSPECIFIED provenance and therefore disagrees) is a false advertisement, not a permitted divergence`,
217
+ ),
218
+ ).toBe(target.advertised);
219
+ });
220
+ });
@@ -35,6 +35,7 @@
35
35
 
36
36
  import { describe, it, expect } from 'vitest';
37
37
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
38
+ import { spawnSync } from 'node:child_process';
38
39
  import { createHash } from 'node:crypto';
39
40
  import { dirname, join, relative, resolve as pathResolve } from 'node:path';
40
41
  import Ajv2020 from 'ajv/dist/2020.js';
@@ -386,7 +387,40 @@ function extractReadmeDocumentIndex(readme: string): string {
386
387
  return readme.slice(start, end);
387
388
  }
388
389
 
389
- function listMarkdownFilesRecursive(dir: string, repoRoot: string = dir): string[] {
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[] {
390
424
  const ignoredDirs = new Set([
391
425
  '.git',
392
426
  'node_modules',
@@ -423,11 +457,15 @@ function listMarkdownFilesRecursive(dir: string, repoRoot: string = dir): string
423
457
  const child = join(dir, entry.name);
424
458
  const repoRelChild = relative(repoRoot, child);
425
459
  if (prunedRepoRelative.has(repoRelChild)) continue;
426
- files.push(...listMarkdownFilesRecursive(child, repoRoot));
460
+ files.push(...listMarkdownFilesRecursive(child, repoRoot, tracked));
427
461
  continue;
428
462
  }
429
463
  if (entry.isFile() && entry.name.endsWith('.md')) {
430
- files.push(join(dir, entry.name));
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);
431
469
  }
432
470
  }
433
471
 
@@ -1244,7 +1282,8 @@ describe.skipIf(README_PATH === null)('spec-corpus: local Markdown links resolve
1244
1282
  // describe.skipIf skips test execution but still evaluates the body for registration; default
1245
1283
  // to '.' so dirname() never receives null in the published-tarball layout.
1246
1284
  const repoRoot = README_PATH === null ? '.' : dirname(README_PATH);
1247
- const markdownFiles = README_PATH === null ? [] : listMarkdownFilesRecursive(repoRoot);
1285
+ const markdownFiles =
1286
+ README_PATH === null ? [] : listMarkdownFilesRecursive(repoRoot, repoRoot, listTrackedMarkdown(repoRoot));
1248
1287
 
1249
1288
  it('finds Markdown files to check', () => {
1250
1289
  expect(markdownFiles.length, 'repo checkout should contain Markdown docs').toBeGreaterThan(0);