@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
|
@@ -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
|
-
*
|
|
17
|
-
* `registrationSource` matches what it emits on `artifact.created` (requirement 3).
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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);
|