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,234 @@
1
+ /**
2
+ * Core assertion class and parsing engine.
3
+ *
4
+ * This module implements the main `Assertion` class which handles parsing,
5
+ * validation, and execution of assertions. It provides the foundational
6
+ * infrastructure for converting assertion parts into executable validation
7
+ * logic with comprehensive error handling and type safety.
8
+ *
9
+ * @packageDocumentation
10
+ */
11
+
12
+ import Debug from 'debug';
13
+ import slug from 'slug';
14
+ import { type ArrayValues } from 'type-fest';
15
+ import { inspect } from 'util';
16
+ import { z } from 'zod/v4';
17
+
18
+ import { kStringLiteral } from '../constant.js';
19
+ import { AssertionError } from '../error.js';
20
+ import { BupkisRegistry } from '../metadata.js';
21
+ import {
22
+ type Assertion,
23
+ type AssertionImpl,
24
+ type AssertionParts,
25
+ type AssertionSlots,
26
+ type ParsedResult,
27
+ type ParsedValues,
28
+ } from './assertion-types.js';
29
+
30
+ const debug = Debug('bupkis:assertion');
31
+
32
+ /**
33
+ * Modified charmap for {@link slug} to use underscores to replace hyphens (and
34
+ * for hyphens to replace everything else that needs replacing).
35
+ *
36
+ * @see {@link BupkisAssertion.generateUniqueId} for usage
37
+ */
38
+ const SLUG_CHARMAP = { ...slug.charmap, '-': '_' };
39
+
40
+ export abstract class BupkisAssertion<
41
+ Parts extends AssertionParts,
42
+ Impl extends AssertionImpl<Parts>,
43
+ Slots extends AssertionSlots<Parts>,
44
+ > implements Assertion<Parts, Impl, Slots>
45
+ {
46
+ readonly id: string;
47
+
48
+ constructor(
49
+ readonly parts: Parts,
50
+ readonly slots: Slots,
51
+ readonly impl: Impl,
52
+ ) {
53
+ this.id = this.generateAssertionId();
54
+ debug('Created assertion %s', this);
55
+ }
56
+
57
+ /**
58
+ * Parses raw arguments synchronously against this `Assertion`'s Slots to
59
+ * determine if they match this `Assertion`.
60
+ *
61
+ * @param args Raw arguments provided to `expect()`
62
+ * @returns Result of parsing attempt
63
+ */
64
+
65
+ /**
66
+ * @returns String representation
67
+ */
68
+ public toString(): string {
69
+ const expand = (zodType: z.core.$ZodType | z.ZodType): string => {
70
+ const def = 'def' in zodType ? zodType.def : zodType._zod.def;
71
+ switch (def.type) {
72
+ case 'custom': {
73
+ const meta = BupkisRegistry.get(zodType);
74
+ if (meta?.name) {
75
+ // our name
76
+ return `{${meta.name}}`;
77
+ } else if ('Class' in zodType._zod.bag) {
78
+ // internal Zod class name. will probably break.
79
+ return `{${(zodType._zod.bag.Class as new (...args: any[]) => any).name}}`;
80
+ }
81
+ return '{custom}';
82
+ }
83
+ case 'default':
84
+ return `{${expand((def as z.core.$ZodDefaultDef).innerType)}}`;
85
+ case 'enum':
86
+ return `${Object.keys((def as z.core.$ZodEnumDef<any>).entries as Record<PropertyKey, unknown>).join(' / ')}`;
87
+ case 'intersection':
88
+ return `${expand((def as z.core.$ZodIntersectionDef<z.core.$ZodType>).left)} & ${expand((def as z.core.$ZodIntersectionDef<z.core.$ZodType>).right)}`;
89
+ case 'literal':
90
+ return (def as z.core.$ZodLiteralDef<any>).values
91
+ .map((value) => `'${value}'`)
92
+ .join(' / ');
93
+ case 'map':
94
+ return `{Map<${expand((def as z.core.$ZodMapDef).keyType)}, ${expand((def as z.core.$ZodMapDef).valueType)}>`;
95
+ case 'nonoptional':
96
+ return `${expand((def as z.core.$ZodNonOptionalDef).innerType)}!`;
97
+ case 'nullable':
98
+ return `${expand((def as z.core.$ZodNullableDef).innerType)}? | null`;
99
+ case 'optional':
100
+ return `${expand((def as z.core.$ZodOptionalDef).innerType)}?`;
101
+ case 'record':
102
+ return `{Record<${expand((def as z.core.$ZodRecordDef).keyType)}, ${expand((def as z.core.$ZodRecordDef).valueType)}>`;
103
+ case 'set':
104
+ return `{Set<${expand((def as z.core.$ZodSetDef).valueType)}>`;
105
+
106
+ case 'tuple':
107
+ return `[${(def as z.core.$ZodTupleDef).items.map(expand).join(', ')}]`;
108
+ case 'union':
109
+ return (
110
+ (def as z.core.$ZodUnionDef<any>).options as z.core.$ZodType[]
111
+ )
112
+ .map(expand)
113
+ .join(' | ');
114
+ default:
115
+ return `{${def.type}}`;
116
+ }
117
+ };
118
+ return `"${this.slots.map(expand).join(' ')}"`;
119
+ }
120
+
121
+ protected maybeParseValuesArgMismatch<Args extends readonly unknown[]>(
122
+ args: Args,
123
+ ): ParsedResult<Parts> | undefined {
124
+ if (this.slots.length !== args.length) {
125
+ return {
126
+ success: false,
127
+ };
128
+ }
129
+ }
130
+
131
+ /**
132
+ * TODO: Fix the return types here. This is all sorts of confusing.
133
+ *
134
+ * @param slot Slot to check
135
+ * @param slotIndex Index of slot
136
+ * @param rawArg Raw argument
137
+ * @returns
138
+ */
139
+ protected parseSlotForLiteral<Slot extends ArrayValues<Slots>>(
140
+ slot: Slot,
141
+ slotIndex: number,
142
+ rawArg: unknown,
143
+ ): boolean | ParsedResult<Parts> {
144
+ const meta = BupkisRegistry.get(slot) ?? {};
145
+ // our branded literal slots are also tagged in meta for runtime
146
+ if (kStringLiteral in meta) {
147
+ if ('value' in meta) {
148
+ if (rawArg !== meta.value) {
149
+ return {
150
+ success: false,
151
+ };
152
+ }
153
+ } else if ('values' in meta) {
154
+ const allowed = meta.values as readonly string[];
155
+ if (!allowed.includes(`${rawArg}`)) {
156
+ return {
157
+ success: false,
158
+ };
159
+ }
160
+ } else {
161
+ /* c8 ignore next */
162
+ throw new TypeError(
163
+ `Invalid metadata for slot ${slotIndex} with value ${inspect(rawArg)}`,
164
+ );
165
+ }
166
+ return true;
167
+ }
168
+ return false;
169
+ }
170
+
171
+ /**
172
+ * Translates a {@link z.ZodError} into an {@link AssertionError} with a
173
+ * human-friendly message.
174
+ *
175
+ * @remarks
176
+ * This does not handle parameterized assertions with more than one parameter
177
+ * too cleanly; it's unclear how a test runner would display the expected
178
+ * values. This will probably need a fix in the future.
179
+ * @param stackStartFn The function to start the stack trace from
180
+ * @param zodError The original `ZodError`
181
+ * @param values Values which caused the error
182
+ * @returns New `AssertionError`
183
+ */
184
+ protected translateZodError(
185
+ stackStartFn: (...args: any[]) => any,
186
+ zodError: z.ZodError,
187
+ ...values: ParsedValues<Parts>
188
+ ): AssertionError {
189
+ const flat = z.flattenError(zodError);
190
+
191
+ let pretty = flat.formErrors.join('; ');
192
+ for (const [keypath, errors] of Object.entries(flat.fieldErrors)) {
193
+ pretty += `; ${keypath}: ${(errors as unknown[]).join('; ')}`;
194
+ }
195
+
196
+ const [actual, ...expected] = values as unknown as [unknown, ...unknown[]];
197
+
198
+ return new AssertionError({
199
+ actual,
200
+ expected: expected.length === 1 ? expected[0] : expected,
201
+ message: `Assertion ${this} failed: ${pretty}`,
202
+ operator: `${this}`,
203
+ stackStartFn,
204
+ });
205
+ }
206
+
207
+ /**
208
+ * Generates a unique¹ ID for this assertion by combining content, structure,
209
+ * and type information.
210
+ *
211
+ * - `s` is slot count
212
+ * - `p` is part count
213
+ *
214
+ * Slugifies the string representation of the assertion. Does not collapse
215
+ * adjacent hyphens, as hyphens are significant in phrase literals.
216
+ *
217
+ * @remarks
218
+ * ¹: "Unique" here means "unique enough" for practical purposes. This is not
219
+ * cryptographically unique, nor does it need to be. The goal is to avoid
220
+ * collisions in common scenarios while keeping the ID human-readable.
221
+ * @returns A human-readable unique identifier
222
+ */
223
+ private generateAssertionId(): string {
224
+ const baseSlug = slug(`${this}`, {
225
+ charmap: SLUG_CHARMAP,
226
+ });
227
+
228
+ // Add structural signature for additional uniqueness
229
+ // Use simple slot count and parts count as differentiators
230
+ const signature = `${this.slots.length}s${this.parts.length}p`;
231
+
232
+ return `${baseSlug}-${signature}`;
233
+ }
234
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Assertion creation factory functions with type-safe sync/async separation.
3
+ *
4
+ * This module provides the core factory functions for creating both synchronous
5
+ * and asynchronous assertions in the Bupkis assertion framework. It implements
6
+ * a dual-creation pattern where `createAssertion()` creates synchronous-only
7
+ * assertions and `createAsyncAssertion()` creates potentially asynchronous
8
+ * assertions, using branded Zod schema types to enforce compile-time safety and
9
+ * prevent accidental mixing of sync and async implementations.
10
+ *
11
+ * The module supports two primary assertion implementation types:
12
+ *
13
+ * - **Schema-based assertions**: Using {@link z.ZodType Zod schemas} for
14
+ * validation
15
+ * - **Function-based assertions**: Using implementation functions that return
16
+ * boolean, void, or Zod schemas for dynamic validation
17
+ *
18
+ * @remarks
19
+ * The factory functions use branded types to distinguish between synchronous
20
+ * and asynchronous schema implementations at compile time. This prevents
21
+ * accidentally passing async schemas to sync assertion creators and vice versa,
22
+ * ensuring type safety throughout the assertion system.
23
+ * @example Creating a synchronous string assertion:
24
+ *
25
+ * ```ts
26
+ * import { createAssertion } from './create.js';
27
+ * import { z } from 'zod/v4';
28
+ *
29
+ * const stringAssertion = createAssertion(['to be a string'], z.string());
30
+ * ```
31
+ *
32
+ * @example Creating an asynchronous Promise resolution assertion:
33
+ *
34
+ * ```ts
35
+ * import { createAsyncAssertion } from './create.js';
36
+ *
37
+ * const promiseAssertion = createAsyncAssertion(
38
+ * ['to resolve'],
39
+ * async (promise) => {
40
+ * try {
41
+ * await promise;
42
+ * return true;
43
+ * } catch {
44
+ * return false;
45
+ * }
46
+ * },
47
+ * );
48
+ * ```
49
+ *
50
+ * @example Creating parameterized assertions:
51
+ *
52
+ * ```ts
53
+ * import { createAssertion } from './create.js';
54
+ * import { z } from 'zod/v4';
55
+ *
56
+ * const greaterThanAssertion = createAssertion(
57
+ * [z.number(), 'to be greater than', z.number()],
58
+ * (subject, expected) => subject > expected,
59
+ * );
60
+ * ```
61
+ *
62
+ * @packageDocumentation
63
+ * @see {@link AssertionParts} for assertion part structure
64
+ * @see {@link AssertionSlots} for processed slot definitions
65
+ * @see {@link AssertionImplSync} for synchronous implementation types
66
+ * @see {@link AssertionImplAsync} for asynchronous implementation types
67
+ */
68
+
69
+ import { z } from 'zod/v4';
70
+
71
+ import type {
72
+ AssertionFunctionAsync,
73
+ AssertionFunctionSync,
74
+ AssertionImplAsync,
75
+ AssertionImplFnAsync,
76
+ AssertionImplFnSync,
77
+ AssertionImplSchemaAsync,
78
+ AssertionImplSchemaSync,
79
+ AssertionImplSync,
80
+ AssertionParts,
81
+ AssertionSchemaAsync,
82
+ AssertionSchemaSync,
83
+ AssertionSlots,
84
+ RawAssertionImplSchemaSync,
85
+ } from './assertion-types.js';
86
+
87
+ import { isFunction, isString, isZodType } from '../guards.js';
88
+ import {
89
+ BupkisAssertionFunctionAsync,
90
+ BupkisAssertionSchemaAsync,
91
+ } from './assertion-async.js';
92
+ import {
93
+ BupkisAssertionFunctionSync,
94
+ BupkisAssertionSchemaSync,
95
+ } from './assertion-sync.js';
96
+ import { slotify } from './slotify.js';
97
+
98
+ /**
99
+ * Create a synchronous `Assertion` from {@link AssertionParts parts} and a
100
+ * {@link z.ZodType Zod schema}.
101
+ *
102
+ * @param parts Assertion parts defining the shape of the assertion
103
+ * @param impl Implementation as a Zod schema
104
+ * @returns New `SchemaAssertion` instance
105
+ * @throws {TypeError} Invalid assertion implementation type
106
+ */
107
+ export function createAssertion<
108
+ const Parts extends AssertionParts,
109
+ Impl extends RawAssertionImplSchemaSync<Parts>,
110
+ Slots extends AssertionSlots<Parts>,
111
+ >(
112
+ parts: Parts,
113
+ impl: Impl,
114
+ ): AssertionSchemaSync<Parts, AssertionImplSchemaSync<Parts>, Slots>;
115
+ /**
116
+ * Create a synchronous `Assertion` from {@link AssertionParts parts} and an
117
+ * implementation function.
118
+ *
119
+ * @param parts Assertion parts defining the shape of the assertion
120
+ * @param impl Implementation as a function
121
+ * @returns New `FunctionAssertion` instance
122
+ * @throws {TypeError} Invalid assertion implementation type
123
+ */
124
+ export function createAssertion<
125
+ const Parts extends AssertionParts,
126
+ Impl extends AssertionImplFnSync<Parts>,
127
+ Slots extends AssertionSlots<Parts>,
128
+ >(parts: Parts, impl: Impl): AssertionFunctionSync<Parts, Impl, Slots>;
129
+ export function createAssertion<
130
+ Impl extends AssertionImplSync<Parts>,
131
+ const Parts extends AssertionParts,
132
+ >(parts: Parts, impl: Impl) {
133
+ if (!Array.isArray(parts)) {
134
+ throw new TypeError('First parameter must be an array');
135
+ }
136
+ if (parts.length === 0) {
137
+ throw new TypeError('At least one value is required for an assertion');
138
+ }
139
+ if (
140
+ !parts.every(
141
+ (part) => isString(part) || Array.isArray(part) || isZodType(part),
142
+ )
143
+ ) {
144
+ throw new TypeError('All assertion parts must be strings or Zod schemas');
145
+ }
146
+ if (!impl) {
147
+ throw new TypeError('An assertion implementation is required');
148
+ }
149
+ try {
150
+ const slots = slotify<Parts>(parts);
151
+
152
+ if (isZodType(impl)) {
153
+ return new BupkisAssertionSchemaSync(parts, slots, impl);
154
+ } else if (isFunction(impl)) {
155
+ return new BupkisAssertionFunctionSync(parts, slots, impl);
156
+ }
157
+ } catch (err) {
158
+ if (err instanceof z.ZodError) {
159
+ throw new TypeError(z.prettifyError(err));
160
+ }
161
+ throw err;
162
+ }
163
+ throw new TypeError(
164
+ 'Assertion implementation must be a function, Zod schema or Zod schema factory',
165
+ );
166
+ }
167
+ /**
168
+ * Create an async `Assertion` from {@link AssertionParts parts} and an async
169
+ * {@link z.ZodType Zod schema}.
170
+ *
171
+ * @param parts Assertion parts defining the shape of the assertion
172
+ * @param impl Implementation as a Zod schema (potentially async)
173
+ * @returns New `BupkisAssertionSchemaAsync` instance
174
+ * @throws {TypeError} Invalid assertion implementation type
175
+ */
176
+ export function createAsyncAssertion<
177
+ const Parts extends AssertionParts,
178
+ Impl extends AssertionImplSchemaAsync<Parts>,
179
+ Slots extends AssertionSlots<Parts>,
180
+ >(parts: Parts, impl: Impl): AssertionSchemaAsync<Parts, Impl, Slots>;
181
+
182
+ /**
183
+ * Create an async `Assertion` from {@link AssertionParts parts} and an
184
+ * implementation function.
185
+ *
186
+ * @param parts Assertion parts defining the shape of the assertion
187
+ * @param impl Implementation as a function (potentially async)
188
+ * @returns New `FunctionAssertion` instance
189
+ * @throws {TypeError} Invalid assertion implementation type
190
+ */
191
+ export function createAsyncAssertion<
192
+ const Parts extends AssertionParts,
193
+ Impl extends AssertionImplFnAsync<Parts>,
194
+ Slots extends AssertionSlots<Parts>,
195
+ >(parts: Parts, impl: Impl): AssertionFunctionAsync<Parts, Impl, Slots>;
196
+ export function createAsyncAssertion<
197
+ const Parts extends AssertionParts,
198
+ Impl extends AssertionImplAsync<Parts>,
199
+ >(parts: Parts, impl: Impl) {
200
+ if (!Array.isArray(parts)) {
201
+ throw new TypeError('First parameter must be an array');
202
+ }
203
+ if (parts.length === 0) {
204
+ throw new TypeError('At least one value is required for an assertion');
205
+ }
206
+ if (
207
+ !parts.every(
208
+ (part) => isString(part) || Array.isArray(part) || isZodType(part),
209
+ )
210
+ ) {
211
+ throw new TypeError('All assertion parts must be strings or Zod schemas');
212
+ }
213
+ if (!impl) {
214
+ throw new TypeError('An assertion implementation is required');
215
+ }
216
+ const slots = slotify<Parts>(parts);
217
+
218
+ if (isZodType(impl)) {
219
+ return new BupkisAssertionSchemaAsync(parts, slots, impl);
220
+ } else if (isFunction(impl)) {
221
+ return new BupkisAssertionFunctionAsync(parts, slots, impl);
222
+ }
223
+ throw new TypeError(
224
+ 'Assertion implementation must be a function, Zod schema or Zod schema factory',
225
+ );
226
+ }
@@ -0,0 +1,13 @@
1
+ # Assertion Implementations
2
+
3
+ This dir contains all the built-in assertions that come with _BUPKIS_.
4
+
5
+ They are sorted into the following files:
6
+
7
+ - `async.ts`: Assertions concerning asynchronous functions
8
+ - `sync-basic.ts`: Basic assertions that don't take parameters, e.g. "to be a string", "to be empty", "to be an Error", etc.
9
+ - `sync-collection.ts`: Assertions concerning collections (arrays, `Set`s, `Map`s, objects); may or may not be parametric
10
+ - `sync-esoteric.ts`: Arcane assertions
11
+ - `sync-parametric.ts`: Assertions that take parameters, e.g. "to be greater than", "to be one of", "to have property", etc.
12
+
13
+ All sync assertions are collected and re-exported from `sync.ts`.