@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/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,17 +12,98 @@ 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
+ }
85
+ /**
86
+ * One line of prose: a string that holds a character other than whitespace and no line terminator.
87
+ * Every one-line fact reads this rule, and so does the label a configuration source answers with.
88
+ */
89
+ export function isProseLine(value) {
90
+ return typeof value === 'string' && prose.test(value) && !lineTerminator.test(value);
91
+ }
13
92
  /**
14
93
  * The `description` core fact: one line of prose every projection reads. A value that is not a
15
94
  * string fails the same way a blank one does, because the author reads one rule for one fact.
16
95
  * An omitted description is absent, not a fault, so it passes through as `undefined`.
17
96
  */
18
- export function checkDescription(subject, value) {
97
+ export function checkDescription(site, value) {
19
98
  if (value === undefined) {
20
99
  return undefined;
21
100
  }
22
- if (typeof value !== 'string' || !prose.test(value) || lineTerminator.test(value)) {
23
- throw new DeclarationError(`${subject} description must hold a character other than whitespace and no line terminator. Supply a one-line summary.`);
101
+ if (!isProseLine(value)) {
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
+ });
24
107
  }
25
108
  return value;
26
109
  }
@@ -29,12 +112,12 @@ export function checkDescription(subject, value) {
29
112
  * asks one question of it, and an omitted declaration reads `false`. Routing selects and parsing
30
113
  * binds without reading it, so a hidden member behaves as any other.
31
114
  */
32
- export function checkHidden(subject, value) {
115
+ export function checkHidden(site, value) {
33
116
  if (value === undefined) {
34
117
  return false;
35
118
  }
36
119
  if (typeof value !== 'boolean') {
37
- throw new DeclarationError(`${subject} hidden must be a Boolean. Supply true or false, or omit it.`);
120
+ throw flagFault(site, 'hidden');
38
121
  }
39
122
  return value;
40
123
  }
@@ -44,12 +127,16 @@ export function checkHidden(subject, value) {
44
127
  * prints. A bare `true` is rejected with every other value that is not prose: a deprecation with
45
128
  * no migration path leaves an operator or an agent with nothing to do.
46
129
  */
47
- export function checkDeprecated(subject, value) {
130
+ export function checkDeprecated(site, value) {
48
131
  if (value === undefined) {
49
132
  return undefined;
50
133
  }
51
- if (typeof value !== 'string' || !prose.test(value) || lineTerminator.test(value)) {
52
- 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.".`);
134
+ if (!isProseLine(value)) {
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
+ });
53
140
  }
54
141
  return value;
55
142
  }
@@ -59,10 +146,14 @@ export function checkDeprecated(subject, value) {
59
146
  * a JavaScript author, or a TypeScript author whose argument config is inferred from a value,
60
147
  * reaches this rule instead.
61
148
  */
62
- export function checkNoListingFacts(subject, declared) {
149
+ export function checkNoListingFacts(site, declared) {
63
150
  for (const fact of ['hidden', 'deprecated']) {
64
151
  if (fact in declared) {
65
- 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
+ });
66
157
  }
67
158
  }
68
159
  }
@@ -72,24 +163,16 @@ export function checkNoListingFacts(subject, declared) {
72
163
  * omitted version is `0.0.0`, which means unversioned, and core keeps no record of which one the
73
164
  * author wrote.
74
165
  */
75
- export function checkVersion(value) {
166
+ export function checkVersion(site, value) {
76
167
  if (value === undefined) {
77
168
  return '0.0.0';
78
169
  }
79
- if (typeof value !== 'string' || !prose.test(value) || lineTerminator.test(value)) {
80
- 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".');
170
+ if (!isProseLine(value)) {
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
+ });
81
176
  }
82
177
  return value;
83
178
  }
84
- /**
85
- * A structural value core reads as plain data: an object literal, and never a declaration that
86
- * carries state of its own. The options slots read it to reject a value that is not an options
87
- * object, and inspection reads it to copy a declared value faithfully.
88
- */
89
- export function isPlainObject(value) {
90
- if (value === null || typeof value !== 'object') {
91
- return false;
92
- }
93
- const prototype = Object.getPrototypeOf(value);
94
- return prototype === Object.prototype || prototype === null;
95
- }
package/dist/globals.d.ts CHANGED
@@ -1,6 +1,9 @@
1
- import { DeclarationError } from './errors.js';
1
+ import type { BoundOption } from './bindings.js';
2
+ import type { DescriptorRegistry } from './extension.js';
3
+ import type { InputSite } from './facts.js';
2
4
  import { compileOptions } from './options.js';
3
- import type { BuiltPlugin, PluginBuild } from './plugin.js';
5
+ import type { CompileScope } from './options.js';
6
+ import type { BuiltPlugin } from './plugin.js';
4
7
  import type { OptionConfig, OptionValue } from './types.js';
5
8
  import type { InputDeclaration, OptionInput, ValidatedInputs } from './validation.js';
6
9
  /**
@@ -18,40 +21,78 @@ type OptionOwner = {
18
21
  identity: string;
19
22
  order: number;
20
23
  };
21
- /** One side of a collision: the option's declared name under the scope that declared it. */
22
- interface OptionSite {
23
- name: string;
24
+ /** One option the globals table holds: the scope that declared it, and where it was declared. */
25
+ interface TableEntry {
24
26
  owner: OptionOwner;
27
+ site: InputSite;
25
28
  }
26
- /** One key claimed twice, whichever two scopes claimed it. */
27
- declare function keyCollision(name: string, first: OptionOwner, second: OptionOwner): DeclarationError;
28
- /** One spelling claimed twice, whichever two scopes claimed it. */
29
- declare function spellingCollision(spelling: string, first: OptionSite, second: OptionSite): DeclarationError;
30
29
  /**
31
- * The compiled global table: one spelling map shared by every Command in the graph. It holds the
32
- * application's global options and every installed plugin's options, because the pre-scan reads one
33
- * table. `inputs` holds the application's declarations alone, because a plugin option is never
34
- * validated and never reaches an action.
30
+ * The globals table the pre-scan reads, with every rule that pairs two of its options settled: one
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.
35
34
  */
36
- interface BuiltGlobals {
37
- bind: (values: ValidatedInputs) => unknown;
38
- inputs: readonly InputDeclaration[];
39
- names: ReadonlyMap<string, OptionOwner>;
35
+ interface GlobalTable {
36
+ names: ReadonlyMap<string, TableEntry>;
40
37
  options: ReturnType<typeof compileOptions>;
38
+ /** The variable each option in the table binds, with the option that binds it. */
39
+ variables: ReadonlyMap<string, BoundOption>;
40
+ }
41
+ /**
42
+ * The compiled table one graph build shares. `inputs` holds the application's declarations alone,
43
+ * because a plugin option is never validated and never reaches an action.
44
+ */
45
+ interface BuiltGlobals extends GlobalTable {
46
+ bind: (values: ValidatedInputs) => unknown;
47
+ inputs: readonly OptionInput[];
41
48
  plugins: readonly BuiltPlugin[];
42
49
  }
43
- /** The Application's private global declarations and their schema-derived value binder. */
50
+ /** The validated extension record of each declaration, keyed by the declaration itself. */
51
+ type InputRecords = ReadonlyMap<InputDeclaration, Readonly<Record<string, unknown>>>;
52
+ /**
53
+ * The Application's private global declarations, their schema-derived value binder, and the
54
+ * extension record each declaration's own call validated.
55
+ */
44
56
  interface GlobalsState<Globals = unknown> {
45
57
  bind: (values: ValidatedInputs) => Globals;
46
58
  inputs: readonly OptionInput[];
59
+ records: InputRecords;
47
60
  }
48
61
  declare function emptyGlobals(): GlobalsState<{}>;
49
- declare function declareGlobalOption<Globals, Name extends string, Config extends OptionConfig>(state: GlobalsState<Globals>, input: OptionInput<Name, Config>): GlobalsState<Globals & Record<Name, OptionValue<Config>>>;
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;
69
+ /**
70
+ * One global option's own facts, binding, and extension values, checked at its `globalOption()`
71
+ * call against the Application's descriptors. The rules that pair it with another option belong to
72
+ * the table, which the caller rebuilds with it.
73
+ */
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
+ };
50
78
  /**
51
79
  * The one table the pre-scan reads: the application's global options in authoring order, then each
52
80
  * installed plugin's options in installation order. Every collision between the two scopes, by key
53
81
  * or by spelling, is reported here, so the pre-scan meets a table with one owner per name.
54
82
  */
55
- declare function buildGlobals(node: GlobalsState, plugins: readonly BuiltPlugin[], build: PluginBuild): BuiltGlobals;
56
- export type { BuiltGlobals, GlobalsState, OptionOwner, OptionSite };
57
- export { buildGlobals, declareGlobalOption, emptyGlobals, keyCollision, spellingCollision };
83
+ declare function globalTable(inputs: readonly OptionInput[], plugins: readonly BuiltPlugin[]): GlobalTable;
84
+ /** The table one graph build shares, which every earlier call already proved free of collisions. */
85
+ declare function buildGlobals(node: GlobalsState, plugins: readonly BuiltPlugin[]): BuiltGlobals;
86
+ /**
87
+ * One Command's own options against the globals table, compiled for dispatch. The table holds the
88
+ * application's globals and every plugin option, so a local collision reads the same sentence
89
+ * whichever scope on the other side claimed the name, the spelling, or the variable. One
90
+ * invocation's scope is this Command's own options and the table, so a variable binds one option
91
+ * there, while a sibling Command may bind it again. `scope` names the Command and places each of
92
+ * its options.
93
+ */
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,17 @@
1
- import { DeclarationError } from './errors.js';
1
+ import { checkEnvBinding, claimVariables } from './bindings.js';
2
+ import { DeclarationError, quoted } from './errors.js';
2
3
  import { buildExtensions } from './extension.js';
3
- import { checkDeprecated, checkDescription, checkHidden } from './facts.js';
4
- 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';
5
8
  const globalSubject = 'the global options';
9
+ /**
10
+ * The presence keys a global option may not declare, in the order its diagnostic names them. A
11
+ * global's validation runs on every Command, plugin Commands included, so a rule that a value must
12
+ * exist belongs to the Commands that read it.
13
+ */
14
+ const omissionRules = ['required', 'validateOmitted'];
6
15
  /** A collision sentence names a plugin first, then the application's globals, then a local. */
7
16
  const ranks = {
8
17
  application: 1,
@@ -22,7 +31,7 @@ function ordered(first, second) {
22
31
  /** How one owner reads in a key collision. A repeated preposition is dropped after the first. */
23
32
  function declaredBy(owner, leading) {
24
33
  if (owner.kind === 'plugin') {
25
- return `${leading ? 'by ' : ''}plugin "${owner.identity}"`;
34
+ return `${leading ? 'by ' : ''}plugin ${quoted(owner.identity)}`;
26
35
  }
27
36
  return owner.kind === 'application'
28
37
  ? 'as a global option'
@@ -31,11 +40,11 @@ function declaredBy(owner, leading) {
31
40
  /** How one owner reads in a spelling collision, where each side names its own option. */
32
41
  function usedBy({ name, owner }) {
33
42
  if (owner.kind === 'plugin') {
34
- return `plugin "${owner.identity}" option "${name}"`;
43
+ return `plugin ${quoted(owner.identity)} option ${quoted(name)}`;
35
44
  }
36
45
  return owner.kind === 'application'
37
- ? `the global option "${name}"`
38
- : `the local option "${name}" on ${owner.subject}`;
46
+ ? `the global option ${quoted(name)}`
47
+ : `the local option ${quoted(name)} on ${owner.subject}`;
39
48
  }
40
49
  /** The correction each pair earns: a local is renamed, and two plugins are chosen between. */
41
50
  function correction(first, second) {
@@ -46,23 +55,91 @@ function correction(first, second) {
46
55
  ? 'Install one of them or rename the option.'
47
56
  : 'Rename one declaration.';
48
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
+ };
49
64
  /** One key claimed twice, whichever two scopes claimed it. */
50
- function keyCollision(name, first, second) {
51
- const [leading, trailing] = ordered({ name, owner: first }, { name, owner: second });
52
- 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
+ });
53
73
  }
54
74
  /** One spelling claimed twice, whichever two scopes claimed it. */
55
75
  function spellingCollision(spelling, first, second) {
56
- const [leading, trailing] = ordered(first, second);
57
- 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
+ });
58
83
  }
59
84
  function emptyGlobals() {
60
- return { bind: () => ({}), inputs: [] };
85
+ return { bind: () => ({}), inputs: [], records: new Map() };
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
+ });
61
95
  }
62
- function declareGlobalOption(state, input) {
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
+ }
104
+ /**
105
+ * One global option's own facts, binding, and extension values, checked at its `globalOption()`
106
+ * call against the Application's descriptors. The rules that pair it with another option belong to
107
+ * the table, which the caller rebuilds with it.
108
+ */
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;
116
+ const rejected = omissionRules.find((key) => key in input.config);
117
+ if (rejected !== undefined) {
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
+ });
123
+ }
124
+ checkDescription(site, input.config.description);
125
+ checkHidden(site, input.config.hidden);
126
+ checkDeprecated(site, input.config.deprecated);
127
+ checkEnvBinding(site, input.config);
128
+ const record = buildExtensions({
129
+ declared: input.config.extensions,
130
+ descriptors,
131
+ site: { ...site, at: '1.extensions' },
132
+ subject: { phrase: `on the global option ${quoted(input.name)}`, sentence },
133
+ target: 'option',
134
+ });
135
+ // The caller validates the captured input this state stores, so both read one capture.
63
136
  return {
64
- bind: (values) => ({ ...state.bind(values), ...values.option(input) }),
65
- inputs: [...state.inputs, input],
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
+ },
66
143
  };
67
144
  }
68
145
  /**
@@ -70,48 +147,96 @@ function declareGlobalOption(state, input) {
70
147
  * installed plugin's options in installation order. Every collision between the two scopes, by key
71
148
  * or by spelling, is reported here, so the pre-scan meets a table with one owner per name.
72
149
  */
73
- function buildGlobals(node, plugins, build) {
150
+ function globalTable(inputs, plugins) {
74
151
  const names = new Map();
75
152
  const application = { kind: 'application' };
76
- // A global option belongs to the application, not to one Command, so its facts read that way.
77
- for (const input of node.inputs) {
78
- const sentence = `Global option "${input.name}"`;
79
- checkDescription(sentence, input.config.description);
80
- checkHidden(sentence, input.config.hidden);
81
- checkDeprecated(sentence, input.config.deprecated);
82
- build.extensions.set(input, buildExtensions({
83
- declared: input.config.extensions,
84
- descriptors: build.descriptors,
85
- subject: { phrase: `on the global option "${input.name}"`, sentence },
86
- target: 'option',
87
- }));
88
- names.set(input.name, application);
153
+ for (const input of inputs) {
154
+ names.set(input.name, { owner: application, site: globalSite(input) });
89
155
  }
90
- const options = compileOptions(node.inputs, globalSubject);
156
+ const options = compileOptions(inputs, { siteOf: globalSite, subject: globalSubject });
91
157
  plugins.forEach((installed, order) => {
92
158
  join({ identity: installed.identity, kind: 'plugin', order }, installed.inputs, {
93
159
  names,
94
160
  options,
95
161
  });
96
162
  });
97
- return { bind: node.bind, inputs: node.inputs, names, options, plugins };
163
+ const variables = claimVariables([
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
+ }),
175
+ ]);
176
+ return { names, options, variables };
177
+ }
178
+ /** The table one graph build shares, which every earlier call already proved free of collisions. */
179
+ function buildGlobals(node, plugins) {
180
+ return { ...globalTable(node.inputs, plugins), bind: node.bind, inputs: node.inputs, plugins };
181
+ }
182
+ /**
183
+ * One Command's own options against the globals table, compiled for dispatch. The table holds the
184
+ * application's globals and every plugin option, so a local collision reads the same sentence
185
+ * whichever scope on the other side claimed the name, the spelling, or the variable. One
186
+ * invocation's scope is this Command's own options and the table, so a variable binds one option
187
+ * there, while a sibling Command may bind it again. `scope` names the Command and places each of
188
+ * its options.
189
+ */
190
+ function checkLocalOptions(declarations, table, scope) {
191
+ const { siteOf, subject } = scope;
192
+ const local = { kind: 'local', subject };
193
+ for (const declaration of declarations) {
194
+ const claimed = table.names.get(declaration.name);
195
+ if (claimed) {
196
+ throw keyCollision({ ...claimed, name: declaration.name }, { name: declaration.name, owner: local, site: siteOf(declaration) });
197
+ }
198
+ }
199
+ const options = compileOptions(declarations, scope);
200
+ for (const [spelling, option] of options) {
201
+ const global = table.options.get(spelling);
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) });
206
+ }
207
+ }
208
+ claimVariables(boundOptions(declarations, (input) => ({
209
+ phrase: `${subject} option "${input.name}"`,
210
+ site: siteOf(input),
211
+ })), table.variables);
212
+ return options;
213
+ }
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 }]);
98
217
  }
99
218
  /** One plugin's options joining the table the application's globals already hold. */
100
219
  function join(owner, inputs, table) {
101
220
  const { names, options } = table;
221
+ const siteOf = pluginSites(owner.identity, inputs);
102
222
  for (const input of inputs) {
103
223
  const claimed = names.get(input.name);
104
224
  if (claimed) {
105
- throw keyCollision(input.name, owner, claimed);
225
+ throw keyCollision({ name: input.name, owner, site: siteOf(input) }, { ...claimed, name: input.name });
106
226
  }
107
- names.set(input.name, owner);
227
+ names.set(input.name, { owner, site: siteOf(input) });
108
228
  }
109
- for (const [spelling, option] of compileOptions(inputs, `plugin "${owner.identity}"`)) {
110
- const claimed = options.get(spelling);
111
- if (claimed) {
112
- 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 });
113
238
  }
114
239
  options.set(spelling, option);
115
240
  }
116
241
  }
117
- export { buildGlobals, declareGlobalOption, emptyGlobals, 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 };