@openwop/openwop-conformance 1.58.0 → 1.64.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.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Pack-manifest vendor extensions — RFC 0138.
3
+ *
4
+ * `host-extensions.md` §"Vendor-prefixed namespaces" carries a normative MUST:
5
+ * *"A client receiving an unknown vendor-prefixed field MUST treat it as
6
+ * opaque."* Every pack manifest, however, set `additionalProperties: false`
7
+ * with no pattern escape — so a vendor-prefixed field could not legally exist
8
+ * on a pack manifest at all. **The corpus mandated a behavior for a case it
9
+ * structurally forbade**, and a host with a working extension had to choose
10
+ * between shipping it and publishing a conformant pack. RFC 0138 adds an
11
+ * `^(x-|vendor\.)` escape hatch on each manifest root and each kind's per-item
12
+ * entry object.
13
+ *
14
+ * Always-on + server-free. Three parts:
15
+ *
16
+ * PART 1 — every pack manifest admits the hatch, INCLUDING the registry
17
+ * publication contract. Without the hatch on `registry-version-manifest`, a
18
+ * pack carrying a root-level extension validates against its source manifest
19
+ * and is then rejected at registry `PUT` — the same split-brain, one layer
20
+ * down. Enumerated by an explicit in-scope count, not a naming glob, so a new
21
+ * pack kind that forgets the hatch fails rather than being skipped.
22
+ *
23
+ * PART 2 — the hatch is NARROW. A misspelled canonical field
24
+ * (`dispalyName`) is still rejected. This is the regression guard that keeps
25
+ * `additionalProperties: false` doing its real job: the hatch admits
26
+ * DECLARED extensions, not arbitrary keys. A change that widened the pattern
27
+ * to `^.*` would pass PART 1 and fail here.
28
+ *
29
+ * PART 3 — the corpus states the opacity + trust rules normatively. The
30
+ * schema alone cannot express "MUST ignore"; without the prose the hatch is
31
+ * just a hole. Guards the `pack-manifest-extension-opaque` invariant text.
32
+ *
33
+ * @see spec/v1/node-packs.md §"Vendor extensions on pack manifests"
34
+ * @see spec/v1/host-extensions.md §"Vendor-prefixed namespaces"
35
+ * @see RFCS/0138-pack-manifest-vendor-extensions.md
36
+ * @see SECURITY/invariants.yaml `pack-manifest-extension-opaque`
37
+ */
38
+
39
+ import { describe, it, expect } from 'vitest';
40
+ import { readdirSync, readFileSync } from 'node:fs';
41
+ import { join } from 'node:path';
42
+ import Ajv2020 from 'ajv/dist/2020.js';
43
+ import addFormats from 'ajv-formats';
44
+ import { SCHEMAS_DIR, V1_DIR } from '../lib/paths.js';
45
+
46
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
47
+
48
+ /** The canonical hatch pattern. Kept as a literal so a drift in any schema is caught. */
49
+ const HATCH = '^(x-|vendor\\.)';
50
+
51
+ /**
52
+ * Manifest schemas RFC 0138 deliberately does NOT cover. Anything else matching
53
+ * `*manifest*.schema.json` MUST carry the hatch — so a new pack kind is caught
54
+ * rather than silently omitted.
55
+ *
56
+ * The first draft of this file globbed `*-pack-manifest.schema.json`, which
57
+ * silently skipped `frontend-plugin-manifest.schema.json` (RFC 0117,
58
+ * `kind: "frontend-plugin"`) purely because it does not carry `-pack-` in its
59
+ * name. Enumerating by naming convention is how a coverage hole hides; the
60
+ * exclusions below are a stated list, not an accident of a glob.
61
+ */
62
+ const OUT_OF_SCOPE: Record<string, string> = {
63
+ // Not pack-manifest structure — their own contracts with their own
64
+ // compatibility surface. Stated in node-packs.md §Vendor extensions.
65
+ 'agent-manifest.schema.json': "a node pack's agents[] entries — separate contract",
66
+ };
67
+
68
+ const manifestFiles = readdirSync(SCHEMAS_DIR)
69
+ .filter((f) => f.includes('manifest') && f.endsWith('.schema.json'))
70
+ .filter((f) => !(f in OUT_OF_SCOPE))
71
+ .sort();
72
+
73
+ const load = (f: string): Record<string, unknown> =>
74
+ JSON.parse(readFileSync(join(SCHEMAS_DIR, f), 'utf8'));
75
+
76
+ /** Walks a schema and returns every object node that declares `additionalProperties: false`. */
77
+ function closedObjects(root: unknown): Array<Record<string, unknown>> {
78
+ const out: Array<Record<string, unknown>> = [];
79
+ (function walk(node: unknown): void {
80
+ if (node === null || typeof node !== 'object') return;
81
+ const o = node as Record<string, unknown>;
82
+ if (o['additionalProperties'] === false) out.push(o);
83
+ for (const k of Object.keys(o)) walk(o[k]);
84
+ })(root);
85
+ return out;
86
+ }
87
+
88
+ describe('pack-manifest-extensions: every manifest admits the hatch (RFC 0138, server-free)', () => {
89
+ it('every pack kind is covered — the enumeration is not a naming-convention glob', () => {
90
+ // 8 source manifests (node, workflow-chain, prompt, artifact-type, card,
91
+ // connection, form-content, frontend-plugin) + the registry publication contract.
92
+ expect(
93
+ manifestFiles.length,
94
+ why('RFC 0138', `expected 9 in-scope manifests, found ${manifestFiles.length}: ${manifestFiles.join(', ')}. A new pack kind MUST be added here, or listed in OUT_OF_SCOPE with a reason.`),
95
+ ).toBe(9);
96
+ });
97
+
98
+ for (const f of manifestFiles) {
99
+ it(`${f.replace(/-?(pack-)?manifest\.schema\.json$/, '')} — manifest ROOT carries the extension hatch`, () => {
100
+ const s = load(f);
101
+ const pp = s['patternProperties'] as Record<string, unknown> | undefined;
102
+ expect(
103
+ pp !== undefined && Object.keys(pp).includes(HATCH),
104
+ why('node-packs.md §Vendor extensions on pack manifests', `${f} root MUST admit ^(x-|vendor\\.) so the host-extensions.md opacity MUST is satisfiable`),
105
+ ).toBe(true);
106
+ });
107
+ }
108
+ });
109
+
110
+ describe('pack-manifest-extensions: the hatch is NARROW — typos still rejected (RFC 0138)', () => {
111
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
112
+ addFormats(ajv);
113
+
114
+ // artifact-type is the motivating kind (a real host carried `x-openwop-app.canvas`
115
+ // on an artifactTypes[] entry and could not publish the pack).
116
+ const validate = ajv.compile(load('artifact-type-pack-manifest.schema.json'));
117
+ const pack = (extra: Record<string, unknown>): Record<string, unknown> => ({
118
+ name: 'community.openwop.canvas-checklist',
119
+ version: '1.0.0',
120
+ kind: 'artifact-type',
121
+ engines: { openwop: '>=1.1.0 <2.0.0' },
122
+ artifactTypes: [
123
+ { artifactTypeId: 'community.openwop.doc.checklist', schemaRef: 'schemas/checklist.json', ...extra },
124
+ ],
125
+ });
126
+
127
+ it('an `x-` extension on an entry VALIDATES (the motivating case)', () => {
128
+ expect(
129
+ validate(pack({ 'x-openwop-app.canvas': { components: ['checklist'] } })),
130
+ why('node-packs.md §Vendor extensions on pack manifests', 'a host extension MUST NOT make the pack unpublishable'),
131
+ ).toBe(true);
132
+ });
133
+
134
+ it('a `vendor.` extension on an entry VALIDATES', () => {
135
+ expect(
136
+ validate(pack({ 'vendor.acme.rating': 5 })),
137
+ why('host-extensions.md §Vendor-prefixed namespaces', 'vendor-prefixed fields are legitimate'),
138
+ ).toBe(true);
139
+ });
140
+
141
+ it('a MISSPELLED canonical field is STILL REJECTED', () => {
142
+ expect(
143
+ validate(pack({ dispalyName: 'Checklist' })),
144
+ why(
145
+ 'node-packs.md §Vendor extensions on pack manifests',
146
+ 'the hatch admits DECLARED extensions, not arbitrary keys — additionalProperties:false still catches typos. A pattern widened to ^.* would pass the other legs and fail here.',
147
+ ),
148
+ ).toBe(false);
149
+ });
150
+
151
+ it('an unextended pack still validates (RFC 0138 is additive)', () => {
152
+ expect(validate(pack({})), why('COMPATIBILITY.md §2.1', 'existing manifests validate unchanged')).toBe(true);
153
+ });
154
+
155
+ it('the hatch pattern is not accidentally permissive', () => {
156
+ for (const f of manifestFiles) {
157
+ for (const node of closedObjects(load(f))) {
158
+ for (const pat of Object.keys((node['patternProperties'] as Record<string, unknown>) ?? {})) {
159
+ const re = new RegExp(pat);
160
+ expect(re.test('dispalyName'), why('RFC 0138', `${f}: pattern ${pat} MUST NOT match a bare canonical-looking key`)).toBe(false);
161
+ expect(re.test('x-anything'), why('RFC 0138', `${f}: pattern ${pat} admits x- extensions`)).toBe(true);
162
+ }
163
+ }
164
+ }
165
+ });
166
+ });
167
+
168
+ describe('pack-manifest-extensions: the corpus states opacity + trust normatively (RFC 0138)', () => {
169
+ const packsDoc = V1_DIR ? readFileSync(join(V1_DIR, 'node-packs.md'), 'utf8') : '';
170
+ const extDoc = V1_DIR ? readFileSync(join(V1_DIR, 'host-extensions.md'), 'utf8') : '';
171
+
172
+ it.skipIf(V1_DIR === null)('an unrecognized extension MUST be ignored, not rejected', () => {
173
+ expect(
174
+ /MUST ignore it\W{0,4}\s*and\W{0,4}\s*MUST NOT\W{0,4}\s*reject the pack/i.test(packsDoc),
175
+ why('node-packs.md §Vendor extensions on pack manifests', 'unrecognized extensions MUST be ignored, never a rejection reason'),
176
+ ).toBe(true);
177
+ });
178
+
179
+ it.skipIf(V1_DIR === null)('"ignore" is defined — not render, execute, interpret, or persist-for-later', () => {
180
+ expect(
181
+ /MUST NOT render it, execute it, interpret it as markup/i.test(packsDoc),
182
+ why('node-packs.md §Vendor extensions on pack manifests', '"ignore" means ignore — the hatch MUST NOT become a rendering or execution channel'),
183
+ ).toBe(true);
184
+ expect(
185
+ /pack-authored content/i.test(packsDoc) && /untrusted/i.test(packsDoc),
186
+ why('node-packs.md §Vendor extensions on pack manifests', 'an extension value is pack-authored, therefore untrusted'),
187
+ ).toBe(true);
188
+ });
189
+
190
+ it.skipIf(V1_DIR === null)('extensions are NOT a capability-negotiation channel', () => {
191
+ expect(
192
+ /NOT\W{0,4}\s*a versioning or capability-negotiation channel/i.test(packsDoc),
193
+ why('node-packs.md §Vendor extensions on pack manifests', 'a host MUST NOT infer support from an extension property'),
194
+ ).toBe(true);
195
+ });
196
+
197
+ it.skipIf(V1_DIR === null)('host-extensions.md still carries the opacity MUST the hatch exists to satisfy', () => {
198
+ expect(
199
+ /unknown vendor-prefixed field MUST treat it as opaque/i.test(extDoc),
200
+ why('host-extensions.md §Vendor-prefixed namespaces', 'the MUST that motivated RFC 0138 is still present'),
201
+ ).toBe(true);
202
+ });
203
+ });