@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
package/dist/plugin.js CHANGED
@@ -1,9 +1,22 @@
1
- import { DeclarationError } from './errors.js';
2
- import { buildExtensions, isDescriptor, registerDescriptor } from './extension.js';
3
- import { checkDeprecated, checkDescription, checkHidden, isPlainObject } from './facts.js';
1
+ import { checkEnvBinding, claimVariables } from './bindings.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';
6
+ import { appliesTo, buildExtensions, isDescriptor, registerDescriptor } from './extension.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';
10
+ import { coreViews } from './lanes.js';
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';
4
15
  import { isProcessSignal } from './signals.js';
5
16
  import { buildTheme } from './theme.js';
17
+ import { readTranslations } from './translators.js';
6
18
  import { captureConfig, checkDeclarations } from './validation.js';
19
+ import { buildViews, viewIdentities } from './view.js';
7
20
  /** Authored values register here, so the public type publishes no state to reach or replace. */
8
21
  const nodes = new WeakMap();
9
22
  /**
@@ -19,8 +32,10 @@ class PluginDeclaration {
19
32
  }
20
33
  /**
21
34
  * One plugin: an identity and the contributions it carries. Creating and installing the value runs
22
- * none of its code: a hook runs at graph build, and the middleware runs inside an invocation, so an
23
- * installed plugin an invocation never reaches costs that invocation its hooks alone.
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
38
+ * that one definition carries on its own throws here, before the value exists.
24
39
  */
25
40
  function plugin(identity, definition) {
26
41
  const captured = {
@@ -29,78 +44,171 @@ function plugin(identity, definition) {
29
44
  ? {}
30
45
  : { theme: isPlainObject(definition.theme) ? { ...definition.theme } : definition.theme }),
31
46
  };
32
- return new PluginDeclaration({
33
- definition: isPlainObject(definition) ? captured : definition,
34
- identity,
35
- });
47
+ return new PluginDeclaration(readPlugin(identity, isPlainObject(definition) ? captured : definition));
36
48
  }
37
49
  /** How every plugin diagnostic names one plugin at the start of a sentence. */
38
50
  function pluginSentence(identity) {
39
- return `Plugin "${identity}"`;
51
+ return `Plugin ${quoted(identity)}`;
52
+ }
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
+ };
40
64
  }
41
- /** Reads the declarations behind an installed value; anything else is a declaration error. */
65
+ /** The declarations behind one installed value, or `undefined` for a value `plugin()` did not make. */
42
66
  function nodeOf(value) {
43
- const node = typeof value === 'object' && value !== null ? nodes.get(value) : undefined;
44
- if (!node) {
45
- throw new DeclarationError('The Application holds a value that is not a plugin. Supply the value returned by plugin(identity, definition).');
46
- }
47
- return node;
67
+ return typeof value === 'object' && value !== null ? nodes.get(value) : undefined;
48
68
  }
49
- /** The identity one installed value declares, which is a nonempty string installed once. */
50
- function readIdentity(node, installed) {
51
- const { identity } = node;
52
- if (typeof identity !== 'string') {
53
- throw new DeclarationError('A plugin declares an identity that is not a string. Supply a nonempty string, such as the package name.');
54
- }
55
- if (identity === '') {
56
- throw new DeclarationError('A plugin declares an empty identity. Supply a nonempty string, such as the package name.');
57
- }
58
- if (installed.has(identity)) {
59
- throw new DeclarationError(`The Application installs plugin "${identity}" twice. Install each plugin once.`);
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
- function definitionOf(identity, node) {
65
- const { definition } = node;
75
+ function definitionOf(identity, definition) {
66
76
  if (!isPlainObject(definition)) {
67
- 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
+ });
68
82
  }
69
83
  return definition;
70
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
+ }
71
116
  /**
72
- * The installed list in composition order, with the rules that read the list itself. The slot is
73
- * read defensively, because a JavaScript author reaches it with any value. Each plugin's own
74
- * declarations are read by the build steps that consume them, in the order those steps run.
117
+ * The installed list in composition order, with every rule that reads two plugins together: an
118
+ * identity installed twice, a second claim on the theme slot, the signals slot, or the
119
+ * configuration source, and two distinct descriptors under one identity. The slot is read
120
+ * defensively, because a JavaScript author reaches it with any value. Each plugin's own rules
121
+ * already ran at its `plugin()` call.
75
122
  */
76
- 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);
77
126
  if (!Array.isArray(plugins)) {
78
- 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
+ });
79
132
  }
80
- const installed = [];
81
- const identities = new Set();
82
- for (const value of plugins) {
133
+ const list = plugins;
134
+ const installed = list.map((value, index) => {
83
135
  const node = nodeOf(value);
84
- const identity = readIdentity(node, identities);
85
- identities.add(identity);
86
- installed.push({ declaration: definitionOf(identity, node), identity });
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();
146
+ const descriptors = new Map();
147
+ // Each slot has one owner, so the first plugin to claim it names the second claimant's diagnostic.
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()) {
157
+ const { identity } = entry;
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
+ });
168
+ }
169
+ positions.set(identity, index);
170
+ if (entry.theme !== undefined) {
171
+ claim('theme', index);
172
+ }
173
+ for (const [key, descriptor] of entry.descriptors) {
174
+ registerDescriptor(descriptors, descriptor, {
175
+ identity: key,
176
+ place: partFinding(site, [index]),
177
+ });
178
+ }
179
+ // An empty claim leaves the signals slot free.
180
+ if (entry.signals.length > 0) {
181
+ claim('signals', index);
182
+ }
183
+ if (entry.source) {
184
+ claim('source', index);
185
+ }
87
186
  }
88
- return installed;
187
+ return { descriptors, plugins: installed };
89
188
  }
90
189
  /** The keys a plugin option may not declare, in the order its diagnostic names them. */
91
190
  const forbidden = ['validate', 'validateOmitted', 'required'];
92
191
  /** The rules a plugin option answers before every rule an ordinary declaration carries. */
93
- function checkPluginOption(sentence, config) {
192
+ function checkPluginOption(site, config) {
193
+ const sentence = site.subject;
94
194
  if (!isPlainObject(config)) {
95
- 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
+ });
96
200
  }
97
201
  const rejected = forbidden.find((key) => key in config);
98
202
  if (rejected !== undefined) {
99
- throw new DeclarationError(`${sentence} declares ${rejected}. Remove it; a plugin option carries no schema or presence rule, and the middleware interprets the value.`);
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
+ });
100
208
  }
101
- checkDescription(sentence, config.description);
102
- checkHidden(sentence, config.hidden);
103
- checkDeprecated(sentence, config.deprecated);
209
+ checkDescription(site, config.description);
210
+ checkHidden(site, config.hidden);
211
+ checkDeprecated(site, config.deprecated);
104
212
  }
105
213
  /**
106
214
  * One plugin's option declarations, in declaration order. They join the globals table, so the rules
@@ -108,18 +216,25 @@ function checkPluginOption(sentence, config) {
108
216
  */
109
217
  function readOptions(identity, declared, build) {
110
218
  if (declared !== undefined && !isPlainObject(declared)) {
111
- 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
+ });
112
224
  }
113
225
  const inputs = [];
114
226
  for (const [name, config] of Object.entries(declared ?? {})) {
115
- const sentence = `${pluginSentence(identity)} option "${name}"`;
116
- checkPluginOption(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);
117
231
  const input = { config: captureConfig(config), kind: 'option', name };
118
232
  // The shared rules name the plugin and the option, so a fault reads with its contributor.
119
- checkDeclarations([input], sentence);
120
- build.extensions.set(input, buildExtensions({
233
+ checkDeclarations([{ input, site }], sentence);
234
+ build.records.set(input, buildExtensions({
121
235
  declared: config.extensions,
122
236
  descriptors: build.descriptors,
237
+ site: { ...site, at: `${site.at}.extensions` },
123
238
  subject: {
124
239
  phrase: `on ${sentence.slice(0, 1).toLowerCase()}${sentence.slice(1)}`,
125
240
  sentence,
@@ -128,28 +243,50 @@ function readOptions(identity, declared, build) {
128
243
  }));
129
244
  inputs.push(input);
130
245
  }
246
+ // Two options of one plugin meet in the one table the pre-scan reads, so they share its rules.
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
+ })));
131
253
  return inputs;
132
254
  }
133
- /** One activation name, which must be one of the plugin's own declared options. */
134
- function readActivationName(identity, name, names) {
135
- if (typeof name !== 'string' || !names.has(name)) {
136
- throw new DeclarationError(`${pluginSentence(identity)} activates middleware on option "${String(name)}", which it does not declare. Name one of the plugin's own options.`);
137
- }
138
- return name;
139
- }
140
- /** The activation a middleware declares, checked against the options its own plugin declares. */
141
- function readActivation(identity, declared, names) {
142
- 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') {
143
263
  return 'always';
144
264
  }
145
- if (!Array.isArray(declared)) {
146
- 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
+ });
147
271
  }
148
- const list = declared;
272
+ const list = activate;
149
273
  if (list.length === 0) {
150
- throw new DeclarationError(`${pluginSentence(identity)} declares middleware with an empty activation list. Name at least one of the plugin's options or use 'always'.`);
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
+ });
151
279
  }
152
- return list.map((name) => readActivationName(identity, name, names));
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
+ });
153
290
  }
154
291
  /**
155
292
  * The loader a middleware declares. Core calls it with no arguments and reads whatever it resolves
@@ -158,21 +295,110 @@ function readActivation(identity, declared, names) {
158
295
  function isLoader(value) {
159
296
  return typeof value === 'function';
160
297
  }
298
+ /**
299
+ * The default export one plugin loader resolves to, checked by the guard its caller supplies. A
300
+ * loader that throws where it is called and one that rejects later are one failure, and a module
301
+ * without the export names the kind of function it owed, such as `middleware` or `source`.
302
+ */
303
+ async function loadDefault(identity, load, owed) {
304
+ let module = undefined;
305
+ try {
306
+ module = await load();
307
+ }
308
+ catch (error) {
309
+ throw new InternalError(pluginLoaderFailed, {
310
+ cause: error,
311
+ correction: loaderCorrection,
312
+ sentence: `Loading plugin ${quoted(identity)} failed: ${reasonOf(error)}`,
313
+ });
314
+ }
315
+ const exported = module !== null && typeof module === 'object' && 'default' in module
316
+ ? module.default
317
+ : undefined;
318
+ if (!owed.guard(exported)) {
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
+ });
324
+ }
325
+ return exported;
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').";
161
329
  /** One plugin's middleware, or `undefined` for a plugin that declares none. */
162
330
  function readMiddleware(identity, declared, names) {
163
331
  if (declared === undefined) {
164
332
  return undefined;
165
333
  }
334
+ const site = pluginSlot(identity, 'middleware', declared);
166
335
  if (!isPlainObject(declared)) {
167
- 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
+ });
168
341
  }
169
- const activate = readActivation(identity, declared.activate, names);
342
+ const activate = readActivation(site, declared, names);
170
343
  const { load } = declared;
171
344
  if (!isLoader(load)) {
172
- 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
+ });
173
350
  }
174
351
  return { activate, load };
175
352
  }
353
+ /**
354
+ * The Commands one plugin attaches to the root, each checked by the attach the root applies, as a
355
+ * finished Command, against the plugin's own earlier Commands, and against the nesting cap. The
356
+ * Application attaches them again when it is constructed, against every other root child.
357
+ */
358
+ function readCommands(identity, declared) {
359
+ if (declared === undefined) {
360
+ return [];
361
+ }
362
+ if (!Array.isArray(declared)) {
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
+ });
368
+ }
369
+ const list = declared;
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
+ });
376
+ // A for...of walk reads a hole as undefined, which the entry rule rejects, where map would skip it.
377
+ for (const [index, value] of list.entries()) {
378
+ const node = commandNode(value);
379
+ const placement = {
380
+ arguments: [identity, { commands: entries }],
381
+ call: 'plugin',
382
+ mark: `1.commands.${String(index)}`,
383
+ };
384
+ if (!node) {
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
+ });
390
+ }
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));
399
+ }
400
+ return commands;
401
+ }
176
402
  /**
177
403
  * One plugin's claim on the signals slot, drawn from the closed set core installs listeners for.
178
404
  * An empty list claims nothing, so it leaves the slot free for another plugin.
@@ -183,21 +409,39 @@ function readSignals(identity, declared) {
183
409
  if (declared === undefined) {
184
410
  return [];
185
411
  }
412
+ const site = pluginSlot(identity, 'signals', declared);
186
413
  if (!Array.isArray(declared)) {
187
- 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
+ });
188
419
  }
189
420
  const list = declared;
190
- const claimed = new Set();
191
- 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()) {
192
424
  if (!isProcessSignal(value)) {
193
- 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
+ });
194
430
  }
195
- if (claimed.has(value)) {
196
- 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
+ });
197
441
  }
198
- claimed.add(value);
442
+ claimed.set(value, index);
199
443
  }
200
- return [...claimed];
444
+ return [...claimed.keys()];
201
445
  }
202
446
  /**
203
447
  * A lifecycle hook is a function core calls at one named point, so being callable is the whole
@@ -212,67 +456,193 @@ function readHook(identity, declared) {
212
456
  return undefined;
213
457
  }
214
458
  if (!isHook(declared)) {
215
- 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
+ });
216
464
  }
217
465
  return declared;
218
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
+ });
482
+ }
483
+ return declared;
484
+ }
485
+ /**
486
+ * The configuration source one plugin declares, or `undefined` for a plugin that declares none.
487
+ * The binding is one of the plugin's own option-target extensions, so core knows which options to
488
+ * ask about without knowing what the binding means. The plugin's own options resolve before the
489
+ * source loads, so none of them may carry its binding.
490
+ */
491
+ function readSource(identity, declaration, own) {
492
+ const declared = declaration.source;
493
+ if (declared === undefined) {
494
+ return undefined;
495
+ }
496
+ const sentence = pluginSentence(identity);
497
+ const site = pluginSlot(identity, 'source', declared);
498
+ if (!isPlainObject(declared)) {
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
+ });
504
+ }
505
+ const { binding, load } = declared;
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
+ });
525
+ }
526
+ if (!isLoader(load)) {
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
+ });
532
+ }
533
+ const carrier = own.inputs.find((input) => Object.hasOwn(own.build.records.get(input) ?? {}, bindingIdentity));
534
+ if (carrier) {
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 };
543
+ }
219
544
  /** A plugin's own list names the extensions it defines, before any declaration carries one. */
220
545
  function defineExtensions(identity, declaration, build) {
221
546
  const { extensions } = declaration;
547
+ const site = pluginSlot(identity, 'extensions', extensions);
222
548
  if (extensions !== undefined && !Array.isArray(extensions)) {
223
- 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
+ });
224
554
  }
225
- for (const descriptor of extensions ?? []) {
555
+ for (const [index, descriptor] of (extensions ?? []).entries()) {
226
556
  if (!isDescriptor(descriptor)) {
227
- 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
+ });
228
562
  }
229
- registerDescriptor(build.descriptors, descriptor);
563
+ registerDescriptor(build.descriptors, descriptor, {
564
+ holder: pluginSentence(identity),
565
+ place: partFinding(site, [index]),
566
+ });
230
567
  }
231
568
  }
232
569
  /**
233
- * Every installed plugin's declarations, in installation order. A plugin's own extensions register
234
- * before any declaration carries a value, so a duplicated package copy is reported from the list
235
- * that installed it.
570
+ * Every rule one definition carries on its own, in the order the definition's slots are read. The
571
+ * plugin's own extensions register before any declaration carries a value, so a duplicated package
572
+ * copy is reported from the list that defines it. The views list is read against core's view
573
+ * identities, and the Application reads it again against every other contributor's.
236
574
  */
237
- function buildPlugins(installed, build) {
238
- /**
239
- * The signals slot has one owner, so the first plugin to claim it names the second claimant's
240
- * diagnostic. An empty claim leaves the slot free.
241
- */
242
- let owner = undefined;
243
- let themeOwner = undefined;
244
- return installed.map(({ declaration, identity }) => {
245
- let theme = undefined;
246
- if (declaration.theme !== undefined) {
247
- if (themeOwner !== undefined) {
248
- throw new DeclarationError(`${pluginSentence(identity)} claims the theme slot, which plugin "${themeOwner}" already holds. Install one owner.`);
249
- }
250
- theme = buildTheme(declaration.theme, identity);
251
- themeOwner = identity;
252
- }
253
- defineExtensions(identity, declaration, build);
254
- const inputs = readOptions(identity, declaration.options, build);
255
- const names = new Set(inputs.map((input) => input.name));
256
- const signals = readSignals(identity, declaration.signals);
257
- if (signals.length > 0) {
258
- if (owner !== undefined) {
259
- throw new DeclarationError(`${pluginSentence(identity)} claims the signals slot, which plugin "${owner}" already holds. Install one owner.`);
260
- }
261
- owner = identity;
262
- }
263
- return {
264
- identity,
265
- inputs,
266
- middleware: readMiddleware(identity, declaration.middleware, names),
267
- onCommandAttach: readHook(identity, declaration.onCommandAttach),
268
- signals,
269
- theme,
270
- views: declaration.views,
271
- };
272
- });
575
+ function readPlugin(named, definition) {
576
+ checkIdentity('plugin', named);
577
+ const declaration = definitionOf(named, definition);
578
+ const theme = declaration.theme === undefined ? undefined : buildTheme(declaration.theme, named);
579
+ const build = { descriptors: new Map(), records: new Map() };
580
+ defineExtensions(named, declaration, build);
581
+ const inputs = readOptions(named, declaration.options, build);
582
+ const names = new Set(inputs.map((input) => input.name));
583
+ const signals = readSignals(named, declaration.signals);
584
+ const source = readSource(named, declaration, { build, inputs });
585
+ const commands = readCommands(named, declaration.commands);
586
+ const middleware = readMiddleware(named, declaration.middleware, names);
587
+ const onCommandAttach = readHook(named, declaration.onCommandAttach);
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);
591
+ return {
592
+ commands,
593
+ descriptors: build.descriptors,
594
+ identity: named,
595
+ inputs,
596
+ middleware,
597
+ onCommandAttach,
598
+ onFailure,
599
+ records: build.records,
600
+ signals,
601
+ source,
602
+ theme,
603
+ translators,
604
+ views: declaration.views,
605
+ };
273
606
  }
274
607
  /** The signals the one slot owner claimed, or none when no installed plugin claims the slot. */
275
608
  function ownedSignals(plugins) {
276
609
  return plugins.find((entry) => entry.signals.length > 0)?.signals ?? [];
277
610
  }
278
- export { buildPlugins, installPlugins, ownedSignals, plugin, pluginSentence };
611
+ /** A collected value, or the declared array default, as this run's own copy. */
612
+ function collectedValue(collected, declared) {
613
+ if (collected) {
614
+ return [...collected];
615
+ }
616
+ return Array.isArray(declared) ? [...declared] : [];
617
+ }
618
+ /** One plugin option's value for one run: what a tier supplied, or the declared default. */
619
+ function pluginValue({ config, name }, values) {
620
+ const declared = config.default;
621
+ if (config.type === 'boolean') {
622
+ return booleanValue(values, name, config);
623
+ }
624
+ if (config.multiple === true) {
625
+ return collectedValue(values.lists.get(name), declared);
626
+ }
627
+ // Build already proved that a string option without a validator declares a string default.
628
+ return values.strings.get(name) ?? (typeof declared === 'string' ? declared : undefined);
629
+ }
630
+ /**
631
+ * One plugin's own option values for one run: what argv or an input source supplied, or the
632
+ * declared default, filled without validation. A collected value and an array default are copied,
633
+ * so a plugin that writes to what it received changes neither the declaration nor the next run.
634
+ * Entries become own keys even for a name such as `__proto__`, which assignment would not.
635
+ */
636
+ function pluginValues(inputs, values) {
637
+ return Object.fromEntries(inputs.map((input) => [input.name, pluginValue(input, values)]));
638
+ }
639
+ /** One plugin's own spellings for one run, frozen, so no plugin writes what another reads. */
640
+ function pluginSpellings(inputs, values) {
641
+ // Entries become own keys even for a name such as `__proto__`, which assignment would not.
642
+ const supplied = inputs.flatMap(({ name }) => {
643
+ const spelling = values.spellings.get(name);
644
+ return spelling === undefined ? [] : [[name, spelling]];
645
+ });
646
+ return Object.freeze(Object.fromEntries(supplied));
647
+ }
648
+ export { installPlugins, loadDefault, ownedSignals, plugin, pluginSentence, pluginSpellings, pluginViews, pluginValues, };