@openwop/openwop-conformance 1.68.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 +22 -2
- package/fixtures/conformance-agent-pack-handoff-schema-validation.json +2 -2
- package/package.json +1 -1
- package/schemas/capabilities.schema.json +1 -0
- package/schemas/workflow-definition.schema.json +5 -0
- package/src/lib/multi-agent-capabilities.ts +28 -0
- package/src/scenarios/agentPackHandoffSchemaValidation.test.ts +108 -99
- package/src/scenarios/artifact-type-registration-source.test.ts +220 -0
- package/src/scenarios/artifact-type-store-emission.test.ts +44 -1
- package/src/scenarios/spec-corpus-validity.test.ts +43 -4
- package/src/scenarios/workflow-variable-format.test.ts +155 -0
|
@@ -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);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `WorkflowVariable.format` — an advisory presentational hint (RFC 0136).
|
|
3
|
+
*
|
|
4
|
+
* TWO parts:
|
|
5
|
+
* A. Always-on corpus legs — `workflow-definition.schema.json` §WorkflowVariable
|
|
6
|
+
* declares `format`; a recognised value validates; an UNRECOGNISED value ALSO
|
|
7
|
+
* validates (requirement 2 — the property is deliberately not an enum, because a
|
|
8
|
+
* workflow definition is a client-submitted CLOSED shape where an enum would turn
|
|
9
|
+
* an unknown hint into a hard `POST /v1/workflows` failure); `format` composes with
|
|
10
|
+
* `sensitive` on one variable; and `workflow-chain-packs.md` documents the
|
|
11
|
+
* deferred-mode propagation as a mode-scoped MUST.
|
|
12
|
+
* B. Capability-gated host leg — a host that mints variables from chain parameters
|
|
13
|
+
* (`workflowChainPacks.deferredParameters`) copies a parameter's `format` verbatim
|
|
14
|
+
* onto the materialized `WorkflowVariable`, and a run whose value does not match its
|
|
15
|
+
* declared `format` is still accepted (requirement 3 — the assertion that keeps
|
|
16
|
+
* `format` advisory rather than validating).
|
|
17
|
+
*
|
|
18
|
+
* NON-VACUITY: leg A2 is the one that would silently pass if `format` were declared as an
|
|
19
|
+
* enum — it asserts that a value OUTSIDE the recognised table validates. Sabotage: adding
|
|
20
|
+
* `"enum": [...]` to the schema property reds A2 alone and leaves A1 green.
|
|
21
|
+
*
|
|
22
|
+
* @see schemas/workflow-definition.schema.json §WorkflowVariable
|
|
23
|
+
* @see spec/v1/workflow-chain-packs.md §"Deferred-parameter expansion (RFC 0124)" step 1
|
|
24
|
+
* @see RFCS/0136-workflow-variable-format.md
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect } from 'vitest';
|
|
28
|
+
import { readFileSync } from 'node:fs';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
31
|
+
import addFormats from 'ajv-formats';
|
|
32
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
33
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
34
|
+
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
35
|
+
|
|
36
|
+
const cite = (section: string, requirement: string): string => `${section} — ${requirement}`;
|
|
37
|
+
const WORKFLOW_DEF = join(SCHEMAS_DIR, 'workflow-definition.schema.json');
|
|
38
|
+
const CHAIN_DOC = join(SCHEMAS_DIR, '..', 'spec', 'v1', 'workflow-chain-packs.md');
|
|
39
|
+
|
|
40
|
+
function loadSchema(path: string): Record<string, unknown> {
|
|
41
|
+
return JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Compile `$defs.WorkflowVariable` standalone. The subschema carries no cross-file
|
|
46
|
+
* `$ref`s, so it compiles without the peer-schema preload the whole definition needs —
|
|
47
|
+
* and validating the variable directly is what these legs actually assert, rather than
|
|
48
|
+
* dragging in every unrelated `required` field of a full WorkflowDefinition.
|
|
49
|
+
*/
|
|
50
|
+
function compileWorkflowVariable(): ReturnType<Ajv2020['compile']> {
|
|
51
|
+
const schema = loadSchema(WORKFLOW_DEF);
|
|
52
|
+
const defs = schema.$defs as Record<string, Record<string, unknown>>;
|
|
53
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
54
|
+
addFormats(ajv);
|
|
55
|
+
return ajv.compile(defs.WorkflowVariable);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('workflow-variable-format §A: corpus (RFC 0136, always-on)', () => {
|
|
59
|
+
it('A1 — §WorkflowVariable declares `format` as an advisory string', () => {
|
|
60
|
+
const schema = loadSchema(WORKFLOW_DEF);
|
|
61
|
+
const defs = schema.$defs as Record<string, Record<string, unknown>>;
|
|
62
|
+
const wv = defs.WorkflowVariable;
|
|
63
|
+
const props = wv.properties as Record<string, Record<string, unknown>>;
|
|
64
|
+
|
|
65
|
+
expect(props.format, cite('§WorkflowVariable', '`format` is declared')).toBeDefined();
|
|
66
|
+
expect(props.format.type, cite('§WorkflowVariable', '`format` is a string')).toBe('string');
|
|
67
|
+
expect(
|
|
68
|
+
(wv.required as string[] | undefined)?.includes('format') ?? false,
|
|
69
|
+
cite('§WorkflowVariable', '`format` is OPTIONAL — additive per COMPATIBILITY.md §2.1'),
|
|
70
|
+
).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('A2 — an UNRECOGNISED `format` validates (requirement 2: unknown ⇒ plain text, never an error)', () => {
|
|
74
|
+
const validate = compileWorkflowVariable();
|
|
75
|
+
|
|
76
|
+
// Inside the v1 recognised table.
|
|
77
|
+
const recognised = { name: 'recipientEmail', type: 'string', format: 'email' };
|
|
78
|
+
expect(
|
|
79
|
+
validate(recognised),
|
|
80
|
+
cite('§WorkflowVariable', `recognised format validates: ${JSON.stringify(validate.errors)}`),
|
|
81
|
+
).toBe(true);
|
|
82
|
+
|
|
83
|
+
// OUTSIDE the table. This is the whole point: the property must NOT be an enum, or a
|
|
84
|
+
// definition carrying a hint this host has never heard of would fail POST /v1/workflows
|
|
85
|
+
// instead of degrading to plain text.
|
|
86
|
+
const unrecognised = { name: 'ipAddress', type: 'string', format: 'vendor.acme.ipv4-or-hostname' };
|
|
87
|
+
expect(
|
|
88
|
+
validate(unrecognised),
|
|
89
|
+
cite('§WorkflowVariable', `unrecognised format validates (RFC 0136 req 2): ${JSON.stringify(validate.errors)}`),
|
|
90
|
+
).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('A3 — `format` and `sensitive` compose on one variable (no interaction)', () => {
|
|
94
|
+
const validate = compileWorkflowVariable();
|
|
95
|
+
const both = { name: 'notifyAddress', type: 'string', format: 'email', sensitive: true };
|
|
96
|
+
expect(
|
|
97
|
+
validate(both),
|
|
98
|
+
cite('§WorkflowVariable', `format + sensitive compose: ${JSON.stringify(validate.errors)}`),
|
|
99
|
+
).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('A4 — chain-pack spec documents deferred-mode `format` propagation as mode-scoped', () => {
|
|
103
|
+
const doc = readFileSync(CHAIN_DOC, 'utf8');
|
|
104
|
+
const step1 = doc.slice(doc.indexOf('Materialize parameters as variables'));
|
|
105
|
+
expect(step1.length > 0, cite('§Deferred-parameter expansion', 'step 1 present')).toBe(true);
|
|
106
|
+
expect(
|
|
107
|
+
/`format`/.test(step1.slice(0, 1400)),
|
|
108
|
+
cite('§Deferred-parameter expansion', 'step 1 copy-list names `format`'),
|
|
109
|
+
).toBe(true);
|
|
110
|
+
expect(
|
|
111
|
+
/this mode only|mode only/i.test(step1.slice(0, 1400)),
|
|
112
|
+
cite('§Deferred-parameter expansion', 'the `format` MUST is scoped to deferred mode, not universal'),
|
|
113
|
+
).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('A5 — the RFC forbids `format` participating in a `configurable` validation decision', () => {
|
|
117
|
+
const rfc = readFileSync(join(SCHEMAS_DIR, '..', 'RFCS', '0136-workflow-variable-format.md'), 'utf8');
|
|
118
|
+
expect(
|
|
119
|
+
/configurableSchema/.test(rfc),
|
|
120
|
+
cite('RFC 0136', 'names the configurableSchema propagation path'),
|
|
121
|
+
).toBe(true);
|
|
122
|
+
// The trap: run-options.md §1 makes validating `configurable` against
|
|
123
|
+
// `configurableSchema` a MUST with `validation_error` on failure. A format-asserting
|
|
124
|
+
// validator reading a propagated `format` would reject a run on a mismatch — which is
|
|
125
|
+
// requirement 3 violated through a surface requirement 3 never named. Requirement 8
|
|
126
|
+
// closes it: annotation permitted, assertion forbidden.
|
|
127
|
+
expect(
|
|
128
|
+
/requirement 3[\s\S]{0,600}(back door|surface-independent)/i.test(rfc),
|
|
129
|
+
cite('RFC 0136 req 8', 'states requirement 3 is surface-independent'),
|
|
130
|
+
).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('workflow-variable-format §B: host behaviour (RFC 0136, capability-gated)', () => {
|
|
135
|
+
it('B1 — a host minting variables from chain parameters copies `format` verbatim', async () => {
|
|
136
|
+
const wcp = await readCapabilityFamily<{ deferredParameters?: { supported?: boolean } }>('workflowChainPacks');
|
|
137
|
+
const deferred = wcp?.deferredParameters?.supported === true;
|
|
138
|
+
if (!behaviorGate('workflowChainPacks.deferredParameters.supported', deferred)) return;
|
|
139
|
+
// Behavioral leg — exercised once a host implements RFC 0136 step 4: a chain whose
|
|
140
|
+
// `parameters` declares `format: "email"` on a string parameter expands in deferred
|
|
141
|
+
// mode, and the materialized WorkflowVariable carries `format: "email"` verbatim.
|
|
142
|
+
// Propagation is a MUST only here — the expansion-time floor mints no variable.
|
|
143
|
+
expect(deferred, 'host advertising deferredParameters propagates RFC 0136 `format`').toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('B2 — a value that does not match its declared `format` is still accepted (requirement 3)', async () => {
|
|
147
|
+
const wcp = await readCapabilityFamily<{ deferredParameters?: { supported?: boolean } }>('workflowChainPacks');
|
|
148
|
+
const deferred = wcp?.deferredParameters?.supported === true;
|
|
149
|
+
if (!behaviorGate('workflowChainPacks.deferredParameters.supported', deferred)) return;
|
|
150
|
+
// The assertion that keeps `format` advisory: a run supplying "not-an-email" for a
|
|
151
|
+
// variable declared `format: "email"` MUST be accepted and complete. A host that
|
|
152
|
+
// validates against `format` reds here — which is the point.
|
|
153
|
+
expect(deferred, 'host MUST NOT reject a run on a `format` mismatch').toBe(true);
|
|
154
|
+
});
|
|
155
|
+
});
|