@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/facts.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { misplacedListingFact, notOneLine } from './command-rules.js';
1
2
  import { DeclarationError } from './errors.js';
3
+ import { flagNotBoolean } from './input-rules.js';
2
4
  /**
3
5
  * One character outside Unicode `White_Space`, so a fact holds prose and not only spacing.
4
6
  * The class covers the tab, the space, the line terminators, the no-break space, and every other
@@ -10,6 +12,76 @@ const prose = /\P{White_Space}/u;
10
12
  * The seven are LF, VT, FF, CR, NEL, LS, and PS, each of them `White_Space` too.
11
13
  */
12
14
  const lineTerminator = /[\n\v\f\r\u0085\u2028\u2029]/u;
15
+ /** The finding for the call one site holds, marking one part of it, with a note when given. */
16
+ export function siteFinding(site, mark, note) {
17
+ const finding = { ...site.declaration, mark };
18
+ return note === undefined ? finding : { ...finding, note };
19
+ }
20
+ /**
21
+ * Where one key of a declaration's options object sits, rebuilt with that key alone, as
22
+ * `plugin(identity, { middleware })` or `new Application(name, { plugins })`, at `1.<key>`. A fault
23
+ * about one slot shows the slot, not every other one the author declared beside it.
24
+ */
25
+ export function slotSite(declaration, key, value) {
26
+ const { call, named, subject } = declaration;
27
+ // `Object.fromEntries` defines the key as an own property whatever its name.
28
+ return {
29
+ at: `1.${key}`,
30
+ declaration: { arguments: [named, Object.fromEntries([[key, value]])], call },
31
+ subject,
32
+ };
33
+ }
34
+ /**
35
+ * The dotted path to one part inside the value a site holds, such as `1.middleware.activate` for
36
+ * `activate` under a site at `1.middleware`. A site at the call's own arguments has an empty path.
37
+ */
38
+ export function partOf(site, ...keys) {
39
+ return [site.at, ...keys.map(String)].filter((key) => key !== '').join('.');
40
+ }
41
+ /** The finding that marks one part inside the value a site holds, with a note when given. */
42
+ export function partFinding(site, keys, note) {
43
+ return siteFinding(site, partOf(site, ...keys), note);
44
+ }
45
+ /**
46
+ * One fact's fault, which marks the fact inside the call that declared it. Every rule about one key
47
+ * of a declaration's config object reports through it, so each marks the key the same way.
48
+ */
49
+ export function factFault(rule, site, parts) {
50
+ const { correction, fact, sentence } = parts;
51
+ return new DeclarationError(rule, {
52
+ correction,
53
+ findings: [siteFinding(site, `${site.at}.${fact}`)],
54
+ sentence,
55
+ });
56
+ }
57
+ /**
58
+ * One yes-or-no declaration key that holds a value other than a Boolean, such as `required` or
59
+ * `hidden`. Every such key reports under one rule, in one sentence and with one correction.
60
+ */
61
+ export function flagFault(site, flag) {
62
+ return factFault(flagNotBoolean, site, {
63
+ correction: 'Use true or false.',
64
+ fact: flag,
65
+ sentence: `${site.subject} declares ${flag} that is not a Boolean.`,
66
+ });
67
+ }
68
+ /** The site of an input a call declared as `call(name, config)`, such as `option()`. */
69
+ export function callSite(subject, declaration) {
70
+ return { at: '1', declaration, named: '0', subject };
71
+ }
72
+ /**
73
+ * The site of one option a plugin declared, rebuilt as `plugin(identity, { options })` from the
74
+ * options the plugin holds. The option's entry in the record stands for its name.
75
+ */
76
+ export function pluginOptionSite(plugin, name, subject) {
77
+ const at = `1.options.${name}`;
78
+ return {
79
+ at,
80
+ declaration: { arguments: [plugin.identity, { options: plugin.options }], call: 'plugin' },
81
+ named: at,
82
+ subject,
83
+ };
84
+ }
13
85
  /**
14
86
  * One line of prose: a string that holds a character other than whitespace and no line terminator.
15
87
  * Every one-line fact reads this rule, and so does the label a configuration source answers with.
@@ -22,12 +94,16 @@ export function isProseLine(value) {
22
94
  * string fails the same way a blank one does, because the author reads one rule for one fact.
23
95
  * An omitted description is absent, not a fault, so it passes through as `undefined`.
24
96
  */
25
- export function checkDescription(subject, value) {
97
+ export function checkDescription(site, value) {
26
98
  if (value === undefined) {
27
99
  return undefined;
28
100
  }
29
101
  if (!isProseLine(value)) {
30
- throw new DeclarationError(`${subject} description must hold a character other than whitespace and no line terminator. Supply a one-line summary.`);
102
+ throw factFault(notOneLine, site, {
103
+ correction: 'Supply a one-line summary.',
104
+ fact: 'description',
105
+ sentence: `${site.subject} description must hold a character other than whitespace and no line terminator.`,
106
+ });
31
107
  }
32
108
  return value;
33
109
  }
@@ -36,12 +112,12 @@ export function checkDescription(subject, value) {
36
112
  * asks one question of it, and an omitted declaration reads `false`. Routing selects and parsing
37
113
  * binds without reading it, so a hidden member behaves as any other.
38
114
  */
39
- export function checkHidden(subject, value) {
115
+ export function checkHidden(site, value) {
40
116
  if (value === undefined) {
41
117
  return false;
42
118
  }
43
119
  if (typeof value !== 'boolean') {
44
- throw new DeclarationError(`${subject} hidden must be a Boolean. Supply true or false, or omit it.`);
120
+ throw flagFault(site, 'hidden');
45
121
  }
46
122
  return value;
47
123
  }
@@ -51,12 +127,16 @@ export function checkHidden(subject, value) {
51
127
  * prints. A bare `true` is rejected with every other value that is not prose: a deprecation with
52
128
  * no migration path leaves an operator or an agent with nothing to do.
53
129
  */
54
- export function checkDeprecated(subject, value) {
130
+ export function checkDeprecated(site, value) {
55
131
  if (value === undefined) {
56
132
  return undefined;
57
133
  }
58
134
  if (!isProseLine(value)) {
59
- throw new DeclarationError(`${subject} deprecated message must hold a character other than whitespace and no line terminator. Supply a one-line migration path, such as "Use get instead.".`);
135
+ throw factFault(notOneLine, site, {
136
+ correction: 'Supply a one-line migration path, such as "Use get instead.".',
137
+ fact: 'deprecated',
138
+ sentence: `${site.subject} deprecated message must hold a character other than whitespace and no line terminator.`,
139
+ });
60
140
  }
61
141
  return value;
62
142
  }
@@ -66,10 +146,14 @@ export function checkDeprecated(subject, value) {
66
146
  * a JavaScript author, or a TypeScript author whose argument config is inferred from a value,
67
147
  * reaches this rule instead.
68
148
  */
69
- export function checkNoListingFacts(subject, declared) {
149
+ export function checkNoListingFacts(site, declared) {
70
150
  for (const fact of ['hidden', 'deprecated']) {
71
151
  if (fact in declared) {
72
- throw new DeclarationError(`${subject} declares ${fact}, which applies to named Commands and options alone. Remove it.`);
152
+ throw factFault(misplacedListingFact, site, {
153
+ correction: 'Remove it.',
154
+ fact,
155
+ sentence: `${site.subject} declares ${fact}, which applies to named Commands and options alone.`,
156
+ });
73
157
  }
74
158
  }
75
159
  }
@@ -79,24 +163,16 @@ export function checkNoListingFacts(subject, declared) {
79
163
  * omitted version is `0.0.0`, which means unversioned, and core keeps no record of which one the
80
164
  * author wrote.
81
165
  */
82
- export function checkVersion(value) {
166
+ export function checkVersion(site, value) {
83
167
  if (value === undefined) {
84
168
  return '0.0.0';
85
169
  }
86
170
  if (!isProseLine(value)) {
87
- throw new DeclarationError('The Application version must be a string that holds a character other than whitespace and no line terminator. Supply a string such as "1.2.0".');
171
+ throw factFault(notOneLine, site, {
172
+ correction: 'Supply a string such as "1.2.0".',
173
+ fact: 'version',
174
+ sentence: `${site.subject} version must be a string that holds a character other than whitespace and no line terminator.`,
175
+ });
88
176
  }
89
177
  return value;
90
178
  }
91
- /**
92
- * A structural value core reads as plain data: an object literal, and never a declaration that
93
- * carries state of its own. The options slots read it to reject a value that is not an options
94
- * object, and inspection reads it to copy a declared value faithfully.
95
- */
96
- export function isPlainObject(value) {
97
- if (value === null || typeof value !== 'object') {
98
- return false;
99
- }
100
- const prototype = Object.getPrototypeOf(value);
101
- return prototype === Object.prototype || prototype === null;
102
- }
package/dist/globals.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { BoundOption } from './bindings.js';
2
- import { DeclarationError } from './errors.js';
3
2
  import type { DescriptorRegistry } from './extension.js';
3
+ import type { InputSite } from './facts.js';
4
4
  import { compileOptions } from './options.js';
5
+ import type { CompileScope } from './options.js';
5
6
  import type { BuiltPlugin } from './plugin.js';
6
7
  import type { OptionConfig, OptionValue } from './types.js';
7
8
  import type { InputDeclaration, OptionInput, ValidatedInputs } from './validation.js';
@@ -20,26 +21,22 @@ type OptionOwner = {
20
21
  identity: string;
21
22
  order: number;
22
23
  };
23
- /** One side of a collision: the option's declared name under the scope that declared it. */
24
- interface OptionSite {
25
- name: string;
24
+ /** One option the globals table holds: the scope that declared it, and where it was declared. */
25
+ interface TableEntry {
26
26
  owner: OptionOwner;
27
+ site: InputSite;
27
28
  }
28
- /** One key claimed twice, whichever two scopes claimed it. */
29
- declare function keyCollision(name: string, first: OptionOwner, second: OptionOwner): DeclarationError;
30
- /** One spelling claimed twice, whichever two scopes claimed it. */
31
- declare function spellingCollision(spelling: string, first: OptionSite, second: OptionSite): DeclarationError;
32
29
  /**
33
30
  * The globals table the pre-scan reads, with every rule that pairs two of its options settled: one
34
- * spelling map, the scope that owns each key, and the variable each option binds. It holds the
35
- * application's global options and every installed plugin's options, because the pre-scan reads one
36
- * table.
31
+ * spelling map, the scope that owns each key and where it was declared, and the variable each
32
+ * option binds. It holds the application's global options and every installed plugin's options,
33
+ * because the pre-scan reads one table.
37
34
  */
38
35
  interface GlobalTable {
39
- names: ReadonlyMap<string, OptionOwner>;
36
+ names: ReadonlyMap<string, TableEntry>;
40
37
  options: ReturnType<typeof compileOptions>;
41
- /** The variable each option in the table binds, under the phrase a duplicate names it by. */
42
- variables: ReadonlyMap<string, string>;
38
+ /** The variable each option in the table binds, with the option that binds it. */
39
+ variables: ReadonlyMap<string, BoundOption>;
43
40
  }
44
41
  /**
45
42
  * The compiled table one graph build shares. `inputs` holds the application's declarations alone,
@@ -62,12 +59,22 @@ interface GlobalsState<Globals = unknown> {
62
59
  records: InputRecords;
63
60
  }
64
61
  declare function emptyGlobals(): GlobalsState<{}>;
62
+ /** Where one global option was declared: its `globalOption()` call on the Application. */
63
+ declare function globalSite(input: OptionInput): InputSite;
64
+ /**
65
+ * Where each of one plugin's options was declared: its entry in the `options` record of the
66
+ * plugin's `plugin()` call, which is rebuilt from every option the plugin holds.
67
+ */
68
+ declare function pluginSites(identity: string, inputs: readonly OptionInput[]): (input: OptionInput) => InputSite;
65
69
  /**
66
70
  * One global option's own facts, binding, and extension values, checked at its `globalOption()`
67
71
  * call against the Application's descriptors. The rules that pair it with another option belong to
68
72
  * the table, which the caller rebuilds with it.
69
73
  */
70
- declare function declareGlobalOption<Globals, Name extends string, Config extends OptionConfig>(state: GlobalsState<Globals>, input: OptionInput<Name, Config>, descriptors: DescriptorRegistry): GlobalsState<Globals & Record<Name, OptionValue<Config>>>;
74
+ declare function declareGlobalOption<Globals, Name extends string, Config extends OptionConfig>(state: GlobalsState<Globals>, declared: OptionInput<Name, Config>, descriptors: DescriptorRegistry): {
75
+ readonly input: OptionInput<Name, Config>;
76
+ readonly state: GlobalsState<Globals & Record<Name, OptionValue<Config>>>;
77
+ };
71
78
  /**
72
79
  * The one table the pre-scan reads: the application's global options in authoring order, then each
73
80
  * installed plugin's options in installation order. Every collision between the two scopes, by key
@@ -81,10 +88,11 @@ declare function buildGlobals(node: GlobalsState, plugins: readonly BuiltPlugin[
81
88
  * application's globals and every plugin option, so a local collision reads the same sentence
82
89
  * whichever scope on the other side claimed the name, the spelling, or the variable. One
83
90
  * invocation's scope is this Command's own options and the table, so a variable binds one option
84
- * there, while a sibling Command may bind it again.
91
+ * there, while a sibling Command may bind it again. `scope` names the Command and places each of
92
+ * its options.
85
93
  */
86
- declare function checkLocalOptions(declarations: readonly OptionInput[], table: GlobalTable, subject: string): ReturnType<typeof compileOptions>;
87
- /** The options in one list that bind a variable, each named by the phrase its scope gives it. */
88
- declare function boundOptions(inputs: readonly OptionInput[], site: (name: string) => string): BoundOption[];
89
- export type { BuiltGlobals, GlobalsState, GlobalTable, InputRecords, OptionOwner, OptionSite };
90
- export { boundOptions, buildGlobals, checkLocalOptions, declareGlobalOption, emptyGlobals, globalTable, keyCollision, spellingCollision, };
94
+ declare function checkLocalOptions(declarations: readonly OptionInput[], table: GlobalTable, scope: CompileScope<OptionInput>): ReturnType<typeof compileOptions>;
95
+ /** The options in one list that bind a variable, each named and placed by its scope. */
96
+ declare function boundOptions(inputs: readonly OptionInput[], describe: (input: OptionInput) => Omit<BoundOption, 'variable'>): BoundOption[];
97
+ export type { BuiltGlobals, GlobalsState, GlobalTable, InputRecords, OptionOwner, TableEntry };
98
+ export { boundOptions, buildGlobals, checkLocalOptions, declareGlobalOption, emptyGlobals, globalSite, globalTable, pluginSites, };
package/dist/globals.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { checkEnvBinding, claimVariables } from './bindings.js';
2
- import { DeclarationError } from './errors.js';
2
+ import { DeclarationError, quoted } from './errors.js';
3
3
  import { buildExtensions } from './extension.js';
4
- import { checkDeprecated, checkDescription, checkHidden } from './facts.js';
5
- import { compileOptions } from './options.js';
4
+ import { callSite, checkDeprecated, checkDescription, checkHidden, factFault, pluginOptionSite, siteFinding, } from './facts.js';
5
+ import { globalPresenceRule, optionDeclaredTwice, spellingTaken } from './input-rules.js';
6
+ import { checkOptionName, compileOptions, spellingMark } from './options.js';
7
+ import { captureConfig, checkInputConfig } from './validation.js';
6
8
  const globalSubject = 'the global options';
7
9
  /**
8
10
  * The presence keys a global option may not declare, in the order its diagnostic names them. A
@@ -29,7 +31,7 @@ function ordered(first, second) {
29
31
  /** How one owner reads in a key collision. A repeated preposition is dropped after the first. */
30
32
  function declaredBy(owner, leading) {
31
33
  if (owner.kind === 'plugin') {
32
- return `${leading ? 'by ' : ''}plugin "${owner.identity}"`;
34
+ return `${leading ? 'by ' : ''}plugin ${quoted(owner.identity)}`;
33
35
  }
34
36
  return owner.kind === 'application'
35
37
  ? 'as a global option'
@@ -38,11 +40,11 @@ function declaredBy(owner, leading) {
38
40
  /** How one owner reads in a spelling collision, where each side names its own option. */
39
41
  function usedBy({ name, owner }) {
40
42
  if (owner.kind === 'plugin') {
41
- return `plugin "${owner.identity}" option "${name}"`;
43
+ return `plugin ${quoted(owner.identity)} option ${quoted(name)}`;
42
44
  }
43
45
  return owner.kind === 'application'
44
- ? `the global option "${name}"`
45
- : `the local option "${name}" on ${owner.subject}`;
46
+ ? `the global option ${quoted(name)}`
47
+ : `the local option ${quoted(name)} on ${owner.subject}`;
46
48
  }
47
49
  /** The correction each pair earns: a local is renamed, and two plugins are chosen between. */
48
50
  function correction(first, second) {
@@ -53,45 +55,91 @@ function correction(first, second) {
53
55
  ? 'Install one of them or rename the option.'
54
56
  : 'Rename one declaration.';
55
57
  }
58
+ /** The note beside one side's finding, which says which scope declared it. */
59
+ const sideNotes = {
60
+ application: 'the global option',
61
+ local: 'the local option',
62
+ plugin: 'the plugin option',
63
+ };
56
64
  /** One key claimed twice, whichever two scopes claimed it. */
57
- function keyCollision(name, first, second) {
58
- const [leading, trailing] = ordered({ name, owner: first }, { name, owner: second });
59
- return new DeclarationError(`Option "${name}" is declared ${declaredBy(leading.owner, true)} and ${declaredBy(trailing.owner, false)}. ${correction(leading.owner, trailing.owner)}`);
65
+ function keyCollision(first, second) {
66
+ const pair = ordered(first, second);
67
+ const [leading, trailing] = pair;
68
+ return new DeclarationError(optionDeclaredTwice, {
69
+ correction: correction(leading.owner, trailing.owner),
70
+ findings: pair.map(({ owner, site }) => siteFinding(site, site.named, sideNotes[owner.kind])),
71
+ sentence: `Option ${quoted(leading.name)} is declared ${declaredBy(leading.owner, true)} and ${declaredBy(trailing.owner, false)}.`,
72
+ });
60
73
  }
61
74
  /** One spelling claimed twice, whichever two scopes claimed it. */
62
75
  function spellingCollision(spelling, first, second) {
63
- const [leading, trailing] = ordered(first, second);
64
- return new DeclarationError(`Option spelling "${spelling}" is used by ${usedBy(leading)} and ${usedBy(trailing)}. Change one declaration.`);
76
+ const pair = ordered(first, second);
77
+ const [leading, trailing] = pair;
78
+ return new DeclarationError(spellingTaken, {
79
+ correction: 'Change one declaration.',
80
+ findings: pair.map(({ owner, role, site }) => siteFinding(site, spellingMark(site, role), sideNotes[owner.kind])),
81
+ sentence: `Option spelling ${quoted(spelling)} is used by ${usedBy(leading)} and ${usedBy(trailing)}.`,
82
+ });
65
83
  }
66
84
  function emptyGlobals() {
67
85
  return { bind: () => ({}), inputs: [], records: new Map() };
68
86
  }
87
+ /** Where one global option was declared: its `globalOption()` call on the Application. */
88
+ function globalSite(input) {
89
+ // A global option belongs to the application, not to one Command, so its facts read that way.
90
+ return callSite(`Global option ${quoted(input.name)}`, {
91
+ arguments: [input.name, input.config],
92
+ call: 'globalOption',
93
+ path: [],
94
+ });
95
+ }
96
+ /**
97
+ * Where each of one plugin's options was declared: its entry in the `options` record of the
98
+ * plugin's `plugin()` call, which is rebuilt from every option the plugin holds.
99
+ */
100
+ function pluginSites(identity, inputs) {
101
+ const options = Object.fromEntries(inputs.map(({ config, name }) => [name, config]));
102
+ return (input) => pluginOptionSite({ identity, options }, input.name, `Plugin ${quoted(identity)} option "${input.name}"`);
103
+ }
69
104
  /**
70
105
  * One global option's own facts, binding, and extension values, checked at its `globalOption()`
71
106
  * call against the Application's descriptors. The rules that pair it with another option belong to
72
107
  * the table, which the caller rebuilds with it.
73
108
  */
74
- function declareGlobalOption(state, input, descriptors) {
75
- // A global option belongs to the application, not to one Command, so its facts read that way.
76
- const sentence = `Global option "${input.name}"`;
109
+ function declareGlobalOption(state, declared, descriptors) {
110
+ // The name is judged before the config, as every other declaration judges its own name first.
111
+ checkOptionName(declared.name, globalSite(declared));
112
+ checkInputConfig(declared, { call: 'globalOption', path: [] });
113
+ const input = { ...declared, config: captureConfig(declared.config) };
114
+ const site = globalSite(input);
115
+ const sentence = site.subject;
77
116
  const rejected = omissionRules.find((key) => key in input.config);
78
117
  if (rejected !== undefined) {
79
- throw new DeclarationError(`${sentence} declares ${rejected}. Remove it; an omitted global option is absent, and a Command that needs its value checks for it.`);
118
+ throw factFault(globalPresenceRule, site, {
119
+ correction: `Remove ${rejected}, and check for the value in each Command that needs it.`,
120
+ fact: rejected,
121
+ sentence: `${sentence} declares ${rejected}.`,
122
+ });
80
123
  }
81
- checkDescription(sentence, input.config.description);
82
- checkHidden(sentence, input.config.hidden);
83
- checkDeprecated(sentence, input.config.deprecated);
84
- checkEnvBinding(sentence, input.config);
124
+ checkDescription(site, input.config.description);
125
+ checkHidden(site, input.config.hidden);
126
+ checkDeprecated(site, input.config.deprecated);
127
+ checkEnvBinding(site, input.config);
85
128
  const record = buildExtensions({
86
129
  declared: input.config.extensions,
87
130
  descriptors,
88
- subject: { phrase: `on the global option "${input.name}"`, sentence },
131
+ site: { ...site, at: '1.extensions' },
132
+ subject: { phrase: `on the global option ${quoted(input.name)}`, sentence },
89
133
  target: 'option',
90
134
  });
135
+ // The caller validates the captured input this state stores, so both read one capture.
91
136
  return {
92
- bind: (values) => ({ ...state.bind(values), ...values.option(input) }),
93
- inputs: [...state.inputs, input],
94
- records: new Map([...state.records, [input, record]]),
137
+ input,
138
+ state: {
139
+ bind: (values) => ({ ...state.bind(values), ...values.option(input) }),
140
+ inputs: [...state.inputs, input],
141
+ records: new Map([...state.records, [input, record]]),
142
+ },
95
143
  };
96
144
  }
97
145
  /**
@@ -103,9 +151,9 @@ function globalTable(inputs, plugins) {
103
151
  const names = new Map();
104
152
  const application = { kind: 'application' };
105
153
  for (const input of inputs) {
106
- names.set(input.name, application);
154
+ names.set(input.name, { owner: application, site: globalSite(input) });
107
155
  }
108
- const options = compileOptions(inputs, globalSubject);
156
+ const options = compileOptions(inputs, { siteOf: globalSite, subject: globalSubject });
109
157
  plugins.forEach((installed, order) => {
110
158
  join({ identity: installed.identity, kind: 'plugin', order }, installed.inputs, {
111
159
  names,
@@ -113,8 +161,17 @@ function globalTable(inputs, plugins) {
113
161
  });
114
162
  });
115
163
  const variables = claimVariables([
116
- ...boundOptions(inputs, (name) => `global option "${name}"`),
117
- ...plugins.flatMap((installed) => boundOptions(installed.inputs, (name) => `plugin "${installed.identity}" option "${name}"`)),
164
+ ...boundOptions(inputs, (input) => ({
165
+ phrase: `global option ${quoted(input.name)}`,
166
+ site: globalSite(input),
167
+ })),
168
+ ...plugins.flatMap((installed) => {
169
+ const siteOf = pluginSites(installed.identity, installed.inputs);
170
+ return boundOptions(installed.inputs, (input) => ({
171
+ phrase: `plugin ${quoted(installed.identity)} option "${input.name}"`,
172
+ site: siteOf(input),
173
+ }));
174
+ }),
118
175
  ]);
119
176
  return { names, options, variables };
120
177
  }
@@ -127,47 +184,59 @@ function buildGlobals(node, plugins) {
127
184
  * application's globals and every plugin option, so a local collision reads the same sentence
128
185
  * whichever scope on the other side claimed the name, the spelling, or the variable. One
129
186
  * invocation's scope is this Command's own options and the table, so a variable binds one option
130
- * there, while a sibling Command may bind it again.
187
+ * there, while a sibling Command may bind it again. `scope` names the Command and places each of
188
+ * its options.
131
189
  */
132
- function checkLocalOptions(declarations, table, subject) {
190
+ function checkLocalOptions(declarations, table, scope) {
191
+ const { siteOf, subject } = scope;
133
192
  const local = { kind: 'local', subject };
134
- const application = { kind: 'application' };
135
193
  for (const declaration of declarations) {
136
194
  const claimed = table.names.get(declaration.name);
137
195
  if (claimed) {
138
- throw keyCollision(declaration.name, claimed, local);
196
+ throw keyCollision({ ...claimed, name: declaration.name }, { name: declaration.name, owner: local, site: siteOf(declaration) });
139
197
  }
140
198
  }
141
- const options = compileOptions(declarations, subject);
199
+ const options = compileOptions(declarations, scope);
142
200
  for (const [spelling, option] of options) {
143
201
  const global = table.options.get(spelling);
144
- if (global) {
145
- throw spellingCollision(spelling, { name: global.name, owner: table.names.get(global.name) ?? application }, { name: option.name, owner: local });
202
+ const claimed = global && table.names.get(global.name);
203
+ const declaration = declarations.find((input) => input.name === option.name);
204
+ if (global && claimed && declaration) {
205
+ throw spellingCollision(spelling, { ...claimed, name: global.name, role: global.role }, { name: option.name, owner: local, role: option.role, site: siteOf(declaration) });
146
206
  }
147
207
  }
148
- claimVariables(boundOptions(declarations, (option) => `${subject} option "${option}"`), table.variables);
208
+ claimVariables(boundOptions(declarations, (input) => ({
209
+ phrase: `${subject} option "${input.name}"`,
210
+ site: siteOf(input),
211
+ })), table.variables);
149
212
  return options;
150
213
  }
151
- /** The options in one list that bind a variable, each named by the phrase its scope gives it. */
152
- function boundOptions(inputs, site) {
153
- return inputs.flatMap(({ config, name }) => config.env === undefined ? [] : [{ site: site(name), variable: config.env }]);
214
+ /** The options in one list that bind a variable, each named and placed by its scope. */
215
+ function boundOptions(inputs, describe) {
216
+ return inputs.flatMap((input) => input.config.env === undefined ? [] : [{ ...describe(input), variable: input.config.env }]);
154
217
  }
155
218
  /** One plugin's options joining the table the application's globals already hold. */
156
219
  function join(owner, inputs, table) {
157
220
  const { names, options } = table;
221
+ const siteOf = pluginSites(owner.identity, inputs);
158
222
  for (const input of inputs) {
159
223
  const claimed = names.get(input.name);
160
224
  if (claimed) {
161
- throw keyCollision(input.name, owner, claimed);
225
+ throw keyCollision({ name: input.name, owner, site: siteOf(input) }, { ...claimed, name: input.name });
162
226
  }
163
- names.set(input.name, owner);
227
+ names.set(input.name, { owner, site: siteOf(input) });
164
228
  }
165
- for (const [spelling, option] of compileOptions(inputs, `plugin "${owner.identity}"`)) {
166
- const claimed = options.get(spelling);
167
- if (claimed) {
168
- throw spellingCollision(spelling, { name: option.name, owner }, { name: claimed.name, owner: names.get(claimed.name) ?? { kind: 'application' } });
229
+ for (const [spelling, option] of compileOptions(inputs, {
230
+ siteOf,
231
+ subject: `plugin ${quoted(owner.identity)}`,
232
+ })) {
233
+ const existing = options.get(spelling);
234
+ const claimed = existing && names.get(existing.name);
235
+ const own = names.get(option.name);
236
+ if (existing && claimed && own) {
237
+ throw spellingCollision(spelling, { ...own, name: option.name, role: option.role }, { ...claimed, name: existing.name, role: existing.role });
169
238
  }
170
239
  options.set(spelling, option);
171
240
  }
172
241
  }
173
- export { boundOptions, buildGlobals, checkLocalOptions, declareGlobalOption, emptyGlobals, globalTable, keyCollision, spellingCollision, };
242
+ export { boundOptions, buildGlobals, checkLocalOptions, declareGlobalOption, emptyGlobals, globalSite, globalTable, pluginSites, };
@@ -0,0 +1,79 @@
1
+ import type { Writable } from 'node:stream';
2
+ import type { DeveloperScene } from './developer.js';
3
+ import type { LoomError } from './errors.js';
4
+ import type { CommandGraph, CommandNode } from './inspect.js';
5
+ import type { Output } from './output.js';
6
+ import type { BuiltPlugin } from './plugin.js';
7
+ import type { ContextualStyle } from './style.js';
8
+ import type { ViewRegistry } from './view.js';
9
+ /**
10
+ * What one `onFailure` hook reads beside the failure. `application` and `path` are the values the
11
+ * failure view reads, and `style` is the contextual style for stderr, so a hook escapes text the
12
+ * operator typed. `graph` is the frozen graph `inspect()` returns for the run, and `command` is the
13
+ * node at `path` inside it; reading either builds the run's graph once.
14
+ */
15
+ interface FailureHookContext {
16
+ readonly application: string;
17
+ readonly path: readonly string[];
18
+ readonly style: ContextualStyle;
19
+ readonly graph: CommandGraph;
20
+ readonly command: CommandNode;
21
+ }
22
+ /**
23
+ * A plugin's `onFailure` hook: a synchronous function that returns one hint, a list of hints, or
24
+ * nothing for a failure `run()` renders after graph build. It receives the failure typed read-only,
25
+ * as a failure view does. Core does not freeze the failure, and it reads the exit code before any
26
+ * hook runs, so a working hook cannot change the code.
27
+ */
28
+ type FailureHook = (failure: Readonly<LoomError>, context: FailureHookContext) => string | readonly string[] | undefined;
29
+ /** What the hooks can read once the graph has built: the installed plugins and the run's graph. */
30
+ interface BuiltRun {
31
+ plugins: readonly BuiltPlugin[];
32
+ inspected: () => CommandGraph;
33
+ }
34
+ /**
35
+ * Where one failure happened, filled where `run()` catches it. `built` is absent for a failure
36
+ * raised at or before graph build, which no hook runs for, because no valid graph exists.
37
+ */
38
+ interface FailureScene {
39
+ application: string;
40
+ path: readonly string[];
41
+ built: BuiltRun | undefined;
42
+ }
43
+ /**
44
+ * What one run's build decides about its reports. A development build shows the author a
45
+ * Developer Diagnostic for every fault only the author can fix, each after the first report of the
46
+ * run opening with one blank line; a distributed build shows the generic defect message at most
47
+ * once per run.
48
+ */
49
+ interface BuildReports {
50
+ readonly development: boolean;
51
+ /** Whether this run has written the generic defect message already. */
52
+ generic: boolean;
53
+ /** Whether this run has written a failure report already. */
54
+ reported: boolean;
55
+ }
56
+ /** Where one failure report is written, the registry its view resolves through, and the build. */
57
+ interface FailureSink {
58
+ output: Output;
59
+ registry: ViewRegistry;
60
+ stderr: Writable;
61
+ build: BuildReports;
62
+ }
63
+ /**
64
+ * Reports one failure. The hooks run first, so the diagnostic or the view receives their hints. A
65
+ * view that breaks leaves core's default text without hints on the plain fallback path, and each
66
+ * broken contract's report follows that whole diagnostic. It answers whether a view or a hook
67
+ * broke, which forces the run's code to 1 outside a cancelled run.
68
+ */
69
+ declare function reportFailure(sink: FailureSink, failure: LoomError, scene: FailureScene & {
70
+ host: DeveloperScene['host'];
71
+ }): Promise<boolean>;
72
+ /**
73
+ * What a run writes on the plain fallback path when a destination failed a write or reporting
74
+ * itself failed: the Developer Diagnostic of the broken destination in a development build, and
75
+ * the generic defect message, at most once per run, in a distributed one.
76
+ */
77
+ declare function destinationReport(build: BuildReports, cause: unknown, scene: DeveloperScene): string;
78
+ export type { BuildReports, BuiltRun, FailureHook, FailureHookContext, FailureScene };
79
+ export { destinationReport, reportFailure };