@volter/twin-standard 1.0.149 → 1.0.151
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 +12 -3
- package/client-sdks/bun.lock +0 -32
- package/client-sdks/package.json +0 -2
- package/dist/client-sdks/bun.lock +0 -32
- package/dist/client-sdks/package.json +0 -2
- package/dist/src/assessment.d.ts +5 -4
- package/dist/src/assessment.js +2 -1
- package/dist/src/cli.js +21 -6
- package/dist/src/clients/harness.d.ts +3 -1
- package/dist/src/clients/harness.js +4 -2
- package/dist/src/clients/jira.d.ts +2 -0
- package/dist/src/clients/jira.js +71 -0
- package/dist/src/clients/polar.d.ts +2 -0
- package/dist/src/clients/polar.js +73 -0
- package/dist/src/clients/resend.js +38 -0
- package/dist/src/clients/run.js +1 -1
- package/dist/src/clients/sentry.js +1 -26
- package/dist/src/clients/smtp.d.ts +2 -0
- package/dist/src/clients/smtp.js +54 -0
- package/dist/src/clients/stripe.js +1 -1
- package/dist/src/clients/volteridentity.js +4 -3
- package/dist/src/clients/x.d.ts +2 -0
- package/dist/src/clients/x.js +64 -0
- package/dist/src/conformance.d.ts +4 -1
- package/dist/src/conformance.js +256 -6
- package/dist/src/consumer.js +5 -1
- package/dist/src/create-pack.d.ts +5 -0
- package/dist/src/create-pack.js +31 -3
- package/dist/src/protocol-3.js +30 -2
- package/dist/src/spec-ir.d.ts +2 -0
- package/dist/src/spec-ir.js +43 -0
- package/dist/src/tools/judge-brief.d.ts +1 -0
- package/dist/src/tools/judge-brief.js +67 -0
- package/dist/src/tools/life-coverage-walk.d.ts +2 -0
- package/dist/src/tools/life-coverage-walk.js +220 -0
- package/dist/src/tools/life-coverage.d.ts +2 -0
- package/dist/src/tools/life-coverage.js +271 -0
- package/dist/src/tools/packs-root.d.ts +9 -0
- package/dist/src/tools/packs-root.js +17 -0
- package/dist/src/tools/run-tool.d.ts +1 -0
- package/dist/src/tools/run-tool.js +18 -0
- package/dist/src/tools/score-pack.d.ts +2 -0
- package/dist/src/tools/score-pack.js +264 -0
- package/dist/src/tools/vendor-examples.d.ts +2 -0
- package/dist/src/tools/vendor-examples.js +1493 -0
- package/dist/src/verdict.js +1 -1
- package/dist/src/walk/behavior-journey.d.ts +6 -0
- package/dist/src/walk/behavior-journey.js +8 -6
- package/dist/src/walk/journey-kit.d.ts +2 -0
- package/dist/src/walk/journey-kit.js +6 -2
- package/dist/src/walk/rendered-scalar.d.ts +3 -0
- package/dist/src/walk/rendered-scalar.js +23 -0
- package/dist/src/walk/sse-example.js +45 -0
- package/package.json +2 -2
- package/src/assessment.ts +2 -1
- package/src/cli.ts +15 -2
- package/src/clients/harness.ts +4 -2
- package/src/clients/jira.ts +73 -0
- package/src/clients/polar.ts +73 -0
- package/src/clients/resend.ts +32 -0
- package/src/clients/run.ts +2 -2
- package/src/clients/sentry.ts +1 -25
- package/src/clients/smtp.ts +49 -0
- package/src/clients/stripe.ts +1 -1
- package/src/clients/volteridentity.ts +4 -3
- package/src/clients/x.ts +66 -0
- package/src/conformance.ts +192 -8
- package/src/consumer.ts +5 -1
- package/src/create-pack.ts +27 -3
- package/src/protocol-3.ts +24 -2
- package/src/spec-ir.ts +31 -0
- package/src/tools/judge-brief.ts +63 -0
- package/src/tools/life-coverage-walk.ts +173 -0
- package/src/tools/life-coverage.ts +237 -0
- package/src/tools/packs-root.ts +21 -0
- package/src/tools/run-tool.ts +10 -0
- package/src/tools/score-pack.ts +244 -0
- package/src/tools/vendor-examples.ts +1250 -0
- package/src/verdict.ts +1 -1
- package/src/walk/behavior-journey.ts +6 -2
- package/src/walk/journey-kit.ts +7 -2
- package/src/walk/rendered-scalar.ts +17 -0
- package/src/walk/sse-example.ts +37 -0
|
@@ -0,0 +1,1250 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// vendor-examples — a pack's twin replayed against the examples its vendor published (docs/contributing/architecture.md,
|
|
3
|
+
// "Protocol 3"). A customer life shows the twin behaves as a believable customer finds it; this shows it answers what the
|
|
4
|
+
// vendor itself says it answers, with no judge reading docs in between.
|
|
5
|
+
//
|
|
6
|
+
// The examples are the spec's own (Smithy, OpenAPI, Swagger, OpenAI's x-oaiMeta, Stripe's fixtures, a line protocol's RFC
|
|
7
|
+
// scenarios) and those the vendor's documentation pages publish, vendored with their URLs in `spec/doc-examples.json`.
|
|
8
|
+
//
|
|
9
|
+
// `journeys/vendor-examples.json` is a journey (the customer life's step format) whose example steps carry
|
|
10
|
+
// `example: "<operation>#<title>"`: the steps before one make true what the example assumes exists, and the example step
|
|
11
|
+
// sends the example's request as published. Its answer must hold every key path of the example's answer with the same
|
|
12
|
+
// JSON type for structured generated values; literal text and complete RESP replies compare exactly. Setup values are the step's
|
|
13
|
+
// own `expect`. A key the twin answers otherwise is either a twin defect, fixed with the vendor's doc cited, or listed in
|
|
14
|
+
// the step's `exampleDiffers` (a key path, or `status`) with why the vendor's example is wrong, or why the server the
|
|
15
|
+
// twin models answers otherwise where the vendor's text allows it (Postfix's 252 to a VRFY the RFC shows answered 250). An example no World can replay is listed in the
|
|
16
|
+
// file's `notReplayed` with its reason.
|
|
17
|
+
//
|
|
18
|
+
// An example of an operation the twin does not serve is counted, not replayed: that operation is breadth's gap
|
|
19
|
+
// (score-pack.ts), and serving it brings its examples in.
|
|
20
|
+
//
|
|
21
|
+
// What the script cannot check a judge rules on (setup steps each an example's precondition or read-back, every
|
|
22
|
+
// decided value asserted, each exampleDiffers and notReplayed reason true against the vendor's documentation), in
|
|
23
|
+
// `journeys/vendor-examples.verdict.json`: `{sha256, plausible, judge: {agent}, authors}`, stamped by the dispatcher as
|
|
24
|
+
// a life's verdict is (score-pack.ts); a verdict whose judge is among the authors, or that names other content, rules nothing.
|
|
25
|
+
//
|
|
26
|
+
// Done: every published example of a served operation replayed or recorded, the published status answered, no check
|
|
27
|
+
// failing, no mismatch unexplained, and a judge's plausible verdict naming the file as it is.
|
|
28
|
+
//
|
|
29
|
+
// Two of the judges' recurring rulings are checks: an example step's answer value that an earlier step of the file sent or
|
|
30
|
+
// captured (a name, an email, an id the setup decided) is asserted, or named in the step's `undecided` with the reason it
|
|
31
|
+
// only coincides (a default the vendor applies); and a write example whose own answer is thin (three keys or fewer) has its
|
|
32
|
+
// subject, minted or in its path, read by a later step, where the vendor gives a GET of the subject or its collection (a
|
|
33
|
+
// delete is read back by either; an RPC write naming its subject in its body is left to the judge). Neither sees what the
|
|
34
|
+
// setup only causes (a count of clicks, a date a send was made, a status a verify leads to), which the author and the judge
|
|
35
|
+
// read for. `--sweep` prints, without changing done, every actor field (creator, user, *_by, ...) no `equals` pins and every
|
|
36
|
+
// list no expect closes: advice for fixing a ruling as a class (its hits include lists a pinned text already fixes).
|
|
37
|
+
//
|
|
38
|
+
// Usage: twin-standard examples <vendor> [--sweep] (from the pack repository's root; twin-world's
|
|
39
|
+
// bun scripts/vendor-examples.ts <vendor> runs the same)
|
|
40
|
+
import { parseExactJson, exactInteger, isExactIntegerValue } from '@volter/world-core/exact-json';
|
|
41
|
+
import { hasOwnSurface } from '../lanes.ts';
|
|
42
|
+
import { archiveSource } from '../archive-source.ts';
|
|
43
|
+
import { sourceHeld, type SourceRecord } from '../check-sources.ts';
|
|
44
|
+
import { notIndependent } from '../verdict.ts';
|
|
45
|
+
import { parse as parseYaml } from 'yaml';
|
|
46
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
47
|
+
import { basename, join, resolve } from 'node:path';
|
|
48
|
+
import { gunzipSync } from 'node:zlib';
|
|
49
|
+
import { inMemoryWorld, walk, type Failure, usePacks } from '../walk/behavior-journey.ts';
|
|
50
|
+
import { vendorFetch, vendorStream } from '../walk/vendor-fetch.ts';
|
|
51
|
+
import { at as selectorAt, captureAt, expandFills, isResp, isGit, isLine, isSocket, isWait, type Journey, type JourneyStep, type LineStep } from '../walk/journey-kit.ts';
|
|
52
|
+
import { lanesOf, shapeChecker } from '../walk/shape.ts';
|
|
53
|
+
import { applySpecPatch, discoveryToOpenAPI, mergeDocuments, readSpecDocuments, slug, type SpecPatch } from '../spec.ts';
|
|
54
|
+
import { packsRoot } from './packs-root.ts';
|
|
55
|
+
import { renderedScalar, isRenderedScalar, matchesRenderedScalar } from '../walk/rendered-scalar.ts';
|
|
56
|
+
import { sseExampleAnswer } from '../walk/sse-example.ts';
|
|
57
|
+
import { parseBracketForm, multipartBoundary, multipartEnvelope, inferMultipartBoundary, multipartDelimiterPattern } from '@volter/world-core/runtime';
|
|
58
|
+
const PACKS = packsRoot();
|
|
59
|
+
usePacks(PACKS);
|
|
60
|
+
|
|
61
|
+
/** `sample`: the example is what an object can hold (Stripe's fixtures), not the answer to one request, so a field the
|
|
62
|
+
* twin answers null for the request it was sent passes where the served spec lets it be null; a field it fills must
|
|
63
|
+
* still be the sample's type. `schema` is the served spec's schema of the answer, which says where null is allowed. */
|
|
64
|
+
/** `placeholder`: strings of the published input matching it stand for ids the World mints (openai's `vs_abc123`), so the
|
|
65
|
+
* step sends a string there, not that one. */
|
|
66
|
+
type Example = { names?: string[]; wire?: 'client'; patchRemoved?: boolean; id: string; operation: string; lane?: string; status?: number; output: unknown; input?: unknown; partial?: boolean; placeholder?: RegExp; target?: string; restXml?: string; lines?: string[]; sample?: boolean; schema?: SpecSchema; request?: { method: string; url: string; headers?: Record<string, string> }; authorizationAbsent?: boolean };
|
|
67
|
+
/** A served spec's schema, its references resolved lazily (`resolveSchema`). */
|
|
68
|
+
type SpecSchema = { $ref?: string; nullable?: boolean; properties?: Record<string, SpecSchema>; items?: SpecSchema; allOf?: SpecSchema[]; anyOf?: SpecSchema[]; oneOf?: SpecSchema[]; [k: string]: unknown };
|
|
69
|
+
let specSchemas: Record<string, SpecSchema> = {};
|
|
70
|
+
// A lane can use the same component names as the vendor API for different wire shapes.
|
|
71
|
+
const schemasByOperation: Record<string, Record<string, SpecSchema>> = {};
|
|
72
|
+
/** Each operation's request-body schema and its JSON answers' schemas by status, as the served spec gives them. */
|
|
73
|
+
const operationSchemas: Record<string, { request?: SpecSchema; responses: Record<string, SpecSchema> }> = {};
|
|
74
|
+
|
|
75
|
+
/** A page's placeholder id in an answer-map key is the id sent at the corresponding published request position.
|
|
76
|
+
* Only declared placeholders bind, and an ambiguous repeated placeholder cannot stand for a guessed map key. */
|
|
77
|
+
function answerKeys(output: unknown, published: unknown, sent: unknown, placeholder: RegExp | undefined, rawBindings = false): unknown {
|
|
78
|
+
if (!placeholder) return output;
|
|
79
|
+
const bindings = new Map<string, string | undefined>();
|
|
80
|
+
const pair = (p: unknown, value: unknown, key?: string): void => {
|
|
81
|
+
if (typeof p === 'string' && typeof value === 'string' && isPlaceholder(placeholder, p, key)) {
|
|
82
|
+
const actual = rawBindings ? value : filled(value);
|
|
83
|
+
bindings.set(p, bindings.has(p) && bindings.get(p) !== actual ? undefined : actual);
|
|
84
|
+
} else if (Array.isArray(p) && Array.isArray(value)) {
|
|
85
|
+
p.forEach((x, i) => pair(x, value[i], key));
|
|
86
|
+
} else if (p && value && typeof p === 'object' && typeof value === 'object') {
|
|
87
|
+
Object.entries(p).forEach(([k, x]) => pair(x, (value as Record<string, unknown>)[k], k));
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
pair(published, sent);
|
|
91
|
+
const mapped = (value: unknown): unknown => renderedMarker(value) ? value : Array.isArray(value) ? value.map(mapped)
|
|
92
|
+
: value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([k, v]) => [bindings.get(k) ?? k, mapped(v)]))
|
|
93
|
+
: value;
|
|
94
|
+
return mapped(output);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The schemas the twin serves: the vendor's, with the pack's spec patches (spec/patches.json) applied as derive-pack
|
|
98
|
+
* applies them. A sample is checked against what the twin is derived from; the published examples themselves stay the
|
|
99
|
+
* vendor's bytes. */
|
|
100
|
+
function served<T extends object>(pkg: string, doc: T): T {
|
|
101
|
+
const patchPath = join(pkg, 'spec', 'patches.json');
|
|
102
|
+
if (!existsSync(patchPath)) return doc;
|
|
103
|
+
const patched = structuredClone(doc);
|
|
104
|
+
for (const p of (parseExactJson(readFileSync(patchPath, 'utf8')) as { patches: SpecPatch[] }).patches) applySpecPatch(patched, p);
|
|
105
|
+
return patched;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── a restXml service's examples (S3's): the input where each member's binding puts it, the answer read by its shape ──
|
|
109
|
+
type Json = any;
|
|
110
|
+
let xmlModel: { shapes: Record<string, Json> } | undefined;
|
|
111
|
+
/** A translated placeholder-key map is a complete map even when its surrounding documented request is partial. */
|
|
112
|
+
function requestMapKeysHold(published: unknown, translated: unknown, sent: unknown, placeholder?: RegExp): boolean {
|
|
113
|
+
if (!placeholder || !published || typeof published !== 'object') return true;
|
|
114
|
+
if (Array.isArray(published)) return published.every((p, i) => requestMapKeysHold(p, (translated as unknown[] | undefined)?.[i], (sent as unknown[] | undefined)?.[i], placeholder));
|
|
115
|
+
if (!sent || typeof sent !== 'object' || !translated || typeof translated !== 'object') return false;
|
|
116
|
+
const keys = Object.keys(published);
|
|
117
|
+
if (keys.some((k) => placeholder.test(k)) && canonical(Object.keys(translated).sort()) !== canonical(Object.keys(sent).sort())) return false;
|
|
118
|
+
return keys.every((k, i) => {
|
|
119
|
+
const mapped = Object.keys(translated)[i]!;
|
|
120
|
+
return requestMapKeysHold((published as Record<string, unknown>)[k], (translated as Record<string, unknown>)[mapped], (sent as Record<string, unknown>)[mapped], placeholder);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const PRELUDE: Record<string, string> = { Boolean: 'boolean', PrimitiveBoolean: 'boolean', Integer: 'integer', PrimitiveInteger: 'integer', Long: 'long', PrimitiveLong: 'long', Float: 'float', Double: 'double' };
|
|
125
|
+
const scalarType = (target: string): string => xmlModel?.shapes[target]?.type ?? PRELUDE[target.split('#')[1] ?? ''] ?? 'string';
|
|
126
|
+
const scalarOf = (target: string, v: unknown): unknown => {
|
|
127
|
+
if (typeof v !== 'string') return v;
|
|
128
|
+
const t = scalarType(target);
|
|
129
|
+
if (t === 'boolean') return v === 'true';
|
|
130
|
+
if (['integer', 'long', 'short', 'byte'].includes(t)) return /^[+-]?\d+$/.test(v) ? exactInteger(v) : v;
|
|
131
|
+
return ['float', 'double'].includes(t) && v !== '' && !Number.isNaN(Number(v)) ? Number(v) : v;
|
|
132
|
+
};
|
|
133
|
+
const arrayOf = (v: unknown): unknown[] => (v === undefined || v === '' ? [] : Array.isArray(v) ? v : [v]);
|
|
134
|
+
|
|
135
|
+
/** An answer's XML members (xml-view) read by the model's shape: each member under its xmlName, a flattened list the
|
|
136
|
+
* repeated element, a wrapped list its container's member elements, scalars as their shape's type. */
|
|
137
|
+
function typedXml(target: string, v: unknown): unknown {
|
|
138
|
+
const shape = xmlModel?.shapes[target];
|
|
139
|
+
if (!shape || shape.type !== 'structure') return scalarOf(target, v);
|
|
140
|
+
if (!v || typeof v !== 'object') return v === '' ? {} : v;
|
|
141
|
+
const obj = v as Record<string, unknown>;
|
|
142
|
+
const out: Record<string, unknown> = {};
|
|
143
|
+
for (const [name, m] of Object.entries((shape.members ?? {}) as Record<string, Json>)) {
|
|
144
|
+
if (Object.keys(m.traits ?? {}).some((t) => /^smithy\.api#http(Header|PrefixHeaders|ResponseCode)$/.test(t))) continue;
|
|
145
|
+
const el = String(m.traits?.['smithy.api#xmlName'] ?? name);
|
|
146
|
+
const ts = xmlModel!.shapes[m.target];
|
|
147
|
+
const raw = obj[el];
|
|
148
|
+
if (ts?.type === 'list') {
|
|
149
|
+
if (m.traits?.['smithy.api#xmlFlattened'] !== undefined) { if (raw !== undefined) out[name] = arrayOf(raw).map((i) => typedXml(ts.member.target, i)); continue; }
|
|
150
|
+
if (raw === undefined) continue;
|
|
151
|
+
const memberEl = String(ts.member.traits?.['smithy.api#xmlName'] ?? 'member');
|
|
152
|
+
out[name] = arrayOf(raw === '' ? undefined : (raw as Record<string, unknown>)[memberEl]).map((i) => typedXml(ts.member.target, i));
|
|
153
|
+
} else if (raw !== undefined) out[name] = typedXml(m.target, raw);
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A restXml operation's success answer as its output structure: the body's members by shape (or its payload
|
|
159
|
+
* member's), and the members its headers carry. */
|
|
160
|
+
function restXmlAnswer(opId: string, body: unknown, headers: Record<string, string> = {}): unknown {
|
|
161
|
+
const out = xmlModel!.shapes[xmlModel!.shapes[opId]?.output?.target ?? ''];
|
|
162
|
+
if (!out) return body;
|
|
163
|
+
const members = Object.entries((out.members ?? {}) as Record<string, Json>);
|
|
164
|
+
const payload = members.find(([, m]) => m.traits?.['smithy.api#httpPayload'] !== undefined);
|
|
165
|
+
const answer = (payload ? { [payload[0]]: typedXml(payload[1].target, body) } : typedXml(xmlModel!.shapes[opId].output.target, body)) as Record<string, unknown>;
|
|
166
|
+
const lower = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
|
|
167
|
+
for (const [name, m] of members) {
|
|
168
|
+
const h = m.traits?.['smithy.api#httpHeader'];
|
|
169
|
+
if (h !== undefined && lower[String(h).toLowerCase()] !== undefined) answer[name] = scalarOf(m.target, lower[String(h).toLowerCase()]);
|
|
170
|
+
// a map bound to a header prefix (S3's Metadata, `x-amz-meta-*`): every header under it, by the rest of its name
|
|
171
|
+
const prefix = m.traits?.['smithy.api#httpPrefixHeaders'];
|
|
172
|
+
if (prefix !== undefined) answer[name] = Object.fromEntries(Object.entries(lower).filter(([k]) => k.startsWith(String(prefix).toLowerCase())).map(([k, v]) => [k.slice(String(prefix).length), v]));
|
|
173
|
+
}
|
|
174
|
+
return answer;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Where a restXml example's input is not what the step sends: each member the example gives, at its binding (a path
|
|
178
|
+
* label, a query key, a header; a payload or body member is the step's body and is not compared). */
|
|
179
|
+
function restXmlInputProblems(opId: string, input: Record<string, unknown>, path: string, headers: Record<string, string>, placeholder: RegExp, rawPath?: string): string[] {
|
|
180
|
+
const shape = xmlModel!.shapes[xmlModel!.shapes[opId]?.input?.target ?? ''];
|
|
181
|
+
const url = new URL(path, 'http://twin.local');
|
|
182
|
+
// the step as written, its captures unfilled: a value it takes from a capture is the World's own id (an S3 upload's
|
|
183
|
+
// UploadId), which stands for the example's literal as a spec's ids stand for the World's (architecture, "Published examples")
|
|
184
|
+
const raw = rawPath ? new URL(rawPath.replace(/\{([^}]+)\}/g, '%7B$1%7D'), 'http://twin.local') : undefined;
|
|
185
|
+
const captured = (v: string | null | undefined): boolean => typeof v === 'string' && /^\{[^}]+\}$/.test(v);
|
|
186
|
+
const lower = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
|
|
187
|
+
const out: string[] = [];
|
|
188
|
+
// each path segment the step fills from a capture stands for one label the example names literally, never more: a
|
|
189
|
+
// label that is not on the path is excused only while such segments remain (a wrong Bucket beside a captured Key fails)
|
|
190
|
+
let capturedSegments = raw ? raw.pathname.split('/').map((x) => decodeURIComponent(x)).filter(captured).length : 0;
|
|
191
|
+
for (const [name, m] of Object.entries((shape?.members ?? {}) as Record<string, Json>)) {
|
|
192
|
+
const v = input[name];
|
|
193
|
+
if (v === undefined || (typeof v === 'string' && placeholder.test(v))) continue;
|
|
194
|
+
const t = m.traits ?? {};
|
|
195
|
+
const want = String(v);
|
|
196
|
+
// a label is a whole path segment, or (a greedy one, S3's Key) the path's whole tail; an empty one names nothing
|
|
197
|
+
if (t['smithy.api#httpLabel'] !== undefined) {
|
|
198
|
+
const segments = url.pathname.split('/').map((x) => decodeURIComponent(x));
|
|
199
|
+
const tail = (i: number): string => segments.slice(i).join('/');
|
|
200
|
+
const onPath = segments.some((seg, i) => seg === want || (i > 0 && tail(i) === want));
|
|
201
|
+
if (want === '' || (!onPath && !(capturedSegments-- > 0))) out.push(`${name} (${JSON.stringify(want)}) is not a segment of the path`);
|
|
202
|
+
}
|
|
203
|
+
else if (t['smithy.api#httpQuery'] !== undefined) { if (url.searchParams.get(String(t['smithy.api#httpQuery'])) !== want && !captured(raw?.searchParams.get(String(t['smithy.api#httpQuery'])))) out.push(`${name} is not the query's ${t['smithy.api#httpQuery']}=${want}`); }
|
|
204
|
+
else if (t['smithy.api#httpHeader'] !== undefined) { if (lower[String(t['smithy.api#httpHeader']).toLowerCase()] !== want) out.push(`${name} is not the header ${t['smithy.api#httpHeader']}: ${want}`); }
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A page's JSON as typed: its elision lines (`...`) dropped, its bare keys quoted, its trailing commas removed. */
|
|
210
|
+
function lenientJson(text: string): string {
|
|
211
|
+
return text.replace(/^\s*\.{3,},?\s*$/gm, '').replace(/([{,]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:/g, '$1"$2":').replace(/,(\s*[}\]])/g, '$1');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Every example the vendor's spec publishes, by `<operation>#<title>`: Smithy's `smithy.api#examples`, an OpenAPI 3
|
|
215
|
+
* response's `example`/`examples` (a `$ref` to `components.examples` followed), a Swagger 2 response's `examples`, Stripe's
|
|
216
|
+
* fixtures (one sample per resource, beside its spec), and OpenAI's `x-oaiMeta.examples` (its `response` is JSON text; one that is not JSON, a stream or an abridged answer, is
|
|
217
|
+
* kept as text and compared with the published framing and payload). */
|
|
218
|
+
function published(pkg: string): Example[] {
|
|
219
|
+
// Both the vendor API and its lanes publish examples; a root spec must not hide a lane. A lane's example names its
|
|
220
|
+
// lane, the operation being the lane's own (its owner is `<lane>:<id>` in the vendor's owners)
|
|
221
|
+
const own = [...(hasOwnSurface(pkg) ? fromSpec(pkg) : []), ...fromDocs(pkg)];
|
|
222
|
+
return [...own, ...lanesOf(pkg).flatMap((lane) => published(lane).map((e) => ({ ...e, lane: e.lane ? `${basename(lane)}:${e.lane}` : basename(lane) })))];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The examples a vendor's documentation pages publish beside its spec (a schedule page's expressions, each with what
|
|
226
|
+
* it means), vendored in `spec/doc-examples.json` as `[{title, source, operation, request?, input?, output?, means, status?}]`:
|
|
227
|
+
* `source` is the page's URL, `input` and `output` its example as written, `means` its own words for what the example
|
|
228
|
+
* does, which the example step's `expect` asserts. A rule the page states ("must have 0 in the minutes field") is one
|
|
229
|
+
* too: `means` quotes the sentence, `input` is the case it forbids, and `status` the refusal the vendor answers. Each is
|
|
230
|
+
* `<operation>#doc <title>`. A page that renders its example from the operation's schema (`rendered`: Vercel's
|
|
231
|
+
* reference writes every property, and every scalar as a string, `"verified": "true"` beside a schema saying boolean)
|
|
232
|
+
* shows what the answer can hold, not one request's answer: its example is a sample against the served schema, and a
|
|
233
|
+
* string where the schema says number, integer or boolean is read as that type, in the answer and the request body. */
|
|
234
|
+
function fromDocs(pkg: string): Example[] {
|
|
235
|
+
const file = join(pkg, 'spec', 'doc-examples.json');
|
|
236
|
+
if (!existsSync(file)) return [];
|
|
237
|
+
const docs = parseExactJson(readFileSync(file, 'utf8')) as Array<{ wire?: 'client'; title: string; source: string; quote?: string; operation: string; input?: unknown; output?: unknown; means: string; status?: number; rendered?: boolean; placeholders?: { names: string[]; quote: string }; request?: { method: string; url: string; headers?: Record<string, string> } }>;
|
|
238
|
+
const authorizationAbsent = new Set<(typeof docs)[number]>();
|
|
239
|
+
const records: Record<string, SourceRecord> = existsSync(join(pkg, 'spec', 'sources.json')) ? parseExactJson(readFileSync(join(pkg, 'spec', 'sources.json'), 'utf8')) : {};
|
|
240
|
+
for (const d of docs) {
|
|
241
|
+
if (d.wire === 'client' && d.request) throw new Error(`${file}: client computation \"${d.title}\" has an HTTP request`);
|
|
242
|
+
const implementation = /\/(?:src|lib|worker|rust|posthog|ee)\//.test(d.source) || /\.(?:c|h|cc|cpp|hpp|rs|py|go|java|kt|swift|[cm]?js|tsx?)(?:[?#]|$)/i.test(d.source);
|
|
243
|
+
if (/raw\.githubusercontent\.com\//.test(d.source) && implementation && !/\/(?:tests?|docs|develop-docs|pages|examples)\//.test(d.source)) throw new Error(`${file}: implementation evidence belongs in decisions.json; published examples require a vendor page or quoted vendor test`);
|
|
244
|
+
if (/raw\.githubusercontent\.com\/.*\/tests?\//.test(d.source ?? '') && (!d.quote || !Object.hasOwn(d, 'output') || records[d.source]?.quotes?.[d.quote] !== true || !sourceHeld(d.source, records[d.source]))) throw new Error(`${file}: vendor tests require their checked literal assertion and expected output`);
|
|
245
|
+
const archive = archiveSource(d.source ?? '');
|
|
246
|
+
if (archive) {
|
|
247
|
+
if (!d.means || !d.quote?.trim() || !sourceHeld(d.source, records[d.source]) || records[d.source]?.quotes?.[d.quote] !== true) throw new Error(`${file}: \"${d.title}\" needs a checked quote from its pinned archive member (check-sources)`);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const recording = /^recording:([\w.-]+\.json(?:\.gz)?)$/.exec(d.source ?? '');
|
|
251
|
+
if ((!/^https:\/\//.test(d.source ?? '') && !recording) || !d.means) throw new Error(`${file}: "${d.title}" needs its page's URL or recording basename (source) and its meaning (means)`);
|
|
252
|
+
if (!recording) continue;
|
|
253
|
+
const path = join(pkg, 'spec', 'recordings', recording[1]!);
|
|
254
|
+
if (!existsSync(path)) throw new Error(`${file}: "${d.title}" names no retained recording`);
|
|
255
|
+
const bytes = readFileSync(path);
|
|
256
|
+
const held = parseExactJson((path.endsWith('.gz') ? gunzipSync(bytes) : bytes).toString()) as { request?: { method?: string; url?: string; authorization?: string }; status?: number; body?: unknown };
|
|
257
|
+
if (!held.request?.method || !held.request.url || !Number.isInteger(held.status) || !Object.hasOwn(held, 'body')) throw new Error(`${file}: "${d.title}" needs a recording with its exact method, URL, status and body`);
|
|
258
|
+
if (!d.request || d.request.method !== held.request.method || d.request.url !== held.request.url || d.status !== held.status || canonical(d.output) !== canonical(held.body)) throw new Error(`${file}: "${d.title}" differs from its retained recording's request or answer`);
|
|
259
|
+
if (held.request.authorization === 'absent') authorizationAbsent.add(d);
|
|
260
|
+
}
|
|
261
|
+
// a page that names its own variables for the reader to fill ("Make sure to fill in the … BRANCH_NAME … variables")
|
|
262
|
+
// lists them with that sentence quoted: each stands for any value, and the published text stays as written
|
|
263
|
+
for (const d of docs) if (d.placeholders && (!d.placeholders.names?.length || !d.placeholders.quote?.trim())) throw new Error(`${file}: "${d.title}" names placeholders without the page's words for them (placeholders.quote)`);
|
|
264
|
+
const smithy = ['smithy.json.gz', 'smithy.json'].some((f) => existsSync(join(pkg, 'spec', f)));
|
|
265
|
+
// a page's example is a fragment (an expression, not the request that carries it): the body holds it
|
|
266
|
+
return docs.map((d) => {
|
|
267
|
+
if (!d.rendered) return fromPage(d, d.output, d.input);
|
|
268
|
+
const schemas = operationSchemas[d.operation];
|
|
269
|
+
const schema = schemas?.responses[String(d.status ?? 200)];
|
|
270
|
+
if (!schema) throw new Error(`${file}: "${d.title}" is rendered from a schema the served spec does not give (${d.operation}, ${d.status ?? 200})`);
|
|
271
|
+
// the renderer writes any string it cannot fill as the word "string": in a rendered input it stands for any string
|
|
272
|
+
const example = fromPage(d, typed(d.output, schema), d.input === undefined ? undefined : typed(d.input, schemas.request));
|
|
273
|
+
// (PlanetScale's renderer writes it `<string>`)
|
|
274
|
+
return { ...example, sample: true, schema };
|
|
275
|
+
});
|
|
276
|
+
function fromPage(d: (typeof docs)[number], output: unknown, input: unknown): Example {
|
|
277
|
+
const named = d.placeholders?.names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
|
|
278
|
+
const placeholder = named ? new RegExp(`${placeholderIds.source}|^(?:${named})$`, placeholderIds.flags) : placeholderIds;
|
|
279
|
+
return { names: d.placeholders?.names, ...(d.wire ? { wire: d.wire } : {}), id: `${d.operation}#doc ${d.title}`, operation: d.operation, output, ...(d.status ? { status: d.status } : {}), ...(input !== undefined ? { input, partial: true, placeholder } : {}), ...(smithy ? (xmlModel ? { restXml: Object.keys(xmlModel.shapes).find((k) => k.endsWith(`#${d.operation}`)) } : { target: d.operation }) : {}), ...(d.request ? { request: d.request } : {}), ...(authorizationAbsent.has(d) ? { authorizationAbsent: true } : {}) };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** A rendered object the page does not fill: any object stands where it is. */
|
|
284
|
+
const ANY_OBJECT = Symbol('rendered unfilled object');
|
|
285
|
+
const ANY_STRING = Symbol('rendered unfilled string');
|
|
286
|
+
const anyObject = (v: unknown): boolean => v === ANY_OBJECT;
|
|
287
|
+
const anyString = (v: unknown): boolean => v === ANY_STRING;
|
|
288
|
+
const renderedMarker = (v: unknown): boolean => anyObject(v) || anyString(v) || isRenderedScalar(v);
|
|
289
|
+
const isObject = (v: unknown): boolean => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
290
|
+
|
|
291
|
+
function hasEnum(schema: SpecSchema, depth = 0): boolean {
|
|
292
|
+
if (Array.isArray(schema.enum)) return true;
|
|
293
|
+
if (depth > 8) return false;
|
|
294
|
+
return [...(schema.allOf ?? []), ...(schema.anyOf ?? []), ...(schema.oneOf ?? [])].some(part => { const s = resolveSchema(part as SpecSchema).schema; return s ? hasEnum(s, depth + 1) : false; });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** A rendered example's value with each string the schema types as a number, integer or boolean read as that type
|
|
298
|
+
* (exactly: "123" is 123, "true" is true), and "null" as null where the schema admits null; any other string stays,
|
|
299
|
+
* and the answer check names it. */
|
|
300
|
+
function typed(value: unknown, schema: SpecSchema | undefined): unknown {
|
|
301
|
+
const { schema: s, nullable } = renderedSchema(schema);
|
|
302
|
+
if (!s) return value;
|
|
303
|
+
// the renderer writes null as "null" (gitBranch `example: null, nullable: true`; framework's enum begins with null)
|
|
304
|
+
if (value === 'null' && schema && (nullable || (Array.isArray(s.enum) && s.enum.includes(null)))) return null;
|
|
305
|
+
// the renderer writes an object it cannot fill as a word (`"value"`, `"string"`: a map keyed by ids, a free-form
|
|
306
|
+
// object): it stands for any object
|
|
307
|
+
if (typeof value === 'string' && (s.type === 'object' || s.properties || s.additionalProperties) && !s.enum) return ANY_OBJECT;
|
|
308
|
+
if (typeof value === 'string') {
|
|
309
|
+
// a union of scalars (`oneOf: [{type: boolean}]`) reads the string as the first alternative it parses as
|
|
310
|
+
const kinds = [s, ...((s.oneOf ?? s.anyOf ?? []) as SpecSchema[]).map((a) => resolveSchema(a).schema ?? {})].map((a) => a.type);
|
|
311
|
+
// (where a string is one of the alternatives, the string may be meant as written)
|
|
312
|
+
const marker = renderedScalar(value, kinds, hasEnum(s));
|
|
313
|
+
if (marker) return marker;
|
|
314
|
+
if (kinds.includes('string')) return !s.enum && (value === 'string' || value === '<string>') ? ANY_STRING : value;
|
|
315
|
+
if (kinds.some((t) => t === 'number' || t === 'integer') && /^-?\d+(\.\d+)?$/.test(value)) return /^-?\d+$/.test(value) ? exactInteger(value) : Number(value);
|
|
316
|
+
if (kinds.includes('boolean') && (value === 'true' || value === 'false')) return value === 'true';
|
|
317
|
+
return value;
|
|
318
|
+
}
|
|
319
|
+
if (Array.isArray(value)) return value.map((v) => typed(v, s.items));
|
|
320
|
+
// a map with no declared keys (`additionalProperties`) is rendered with made-up keys (`property1`, `property2`): it
|
|
321
|
+
// stands for any object, which the map may hold none of
|
|
322
|
+
if (value && typeof value === 'object' && !s.properties && s.additionalProperties && Object.keys(value).every((k) => /^property\d+$/.test(k))) return ANY_OBJECT;
|
|
323
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, typed(v, s.properties?.[k])]));
|
|
324
|
+
return value;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** A published id the World cannot mint as written: one starting with an id prefix the pack declares (`vs_`, `file-`,
|
|
328
|
+
* `asst_`), a UUID where the pack's ids are UUIDs (`ids.template: '{uuid}'`), or the vendor's placeholder idioms
|
|
329
|
+
* (`abc123`, `def456`, `call_001`, a rendered page's `example_id`). The step sends its own World's id there, in a spec's example or a page's. */
|
|
330
|
+
let placeholderIds = /abc\d*|def\d+|ghi\d+|xyz\d*|_\d{3,}$|^example_id$/;
|
|
331
|
+
/** A snowflake where the pack's ids are snowflakes (`ids.template: '{snowflake:E}'`): 17 to 20 digits, an id only under
|
|
332
|
+
* an id-named key (`id`, `*_id`, `*Id`, or their plurals' elements), never another field's digits (Discord's
|
|
333
|
+
* permission bitfields are strings of digits too), and never `custom_id`, a value the caller chooses. */
|
|
334
|
+
let snowflakeIds: RegExp | undefined;
|
|
335
|
+
const ID_KEY = /^(?!custom_ids?$)(?:ids?|.+_ids?|.+[a-z]Ids?)$/;
|
|
336
|
+
/** Whether a published string stands for an id the World mints: by its form, or (a snowflake) by its form and its key. */
|
|
337
|
+
const isPlaceholder = (placeholder: RegExp, value: string, key?: string): boolean =>
|
|
338
|
+
placeholder.test(value) || (snowflakeIds !== undefined && key !== undefined && ID_KEY.test(key) && snowflakeIds.test(value));
|
|
339
|
+
async function loadPlaceholders(pkg: string): Promise<void> {
|
|
340
|
+
const manifestPath = join(pkg, 'src', 'manifest.ts');
|
|
341
|
+
if (!existsSync(manifestPath)) return;
|
|
342
|
+
const { manifest } = (await import(manifestPath)) as { manifest?: { resources?: Record<string, { idPrefix?: string }>; ids?: { template?: string } } };
|
|
343
|
+
const prefixes = [...new Set(Object.values(manifest?.resources ?? {}).map((r) => r.idPrefix?.replace(/[_:-]$/, '')).filter((p): p is string => !!p && /^[a-z]+$/.test(p)))];
|
|
344
|
+
if (prefixes.length) placeholderIds = new RegExp(`^(?:${prefixes.join('|')})[_-]|${placeholderIds.source}`);
|
|
345
|
+
snowflakeIds = manifest?.ids?.template?.startsWith('{snowflake:') ? /^\d{17,20}$/ : undefined;
|
|
346
|
+
if (manifest?.ids?.template === '{uuid}') placeholderIds = new RegExp(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$|${placeholderIds.source}`, 'i');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function decodedExample(media: string, value: unknown): unknown {
|
|
350
|
+
if (typeof value !== 'string' || !/^application\/(?:[a-z0-9.-]+\+)?json(?:;|$)/i.test(media)) return value;
|
|
351
|
+
try { return parseExactJson(value); } catch { return value; }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function fromSpec(pkg: string): Example[] {
|
|
355
|
+
const rfc = existsSync(join(pkg, 'spec')) ? readdirSync(join(pkg, 'spec')).find((f) => /^rfc\d+\.txt$/.test(f)) : undefined;
|
|
356
|
+
if (rfc) return scenarios(readFileSync(join(pkg, 'spec', rfc), 'utf8'));
|
|
357
|
+
// a spec vendored as published or gzipped, as derive-pack reads it
|
|
358
|
+
const spec = ['smithy.json.gz', 'smithy.json', 'openapi.json.gz', 'openapi.json', 'openapi.yaml.gz', 'openapi.yaml', 'discovery.json.gz', 'discovery.json'].map((f) => join(pkg, 'spec', f)).find((p) => existsSync(p));
|
|
359
|
+
// a Protocol Buffers service publishes no examples in its .proto files: its examples are its documentation's alone
|
|
360
|
+
if (!spec && existsSync(join(pkg, 'spec', 'proto'))) return [];
|
|
361
|
+
// a command table (Redis's src/commands/*.json) publishes no examples either: its examples are its documentation's
|
|
362
|
+
if (!spec && existsSync(join(pkg, 'spec', 'commands'))) return [];
|
|
363
|
+
// a vendor's own client (client-ops.json) publishes no examples: its examples are its reference pages'
|
|
364
|
+
if (!spec && existsSync(join(pkg, 'spec', 'client-ops.json'))) return [];
|
|
365
|
+
// one API's several documents (spec/openapi/): each document's examples, judged against their patched union
|
|
366
|
+
const documents = spec ? undefined : readSpecDocuments(join(pkg, 'spec'));
|
|
367
|
+
if (!spec && !documents) throw new Error(`${pkg}: no published-example reader for this spec yet`);
|
|
368
|
+
const text = spec ? (spec.endsWith('.gz') ? gunzipSync(readFileSync(spec)) : readFileSync(spec)).toString('utf8') : '';
|
|
369
|
+
const out: Example[] = [];
|
|
370
|
+
if (spec && /smithy\.json(\.gz)?$/.test(spec)) {
|
|
371
|
+
const model = parseExactJson(text) as { shapes: Record<string, { type?: string; traits?: Record<string, unknown> }> };
|
|
372
|
+
const restXml = Object.values(model.shapes).some((sh) => sh.type === 'service' && sh.traits?.['aws.protocols#restXml'] !== undefined);
|
|
373
|
+
if (restXml) xmlModel = model as { shapes: Record<string, Json> };
|
|
374
|
+
for (const [id, shape] of Object.entries(model.shapes)) {
|
|
375
|
+
const examples = shape.traits?.['smithy.api#examples'] as Array<{ title: string; input?: unknown; output?: unknown; error?: { content?: unknown } }> | undefined;
|
|
376
|
+
const operation = id.split('#')[1];
|
|
377
|
+
// an awsJson operation is named by its x-amz-target header, `<service>.<operation>`; its body is the input. A
|
|
378
|
+
// restXml operation (S3's) is named by its method and path, and its input is where each member's binding puts it
|
|
379
|
+
for (const e of examples ?? []) out.push({ id: `${operation}#${e.title}`, operation, output: e.error ? e.error.content : e.output, input: e.input ?? {}, ...(restXml ? { restXml: id } : { target: operation }) });
|
|
380
|
+
}
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
type Op = { operationId?: string; responses?: Record<string, Record<string, unknown>>; 'x-oaiMeta'?: { examples?: unknown } };
|
|
384
|
+
type Doc = { paths: Record<string, Record<string, Op>>; components?: { examples?: Record<string, { value?: unknown }>; schemas?: Record<string, SpecSchema> }; definitions?: Record<string, SpecSchema> };
|
|
385
|
+
// a Google API Discovery document publishes no examples; it is read as the OpenAPI document derive-pack reads it as,
|
|
386
|
+
// its patches applied to the Discovery document first (spec-ir-discovery.ts)
|
|
387
|
+
const discovery = spec !== undefined && /discovery\.json(\.gz)?$/.test(spec);
|
|
388
|
+
const docs = (documents ? Object.values(documents.documents) : [discovery ? discoveryToOpenAPI(parseExactJson(text)) : /\.yaml(\.gz)?$/.test(spec!) ? parseExactJson(JSON.stringify(parseYaml(text, { intAsBigInt: true }), (_key, value) => typeof value === 'bigint' ? exactInteger(String(value)) : value)) : parseExactJson(text)]) as Doc[];
|
|
389
|
+
const servedDoc = (documents ? mergeDocuments(served(pkg, documents)).doc : discovery ? discoveryToOpenAPI(served(pkg, parseExactJson(text))) : served(pkg, docs[0]!)) as Doc;
|
|
390
|
+
// a Swagger 2 spec (PlanetScale's) keeps its schemas in `definitions`, referred to as `#/definitions/<name>`
|
|
391
|
+
specSchemas = servedDoc.components?.schemas ?? servedDoc.definitions ?? {};
|
|
392
|
+
// each operation's request and answer schemas as served (a patch that corrects an answer, Vercel's getEdgeConfigs
|
|
393
|
+
// a list, is what a sample is judged against)
|
|
394
|
+
const json = (content: unknown): SpecSchema | undefined => (content as Record<string, { schema?: SpecSchema }> | undefined)?.['application/json']?.schema;
|
|
395
|
+
// an operation the spec names no id for is the IR's `method_path` slug
|
|
396
|
+
const idOf = (op: Op, method: string, path: string): string => op.operationId || slug(method, path);
|
|
397
|
+
for (const [path, item] of Object.entries(servedDoc.paths ?? {})) {
|
|
398
|
+
for (const [method, op] of Object.entries(item)) {
|
|
399
|
+
if (!op || typeof op !== 'object' || !('responses' in op)) continue;
|
|
400
|
+
// Swagger 2: the request is the `in: body` parameter's schema, an answer its response's `schema`
|
|
401
|
+
const bodyParam = ((op as { parameters?: Array<{ in?: string; schema?: SpecSchema }> }).parameters ?? []).find((x) => x.in === 'body');
|
|
402
|
+
schemasByOperation[idOf(op, method, path)] = specSchemas;
|
|
403
|
+
operationSchemas[idOf(op, method, path)] = {
|
|
404
|
+
request: json((op as { requestBody?: { content?: unknown } }).requestBody?.content) ?? bodyParam?.schema,
|
|
405
|
+
responses: Object.fromEntries(Object.entries(op.responses ?? {}).flatMap(([code, r]) => { const sch = json(r.content) ?? (r as { schema?: SpecSchema }).schema; return sch ? [[code, sch]] : []; })),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const refIn = (doc: Doc, v: unknown): unknown => {
|
|
410
|
+
const r = (v as { $ref?: string })?.$ref;
|
|
411
|
+
return r ? doc.components?.examples?.[r.split('/').pop()!]?.value : (v as { value?: unknown })?.value;
|
|
412
|
+
};
|
|
413
|
+
type Schema = { $ref?: string; properties?: { data?: { items?: { $ref?: string } } } };
|
|
414
|
+
const fixturesPath = join(pkg, 'spec', 'fixtures3.json.gz');
|
|
415
|
+
const fixtures = existsSync(fixturesPath) ? (parseExactJson(gunzipSync(readFileSync(fixturesPath)).toString('utf8')) as { resources: Record<string, unknown> }).resources : undefined;
|
|
416
|
+
// A removed route still published its examples: retain and count them without requiring replay.
|
|
417
|
+
// Trailing-slash aliases can publish the same id and content; keep one without changing the served spec.
|
|
418
|
+
const routeExamples = new Map<string, { path: string; method: string; example: Example }>();
|
|
419
|
+
const pointer = (part: string): string => part.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
420
|
+
const patchPath = join(pkg, 'spec', 'patches.json');
|
|
421
|
+
const removed = new Set(existsSync(patchPath) ? (parseExactJson(readFileSync(patchPath, 'utf8')) as { patches: SpecPatch[] }).patches.filter((x) => x.op === 'remove').map((x) => x.path) : []);
|
|
422
|
+
for (const doc of docs) for (const [path, item] of Object.entries(doc.paths)) {
|
|
423
|
+
const ref = (v: unknown): unknown => refIn(doc, v);
|
|
424
|
+
for (const [method, op] of Object.entries(item)) {
|
|
425
|
+
if (!op || typeof op !== 'object' || !('responses' in op)) continue;
|
|
426
|
+
const patchRemoved = removed.has(`/paths/${pointer(path)}`) || removed.has(`/paths/${pointer(path)}/${method}`);
|
|
427
|
+
const firstExample = out.length;
|
|
428
|
+
const operation = idOf(op, method, path);
|
|
429
|
+
for (const [code, response] of Object.entries(op.responses ?? {})) {
|
|
430
|
+
const status = Number(code) || undefined;
|
|
431
|
+
// Swagger 2: examples by media type on the response itself
|
|
432
|
+
for (const [media, value] of Object.entries((response.examples ?? {}) as Record<string, unknown>)) out.push({ id: `${operation}#${code} ${media}`, operation, status, output: decodedExample(media, value) });
|
|
433
|
+
for (const [media, content] of Object.entries((response.content ?? {}) as Record<string, { example?: unknown; examples?: Record<string, unknown> }>)) {
|
|
434
|
+
if (content.example !== undefined) out.push({ id: `${operation}#${code} ${media}`, operation, status, output: decodedExample(media, content.example) });
|
|
435
|
+
// one example name may be published under several media types (a diff and a patch beside the JSON)
|
|
436
|
+
for (const [name, e] of Object.entries(content.examples ?? {})) out.push({ id: `${operation}#${code} ${name}${media === 'application/json' ? '' : ` (${media})`}`, operation, status, output: decodedExample(media, ref(e)) });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// Stripe publishes one sample object per resource beside its spec (fixtures3.json): an operation's example is the
|
|
440
|
+
// sample of what it answers, alone or as a list's page
|
|
441
|
+
if (fixtures) {
|
|
442
|
+
const schema = ((op.responses?.['200']?.content as Record<string, { schema?: Schema }> | undefined)?.['application/json']?.schema ?? {}) as Schema;
|
|
443
|
+
const one = schema.$ref?.split('/').pop();
|
|
444
|
+
const listed = schema.properties?.data?.items?.$ref?.split('/').pop();
|
|
445
|
+
if (one && fixtures[one]) out.push({ id: `${operation}#fixture ${one}`, operation, status: 200, output: fixtures[one], sample: true, schema: { $ref: `#/components/schemas/${one}` } });
|
|
446
|
+
else if (listed && fixtures[listed]) out.push({ id: `${operation}#fixture list of ${listed}`, operation, status: 200, output: { object: 'list', data: [fixtures[listed]], has_more: false }, sample: true, schema: { properties: { data: { items: { $ref: `#/components/schemas/${listed}` } } } } });
|
|
447
|
+
}
|
|
448
|
+
const meta = op['x-oaiMeta']?.examples;
|
|
449
|
+
for (const [i, e] of (Array.isArray(meta) ? meta : meta ? [meta] : []).entries() as Iterable<[number, { title?: string; response?: unknown; request?: { curl?: string } }]>) {
|
|
450
|
+
let output: unknown = e.response;
|
|
451
|
+
// JSON text, or JSON the page's typing broke: a trailing comma before a closing bracket (OpenAI's list of files,
|
|
452
|
+
// `"purpose": "assistants",}`), keys left unquoted (`id: "vs_abc123"`), a line of `...` eliding a list's middle
|
|
453
|
+
// items; else the published literal text, SSE included
|
|
454
|
+
if (typeof output === 'string') { const text = output; try { output = parseExactJson(text); } catch { try { output = parseExactJson(lenientJson(text)); } catch { /* retain the published literal text, including SSE */ } } }
|
|
455
|
+
// the request is published as a curl command: its `-d` JSON is the body (a form upload, `-F`, is not checked)
|
|
456
|
+
const data = /\s-d\s+'([\s\S]*?)'/m.exec(e.request?.curl ?? '')?.[1];
|
|
457
|
+
let input: unknown;
|
|
458
|
+
if (data) { try { input = parseExactJson(data); } catch { /* not JSON: not checked */ } }
|
|
459
|
+
out.push({ id: `${operation}#${e.title ?? (i ? String(i + 1) : 'default')}`, operation, output, ...(input !== undefined ? { input, placeholder: placeholderIds } : {}) });
|
|
460
|
+
}
|
|
461
|
+
const added = out.splice(firstExample);
|
|
462
|
+
for (const example of added) {
|
|
463
|
+
if (patchRemoved) example.patchRemoved = true;
|
|
464
|
+
const previous = routeExamples.get(example.id);
|
|
465
|
+
const normalized = (route: string): string => route.replace(/\/+$/, '') || '/';
|
|
466
|
+
if (previous && previous.method === method && previous.path !== path && normalized(previous.path) === normalized(path)
|
|
467
|
+
&& canonical(previous.example) === canonical(example)) continue;
|
|
468
|
+
routeExamples.set(example.id, { path, method, example });
|
|
469
|
+
out.push(example);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// an operation two documents both publish (the same, or the union would not read) gives its examples once
|
|
474
|
+
return documents ? [...new Map(out.map((e) => [e.id, e])).values()] : out;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** A line protocol's RFC publishes its examples as scenarios (RFC 5321, Appendix D): `S:` and `C:` lines under a
|
|
478
|
+
* numbered heading. Each client turn (the lines sent before the server replies: a message's text is one turn) is an
|
|
479
|
+
* example, `<scenario>#<n> <its first line>`, whose status is the code of the reply it draws; the greeting is turn 0. */
|
|
480
|
+
function scenarios(text: string): Example[] {
|
|
481
|
+
const out: Example[] = [];
|
|
482
|
+
const appendix = text.slice(text.search(/^Appendix D\./m));
|
|
483
|
+
let scenario = '';
|
|
484
|
+
let turn = 0;
|
|
485
|
+
let sent: string[] | undefined;
|
|
486
|
+
let closed = false;
|
|
487
|
+
for (const line of appendix.split('\n')) {
|
|
488
|
+
const heading = /^(D\.\d+)\.\s+(.*)$/.exec(line);
|
|
489
|
+
if (heading) { scenario = `${heading[1]} ${heading[2].trim()}`; turn = 0; sent = undefined; closed = false; continue; }
|
|
490
|
+
const m = /^\s+([SC]):\s?(.*)$/.exec(line);
|
|
491
|
+
if (!m || !scenario) continue;
|
|
492
|
+
if (m[1] === 'C') { (sent ??= []).push(m[2]); continue; }
|
|
493
|
+
// the first line of a reply answers the turn before it; continuation lines (`250-…`) belong to that reply, and a
|
|
494
|
+
// reply after the channel closed (221) greets a new connection (a relay's next hop)
|
|
495
|
+
if (sent === undefined && turn > 0 && !closed) continue;
|
|
496
|
+
closed = m[2].startsWith('221');
|
|
497
|
+
const verb = sent ? sent[0].split(/[\s:]/)[0].toUpperCase() : 'CONNECT';
|
|
498
|
+
out.push({ id: `${scenario}#${turn} ${sent ? sent[0] : '(connect)'}`, operation: verb, status: Number(m[2].slice(0, 3)), output: undefined, lines: sent ?? [] });
|
|
499
|
+
turn++;
|
|
500
|
+
sent = undefined;
|
|
501
|
+
}
|
|
502
|
+
return out;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** JSON with every object's keys sorted: two bodies that differ only in key order are the same body. */
|
|
506
|
+
const canonical = (v: unknown): string => JSON.stringify(v, (_k, x) => (x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => a.localeCompare(b))) : x));
|
|
507
|
+
|
|
508
|
+
/** The published body selects JSON comparison; a malformed answer never falls back to text. */
|
|
509
|
+
const jsonTexts = (sent: unknown, published: string): { valid: boolean; sent?: unknown; published: unknown } | undefined => {
|
|
510
|
+
let parsed: unknown;
|
|
511
|
+
try { parsed = parseExactJson(published); } catch { return undefined; }
|
|
512
|
+
if (typeof sent !== 'string') return { valid: false, published: parsed };
|
|
513
|
+
try { return { valid: true, sent: parseExactJson(sent), published: parsed }; }
|
|
514
|
+
catch { return { valid: false, published: parsed }; }
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
/** JSON bodies and framed text retain their structure before any placeholder inference. */
|
|
518
|
+
function comparedText(sent: unknown, published: string, placeholder: RegExp, key?: string, names?: string[], contentType?: string, boundaries: string[] = [], outputJson?: (expected: unknown, actual: unknown) => boolean): { matches: boolean; problem?: string } | undefined {
|
|
519
|
+
let problem: string | undefined;
|
|
520
|
+
const record = (actual: string, expected: string, type?: string, enclosing = boundaries, framedJson = false): boolean => {
|
|
521
|
+
if (framedJson && outputJson) {
|
|
522
|
+
const json = jsonTexts(actual, expected);
|
|
523
|
+
if (json) {
|
|
524
|
+
if (!json.valid) problem ??= 'answer is not JSON';
|
|
525
|
+
return json.valid && outputJson(json.published, json.sent);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
const comparison = comparedText(actual, expected, placeholder, key, names, type, enclosing, outputJson);
|
|
529
|
+
problem ??= comparison?.problem;
|
|
530
|
+
return comparison ? comparison.matches : same(actual, expected, placeholder, key, names, false, undefined, enclosing);
|
|
531
|
+
};
|
|
532
|
+
const lines = (text: string): string[] => text.split(/(\r\n|\n|\r)/);
|
|
533
|
+
// Recognize outer MIME framing before interpreting a part's JSON, SSE, or other payload.
|
|
534
|
+
const encode = (text: string): Uint8Array => new TextEncoder().encode(text);
|
|
535
|
+
const decode = (bytes: Uint8Array): string => new TextDecoder().decode(bytes);
|
|
536
|
+
const boundary = contentType && /^multipart\//i.test(contentType)
|
|
537
|
+
? multipartBoundary(contentType) : inferMultipartBoundary(encode(published));
|
|
538
|
+
if (boundary || contentType && /^multipart\//i.test(contentType)) {
|
|
539
|
+
if (!boundary || typeof sent !== 'string') return { matches: false, problem: 'invalid multipart framing' };
|
|
540
|
+
const expected = multipartEnvelope(encode(published), boundary);
|
|
541
|
+
const actual = multipartEnvelope(encode(sent), boundary);
|
|
542
|
+
if (!expected.complete || !actual.complete) return { matches: false, problem: 'invalid multipart framing' };
|
|
543
|
+
const literal = (a: Uint8Array, b: Uint8Array): boolean => a.length === b.length && a.every((byte, i) => byte === b[i]);
|
|
544
|
+
const matches = literal(actual.preamble, expected.preamble) && literal(actual.epilogue, expected.epilogue)
|
|
545
|
+
&& actual.delimiters.length === expected.delimiters.length
|
|
546
|
+
&& expected.delimiters.every((line, i) => literal(actual.delimiters[i]!, line))
|
|
547
|
+
&& actual.parts.length === expected.parts.length && expected.parts.every((e, i) => {
|
|
548
|
+
const a = actual.parts[i]!;
|
|
549
|
+
if (!literal(a.leading, e.leading) || !literal(a.separator, e.separator) || !literal(a.trailing, e.trailing)) return false;
|
|
550
|
+
const headers = lines(decode(e.headers));
|
|
551
|
+
const actualHeaders = lines(decode(a.headers));
|
|
552
|
+
const enclosing = [...boundaries, boundary];
|
|
553
|
+
return headers.length === actualHeaders.length
|
|
554
|
+
&& headers.every((header, j) => j % 2 ? header === actualHeaders[j] : record(actualHeaders[j]!, header, undefined, enclosing))
|
|
555
|
+
&& record(decode(a.body), decode(e.body), e.type ?? undefined, enclosing);
|
|
556
|
+
});
|
|
557
|
+
return { matches, ...(problem ? { problem } : {}) };
|
|
558
|
+
}
|
|
559
|
+
const json = jsonTexts(sent, published);
|
|
560
|
+
if (json) return json.valid ? { matches: same(json.sent, json.published, placeholder, key, names, true) }
|
|
561
|
+
: { matches: false, problem: 'answer is not JSON' };
|
|
562
|
+
// Preserve line separators and blank records, as well as each SSE field's literal prefix.
|
|
563
|
+
const expectedLines = lines(published);
|
|
564
|
+
const content = expectedLines.filter((_line, i) => i % 2 === 0).filter((line) => line.length);
|
|
565
|
+
const sse = content.some((line) => /^data:/.test(line));
|
|
566
|
+
const jsonLines = content.length > 1 && (/^\s*[\[{]/.test(content[0]!)
|
|
567
|
+
|| content.every((line) => jsonTexts(undefined, line) !== undefined));
|
|
568
|
+
if (sse || jsonLines) {
|
|
569
|
+
if (typeof sent !== 'string') return { matches: false };
|
|
570
|
+
const actualLines = lines(sent);
|
|
571
|
+
const matches = actualLines.length === expectedLines.length && expectedLines.every((line, i) => {
|
|
572
|
+
if (i % 2 || !line) return actualLines[i] === line;
|
|
573
|
+
if (!sse) return record(actualLines[i]!, line, undefined, boundaries, true);
|
|
574
|
+
const field = /^(data|event|id|retry): ?/.exec(line)?.[0];
|
|
575
|
+
if (field === undefined) return record(actualLines[i]!, line);
|
|
576
|
+
return actualLines[i]!.startsWith(field) && record(actualLines[i]!.slice(field.length), line.slice(field.length), undefined, boundaries, field.startsWith('data:'));
|
|
577
|
+
});
|
|
578
|
+
return { matches, ...(problem ? { problem } : {}) };
|
|
579
|
+
}
|
|
580
|
+
return undefined;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Whether `sent` is the published input, preserving JSON keys, types and array lengths. */
|
|
584
|
+
const same = (sent: unknown, published: unknown, placeholder: RegExp, key?: string, names?: string[], stringLeaf = false, contentType?: string, boundaries: string[] = [], outputJson?: (expected: unknown, actual: unknown) => boolean): boolean => {
|
|
585
|
+
if (!stringLeaf && typeof published === 'string') {
|
|
586
|
+
const text = comparedText(sent, published, placeholder, key, names, contentType, boundaries, outputJson);
|
|
587
|
+
if (text) return text.matches;
|
|
588
|
+
}
|
|
589
|
+
return anyObject(published) ? isObject(sent)
|
|
590
|
+
: anyString(published) ? typeof sent === 'string'
|
|
591
|
+
: isRenderedScalar(published) ? matchesRenderedScalar(published, sent)
|
|
592
|
+
: typeof published === 'string' && typeof sent === 'string' && textPattern(published, names, sent, stringLeaf, boundaries) ? textPattern(published, names, sent, stringLeaf, boundaries)!.test(filled(sent, requestCaptures))
|
|
593
|
+
: typeof published === 'string' && isPlaceholder(placeholder, published, key) ? typeof sent === 'string'
|
|
594
|
+
: Array.isArray(published) ? Array.isArray(sent) && sent.length === published.length && published.every((p, i) => same(sent[i], p, placeholder, key, names, true))
|
|
595
|
+
: published !== null && typeof published === 'object' ? sent !== null && typeof sent === 'object' && !Array.isArray(sent) && canonical(Object.keys(sent).sort()) === canonical(Object.keys(published).sort()) && Object.entries(published).every(([k, p]) => same((sent as Record<string, unknown>)[k], p, placeholder, k, names, true))
|
|
596
|
+
: sent === published;
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
/** A value with each dot path removed (`a.b`; `a[].b` in every element of the array `a`). */
|
|
600
|
+
function withoutPaths(v: unknown, paths: string[]): unknown {
|
|
601
|
+
// Symbols cannot be forged by JSON or structured-cloned; preserve only the renderer's marker.
|
|
602
|
+
const clone = (value: unknown): unknown => renderedMarker(value) ? value : Array.isArray(value) ? value.map(clone)
|
|
603
|
+
: value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([k, x]) => [k, clone(x)])) : value;
|
|
604
|
+
const out = clone(v);
|
|
605
|
+
const drop = (node: unknown, keys: string[]): void => {
|
|
606
|
+
if (!node || typeof node !== 'object' || !keys.length) return;
|
|
607
|
+
const [head, ...rest] = keys;
|
|
608
|
+
const every = head!.endsWith('[]');
|
|
609
|
+
const key = every ? head!.slice(0, -2) : head!;
|
|
610
|
+
const obj = node as Record<string, unknown>;
|
|
611
|
+
if (!rest.length && !every) { delete obj[key]; return; }
|
|
612
|
+
if (every) { for (const el of (Array.isArray(obj[key]) ? obj[key] : []) as unknown[]) drop(el, rest); return; }
|
|
613
|
+
drop(obj[key], rest);
|
|
614
|
+
};
|
|
615
|
+
for (const p of paths) drop(out, p.split('.'));
|
|
616
|
+
return out;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Whether a request schema requires the field at a dot path (`a[].b`: `b` of each item of `a`). */
|
|
620
|
+
function requiredAt(schema: SpecSchema | undefined, path: string): boolean {
|
|
621
|
+
let at = resolveSchema(schema).schema;
|
|
622
|
+
const keys = path.split('.');
|
|
623
|
+
for (const [i, raw] of keys.entries()) {
|
|
624
|
+
const key = raw.replace(/\[\]$/, '');
|
|
625
|
+
if (!at) return false;
|
|
626
|
+
if (i === keys.length - 1) return Array.isArray(at.required) && (at.required as string[]).includes(key);
|
|
627
|
+
let next = resolveSchema(at.properties?.[key]).schema;
|
|
628
|
+
if (raw.endsWith('[]')) next = resolveSchema(next?.items).schema;
|
|
629
|
+
at = next;
|
|
630
|
+
}
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Callers compare JSON text bodies by keys, types and array lengths, confining this pattern to corresponding string
|
|
635
|
+
* leaves. In other text an unbound span excludes its following literal's first character, unescaped double quotes
|
|
636
|
+
* inside a quoted value, record delimiter lines, and line breaks unless that published quoted value contains them. Adjacent unbound spans
|
|
637
|
+
* coalesce; a final span reaches the end of its value. Known bindings constrain splitting first and compare literally;
|
|
638
|
+
* surrounding literals and each whole string match exactly. */
|
|
639
|
+
const textPattern = (text: string, names: string[] = [], sent?: string, stringLeaf = false, boundaries: string[] = []): RegExp | undefined => {
|
|
640
|
+
const escape = (s: string) => s.replace(/[.*+?^$(){}|[\]\\]/g, '\\$&');
|
|
641
|
+
const marker = new RegExp('<[A-Za-z_][\\w-]*>' + (names.length ? '|' + [...names].sort((a, b) => b.length - a.length).map(escape).join('|') : ''), 'g');
|
|
642
|
+
const hits = [...text.matchAll(marker)];
|
|
643
|
+
if (!hits.length) return undefined;
|
|
644
|
+
const valueContext = (index: number): { quoted: boolean; multiline: boolean } => {
|
|
645
|
+
let opening = -1;
|
|
646
|
+
let escaped = false;
|
|
647
|
+
for (let i = 0; i < index; i++) {
|
|
648
|
+
if (escaped) { escaped = false; continue; }
|
|
649
|
+
if (text[i] === '\\') { escaped = true; continue; }
|
|
650
|
+
if (text[i] === '"') opening = opening < 0 ? i : -1;
|
|
651
|
+
}
|
|
652
|
+
if (opening < 0) return { quoted: false, multiline: false };
|
|
653
|
+
let end = text.length;
|
|
654
|
+
escaped = false;
|
|
655
|
+
for (let i = index; i < text.length; i++) {
|
|
656
|
+
if (escaped) { escaped = false; continue; }
|
|
657
|
+
if (text[i] === '\\') { escaped = true; continue; }
|
|
658
|
+
if (text[i] === '"') { end = i; break; }
|
|
659
|
+
}
|
|
660
|
+
return { quoted: true, multiline: /[\r\n]/.test(text.slice(opening + 1, end)) };
|
|
661
|
+
};
|
|
662
|
+
const spans: Array<{ literal: string; value?: string; quoted: boolean; multiline: boolean }> = [];
|
|
663
|
+
for (const [i, hit] of hits.entries()) {
|
|
664
|
+
const literal = text.slice(i ? hits[i - 1]!.index! + hits[i - 1]![0].length : 0, hit.index);
|
|
665
|
+
const value = requestCaptures[hit[0].replace(/^<|>$/g, '')];
|
|
666
|
+
if (spans.length && !literal && value === undefined && spans.at(-1)!.value === undefined) continue;
|
|
667
|
+
spans.push({ literal, value, ...valueContext(hit.index!) });
|
|
668
|
+
}
|
|
669
|
+
const tail = text.slice(hits.at(-1)!.index! + hits.at(-1)![0].length);
|
|
670
|
+
// Unchanged published text needs no inferred replacements (angle-bracket markup can itself be literal text).
|
|
671
|
+
// Known bindings still take precedence: copying a bound placeholder's spelling is not sending its value.
|
|
672
|
+
if (sent === text && spans.every((span) => span.value === undefined)) return new RegExp('^' + escape(text) + '(?![\\s\\S])');
|
|
673
|
+
const character = (s: string) => s.replace(/[\\\]\-^]/g, '\\$&');
|
|
674
|
+
const excluding = (chars: string): string => chars ? '[^' + character(chars) + ']' : '[\\s\\S]';
|
|
675
|
+
// Guard every consumed character, including escaped characters: framing found anywhere in the published
|
|
676
|
+
// text cannot be swallowed by a multiline value even when the body is too incomplete for a framed reader.
|
|
677
|
+
const delimiterLines = text.split(/\r\n|\n|\r/).filter((line) => line.startsWith('--'));
|
|
678
|
+
const stops = delimiterLines.map((line) => multipartDelimiterPattern(line.slice(2).replace(/[ \t]+$/, '')));
|
|
679
|
+
stops.push(...boundaries.map(multipartDelimiterPattern));
|
|
680
|
+
if (/(?:^|[\r\n])data:/.test(text)) stops.push('(?:^|(?<=[\\r\\n]))data:');
|
|
681
|
+
if (/\r\n\r\n|\n\n|\r\r/.test(text)) stops.push('\\r\\n\\r\\n|\\n\\n|\\r\\r');
|
|
682
|
+
const guard = !stringLeaf && stops.length ? '(?!(?:' + stops.join('|') + '))' : '';
|
|
683
|
+
const guarded = (character: string): string => guard + character;
|
|
684
|
+
const unbound = spans.map((span, i) => {
|
|
685
|
+
const following = spans.slice(i + 1).find((next) => next.literal)?.literal ?? tail;
|
|
686
|
+
const delimiter = following[0] ?? '';
|
|
687
|
+
const breaks = !stringLeaf && !span.multiline ? '\r\n' : '';
|
|
688
|
+
if (stringLeaf || !span.quoted) return '(?:' + guarded(excluding(delimiter + breaks)) + ')*?';
|
|
689
|
+
// A backslash escape stays within this quoted value; an unescaped quote ends it.
|
|
690
|
+
const escaped = guarded('\\\\') + guarded(excluding((delimiter === '"' || delimiter === '\\' ? '' : delimiter) + breaks));
|
|
691
|
+
return '(?:' + escaped + '|' + guarded(excluding(delimiter + breaks + '"\\')) + ')*?';
|
|
692
|
+
});
|
|
693
|
+
// Resolve templates against values held before this request, not captures overwritten by its answer.
|
|
694
|
+
const end = escape(tail) + '(?![\\s\\S])';
|
|
695
|
+
const extraction = spans.map((span, i) => escape(span.literal) + '(' + (span.value === undefined ? unbound[i] : escape(span.value)) + ')').join('');
|
|
696
|
+
const inferred = sent === undefined ? undefined : new RegExp('^' + extraction + end).exec(filled(sent, requestCaptures));
|
|
697
|
+
const pattern = spans.map((span, i) => {
|
|
698
|
+
const value = span.value ?? inferred?.[i + 1];
|
|
699
|
+
return escape(span.literal) + (value === undefined ? unbound[i] : escape(value));
|
|
700
|
+
}).join('');
|
|
701
|
+
return new RegExp('^' + pattern + end);
|
|
702
|
+
};
|
|
703
|
+
/** Whether `whole` holds the published fields. Parseable JSON text bodies require the same complete structure;
|
|
704
|
+
* placeholders compare only corresponding string leaves. Structured partial inputs retain their field subset rule. */
|
|
705
|
+
const holds = (whole: unknown, part: unknown, placeholder?: RegExp, key?: string, names?: string[], stringLeaf = false, contentType?: string, boundaries: string[] = []): boolean => {
|
|
706
|
+
if (!stringLeaf && typeof part === 'string') {
|
|
707
|
+
const text = comparedText(whole, part, placeholder ?? /(?!)/, key, names, contentType, boundaries);
|
|
708
|
+
if (text) return text.matches;
|
|
709
|
+
}
|
|
710
|
+
return anyObject(part) ? isObject(whole)
|
|
711
|
+
: anyString(part) ? typeof whole === 'string'
|
|
712
|
+
: isRenderedScalar(part) ? matchesRenderedScalar(part, whole)
|
|
713
|
+
: typeof part === 'string' && typeof whole === 'string' && textPattern(part, names, whole, stringLeaf, boundaries) ? textPattern(part, names, whole, stringLeaf, boundaries)!.test(filled(whole, requestCaptures))
|
|
714
|
+
: typeof part === 'string' && placeholder && isPlaceholder(placeholder, part, key) ? typeof whole === 'string'
|
|
715
|
+
: Array.isArray(part) ? same(whole, part, placeholder ?? /(?!)/, key, names, true)
|
|
716
|
+
: part !== null && typeof part === 'object' && !Array.isArray(part)
|
|
717
|
+
? whole !== null && typeof whole === 'object' && !Array.isArray(whole) && Object.entries(part).every(([k, v]) => holds((whole as Record<string, unknown>)[k], v, placeholder, k, names, true))
|
|
718
|
+
: whole === part;
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
/** Every string and number in a value, by its dot path (`a.0.b`), as `expect` paths name them. */
|
|
722
|
+
const leaves = (v: unknown, prefix = ''): Array<[string, string | number]> =>
|
|
723
|
+
typeof v === 'string' || typeof v === 'number' ? [[prefix, v]]
|
|
724
|
+
: v && typeof v === 'object' ? Object.entries(v).flatMap(([k, x]) => leaves(x, prefix ? `${prefix}.${k}` : k)) : [];
|
|
725
|
+
|
|
726
|
+
const kind = (v: unknown): string => (v === null ? 'null' : Array.isArray(v) ? 'array' : isExactIntegerValue(v) ? 'number' : typeof v);
|
|
727
|
+
|
|
728
|
+
/** A schema with its reference followed; a union's object alternative stands for it (an expandable field is `anyOf:
|
|
729
|
+
* [string, the object]`), and `nullable` says whether the schema or any alternative admits null. */
|
|
730
|
+
function resolveSchema(schema: SpecSchema | undefined, depth = 0): { schema?: SpecSchema; nullable: boolean } {
|
|
731
|
+
if (!schema || depth > 8) return { nullable: true };
|
|
732
|
+
if (schema.$ref) return resolveSchema(specSchemas[schema.$ref.split('/').pop()!], depth + 1);
|
|
733
|
+
const alts = [...(schema.anyOf ?? []), ...(schema.oneOf ?? [])].map((a) => resolveSchema(a, depth + 1));
|
|
734
|
+
// (Swagger 2 says it with the `x-nullable` extension, OpenAPI 3.1 with `null` among its types)
|
|
735
|
+
const types = (schema as { type?: unknown }).type;
|
|
736
|
+
const nullable = schema.nullable === true || schema['x-nullable'] === true || types === 'null' || (Array.isArray(types) && types.includes('null')) || alts.some((a) => a.nullable);
|
|
737
|
+
const object = alts.find((a) => a.schema?.properties || a.schema?.items);
|
|
738
|
+
return { schema: alts.length ? (object?.schema ?? schema) : schema, nullable };
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** A rendered object may declare its fields through allOf references. Retain every required key. */
|
|
742
|
+
function renderedSchema(schema: SpecSchema | undefined, depth = 0): { schema?: SpecSchema; nullable: boolean } {
|
|
743
|
+
const held = resolveSchema(schema);
|
|
744
|
+
if (!held.schema?.allOf || depth > 8) return held;
|
|
745
|
+
const parts = (held.schema.allOf as SpecSchema[]).map(part => renderedSchema(part, depth + 1).schema).filter((part): part is SpecSchema => Boolean(part));
|
|
746
|
+
return { ...held, schema: { ...held.schema,
|
|
747
|
+
properties: Object.fromEntries([...new Set([...parts.flatMap(part => Object.keys(part.properties ?? {})), ...Object.keys(held.schema.properties ?? {})])].map(key => {
|
|
748
|
+
const fields = [...parts.map(part => part.properties?.[key]), held.schema?.properties?.[key]].filter((field): field is SpecSchema => Boolean(field));
|
|
749
|
+
return [key, fields.length === 1 ? fields[0] : { allOf: fields }];
|
|
750
|
+
})),
|
|
751
|
+
required: [...new Set([...parts.flatMap(part => Array.isArray(part.required) ? part.required : []), ...(Array.isArray(held.schema.required) ? held.schema.required : [])])],
|
|
752
|
+
} };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Only output null/type and collection-length decisions use these schema readers. The legacy
|
|
756
|
+
* sample/placeholder readers above, request comparison and literal/framing checks stay unchanged. */
|
|
757
|
+
function outputSchema(schema: SpecSchema | undefined, depth = 0): SpecSchema | undefined {
|
|
758
|
+
if (!schema || depth > 16) return undefined;
|
|
759
|
+
return schema.$ref ? outputSchema(specSchemas[schema.$ref.split('/').pop()!], depth + 1) : schema;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function outputChildSchema(schema: SpecSchema | undefined, key: string | undefined, depth = 0): SpecSchema | undefined {
|
|
763
|
+
const s = outputSchema(schema);
|
|
764
|
+
if (!s || depth > 16) return undefined;
|
|
765
|
+
const direct = key === undefined ? s.items : s.properties?.[key];
|
|
766
|
+
if (direct) return direct;
|
|
767
|
+
for (const union of ['allOf', 'anyOf', 'oneOf'] as const) {
|
|
768
|
+
const children = ((s[union] ?? []) as SpecSchema[]).map((part) => outputChildSchema(part, key, depth + 1)).filter((part): part is SpecSchema => part !== undefined);
|
|
769
|
+
if (children.length) return children.length === 1 ? children[0] : { [union]: children };
|
|
770
|
+
}
|
|
771
|
+
return key !== undefined && s.additionalProperties && typeof s.additionalProperties === 'object' ? s.additionalProperties as SpecSchema : undefined;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** A published null supplies no object shape: require a declared type and any enum, but do
|
|
775
|
+
* not invent property, item or value constraints from the null illustration. */
|
|
776
|
+
function outputTypeAdmits(schema: SpecSchema | undefined, value: unknown, depth = 0): boolean {
|
|
777
|
+
const s = outputSchema(schema);
|
|
778
|
+
if (!s || depth > 16) return false;
|
|
779
|
+
if (Array.isArray(s.enum) && !s.enum.some((member) => canonical(member) === canonical(value))) return false;
|
|
780
|
+
const alternatives = [...(s.anyOf ?? []), ...(s.oneOf ?? [])];
|
|
781
|
+
const types = Array.isArray(s.type) ? s.type : typeof s.type === 'string' ? [s.type] : [];
|
|
782
|
+
const admits = (type: unknown): boolean => type === 'integer' ? isExactIntegerValue(value) : type === kind(value);
|
|
783
|
+
if (types.length && !types.some(admits)) return false;
|
|
784
|
+
if (alternatives.length && !alternatives.some((part) => outputTypeAdmits(part, value, depth + 1))) return false;
|
|
785
|
+
const parts = (s.allOf ?? []) as SpecSchema[];
|
|
786
|
+
const declared = types.length > 0 || alternatives.length > 0 || parts.some((part) => outputTypeAdmits(part, value, depth + 1));
|
|
787
|
+
return declared && parts.every((part) => {
|
|
788
|
+
const held = outputSchema(part);
|
|
789
|
+
return held !== undefined && (held.type === undefined && !held.anyOf && !held.oneOf && !held.allOf
|
|
790
|
+
? !Array.isArray(held.enum) || held.enum.some((member) => canonical(member) === canonical(value))
|
|
791
|
+
: outputTypeAdmits(part, value, depth + 1));
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** Further shape checks for a value admitted against a sample's null. These use the served
|
|
796
|
+
* schema, including each applicable union branch, rather than manufacturing an example object. */
|
|
797
|
+
function outputSchemaMismatches(schema: SpecSchema | undefined, value: unknown, prefix: string, depth = 0): Array<{ path: string; detail: string }> {
|
|
798
|
+
const s = outputSchema(schema);
|
|
799
|
+
if (!s || depth > 16) return [{ path: prefix, detail: 'has no known served schema' }];
|
|
800
|
+
if (value === null && s.nullable) return [];
|
|
801
|
+
if ((s.type !== undefined || s.anyOf || s.oneOf) && !outputTypeAdmits(s, value)) return [{ path: prefix, detail: `is ${kind(value)}, not an admitted served schema type or enum value` }];
|
|
802
|
+
if (Array.isArray(s.enum) && !s.enum.some((member) => canonical(member) === canonical(value))) return [{ path: prefix, detail: 'is not in the served schema enum' }];
|
|
803
|
+
const out = ((s.allOf ?? []) as SpecSchema[]).flatMap((part) => outputSchemaMismatches(part, value, prefix, depth + 1));
|
|
804
|
+
for (const alternatives of [s.anyOf, s.oneOf]) {
|
|
805
|
+
if (!alternatives?.length) continue;
|
|
806
|
+
const choices = alternatives.map((part) => outputSchemaMismatches(part, value, prefix, depth + 1));
|
|
807
|
+
if (!choices.some((choice) => choice.length === 0)) out.push(...choices.reduce((best, choice) => choice.length < best.length ? choice : best));
|
|
808
|
+
}
|
|
809
|
+
if (isObject(value)) {
|
|
810
|
+
const object = value as Record<string, unknown>;
|
|
811
|
+
for (const key of (Array.isArray(s.required) ? s.required : []) as string[]) {
|
|
812
|
+
if (!Object.hasOwn(object, key)) out.push({ path: `${prefix}.${key}`, detail: 'is absent, and the served schema requires it' });
|
|
813
|
+
}
|
|
814
|
+
for (const [key, item] of Object.entries(object)) {
|
|
815
|
+
const field = s.properties?.[key] ?? (typeof s.additionalProperties === 'object' ? s.additionalProperties as SpecSchema : undefined);
|
|
816
|
+
if (field) out.push(...outputSchemaMismatches(field, item, `${prefix}.${key}`, depth + 1));
|
|
817
|
+
else if (s.additionalProperties === false) out.push({ path: `${prefix}.${key}`, detail: 'is not an admitted served schema property' });
|
|
818
|
+
}
|
|
819
|
+
} else if (Array.isArray(value) && s.items) {
|
|
820
|
+
const fixed = fixedOutputLength(s);
|
|
821
|
+
if (fixed !== undefined && value.length !== fixed) out.push({ path: prefix, detail: `has ${value.length} items, the served spec fixes the length at ${fixed}` });
|
|
822
|
+
value.forEach((item, i) => out.push(...outputSchemaMismatches(Array.isArray(s.items) ? s.items[i] : s.items, item, `${prefix}.${i}`, depth + 1)));
|
|
823
|
+
}
|
|
824
|
+
return out;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** List fields not exposed as a resource list by the derived surface. Each is a read of
|
|
828
|
+
* existing state, not a request-minted collection; the pinned operation/field evidence is
|
|
829
|
+
* recorded with the per-example classification. Request echoes and generated output are absent. */
|
|
830
|
+
const storedCollectionFields: Record<string, Record<string, string[]>> = {
|
|
831
|
+
'ai-gateway': { listModels: ['data'] },
|
|
832
|
+
aws: { ListBuckets: ['Buckets'], ListMultipartUploads: ['Uploads'], ListObjects: ['Contents', 'CommonPrefixes'], ListObjectsV2: ['Contents', 'CommonPrefixes'] },
|
|
833
|
+
cloudflare: { ListBuckets: ['Buckets'], ListMultipartUploads: ['Uploads'], ListObjects: ['Contents', 'CommonPrefixes'], ListObjectsV2: ['Contents', 'CommonPrefixes'] },
|
|
834
|
+
discord: { oauth2_token: ['guild.roles'], get_my_oauth2_authorization: ['scopes'] },
|
|
835
|
+
github: { 'repos/get-branch': ['commit.parents'], 'repos/get-commit': ['parents', 'files'], 'repos/get-content': ['', 'entries'], 'repos/compare-commits': ['commits', 'files'], 'git/get-tree': ['tree'], 'git/list-matching-refs': [''], 'repos/get-repo-rulesets': [''] },
|
|
836
|
+
hackernews: { getItem: ['kids'], getUser: ['submitted'], getUpdates: ['items', 'profiles'], getTopStories: [''], getNewStories: [''], getBestStories: [''], getAskStories: [''], getShowStories: [''], getJobStories: [''] },
|
|
837
|
+
linkedin: { findOrganizationAclsByRoleAssignee: ['elements'] },
|
|
838
|
+
'npm-registry': { 'libnpmsearch.searchStream': ['objects', 'objects.[].package.maintainers'] },
|
|
839
|
+
openrouter: { getCurrentKey: ['data.allowed_data_regions'], getModels: ['data'] },
|
|
840
|
+
redis: { SCAN: ['[].1'] },
|
|
841
|
+
resend: { 'domains/get': ['records'], 'emails/list-receiving': ['data', 'data.[].attachments'] },
|
|
842
|
+
sentry: { listOrganizations: ['', '[].features'] },
|
|
843
|
+
slack: { conversations_rename: ['channel.previous_names'], conversations_invite: ['channel.previous_names'], conversations_members: ['members'], conversations_list: ['channels', 'channels.[].previous_names'], users_conversations: ['channels', 'channels.[].previous_names'], conversations_info: ['channel.previous_names'], conversations_replies: ['messages'], conversations_history: ['messages'], usergroups_list: ['usergroups'] },
|
|
844
|
+
tinybird: { datasources: ['datasources'], jobs: ['jobs'], pipes: ['pipes'], tokens: ['tokens'], token_get: ['scopes'], token_refresh: ['scopes'], pipe_set_endpoint: ['nodes'] },
|
|
845
|
+
};
|
|
846
|
+
let comparisonCollections: string[] = [];
|
|
847
|
+
function storedCollection(prefix: string): boolean {
|
|
848
|
+
const path = prefix.replace(/^\./, '').split('.');
|
|
849
|
+
return comparisonCollections.some((pattern) => {
|
|
850
|
+
const parts = pattern.split('.');
|
|
851
|
+
return parts.length === path.length && parts.every((part, i) => part === '[]' ? /^\d+$/.test(path[i]!) : part === path[i]);
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function fixedOutputLength(schema: SpecSchema | undefined, depth = 0): number | undefined {
|
|
856
|
+
const s = outputSchema(schema);
|
|
857
|
+
if (!s || depth > 16) return undefined;
|
|
858
|
+
if (typeof s.minItems === 'number' && s.minItems === s.maxItems) return s.minItems;
|
|
859
|
+
const parts = (s.allOf ?? []) as SpecSchema[];
|
|
860
|
+
const bounds = parts.map((part) => outputSchema(part)).filter((part): part is SpecSchema => part !== undefined);
|
|
861
|
+
const min = Math.max(Number(s.minItems ?? 0), ...bounds.map((part) => Number(part.minItems ?? 0)));
|
|
862
|
+
const max = Math.min(Number(s.maxItems ?? Infinity), ...bounds.map((part) => Number(part.maxItems ?? Infinity)));
|
|
863
|
+
if (min === max) return min;
|
|
864
|
+
const fixed = parts.map((part) => fixedOutputLength(part, depth + 1)).find((length) => length !== undefined);
|
|
865
|
+
if (fixed !== undefined) return fixed;
|
|
866
|
+
const alternatives = [...(s.anyOf ?? []), ...(s.oneOf ?? [])];
|
|
867
|
+
const lengths = alternatives.map((part) => fixedOutputLength(part, depth + 1));
|
|
868
|
+
return lengths.length > 0 && lengths.every((length) => length !== undefined && length === lengths[0]) ? lengths[0] : undefined;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** Each key path of the example's answer the twin's answer lacks, or holds as another JSON type. A sample's field the
|
|
872
|
+
* twin answers null is a mismatch only where the served spec does not let it be null (a payout's `destination`, a
|
|
873
|
+
* preview invoice's `id`: each passed as a sample twice before a judge read the spec). */
|
|
874
|
+
let comparisonPlaceholder: RegExp | undefined;
|
|
875
|
+
let comparisonNames: string[] | undefined;
|
|
876
|
+
function mismatches(example: unknown, got: unknown, prefix = '', sample = false, schema?: SpecSchema, literal = prefix === '' && (example === null || typeof example !== 'object'), contentType?: string, valueSchema = schema): Array<{ path: string; detail: string }> {
|
|
877
|
+
if (example === undefined) return [];
|
|
878
|
+
if (example === null) {
|
|
879
|
+
if (got === null) return [];
|
|
880
|
+
if (sample && outputTypeAdmits(valueSchema, got)) return outputSchemaMismatches(valueSchema, got, prefix || '(the answer)');
|
|
881
|
+
return [{ path: prefix || '(the answer)', detail: `is ${kind(got)}, the published answer is null` }];
|
|
882
|
+
}
|
|
883
|
+
if (sample && got === null) return schema && !resolveSchema(schema).nullable ? [{ path: prefix || '(the answer)', detail: 'is null, and the served spec does not let it be null' }] : [];
|
|
884
|
+
if (isRenderedScalar(example)) return matchesRenderedScalar(example, got) ? [] : [{ path: prefix || '(the answer)', detail: `is ${kind(got)}, unlike the published schema-rendered scalar` }];
|
|
885
|
+
if (anyObject(example)) return isObject(got) ? [] : [{ path: prefix || '(the answer)', detail: `is ${kind(got)}, the example's is an object` }];
|
|
886
|
+
if (anyString(example)) return typeof got === 'string' ? [] : [{ path: prefix || '(the answer)', detail: `is ${kind(got)}, the example's is a string` }];
|
|
887
|
+
if (kind(example) !== kind(got)) return [{ path: prefix || '(the answer)', detail: `is ${kind(got)}, the example's is ${kind(example)}` }];
|
|
888
|
+
if (Array.isArray(example)) {
|
|
889
|
+
const list = got as unknown[];
|
|
890
|
+
const fixed = fixedOutputLength(valueSchema);
|
|
891
|
+
const stored = storedCollection(prefix);
|
|
892
|
+
const collection = example.length > 0 && (stored ? example.every((item) => !Array.isArray(item)) : sample && example.every(isObject));
|
|
893
|
+
if (fixed !== undefined && list.length !== fixed) return [{ path: prefix, detail: `has ${list.length} items, the served spec fixes the length at ${fixed}` }];
|
|
894
|
+
// an array of unfilled type words (`["string"]`) is the schema renderer's, not an answer: its length is not data; and a
|
|
895
|
+
// spec's sample list of scalars (a tier's name) illustrates its items, never their count: each item the answer holds
|
|
896
|
+
// takes the sample's type, and an empty list is a state the sample does not deny
|
|
897
|
+
const scalars = sample && !stored && example.every((item) => item === null || typeof item !== 'object');
|
|
898
|
+
if ((example.length > 0 && example.every(renderedMarker)) || scalars) return list.flatMap((item, i) => mismatches(example[0], item, `${prefix}.${i}`, sample, resolveSchema(schema).schema?.items, literal, contentType, outputChildSchema(valueSchema, undefined)));
|
|
899
|
+
if (!collection && list.length !== example.length) return [{ path: prefix, detail: `has ${list.length} items, the published answer has ${example.length}` }];
|
|
900
|
+
if (!example.length) return [];
|
|
901
|
+
if (!list.length) return [{ path: prefix, detail: 'nothing to compare: the collection is empty' }];
|
|
902
|
+
const itemSchema = resolveSchema(schema).schema?.items;
|
|
903
|
+
const itemValueSchema = outputChildSchema(valueSchema, undefined);
|
|
904
|
+
if (collection && stored) return list.flatMap((item, i) => {
|
|
905
|
+
const choices = example.map((published) => mismatches(published, item, `${prefix}.${i}`, sample, itemSchema, literal, contentType, itemValueSchema));
|
|
906
|
+
return choices.some((choice) => choice.length === 0) ? [] : choices.reduce((best, choice) => choice.length < best.length ? choice : best);
|
|
907
|
+
});
|
|
908
|
+
return list.flatMap((item, i) => {
|
|
909
|
+
if (i < example.length) return mismatches(example[i], item, `${prefix}.${i}`, sample, itemSchema, literal, contentType, itemValueSchema);
|
|
910
|
+
return mismatches(example[0], item, `${prefix}.${i}`, sample, itemSchema, literal, contentType, itemValueSchema);
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
if (typeof example !== 'object') return !literal || same(got, example, comparisonPlaceholder ?? /(?!)/, prefix.split('.').at(-1), comparisonNames, false, contentType, [], (expected, actual) => mismatches(expected, actual, '', sample, schema, undefined, undefined, valueSchema).length === 0) ? [] : [{ path: prefix || '(the answer)', detail: `is ${JSON.stringify(got)}, the published value is ${JSON.stringify(example)}` }];
|
|
914
|
+
const out: Array<{ path: string; detail: string }> = [];
|
|
915
|
+
for (const [k, v] of Object.entries(example as Record<string, unknown>)) {
|
|
916
|
+
const p = prefix ? `${prefix}.${k}` : k;
|
|
917
|
+
const g = (got as Record<string, unknown>)[k];
|
|
918
|
+
// a sample's field the served spec does not require may be absent from one request's answer (a card's
|
|
919
|
+
// `extended_authorization` where the charge used none); anywhere else an absent key is a mismatch
|
|
920
|
+
// (a schema with no `required` list requires none of its properties)
|
|
921
|
+
const held = sample && schema ? renderedSchema(schema).schema : undefined;
|
|
922
|
+
const optional = Boolean(held?.properties?.[k]) && !(Array.isArray(held?.required) && (held.required as string[]).includes(k));
|
|
923
|
+
if (g === undefined) { if (!optional) out.push({ path: p, detail: 'is absent' }); }
|
|
924
|
+
else out.push(...mismatches(v, g, p, sample, renderedSchema(schema).schema?.properties?.[k], literal, contentType, outputChildSchema(valueSchema, k)));
|
|
925
|
+
}
|
|
926
|
+
return out;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const vendor = process.argv[2];
|
|
930
|
+
if (!vendor || vendor.startsWith('-')) { console.error('usage: twin-standard examples <vendor> [--sweep] (from the pack repository\'s root)'); process.exit(1); }
|
|
931
|
+
const PKG = join(PACKS, vendor);
|
|
932
|
+
const file = join(PKG, 'journeys', 'vendor-examples.json');
|
|
933
|
+
await loadPlaceholders(PKG);
|
|
934
|
+
const examples = published(PKG);
|
|
935
|
+
const byId = new Map(examples.map((e) => [e.id, e]));
|
|
936
|
+
|
|
937
|
+
if (byId.size !== examples.length) throw new Error(`${vendor}: ${examples.length - byId.size} published examples share an id`);
|
|
938
|
+
const journey = expandFills((existsSync(file) ? parseExactJson(readFileSync(file, 'utf8')) : { vendor, about: '', steps: [] }) as Journey & { notReplayed?: Record<string, string> });
|
|
939
|
+
// The architecture allows the exact example id as a step's name. Normalize only the replay copy; verdict hashes
|
|
940
|
+
// continue to name the authored file's bytes, and an explicit example field always wins.
|
|
941
|
+
journey.steps = journey.steps.map((step) => {
|
|
942
|
+
const named = step as typeof step & { example?: string };
|
|
943
|
+
return named.example === undefined && byId.has(step.name) ? { ...step, example: step.name } : step;
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
const shape = await shapeChecker(PKG);
|
|
947
|
+
// a page's example that names its request is the operation the spec routes that request to, never the author's guess
|
|
948
|
+
// (a page of an operation the spec lacks can match another's route)
|
|
949
|
+
for (const e of examples) {
|
|
950
|
+
if (e.patchRemoved || !e.request || !shape) continue;
|
|
951
|
+
const routed = shape.operationOf(e.request.method, e.request.url, {})?.op.id;
|
|
952
|
+
if (e.operation === 'gap' && routed === undefined) continue;
|
|
953
|
+
if (routed !== e.operation) throw new Error(`spec/doc-examples.json: "${e.id}" sends ${e.request.method} ${e.request.url}, which the spec routes to ${routed ?? 'no operation'}, not ${e.operation}`);
|
|
954
|
+
}
|
|
955
|
+
const sweeping = process.argv.includes('--sweep');
|
|
956
|
+
const sweepHits: string[] = [];
|
|
957
|
+
const ACTOR = (k: string, parent: string): boolean => ['creator', 'user', 'inviter', 'inviter_id', 'actor_id', 'share_user_id', 'parent_user_id'].includes(k) || k.endsWith('_by') || (k === 'user' && parent.endsWith('edited'));
|
|
958
|
+
const SKIP = /(^|\.)(blocks|attachments|elements|scopes|response_metadata|warnings)(\.|$)/;
|
|
959
|
+
/** Actor fields no `equals` pins and lists no expect closes, in one answer. */
|
|
960
|
+
function sweep(body: unknown, expects: NonNullable<JourneyStep['expect']>): string[] {
|
|
961
|
+
// an `equals`, `contains` or `members` pins its path and all under it (an empty path, the whole answer)
|
|
962
|
+
const pinned = (p: string): boolean => expects.some((e) => ('equals' in e || 'contains' in e || 'members' in e) && (!e.path || e.path === p || p.startsWith(`${e.path}.`)));
|
|
963
|
+
const closed = (p: string, n: number): boolean => expects.some((e) => e.exists === false && e.path === `${p}.${n}`);
|
|
964
|
+
const out: string[] = [];
|
|
965
|
+
const go = (v: unknown, p: string, parent: string): void => {
|
|
966
|
+
if (Array.isArray(v)) {
|
|
967
|
+
if (p && !SKIP.test(p) && !pinned(p) && !closed(p, v.length)) out.push(`list ${p} (${v.length})`);
|
|
968
|
+
v.forEach((x, i) => go(x, p ? `${p}.${i}` : String(i), p));
|
|
969
|
+
} else if (v && typeof v === 'object') {
|
|
970
|
+
for (const [k, x] of Object.entries(v)) {
|
|
971
|
+
const q = p ? `${p}.${k}` : k;
|
|
972
|
+
if (ACTOR(k, p) && (typeof x === 'string' || x === null) && !SKIP.test(q) && !pinned(q)) out.push(`actor ${q} = ${JSON.stringify(x)}`);
|
|
973
|
+
go(x, q, p);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
go(body, '', '');
|
|
978
|
+
return out;
|
|
979
|
+
}
|
|
980
|
+
/** The example's actual request fields, including a multipart upload carried as raw journey bytes. */
|
|
981
|
+
function exampleBody(step: JourneyStep, published?: unknown): unknown {
|
|
982
|
+
const decoded = (fields: Record<string, unknown>): Record<string, unknown> => {
|
|
983
|
+
for (const [key, expected] of Object.entries((published && typeof published === 'object' ? published : {}) as Record<string, unknown>)) {
|
|
984
|
+
const value = fields[key];
|
|
985
|
+
if (expected && typeof expected === 'object' && typeof value === 'string') {
|
|
986
|
+
try { fields[key] = parseExactJson(value); } catch { /* malformed structured field remains a mismatch */ }
|
|
987
|
+
} else if (typeof expected === 'string' && Array.isArray(value) && value.length === 1) fields[key] = value[0];
|
|
988
|
+
}
|
|
989
|
+
return fields;
|
|
990
|
+
};
|
|
991
|
+
const type = Object.entries({ ...journey.headers, ...step.headers }).find(([k]) => k.toLowerCase() === 'content-type')?.[1];
|
|
992
|
+
if (type?.split(';')[0]?.trim() === 'application/x-www-form-urlencoded' && typeof step.body === 'string') {
|
|
993
|
+
return decoded(parseBracketForm(step.body));
|
|
994
|
+
}
|
|
995
|
+
// a form the walk builds from its parts (journey-kit's `form`): its text fields, and each file by its name
|
|
996
|
+
if (step.form) {
|
|
997
|
+
const out: Record<string, unknown> = {};
|
|
998
|
+
// Match the form decoder: one text part is a scalar; repeated parts retain their order.
|
|
999
|
+
const append = (name: string, value: unknown, file = false): void => {
|
|
1000
|
+
out[name] = Object.hasOwn(out, name) ? [...(Array.isArray(out[name]) ? out[name] as unknown[] : [out[name]]), value] : file ? [value] : value;
|
|
1001
|
+
};
|
|
1002
|
+
for (const field of step.form.fields === undefined ? [] : parseExactJson(filled(step.form.fields)) as Array<{ name: string; value: string }>) append(field.name, field.value);
|
|
1003
|
+
for (const p of step.form.parts) {
|
|
1004
|
+
if (!('filename' in p)) append(p.name, p.value);
|
|
1005
|
+
else if (p.base64 || p.png || p.value) append(p.name, p.filename, true);
|
|
1006
|
+
}
|
|
1007
|
+
return decoded(out);
|
|
1008
|
+
}
|
|
1009
|
+
// Raw published MIME bytes retain part counts and headers instead of becoming a lossy field map.
|
|
1010
|
+
if (/^multipart\//i.test(type ?? '') && typeof published === 'string') return step.bodyBase64 === undefined ? step.body : Buffer.from(step.bodyBase64, 'base64').toString('utf8');
|
|
1011
|
+
const boundary = multipartBoundary(type ?? null);
|
|
1012
|
+
if (!boundary || (step.bodyBase64 === undefined && typeof step.body !== 'string')) return step.body;
|
|
1013
|
+
const envelope = multipartEnvelope(step.bodyBase64 !== undefined ? Uint8Array.from(Buffer.from(step.bodyBase64, 'base64')) : new TextEncoder().encode(step.body as string), boundary);
|
|
1014
|
+
if (!envelope.complete) return undefined;
|
|
1015
|
+
const out: Record<string, unknown> = {};
|
|
1016
|
+
for (const part of envelope.parts) {
|
|
1017
|
+
const value = part.filename === null ? new TextDecoder().decode(part.body) : part.body.length ? part.filename : undefined;
|
|
1018
|
+
if (value === undefined) continue;
|
|
1019
|
+
// OpenAPI's file arrays repeat the field name, even when one file is sent.
|
|
1020
|
+
if (part.filename !== null) out[part.name] = [...(Array.isArray(out[part.name]) ? out[part.name] as unknown[] : Object.hasOwn(out, part.name) ? [out[part.name]] : []), value];
|
|
1021
|
+
else out[part.name] = Object.hasOwn(out, part.name) ? [...(Array.isArray(out[part.name]) ? out[part.name] as unknown[] : [out[part.name]]), value] : value;
|
|
1022
|
+
}
|
|
1023
|
+
return decoded(out);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** The values a request sends, its templates filled from the captures so far: its JSON leaves, or a form body's fields;
|
|
1027
|
+
* a template still unfilled is no value; one shorter than six characters is matched with its field's name (namedScalars). */
|
|
1028
|
+
const captures: Record<string, string> = {};
|
|
1029
|
+
let requestCaptures: Record<string, string> = {};
|
|
1030
|
+
const filled = (v: string, values = captures): string => v.replace(/\{([A-Za-z_][A-Za-z0-9_.]*)\}/g, (m, k: string) => values[k] ?? m);
|
|
1031
|
+
const sentValues = (body: unknown): string[] => {
|
|
1032
|
+
const form = typeof body === 'string' && body.includes('=') && !body.trim().startsWith('{') ? [...new URLSearchParams(body).values()] : undefined;
|
|
1033
|
+
return (form ?? leaves(body ?? {}).map(([, v]) => String(v))).map((v) => filled(v)).filter((v) => v.length >= 6 && !/^\d+$/.test(v) && !/\{\w+\}/.test(v));
|
|
1034
|
+
};
|
|
1035
|
+
/** A request's booleans and numbers by their field's name (`unsubscribed=true`): a flag or count the setup sent, which an
|
|
1036
|
+
* answer carries back under the same name; matched with the name, since a bare `true` is in every answer. */
|
|
1037
|
+
const namedScalars = (v: unknown, key = ''): string[] =>
|
|
1038
|
+
typeof v === 'boolean' || typeof v === 'number' || isExactIntegerValue(v) || (typeof v === 'string' && v.length < 6 && v.length >= 2) ? (key && !/^\d+$/.test(key) ? [`${key}=${v}`] : [])
|
|
1039
|
+
: Array.isArray(v) ? v.flatMap((x) => namedScalars(x, key))
|
|
1040
|
+
: v && typeof v === 'object' ? Object.entries(v).flatMap(([k, x]) => namedScalars(x, k)) : [];
|
|
1041
|
+
const setupScalars = new Set<string>();
|
|
1042
|
+
/** The value at a dot path of an answer (`data.0.id`), as a capture reads it. */
|
|
1043
|
+
const at = selectorAt;
|
|
1044
|
+
// every value an earlier step sent, the setup's decisions an example's answer may carry back
|
|
1045
|
+
const setupSent = new Set<string>();
|
|
1046
|
+
const failures: Failure[] = [];
|
|
1047
|
+
const replayed = new Set<string>();
|
|
1048
|
+
const unknown: string[] = [];
|
|
1049
|
+
const world = inMemoryWorld(`examples-${vendor}`);
|
|
1050
|
+
// a write example whose own answer is thin (an id and little else) shows its effect only if a later step reads it: the
|
|
1051
|
+
// writes and the reads, by the resource each touched, for the sweep's read-back line
|
|
1052
|
+
const thinWrites: Array<{ step: string; base: string; id: string; from: number; deletes: boolean }> = [];
|
|
1053
|
+
const reads: Array<{ path: string; body: string }> = [];
|
|
1054
|
+
const bare = (p: string): string => p.replace(/^https?:\/\/[^/]+/, '').split('?')[0]!.replace(/\/+$/, '');
|
|
1055
|
+
await walk(journey, world.root(), failures, {
|
|
1056
|
+
beats: false,
|
|
1057
|
+
hooks: {
|
|
1058
|
+
before: (step) => {
|
|
1059
|
+
requestCaptures = { ...captures };
|
|
1060
|
+
for (const [name, value] of Object.entries((step as JourneyStep).exampleBindings ?? {})) {
|
|
1061
|
+
const bound = filled(value, captures);
|
|
1062
|
+
if (requestCaptures[name] !== undefined && requestCaptures[name] !== bound) failures.push({ step: step.name, beat: 'EXAMPLE', detail: `binding ${name} contradicts an earlier capture` });
|
|
1063
|
+
else requestCaptures[name] = bound;
|
|
1064
|
+
}
|
|
1065
|
+
},
|
|
1066
|
+
after: (step, _path, answer, stepFailures) => {
|
|
1067
|
+
if (isGit(step) || isWait(step) || isSocket(step)) return;
|
|
1068
|
+
if (!isResp(step) && !isLine(step as JourneyStep & LineStep)) {
|
|
1069
|
+
const st = step as JourneyStep;
|
|
1070
|
+
const method = (st.method ?? 'GET').toUpperCase();
|
|
1071
|
+
// a read by what the operation does (an RPC wire POSTs its reads), else by its verb
|
|
1072
|
+
const op = shape?.operationOf(method, _path, { ...(journey.headers ?? {}), ...(st.headers ?? {}) })?.op;
|
|
1073
|
+
const opClass = op?.class;
|
|
1074
|
+
const isRead = opClass ? ['retrieve', 'list', 'computed'].includes(opClass) : method === 'GET';
|
|
1075
|
+
if (isRead) reads.push({ path: bare(_path), body: JSON.stringify(answer.body ?? '') });
|
|
1076
|
+
else if (st.example && answer.status < 300 && answer.body && typeof answer.body === 'object' && !Array.isArray(answer.body) && Object.keys(answer.body).length <= 3) {
|
|
1077
|
+
const body = answer.body as Record<string, unknown>;
|
|
1078
|
+
const segs = bare(_path).split('/');
|
|
1079
|
+
// an action on a subject (`/emails/{id}/cancel`) touches the subject; a create touches the new id under the collection;
|
|
1080
|
+
// a write whose subject is neither minted nor in its path (an RPC call naming it in its body) is left to the judge
|
|
1081
|
+
const minted = typeof body.id === 'string' ? body.id : '';
|
|
1082
|
+
const idInPath = segs.some((x) => /\d/.test(x) && x.length >= 6);
|
|
1083
|
+
if (minted || idInPath) {
|
|
1084
|
+
const base = minted && !segs.includes(minted) ? `${segs.join('/')}/${minted}` : /^[a-z_-]+$/.test(segs.at(-1) ?? '') && segs.length > 2 ? segs.slice(0, -1).join('/') : segs.join('/');
|
|
1085
|
+
// only an effect the vendor gives a door to read: a GET of the subject or of its collection (a moderation has neither)
|
|
1086
|
+
const hdrs = { ...(journey.headers ?? {}), ...(st.headers ?? {}) };
|
|
1087
|
+
const gets = (at: string): boolean => shape?.operationOf('GET', at, hdrs)?.op.method.toUpperCase() === 'GET';
|
|
1088
|
+
const door = !shape || gets(base) || gets(base.split('/').slice(0, -1).join('/'));
|
|
1089
|
+
if (door) thinWrites.push({ step: st.name, base, id: base.split('/').at(-1) ?? '', from: reads.length, deletes: method === 'DELETE' || opClass === 'delete' });
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
const s = step as JourneyStep & LineStep;
|
|
1094
|
+
const earlier = [...setupSent];
|
|
1095
|
+
const earlierScalars = new Set(setupScalars);
|
|
1096
|
+
for (const v of sentValues(exampleBody(s, byId.get(s.example ?? '')?.input) ?? s.send)) setupSent.add(v);
|
|
1097
|
+
if (s.body && typeof s.body === 'object') for (const v of namedScalars(s.body)) setupScalars.add(v);
|
|
1098
|
+
// what the step captures (an id the vendor minted) is the setup's from here on: a later answer carrying it decides nothing new
|
|
1099
|
+
for (const [name, from] of Object.entries(s.capture ?? {})) {
|
|
1100
|
+
const v = captureAt(answer.body, from, answer.headers, typeof answer.body === 'string' ? answer.body : undefined);
|
|
1101
|
+
if (v !== undefined && v !== null) captures[name] = typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
1102
|
+
if ((typeof v === 'string' || typeof v === 'number') && String(v).length >= 2) setupSent.add(String(v));
|
|
1103
|
+
}
|
|
1104
|
+
if (!s.example) return;
|
|
1105
|
+
const ex = byId.get(s.example);
|
|
1106
|
+
if (!ex) { unknown.push(s.example); return; }
|
|
1107
|
+
specSchemas = schemasByOperation[ex.operation] ?? {};
|
|
1108
|
+
replayed.add(s.example);
|
|
1109
|
+
if (ex.operation === 'gap' && ex.request) {
|
|
1110
|
+
const sent = new URL(_path, `https://${journey.host}`);
|
|
1111
|
+
if (s.method !== ex.request.method || sent.href !== ex.request.url) failures.push({ step: s.name, beat: 'EXAMPLE', detail: 'the unknown request differs from its recorded method or URL' });
|
|
1112
|
+
}
|
|
1113
|
+
if (ex.authorizationAbsent && Object.keys({ ...(journey.headers ?? {}), ...(s.headers ?? {}) }).some((key) => key.toLowerCase() === 'authorization')) failures.push({ step: s.name, beat: 'EXAMPLE', detail: 'the recording has no Authorization header, but the replay sends one' });
|
|
1114
|
+
// the step calls the example's operation (the spec's, as the walk's SHAPE check reads the request)
|
|
1115
|
+
if (!isResp(step) && !isLine(step) && shape) {
|
|
1116
|
+
const called = shape.operationOf(s.method, _path, { ...(journey.headers ?? {}), ...(s.headers ?? {}) })?.op.id;
|
|
1117
|
+
if (called && called !== ex.operation) failures.push({ step: s.name, beat: 'EXAMPLE', detail: `calls ${called}, not the example's operation ${ex.operation}` });
|
|
1118
|
+
}
|
|
1119
|
+
// the example is sent as published: its input as the body, to its operation
|
|
1120
|
+
// a published field the step leaves out (`unsent`: a rendered value naming an object the World does not hold) must
|
|
1121
|
+
// be one the operation's request schema does not require
|
|
1122
|
+
for (const [path, why] of Object.entries(s.unsent ?? {})) {
|
|
1123
|
+
if (!why) failures.push({ step: s.name, beat: 'EXAMPLE', detail: `unsent ${path} gives no reason` });
|
|
1124
|
+
if (requiredAt(operationSchemas[ex.operation]?.request, path)) failures.push({ step: s.name, beat: 'EXAMPLE', detail: `unsent ${path} is required by the operation's request schema` });
|
|
1125
|
+
}
|
|
1126
|
+
const publishedBody = exampleBody({ ...s, body: ex.input, form: undefined, bodyBase64: undefined }, ex.input);
|
|
1127
|
+
const inputType = Object.entries(ex.request?.headers ?? {}).find(([k]) => k.toLowerCase() === 'content-type')?.[1]
|
|
1128
|
+
?? Object.entries({ ...journey.headers, ...s.headers }).find(([k]) => k.toLowerCase() === 'content-type')?.[1];
|
|
1129
|
+
const publishedInput = s.unsent && publishedBody !== undefined ? withoutPaths(publishedBody, Object.keys(s.unsent)) : publishedBody;
|
|
1130
|
+
// the body the step sends, every check below reading it alike: no body (a GET) is the empty input a page publishes for it
|
|
1131
|
+
const body = isResp(s) ? s.resp : exampleBody(s, publishedInput) ?? {};
|
|
1132
|
+
// A placeholder used as a request-map key is bound by its scalar request position, just as an answer key is.
|
|
1133
|
+
const input = answerKeys(publishedInput, publishedInput, body, ex.placeholder, true);
|
|
1134
|
+
if (ex.restXml) for (const p of restXmlInputProblems(ex.restXml, (input ?? {}) as Record<string, unknown>, shape?.pathOf?.(_path) ?? _path, { ...(journey.headers ?? {}), ...(s.headers ?? {}) }, placeholderIds, (s as { path?: string }).path)) failures.push({ step: s.name, beat: 'EXAMPLE', detail: `the request is not the example's published input: ${p}` });
|
|
1135
|
+
else if (input !== undefined && (!requestMapKeysHold(publishedInput, input, body, ex.placeholder) || (ex.partial ? !holds(body, input, ex.placeholder, undefined, ex.names, false, inputType) : !same(body, input, ex.placeholder ?? /(?!)/, undefined, ex.names, false, inputType)))) failures.push({ step: s.name, beat: 'EXAMPLE', detail: typeof input === 'string' ? comparedText(body, input, ex.placeholder ?? /(?!)/, undefined, ex.names, inputType)?.problem ?? "the body is not the example's published input" : "the body is not the example's published input" });
|
|
1136
|
+
// a scenario's turn is sent as the RFC writes it: its client lines (none for the greeting, a connect)
|
|
1137
|
+
if (ex.lines && JSON.stringify([s.send ?? []].flat()) !== JSON.stringify(ex.lines)) failures.push({ step: s.name, beat: 'EXAMPLE', detail: `sends ${JSON.stringify(s.send ?? null)}, not the scenario's ${JSON.stringify(ex.lines)}` });
|
|
1138
|
+
const target = Object.entries({ ...journey.headers, ...s.headers }).find(([k]) => k.toLowerCase() === 'x-amz-target')?.[1];
|
|
1139
|
+
if (ex.target && !target?.endsWith(`.${ex.target}`)) failures.push({ step: s.name, beat: 'EXAMPLE', detail: `x-amz-target is ${target}, not the example's operation ${ex.target}` });
|
|
1140
|
+
if (ex.status && answer.status !== ex.status && !s.exampleDiffers?.status) failures.push({ step: s.name, beat: 'EXAMPLE', detail: `answered ${answer.status}, the example ${ex.status}` });
|
|
1141
|
+
if (stepFailures.length) return;
|
|
1142
|
+
if (sweeping) for (const h of sweep(answer.body, s.expect ?? [])) sweepHits.push(` SWEEP ${s.name.slice(0, 60)} ${h}`);
|
|
1143
|
+
{
|
|
1144
|
+
// a value the setup decided, answered back, is asserted or named `undecided` with its reason (a check since every
|
|
1145
|
+
// done pack read 0: the judges' recurring ruling, made mechanical)
|
|
1146
|
+
const decided = new Set(earlier);
|
|
1147
|
+
const pinned = (s.expect ?? []).map((e) => { const p = e.path ?? ''; return p.startsWith('/') ? p.slice(1).split('/').map((k) => k.replace(/~1/g, '/').replace(/~0/g, '~')).join('.') : p; });
|
|
1148
|
+
const undecided = Object.keys((s as JourneyStep & { undecided?: Record<string, string> }).undecided ?? {});
|
|
1149
|
+
const isPinned = (path: string): boolean => undecided.includes(path) || pinned.some((a) => a === '' || path === a || path.startsWith(`${a}.`));
|
|
1150
|
+
for (const [path, v] of leaves(answer.body)) {
|
|
1151
|
+
if (typeof v === 'number' || isExactIntegerValue(v) || !decided.has(String(v)) || isPinned(path) || /(^|\.)(type|object)$/.test(path)) continue;
|
|
1152
|
+
failures.push({ step: s.name, beat: 'EXAMPLE', detail: `${path} answers ${JSON.stringify(v)}, which an earlier step sent or captured, and the step neither asserts it nor names it undecided` });
|
|
1153
|
+
}
|
|
1154
|
+
const scalars = (v: unknown, p: string): void => {
|
|
1155
|
+
if (typeof v === 'boolean' || typeof v === 'number' || isExactIntegerValue(v) || (typeof v === 'string' && v.length < 6)) {
|
|
1156
|
+
const key = p.split('.').at(-1) ?? '';
|
|
1157
|
+
if (key === 'type' || key === 'object') return;
|
|
1158
|
+
if (earlierScalars.has(`${key}=${v}`) && !isPinned(p)) failures.push({ step: s.name, beat: 'EXAMPLE', detail: `${p} answers ${JSON.stringify(v)}, which an earlier step sent under that name, and the step neither asserts it nor names it undecided` });
|
|
1159
|
+
} else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) scalars(x, p ? `${p}.${k}` : k);
|
|
1160
|
+
};
|
|
1161
|
+
scalars(answer.body, '');
|
|
1162
|
+
}
|
|
1163
|
+
comparisonPlaceholder = ex.placeholder; comparisonNames = ex.names;
|
|
1164
|
+
const answerAs = isResp(s) ? (answer.body as { replies: unknown[] }).replies : ex.restXml && answer.status < 300 ? restXmlAnswer(ex.restXml, answer.body, answer.headers) : answer.body;
|
|
1165
|
+
const publishedValue = answerKeys(ex.output, publishedBody, exampleBody(s, publishedInput), ex.placeholder);
|
|
1166
|
+
const published = isResp(s) && publishedValue !== undefined ? (Array.isArray(s.resp?.[0]) ? publishedValue : [publishedValue]) : publishedValue;
|
|
1167
|
+
const responseType = answer.headers?.['content-type'];
|
|
1168
|
+
const literal = isResp(s) || (published !== null && typeof published !== 'object');
|
|
1169
|
+
const valueSchema = ex.schema ?? operationSchemas[ex.operation]?.responses[String(answer.status)];
|
|
1170
|
+
comparisonCollections = [...(storedCollectionFields[vendor]?.[ex.operation] ?? [])];
|
|
1171
|
+
const operation = shape?.operationOf((s.method ?? 'GET').toUpperCase(), _path, { ...(journey.headers ?? {}), ...(s.headers ?? {}) })?.op;
|
|
1172
|
+
// A derived resource list names its holder; never treat every array under a read as a list.
|
|
1173
|
+
if (operation?.class === 'list' && operation.answers?.list) {
|
|
1174
|
+
const holder = operation.answers.key ? (published as Record<string, unknown>)?.[operation.answers.key] : published;
|
|
1175
|
+
if (Array.isArray(holder)) comparisonCollections.push(operation.answers.key ?? '');
|
|
1176
|
+
else if (isObject(holder) && Array.isArray((holder as Record<string, unknown>).data)) comparisonCollections.push(operation.answers.key ? `${operation.answers.key}.data` : 'data');
|
|
1177
|
+
}
|
|
1178
|
+
const compared = sseExampleAnswer(published, answerAs, (frame) => mismatches(published, frame, '', ex.sample, ex.schema, literal, responseType, valueSchema).length === 0);
|
|
1179
|
+
const explainedStatus = ex.status !== undefined && answer.status !== ex.status && s.exampleDiffers?.status;
|
|
1180
|
+
for (const m of explainedStatus ? [] : mismatches(published, compared, '', ex.sample, ex.schema, literal, responseType, valueSchema)) {
|
|
1181
|
+
// Existing declarations name array item paths with [] (scopes[].filter); inspect each actual
|
|
1182
|
+
// index while retaining that explicit ruling, only where the published node is an array.
|
|
1183
|
+
let node: unknown = published;
|
|
1184
|
+
let declaredPath = '';
|
|
1185
|
+
for (const key of m.path.split('.').filter(Boolean)) {
|
|
1186
|
+
if (Array.isArray(node) && /^\d+$/.test(key)) {
|
|
1187
|
+
declaredPath += '[]'; node = node[Number(key)] ?? node[0];
|
|
1188
|
+
} else {
|
|
1189
|
+
declaredPath += `${declaredPath ? '.' : ''}${key}`;
|
|
1190
|
+
node = node && typeof node === 'object' ? (node as Record<string, unknown>)[key] : undefined;
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
if (s.exampleDiffers?.[m.path] || s.exampleDiffers?.[declaredPath]) continue;
|
|
1194
|
+
failures.push({ step: s.name, beat: 'EXAMPLE', detail: `${m.path} ${m.detail}` });
|
|
1195
|
+
}
|
|
1196
|
+
// a value the step's own request decides, answered back, is asserted: an answer echoing the request is the
|
|
1197
|
+
// cheapest place for a twin to be wrong unseen (each judge of the first two packs ruled on one left unasserted)
|
|
1198
|
+
const sent = new Set(leaves(s.body ?? s.send ?? {}).map(([, v]) => String(v)).filter((v) => v.length >= 3));
|
|
1199
|
+
const asserted = (s.expect ?? []).map((e) => e.path ?? '');
|
|
1200
|
+
for (const [path, v] of leaves(answer.body)) {
|
|
1201
|
+
// (or named undecided: a value the vendor generates that only coincides with what was sent, PostHog's `hogql`)
|
|
1202
|
+
if (!sent.has(String(v)) || s.exampleDiffers?.[path] || (s as JourneyStep & { undecided?: Record<string, string> }).undecided?.[path]) continue;
|
|
1203
|
+
if (asserted.some((a) => a === '' || path === a || path.startsWith(`${a}.`))) continue;
|
|
1204
|
+
failures.push({ step: s.name, beat: 'EXAMPLE', detail: `${path} answers ${JSON.stringify(v)} from the request, and the step does not assert it` });
|
|
1205
|
+
}
|
|
1206
|
+
},
|
|
1207
|
+
},
|
|
1208
|
+
});
|
|
1209
|
+
// an example of an operation the twin does not serve is breadth's gap (score-pack), counted here and not replayed
|
|
1210
|
+
type Owned = { owners?: () => Record<string, string> } | undefined;
|
|
1211
|
+
const owners = ((await vendorFetch(PACKS, vendor))?.(world.root()) as Owned)?.owners?.() ?? ((await vendorStream(PACKS, vendor))?.(world.root()) as Owned)?.owners?.() ?? {};
|
|
1212
|
+
const ownerOf = (e: Example): string | undefined => e.wire === 'client' ? 'gap' : owners[e.lane ? `${e.lane}:${e.operation}` : e.operation];
|
|
1213
|
+
const patchRemoved = examples.filter((e) => e.patchRemoved);
|
|
1214
|
+
const unserved = examples.filter((e) => !e.patchRemoved && ownerOf(e) === 'gap');
|
|
1215
|
+
const recorded = Object.keys(journey.notReplayed ?? {});
|
|
1216
|
+
const missing = examples.filter((e) => !e.patchRemoved && !replayed.has(e.id) && !recorded.includes(e.id) && ownerOf(e) !== 'gap');
|
|
1217
|
+
world.restore();
|
|
1218
|
+
const embeddedDeletionRead = (body: string, collection: string, id: string): boolean => {
|
|
1219
|
+
const contains = (value: unknown): boolean => {
|
|
1220
|
+
if (!value || typeof value !== 'object') return false;
|
|
1221
|
+
if (Array.isArray(value)) return value.some(contains);
|
|
1222
|
+
const object = value as Record<string, unknown>;
|
|
1223
|
+
if (object.object === 'list' && typeof object.url === 'string' && bare(object.url) === collection && Array.isArray(object.data)) {
|
|
1224
|
+
return !object.data.some((item) => item && typeof item === 'object' && (item as Record<string, unknown>).id === id);
|
|
1225
|
+
}
|
|
1226
|
+
return Object.values(object).some(contains);
|
|
1227
|
+
};
|
|
1228
|
+
try { return contains(parseExactJson(body)); } catch { return false; }
|
|
1229
|
+
};
|
|
1230
|
+
for (const w of thinWrites) {
|
|
1231
|
+
// a delete is read back by a read of the subject (its not-found) or of its collection (no longer listing it)
|
|
1232
|
+
const collection = w.base.split('/').slice(0, -1).join('/');
|
|
1233
|
+
const readBack = reads.slice(w.from).some((r) => r.path === w.base || r.path.startsWith(`${w.base}/`) || (w.deletes && (r.path === collection || embeddedDeletionRead(r.body, collection, w.id))) || (w.id.length >= 6 && r.body.includes(`"${w.id}"`)));
|
|
1234
|
+
if (!readBack) failures.push({ step: w.step, beat: 'EXAMPLE', detail: `a thin answer whose effect (${w.base}) no later step reads back` });
|
|
1235
|
+
}
|
|
1236
|
+
for (const h of sweepHits) console.log(h);
|
|
1237
|
+
if (sweeping) console.log(` sweep: ${sweepHits.length} unpinned actor fields and unclosed lists (advisory)`);
|
|
1238
|
+
for (const f of failures) console.log(` FAIL ${f.step} [${f.beat}] ${f.detail}`);
|
|
1239
|
+
for (const id of unknown) console.log(` FAIL ${id} [EXAMPLE] the spec publishes no example of this name`);
|
|
1240
|
+
for (const e of missing) console.log(` GAP ${e.id} neither replayed nor recorded`);
|
|
1241
|
+
const verdictPath = join(PKG, 'journeys', 'vendor-examples.verdict.json');
|
|
1242
|
+
const verdict = existsSync(verdictPath) ? (parseExactJson(readFileSync(verdictPath, 'utf8')) as { sha256?: string; plausible?: boolean; judge?: { agent?: string; session?: string }; authors?: string[] }) : undefined;
|
|
1243
|
+
const dependent = verdict ? notIndependent(verdict) : undefined;
|
|
1244
|
+
const judged = !existsSync(file) ? 'no file' : !verdict ? 'unjudged' : dependent ? dependent : verdict.plausible ? 'plausible' : 'rejected';
|
|
1245
|
+
const replayedAll = !failures.length && !unknown.length && !missing.length;
|
|
1246
|
+
const done = replayedAll && judged === 'plausible';
|
|
1247
|
+
console.log(` replayed: ${replayedAll ? 'yes' : 'no'}`);
|
|
1248
|
+
console.log(` verdict: ${judged}`);
|
|
1249
|
+
console.log(`${vendor} examples: ${examples.length} published, ${patchRemoved.length} of patch-removed operations, ${unserved.length} of operations the twin does not serve, ${replayed.size} replayed, ${recorded.length} recorded; ${failures.length + unknown.length} failing done: ${done ? 'yes' : 'no'}`);
|
|
1250
|
+
process.exit(done ? 0 : 1);
|