@wirecat/cli-base 0.48.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,48 @@ Versions before 0.8.0 are in the [git tags](https://github.com/WireCatLabs/cli-b
8
8
  Every entry says what changed as a caller sees it, why, and what to watch for — the rules are
9
9
  [`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog).
10
10
 
11
+ ## 0.50.0 — 22.10.2026
12
+
13
+ ### Changed — may break callers
14
+
15
+ - **The shared scripts TypeScript configuration checks `vitest.config.ts`.** Consumers extending it now
16
+ catch type errors in their test-runner configuration alongside scripts; fix newly reported errors before upgrading.
17
+
18
+ - **The handbook clarifies JSON previews and local release validation.** A dry-run preview uses the item
19
+ envelope; local releases must run coverage, engine, contract and security checks, with the npm token
20
+ available only to publishing. Command discovery remains a bare document.
21
+
22
+ ### Added
23
+
24
+ - **`adaptOpenApi` reads an OpenAPI 3.0 or 3.1 document into the codegen model.** It moved here from max-cli because
25
+ whatsapp-cli needs it too for Meta's Cloud API file. Beyond what max-cli's copy did, it resolves `$ref` parameters,
26
+ drops the `Authorization`, `User-Agent`, `Content-Type` and `Accept` header parameters (the
27
+ HTTP client sets them), reads `oneOf` as a union, `const` and a typeless `enum` as their type, `type: [T, "null"]`
28
+ as nullable, and an array with no `items` as any item. A body that is multipart or a form keeps that in the new
29
+ `ApiBody.contentType` and manifest `request.contentType`; a multipart body with a file wins over a JSON one. A body
30
+ typed only from sample values gets `confidence: "example"`. A path with no `operationId` is skipped instead of
31
+ refused, since Meta uses one to describe its webhook payload. It still stops on anything else it does not
32
+ understand, now under the name `OpenApiError`. Parse YAML yourself and hand it the plain object.
33
+ - **One subtype may belong to two discriminated schemas, or to one schema under two values.** The generated type and
34
+ schema accept each value that selects it (`"unsupported" | "unknown"`); before, generation stopped. Meta's sent and
35
+ received message unions share `TextMessage` this way.
36
+
37
+ - **Release checks can audit Go dependencies for reachable vulnerabilities.** Set `govulncheck: true` and
38
+ `go-version-file` to the module's `go.mod` path. The audit uses that module's Go version and runs in its
39
+ directory; it stays off for existing callers.
40
+ - **The weekly cross-CLI audit includes WhatsApp.** Its latest published command tree joins the checks for
41
+ required commands, shared option meanings, answer shapes and error reasons.
42
+
43
+ ## 0.49.0 — 21.10.2026
44
+
45
+ ### Added
46
+
47
+ - **`parseLucene` can read a phrase's distance.** With `{ proximity: true }`, `"a b"~3` parses as a phrase predicate
48
+ with `slop: 3`, and `queryNodeSchema` accepts `slop`. Meeting search needs it for words spread over neighbouring
49
+ transcript lines, and a caller that turns it on says what the number counts. Without the option `~` is still
50
+ `query.unsupported-operator`, so messages and lists see no change. A distance that is not a whole number is
51
+ `query.invalid-syntax`; `word~2` (fuzzy) stays unsupported either way.
52
+
11
53
  ## 0.48.0 — 21.10.2026
12
54
 
13
55
  ### Changed — may break callers
package/README.md CHANGED
@@ -171,6 +171,7 @@ unless you pass `isolated: true`; and a locked Linux keyring can block on a prom
171
171
  ### Generated API clients that never guess
172
172
 
173
173
  `/codegen` turns an API description into TypeScript types, Valibot schemas, an operation manifest and a coverage page.
174
+ `adaptOpenApi` reads an OpenAPI 3.0 or 3.1 document; another format needs an adapter in the caller.
174
175
  Every operation is classified as a read, a write or destructive, with how far the source can be trusted. Generation
175
176
  stops, naming the problem, on a collision, an unclassified operation or a dangling reference — it never drops an
176
177
  operation and never falls back to `any`. 64-bit integers stay exact.
@@ -8,6 +8,6 @@
8
8
  "rootDir": "${configDir}",
9
9
  "allowImportingTsExtensions": true
10
10
  },
11
- "include": ["${configDir}/scripts/**/*.ts"],
11
+ "include": ["${configDir}/scripts/**/*.ts", "${configDir}/vitest.config.ts"],
12
12
  "exclude": []
13
13
  }
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Turns an official API description into committed code: types, Valibot schemas, an operation
3
3
  * manifest and a coverage page. Build-time only, and format-neutral: it parses no file format and
4
- * adds no dependency. The adapter from OpenAPI, Postman or anything else lives in the consumer.
4
+ * adds no dependency. `adaptOpenApi` reads an OpenAPI document the consumer has parsed; an adapter from
5
+ * Postman or anything else lives in the consumer.
5
6
  */
6
7
  export { type CoverageOptions, coverageGenerator, definitionsGenerator, type ManifestOperation, manifestGenerator, } from "./manifest.js";
7
8
  export type { ApiBody, ApiModel, ApiOperation, ApiParameter, ApiSchema, Binding, CodeGenerator, Confidence, Discriminator, Effect, GeneratedArtifact, HttpBinding, HttpMethod, Override, RpcBinding, SchemaNode, SourceInfo, SourceReference, } from "./model.js";
8
9
  export { identifier, kebab } from "./names.js";
10
+ export { adaptOpenApi, OpenApiError } from "./openapi.js";
9
11
  export { applyOverrides, CodegenError, type GenerateOptions, generate, validateModel, type WriteOptions, writeArtifacts, } from "./pipeline.js";
10
12
  export { typesGenerator } from "./typescript.js";
11
13
  export { type ValibotOptions, valibotGenerator } from "./valibot.js";
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * Turns an official API description into committed code: types, Valibot schemas, an operation
3
3
  * manifest and a coverage page. Build-time only, and format-neutral: it parses no file format and
4
- * adds no dependency. The adapter from OpenAPI, Postman or anything else lives in the consumer.
4
+ * adds no dependency. `adaptOpenApi` reads an OpenAPI document the consumer has parsed; an adapter from
5
+ * Postman or anything else lives in the consumer.
5
6
  */
6
7
  export { coverageGenerator, definitionsGenerator, manifestGenerator, } from "./manifest.js";
7
8
  export { identifier, kebab } from "./names.js";
9
+ export { adaptOpenApi, OpenApiError } from "./openapi.js";
8
10
  export { applyOverrides, CodegenError, generate, validateModel, writeArtifacts, } from "./pipeline.js";
9
11
  export { typesGenerator } from "./typescript.js";
10
12
  export { valibotGenerator } from "./valibot.js";
@@ -21,6 +21,7 @@ export interface ManifestOperation {
21
21
  required: boolean;
22
22
  confidence: Confidence;
23
23
  schema?: string;
24
+ contentType?: string;
24
25
  };
25
26
  response?: {
26
27
  confidence: Confidence;
@@ -16,6 +16,7 @@ const toManifest = (operation, tree) => withoutUndefined({
16
16
  required: operation.requestBody.required,
17
17
  confidence: operation.requestBody.confidence,
18
18
  schema: tree.requestName(operation.id),
19
+ contentType: operation.requestBody.contentType,
19
20
  })
20
21
  : undefined,
21
22
  response: operation.response
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The model every generator reads, whatever the source was: an OpenAPI file, a Postman collection,
3
- * a scraped reference page. A source adapter lives in the consumer and produces this; nothing here
4
- * parses a file format.
3
+ * a scraped reference page. An adapter produces this from a document already parsed into plain
4
+ * values: `adaptOpenApi` for OpenAPI here, the rest in the consumer. Nothing here reads YAML or a file.
5
5
  */
6
6
  export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
7
7
  /** How much the source promises. A Postman example is not a contract and must never become one. */
@@ -100,6 +100,8 @@ export interface ApiParameter {
100
100
  export interface ApiBody {
101
101
  confidence: Confidence;
102
102
  required: boolean;
103
+ /** How the body travels when it is not JSON: `multipart/form-data` for a file upload, or a form. */
104
+ contentType?: string;
103
105
  schema?: SchemaNode;
104
106
  example?: unknown;
105
107
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The model every generator reads, whatever the source was: an OpenAPI file, a Postman collection,
3
- * a scraped reference page. A source adapter lives in the consumer and produces this; nothing here
4
- * parses a file format.
3
+ * a scraped reference page. An adapter produces this from a document already parsed into plain
4
+ * values: `adaptOpenApi` for OpenAPI here, the rest in the consumer. Nothing here reads YAML or a file.
5
5
  */
6
6
  export {};
@@ -0,0 +1,16 @@
1
+ /**
2
+ * An OpenAPI 3.0 or 3.1 document, already parsed from YAML or JSON → the format-neutral `ApiModel`.
3
+ *
4
+ * Strict on purpose: a keyword this does not understand stops generation and names the place,
5
+ * because a constraint dropped here is a request the server refuses at run time.
6
+ */
7
+ import type { ApiModel } from "./model.js";
8
+ export declare class OpenApiError extends Error {
9
+ readonly problems: readonly string[];
10
+ constructor(problems: readonly string[]);
11
+ }
12
+ /** Throws `OpenApiError` with every problem at once, so one run shows all a spec update broke. */
13
+ export declare const adaptOpenApi: (document: unknown, source?: {
14
+ sourceUrl?: string;
15
+ sourceRevision?: string;
16
+ }) => ApiModel;
@@ -0,0 +1,446 @@
1
+ const METHODS = ["get", "post", "put", "patch", "delete", "head"];
2
+ /**
3
+ * Carried by the model, or harmless to lose. `not` only narrows what a payload may hold; losing it
4
+ * accepts more, never refuses a valid request.
5
+ */
6
+ const IGNORED = new Set(["readOnly", "example", "examples", "externalDocs", "title", "xml", "writeOnly", "not"]);
7
+ const KNOWN = new Set([
8
+ ...IGNORED,
9
+ "type",
10
+ "format",
11
+ "description",
12
+ "nullable",
13
+ "deprecated",
14
+ "default",
15
+ "enum",
16
+ "const",
17
+ "minLength",
18
+ "maxLength",
19
+ "pattern",
20
+ "minimum",
21
+ "maximum",
22
+ "items",
23
+ "minItems",
24
+ "maxItems",
25
+ "uniqueItems",
26
+ "properties",
27
+ "required",
28
+ "additionalProperties",
29
+ "allOf",
30
+ "oneOf",
31
+ "$ref",
32
+ "discriminator",
33
+ ]);
34
+ const PRIMITIVE_CONSTRAINTS = ["pattern", "minLength", "maxLength", "minimum", "maximum", "enum"];
35
+ /** What the HTTP client sets itself; as flags they would only invite a second, wrong value. */
36
+ const TRANSPORT_HEADERS = new Set(["authorization", "user-agent", "content-type", "accept"]);
37
+ /** What a body may be sent as, in the order an operation offering several is read. */
38
+ const JSON_TYPE = "application/json";
39
+ const MULTIPART = "multipart/form-data";
40
+ const FORM = "application/x-www-form-urlencoded";
41
+ /** Any of these anywhere in a body schema means someone wrote a contract, not a sample. */
42
+ const CONTRACT_MARKS = [
43
+ "$ref",
44
+ "description",
45
+ "required",
46
+ "enum",
47
+ "const",
48
+ "pattern",
49
+ "minLength",
50
+ "maxLength",
51
+ "allOf",
52
+ "oneOf",
53
+ ];
54
+ export class OpenApiError extends Error {
55
+ problems;
56
+ constructor(problems) {
57
+ super(`the OpenAPI document cannot be adapted:\n${problems.map((problem) => ` - ${problem}`).join("\n")}`);
58
+ this.name = "OpenApiError";
59
+ this.problems = problems;
60
+ }
61
+ }
62
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
63
+ const EFFECT_BY_METHOD = {
64
+ GET: "read",
65
+ HEAD: "read",
66
+ POST: "write",
67
+ PUT: "write",
68
+ PATCH: "write",
69
+ DELETE: "destructive",
70
+ };
71
+ const pick = (source, keys) => Object.fromEntries(keys.map((key) => [key, source[key]]));
72
+ const typeOfConst = (value) => typeof value === "string"
73
+ ? "string"
74
+ : typeof value === "boolean"
75
+ ? "boolean"
76
+ : Number.isInteger(value)
77
+ ? "integer"
78
+ : typeof value === "number"
79
+ ? "number"
80
+ : undefined;
81
+ /**
82
+ * A body typed from a sample request: only `type` and `example` all the way down. Its field types
83
+ * are what one example happened to hold, so the model must never treat it as a contract.
84
+ */
85
+ const typedFromExample = (schema) => {
86
+ const marked = (node) => {
87
+ if (Array.isArray(node))
88
+ return node.some(marked);
89
+ if (!isObject(node))
90
+ return false;
91
+ if (CONTRACT_MARKS.some((mark) => node[mark] !== undefined))
92
+ return true;
93
+ return Object.entries(node).some(([key, value]) => key !== "example" && marked(value));
94
+ };
95
+ return isObject(schema) && !marked(schema);
96
+ };
97
+ class Adapter {
98
+ problems = [];
99
+ document;
100
+ #components;
101
+ #parameters;
102
+ constructor(document) {
103
+ this.document = document;
104
+ const components = isObject(document.components) ? document.components : {};
105
+ this.#components = isObject(components.schemas) ? components.schemas : {};
106
+ this.#parameters = isObject(components.parameters) ? components.parameters : {};
107
+ }
108
+ refId(ref, where) {
109
+ const prefix = "#/components/schemas/";
110
+ if (typeof ref === "string" && ref.startsWith(prefix))
111
+ return ref.slice(prefix.length);
112
+ this.problems.push(`${where}: unsupported reference ${JSON.stringify(ref)}`);
113
+ return "Unknown";
114
+ }
115
+ /** A parameter given as a `$ref` into `components/parameters`, followed once. */
116
+ #parameterSource(source, where) {
117
+ if (!isObject(source) || source.$ref === undefined)
118
+ return source;
119
+ const prefix = "#/components/parameters/";
120
+ const ref = source.$ref;
121
+ if (typeof ref === "string" && ref.startsWith(prefix) && isObject(this.#parameters[ref.slice(prefix.length)]))
122
+ return this.#parameters[ref.slice(prefix.length)];
123
+ this.problems.push(`${where}: unsupported reference ${JSON.stringify(ref)}`);
124
+ return undefined;
125
+ }
126
+ #common(source) {
127
+ const common = {};
128
+ if (typeof source.description === "string" && source.description.trim())
129
+ common.description = source.description;
130
+ if (source.nullable === true)
131
+ common.nullable = true;
132
+ if (source.deprecated === true)
133
+ common.deprecated = true;
134
+ if (source.default !== undefined)
135
+ common.default = source.default;
136
+ return common;
137
+ }
138
+ /** OpenAPI 3.1 writes `nullable` as `type: [T, "null"]`. */
139
+ #type(source, common, where) {
140
+ if (Array.isArray(source.type)) {
141
+ const types = source.type.filter((type) => type !== "null");
142
+ if (types.length !== source.type.length)
143
+ common.nullable = true;
144
+ if (types.length === 1)
145
+ return types[0];
146
+ this.problems.push(`${where}: a schema of several types ${JSON.stringify(source.type)}`);
147
+ return null;
148
+ }
149
+ if (source.type !== undefined)
150
+ return source.type;
151
+ if (source.const !== undefined)
152
+ return typeOfConst(source.const);
153
+ if (Array.isArray(source.enum)) {
154
+ const types = new Set(source.enum.map(typeOfConst));
155
+ if (types.size === 1)
156
+ return [...types][0];
157
+ }
158
+ if (source.items !== undefined)
159
+ return "array";
160
+ if (source.properties !== undefined || source.required !== undefined || source.additionalProperties !== undefined)
161
+ return "object";
162
+ return undefined;
163
+ }
164
+ schema(source, where) {
165
+ if (!isObject(source)) {
166
+ this.problems.push(`${where}: a schema must be an object`);
167
+ return { type: "unknown" };
168
+ }
169
+ for (const keyword of Object.keys(source)) {
170
+ if (!KNOWN.has(keyword) && !keyword.startsWith("x-")) {
171
+ this.problems.push(`${where}: unsupported keyword "${keyword}"`);
172
+ }
173
+ }
174
+ const common = this.#common(source);
175
+ if (source.$ref !== undefined)
176
+ return { ...common, type: "ref", ref: this.refId(source.$ref, where) };
177
+ if (Array.isArray(source.allOf))
178
+ return this.#allOf(source, source.allOf, common, where);
179
+ if (Array.isArray(source.oneOf)) {
180
+ const of = source.oneOf.map((member, index) => this.schema(member, `${where}.oneOf[${index}]`));
181
+ return { ...common, type: "union", of };
182
+ }
183
+ const type = this.#type(source, common, where);
184
+ const values = source.const !== undefined ? [source.const] : Array.isArray(source.enum) ? source.enum : undefined;
185
+ switch (type) {
186
+ case "string": {
187
+ const node = { ...common, type: "string" };
188
+ if (values)
189
+ node.enum = values.map(String);
190
+ if (source.format === "binary")
191
+ node.format = "binary";
192
+ if (typeof source.minLength === "number")
193
+ node.minLength = source.minLength;
194
+ if (typeof source.maxLength === "number")
195
+ node.maxLength = source.maxLength;
196
+ if (typeof source.pattern === "string")
197
+ node.pattern = source.pattern;
198
+ return node;
199
+ }
200
+ case "integer": {
201
+ const node = { ...common, type: "integer" };
202
+ if (source.format === "int32" || source.format === "int64")
203
+ node.format = source.format;
204
+ else if (source.format !== undefined)
205
+ this.problems.push(`${where}: unsupported integer format "${source.format}"`);
206
+ if (typeof source.minimum === "number")
207
+ node.minimum = source.minimum;
208
+ if (typeof source.maximum === "number")
209
+ node.maximum = source.maximum;
210
+ if (values)
211
+ node.enum = values.map(Number);
212
+ return node;
213
+ }
214
+ case "number": {
215
+ const node = { ...common, type: "number" };
216
+ if (typeof source.minimum === "number")
217
+ node.minimum = source.minimum;
218
+ if (typeof source.maximum === "number")
219
+ node.maximum = source.maximum;
220
+ return node;
221
+ }
222
+ case "boolean": {
223
+ const node = { ...common, type: "boolean" };
224
+ if (values)
225
+ node.enum = values.map((value) => value === true);
226
+ return node;
227
+ }
228
+ case "array": {
229
+ // JSON Schema reads a missing `items` as "any item".
230
+ const items = source.items === undefined ? { type: "unknown" } : this.schema(source.items, `${where}[]`);
231
+ const node = { ...common, type: "array", items };
232
+ if (typeof source.minItems === "number")
233
+ node.minItems = source.minItems;
234
+ if (typeof source.maxItems === "number")
235
+ node.maxItems = source.maxItems;
236
+ if (source.uniqueItems === true)
237
+ node.uniqueItems = true;
238
+ return node;
239
+ }
240
+ case "object":
241
+ return this.#object(source, common, where);
242
+ case null:
243
+ return { type: "unknown" };
244
+ case undefined:
245
+ if (Object.keys(source).every((keyword) => IGNORED.has(keyword) || keyword === "description")) {
246
+ return { ...common, type: "unknown" };
247
+ }
248
+ this.problems.push(`${where}: a schema with no type`);
249
+ return { type: "unknown" };
250
+ default:
251
+ this.problems.push(`${where}: unsupported type ${JSON.stringify(type)}`);
252
+ return { type: "unknown" };
253
+ }
254
+ }
255
+ #object(source, common, where) {
256
+ const properties = {};
257
+ for (const [name, property] of Object.entries(isObject(source.properties) ? source.properties : {})) {
258
+ properties[name] = this.schema(property, `${where}.${name}`);
259
+ }
260
+ const node = {
261
+ ...common,
262
+ type: "object",
263
+ properties,
264
+ required: Array.isArray(source.required) ? source.required.map(String) : [],
265
+ };
266
+ // `true` and `{}` say "anything else may appear", which a loose object already allows.
267
+ if (isObject(source.additionalProperties) && Object.keys(source.additionalProperties).length > 0) {
268
+ node.additionalProperties = this.schema(source.additionalProperties, `${where}.*`);
269
+ }
270
+ return node;
271
+ }
272
+ #allOf(source, members, common, where) {
273
+ const of = members.map((member, index) => this.schema(member, `${where}.allOf[${index}]`));
274
+ const [only] = of;
275
+ // `type: object` beside a one-member allOf is inheritance with no fields of its own; without it,
276
+ // the allOf only attaches a description or `nullable` to a reference.
277
+ if (of.length !== 1 || !only || source.type === "object")
278
+ return { ...common, type: "allOf", of };
279
+ const constraints = PRIMITIVE_CONSTRAINTS.filter((keyword) => source[keyword] !== undefined);
280
+ if (constraints.length === 0)
281
+ return { ...only, ...common };
282
+ if (only.type !== "ref") {
283
+ this.problems.push(`${where}: constraints beside a non-reference allOf member`);
284
+ return only;
285
+ }
286
+ const target = this.#components[only.ref];
287
+ const inlined = this.schema({ ...(isObject(target) ? target : {}), ...pick(source, constraints) }, where);
288
+ if (inlined.type === "object" || inlined.type === "allOf") {
289
+ this.problems.push(`${where}: constraints ${constraints.join(", ")} beside a reference to an object`);
290
+ }
291
+ return { ...inlined, ...common };
292
+ }
293
+ components() {
294
+ return Object.entries(this.#components).map(([id, source]) => {
295
+ const where = `#/components/schemas/${id}`;
296
+ const schema = { id, schema: this.schema(source, where) };
297
+ if (isObject(source) && isObject(source.discriminator)) {
298
+ const { propertyName, mapping } = source.discriminator;
299
+ if (typeof propertyName !== "string" || !isObject(mapping)) {
300
+ this.problems.push(`${where}: a discriminator needs propertyName and an explicit mapping`);
301
+ }
302
+ else {
303
+ schema.discriminator = {
304
+ property: propertyName,
305
+ mapping: Object.fromEntries(Object.entries(mapping).map(([value, ref]) => [value, this.refId(ref, `${where}.discriminator`)])),
306
+ };
307
+ }
308
+ }
309
+ return schema;
310
+ });
311
+ }
312
+ #parameter(reference, where) {
313
+ const source = this.#parameterSource(reference, where);
314
+ if (source === undefined)
315
+ return undefined;
316
+ if (!isObject(source) || typeof source.name !== "string") {
317
+ this.problems.push(`${where}: a parameter needs a name`);
318
+ return undefined;
319
+ }
320
+ if (source.in === "header" && TRANSPORT_HEADERS.has(source.name.toLowerCase()))
321
+ return undefined;
322
+ if (source.in !== "path" && source.in !== "query" && source.in !== "header") {
323
+ this.problems.push(`${where}: parameter "${source.name}" is in ${JSON.stringify(source.in)}`);
324
+ return undefined;
325
+ }
326
+ const parameter = {
327
+ name: source.name,
328
+ in: source.in,
329
+ required: source.required === true || source.in === "path",
330
+ schema: this.schema(source.schema, `${where}.${source.name}`),
331
+ };
332
+ if (typeof source.description === "string" && source.description.trim())
333
+ parameter.description = source.description;
334
+ return parameter;
335
+ }
336
+ /**
337
+ * JSON when offered, unless a multipart body carries a file, which JSON cannot; then multipart,
338
+ * then a form. A content type outside these three refuses the operation.
339
+ */
340
+ #requestBody(source, where) {
341
+ const content = isObject(source.content) ? source.content : {};
342
+ const schemaOf = (type) => (isObject(content[type]) ? content[type].schema : undefined);
343
+ const carriesFile = JSON.stringify(schemaOf(MULTIPART) ?? {}).includes('"format":"binary"');
344
+ const offered = Object.keys(content);
345
+ const type = carriesFile ? MULTIPART : [JSON_TYPE, MULTIPART, FORM].find((candidate) => offered.includes(candidate));
346
+ const body = { confidence: "contract", required: source.required === true };
347
+ if (offered.length === 0)
348
+ return body;
349
+ if (!type) {
350
+ this.problems.push(`${where}: a body must be JSON, multipart or a form, found ${offered.join(", ")}`);
351
+ return body;
352
+ }
353
+ if (type !== JSON_TYPE)
354
+ body.contentType = type;
355
+ const schema = schemaOf(type);
356
+ if (schema === undefined)
357
+ return body;
358
+ if (typedFromExample(schema))
359
+ body.confidence = "example";
360
+ body.schema = this.schema(schema, `${where}.requestBody`);
361
+ return body;
362
+ }
363
+ /**
364
+ * Only an inline JSON answer has a shape to check; a file, plain text or a `$ref` to a shared response
365
+ * is passed through as it is. Nothing at run time validates an answer yet.
366
+ */
367
+ #response(source, where) {
368
+ const ok = isObject(source.responses) ? source.responses["200"] : undefined;
369
+ if (!isObject(ok) || !isObject(ok.content))
370
+ return undefined;
371
+ const json = ok.content[JSON_TYPE];
372
+ if (!isObject(json) || json.schema === undefined)
373
+ return undefined;
374
+ return this.schema(json.schema, `${where}.responses.200`);
375
+ }
376
+ operations() {
377
+ const operations = [];
378
+ const paths = isObject(this.document.paths) ? this.document.paths : {};
379
+ for (const [path, item] of Object.entries(paths)) {
380
+ if (!isObject(item))
381
+ continue;
382
+ const shared = Array.isArray(item.parameters) ? item.parameters : [];
383
+ for (const method of METHODS) {
384
+ const source = item[method];
385
+ if (!isObject(source))
386
+ continue;
387
+ const pointer = `#/paths/${path.replaceAll("~", "~0").replaceAll("/", "~1")}/${method}`;
388
+ // A path without an id is a description, not a call: Meta models its webhook payload this way.
389
+ if (typeof source.operationId !== "string")
390
+ continue;
391
+ const verb = method.toUpperCase();
392
+ const own = (Array.isArray(source.parameters) ? source.parameters : [])
393
+ .map((parameter, index) => this.#parameter(parameter, `${pointer}.parameters[${index}]`))
394
+ .filter((parameter) => parameter !== undefined);
395
+ const inherited = shared
396
+ .map((parameter, index) => this.#parameter(parameter, `${pointer.replace(/\/[a-z]+$/, "")}.parameters[${index}]`))
397
+ .filter((parameter) => parameter !== undefined && !own.some((mine) => mine.name === parameter.name && mine.in === parameter.in));
398
+ const parameters = [...inherited, ...own];
399
+ for (const [, name] of path.matchAll(/\{([^}]+)\}/g)) {
400
+ if (!parameters.some((parameter) => parameter.in === "path" && parameter.name === name))
401
+ this.problems.push(`${pointer}: path parameter "${name}" is not declared`);
402
+ }
403
+ const operation = {
404
+ id: source.operationId,
405
+ binding: { kind: "http", method: verb, path },
406
+ tags: Array.isArray(source.tags) ? source.tags.map(String) : [],
407
+ parameters,
408
+ effect: EFFECT_BY_METHOD[verb],
409
+ source: { location: pointer },
410
+ };
411
+ if (typeof source.summary === "string")
412
+ operation.summary = source.summary;
413
+ if (typeof source.description === "string")
414
+ operation.description = source.description;
415
+ if (source.deprecated === true)
416
+ operation.deprecated = true;
417
+ if (isObject(source.requestBody))
418
+ operation.requestBody = this.#requestBody(source.requestBody, pointer);
419
+ const response = this.#response(source, pointer);
420
+ if (response)
421
+ operation.response = { confidence: "contract", required: true, schema: response };
422
+ operations.push(operation);
423
+ }
424
+ }
425
+ return operations;
426
+ }
427
+ }
428
+ /** Throws `OpenApiError` with every problem at once, so one run shows all a spec update broke. */
429
+ export const adaptOpenApi = (document, source = {}) => {
430
+ if (!isObject(document) || typeof document.openapi !== "string" || !document.openapi.startsWith("3.")) {
431
+ throw new OpenApiError(["not an OpenAPI 3.x document"]);
432
+ }
433
+ const adapter = new Adapter(document);
434
+ const schemas = adapter.components();
435
+ const operations = adapter.operations();
436
+ if (operations.length === 0)
437
+ adapter.problems.push("the document has no operations");
438
+ if (adapter.problems.length > 0)
439
+ throw new OpenApiError(adapter.problems);
440
+ const info = isObject(document.info) ? document.info : {};
441
+ return {
442
+ source: { kind: "openapi", ...(typeof info.version === "string" ? { version: info.version } : {}), ...source },
443
+ schemas,
444
+ operations,
445
+ };
446
+ };
@@ -2,6 +2,11 @@ import type { ApiModel, ApiSchema, SchemaNode } from "./model.js";
2
2
  type ObjectNode = Extract<SchemaNode, {
3
3
  type: "object";
4
4
  }>;
5
+ /** The values a subtype's discriminator property may hold: one per mapping entry that selects it. */
6
+ export interface Literal {
7
+ property: string;
8
+ values: readonly string[];
9
+ }
5
10
  export interface NamedSchema {
6
11
  name: string;
7
12
  schema: ApiSchema;
@@ -17,11 +22,8 @@ export declare class SchemaTree {
17
22
  schema(id: string): ApiSchema;
18
23
  requestName(operationId: string): string | undefined;
19
24
  responseName(operationId: string): string | undefined;
20
- /** The value a subtype's discriminator property must hold, if the schema is a subtype. */
21
- literalFor(id: string): {
22
- property: string;
23
- value: string;
24
- } | undefined;
25
+ /** The values a subtype's discriminator property may hold, if the schema is a subtype. */
26
+ literalFor(id: string): Literal | undefined;
25
27
  /** Merges inheritance into one object. A member that is not an object stops generation. */
26
28
  flatten(node: SchemaNode, where: string): ObjectNode;
27
29
  }
@@ -36,10 +36,16 @@ export class SchemaTree {
36
36
  }
37
37
  for (const schema of model.schemas) {
38
38
  for (const [value, target] of Object.entries(schema.discriminator?.mapping ?? {})) {
39
- if (this.#literals.has(target)) {
40
- throw new CodegenError([`schema "${target}" is a subtype of two discriminated schemas`]);
39
+ const property = schema.discriminator?.property ?? "";
40
+ const known = this.#literals.get(target);
41
+ // Two bases may share a subtype when both choose it by the same property, as Meta's sent and received messages do.
42
+ if (known && known.property !== property) {
43
+ throw new CodegenError([
44
+ `schema "${target}" is a subtype chosen by both "${known.property}" and "${property}"`,
45
+ ]);
41
46
  }
42
- this.#literals.set(target, { property: schema.discriminator?.property ?? "", value });
47
+ const values = known?.values ?? [];
48
+ this.#literals.set(target, { property, values: values.includes(value) ? values : [...values, value] });
43
49
  }
44
50
  }
45
51
  }
@@ -55,7 +61,7 @@ export class SchemaTree {
55
61
  responseName(operationId) {
56
62
  return this.#responses.get(operationId);
57
63
  }
58
- /** The value a subtype's discriminator property must hold, if the schema is a subtype. */
64
+ /** The values a subtype's discriminator property may hold, if the schema is a subtype. */
59
65
  literalFor(id) {
60
66
  return this.#literals.get(id);
61
67
  }
@@ -55,7 +55,9 @@ class TypeWriter {
55
55
  const fields = names.map((name) => {
56
56
  const property = node.properties[name] ?? { type: "string" };
57
57
  const optional = node.required.includes(name) || literal?.property === name ? "" : "?";
58
- const type = literal?.property === name ? JSON.stringify(literal.value) : this.node(property, `${where}.${name}`);
58
+ const type = literal?.property === name
59
+ ? literal.values.map((value) => JSON.stringify(value)).join(" | ")
60
+ : this.node(property, `${where}.${name}`);
59
61
  return `${doc(property.description, " ")} ${key(name)}${optional}: ${type}`;
60
62
  });
61
63
  const body = `{\n${fields.join("\n")}\n}`;
@@ -97,8 +97,13 @@ class SchemaWriter {
97
97
  if (names.length === 0)
98
98
  return rest ? `v.record(v.string(), ${rest})` : "v.looseObject({})";
99
99
  const entries = names.map((name) => {
100
- if (literal?.property === name)
101
- return ` ${key(name)}: v.literal(${JSON.stringify(literal.value)}),`;
100
+ if (literal?.property === name) {
101
+ const [only] = literal.values;
102
+ const exact = literal.values.length === 1
103
+ ? `v.literal(${JSON.stringify(only)})`
104
+ : `v.picklist(${JSON.stringify(literal.values)})`;
105
+ return ` ${key(name)}: ${exact},`;
106
+ }
102
107
  const property = node.properties[name];
103
108
  const schema = this.node(property, `${where}.${name}`);
104
109
  return ` ${key(name)}: ${node.required.includes(name) ? schema : `v.optional(${schema})`},`;
@@ -2,7 +2,9 @@ import { type QueryAst, type QueryContext } from "./types.js";
2
2
  export interface ParseOptions extends QueryContext {
3
3
  /** The field a bare word searches; `none` for a list, where a bare word is `query.bare-word`. */
4
4
  defaultField?: string;
5
+ /** Read `"a b"~N` as a phrase with `slop: N`; a caller that turns it on says what N counts. */
6
+ proximity?: boolean;
5
7
  }
6
- export declare const parseLucene: (text: string, { defaultField, ...context }?: ParseOptions) => QueryAst;
8
+ export declare const parseLucene: (text: string, { defaultField, proximity, ...context }?: ParseOptions) => QueryAst;
7
9
  /** Bare terms eligible for forgiving retrieval; explicit syntax keeps strict semantics. */
8
10
  export declare const implicitTextTerms: (text: string) => Set<number>;
@@ -141,7 +141,7 @@ const regexHint = (text, at) => {
141
141
  const word = `${/[^\s()"]*$/u.exec(text.slice(0, at))?.[0] ?? ""}${/^[^\s()"]*/u.exec(text.slice(at))?.[0] ?? ""}`;
142
142
  return quoteHint(text, at) ?? `/ starts a regular expression; put the value in quotes: "${word}"`;
143
143
  };
144
- export const parseLucene = (text, { defaultField = "text", ...context } = {}) => {
144
+ export const parseLucene = (text, { defaultField = "text", proximity = false, ...context } = {}) => {
145
145
  const fail = (reason, span, alternative) => queryError(reason, span, alternative ?? quoteHint(text, span.start), context);
146
146
  if (text.length > QUERY_LIMITS.bytes || new TextEncoder().encode(text).length > QUERY_LIMITS.bytes)
147
147
  fail("query.too-large", { start: 0, end: text.length });
@@ -290,6 +290,13 @@ export const parseLucene = (text, { defaultField = "text", ...context } = {}) =>
290
290
  ...(implicit ? { implicit: true } : {}),
291
291
  });
292
292
  }
293
+ if (proximity && peek().kind === "~" && node.kind === "predicate" && node.operator === "phrase") {
294
+ take();
295
+ const distance = take();
296
+ if (distance.kind !== "number" || !/^\d+$/u.test(distance.raw))
297
+ fail("query.invalid-syntax", distance.span, 'write the distance as a whole number: "a b"~3');
298
+ node = { ...node, slop: Number(distance.raw), span: { start: node.span.start, end: distance.span.end } };
299
+ }
293
300
  if (peek().kind === "~")
294
301
  fail("query.unsupported-operator", peek().span, node.kind === "predicate" && node.operator === "phrase"
295
302
  ? "proximity (~) is not supported — drop the ~ for the exact phrase, or join the words with AND"
@@ -21,6 +21,8 @@ export interface Predicate {
21
21
  span: Span;
22
22
  /** A bare word, under no field. */
23
23
  implicit?: true;
24
+ /** `"a b"~N`, read only when the parser is asked to: how far apart the phrase's words may be. */
25
+ slop?: number;
24
26
  }
25
27
  export type QueryNode = Predicate | {
26
28
  kind: "boolean";
@@ -16,6 +16,7 @@ const predicateSchema = v.object({
16
16
  upper_inclusive: v.optional(v.boolean()),
17
17
  span: spanSchema,
18
18
  implicit: v.optional(v.literal(true)),
19
+ slop: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0))),
19
20
  });
20
21
  export const queryNodeSchema = v.variant("kind", [
21
22
  predicateSchema,
@@ -15,8 +15,9 @@ Rule ids on this page are `WC-ACCT-<n>`. How to read a rule, how ids work and wh
15
15
 
16
16
  cli-core resolves the profile for tg, max and wa. WC-ACCT-1 needs cli-core 0.319.0 or newer, and all three pin
17
17
  0.321.0. memo has no messenger profile: its shared commands run without one, and they pick accounts with `--provider`
18
- and `--account` (WC-ACCT-7). drive and zoom still have their own profile code: drive registers its own profiles, and
19
- zoom keeps one saved profile in its config file. These are known gaps, not exceptions.
18
+ and `--account` (WC-ACCT-7). zoom resolves its own profile word by the same rules (`zm <profile> …`, `ZM_PROFILE`,
19
+ `ZM_PROFILE_LOCK`) from zoom-cli 0.35.0, with cli-core's `liftProfile` and `usableProfileName`. drive still has its own
20
+ profile code: it registers its own profiles. That is a known gap, not an exception.
20
21
 
21
22
  ## Rules
22
23
 
@@ -96,7 +96,7 @@ cli-testing, cli-docs, plus community itself.
96
96
  cli-docs validates its site before each deployment instead, and community has no build. Third-party security apps
97
97
  may post their own checks.
98
98
  - **Applies to:** the CLIs, cli-base, cli-core and cli-testing. cli-base, whatsapp-cli, tg-cli, max-cli, cli-core
99
- and cli-memo have `pr.yml`; drive-cli, zoom-cli and cli-testing do not have it yet. **Since:**
99
+ cli-memo and zoom-cli have `pr.yml`; drive-cli and cli-testing do not have it yet. **Since:**
100
100
  cli-base 0.41.0 (pull request validation since cli-base 0.44.6). **Rulings:** none.
101
101
 
102
102
  ### WC-CI-6 · Run the changelog guard on every pull request
@@ -169,8 +169,9 @@ cli-testing, cli-docs, plus community itself.
169
169
  - **Enforced by:** `whatsapp-cli: main ruleset required checks` · `cli-core: main ruleset required checks` ·
170
170
  `tg-cli: main ruleset required checks` · `max-cli: main ruleset required checks` ·
171
171
  `cli-memo: main ruleset required checks`, plus the main ruleset of cli-base itself. In each repository with pr.yml,
172
- the ruleset requires the pull request validation jobs besides the standards job. drive-cli, zoom-cli, cli-testing
173
- and cli-docs have no pr.yml, so their rulesets require only the standards job, and review covers the rest.
172
+ the ruleset requires the pull request validation jobs besides the standards job. drive-cli, cli-testing and cli-docs
173
+ have no pr.yml, so their rulesets require only the standards job, and review covers the rest. zoom-cli has pr.yml,
174
+ but its ruleset still requires only the standards job.
174
175
  - **Example:** `gh pr checks <number>` shows every line as pass before `gh pr merge`. Never merge with `--admin`. A
175
176
  release changes `main` the same way
176
177
  ([WC-REL-2](releases.md#wc-rel-2--main-changes-only-through-a-pull-request-also-during-a-release)).
@@ -20,9 +20,10 @@ Rule ids on this page are `WC-MCP-<n>`. How to read a rule, how ids work and whi
20
20
  ### Who follows these rules today
21
21
 
22
22
  tg, max and wa serve cli-core's personal server (`<cli> <profile> mcp`) and bot server (`<cli> <profile> bot mcp`).
23
- memo, drive and zoom have their own servers: memo has hand-written tools, drive builds one tool per command from its
24
- command tree and skips commands through its own list, and zoom's `serve --mcp` only reads. Those three are known gaps
25
- against these rules.
23
+ zoom serves the three shared tools over its own commands from zoom-cli 0.35.0 (`zm <profile> mcp`, cli-core's
24
+ `toolsFromCommands` and `runCommandForAgent`). memo and drive have their own servers: memo has hand-written tools, and
25
+ drive builds one tool per command from its command tree and skips commands through its own list. Those two are known
26
+ gaps against these rules.
26
27
 
27
28
  WC-MCP-2 and WC-MCP-8 need cli-core 0.321.0, which tg, max and wa pin.
28
29
 
@@ -52,9 +52,9 @@ JSON Schema 2020-12.
52
52
  - **Enforced by:** `cli-core: src/cli/envelope.test.ts` · `cli-core: src/mcp/output-schema.test.ts`.
53
53
  - **Example:** a list is `{ items, page, next?, missing?, meta }`. One thing is `{ item, next?, missing?, meta }`.
54
54
  cli-core builds them with `listOf` and `itemOf`, and its `renderAnswer` refuses any other shape. A list never
55
- answers a bare array.
56
- - **Exceptions:** a document that is not about the owner's data is printed whole, with no envelope: `commands --json`
57
- and a `--dry-run` preview.
55
+ answers a bare array. A `--dry-run` preview is one thing: `{ item: <preview>, meta }`, with the
56
+ preview's parsed arguments and declared effects inside `item`.
57
+ - **Exceptions:** command discovery (`commands --json`) is printed whole, with no envelope.
58
58
  - **Applies to:** all CLIs. **Since:** cli-base 0.41.0. **Rulings:** NEED-358 (its older list shape is replaced by
59
59
  this one), NEED-431.
60
60
 
@@ -18,8 +18,10 @@ Rule ids on this page are `WC-PERM-<n>`. How to read a rule, how ids work and wh
18
18
  ### Who follows these rules today
19
19
 
20
20
  tg, max and wa use cli-core's permissions and write guard in full. memo uses them for the shared commands only. drive
21
- has its own policy and guard, with its own `--read-only` flag; zoom only reads. Where this page says "every CLI that
22
- writes", drive is a known gap.
21
+ has its own policy and guard, with its own `--read-only` flag. zoom has per-profile `permissions` from zoom-cli 0.35.0,
22
+ read with cli-core's `levelFor`; its writes change Zoom meetings, its built-in default is `remote-meetings: ask`, and
23
+ it has no send journal or recipient list, since it sends no messages. Where this page says "every CLI that writes",
24
+ drive is a known gap.
23
25
 
24
26
  WC-PERM-10 and WC-PERM-11 need cli-core 0.321.0, which tg, max and wa pin. WC-PERM-12 as written needs cli-core
25
27
  0.337.0; before it, the lock let through some local writes that overwrite what the owner saved. A command other
@@ -161,7 +161,11 @@ on the [CI](ci.md) page.
161
161
  - **Enforced by:** `tg-cli: bin/release` · `max-cli: bin/release` · `whatsapp-cli: bin/release` ·
162
162
  `cli-memo: bin/release`.
163
163
  - **Example:** `bin/release --local` runs the same checks on this machine and reads the npm token from the keyring
164
- into the publish command's environment. Neither success nor an error prints it.
164
+ into the publish command's environment. The checks include the test suite with its coverage floor,
165
+ any shipped non-JavaScript engine's tests, offline contracts, leak and secret scans, and dependency
166
+ vulnerability audits for each shipped language. Build and validate before supplying the token to the
167
+ publish process; publishing must not run unchecked lifecycle scripts with it. Neither success nor an
168
+ error prints the token.
165
169
  - **Exceptions:** none. drive's and zoom's `bin/release` have no `--local` yet.
166
170
  - **Applies to:** all CLIs and shared libraries. **Since:** cli-base 0.41.0. **Rulings:** none.
167
171
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-base",
3
- "version": "0.48.0",
3
+ "version": "0.50.0",
4
4
  "description": "The parts every command line tool needs: output modes, terminal rendering, error model, exit codes, credentials, clocks",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",
@@ -139,7 +139,8 @@
139
139
  "lefthook": "2.1.16",
140
140
  "markdownlint-cli2": "0.23.3",
141
141
  "typescript": "^7.0.2",
142
- "vitest": "^5.0.2"
142
+ "vitest": "^5.0.2",
143
+ "yaml": "^2.9.1"
143
144
  },
144
145
  "publishConfig": {
145
146
  "access": "public"