@loomcli/core 0.4.0 → 0.5.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,249 @@
1
+ import { asSentence, InputError, InternalError, reasonOf, ResultError } from './errors.js';
2
+ import { isPlainObject, isProseLine } from './facts.js';
3
+ import { isSupplied } from './options.js';
4
+ import { loadDefault, pluginSentence, pluginValues } from './plugin.js';
5
+ /** The Boolean grammar a bound variable is read through: the whole value, case-insensitive. */
6
+ const truths = new Map([
7
+ ['0', false],
8
+ ['1', true],
9
+ ['false', false],
10
+ ['true', true],
11
+ ]);
12
+ /**
13
+ * Reads one option's bound variable. An empty variable is unset and falls through, a string option
14
+ * receives the raw string, and a Boolean option reads the whole value through the grammar, so the
15
+ * value states the option's value and not a spelling.
16
+ */
17
+ function readVariable(input, env) {
18
+ const variable = input.config.env;
19
+ const raw = variable !== undefined && Object.hasOwn(env, variable) ? env[variable] : undefined;
20
+ if (raw === undefined || raw === '') {
21
+ return undefined;
22
+ }
23
+ if (input.config.type === 'string') {
24
+ return { kind: 'fill', value: raw };
25
+ }
26
+ const value = truths.get(raw.toLowerCase());
27
+ return value === undefined ? { kind: 'rejected' } : { kind: 'fill', value };
28
+ }
29
+ /** Writes one filled value where the option's own shape keeps it, a list as the run's own copy. */
30
+ function fill(values, name, value) {
31
+ if (typeof value === 'boolean') {
32
+ values.booleans.set(name, value);
33
+ }
34
+ else if (typeof value === 'string') {
35
+ values.strings.set(name, value);
36
+ }
37
+ else {
38
+ values.lists.set(name, [...value]);
39
+ }
40
+ }
41
+ /** How a fault names the value an answer owed, by the shape the option takes. */
42
+ const owed = {
43
+ boolean: 'a Boolean',
44
+ list: 'an array of strings',
45
+ string: 'a string',
46
+ };
47
+ function rawKind(input) {
48
+ if (input.config.type === 'boolean') {
49
+ return 'boolean';
50
+ }
51
+ return input.config.multiple === true ? 'list' : 'string';
52
+ }
53
+ /**
54
+ * The run's own copy of an answered list, or `undefined` when it is not a list of strings. Every
55
+ * index is read, so a hole fails the check as any entry that is not a string does.
56
+ */
57
+ function stringList(value) {
58
+ if (!Array.isArray(value)) {
59
+ return undefined;
60
+ }
61
+ const list = [];
62
+ // Iteration visits a hole as undefined, where every() skips it.
63
+ for (const entry of value) {
64
+ if (typeof entry !== 'string') {
65
+ return undefined;
66
+ }
67
+ list.push(entry);
68
+ }
69
+ return list;
70
+ }
71
+ /** One answered value as the raw type its option takes, or `undefined` when it holds another. */
72
+ function rawValue(kind, value) {
73
+ if (kind === 'list') {
74
+ return stringList(value);
75
+ }
76
+ if (kind === 'boolean') {
77
+ return typeof value === 'boolean' ? value : undefined;
78
+ }
79
+ return typeof value === 'string' ? value : undefined;
80
+ }
81
+ /**
82
+ * The faults the answers rule names. Reading the answers runs the plugin's own code, such as a
83
+ * getter, so a throw of any other kind while reading them is the plugin's failure.
84
+ */
85
+ const ruleFaults = new WeakSet();
86
+ /** A fault the answers rule names, recorded so that reading the answers rethrows it unframed. */
87
+ function ruleFault(message) {
88
+ const fault = new InternalError(message, undefined);
89
+ ruleFaults.add(fault);
90
+ return fault;
91
+ }
92
+ /** One answer read under the answers rule: an object that holds a one-line label and a value. */
93
+ function readAnswer(sentence, input, answer) {
94
+ const shape = isPlainObject(answer) ? answer : undefined;
95
+ const label = shape?.label;
96
+ if (!shape || !isProseLine(label)) {
97
+ throw ruleFault(`${sentence} answered option "${input.name}" with an answer that is not { value, label }.`);
98
+ }
99
+ const kind = rawKind(input);
100
+ const value = rawValue(kind, shape.value);
101
+ if (value === undefined) {
102
+ throw ruleFault(`${sentence} answered option "${input.name}" with a value that is not ${owed[kind]}.`);
103
+ }
104
+ return { label, value };
105
+ }
106
+ /**
107
+ * Every answer the resolver returned, read before any is filled, so an answer the rule rejects
108
+ * leaves every requested option unfilled. A key core did not request is the source's fault.
109
+ */
110
+ function readAnswers(sentence, answers, requested) {
111
+ if (!isPlainObject(answers)) {
112
+ throw ruleFault(`${sentence} returned configuration answers that are not a record.`);
113
+ }
114
+ const byName = new Map(requested.map((target) => [target.input.name, target]));
115
+ return Object.entries(answers).map(([name, answer]) => {
116
+ const target = byName.get(name);
117
+ if (!target) {
118
+ throw ruleFault(`${sentence} answered option "${name}", which core did not request.`);
119
+ }
120
+ const { label, value } = readAnswer(sentence, target.input, answer);
121
+ return { label, target, value };
122
+ });
123
+ }
124
+ /**
125
+ * The default export a source loader must resolve to. A loaded module is data core never declared,
126
+ * so the check is the one runtime fact that decides it: the export is callable.
127
+ */
128
+ function isResolverExport(value) {
129
+ return typeof value === 'function';
130
+ }
131
+ /** The fault of a source that threw, while it ran or while core read what it returned. */
132
+ function sourceFailure(sentence, error) {
133
+ return new InternalError(`${sentence} failed in its configuration source: ${asSentence(reasonOf(error))}`, error);
134
+ }
135
+ /**
136
+ * Asks the one configuration source about every requested option, and answers with what it said.
137
+ * Core loads the source here and calls it once, awaiting it; one cancelled during the call awaits
138
+ * it. The run checks its signal and then reaches the load with no await between, so a run
139
+ * cancelled before the load starts neither step, and one cancelled during the load calls nothing.
140
+ * Core builds the context before the call, so a fault of its own is never the plugin's.
141
+ */
142
+ async function askSource(stage, call) {
143
+ const { owner, requested } = call;
144
+ const resolver = await loadDefault(owner.identity, owner.source.load, {
145
+ guard: isResolverExport,
146
+ noun: 'source',
147
+ });
148
+ if (stage.signal.aborted) {
149
+ return [];
150
+ }
151
+ const sentence = pluginSentence(owner.identity);
152
+ const context = {
153
+ graph: stage.inspected(),
154
+ host: stage.host,
155
+ options: pluginValues(owner.inputs, stage.globals.values),
156
+ out: stage.out,
157
+ requests: requested.map(({ input, scope }) => stage.request(input, scope.global)),
158
+ style: stage.style,
159
+ };
160
+ let answers = undefined;
161
+ try {
162
+ answers = await resolver(context);
163
+ }
164
+ catch (error) {
165
+ // The resolver's own InputError is a usage failure.
166
+ // Its out.results() call is the results fault that names it.
167
+ // Every other throw is the plugin's fault.
168
+ if (error instanceof InputError || (error instanceof ResultError && error.kind === 'source')) {
169
+ throw error;
170
+ }
171
+ throw sourceFailure(sentence, error);
172
+ }
173
+ try {
174
+ return readAnswers(sentence, answers, requested);
175
+ }
176
+ catch (error) {
177
+ if (error instanceof InternalError && ruleFaults.has(error)) {
178
+ throw error;
179
+ }
180
+ throw sourceFailure(sentence, error);
181
+ }
182
+ }
183
+ /**
184
+ * The environment tier: each option in scope that argv left unfilled reads its bound variable. A
185
+ * filled option is labeled with its variable, and a Boolean one outside the grammar is recorded
186
+ * as rejected, which fills nothing.
187
+ */
188
+ function fillFromEnvironment(scopes, env) {
189
+ const labels = new Map();
190
+ const rejected = new Map();
191
+ for (const scope of scopes) {
192
+ for (const input of scope.inputs) {
193
+ const reading = isSupplied(scope.values, input.name) ? undefined : readVariable(input, env);
194
+ const variable = input.config.env ?? '';
195
+ if (reading?.kind === 'fill') {
196
+ fill(scope.values, input.name, reading.value);
197
+ labels.set(input.name, variable);
198
+ }
199
+ else if (reading?.kind === 'rejected') {
200
+ rejected.set(input.name, variable);
201
+ }
202
+ }
203
+ }
204
+ return { labels, rejected };
205
+ }
206
+ /**
207
+ * The options the configuration source is asked about: those still unfilled that carry its
208
+ * binding and hold no environment fault, the globals table first and then the routed Command's own,
209
+ * each in declaration order.
210
+ */
211
+ function requestedOf(scopes, excluded, bound) {
212
+ return scopes.flatMap((scope) => scope.inputs
213
+ .filter((input) => !isSupplied(scope.values, input.name) &&
214
+ !excluded.has(input.name) &&
215
+ Object.hasOwn(bound.extensions.get(input) ?? {}, bound.binding))
216
+ .map((input) => ({ input, scope })));
217
+ }
218
+ /**
219
+ * The input-source stage: the environment, then the configuration source, for every option in
220
+ * scope that argv left unfilled. When no option is left to ask, the source never loads. A source
221
+ * fault stops the stage and fills nothing more.
222
+ */
223
+ async function fillInputs(stage) {
224
+ const scopes = stage.locals ? [stage.globals, stage.locals] : [stage.globals];
225
+ const { labels, rejected } = fillFromEnvironment(scopes, stage.host.env);
226
+ const owner = stage.plugins.find((installed) => installed.source !== undefined);
227
+ const requested = owner
228
+ ? requestedOf(scopes, rejected, { binding: owner.source.binding, extensions: stage.extensions })
229
+ : [];
230
+ if (!owner || requested.length === 0) {
231
+ return { fault: undefined, labels, rejected };
232
+ }
233
+ try {
234
+ for (const { label, target, value } of await askSource(stage, { owner, requested })) {
235
+ fill(target.scope.values, target.input.name, value);
236
+ labels.set(target.input.name, label);
237
+ }
238
+ return { fault: undefined, labels, rejected };
239
+ }
240
+ catch (error) {
241
+ // The resolver's InputError reports as a usage failure.
242
+ // Every other fault above is raised as an internal error, and anything else is wrapped the same way.
243
+ const fault = error instanceof InternalError || error instanceof InputError
244
+ ? error
245
+ : new InternalError(reasonOf(error), error);
246
+ return { fault, labels, rejected };
247
+ }
248
+ }
249
+ export { fillInputs };
package/dist/types.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import type { Readable, Writable } from 'node:stream';
3
3
  import type { StandardSchemaV1 } from '@standard-schema/spec';
4
4
  import type { ExtensionValue } from './extension.js';
5
- import type { ResultNode } from './inspect.js';
5
+ import type { CommandGraph, CommandNode, ResultNode } from './inspect.js';
6
6
  import type { RenderingPolicy } from './rendering.js';
7
7
  import type { ContextualStyle } from './style.js';
8
8
  type LowercaseLetter = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z';
@@ -21,12 +21,23 @@ type Presence = {
21
21
  required?: false;
22
22
  default?: unknown;
23
23
  };
24
- /** The literal member keeps `multiple: true` exact under contextual typing, as `Presence` does. */
24
+ /**
25
+ * The literal member keeps `multiple: true` exact under contextual typing, as `Presence` does.
26
+ * A multiple option takes its list from the configuration source, so it binds no variable.
27
+ */
25
28
  type Multiplicity = {
26
29
  multiple: true;
27
- } | {
30
+ env?: never;
31
+ } | ({
28
32
  multiple?: false;
29
- };
33
+ } & EnvBinding);
34
+ /**
35
+ * The environment binding: the variable the input-source stage fills the option from when argv
36
+ * supplies none. The declaration names it explicitly, and core derives no name.
37
+ */
38
+ interface EnvBinding {
39
+ env?: string;
40
+ }
30
41
  /**
31
42
  * `validateOmitted: true` sends an omitted optional scalar to its own schema. The literal member
32
43
  * keeps the flag exact under contextual typing, as `Multiplicity` does.
@@ -62,15 +73,22 @@ interface ArgumentExtensions {
62
73
  extensions?: readonly ExtensionValue<'argument'>[];
63
74
  }
64
75
  /**
65
- * The tokens one declaration collects before validation: one string, or the whole collection. A
66
- * multiple option and a variadic argument collect alike, so they share this raw shape.
76
+ * The tokens one declaration collects before validation: one string, or several. A multiple option
77
+ * and a variadic argument collect alike, so they share this raw shape.
67
78
  */
68
79
  type RawValue<Config> = Config extends {
69
80
  multiple: true;
70
81
  } | {
71
82
  variadic: true;
72
83
  } ? string[] : string;
73
- type SchemaOutput<Schema, Raw> = Schema extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<Schema> : Raw;
84
+ /** A multiple option and a variadic argument pass each value through the same validator. */
85
+ type PerValue<Config, Value> = Config extends {
86
+ multiple: true;
87
+ } | {
88
+ variadic: true;
89
+ } ? Value[] : Value;
90
+ /** The validated value: the validator's output for each value, or the raw shape without one. */
91
+ type SchemaOutput<Config, Schema, Raw> = Schema extends StandardSchemaV1 ? PerValue<Config, StandardSchemaV1.InferOutput<Schema>> : Raw;
74
92
  type SchemaInput<Schema> = Schema extends StandardSchemaV1 ? StandardSchemaV1.InferInput<Schema> : string;
75
93
  /** The named key states the rule, so a rejected declaration name reads as its own diagnostic. */
76
94
  interface LiteralNameFault {
@@ -324,19 +342,24 @@ export type ScalarArgument = Presence & Omission & Described & ArgumentExtension
324
342
  validate?: StandardSchemaV1;
325
343
  };
326
344
  export type ArgumentConfig = VariadicArgument | ScalarArgument;
327
- export type ValidatedValue<Config, Raw> = Config extends unknown ? 'validate' extends keyof Config ? SchemaOutput<Config['validate'], Raw> : Raw : never;
328
- /** Keep each conditional declaration paired with its own schema input type. */
345
+ export type ValidatedValue<Config, Raw> = Config extends unknown ? 'validate' extends keyof Config ? SchemaOutput<Config, Config['validate'], Raw> : Raw : never;
346
+ /** Keep each conditional declaration paired with its own validator input type. */
329
347
  export type DefaultConstraint<Config> = Config extends unknown ? Config & {
330
- default?: 'validate' extends keyof Config ? SchemaInput<Config['validate']> : RawValue<Config>;
348
+ default?: 'validate' extends keyof Config ? PerValue<Config, SchemaInput<Config['validate']>> : RawValue<Config>;
331
349
  } : never;
332
350
  /**
333
- * A multiple option hands its whole collection to one schema, so the declared schema must accept a
334
- * `string[]` input. The key names the fault, the way the other declaration constraints do.
351
+ * A multiple option or a variadic argument passes each value to its validator alone, so the
352
+ * declared validator must accept one `string`. The key names the fault, the way the other
353
+ * declaration constraints do.
335
354
  */
336
- export type MultipleConstraint<Config> = Config extends {
355
+ export type PerValueConstraint<Config> = Config extends {
337
356
  multiple: true;
338
- } ? 'validate' extends keyof Config ? string[] extends SchemaInput<Config['validate']> ? unknown : {
339
- 'A multiple option schema must accept a string[] input': Config['validate'];
357
+ } | {
358
+ variadic: true;
359
+ } ? 'validate' extends keyof Config ? [
360
+ SchemaInput<Config['validate']>
361
+ ] extends [never] ? unknown : string extends SchemaInput<Config['validate']> ? unknown : {
362
+ 'A validator of several values must accept one string input': Config['validate'];
340
363
  } : unknown : unknown;
341
364
  /**
342
365
  * The declaration rules `validateOmitted: true` needs: a schema that accepts `undefined`, an
@@ -362,12 +385,28 @@ export type ValidateOmittedConstraint<Config> = Config extends {
362
385
  } | {
363
386
  variadic: true;
364
387
  } ? {
365
- 'A collected input validates its omission as an empty array': never;
388
+ 'An input of several values receives no values as an empty array': never;
366
389
  } : 'validate' extends keyof Config ? undefined extends SchemaInput<Config['validate']> ? unknown : {
367
- 'A validateOmitted schema must accept an undefined input': Config['validate'];
390
+ 'A validateOmitted validator must accept an undefined input': Config['validate'];
368
391
  } : {
369
- 'validateOmitted needs a validate schema to receive the omission': never;
392
+ 'validateOmitted needs a validator to receive the omission': never;
370
393
  } : unknown;
394
+ /**
395
+ * A global option declares no presence rule, so its omission is always plain absence. A union
396
+ * config fails when any member declares the key, and a wide `OptionConfig` passes, because its
397
+ * members only allow the key.
398
+ */
399
+ export type GlobalOmissionConstraint<Config> = [Extract<Config, {
400
+ required: unknown;
401
+ }>] extends [
402
+ never
403
+ ] ? [Extract<Config, {
404
+ validateOmitted: unknown;
405
+ }>] extends [never] ? unknown : {
406
+ 'A global option declares no validateOmitted; its omission is plain absence': never;
407
+ } : {
408
+ 'A global option declares no required; the Commands that read it check for it': never;
409
+ };
371
410
  export type ArgumentValue<Config extends ArgumentConfig> = Config extends {
372
411
  variadic: true;
373
412
  } ? ValidatedValue<Config, string[]> : ValidatedValue<Config, string> | (Config extends {
@@ -377,7 +416,7 @@ export type ArgumentValue<Config extends ArgumentConfig> = Config extends {
377
416
  } | {
378
417
  validateOmitted: true;
379
418
  } ? never : undefined);
380
- export type BooleanOption = (OptionSpelling & Described & Listed & OptionExtensions & {
419
+ export type BooleanOption = (OptionSpelling & Described & Listed & EnvBinding & OptionExtensions & {
381
420
  type: 'boolean';
382
421
  validate?: never;
383
422
  default?: never;
@@ -385,7 +424,7 @@ export type BooleanOption = (OptionSpelling & Described & Listed & OptionExtensi
385
424
  required?: never;
386
425
  validateOmitted?: never;
387
426
  polarity?: 'positive' | 'negative';
388
- }) | (Described & Listed & OptionExtensions & {
427
+ }) | (Described & Listed & EnvBinding & OptionExtensions & {
389
428
  type: 'boolean';
390
429
  validate?: never;
391
430
  default?: never;
@@ -422,10 +461,16 @@ export type OptionValue<Config extends OptionConfig> = Config extends StringOpti
422
461
  } | {
423
462
  validateOmitted: true;
424
463
  } ? never : undefined) : boolean;
464
+ /**
465
+ * What an action receives. `graph` is the frozen graph `inspect()` returns for this run, and
466
+ * `command` is the routed node inside it: the same two values the run's middleware receive.
467
+ */
425
468
  export interface ActionContext<Args, Options = {}, Result = unknown> {
426
469
  readonly style: ContextualStyle;
427
470
  args: Args;
428
471
  options: Options;
472
+ readonly graph: CommandGraph;
473
+ readonly command: CommandNode;
429
474
  passthrough: string[];
430
475
  out: Out<Result>;
431
476
  host: Host;
@@ -24,6 +24,14 @@ export interface ScopedInputs {
24
24
  globals: readonly InputDeclaration[];
25
25
  locals: readonly InputDeclaration[];
26
26
  }
27
+ /**
28
+ * What the input-source stage leaves for validation's messages, by option name: the label of each
29
+ * value it filled, and the variable of each Boolean option whose value is outside the grammar.
30
+ */
31
+ interface Provenance {
32
+ labels: ReadonlyMap<string, string>;
33
+ rejected: ReadonlyMap<string, string>;
34
+ }
27
35
  /** The raw tokens one invocation collected, keyed by declaration and by option name. */
28
36
  interface SuppliedValues {
29
37
  args: ReadonlyMap<InputDeclaration, string | string[]>;
@@ -36,8 +44,14 @@ export interface Invocation {
36
44
  host: Host;
37
45
  inputs: ScopedInputs;
38
46
  passthrough: readonly string[];
39
- /** The run's cancellation signal, which stops this phase between two schema calls. */
47
+ /**
48
+ * The plugin options, which are never validated. A Boolean one whose variable is outside the
49
+ * grammar is still a problem of this phase, reported after the globals and before the locals.
50
+ */
51
+ plugins: readonly OptionInput[];
52
+ /** The run's cancellation signal, which stops this phase between two validator calls. */
40
53
  signal: AbortSignal;
54
+ sources: Provenance;
41
55
  supplied: SuppliedValues;
42
56
  }
43
57
  /**
@@ -66,7 +80,7 @@ export type { ValidatedInputs };
66
80
  */
67
81
  export declare function captureConfig<Config extends ArgumentConfig | OptionConfig>(config: Config): Config;
68
82
  /**
69
- * The declaration flag that sends an omitted value to its own schema. Every declaration reads it
83
+ * The declaration flag that sends an omitted value to its own validator. Every declaration reads it
70
84
  * here, and the declaration rules below reject it wherever another rule already decides absence.
71
85
  */
72
86
  export declare function validatesOmission(input: InputDeclaration): boolean;
@@ -77,16 +91,16 @@ export declare function validatesOmission(input: InputDeclaration): boolean;
77
91
  */
78
92
  export declare function issuePath(issue: StandardSchemaV1.Issue): string | undefined;
79
93
  /**
80
- * Every declaration rule that reads the declaration alone. It is synchronous, so `inspect()` and
81
- * `run()` apply exactly the same rules, and only validating a default through its schema, which
82
- * can be asynchronous, is left to `run()`. A contributor that declares under its own name, such as
83
- * a plugin, supplies the subject its diagnostics read with; every other caller is named by the
84
- * declaration itself.
94
+ * Every declaration rule that reads the declaration alone. It is synchronous, so the call that
95
+ * declares an input applies it, and build applies it to an input a lifecycle hook declared; only
96
+ * validating a default through its validator, which can be asynchronous, is left to `run()`. A
97
+ * contributor that declares under its own name, such as a plugin, supplies the subject its
98
+ * diagnostics read with; every other caller is named by the declaration itself.
85
99
  */
86
100
  export declare function checkDeclarations(inputs: readonly InputDeclaration[], named?: string): void;
87
101
  /**
88
102
  * Every declared default, validated before any token is read. The host is captured by then, so a
89
- * default's schema reads the same Host its action will, under the `default` phase.
103
+ * default's validator reads the same Host its action will, under the `default` phase.
90
104
  */
91
105
  export declare function prepareInputs(inputs: ScopedInputs, host: Host): Promise<DefaultValues>;
92
106
  export declare function validateValues(invocation: Invocation): Promise<ValidatedInputs>;