@loomcli/core 0.4.0 → 0.6.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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/dist/application.d.ts +60 -31
  3. package/dist/application.js +356 -149
  4. package/dist/bindings.d.ts +31 -0
  5. package/dist/bindings.js +64 -0
  6. package/dist/chain.d.ts +26 -12
  7. package/dist/chain.js +59 -92
  8. package/dist/command-rules.d.ts +55 -0
  9. package/dist/command-rules.js +142 -0
  10. package/dist/command.d.ts +212 -93
  11. package/dist/command.js +1224 -454
  12. package/dist/controls.d.ts +8 -0
  13. package/dist/controls.js +23 -0
  14. package/dist/defect.d.ts +18 -0
  15. package/dist/defect.js +272 -0
  16. package/dist/developer.d.ts +24 -0
  17. package/dist/developer.js +52 -0
  18. package/dist/diagnostic-text.d.ts +81 -0
  19. package/dist/diagnostic-text.js +283 -0
  20. package/dist/diagnostic.d.ts +11 -0
  21. package/dist/diagnostic.js +70 -0
  22. package/dist/errors.d.ts +115 -26
  23. package/dist/errors.js +333 -54
  24. package/dist/exit-codes.d.ts +45 -0
  25. package/dist/exit-codes.js +46 -0
  26. package/dist/extension.d.ts +44 -9
  27. package/dist/extension.js +147 -65
  28. package/dist/facts.d.ts +71 -11
  29. package/dist/facts.js +108 -25
  30. package/dist/globals.d.ts +63 -22
  31. package/dist/globals.js +164 -39
  32. package/dist/hints.d.ts +79 -0
  33. package/dist/hints.js +247 -0
  34. package/dist/host.d.ts +13 -0
  35. package/dist/host.js +43 -1
  36. package/dist/identity.d.ts +19 -0
  37. package/dist/identity.js +72 -0
  38. package/dist/index.d.ts +15 -4
  39. package/dist/index.js +6 -0
  40. package/dist/input-rules.d.ts +64 -0
  41. package/dist/input-rules.js +145 -0
  42. package/dist/inspect.d.ts +36 -5
  43. package/dist/inspect.js +125 -27
  44. package/dist/lanes.js +1 -1
  45. package/dist/locate.d.ts +41 -0
  46. package/dist/locate.js +121 -0
  47. package/dist/options.d.ts +96 -2
  48. package/dist/options.js +259 -71
  49. package/dist/output.d.ts +11 -2
  50. package/dist/output.js +23 -3
  51. package/dist/plain.d.ts +6 -0
  52. package/dist/plain.js +12 -0
  53. package/dist/plugin-rules.d.ts +62 -0
  54. package/dist/plugin-rules.js +155 -0
  55. package/dist/plugin.d.ts +121 -55
  56. package/dist/plugin.js +496 -126
  57. package/dist/prototypes.d.ts +7 -0
  58. package/dist/prototypes.js +29 -0
  59. package/dist/rendering.d.ts +6 -1
  60. package/dist/rendering.js +23 -5
  61. package/dist/rules.d.ts +51 -0
  62. package/dist/rules.js +115 -0
  63. package/dist/sequence.js +6 -1
  64. package/dist/sources.d.ts +58 -0
  65. package/dist/sources.js +258 -0
  66. package/dist/style-wire.js +1 -1
  67. package/dist/style.js +1 -1
  68. package/dist/theme.d.ts +4 -0
  69. package/dist/theme.js +25 -5
  70. package/dist/thenable.d.ts +15 -0
  71. package/dist/thenable.js +29 -0
  72. package/dist/translators.d.ts +69 -0
  73. package/dist/translators.js +253 -0
  74. package/dist/types.d.ts +76 -21
  75. package/dist/validation.d.ts +65 -10
  76. package/dist/validation.js +314 -108
  77. package/dist/view.d.ts +49 -15
  78. package/dist/view.js +157 -79
  79. package/package.json +3 -2
@@ -1,6 +1,12 @@
1
1
  import { schemaOptions } from './context.js';
2
- import { DeclarationError, InputError } from './errors.js';
2
+ import { escapeControlCharacters } from './controls.js';
3
+ import { asSentence, DeclarationError, InputError, quoted, reasonOf } from './errors.js';
4
+ import { callSite, factFault, flagFault, partOf, siteFinding } from './facts.js';
5
+ import { booleanOptionValueRule, defaultShape, invalidDefault, notAValidator, omissionAlreadyDecided, omissionWithoutValidator, requiredWithDefault, } from './input-rules.js';
3
6
  import { booleanValue } from './options.js';
7
+ import { isPlainObject } from './plain.js';
8
+ import { notAnObject } from './plugin-rules.js';
9
+ import { validatorFailed } from './rules.js';
4
10
  /** Every declaration in validation order: the globals first, then the reading Command's own. */
5
11
  function scoped(inputs) {
6
12
  return [
@@ -46,6 +52,27 @@ class ValidatedInputs {
46
52
  return { [input.name]: this.#values.get(input) };
47
53
  }
48
54
  }
55
+ /**
56
+ * The one check every `argument()`, `option()`, and `globalOption()` call makes before it reads its
57
+ * config, so a call in JavaScript that supplies none, or a value of another kind, reports a
58
+ * declaration fault and not a TypeError. `place` is where the call sits.
59
+ */
60
+ export function checkInputConfig(declared, place) {
61
+ const { config, kind, name } = declared;
62
+ if (isPlainObject(config)) {
63
+ return;
64
+ }
65
+ const subject = kind === 'argument' ? 'Argument' : 'Option';
66
+ const site = callSite(`${subject} ${quoted(name)}`, {
67
+ arguments: [name, config],
68
+ ...place,
69
+ });
70
+ throw new DeclarationError(notAnObject, {
71
+ correction: `Supply ${kind === 'argument' ? 'an argument' : 'an option'} config object, such as ${kind === 'argument' ? '{}' : "{ type: 'string' }"}.`,
72
+ findings: [siteFinding(site, '1')],
73
+ sentence: `${site.subject} declares a config that is not an object.`,
74
+ });
75
+ }
49
76
  /**
50
77
  * Authoring's snapshot of one config. An array default is the one declared value core hands to an
51
78
  * action as its own value, so the declaration keeps a copy and the caller keeps its array. Every
@@ -59,8 +86,25 @@ export function captureConfig(config) {
59
86
  * A declaration error names the declaration, because the author reads the declaration to fix it.
60
87
  * An argument declares and reads under one name, so the two namings differ for options alone.
61
88
  */
62
- function declaredName(input) {
63
- return input.kind === 'argument' ? `Argument "${input.name}"` : `Option "${input.name}"`;
89
+ export function declarationSubject(input) {
90
+ return input.kind === 'argument'
91
+ ? `Argument ${quoted(input.name)}`
92
+ : `Option ${quoted(input.name)}`;
93
+ }
94
+ /**
95
+ * Where one input was declared: a global option at its `globalOption()` call, and every other input
96
+ * at its own `argument()` or `option()` call on the Command at `path`.
97
+ */
98
+ export function inputPlace(input, scope) {
99
+ return scope.global ? { call: 'globalOption', path: [] } : { call: input.kind, path: scope.path };
100
+ }
101
+ /**
102
+ * The site of the call that declared one input at `place`, rebuilt as `call(name, config)`. Every
103
+ * finding for an input's own call starts from it, so each marks the call the same way. `subject`
104
+ * is the sentence's name for the input.
105
+ */
106
+ export function declaringSite(input, place, subject = declarationSubject(input)) {
107
+ return callSite(subject, { arguments: [input.name, input.config], ...place });
64
108
  }
65
109
  /**
66
110
  * The token an operator would type for one declaration: `--file` for an option, `-F` when the
@@ -73,33 +117,44 @@ function spellingOf(input) {
73
117
  return input.name;
74
118
  }
75
119
  const { config } = input;
76
- return config.shortOnly === true && config.short !== undefined
77
- ? `-${config.short}`
120
+ if (config.shortOnly === true && config.short !== undefined) {
121
+ return `-${config.short}`;
122
+ }
123
+ // A negative-only Boolean option accepts its negative form alone.
124
+ return config.type === 'boolean' && config.polarity === 'negative'
125
+ ? `--no-${input.name}`
78
126
  : `--${input.name}`;
79
127
  }
80
- /** An input diagnostic names the declaration by kind and by the spelling that reaches it. */
81
- function suppliedName(input, spelling) {
82
- return input.kind === 'argument' ? `Argument "${spelling}"` : `Option "${spelling}"`;
128
+ /**
129
+ * An input diagnostic names the declaration by kind and by the spelling that reaches it. A value an
130
+ * input source filled adds its source in parentheses, so the operator learns where it came from.
131
+ */
132
+ function suppliedName(input, spelling, origin) {
133
+ if (input.kind === 'argument') {
134
+ return `Argument "${spelling}"`;
135
+ }
136
+ return origin === undefined ? `Option "${spelling}"` : `Option "${spelling}" (from ${origin})`;
83
137
  }
84
138
  /**
85
139
  * The default sentence for an omitted required input. An argument and an option keep the wording
86
140
  * each phase used before omission became one problem, and a collected input asks for one value
87
141
  * more than a scalar does.
88
142
  */
89
- function missingMessage(input, spelling, collected) {
143
+ function missingMessage(input, spelling, facts) {
144
+ const { collected, origin } = facts;
90
145
  return input.kind === 'argument'
91
146
  ? `Argument "${spelling}" requires ${collected ? 'at least one value' : 'a value'}. Supply a value for "${spelling}".`
92
- : `Option "${spelling}" is required. Supply ${collected ? 'at least one value' : 'a value'}.`;
147
+ : `${suppliedName(input, spelling, origin)} is required. Supply ${collected ? 'at least one value' : 'a value'}.`;
93
148
  }
94
149
  /**
95
150
  * A multiple option collects its occurrences and a variadic argument collects the remaining
96
- * tokens, so either one carries the whole `string[]` as its raw value.
151
+ * tokens, so either one carries a `string[]` as its raw value and validates each value alone.
97
152
  */
98
153
  function collects(input) {
99
154
  return input.kind === 'option' ? input.config.multiple === true : input.config.variadic === true;
100
155
  }
101
156
  /**
102
- * The declaration flag that sends an omitted value to its own schema. Every declaration reads it
157
+ * The declaration flag that sends an omitted value to its own validator. Every declaration reads it
103
158
  * here, and the declaration rules below reject it wherever another rule already decides absence.
104
159
  */
105
160
  export function validatesOmission(input) {
@@ -114,72 +169,106 @@ function suppliedOption(options, name, collected) {
114
169
  function copied(value) {
115
170
  return Array.isArray(value) ? [...value] : value;
116
171
  }
117
- /** Without a schema the raw shape is the declared default's only contract. */
172
+ /**
173
+ * Without a validator the raw shape is the declared default's only contract. With one, a default
174
+ * of several values must still be an array, because each of its values passes the validator.
175
+ */
118
176
  function holdsRawDefault(input) {
119
177
  const value = input.config.default;
120
- return collects(input)
121
- ? Array.isArray(value) && value.every((entry) => typeof entry === 'string')
122
- : typeof value === 'string';
178
+ if (!collects(input)) {
179
+ return input.config.validate !== undefined || typeof value === 'string';
180
+ }
181
+ return (Array.isArray(value) &&
182
+ (input.config.validate !== undefined ||
183
+ value.every((entry) => typeof entry === 'string')));
123
184
  }
124
185
  /**
125
- * `validateOmitted: true` is the one way an omitted scalar reaches its schema, so every other rule
126
- * that already decides absence rejects it, and the flag needs a schema to receive the omission.
186
+ * `validateOmitted: true` is the one way an omitted scalar reaches its validator, so every other
187
+ * rule that already decides absence rejects it, and the flag needs a validator to receive the
188
+ * omission.
127
189
  */
128
- function checkOmissionValidation(input, subject) {
190
+ function checkOmissionValidation(input, site, subject) {
129
191
  const { config } = input;
192
+ const decided = (sentence, correction) => factFault(omissionAlreadyDecided, site, { correction, fact: 'validateOmitted', sentence });
130
193
  if (config.required) {
131
- throw new DeclarationError(`${subject} is required and declares validateOmitted. Remove validateOmitted or make the input optional.`);
194
+ throw decided(`${subject} is required and declares validateOmitted.`, 'Remove validateOmitted or make the input optional.');
132
195
  }
133
196
  if (hasDefault(input)) {
134
- throw new DeclarationError(`${subject} declares a default and validateOmitted. Remove one; the default already fills an omitted value.`);
197
+ throw decided(`${subject} declares a default and validateOmitted.`, 'Remove one; the default already fills an omitted value.');
135
198
  }
136
199
  if (collects(input)) {
137
- throw new DeclarationError(`${subject} collects its values and declares validateOmitted. Remove validateOmitted; an omitted collection reaches the schema as an empty array.`);
200
+ throw decided(`${subject} takes several values and declares validateOmitted.`, 'Remove validateOmitted; with no values the action receives an empty array and no validator runs.');
138
201
  }
139
202
  if (config.validate === undefined) {
140
- throw new DeclarationError(`${subject} declares validateOmitted without a schema. Add validate or remove validateOmitted.`);
203
+ throw factFault(omissionWithoutValidator, site, {
204
+ correction: 'Add validate or remove validateOmitted.',
205
+ fact: 'validateOmitted',
206
+ sentence: `${subject} declares validateOmitted without a validator.`,
207
+ });
208
+ }
209
+ }
210
+ /** The value rules a Boolean option may not declare, in the order its diagnostic names them. */
211
+ const valueRules = ['validate', 'default', 'required', 'validateOmitted'];
212
+ /** Whether a `validate` value is a Standard Schema v1 object that core can call. */
213
+ function isStandardSchema(validator) {
214
+ if (validator === null || (typeof validator !== 'object' && typeof validator !== 'function')) {
215
+ return false;
141
216
  }
217
+ const props = Reflect.get(validator, '~standard');
218
+ return (typeof props === 'object' &&
219
+ props !== null &&
220
+ Reflect.get(props, 'version') === 1 &&
221
+ typeof Reflect.get(props, 'vendor') === 'string' &&
222
+ typeof Reflect.get(props, 'validate') === 'function');
142
223
  }
143
- function checkDeclaration(input, subject) {
224
+ function checkDeclaration(input, site, subject) {
144
225
  const { config } = input;
145
226
  if (input.kind === 'option' && input.config.type === 'boolean') {
146
- if ('validate' in config ||
147
- 'default' in config ||
148
- 'required' in config ||
149
- 'validateOmitted' in config) {
150
- throw new DeclarationError(`${subject} is Boolean. Remove validate, default, required, and validateOmitted; use polarity to control its absent value.`);
227
+ const declared = valueRules.find((key) => key in config);
228
+ if (declared !== undefined) {
229
+ throw factFault(booleanOptionValueRule, site, {
230
+ correction: `Remove ${declared}; use polarity to control its absent value.`,
231
+ fact: declared,
232
+ sentence: `${subject} is Boolean and declares ${declared}.`,
233
+ });
151
234
  }
152
235
  return;
153
236
  }
154
237
  if (config.required !== undefined && typeof config.required !== 'boolean') {
155
- throw new DeclarationError(`${subject} required must be Boolean. Use true or false.`);
238
+ throw flagFault(site, 'required');
156
239
  }
157
240
  if (input.kind === 'argument' &&
158
241
  input.config.variadic !== undefined &&
159
242
  typeof input.config.variadic !== 'boolean') {
160
- throw new DeclarationError(`${subject} variadic must be Boolean. Use true or false.`);
243
+ throw flagFault(site, 'variadic');
161
244
  }
162
245
  // The test reads presence, not truth, so a declared `undefined` is a declaration to reject.
163
246
  if ('validateOmitted' in config && typeof config.validateOmitted !== 'boolean') {
164
- throw new DeclarationError(`${subject} validateOmitted must be Boolean. Use true or false.`);
247
+ throw flagFault(site, 'validateOmitted');
165
248
  }
166
249
  if (config.required && Object.hasOwn(config, 'default')) {
167
- throw new DeclarationError(`${subject} is required and declares a default. Remove the default or make the input optional.`);
250
+ throw factFault(requiredWithDefault, site, {
251
+ correction: 'Remove the default or make the input optional.',
252
+ fact: 'default',
253
+ sentence: `${subject} is required and declares a default.`,
254
+ });
168
255
  }
169
256
  if (validatesOmission(input)) {
170
- checkOmissionValidation(input, subject);
257
+ checkOmissionValidation(input, site, subject);
171
258
  }
172
- const schema = config.validate;
173
- if (schema !== undefined &&
174
- (schema === null ||
175
- (typeof schema !== 'object' && typeof schema !== 'function') ||
176
- !schema['~standard'] ||
177
- schema['~standard'].version !== 1 ||
178
- typeof schema['~standard'].vendor !== 'string' ||
179
- typeof schema['~standard'].validate !== 'function')) {
180
- throw new DeclarationError(`${subject} validate must be a Standard Schema v1 object. Supply a compatible schema.`);
259
+ if (config.validate !== undefined && !isStandardSchema(config.validate)) {
260
+ throw factFault(notAValidator, site, {
261
+ correction: 'Supply a compatible validator.',
262
+ fact: 'validate',
263
+ sentence: `${subject} validate must be a Standard Schema v1 object.`,
264
+ });
181
265
  }
182
266
  }
267
+ /**
268
+ * One issue a validator returned, checked where it enters. Core keeps every own enumerable field
269
+ * the issue carries and rewrites only `path`, reducing each segment to its key, so a field core
270
+ * never reads, such as a validator's issue code, reaches a failure view unchanged.
271
+ */
183
272
  function readIssue(issue) {
184
273
  if (issue === null || typeof issue !== 'object' || !('message' in issue)) {
185
274
  throw new Error('The validator returned an invalid Standard Schema issue.');
@@ -190,7 +279,7 @@ function readIssue(issue) {
190
279
  }
191
280
  const suppliedPath = 'path' in issue ? issue.path : undefined;
192
281
  if (suppliedPath === undefined) {
193
- return { message };
282
+ return { ...issue, message };
194
283
  }
195
284
  if (!Array.isArray(suppliedPath)) {
196
285
  throw new Error('The validator returned an invalid Standard Schema issue path.');
@@ -202,19 +291,21 @@ function readIssue(issue) {
202
291
  }
203
292
  return key;
204
293
  });
205
- return { message, path };
294
+ return { ...issue, message, path };
206
295
  }
207
296
  /**
208
297
  * A broken validator is a fault in the declaration, whichever value reached it, so its diagnostic
209
- * names the declaration. Returned issues belong to the value, so the caller names those.
298
+ * names the declaration and its finding marks the validator on the call at `place`. Returned issues
299
+ * belong to the value, so the caller names those.
210
300
  */
211
- async function validate(input, raw, context) {
212
- const schema = input.config.validate;
213
- if (schema === undefined) {
301
+ async function validate(input, raw, call) {
302
+ const { context, place } = call;
303
+ const validator = input.config.validate;
304
+ if (validator === undefined) {
214
305
  return { value: raw };
215
306
  }
216
307
  try {
217
- const result = await schema['~standard'].validate(raw, schemaOptions(context));
308
+ const result = await validator['~standard'].validate(raw, schemaOptions(context));
218
309
  if (result === null || typeof result !== 'object') {
219
310
  throw new Error('The validator returned an invalid Standard Schema result.');
220
311
  }
@@ -228,10 +319,51 @@ async function validate(input, raw, context) {
228
319
  return { issues: Array.from(issues, readIssue) };
229
320
  }
230
321
  catch (error) {
231
- const reason = error instanceof Error ? error.message : 'Unknown validator failure.';
232
- throw new DeclarationError(`${declaredName(input)} validator failed unexpectedly: ${reason} Fix the validator.`);
322
+ // The reason is the author's detail: a distributed build shows the generic defect message.
323
+ const site = place === undefined ? undefined : declaringSite(input, place);
324
+ throw new DeclarationError(validatorFailed, {
325
+ correction: 'Fix the validator.',
326
+ findings: site === undefined ? [] : [siteFinding(site, partOf(site, 'validate'))],
327
+ sentence: `${declarationSubject(input)} validator failed unexpectedly: ${asSentence(reasonOf(error))}`,
328
+ }, { cause: error });
233
329
  }
234
330
  }
331
+ /**
332
+ * One validation of a declared value. A multiple option or a variadic argument passes each of its
333
+ * values through the validator in order, and each issue reads at its value's position before its
334
+ * own path, so the action receives the array of outputs. Every other input passes its value once.
335
+ * Each call reads a fresh context whose arrays are copies, so a write to them never reaches the
336
+ * next call. The host is the one captured object that every call and the action share.
337
+ */
338
+ async function validateDeclared(input, raw, call) {
339
+ const { context, place, signal } = call;
340
+ if (!collects(input) || input.config.validate === undefined) {
341
+ return validate(input, raw, { context: context(), place });
342
+ }
343
+ if (!Array.isArray(raw)) {
344
+ // The parser, the input sources, and the declaration rules only ever supply an array here.
345
+ throw new TypeError(`${declarationSubject(input)} reached validation without an array of values.`);
346
+ }
347
+ const outputs = [];
348
+ const issues = [];
349
+ for (const [position, value] of raw.entries()) {
350
+ if (signal?.aborted) {
351
+ // A cancelled run starts no further call; the run resolves its cancellation code instead.
352
+ break;
353
+ }
354
+ const result = await validate(input, value, { context: context(), place });
355
+ if (result.issues === undefined) {
356
+ outputs.push(result.value);
357
+ }
358
+ else {
359
+ // The issue keeps its own fields, and only its path gains the value's position.
360
+ for (const issue of reported(result.issues)) {
361
+ issues.push({ ...issue, path: [position, ...(issue.path ?? [])] });
362
+ }
363
+ }
364
+ }
365
+ return issues.length === 0 ? { value: outputs } : { issues };
366
+ }
235
367
  /**
236
368
  * The dotted path an issue names inside a value, or `undefined` when the issue names the value
237
369
  * itself. Core's default text and an application's own view read a position through this one
@@ -244,73 +376,97 @@ export function issuePath(issue) {
244
376
  return path === undefined || path === '' ? undefined : path;
245
377
  }
246
378
  /**
247
- * The issues one rejection reports. A schema that returned none still rejected the value, so the
379
+ * The issues one rejection reports. A validator that returned none still rejected the value, so the
248
380
  * placeholder stands in for its silence. Reporting takes this list once: the reported problem
249
381
  * carries it and the default text is derived from it, so a view and core read the same issues.
250
382
  */
251
383
  function reported(issues) {
252
384
  return issues.length === 0
253
- ? [{ message: 'The schema rejected this value without an explanation.' }]
385
+ ? [
386
+ {
387
+ message: 'The validator rejected this value without an explanation. Supply a different value.',
388
+ },
389
+ ]
254
390
  : issues;
255
391
  }
392
+ /**
393
+ * One line per issue under the input's subject. A path segment can hold text the operator typed,
394
+ * such as a key of a record, so the line escapes the path; `issuePath` keeps it raw for a view.
395
+ */
256
396
  function messages(subject, issues) {
257
397
  return issues.map((issue) => {
258
398
  const path = issuePath(issue);
259
- return `${subject}${path === undefined ? '' : ` at ${path}`}: ${issue.message}`;
399
+ const position = path === undefined ? '' : ` at ${escapeControlCharacters(path)}`;
400
+ return `${subject}${position}: ${issue.message}`;
260
401
  });
261
402
  }
403
+ /** The fault a default of the wrong raw shape reports, by the shape its declaration expects. */
404
+ function defaultShapeFault(input, site, subject) {
405
+ const fault = (sentence, correction) => factFault(defaultShape, site, { correction, fact: 'default', sentence });
406
+ if (!collects(input)) {
407
+ return fault(`${subject} default must be a string without a validator.`, 'Supply a string default.');
408
+ }
409
+ return input.config.validate === undefined
410
+ ? fault(`${subject} default must be an array of strings without a validator.`, 'Supply a string array default.')
411
+ : fault(`${subject} default must be an array.`, 'Supply an array of values.');
412
+ }
262
413
  /** A declared `default: undefined` is a default, so presence is the key, never the value. */
263
414
  function hasDefault(input) {
264
415
  return Object.hasOwn(input.config, 'default');
265
416
  }
266
417
  /**
267
- * Every declaration rule that reads the declaration alone. It is synchronous, so `inspect()` and
268
- * `run()` apply exactly the same rules, and only validating a default through its schema, which
269
- * can be asynchronous, is left to `run()`. A contributor that declares under its own name, such as
270
- * a plugin, supplies the subject its diagnostics read with; every other caller is named by the
271
- * declaration itself.
418
+ * Every declaration rule that reads the declaration alone. It is synchronous, so the call that
419
+ * declares an input applies it, and build applies it to an input a lifecycle hook declared; only
420
+ * validating a default through its validator, which can be asynchronous, is left to `run()`. A
421
+ * contributor that declares under its own name, such as a plugin, supplies the subject its
422
+ * diagnostics read with; every other caller is named by the declaration itself.
272
423
  */
273
424
  export function checkDeclarations(inputs, named) {
274
- for (const input of inputs) {
275
- checkDeclaration(input, named ?? declaredName(input));
276
- }
277
- for (const input of inputs.filter((entry) => hasDefault(entry))) {
278
- if (input.config.validate === undefined && !holdsRawDefault(input)) {
279
- const subject = named ?? declaredName(input);
280
- throw new DeclarationError(collects(input)
281
- ? `${subject} default must be an array of strings without a schema. Supply a string array default.`
282
- : `${subject} default must be a string without a schema. Supply a string default.`);
425
+ for (const { input, site } of inputs) {
426
+ checkDeclaration(input, site, named ?? declarationSubject(input));
427
+ }
428
+ for (const { input, site } of inputs.filter((entry) => hasDefault(entry.input))) {
429
+ if (!holdsRawDefault(input)) {
430
+ throw defaultShapeFault(input, site, named ?? declarationSubject(input));
283
431
  }
284
432
  }
285
433
  }
286
434
  /**
287
435
  * Every declared default, validated before any token is read. The host is captured by then, so a
288
- * default's schema reads the same Host its action will, under the `default` phase.
436
+ * default's validator reads the same Host its action will, under the `default` phase. `places`
437
+ * says where each declaration sits, which a rejected default's finding rebuilds.
289
438
  */
290
- export async function prepareInputs(inputs, host) {
439
+ export async function prepareInputs(inputs, host, places) {
291
440
  const declarations = scoped(inputs);
292
- checkDeclarations(declarations.map((entry) => entry.input));
293
441
  const defaults = new Map();
294
442
  for (const entry of declarations.filter(({ input }) => hasDefault(input))) {
295
443
  const { input } = entry;
296
- const subject = declaredName(input);
297
- const result = await validate(input, input.config.default, {
298
- host,
299
- input: identityOf(entry),
300
- phase: 'default',
444
+ const subject = declarationSubject(input);
445
+ const place = places.get(input);
446
+ const result = await validateDeclared(input, input.config.default, {
447
+ context: () => ({ host, input: identityOf(entry), phase: 'default' }),
448
+ place,
301
449
  });
302
450
  if (result.issues !== undefined) {
303
- throw new DeclarationError(`${subject} has an invalid default. Fix the default or its schema.\n${messages(subject, reported(result.issues)).join('\n')}`);
451
+ const site = place === undefined ? undefined : declaringSite(input, place);
452
+ throw new DeclarationError(invalidDefault, {
453
+ correction: 'Fix the default or its validator.',
454
+ findings: site === undefined ? [] : [siteFinding(site, partOf(site, 'default'))],
455
+ sentence: [
456
+ `${subject} has an invalid default.`,
457
+ ...messages(subject, reported(result.issues)),
458
+ ].join('\n'),
459
+ });
304
460
  }
305
461
  defaults.set(input, result.value);
306
462
  }
307
463
  return defaults;
308
464
  }
309
465
  /**
310
- * An array default reaches the action as its own copy, so an action that mutates its collection
311
- * rewrites neither the declaration nor the next invocation. A schema that returns a new array is
312
- * copied too, because a pass-through schema returns the declared array itself and cannot be told
313
- * apart from one that built its own. Every other output passes through unchanged.
466
+ * An array default reaches the action as its own copy, so an action that mutates its array
467
+ * rewrites neither the declaration nor the next invocation. One prepared default serves every
468
+ * invocation of a run, and an unvalidated default is the declared array itself, so each read
469
+ * copies it. Every other output passes through unchanged.
314
470
  */
315
471
  function freshDefault(value) {
316
472
  return Array.isArray(value) ? [...value] : value;
@@ -339,50 +495,95 @@ function suppliedInputs(declarations, supplied) {
339
495
  }
340
496
  return { args, options };
341
497
  }
498
+ /** The Boolean grammar's one issue, which a variable outside it reports. */
499
+ const grammarIssues = [{ message: 'Use true, false, 1, or 0.' }];
500
+ /**
501
+ * Every declaration in the order this phase reports its problems: the globals, then each plugin
502
+ * option whose variable is outside the grammar, then the routed Command's own declarations.
503
+ */
504
+ function reportingOrder(invocation) {
505
+ const declarations = scoped(invocation.inputs);
506
+ const globals = declarations.filter((entry) => entry.global);
507
+ const rejected = invocation.plugins
508
+ .filter((input) => invocation.sources.rejected.has(input.name))
509
+ .map((input) => ({ global: true, input }));
510
+ return [...globals, ...rejected, ...declarations.filter((entry) => !entry.global)];
511
+ }
342
512
  export async function validateValues(invocation) {
343
- const { defaults, supplied } = invocation;
513
+ const { defaults, sources, supplied } = invocation;
344
514
  const declarations = scoped(invocation.inputs);
515
+ /** Where a filled option's value came from, which its diagnostic names; argv names none. */
516
+ const originOf = (input) => input.kind === 'option' ? sources.labels.get(input.name) : undefined;
345
517
  /**
346
- * One reading of the tokens and the route, built anew for each schema call. The route, the
347
- * tail, and every collected value are copies, so a schema that writes to them reaches neither
348
- * the parser's collections, nor the tail the action receives, nor the next schema of this
349
- * invocation. The host is the captured object itself, the one the action receives.
518
+ * One reading of the tokens and the route, built anew for each validator call. The route, the
519
+ * tail, and every collected value are copies, so a validator that writes to them reaches neither
520
+ * the parser's collections, nor the tail the action receives, nor the next validator call of
521
+ * this invocation. Each copy is made on its first read and kept for the call, so a validator
522
+ * that never reads one never pays for it, however many values a list holds. The host is the
523
+ * captured object itself, the one the action receives.
350
524
  */
351
- const facts = () => ({
352
- command: [...invocation.command],
353
- host: invocation.host,
354
- passthrough: [...invocation.passthrough],
355
- supplied: suppliedInputs(declarations.map((entry) => entry.input), supplied),
356
- });
525
+ const contextOf = (entry) => {
526
+ const copies = {};
527
+ return {
528
+ get command() {
529
+ copies.command ??= [...invocation.command];
530
+ return copies.command;
531
+ },
532
+ host: invocation.host,
533
+ input: identityOf(entry),
534
+ get passthrough() {
535
+ copies.passthrough ??= [...invocation.passthrough];
536
+ return copies.passthrough;
537
+ },
538
+ phase: 'invocation',
539
+ get supplied() {
540
+ copies.supplied ??= suppliedInputs(declarations.map((declared) => declared.input), supplied);
541
+ return copies.supplied;
542
+ },
543
+ };
544
+ };
357
545
  const values = new Map();
358
546
  const lines = [];
359
547
  const problems = [];
360
- /** One path for every value the schema reads, so a raw shape and its issues meet it once. */
361
- const accept = async (entry, raw, spelling) => {
362
- const result = await validate(entry.input, raw, {
363
- ...facts(),
548
+ /** One rejected input, whatever rejected it, in the order this phase reaches it. */
549
+ const reject = (entry, issues, subject) => {
550
+ problems.push({
364
551
  input: identityOf(entry),
365
- phase: 'invocation',
552
+ issues,
553
+ reason: 'invalid',
554
+ spelling: spellingOf(entry.input),
555
+ });
556
+ lines.push(...messages(subject, issues));
557
+ };
558
+ /** One path for every value a validator reads, so a raw shape and its issues meet it once. */
559
+ const accept = async (entry, raw, spelling) => {
560
+ const result = await validateDeclared(entry.input, raw, {
561
+ context: () => contextOf(entry),
562
+ place: inputPlace(entry.input, { global: entry.global, path: invocation.command }),
563
+ signal: invocation.signal,
366
564
  });
367
565
  if (result.issues === undefined) {
368
566
  values.set(entry.input, result.value);
369
567
  return;
370
568
  }
371
- const issues = reported(result.issues);
372
- problems.push({ input: identityOf(entry), issues, reason: 'invalid', spelling });
373
- lines.push(...messages(suppliedName(entry.input, spelling), issues));
569
+ reject(entry, reported(result.issues), suppliedName(entry.input, spelling, originOf(entry.input)));
374
570
  };
375
- for (const entry of declarations) {
571
+ for (const entry of reportingOrder(invocation)) {
376
572
  if (invocation.signal.aborted) {
377
573
  /**
378
- * A cancelled run starts no further schema call. The one already in flight was awaited
574
+ * A cancelled run starts no further validator call. The one already in flight was awaited
379
575
  * above, and whatever this phase collected is never raised, because the run resolves its
380
576
  * cancellation code instead.
381
577
  */
382
578
  break;
383
579
  }
384
580
  const { input } = entry;
385
- if (input.kind === 'option' && input.config.type === 'boolean') {
581
+ const variable = sources.rejected.get(input.name);
582
+ if (input.kind === 'option' && variable !== undefined) {
583
+ // A Boolean variable outside the grammar filled nothing, so it is the option's problem.
584
+ reject(entry, grammarIssues, suppliedName(input, spellingOf(input), variable));
585
+ }
586
+ else if (input.kind === 'option' && input.config.type === 'boolean') {
386
587
  values.set(input, booleanValue(supplied.options, input.name, input.config));
387
588
  }
388
589
  else {
@@ -396,14 +597,14 @@ export async function validateValues(invocation) {
396
597
  // An omitted required argument arrives here too, so omission has one class.
397
598
  // One aggregated diagnostic covers an omitted argument and an omitted option alike.
398
599
  problems.push({ input: identityOf(entry), reason: 'missing', spelling });
399
- lines.push(missingMessage(input, spelling, collected));
600
+ lines.push(missingMessage(input, spelling, { collected }));
400
601
  }
401
602
  else if (collected && !defaults.has(input)) {
402
- // No occurrence is an accurate empty collection, so it reads like a supplied value.
403
- await accept(entry, [], spelling);
603
+ // No occurrence has no value to validate, so the action receives an empty array.
604
+ values.set(input, []);
404
605
  }
405
606
  else if (validatesOmission(input)) {
406
- // The flag sends the omission itself to the schema.
607
+ // The flag sends the omission itself to the validator.
407
608
  // An absence rule reads the context a supplied value reads, and reports input issues.
408
609
  await accept(entry, undefined, spelling);
409
610
  }
@@ -411,6 +612,11 @@ export async function validateValues(invocation) {
411
612
  values.set(input, freshDefault(defaults.get(input)));
412
613
  }
413
614
  }
615
+ else if (input.config.required && Array.isArray(raw) && raw.length === 0) {
616
+ // A filled list satisfies the at-least-one rule by its length, so an empty one is missing.
617
+ problems.push({ input: identityOf(entry), reason: 'missing', spelling });
618
+ lines.push(missingMessage(input, spelling, { collected, origin: originOf(input) }));
619
+ }
414
620
  else {
415
621
  await accept(entry, raw, spelling);
416
622
  }