bupkis 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,203 @@
1
+ import Debug from 'debug';
2
+ import { inspect } from 'util';
3
+ import z from 'zod/v4';
4
+
5
+ import { kStringLiteral } from '../constant.js';
6
+ import { AssertionError } from '../error.js';
7
+ import { isA, isAssertionFailure, isBoolean, isZodType } from '../guards.js';
8
+ import { BupkisRegistry } from '../metadata.js';
9
+ import {
10
+ type AssertionAsync,
11
+ type AssertionFunctionAsync,
12
+ type AssertionImplAsync,
13
+ type AssertionImplFnAsync,
14
+ type AssertionImplSchemaAsync,
15
+ type AssertionParts,
16
+ type AssertionSchemaAsync,
17
+ type AssertionSlots,
18
+ type ParsedResult,
19
+ type ParsedValues,
20
+ } from './assertion-types.js';
21
+ import { BupkisAssertion } from './assertion.js';
22
+ const debug = Debug('bupkis:assertion:async');
23
+
24
+ export abstract class BupkisAssertionAsync<
25
+ Parts extends AssertionParts,
26
+ Impl extends AssertionImplAsync<Parts>,
27
+ Slots extends AssertionSlots<Parts>,
28
+ >
29
+ extends BupkisAssertion<Parts, Impl, Slots>
30
+ implements AssertionAsync<Parts, Impl, Slots>
31
+ {
32
+ abstract executeAsync(
33
+ parsedValues: ParsedValues<Parts>,
34
+ args: unknown[],
35
+ stackStartFn: (...args: any[]) => any,
36
+ parseResult?: ParsedResult<Parts>,
37
+ ): Promise<void>;
38
+
39
+ async parseValuesAsync<Args extends readonly unknown[]>(
40
+ args: Args,
41
+ ): Promise<ParsedResult<Parts>> {
42
+ const { slots } = this;
43
+ const parsedValues: any[] = [];
44
+ if (slots.length !== args.length) {
45
+ return {
46
+ success: false,
47
+ };
48
+ }
49
+ let exactMatch = true;
50
+ for (let i = 0; i < slots.length; i++) {
51
+ const slot = slots[i]!;
52
+ const arg = args[i];
53
+
54
+ const parsedLiteralResult = this.parseSlotForLiteral(slot, i, arg);
55
+ if (parsedLiteralResult === true) {
56
+ continue;
57
+ } else if (parsedLiteralResult !== false) {
58
+ return parsedLiteralResult;
59
+ }
60
+
61
+ // unknown/any accept anything
62
+ // IMPORTANT: do not use a type guard here; it will break inference
63
+ if (slot.def.type === 'unknown' || slot.def.type === 'any') {
64
+ debug('Skipping unknown/any slot validation for arg', arg);
65
+ parsedValues.push(arg);
66
+ exactMatch = false;
67
+ continue;
68
+ }
69
+ const result = await slot.safeParseAsync(arg);
70
+ if (!result.success) {
71
+ return {
72
+ success: false,
73
+ };
74
+ }
75
+ parsedValues.push(result.data);
76
+ }
77
+ return {
78
+ exactMatch,
79
+ parsedValues: parsedValues as unknown as ParsedValues<Parts>,
80
+ success: true,
81
+ };
82
+ }
83
+ }
84
+ /**
85
+ * An assertion implemented as a Zod schema.
86
+ *
87
+ * Async schemas are supported via {@link expectAsync}.
88
+ */
89
+ /**
90
+ * Optimized schema assertion that performs subject validation during
91
+ * parseValues() to eliminate double parsing for simple schema-based
92
+ * assertions.
93
+ *
94
+ * This class implements Option 2 from the z.function() analysis - it caches the
95
+ * subject validation result during argument parsing and reuses it during
96
+ * execution, eliminating the double parsing overhead.
97
+ */
98
+
99
+ export class BupkisAssertionFunctionAsync<
100
+ Parts extends AssertionParts,
101
+ Impl extends AssertionImplFnAsync<Parts>,
102
+ Slots extends AssertionSlots<Parts>,
103
+ >
104
+ extends BupkisAssertionAsync<Parts, Impl, Slots>
105
+ implements AssertionFunctionAsync<Parts, Impl, Slots>
106
+ {
107
+ override async executeAsync(
108
+ parsedValues: ParsedValues<Parts>,
109
+ args: unknown[],
110
+ stackStartFn: (...args: any[]) => any,
111
+ _parseResult?: ParsedResult<Parts>,
112
+ ): Promise<void> {
113
+ const { impl } = this;
114
+ const result = await impl(...parsedValues);
115
+ if (isZodType(result)) {
116
+ try {
117
+ await result.parseAsync(parsedValues[0]);
118
+ } catch (error) {
119
+ if (isA(error, z.ZodError)) {
120
+ throw this.translateZodError(stackStartFn, error, ...parsedValues);
121
+ }
122
+ throw error;
123
+ }
124
+ } else if (isBoolean(result)) {
125
+ if (!result) {
126
+ throw new AssertionError({
127
+ message: `Assertion ${this} failed for arguments: ${inspect(args)}`,
128
+ });
129
+ }
130
+ } else if (isAssertionFailure(result)) {
131
+ throw new AssertionError({
132
+ actual: result.actual,
133
+ expected: result.expected,
134
+ message: result.message ?? `Assertion ${this} failed`,
135
+ });
136
+ } else if (result as unknown) {
137
+ throw new TypeError(
138
+ `Invalid return type from assertion ${this}; expected boolean, ZodType, or AssertionFailure`,
139
+ );
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * A class representing an assertion implemented as a function.
146
+ *
147
+ * This function may:
148
+ *
149
+ * 1. Return a `boolean` indicating pass/fail.
150
+ * 2. Return a `ZodType` which will be used to validate the subject.
151
+ * 3. Return a `Promise` resolving to either of the above (when called via
152
+ * {@link expectAsync})
153
+ * 4. Throw a {@link AssertionError}; when called via {@link expectAsync}, reject
154
+ * with an {@link AssertionError}
155
+ */
156
+
157
+ export class BupkisAssertionSchemaAsync<
158
+ Parts extends AssertionParts,
159
+ Impl extends AssertionImplSchemaAsync<Parts>,
160
+ Slots extends AssertionSlots<Parts>,
161
+ >
162
+ extends BupkisAssertionAsync<Parts, Impl, Slots>
163
+ implements AssertionSchemaAsync<Parts, Impl, Slots>
164
+ {
165
+ override async executeAsync(
166
+ parsedValues: ParsedValues<Parts>,
167
+ _args: unknown[],
168
+ stackStartFn: (...args: any[]) => any,
169
+ _parseResult?: ParsedResult<Parts>,
170
+ ): Promise<void> {
171
+ // For async, fall back to standard implementation for now
172
+ const [subject] = parsedValues;
173
+ try {
174
+ await this.impl.parseAsync(subject);
175
+ } catch (error) {
176
+ if (isA(error, z.ZodError)) {
177
+ throw this.translateZodError(stackStartFn, error, ...parsedValues);
178
+ }
179
+ throw error;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Determines if this assertion can be optimized (simple single-subject
185
+ * schema). Only simple assertions like ['to be a string'] with z.string()
186
+ * qualify.
187
+ */
188
+ private isSimpleSchemaAssertion(): boolean {
189
+ // Only optimize if we have exactly one subject slot + string literal slots
190
+ // and no complex argument processing
191
+ const hasSubjectSlot =
192
+ this.slots.length > 0 &&
193
+ (this.slots[0]?.def.type === 'unknown' ||
194
+ this.slots[0]?.def.type === 'any');
195
+
196
+ const allOtherSlotsAreLiterals = this.slots.slice(1).every((slot) => {
197
+ const meta = BupkisRegistry.get(slot) ?? {};
198
+ return kStringLiteral in meta;
199
+ });
200
+
201
+ return hasSubjectSlot && allOtherSlotsAreLiterals;
202
+ }
203
+ }
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Synchronous assertion subclasses.
3
+ *
4
+ * @packageDocumentation
5
+ * @see {@link AssertionFunctionSync} for function-based assertions
6
+ * @see {@link AssertionSchemaSync} for schema-based assertions
7
+ */
8
+
9
+ import Debug from 'debug';
10
+ import { inspect } from 'util';
11
+ import { type z } from 'zod/v4';
12
+
13
+ import { kStringLiteral } from '../constant.js';
14
+ import { AssertionError } from '../error.js';
15
+ import {
16
+ isAssertionFailure,
17
+ isBoolean,
18
+ isPromiseLike,
19
+ isZodPromise,
20
+ isZodType,
21
+ } from '../guards.js';
22
+ import { BupkisRegistry } from '../metadata.js';
23
+ import {
24
+ type AssertionFunctionSync,
25
+ type AssertionImplFnSync,
26
+ type AssertionImplSchemaSync,
27
+ type AssertionImplSync,
28
+ type AssertionParts,
29
+ type AssertionSchemaSync,
30
+ type AssertionSlots,
31
+ type AssertionSync,
32
+ type ParsedResult,
33
+ type ParsedResultSuccess,
34
+ type ParsedValues,
35
+ } from './assertion-types.js';
36
+ import { BupkisAssertion } from './assertion.js';
37
+
38
+ const debug = Debug('bupkis:assertion:sync');
39
+
40
+ /**
41
+ * Abstract class for synchronous assertions.
42
+ *
43
+ * Child classes are expected to implement {@link execute}.
44
+ */
45
+ export abstract class BupkisAssertionSync<
46
+ Parts extends AssertionParts,
47
+ Impl extends AssertionImplSync<Parts>,
48
+ Slots extends AssertionSlots<Parts>,
49
+ >
50
+ extends BupkisAssertion<Parts, Impl, Slots>
51
+ implements AssertionSync<Parts, Impl, Slots>
52
+ {
53
+ /**
54
+ * Parses raw arguments synchronously against this `Assertion`'s Slots to
55
+ * determine if they match this `Assertion`.
56
+ *
57
+ * @param args Raw arguments provided to `expect()`
58
+ * @returns Result of parsing attempt
59
+ */
60
+ abstract execute(
61
+ parsedValues: ParsedValues<Parts>,
62
+ args: unknown[],
63
+ stackStartFn: (...args: any[]) => any,
64
+ parseResult?: ParsedResult<Parts>,
65
+ ): void;
66
+
67
+ /**
68
+ * Parses raw arguments against the slots of this assertion to determine if
69
+ * this assertion should be executed against those arguments.
70
+ *
71
+ * For example, if an assertion wants the subject to be a `z.string()`, then
72
+ * this will validate that the first raw arg parses as a string. It will also
73
+ * validate Phrase Literals as well, such as "to be a string". If all slots
74
+ * match and none of the slots are "unknown" or "any", then `exactMatch` will
75
+ * be true.
76
+ *
77
+ * If any slot does not match, this returns `success: false`.
78
+ *
79
+ * @param args Raw arguments
80
+ * @returns Result of parsing attempt
81
+ */
82
+ parseValues<Args extends readonly unknown[]>(
83
+ args: Args,
84
+ ): ParsedResult<Parts> {
85
+ const { slots } = this;
86
+ const parsedValues: any[] = [];
87
+
88
+ const mismatch = this.maybeParseValuesArgMismatch(args);
89
+ if (mismatch) {
90
+ return mismatch;
91
+ }
92
+
93
+ let exactMatch = true;
94
+ for (let i = 0; i < slots.length; i++) {
95
+ const slot = slots[i]!;
96
+ const arg = args[i];
97
+
98
+ const parsedLiteralResult = this.parseSlotForLiteral(slot, i, arg);
99
+ if (parsedLiteralResult === true) {
100
+ continue;
101
+ } else if (parsedLiteralResult !== false) {
102
+ return parsedLiteralResult;
103
+ }
104
+
105
+ // unknown/any accept anything
106
+ // IMPORTANT: do not use a type guard here
107
+ if (slot.def.type === 'unknown' || slot.def.type === 'any') {
108
+ // debug('Skipping unknown/any slot validation for arg', arg);
109
+ parsedValues.push(arg);
110
+ exactMatch = false;
111
+ continue;
112
+ }
113
+ // low-effort check
114
+ if (isZodPromise(slot)) {
115
+ throw new TypeError(
116
+ `${this} expects a Promise for slot ${i}; use expectAsync() instead of expect()`,
117
+ );
118
+ }
119
+ const result = slot.safeParse(arg);
120
+ if (!result.success) {
121
+ return {
122
+ success: false,
123
+ };
124
+ }
125
+
126
+ parsedValues.push(arg);
127
+ }
128
+ return {
129
+ exactMatch,
130
+ parsedValues: parsedValues as unknown as ParsedValues<Parts>,
131
+ success: true,
132
+ };
133
+ }
134
+ }
135
+
136
+ export class BupkisAssertionFunctionSync<
137
+ Parts extends AssertionParts,
138
+ Impl extends AssertionImplFnSync<Parts>,
139
+ Slots extends AssertionSlots<Parts>,
140
+ >
141
+ extends BupkisAssertionSync<Parts, Impl, Slots>
142
+ implements AssertionFunctionSync<Parts, Impl, Slots>
143
+ {
144
+ override execute(
145
+ parsedValues: ParsedValues<Parts>,
146
+ args: unknown[],
147
+ stackStartFn: (...args: any[]) => any,
148
+ _parseResult?: ParsedResult<Parts>,
149
+ ): void {
150
+ const result = (this.impl as AssertionImplFnSync<Parts>).call(
151
+ null,
152
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
153
+ ...(parsedValues as any),
154
+ );
155
+ if (isPromiseLike(result)) {
156
+ // Avoid unhandled promise rejection
157
+ Promise.resolve(result).catch((err) => {
158
+ debug(`Ate unhandled rejection from assertion %s: %O`, this, err);
159
+ });
160
+
161
+ throw new TypeError(
162
+ `Assertion ${this} returned a Promise; use expectAsync() instead of expect()`,
163
+ );
164
+ }
165
+ if (isZodType(result)) {
166
+ const zodResult = result.safeParse(parsedValues[0]);
167
+ if (!zodResult.success) {
168
+ throw this.translateZodError(
169
+ stackStartFn,
170
+ zodResult.error,
171
+ ...parsedValues,
172
+ );
173
+ }
174
+ } else if (isBoolean(result)) {
175
+ if (!result) {
176
+ throw new AssertionError({
177
+ message: `Assertion ${this} failed for arguments: ${inspect(args)}`,
178
+ });
179
+ }
180
+ } else if (isAssertionFailure(result)) {
181
+ throw new AssertionError({
182
+ actual: result.actual,
183
+ expected: result.expected,
184
+ message: result.message ?? `Assertion ${this} failed`,
185
+ });
186
+ } else if (result as unknown) {
187
+ throw new TypeError(
188
+ `Invalid return type from assertion ${this}; expected boolean, ZodType, or AssertionFailure`,
189
+ );
190
+ }
191
+ }
192
+ }
193
+
194
+ /**
195
+ * A class representing an assertion implemented as a function.
196
+ *
197
+ * This function may:
198
+ *
199
+ * 1. Return a `boolean` indicating pass/fail.
200
+ * 2. Return a `ZodType` which will be used to validate the subject.
201
+ * 3. Return a `Promise` resolving to either of the above (when called via
202
+ * {@link expectAsync})
203
+ * 4. Throw a {@link AssertionError}; when called via {@link expectAsync}, reject
204
+ * with an {@link AssertionError}
205
+ */
206
+
207
+ export class BupkisAssertionSchemaSync<
208
+ Parts extends AssertionParts,
209
+ Impl extends AssertionImplSchemaSync<Parts>,
210
+ Slots extends AssertionSlots<Parts>,
211
+ >
212
+ extends BupkisAssertionSync<Parts, Impl, Slots>
213
+ implements AssertionSchemaSync<Parts, Impl, Slots>
214
+ {
215
+ override execute(
216
+ parsedValues: ParsedValues<Parts>,
217
+ args: unknown[],
218
+ stackStartFn: (...args: any[]) => any,
219
+ parseResult?: ParsedResult<Parts>,
220
+ ): void {
221
+ // Check if we have cached validation result from parseValues
222
+ const cachedValidation = parseResult?.success
223
+ ? parseResult.subjectValidationResult
224
+ : undefined;
225
+
226
+ if (cachedValidation) {
227
+ debug(
228
+ 'Using cached subject validation result from parseValues for %s',
229
+ this,
230
+ );
231
+ if (!cachedValidation.success) {
232
+ // Subject validation failed during parseValues, throw the cached error
233
+ throw this.translateZodError(
234
+ stackStartFn,
235
+ cachedValidation.error,
236
+ ...parsedValues,
237
+ );
238
+ }
239
+ // Subject validation passed, nothing more to do
240
+ return;
241
+ }
242
+
243
+ // Fall back to standard validation if no cached result
244
+ const [subject] = parsedValues;
245
+ const result = this.impl.safeParse(subject);
246
+ if (!result.success) {
247
+ throw this.translateZodError(stackStartFn, result.error, ...parsedValues);
248
+ }
249
+ }
250
+
251
+ override parseValues<Args extends readonly unknown[]>(
252
+ args: Args,
253
+ ): ParsedResult<Parts> {
254
+ const { slots } = this;
255
+ const parsedValues: any[] = [];
256
+ const mismatch = this.maybeParseValuesArgMismatch(args);
257
+ if (mismatch) {
258
+ return mismatch;
259
+ }
260
+
261
+ let exactMatch = true;
262
+ let subjectValidationResult:
263
+ | undefined
264
+ | { data: any; success: true }
265
+ | { error: z.ZodError; success: false };
266
+
267
+ for (let i = 0; i < slots.length; i++) {
268
+ const slot = slots[i]!;
269
+ const arg = args[i];
270
+ const parsedLiteralResult = this.parseSlotForLiteral(slot, i, arg);
271
+ if (parsedLiteralResult === true) {
272
+ continue;
273
+ } else if (parsedLiteralResult !== false) {
274
+ return parsedLiteralResult;
275
+ }
276
+
277
+ // For the subject slot (first slot if it's unknown/any), try optimized validation
278
+ if (
279
+ i === 0 &&
280
+ (slot.def.type === 'unknown' || slot.def.type === 'any') &&
281
+ this.isSimpleSchemaAssertion()
282
+ ) {
283
+ const result = this.impl.safeParse(arg);
284
+ if (result.success) {
285
+ subjectValidationResult = { data: result.data, success: true };
286
+ parsedValues.push(result.data); // Use validated data
287
+ } else {
288
+ subjectValidationResult = { error: result.error, success: false };
289
+ parsedValues.push(arg); // Keep original for error reporting
290
+ }
291
+ exactMatch = false; // Subject was validated, so we know the exact type
292
+ continue;
293
+ }
294
+
295
+ // Standard slot processing for non-optimized cases
296
+ if (slot.def.type === 'unknown' || slot.def.type === 'any') {
297
+ debug('Skipping unknown/any slot validation for arg', arg);
298
+ parsedValues.push(arg);
299
+ exactMatch = false;
300
+ continue;
301
+ }
302
+
303
+ if (isZodPromise(slot)) {
304
+ throw new TypeError(
305
+ `${this} expects a Promise for slot ${i}; use expectAsync() instead of expect()`,
306
+ );
307
+ }
308
+ const result = slot.safeParse(arg);
309
+ if (!result.success) {
310
+ return {
311
+ success: false,
312
+ };
313
+ }
314
+ parsedValues.push(arg);
315
+ }
316
+
317
+ const result: ParsedResultSuccess<Parts> = {
318
+ exactMatch,
319
+ parsedValues: parsedValues as unknown as ParsedValues<Parts>,
320
+ success: true,
321
+ };
322
+
323
+ // Add cached validation result if we performed optimization
324
+ if (subjectValidationResult) {
325
+ result.subjectValidationResult = subjectValidationResult;
326
+ }
327
+
328
+ return result;
329
+ }
330
+
331
+ /**
332
+ * Determines if this assertion can be optimized (simple single-subject
333
+ * schema). Only simple assertions like ['to be a string'] with z.string()
334
+ * qualify.
335
+ */
336
+ private isSimpleSchemaAssertion(): boolean {
337
+ // Only optimize if we have exactly one subject slot + string literal slots
338
+ // and no complex argument processing
339
+ const hasSubjectSlot =
340
+ this.slots.length > 0 &&
341
+ (this.slots[0]?.def.type === 'unknown' ||
342
+ this.slots[0]?.def.type === 'any');
343
+
344
+ const allOtherSlotsAreLiterals = this.slots.slice(1).every((slot) => {
345
+ const meta = BupkisRegistry.get(slot) ?? {};
346
+ return kStringLiteral in meta;
347
+ });
348
+
349
+ return hasSubjectSlot && allOtherSlotsAreLiterals;
350
+ }
351
+ }