@loomcli/core 0.5.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 (77) hide show
  1. package/dist/application.d.ts +18 -2
  2. package/dist/application.js +266 -71
  3. package/dist/bindings.d.ts +15 -10
  4. package/dist/bindings.js +34 -15
  5. package/dist/chain.d.ts +15 -6
  6. package/dist/chain.js +40 -20
  7. package/dist/command-rules.d.ts +55 -0
  8. package/dist/command-rules.js +142 -0
  9. package/dist/command.d.ts +65 -23
  10. package/dist/command.js +695 -226
  11. package/dist/controls.d.ts +8 -0
  12. package/dist/controls.js +23 -0
  13. package/dist/defect.d.ts +18 -0
  14. package/dist/defect.js +272 -0
  15. package/dist/developer.d.ts +24 -0
  16. package/dist/developer.js +52 -0
  17. package/dist/diagnostic-text.d.ts +81 -0
  18. package/dist/diagnostic-text.js +283 -0
  19. package/dist/diagnostic.d.ts +11 -0
  20. package/dist/diagnostic.js +70 -0
  21. package/dist/errors.d.ts +108 -24
  22. package/dist/errors.js +324 -53
  23. package/dist/exit-codes.d.ts +45 -0
  24. package/dist/exit-codes.js +46 -0
  25. package/dist/extension.d.ts +41 -8
  26. package/dist/extension.js +142 -57
  27. package/dist/facts.d.ts +66 -11
  28. package/dist/facts.js +98 -22
  29. package/dist/globals.d.ts +29 -21
  30. package/dist/globals.js +115 -46
  31. package/dist/hints.d.ts +79 -0
  32. package/dist/hints.js +247 -0
  33. package/dist/host.d.ts +13 -0
  34. package/dist/host.js +43 -1
  35. package/dist/identity.d.ts +19 -0
  36. package/dist/identity.js +72 -0
  37. package/dist/index.d.ts +11 -2
  38. package/dist/index.js +5 -0
  39. package/dist/input-rules.d.ts +64 -0
  40. package/dist/input-rules.js +145 -0
  41. package/dist/inspect.d.ts +12 -4
  42. package/dist/inspect.js +88 -28
  43. package/dist/lanes.js +1 -1
  44. package/dist/locate.js +4 -4
  45. package/dist/options.d.ts +23 -2
  46. package/dist/options.js +135 -44
  47. package/dist/output.d.ts +9 -2
  48. package/dist/output.js +18 -2
  49. package/dist/plain.d.ts +6 -0
  50. package/dist/plain.js +12 -0
  51. package/dist/plugin-rules.d.ts +62 -0
  52. package/dist/plugin-rules.js +155 -0
  53. package/dist/plugin.d.ts +22 -10
  54. package/dist/plugin.js +348 -116
  55. package/dist/prototypes.d.ts +7 -0
  56. package/dist/prototypes.js +29 -0
  57. package/dist/rendering.d.ts +6 -1
  58. package/dist/rendering.js +23 -5
  59. package/dist/rules.d.ts +51 -0
  60. package/dist/rules.js +115 -0
  61. package/dist/sequence.js +6 -1
  62. package/dist/sources.d.ts +6 -4
  63. package/dist/sources.js +22 -13
  64. package/dist/style-wire.js +1 -1
  65. package/dist/style.js +1 -1
  66. package/dist/theme.d.ts +4 -0
  67. package/dist/theme.js +25 -5
  68. package/dist/thenable.d.ts +15 -0
  69. package/dist/thenable.js +29 -0
  70. package/dist/translators.d.ts +69 -0
  71. package/dist/translators.js +253 -0
  72. package/dist/types.d.ts +11 -1
  73. package/dist/validation.d.ts +44 -3
  74. package/dist/validation.js +156 -57
  75. package/dist/view.d.ts +48 -14
  76. package/dist/view.js +155 -77
  77. package/package.json +1 -1
package/dist/plugin.js CHANGED
@@ -1,13 +1,20 @@
1
1
  import { checkEnvBinding, claimVariables } from './bindings.js';
2
- import { attach, commandNode } from './command.js';
3
- import { DeclarationError, InternalError, reasonOf } from './errors.js';
2
+ import { notACommand } from './command-rules.js';
3
+ import { attach, commandCode, commandNode } from './command.js';
4
+ import { elided, quoteString, spelled } from './diagnostic-text.js';
5
+ import { DeclarationError, InternalError, quoted, reasonOf } from './errors.js';
4
6
  import { appliesTo, buildExtensions, isDescriptor, registerDescriptor } from './extension.js';
5
- import { checkDeprecated, checkDescription, checkHidden, isPlainObject } from './facts.js';
6
- import { boundOptions } from './globals.js';
7
+ import { checkDeprecated, checkDescription, checkHidden, factFault, partFinding, pluginOptionSite, slotSite, } from './facts.js';
8
+ import { boundOptions, pluginSites } from './globals.js';
9
+ import { checkIdentity } from './identity.js';
7
10
  import { coreViews } from './lanes.js';
8
11
  import { booleanValue, compileOptions } from './options.js';
12
+ import { isPlainObject } from './plain.js';
13
+ import { foreignValue, middlewareActivation, notAFunction, notAList, notAnObject, pluginInstalledTwice, pluginOptionRule, signalClaimedTwice, slotTaken, sourceBinding, sourceBoundOwnOption, unknownSignal, } from './plugin-rules.js';
14
+ import { pluginLoaderFailed } from './rules.js';
9
15
  import { isProcessSignal } from './signals.js';
10
16
  import { buildTheme } from './theme.js';
17
+ import { readTranslations } from './translators.js';
11
18
  import { captureConfig, checkDeclarations } from './validation.js';
12
19
  import { buildViews, viewIdentities } from './view.js';
13
20
  /** Authored values register here, so the public type publishes no state to reach or replace. */
@@ -25,8 +32,9 @@ class PluginDeclaration {
25
32
  }
26
33
  /**
27
34
  * One plugin: an identity and the contributions it carries. Creating and installing the value runs
28
- * none of its code: a hook runs at graph build, and the middleware runs inside an invocation, so an
29
- * installed plugin an invocation never reaches costs that invocation its hooks alone. Every rule
35
+ * none of its code: `onCommandAttach` runs at graph build, `onFailure` runs when `run()` renders a
36
+ * failure, and the middleware runs inside an invocation, so an installed plugin an invocation never
37
+ * reaches costs that invocation its hooks alone. Every rule
30
38
  * that one definition carries on its own throws here, before the value exists.
31
39
  */
32
40
  function plugin(identity, definition) {
@@ -40,33 +48,71 @@ function plugin(identity, definition) {
40
48
  }
41
49
  /** How every plugin diagnostic names one plugin at the start of a sentence. */
42
50
  function pluginSentence(identity) {
43
- return `Plugin "${identity}"`;
51
+ return `Plugin ${quoted(identity)}`;
44
52
  }
45
- /** Reads the declarations behind an installed value; anything else is a declaration error. */
53
+ /** Where one slot of a plugin's definition sits, rebuilt as `plugin(identity, { slot })`. */
54
+ function pluginSlot(identity, slot, value) {
55
+ return slotSite({ call: 'plugin', named: identity, subject: pluginSentence(identity) }, slot, value);
56
+ }
57
+ /** The subject a plugin's `views` list reports under, and the call that declared it. */
58
+ function pluginViews(identity) {
59
+ return {
60
+ declares: true,
61
+ owner: { call: 'plugin', named: identity },
62
+ sentence: pluginSentence(identity),
63
+ };
64
+ }
65
+ /** The declarations behind one installed value, or `undefined` for a value `plugin()` did not make. */
46
66
  function nodeOf(value) {
47
- const node = typeof value === 'object' && value !== null ? nodes.get(value) : undefined;
48
- if (!node) {
49
- throw new DeclarationError('The Application holds a value that is not a plugin. Supply the value returned by plugin(identity, definition).');
50
- }
51
- return node;
67
+ return typeof value === 'object' && value !== null ? nodes.get(value) : undefined;
52
68
  }
53
- /** The identity one plugin declares, which is a nonempty string. */
54
- function readIdentity(identity) {
55
- if (typeof identity !== 'string') {
56
- throw new DeclarationError('A plugin declares an identity that is not a string. Supply a nonempty string, such as the package name.');
57
- }
58
- if (identity === '') {
59
- throw new DeclarationError('A plugin declares an empty identity. Supply a nonempty string, such as the package name.');
60
- }
61
- return identity;
69
+ /** One `plugins` entry as a finding prints it: a plugin as its `plugin()` call, elided. */
70
+ function entryCode(value) {
71
+ const node = nodeOf(value);
72
+ return node ? spelled(`plugin(${quoteString(node.identity)}, ${elided})`) : value;
62
73
  }
63
74
  /** The declarations one plugin value carries, which a JavaScript author reaches as any value. */
64
75
  function definitionOf(identity, definition) {
65
76
  if (!isPlainObject(definition)) {
66
- throw new DeclarationError(`${pluginSentence(identity)} declares a definition that is not an object. Supply { options, middleware, extensions, views }.`);
77
+ throw new DeclarationError(notAnObject, {
78
+ correction: 'Supply { options, middleware, extensions, views }.',
79
+ findings: [{ arguments: [identity, definition], call: 'plugin', mark: '1' }],
80
+ sentence: `${pluginSentence(identity)} declares a definition that is not an object.`,
81
+ });
67
82
  }
68
83
  return definition;
69
84
  }
85
+ /** How a second claim on one slot reads: its clause, the note on the owner's entry, and the fix. */
86
+ const slotWords = {
87
+ signals: {
88
+ clause: (owner) => `claims the signals slot, which plugin ${quoted(owner)} already holds.`,
89
+ correction: 'Install one owner.',
90
+ held: 'holds the signals slot',
91
+ },
92
+ source: {
93
+ clause: (owner) => `declares a configuration source, which plugin ${quoted(owner)} already declares.`,
94
+ correction: 'Install one source.',
95
+ held: 'declares the configuration source',
96
+ },
97
+ theme: {
98
+ clause: (owner) => `claims the theme slot, which plugin ${quoted(owner)} already holds.`,
99
+ correction: 'Install one owner.',
100
+ held: 'holds the theme slot',
101
+ },
102
+ };
103
+ /** A second claim on one slot, which marks the owner's entry and the claimant's in `plugins`. */
104
+ function slotFault(site, slot, claim) {
105
+ const { claimant, installed, owner } = claim;
106
+ const words = slotWords[slot];
107
+ return new DeclarationError(slotTaken, {
108
+ correction: words.correction,
109
+ findings: [
110
+ partFinding(site, [owner], words.held),
111
+ partFinding(site, [claimant], 'claims it again'),
112
+ ],
113
+ sentence: `${pluginSentence(installed[claimant]?.identity ?? '')} ${words.clause(installed[owner]?.identity ?? '')}`,
114
+ });
115
+ }
70
116
  /**
71
117
  * The installed list in composition order, with every rule that reads two plugins together: an
72
118
  * identity installed twice, a second claim on the theme slot, the signals slot, or the
@@ -74,43 +120,68 @@ function definitionOf(identity, definition) {
74
120
  * defensively, because a JavaScript author reaches it with any value. Each plugin's own rules
75
121
  * already ran at its `plugin()` call.
76
122
  */
77
- function installPlugins(plugins) {
123
+ function installPlugins(application, plugins) {
124
+ const printed = Array.isArray(plugins) ? Array.from(plugins, entryCode) : plugins;
125
+ const site = slotSite({ call: 'new Application', named: application, subject: 'The Application' }, 'plugins', printed);
78
126
  if (!Array.isArray(plugins)) {
79
- throw new DeclarationError('The Application plugins must be an array. Supply a list of plugin values.');
127
+ throw new DeclarationError(notAList, {
128
+ correction: 'Supply a list of plugin values.',
129
+ findings: [partFinding(site, [])],
130
+ sentence: 'The Application declares plugins that are not an array.',
131
+ });
80
132
  }
81
133
  const list = plugins;
82
- const installed = list.map((value) => nodeOf(value));
83
- const identities = new Set();
134
+ const installed = list.map((value, index) => {
135
+ const node = nodeOf(value);
136
+ if (!node) {
137
+ throw new DeclarationError(foreignValue, {
138
+ correction: 'Supply the value returned by plugin(identity, definition).',
139
+ findings: [partFinding(site, [index])],
140
+ sentence: 'The Application holds a value that is not a plugin.',
141
+ });
142
+ }
143
+ return node;
144
+ });
145
+ const positions = new Map();
84
146
  const descriptors = new Map();
85
147
  // Each slot has one owner, so the first plugin to claim it names the second claimant's diagnostic.
86
- const owners = {};
87
- for (const entry of installed) {
148
+ const owners = new Map();
149
+ const claim = (slot, index) => {
150
+ const owner = owners.get(slot);
151
+ if (owner !== undefined) {
152
+ throw slotFault(site, slot, { claimant: index, installed, owner });
153
+ }
154
+ owners.set(slot, index);
155
+ };
156
+ for (const [index, entry] of installed.entries()) {
88
157
  const { identity } = entry;
89
- if (identities.has(identity)) {
90
- throw new DeclarationError(`The Application installs plugin "${identity}" twice. Install each plugin once.`);
158
+ const first = positions.get(identity);
159
+ if (first !== undefined) {
160
+ throw new DeclarationError(pluginInstalledTwice, {
161
+ correction: 'Install each plugin once.',
162
+ findings: [
163
+ partFinding(site, [first], 'the first installation'),
164
+ partFinding(site, [index], 'the second installation'),
165
+ ],
166
+ sentence: `The Application installs plugin ${quoted(identity)} twice.`,
167
+ });
91
168
  }
92
- identities.add(identity);
169
+ positions.set(identity, index);
93
170
  if (entry.theme !== undefined) {
94
- if (owners.theme !== undefined) {
95
- throw new DeclarationError(`${pluginSentence(identity)} claims the theme slot, which plugin "${owners.theme}" already holds. Install one owner.`);
96
- }
97
- owners.theme = identity;
171
+ claim('theme', index);
98
172
  }
99
- for (const descriptor of entry.descriptors.values()) {
100
- registerDescriptor(descriptors, descriptor);
173
+ for (const [key, descriptor] of entry.descriptors) {
174
+ registerDescriptor(descriptors, descriptor, {
175
+ identity: key,
176
+ place: partFinding(site, [index]),
177
+ });
101
178
  }
102
179
  // An empty claim leaves the signals slot free.
103
180
  if (entry.signals.length > 0) {
104
- if (owners.signals !== undefined) {
105
- throw new DeclarationError(`${pluginSentence(identity)} claims the signals slot, which plugin "${owners.signals}" already holds. Install one owner.`);
106
- }
107
- owners.signals = identity;
181
+ claim('signals', index);
108
182
  }
109
183
  if (entry.source) {
110
- if (owners.source !== undefined) {
111
- throw new DeclarationError(`${pluginSentence(identity)} declares a configuration source, which plugin "${owners.source}" already declares. Install one source.`);
112
- }
113
- owners.source = identity;
184
+ claim('source', index);
114
185
  }
115
186
  }
116
187
  return { descriptors, plugins: installed };
@@ -118,17 +189,26 @@ function installPlugins(plugins) {
118
189
  /** The keys a plugin option may not declare, in the order its diagnostic names them. */
119
190
  const forbidden = ['validate', 'validateOmitted', 'required'];
120
191
  /** The rules a plugin option answers before every rule an ordinary declaration carries. */
121
- function checkPluginOption(sentence, config) {
192
+ function checkPluginOption(site, config) {
193
+ const sentence = site.subject;
122
194
  if (!isPlainObject(config)) {
123
- throw new DeclarationError(`${sentence} is not an option declaration. Supply { type, ... }.`);
195
+ throw new DeclarationError(notAnObject, {
196
+ correction: 'Supply { type, ... }.',
197
+ findings: [partFinding(site, [])],
198
+ sentence: `${sentence} is not an option declaration.`,
199
+ });
124
200
  }
125
201
  const rejected = forbidden.find((key) => key in config);
126
202
  if (rejected !== undefined) {
127
- throw new DeclarationError(`${sentence} declares ${rejected}. Remove it; a plugin option carries no validator or presence rule, and the middleware interprets the value.`);
128
- }
129
- checkDescription(sentence, config.description);
130
- checkHidden(sentence, config.hidden);
131
- checkDeprecated(sentence, config.deprecated);
203
+ throw factFault(pluginOptionRule, site, {
204
+ correction: 'Remove it; a plugin option carries no validator or presence rule, and the middleware interprets the value.',
205
+ fact: rejected,
206
+ sentence: `${sentence} declares ${rejected}.`,
207
+ });
208
+ }
209
+ checkDescription(site, config.description);
210
+ checkHidden(site, config.hidden);
211
+ checkDeprecated(site, config.deprecated);
132
212
  }
133
213
  /**
134
214
  * One plugin's option declarations, in declaration order. They join the globals table, so the rules
@@ -136,19 +216,25 @@ function checkPluginOption(sentence, config) {
136
216
  */
137
217
  function readOptions(identity, declared, build) {
138
218
  if (declared !== undefined && !isPlainObject(declared)) {
139
- throw new DeclarationError(`${pluginSentence(identity)} declares options that are not an object. Supply a record of option declarations.`);
219
+ throw new DeclarationError(notAnObject, {
220
+ correction: 'Supply a record of option declarations.',
221
+ findings: [partFinding(pluginSlot(identity, 'options', declared), [])],
222
+ sentence: `${pluginSentence(identity)} declares options that are not an object.`,
223
+ });
140
224
  }
141
225
  const inputs = [];
142
226
  for (const [name, config] of Object.entries(declared ?? {})) {
143
- const sentence = `${pluginSentence(identity)} option "${name}"`;
144
- checkPluginOption(sentence, config);
145
- checkEnvBinding(sentence, config);
227
+ const sentence = `${pluginSentence(identity)} option ${quoted(name)}`;
228
+ const site = pluginOptionSite({ identity, options: declared }, name, sentence);
229
+ checkPluginOption(site, config);
230
+ checkEnvBinding(site, config);
146
231
  const input = { config: captureConfig(config), kind: 'option', name };
147
232
  // The shared rules name the plugin and the option, so a fault reads with its contributor.
148
- checkDeclarations([input], sentence);
233
+ checkDeclarations([{ input, site }], sentence);
149
234
  build.records.set(input, buildExtensions({
150
235
  declared: config.extensions,
151
236
  descriptors: build.descriptors,
237
+ site: { ...site, at: `${site.at}.extensions` },
152
238
  subject: {
153
239
  phrase: `on ${sentence.slice(0, 1).toLowerCase()}${sentence.slice(1)}`,
154
240
  sentence,
@@ -158,30 +244,49 @@ function readOptions(identity, declared, build) {
158
244
  inputs.push(input);
159
245
  }
160
246
  // Two options of one plugin meet in the one table the pre-scan reads, so they share its rules.
161
- compileOptions(inputs, `plugin "${identity}"`);
162
- claimVariables(boundOptions(inputs, (name) => `plugin "${identity}" option "${name}"`));
247
+ const siteOf = pluginSites(identity, inputs);
248
+ compileOptions(inputs, { siteOf, subject: `plugin ${quoted(identity)}` });
249
+ claimVariables(boundOptions(inputs, (input) => ({
250
+ phrase: `plugin ${quoted(identity)} option "${input.name}"`,
251
+ site: siteOf(input),
252
+ })));
163
253
  return inputs;
164
254
  }
165
- /** One activation name, which must be one of the plugin's own declared options. */
166
- function readActivationName(identity, name, names) {
167
- if (typeof name !== 'string' || !names.has(name)) {
168
- throw new DeclarationError(`${pluginSentence(identity)} activates middleware on option "${String(name)}", which it does not declare. Name one of the plugin's own options.`);
169
- }
170
- return name;
171
- }
172
- /** The activation a middleware declares, checked against the options its own plugin declares. */
173
- function readActivation(identity, declared, names) {
174
- if (declared === 'always') {
255
+ /**
256
+ * The activation a middleware declares, checked against the options its own plugin declares. `site`
257
+ * holds the middleware object, and a fault marks its `activate` key, or the object itself when the
258
+ * key is absent.
259
+ */
260
+ function readActivation(site, declared, names) {
261
+ const { activate } = declared;
262
+ if (activate === 'always') {
175
263
  return 'always';
176
264
  }
177
- if (!Array.isArray(declared)) {
178
- throw new DeclarationError(`${pluginSentence(identity)} declares middleware with no activation. Supply activate: 'always' or a list of the plugin's own option names.`);
265
+ if (!Array.isArray(activate)) {
266
+ throw new DeclarationError(middlewareActivation, {
267
+ correction: "Supply activate: 'always' or a list of the plugin's own option names.",
268
+ findings: [partFinding(site, 'activate' in declared ? ['activate'] : [])],
269
+ sentence: `${site.subject} declares middleware with no activation.`,
270
+ });
179
271
  }
180
- const list = declared;
272
+ const list = activate;
181
273
  if (list.length === 0) {
182
- throw new DeclarationError(`${pluginSentence(identity)} declares middleware with an empty activation list. Name at least one of the plugin's options or use 'always'.`);
183
- }
184
- return list.map((name) => readActivationName(identity, name, names));
274
+ throw new DeclarationError(middlewareActivation, {
275
+ correction: "Name at least one of the plugin's options or use 'always'.",
276
+ findings: [partFinding(site, ['activate'])],
277
+ sentence: `${site.subject} declares middleware with an empty activation list.`,
278
+ });
279
+ }
280
+ return list.map((name, index) => {
281
+ if (typeof name !== 'string' || !names.has(name)) {
282
+ throw new DeclarationError(middlewareActivation, {
283
+ correction: "Name one of the plugin's own options.",
284
+ findings: [partFinding(site, ['activate', index])],
285
+ sentence: `${site.subject} activates middleware on option ${quoted(name)}, which it does not declare.`,
286
+ });
287
+ }
288
+ return name;
289
+ });
185
290
  }
186
291
  /**
187
292
  * The loader a middleware declares. Core calls it with no arguments and reads whatever it resolves
@@ -201,28 +306,47 @@ async function loadDefault(identity, load, owed) {
201
306
  module = await load();
202
307
  }
203
308
  catch (error) {
204
- throw new InternalError(`Loading plugin "${identity}" failed: ${reasonOf(error)}`, error);
309
+ throw new InternalError(pluginLoaderFailed, {
310
+ cause: error,
311
+ correction: loaderCorrection,
312
+ sentence: `Loading plugin ${quoted(identity)} failed: ${reasonOf(error)}`,
313
+ });
205
314
  }
206
315
  const exported = module !== null && typeof module === 'object' && 'default' in module
207
316
  ? module.default
208
317
  : undefined;
209
318
  if (!owed.guard(exported)) {
210
- throw new InternalError(`Loading plugin "${identity}" failed: the module exports no default ${owed.noun} function.`, undefined);
319
+ throw new InternalError(pluginLoaderFailed, {
320
+ cause: undefined,
321
+ correction: loaderCorrection,
322
+ sentence: `Loading plugin ${quoted(identity)} failed: the module exports no default ${owed.noun} function.`,
323
+ });
211
324
  }
212
325
  return exported;
213
326
  }
327
+ /** The fix every plugin loader fault shares. */
328
+ const loaderCorrection = "Make load() resolve to a module whose default export is the plugin's function, such as () => import('./middleware.js').";
214
329
  /** One plugin's middleware, or `undefined` for a plugin that declares none. */
215
330
  function readMiddleware(identity, declared, names) {
216
331
  if (declared === undefined) {
217
332
  return undefined;
218
333
  }
334
+ const site = pluginSlot(identity, 'middleware', declared);
219
335
  if (!isPlainObject(declared)) {
220
- throw new DeclarationError(`${pluginSentence(identity)} declares middleware that is not an object. Supply { activate, load }.`);
336
+ throw new DeclarationError(notAnObject, {
337
+ correction: 'Supply { activate, load }.',
338
+ findings: [partFinding(site, [])],
339
+ sentence: `${pluginSentence(identity)} declares middleware that is not an object.`,
340
+ });
221
341
  }
222
- const activate = readActivation(identity, declared.activate, names);
342
+ const activate = readActivation(site, declared, names);
223
343
  const { load } = declared;
224
344
  if (!isLoader(load)) {
225
- throw new DeclarationError(`${pluginSentence(identity)} declares middleware with no load function. Supply load: () => import('./middleware.js').`);
345
+ throw new DeclarationError(notAFunction, {
346
+ correction: "Supply load: () => import('./middleware.js').",
347
+ findings: [partFinding(site, 'load' in declared ? ['load'] : [])],
348
+ sentence: `${pluginSentence(identity)} declares middleware with no load function.`,
349
+ });
226
350
  }
227
351
  return { activate, load };
228
352
  }
@@ -236,17 +360,42 @@ function readCommands(identity, declared) {
236
360
  return [];
237
361
  }
238
362
  if (!Array.isArray(declared)) {
239
- throw new DeclarationError(`${pluginSentence(identity)} declares commands that are not an array. Supply a list of Command values.`);
363
+ throw new DeclarationError(notAList, {
364
+ correction: 'Supply a list of Command values.',
365
+ findings: [partFinding(pluginSlot(identity, 'commands', declared), [])],
366
+ sentence: `${pluginSentence(identity)} declares commands that are not an array.`,
367
+ });
240
368
  }
241
369
  const list = declared;
242
370
  const commands = [];
371
+ // Each entry prints as the Command it is, so a finding rebuilds the list the plugin declared.
372
+ const entries = Array.from(list, (value) => {
373
+ const node = commandNode(value);
374
+ return node ? commandCode(node.name) : value;
375
+ });
243
376
  // A for...of walk reads a hole as undefined, which the entry rule rejects, where map would skip it.
244
- for (const value of list) {
377
+ for (const [index, value] of list.entries()) {
245
378
  const node = commandNode(value);
379
+ const placement = {
380
+ arguments: [identity, { commands: entries }],
381
+ call: 'plugin',
382
+ mark: `1.commands.${String(index)}`,
383
+ };
246
384
  if (!node) {
247
- throw new DeclarationError(`${pluginSentence(identity)} holds a value that is not a Command. Supply the value returned by new Command(name).`);
385
+ throw new DeclarationError(notACommand, {
386
+ correction: 'Supply the value returned by new Command(name).',
387
+ findings: [placement],
388
+ sentence: `${pluginSentence(identity)} holds a value that is not a Command.`,
389
+ });
248
390
  }
249
- commands.push(attach({ argument: undefined, children: commands, hasAction: false, name: null }, node));
391
+ const parent = {
392
+ argument: undefined,
393
+ children: commands,
394
+ hasAction: false,
395
+ name: null,
396
+ path: [],
397
+ };
398
+ commands.push(attach(parent, node, placement));
250
399
  }
251
400
  return commands;
252
401
  }
@@ -260,21 +409,39 @@ function readSignals(identity, declared) {
260
409
  if (declared === undefined) {
261
410
  return [];
262
411
  }
412
+ const site = pluginSlot(identity, 'signals', declared);
263
413
  if (!Array.isArray(declared)) {
264
- throw new DeclarationError(`${pluginSentence(identity)} declares signals that are not an array. Supply a list of signal names.`);
414
+ throw new DeclarationError(notAList, {
415
+ correction: 'Supply a list of signal names.',
416
+ findings: [partFinding(site, [])],
417
+ sentence: `${pluginSentence(identity)} declares signals that are not an array.`,
418
+ });
265
419
  }
266
420
  const list = declared;
267
- const claimed = new Set();
268
- for (const value of list) {
421
+ // Each claimed signal's position, so a repeat marks both claims.
422
+ const claimed = new Map();
423
+ for (const [index, value] of list.entries()) {
269
424
  if (!isProcessSignal(value)) {
270
- throw new DeclarationError(`${pluginSentence(identity)} claims signal "${String(value)}". Claim SIGINT or SIGTERM.`);
425
+ throw new DeclarationError(unknownSignal, {
426
+ correction: 'Claim SIGINT or SIGTERM.',
427
+ findings: [partFinding(site, [index])],
428
+ sentence: `${pluginSentence(identity)} claims signal ${quoted(value)}.`,
429
+ });
271
430
  }
272
- if (claimed.has(value)) {
273
- throw new DeclarationError(`${pluginSentence(identity)} claims signal "${value}" twice. Claim each signal once.`);
431
+ const first = claimed.get(value);
432
+ if (first !== undefined) {
433
+ throw new DeclarationError(signalClaimedTwice, {
434
+ correction: 'Claim each signal once.',
435
+ findings: [
436
+ partFinding(site, [first], 'the first claim'),
437
+ partFinding(site, [index], 'the second claim'),
438
+ ],
439
+ sentence: `${pluginSentence(identity)} claims signal ${quoted(value)} twice.`,
440
+ });
274
441
  }
275
- claimed.add(value);
442
+ claimed.set(value, index);
276
443
  }
277
- return [...claimed];
444
+ return [...claimed.keys()];
278
445
  }
279
446
  /**
280
447
  * A lifecycle hook is a function core calls at one named point, so being callable is the whole
@@ -289,7 +456,29 @@ function readHook(identity, declared) {
289
456
  return undefined;
290
457
  }
291
458
  if (!isHook(declared)) {
292
- throw new DeclarationError(`${pluginSentence(identity)} declares onCommandAttach that is not a function. Supply a function of the Command.`);
459
+ throw new DeclarationError(notAFunction, {
460
+ correction: 'Supply a function of the Command.',
461
+ findings: [partFinding(pluginSlot(identity, 'onCommandAttach', declared), [])],
462
+ sentence: `${pluginSentence(identity)} declares onCommandAttach that is not a function.`,
463
+ });
464
+ }
465
+ return declared;
466
+ }
467
+ /** Being callable is the whole claim, as it is for `onCommandAttach`; `run()` checks what it returns. */
468
+ function isFailureHook(value) {
469
+ return typeof value === 'function';
470
+ }
471
+ /** One plugin's `onFailure` hook, or `undefined` for a plugin that declares none. */
472
+ function readFailureHook(identity, declared) {
473
+ if (declared === undefined) {
474
+ return undefined;
475
+ }
476
+ if (!isFailureHook(declared)) {
477
+ throw new DeclarationError(notAFunction, {
478
+ correction: 'Supply a function of the failure and its context.',
479
+ findings: [partFinding(pluginSlot(identity, 'onFailure', declared), [])],
480
+ sentence: `${pluginSentence(identity)} declares onFailure that is not a function.`,
481
+ });
293
482
  }
294
483
  return declared;
295
484
  }
@@ -305,37 +494,76 @@ function readSource(identity, declaration, own) {
305
494
  return undefined;
306
495
  }
307
496
  const sentence = pluginSentence(identity);
497
+ const site = pluginSlot(identity, 'source', declared);
308
498
  if (!isPlainObject(declared)) {
309
- throw new DeclarationError(`${sentence} declares a source that is not an object. Supply { binding, load }.`);
499
+ throw new DeclarationError(notAnObject, {
500
+ correction: 'Supply { binding, load }.',
501
+ findings: [partFinding(site, [])],
502
+ sentence: `${sentence} declares a source that is not an object.`,
503
+ });
310
504
  }
311
505
  const { binding, load } = declared;
312
- const listed = (declaration.extensions ?? []).some((descriptor) => descriptor === binding);
313
- if (!listed || !isDescriptor(binding)) {
314
- throw new DeclarationError(`${sentence} declares a source binding that is not one of its extensions. Supply a descriptor the plugin lists under extensions.`);
315
- }
316
- if (binding.target !== 'option') {
317
- throw new DeclarationError(`${sentence} declares source binding "${binding.identity}", which applies to ${appliesTo(binding.target)}. Supply an extension that applies to options.`);
506
+ // A fault about one key marks the key, or the source itself when the key is absent.
507
+ const keyFinding = (key) => partFinding(site, key in declared ? [key] : []);
508
+ // The plugin's own extensions were admitted first, so a listed binding is in the registry.
509
+ // Its identity is read from there, where admission read it once, and never from the binding again.
510
+ const bound = [...own.build.descriptors].find(([, descriptor]) => descriptor === binding);
511
+ if (bound === undefined) {
512
+ throw new DeclarationError(sourceBinding, {
513
+ correction: 'Supply a descriptor the plugin lists under extensions.',
514
+ findings: [keyFinding('binding')],
515
+ sentence: `${sentence} declares a source binding that is not one of its extensions.`,
516
+ });
517
+ }
518
+ const [bindingIdentity, { target }] = bound;
519
+ if (target !== 'option') {
520
+ throw new DeclarationError(sourceBinding, {
521
+ correction: 'Supply an extension that applies to options.',
522
+ findings: [keyFinding('binding')],
523
+ sentence: `${sentence} declares source binding ${quoted(bindingIdentity)}, which applies to ${appliesTo(target)}.`,
524
+ });
318
525
  }
319
526
  if (!isLoader(load)) {
320
- throw new DeclarationError(`${sentence} declares a source with no load function. Supply load: () => import('./source.js').`);
527
+ throw new DeclarationError(notAFunction, {
528
+ correction: "Supply load: () => import('./source.js').",
529
+ findings: [keyFinding('load')],
530
+ sentence: `${sentence} declares a source with no load function.`,
531
+ });
321
532
  }
322
- const carrier = own.inputs.find((input) => Object.hasOwn(own.build.records.get(input) ?? {}, binding.identity));
533
+ const carrier = own.inputs.find((input) => Object.hasOwn(own.build.records.get(input) ?? {}, bindingIdentity));
323
534
  if (carrier) {
324
- throw new DeclarationError(`${sentence} option "${carrier.name}" carries its own source binding. Remove the value; the source's own options resolve before it loads.`);
325
- }
326
- return { binding: binding.identity, load };
535
+ const option = pluginOptionSite({ identity, options: declaration.options }, carrier.name, `${sentence} option ${quoted(carrier.name)}`);
536
+ throw new DeclarationError(sourceBoundOwnOption, {
537
+ correction: "Remove the value; the source's own options resolve before it loads.",
538
+ findings: [partFinding(option, ['extensions'])],
539
+ sentence: `${option.subject} carries its own source binding.`,
540
+ });
541
+ }
542
+ return { binding: bindingIdentity, load };
327
543
  }
328
544
  /** A plugin's own list names the extensions it defines, before any declaration carries one. */
329
545
  function defineExtensions(identity, declaration, build) {
330
546
  const { extensions } = declaration;
547
+ const site = pluginSlot(identity, 'extensions', extensions);
331
548
  if (extensions !== undefined && !Array.isArray(extensions)) {
332
- throw new DeclarationError(`${pluginSentence(identity)} declares extensions that are not an array. Supply a list of extension descriptors.`);
549
+ throw new DeclarationError(notAList, {
550
+ correction: 'Supply a list of extension descriptors.',
551
+ findings: [partFinding(site, [])],
552
+ sentence: `${pluginSentence(identity)} declares extensions that are not an array.`,
553
+ });
333
554
  }
334
- for (const descriptor of extensions ?? []) {
555
+ for (const [index, descriptor] of (extensions ?? []).entries()) {
335
556
  if (!isDescriptor(descriptor)) {
336
- throw new DeclarationError(`${pluginSentence(identity)} holds a value that is not an extension. Supply the value returned by extension(identity, config).`);
557
+ throw new DeclarationError(foreignValue, {
558
+ correction: 'Supply the value returned by extension(identity, config).',
559
+ findings: [partFinding(site, [index])],
560
+ sentence: `${pluginSentence(identity)} holds a value that is not an extension.`,
561
+ });
337
562
  }
338
- registerDescriptor(build.descriptors, descriptor);
563
+ registerDescriptor(build.descriptors, descriptor, {
564
+ holder: pluginSentence(identity),
565
+ place: partFinding(site, [index]),
566
+ });
339
567
  }
340
568
  }
341
569
  /**
@@ -344,8 +572,8 @@ function defineExtensions(identity, declaration, build) {
344
572
  * copy is reported from the list that defines it. The views list is read against core's view
345
573
  * identities, and the Application reads it again against every other contributor's.
346
574
  */
347
- function readPlugin(identity, definition) {
348
- const named = readIdentity(identity);
575
+ function readPlugin(named, definition) {
576
+ checkIdentity('plugin', named);
349
577
  const declaration = definitionOf(named, definition);
350
578
  const theme = declaration.theme === undefined ? undefined : buildTheme(declaration.theme, named);
351
579
  const build = { descriptors: new Map(), records: new Map() };
@@ -357,7 +585,9 @@ function readPlugin(identity, definition) {
357
585
  const commands = readCommands(named, declaration.commands);
358
586
  const middleware = readMiddleware(named, declaration.middleware, names);
359
587
  const onCommandAttach = readHook(named, declaration.onCommandAttach);
360
- buildViews({ declares: true, sentence: pluginSentence(named) }, declaration.views, viewIdentities(coreViews));
588
+ const onFailure = readFailureHook(named, declaration.onFailure);
589
+ buildViews(pluginViews(named), declaration.views, viewIdentities(coreViews));
590
+ const translators = readTranslations(pluginSlot(named, 'translators', declaration.translators), declaration.translators);
361
591
  return {
362
592
  commands,
363
593
  descriptors: build.descriptors,
@@ -365,10 +595,12 @@ function readPlugin(identity, definition) {
365
595
  inputs,
366
596
  middleware,
367
597
  onCommandAttach,
598
+ onFailure,
368
599
  records: build.records,
369
600
  signals,
370
601
  source,
371
602
  theme,
603
+ translators,
372
604
  views: declaration.views,
373
605
  };
374
606
  }
@@ -407,10 +639,10 @@ function pluginValues(inputs, values) {
407
639
  /** One plugin's own spellings for one run, frozen, so no plugin writes what another reads. */
408
640
  function pluginSpellings(inputs, values) {
409
641
  // Entries become own keys even for a name such as `__proto__`, which assignment would not.
410
- const spelled = inputs.flatMap(({ name }) => {
642
+ const supplied = inputs.flatMap(({ name }) => {
411
643
  const spelling = values.spellings.get(name);
412
644
  return spelling === undefined ? [] : [[name, spelling]];
413
645
  });
414
- return Object.freeze(Object.fromEntries(spelled));
646
+ return Object.freeze(Object.fromEntries(supplied));
415
647
  }
416
- export { installPlugins, loadDefault, ownedSignals, plugin, pluginSentence, pluginSpellings, pluginValues, };
648
+ export { installPlugins, loadDefault, ownedSignals, plugin, pluginSentence, pluginSpellings, pluginViews, pluginValues, };