@openwop/openwop-conformance 1.37.0 → 1.43.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,149 @@
1
+ /**
2
+ * Compact tool projection — `GET /v1/tools?view=compact` (RFC 0112) — behavioral.
3
+ *
4
+ * Capability-gated on `capabilities.toolCatalog.compactView === true` (root-first
5
+ * per RFC 0073). Soft-skips when unadvertised (default) / hard-fails under
6
+ * `OPENWOP_REQUIRE_BEHAVIOR=true`. The standard projection coverage lives in
7
+ * `tool-catalog-projection.test.ts`; this asserts the OPT-IN compact view per
8
+ * `spec/v1/tool-catalog.md` §compact (and the new
9
+ * `compact-tool-descriptor.schema.json`):
10
+ *
11
+ * 1. ENVELOPE (§compact) — `GET /v1/tools?view=compact` returns the
12
+ * `{ tools: CompactToolDescriptor[] }` envelope (NOT a bare array).
13
+ * 2. SCHEMA — every compact descriptor validates against
14
+ * `compact-tool-descriptor.schema.json` (closed field set; the heavy
15
+ * `ToolDescriptor` fields — `outputSchema`/`auth`/`egress`/`approval`/
16
+ * `replayPolicy`/`costHint`/`latencyHint` — are ABSENT).
17
+ * 3. STRUCTURAL SUBSET — every present `inputSchema` satisfies the compact
18
+ * structural subset: top-level `type: "object"` with `properties`, and
19
+ * none of `$ref`/`oneOf`/`allOf`/`anyOf`/`not`/`patternProperties`/
20
+ * `dependentSchemas`. Validated against the schema (no dereference of the
21
+ * informative RFC 0030 Tier-1 table).
22
+ * 4. PROJECTION COMPLETENESS — the compact `tools[]` `toolId` set EQUALS the
23
+ * standard `tools[]` `toolId` set for the same principal (a compact catalog
24
+ * that drops a tool the standard view shows is non-conformant;
25
+ * authorization-scoping preserved).
26
+ * 5. BY-ID — `GET /v1/tools/{toolId}?view=compact` returns one schema-valid
27
+ * CompactToolDescriptor.
28
+ *
29
+ * Spec references:
30
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/tool-catalog.md (§compact)
31
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0112-compact-tool-projection.md
32
+ */
33
+
34
+ import { describe, it, expect } from 'vitest';
35
+ import { readFileSync } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ import Ajv2020 from 'ajv/dist/2020.js';
38
+ import addFormats from 'ajv-formats';
39
+ import { driver } from '../lib/driver.js';
40
+ import { behaviorGate } from '../lib/behavior-gate.js';
41
+ import { SCHEMAS_DIR } from '../lib/paths.js';
42
+ import {
43
+ readToolCatalogCap,
44
+ listToolsCompact,
45
+ COMPACT_DROPPED_FIELDS,
46
+ findBannedInputSchemaKeyword,
47
+ type CompactToolDescriptor,
48
+ } from '../lib/toolCatalog.js';
49
+
50
+ function loadSchema(name: string): Record<string, unknown> {
51
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
52
+ }
53
+
54
+ /** Extract the `toolId` set from a `GET /v1/tools` body, tolerating both the
55
+ * bare-array and `{ tools: [] }` envelope standard shapes (cast-free). */
56
+ function toolIdSet(body: unknown): Set<string> {
57
+ const ids = new Set<string>();
58
+ const arr: unknown[] = Array.isArray(body)
59
+ ? body
60
+ : body && typeof body === 'object' && Array.isArray((body as { tools?: unknown }).tools)
61
+ ? ((body as { tools: unknown[] }).tools)
62
+ : [];
63
+ for (const t of arr) {
64
+ if (t && typeof t === 'object') {
65
+ const id = (t as { toolId?: unknown }).toolId;
66
+ if (typeof id === 'string') ids.add(id);
67
+ }
68
+ }
69
+ return ids;
70
+ }
71
+
72
+ describe('tool-catalog-compact-projection (RFC 0112 §compact)', () => {
73
+ it('serves the { tools: CompactToolDescriptor[] } projection — closed shape, bounded inputSchema, same toolId set as standard', async () => {
74
+ const cap = await readToolCatalogCap();
75
+ if (!behaviorGate('openwop-tool-catalog-compact', cap?.compactView === true)) return;
76
+
77
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
78
+ addFormats(ajv);
79
+ const validate = ajv.compile(loadSchema('compact-tool-descriptor.schema.json'));
80
+
81
+ // ---- Leg 1: the compact envelope (§compact) -------------------------
82
+ const compact = await listToolsCompact();
83
+ if (compact === null) return; // advertises the cap but doesn't serve the read — soft-skip the rest
84
+
85
+ for (const t of compact) {
86
+ // ---- Leg 2: schema validity + heavy fields dropped ----------------
87
+ expect(
88
+ validate(t),
89
+ driver.describe('compact-tool-descriptor.schema.json', `each CompactToolDescriptor MUST validate (${ajv.errorsText(validate.errors)})`),
90
+ ).toBe(true);
91
+ for (const f of COMPACT_DROPPED_FIELDS) {
92
+ expect(
93
+ !(f in t),
94
+ driver.describe('tool-catalog.md §compact', `CompactToolDescriptor MUST drop the heavy field "${f}"`),
95
+ ).toBe(true);
96
+ }
97
+
98
+ // ---- Leg 3: the compact structural subset on inputSchema ----------
99
+ const input = (t as CompactToolDescriptor).inputSchema;
100
+ if (input !== undefined) {
101
+ expect(
102
+ input.type === 'object' && typeof input.properties === 'object' && input.properties !== null,
103
+ driver.describe('tool-catalog.md §compact', 'compact inputSchema MUST be top-level type:"object" with a properties map'),
104
+ ).toBe(true);
105
+ // Total (any-depth), schema-aware: a nested oneOf/$ref under a property
106
+ // schema is exactly the verbosity the compact view exists to drop.
107
+ const banned = findBannedInputSchemaKeyword(input);
108
+ expect(
109
+ banned,
110
+ driver.describe('tool-catalog.md §compact', `compact inputSchema MUST NOT use $ref/oneOf/allOf/anyOf/not/patternProperties/dependentSchemas at any nesting depth (found "${banned ?? 'none'}")`),
111
+ ).toBe(null);
112
+ }
113
+ }
114
+
115
+ // ---- Leg 4: projection completeness vs the standard view -------------
116
+ const standardRes = await driver.get('/v1/tools');
117
+ const standardIds = toolIdSet(standardRes.json);
118
+ const compactIds = new Set<string>();
119
+ for (const t of compact) {
120
+ if (typeof t.toolId === 'string') compactIds.add(t.toolId);
121
+ }
122
+ const sameSet =
123
+ standardIds.size === compactIds.size && [...standardIds].every((id) => compactIds.has(id));
124
+ expect(
125
+ sameSet,
126
+ driver.describe(
127
+ 'tool-catalog.md §compact',
128
+ `compact tools[] MUST carry the same toolId set as the standard view (standard=${[...standardIds].sort().join(',')} compact=${[...compactIds].sort().join(',')})`,
129
+ ),
130
+ ).toBe(true);
131
+
132
+ // ---- Leg 5: by-id compact round-trip --------------------------------
133
+ if (compact.length > 0 && typeof compact[0]!.toolId === 'string') {
134
+ const id = compact[0]!.toolId;
135
+ const one = await driver.get(`/v1/tools/${encodeURIComponent(id)}?view=compact`);
136
+ if (one.status === 200) {
137
+ expect(
138
+ validate(one.json),
139
+ driver.describe('compact-tool-descriptor.schema.json', `GET /v1/tools/{toolId}?view=compact MUST return a valid CompactToolDescriptor (${ajv.errorsText(validate.errors)})`),
140
+ ).toBe(true);
141
+ const got = one.json;
142
+ expect(
143
+ got && typeof got === 'object' && (got as { toolId?: unknown }).toolId === id,
144
+ driver.describe('tool-catalog.md §compact', 'GET /v1/tools/{toolId}?view=compact MUST return the requested descriptor'),
145
+ ).toBe(true);
146
+ }
147
+ }
148
+ });
149
+ });