@loomcli/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,406 @@
1
+ import { schemaOptions } from './context.js';
2
+ import { DeclarationError, InputError } from './errors.js';
3
+ /** Every declaration in validation order: the globals first, then the reading Command's own. */
4
+ function scoped(inputs) {
5
+ return [
6
+ ...inputs.globals.map((input) => ({ global: true, input })),
7
+ ...inputs.locals.map((input) => ({ global: false, input })),
8
+ ];
9
+ }
10
+ function identityOf({ global, input }) {
11
+ return { global, kind: input.kind, name: input.name };
12
+ }
13
+ /**
14
+ * The validated values of one invocation, keyed by declaration. Only `validateValues` constructs
15
+ * one, and its two readers are the only places where a validated value takes its declared type, so
16
+ * every binder reads through them and none asserts on its own.
17
+ */
18
+ class ValidatedInputs {
19
+ #values;
20
+ constructor(values) {
21
+ this.#values = values;
22
+ }
23
+ /** The one-key record this argument contributes to `args`, typed by its own config. */
24
+ argument(input) {
25
+ return this.#field(input);
26
+ }
27
+ /** The one-key record this option contributes to `options`, typed by its own config. */
28
+ option(input) {
29
+ return this.#field(input);
30
+ }
31
+ #field(input) {
32
+ // Last resort: no typed path exists. The map stores every validated value as `unknown`.
33
+ // An object literal with a generic computed key does not type as `Record<Name, _>` either.
34
+ // So neither the value nor the key can reach `Record<Name, Value>` without this assertion.
35
+ // It holds because validation stores the output the config declares under this declaration.
36
+ // The two callers above derive `Value` from that same config.
37
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
38
+ return { [input.name]: this.#values.get(input) };
39
+ }
40
+ }
41
+ /**
42
+ * Authoring's snapshot of one config. An array default is the one declared value core hands to an
43
+ * action as its own value, so the declaration keeps a copy and the caller keeps its array. Every
44
+ * other property is captured as declared, because core clones no library object.
45
+ */
46
+ export function captureConfig(config) {
47
+ const value = config.default;
48
+ return Array.isArray(value) ? { ...config, default: [...value] } : { ...config };
49
+ }
50
+ /**
51
+ * A declaration error names the declaration, because the author reads the declaration to fix it.
52
+ * An argument declares and reads under one name, so the two namings differ for options alone.
53
+ */
54
+ function declaredName(input) {
55
+ return input.kind === 'argument' ? `Argument "${input.name}"` : `Option "${input.name}"`;
56
+ }
57
+ /**
58
+ * The token an operator would type for one declaration: `--file` for an option, `-F` when the
59
+ * option declares `shortOnly`, and the declared name for an argument. Every input diagnostic and
60
+ * every reported problem names the declaration this way, so an omission and a rejected value read
61
+ * alike and a `shortOnly` option is never named by a long form it does not accept.
62
+ */
63
+ function spellingOf(input) {
64
+ if (input.kind === 'argument') {
65
+ return input.name;
66
+ }
67
+ const { config } = input;
68
+ return config.shortOnly === true && config.short !== undefined
69
+ ? `-${config.short}`
70
+ : `--${input.name}`;
71
+ }
72
+ /** An input diagnostic names the declaration by kind and by the spelling that reaches it. */
73
+ function suppliedName(input, spelling) {
74
+ return input.kind === 'argument' ? `Argument "${spelling}"` : `Option "${spelling}"`;
75
+ }
76
+ /**
77
+ * The default sentence for an omitted required input. An argument and an option keep the wording
78
+ * each phase used before omission became one problem, and a collected input asks for one value
79
+ * more than a scalar does.
80
+ */
81
+ function missingMessage(input, spelling, collected) {
82
+ return input.kind === 'argument'
83
+ ? `Argument "${spelling}" requires ${collected ? 'at least one value' : 'a value'}. Supply a value for "${spelling}".`
84
+ : `Option "${spelling}" is required. Supply ${collected ? 'at least one value' : 'a value'}.`;
85
+ }
86
+ /**
87
+ * A multiple option collects its occurrences and a variadic argument collects the remaining
88
+ * tokens, so either one carries the whole `string[]` as its raw value.
89
+ */
90
+ function collects(input) {
91
+ return input.kind === 'option' ? input.config.multiple === true : input.config.variadic === true;
92
+ }
93
+ /**
94
+ * The declaration flag that sends an omitted value to its own schema. Every declaration reads it
95
+ * here, and the declaration rules below reject it wherever another rule already decides absence.
96
+ */
97
+ export function validatesOmission(input) {
98
+ const { config } = input;
99
+ return 'validateOmitted' in config && config.validateOmitted;
100
+ }
101
+ /** One accessor for a supplied option value, so the collected and single shapes read alike. */
102
+ function suppliedOption(options, name, collected) {
103
+ return collected ? options.lists.get(name) : options.strings.get(name);
104
+ }
105
+ /** The copy a collected value is handed out as, because the parser's array is the action's. */
106
+ function copied(value) {
107
+ return Array.isArray(value) ? [...value] : value;
108
+ }
109
+ /** Without a schema the raw shape is the declared default's only contract. */
110
+ function holdsRawDefault(input) {
111
+ const value = input.config.default;
112
+ return collects(input)
113
+ ? Array.isArray(value) && value.every((entry) => typeof entry === 'string')
114
+ : typeof value === 'string';
115
+ }
116
+ /**
117
+ * `validateOmitted: true` is the one way an omitted scalar reaches its schema, so every other rule
118
+ * that already decides absence rejects it, and the flag needs a schema to receive the omission.
119
+ */
120
+ function checkOmissionValidation(input) {
121
+ const { config } = input;
122
+ const subject = declaredName(input);
123
+ if (config.required) {
124
+ throw new DeclarationError(`${subject} is required and declares validateOmitted. Remove validateOmitted or make the input optional.`);
125
+ }
126
+ if (hasDefault(input)) {
127
+ throw new DeclarationError(`${subject} declares a default and validateOmitted. Remove one; the default already fills an omitted value.`);
128
+ }
129
+ if (collects(input)) {
130
+ throw new DeclarationError(`${subject} collects its values and declares validateOmitted. Remove validateOmitted; an omitted collection reaches the schema as an empty array.`);
131
+ }
132
+ if (config.validate === undefined) {
133
+ throw new DeclarationError(`${subject} declares validateOmitted without a schema. Add validate or remove validateOmitted.`);
134
+ }
135
+ }
136
+ function checkDeclaration(input) {
137
+ const { config } = input;
138
+ if (input.kind === 'option' && input.config.type === 'boolean') {
139
+ if ('validate' in config ||
140
+ 'default' in config ||
141
+ 'required' in config ||
142
+ 'validateOmitted' in config) {
143
+ throw new DeclarationError(`${declaredName(input)} is Boolean. Remove validate, default, required, and validateOmitted; use polarity to control its absent value.`);
144
+ }
145
+ return;
146
+ }
147
+ if (config.required !== undefined && typeof config.required !== 'boolean') {
148
+ throw new DeclarationError(`${declaredName(input)} required must be Boolean. Use true or false.`);
149
+ }
150
+ if (input.kind === 'argument' &&
151
+ input.config.variadic !== undefined &&
152
+ typeof input.config.variadic !== 'boolean') {
153
+ throw new DeclarationError(`${declaredName(input)} variadic must be Boolean. Use true or false.`);
154
+ }
155
+ // The test reads presence, not truth, so a declared `undefined` is a declaration to reject.
156
+ if ('validateOmitted' in config && typeof config.validateOmitted !== 'boolean') {
157
+ throw new DeclarationError(`${declaredName(input)} validateOmitted must be Boolean. Use true or false.`);
158
+ }
159
+ if (config.required && Object.hasOwn(config, 'default')) {
160
+ throw new DeclarationError(`${declaredName(input)} is required and declares a default. Remove the default or make the input optional.`);
161
+ }
162
+ if (validatesOmission(input)) {
163
+ checkOmissionValidation(input);
164
+ }
165
+ const schema = config.validate;
166
+ if (schema !== undefined &&
167
+ (schema === null ||
168
+ (typeof schema !== 'object' && typeof schema !== 'function') ||
169
+ !schema['~standard'] ||
170
+ schema['~standard'].version !== 1 ||
171
+ typeof schema['~standard'].vendor !== 'string' ||
172
+ typeof schema['~standard'].validate !== 'function')) {
173
+ throw new DeclarationError(`${declaredName(input)} validate must be a Standard Schema v1 object. Supply a compatible schema.`);
174
+ }
175
+ }
176
+ function readIssue(issue) {
177
+ if (issue === null || typeof issue !== 'object' || !('message' in issue)) {
178
+ throw new Error('The validator returned an invalid Standard Schema issue.');
179
+ }
180
+ const message = issue.message;
181
+ if (typeof message !== 'string') {
182
+ throw new Error('The validator returned an invalid Standard Schema issue message.');
183
+ }
184
+ const suppliedPath = 'path' in issue ? issue.path : undefined;
185
+ if (suppliedPath === undefined) {
186
+ return { message };
187
+ }
188
+ if (!Array.isArray(suppliedPath)) {
189
+ throw new Error('The validator returned an invalid Standard Schema issue path.');
190
+ }
191
+ const path = Array.from(suppliedPath, (segment) => {
192
+ const key = segment !== null && typeof segment === 'object' && 'key' in segment ? segment.key : segment;
193
+ if (typeof key !== 'string' && typeof key !== 'number' && typeof key !== 'symbol') {
194
+ throw new Error('The validator returned an invalid Standard Schema path key.');
195
+ }
196
+ return key;
197
+ });
198
+ return { message, path };
199
+ }
200
+ /**
201
+ * A broken validator is a fault in the declaration, whichever value reached it, so its diagnostic
202
+ * names the declaration. Returned issues belong to the value, so the caller names those.
203
+ */
204
+ async function validate(input, raw, context) {
205
+ const schema = input.config.validate;
206
+ if (schema === undefined) {
207
+ return { value: raw };
208
+ }
209
+ try {
210
+ const result = await schema['~standard'].validate(raw, schemaOptions(context));
211
+ if (result === null || typeof result !== 'object') {
212
+ throw new Error('The validator returned an invalid Standard Schema result.');
213
+ }
214
+ const issues = 'issues' in result ? result.issues : undefined;
215
+ if (issues === undefined && 'value' in result) {
216
+ return { value: result.value };
217
+ }
218
+ if (!Array.isArray(issues)) {
219
+ throw new Error('The validator returned an invalid Standard Schema result.');
220
+ }
221
+ return { issues: Array.from(issues, readIssue) };
222
+ }
223
+ catch (error) {
224
+ const reason = error instanceof Error ? error.message : 'Unknown validator failure.';
225
+ throw new DeclarationError(`${declaredName(input)} validator failed unexpectedly: ${reason} Fix the validator.`);
226
+ }
227
+ }
228
+ /**
229
+ * The dotted path an issue names inside a value, or `undefined` when the issue names the value
230
+ * itself. Core's default text and an application's own renderer read a position through this one
231
+ * helper, so a rejected item reads alike wherever its diagnostic is written.
232
+ */
233
+ export function issuePath(issue) {
234
+ const path = issue.path
235
+ ?.map((segment) => String(typeof segment === 'object' ? segment.key : segment))
236
+ .join('.');
237
+ return path === undefined || path === '' ? undefined : path;
238
+ }
239
+ /**
240
+ * The issues one rejection reports. A schema that returned none still rejected the value, so the
241
+ * placeholder stands in for its silence. Reporting takes this list once: the reported problem
242
+ * carries it and the default text is derived from it, so a renderer and core read the same issues.
243
+ */
244
+ function reported(issues) {
245
+ return issues.length === 0
246
+ ? [{ message: 'The schema rejected this value without an explanation.' }]
247
+ : issues;
248
+ }
249
+ function messages(subject, issues) {
250
+ return issues.map((issue) => {
251
+ const path = issuePath(issue);
252
+ return `${subject}${path === undefined ? '' : ` at ${path}`}: ${issue.message}`;
253
+ });
254
+ }
255
+ /** A declared `default: undefined` is a default, so presence is the key, never the value. */
256
+ function hasDefault(input) {
257
+ return Object.hasOwn(input.config, 'default');
258
+ }
259
+ /**
260
+ * Every declaration rule that reads the declaration alone. It is synchronous, so `inspect()` and
261
+ * `run()` apply exactly the same rules, and only validating a default through its schema, which
262
+ * can be asynchronous, is left to `run()`.
263
+ */
264
+ export function checkDeclarations(inputs) {
265
+ for (const input of inputs) {
266
+ checkDeclaration(input);
267
+ }
268
+ for (const input of inputs.filter((entry) => hasDefault(entry))) {
269
+ if (input.config.validate === undefined && !holdsRawDefault(input)) {
270
+ const subject = declaredName(input);
271
+ throw new DeclarationError(collects(input)
272
+ ? `${subject} default must be an array of strings without a schema. Supply a string array default.`
273
+ : `${subject} default must be a string without a schema. Supply a string default.`);
274
+ }
275
+ }
276
+ }
277
+ /**
278
+ * Every declared default, validated before any token is read. The host is captured by then, so a
279
+ * default's schema reads the same Host its action will, under the `default` phase.
280
+ */
281
+ export async function prepareInputs(inputs, host) {
282
+ const declarations = scoped(inputs);
283
+ checkDeclarations(declarations.map((entry) => entry.input));
284
+ const defaults = new Map();
285
+ for (const entry of declarations.filter(({ input }) => hasDefault(input))) {
286
+ const { input } = entry;
287
+ const subject = declaredName(input);
288
+ const result = await validate(input, input.config.default, {
289
+ host,
290
+ input: identityOf(entry),
291
+ phase: 'default',
292
+ });
293
+ if (result.issues !== undefined) {
294
+ throw new DeclarationError(`${subject} has an invalid default. Fix the default or its schema.\n${messages(subject, reported(result.issues)).join('\n')}`);
295
+ }
296
+ defaults.set(input, result.value);
297
+ }
298
+ return defaults;
299
+ }
300
+ /**
301
+ * An array default reaches the action as its own copy, so an action that mutates its collection
302
+ * rewrites neither the declaration nor the next invocation. A schema that returns a new array is
303
+ * copied too, because a pass-through schema returns the declared array itself and cannot be told
304
+ * apart from one that built its own. Every other output passes through unchanged.
305
+ */
306
+ function freshDefault(value) {
307
+ return Array.isArray(value) ? [...value] : value;
308
+ }
309
+ /**
310
+ * The raw tokens of one invocation, keyed by declared name. Every declared input of the routed
311
+ * Command and every global appears, so absence reads as the shape its declaration collects. Each
312
+ * collected value is copied, because the parser's own array is what the action reads.
313
+ */
314
+ function suppliedInputs(declarations, supplied) {
315
+ const args = {};
316
+ const options = {};
317
+ for (const input of declarations) {
318
+ const collected = collects(input);
319
+ if (input.kind === 'argument') {
320
+ args[input.name] = copied(supplied.args.get(input)) ?? (collected ? [] : undefined);
321
+ }
322
+ else if (input.config.type === 'boolean') {
323
+ options[input.name] = supplied.options.booleans.get(input.name);
324
+ }
325
+ else {
326
+ options[input.name] =
327
+ copied(suppliedOption(supplied.options, input.name, collected)) ??
328
+ (collected ? [] : undefined);
329
+ }
330
+ }
331
+ return { args, options };
332
+ }
333
+ export async function validateValues(invocation) {
334
+ const { defaults, supplied } = invocation;
335
+ const declarations = scoped(invocation.inputs);
336
+ /**
337
+ * One reading of the tokens and the route, built anew for each schema call. The route, the
338
+ * tail, and every collected value are copies, so a schema that writes to them reaches neither
339
+ * the parser's collections, nor the tail the action receives, nor the next schema of this
340
+ * invocation. The host is the captured object itself, the one the action receives.
341
+ */
342
+ const facts = () => ({
343
+ command: [...invocation.command],
344
+ host: invocation.host,
345
+ passthrough: [...invocation.passthrough],
346
+ supplied: suppliedInputs(declarations.map((entry) => entry.input), supplied),
347
+ });
348
+ const values = new Map();
349
+ const lines = [];
350
+ const problems = [];
351
+ /** One path for every value the schema reads, so a raw shape and its issues meet it once. */
352
+ const accept = async (entry, raw, spelling) => {
353
+ const result = await validate(entry.input, raw, {
354
+ ...facts(),
355
+ input: identityOf(entry),
356
+ phase: 'invocation',
357
+ });
358
+ if (result.issues === undefined) {
359
+ values.set(entry.input, result.value);
360
+ return;
361
+ }
362
+ const issues = reported(result.issues);
363
+ problems.push({ input: identityOf(entry), issues, reason: 'invalid', spelling });
364
+ lines.push(...messages(suppliedName(entry.input, spelling), issues));
365
+ };
366
+ for (const entry of declarations) {
367
+ const { input } = entry;
368
+ if (input.kind === 'option' && input.config.type === 'boolean') {
369
+ values.set(input, supplied.options.booleans.get(input.name) ?? input.config.polarity === 'negative');
370
+ }
371
+ else {
372
+ const collected = collects(input);
373
+ const spelling = spellingOf(input);
374
+ const raw = input.kind === 'argument'
375
+ ? supplied.args.get(input)
376
+ : suppliedOption(supplied.options, input.name, collected);
377
+ if (raw === undefined) {
378
+ if (input.config.required) {
379
+ // An omitted required argument arrives here too, so omission has one class.
380
+ // One aggregated diagnostic covers an omitted argument and an omitted option alike.
381
+ problems.push({ input: identityOf(entry), reason: 'missing', spelling });
382
+ lines.push(missingMessage(input, spelling, collected));
383
+ }
384
+ else if (collected && !defaults.has(input)) {
385
+ // No occurrence is an accurate empty collection, so it reads like a supplied value.
386
+ await accept(entry, [], spelling);
387
+ }
388
+ else if (validatesOmission(input)) {
389
+ // The flag sends the omission itself to the schema.
390
+ // An absence rule reads the context a supplied value reads, and reports input issues.
391
+ await accept(entry, undefined, spelling);
392
+ }
393
+ else {
394
+ values.set(input, freshDefault(defaults.get(input)));
395
+ }
396
+ }
397
+ else {
398
+ await accept(entry, raw, spelling);
399
+ }
400
+ }
401
+ }
402
+ if (problems.length > 0) {
403
+ throw new InputError(lines.join('\n'), problems);
404
+ }
405
+ return new ValidatedInputs(values);
406
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@loomcli/core",
3
+ "version": "0.1.0",
4
+ "description": "The Loom CLI core package. Provides the scaffolding for creating new Loom CLI applications.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/dbtlr/loomcli.git"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "type": "module",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ }
19
+ },
20
+ "dependencies": {
21
+ "@standard-schema/spec": "^1.1.0",
22
+ "@types/node": "22.20.1"
23
+ },
24
+ "engines": {
25
+ "bun": ">=1.4.0",
26
+ "node": ">=22.23.2"
27
+ }
28
+ }