@loomcli/core 0.1.1 → 0.2.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.
@@ -0,0 +1,313 @@
1
+ import { DeclarationError, reasonOf } from './errors.js';
2
+ import { isPlainObject } from './facts.js';
3
+ /** Authored values register here, so the public brand publishes no state to reach or replace. */
4
+ const values = new WeakMap();
5
+ /**
6
+ * The descriptor that produced each stored output, keyed by the frozen record a node publishes. It
7
+ * lives beside the graph rather than on it, so a projection reads plain data while a typed read
8
+ * still compares descriptors by reference.
9
+ */
10
+ const owners = new WeakMap();
11
+ /**
12
+ * The value one extension produces, branded with its target so a value on the wrong declaration is
13
+ * a compile error. It publishes the brand alone, so a value is never mistaken for the descriptor
14
+ * that produced it, and the input it carries stays private to this package.
15
+ */
16
+ class ExtensionCarrier {
17
+ constructor(record) {
18
+ values.set(this, record);
19
+ Object.freeze(this);
20
+ }
21
+ }
22
+ /**
23
+ * One typed fact a plugin defines for one target. The descriptor is compared by reference wherever
24
+ * it appears, so one identity means one descriptor and a duplicated package copy is visible.
25
+ */
26
+ function extension(identity, config) {
27
+ // `Object.assign` returns the same function object, so the value carries the descriptor itself.
28
+ function create(input) {
29
+ return new ExtensionCarrier({ descriptor, input });
30
+ }
31
+ const descriptor = Object.assign(create, {
32
+ identity,
33
+ schema: config.schema,
34
+ target: config.target,
35
+ });
36
+ Object.freeze(descriptor);
37
+ return descriptor;
38
+ }
39
+ /**
40
+ * The typed read of one extension value. It takes the node kind the descriptor targets, returns the
41
+ * stored output or `undefined`, compares the descriptor by reference with the one that produced the
42
+ * value, and runs no schema.
43
+ */
44
+ function readExtension(node, descriptor) {
45
+ const record = node.extensions;
46
+ const owner = owners.get(record)?.get(descriptor.identity);
47
+ if (owner === undefined) {
48
+ return undefined;
49
+ }
50
+ if (owner !== descriptor) {
51
+ throw new DeclarationError(`Extension "${descriptor.identity}" was read through a descriptor that did not define the stored value. Install one copy of the package that defines it.`);
52
+ }
53
+ // Last resort: no typed path exists. The graph stores every output under a string identity, so
54
+ // The record reads back as `unknown` and no key relates one entry to a descriptor's schema.
55
+ // It holds because the reference test above proves this descriptor produced this output, and
56
+ // Build stored exactly what that descriptor's own schema returned.
57
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion
58
+ return record[descriptor.identity];
59
+ }
60
+ /** How a diagnostic names the declarations one target covers. */
61
+ const applies = {
62
+ argument: 'arguments',
63
+ command: 'Commands',
64
+ option: 'options',
65
+ };
66
+ /**
67
+ * The children one container contributes, or nothing when its own shape is not plain data. An
68
+ * accessor, a symbol key, and a non-enumerable own property are not plain data, and an `undefined`
69
+ * property value is absence, so it is dropped rather than stored. An array contributes its items,
70
+ * the way it reads back.
71
+ */
72
+ function childrenOf(source) {
73
+ if (Array.isArray(source)) {
74
+ // `Array.from` reads a hole as the `undefined` it is, which the walk rejects like any other
75
+ // Value that is not plain data.
76
+ return Array.from(source, (value, index) => ({ key: String(index), value }));
77
+ }
78
+ if (!isPlainObject(source) || Object.getOwnPropertySymbols(source).length > 0) {
79
+ return undefined;
80
+ }
81
+ const children = [];
82
+ for (const key of Object.getOwnPropertyNames(source)) {
83
+ const property = Object.getOwnPropertyDescriptor(source, key);
84
+ if (!property || !('value' in property) || !property.enumerable) {
85
+ return undefined;
86
+ }
87
+ if (property.value !== undefined) {
88
+ children.push({ key, value: property.value });
89
+ }
90
+ }
91
+ return children;
92
+ }
93
+ /**
94
+ * One value the walk reached. A primitive is copied where it is read, and a container answers with
95
+ * the frame the walk fills. `ancestors` holds the containers on the path to this value, so a value
96
+ * that is one of them is a cycle.
97
+ */
98
+ function openValue(value, key, ancestors) {
99
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') {
100
+ return { data: value, kind: 'leaf' };
101
+ }
102
+ if (typeof value === 'number') {
103
+ return Number.isFinite(value) ? { data: value, kind: 'leaf' } : undefined;
104
+ }
105
+ if (typeof value !== 'object' || ancestors.has(value)) {
106
+ return undefined;
107
+ }
108
+ const children = childrenOf(value);
109
+ if (!children) {
110
+ return undefined;
111
+ }
112
+ const isArray = Array.isArray(value);
113
+ return { frame: { children, entries: [], index: 0, isArray, key, source: value }, kind: 'frame' };
114
+ }
115
+ /**
116
+ * The frozen copy one finished container contributes. `Object.fromEntries` defines every key as an
117
+ * own data property, so an output key of `__proto__` is stored under that name and the copy keeps
118
+ * `Object.prototype`.
119
+ */
120
+ function closeFrame(frame) {
121
+ return Object.freeze(frame.isArray ? frame.entries.map(([, value]) => value) : Object.fromEntries(frame.entries));
122
+ }
123
+ /**
124
+ * Whether a produced output is the plain data a node can freeze and every projection can read:
125
+ * strings, finite numbers, Booleans, null, arrays, and objects whose prototype is `Object.prototype`
126
+ * or null, to any depth and without cycles. It answers with the frozen copy, because the walk that
127
+ * proves the shape is the walk that builds it. The walk holds its own stack, so a deep output is
128
+ * bounded by the heap and never by the call stack. A declared default's snapshot answers a different
129
+ * question: it keeps a library object as it is, where an extension output holding one is rejected.
130
+ */
131
+ function plainData(value) {
132
+ const ancestors = new Set();
133
+ const opened = openValue(value, '', ancestors);
134
+ if (!opened) {
135
+ return undefined;
136
+ }
137
+ if (opened.kind === 'leaf') {
138
+ return { data: opened.data };
139
+ }
140
+ const stack = [opened.frame];
141
+ ancestors.add(opened.frame.source);
142
+ for (;;) {
143
+ const frame = stack.at(-1);
144
+ if (!frame) {
145
+ return undefined;
146
+ }
147
+ const child = frame.children[frame.index];
148
+ if (child) {
149
+ const next = openValue(child.value, child.key, ancestors);
150
+ if (!next) {
151
+ return undefined;
152
+ }
153
+ if (next.kind === 'leaf') {
154
+ frame.entries.push([child.key, next.data]);
155
+ frame.index += 1;
156
+ }
157
+ else {
158
+ stack.push(next.frame);
159
+ ancestors.add(next.frame.source);
160
+ }
161
+ }
162
+ else {
163
+ stack.pop();
164
+ ancestors.delete(frame.source);
165
+ const data = closeFrame(frame);
166
+ const parent = stack.at(-1);
167
+ if (!parent) {
168
+ return { data };
169
+ }
170
+ parent.entries.push([frame.key, data]);
171
+ parent.index += 1;
172
+ }
173
+ }
174
+ }
175
+ /** Whether a value is a descriptor: a callable object carrying an identity and a declared target. */
176
+ function isDescriptor(value) {
177
+ return (value !== null &&
178
+ (typeof value === 'object' || typeof value === 'function') &&
179
+ 'identity' in value &&
180
+ typeof value.identity === 'string' &&
181
+ 'target' in value &&
182
+ (value.target === 'argument' || value.target === 'command' || value.target === 'option'));
183
+ }
184
+ /** One identity means one descriptor, wherever on the graph that descriptor appears. */
185
+ function registerDescriptor(descriptors, descriptor) {
186
+ const known = descriptors.get(descriptor.identity);
187
+ if (known === undefined) {
188
+ descriptors.set(descriptor.identity, descriptor);
189
+ return;
190
+ }
191
+ if (known !== descriptor) {
192
+ throw new DeclarationError(`Extension "${descriptor.identity}" is defined twice. Install one copy of the package that defines it.`);
193
+ }
194
+ }
195
+ /** Whether a value answers the Standard Schema v1 contract this build calls synchronously. */
196
+ function isSchema(value) {
197
+ if (value === null || (typeof value !== 'object' && typeof value !== 'function')) {
198
+ return false;
199
+ }
200
+ const standard = '~standard' in value ? value['~standard'] : undefined;
201
+ return (standard !== null &&
202
+ typeof standard === 'object' &&
203
+ 'validate' in standard &&
204
+ typeof standard.validate === 'function');
205
+ }
206
+ /**
207
+ * One schema message as a sentence of its own. A schema author writes the message with or without a
208
+ * full stop, so the diagnostic supplies one only where the message carries none.
209
+ */
210
+ function sentence(text) {
211
+ return text.endsWith('.') ? text : `${text}.`;
212
+ }
213
+ /** The message one rejected value reports, with the placeholder a silent schema earns. */
214
+ function issueText(issues) {
215
+ const first = Array.isArray(issues) ? issues[0] : undefined;
216
+ if (first !== null && typeof first === 'object' && 'message' in first) {
217
+ const { message } = first;
218
+ if (typeof message === 'string') {
219
+ return message;
220
+ }
221
+ }
222
+ // The sentence the caller composes ends the diagnostic, so this text carries no full stop.
223
+ return 'The schema rejected this value without an explanation';
224
+ }
225
+ /**
226
+ * Whether one schema answered with a promise. A thenable object and a promise from another realm
227
+ * are as unwaitable here as a native one, so the test is the contract and not the class.
228
+ */
229
+ function isThenable(value) {
230
+ return 'then' in value && typeof value.then === 'function';
231
+ }
232
+ /** The schema one descriptor answers with, which a JavaScript author can leave out. */
233
+ function schemaOf(subject, descriptor) {
234
+ const schema = 'schema' in descriptor ? descriptor.schema : undefined;
235
+ if (!isSchema(schema)) {
236
+ throw new DeclarationError(`${subject.sentence} holds extension "${descriptor.identity}", which declares no schema. Supply a Standard Schema v1 object that answers synchronously.`);
237
+ }
238
+ return schema;
239
+ }
240
+ /** The result one schema answered with, or the rejection its own throw is. */
241
+ function validated(subject, carried, schema) {
242
+ try {
243
+ return schema['~standard'].validate(carried.input);
244
+ }
245
+ catch (error) {
246
+ // A schema that throws rejected the value the only way it could, so it reads as a rejection.
247
+ throw new DeclarationError(`${subject.sentence} holds an invalid "${carried.descriptor.identity}" value: ${sentence(reasonOf(error))} Correct the value.`);
248
+ }
249
+ }
250
+ /** Validates one carried value and answers the plain-data output the node stores under it. */
251
+ function validateValue(subject, carried) {
252
+ const { identity } = carried.descriptor;
253
+ const result = validated(subject, carried, schemaOf(subject, carried.descriptor));
254
+ if (result === null || typeof result !== 'object' || isThenable(result)) {
255
+ throw new DeclarationError(`Extension "${identity}" validates asynchronously. Supply a schema that answers synchronously.`);
256
+ }
257
+ const issues = 'issues' in result ? result.issues : undefined;
258
+ if (issues !== undefined) {
259
+ throw new DeclarationError(`${subject.sentence} holds an invalid "${identity}" value: ${sentence(issueText(issues))} Correct the value.`);
260
+ }
261
+ const output = plainData('value' in result ? result.value : undefined);
262
+ if (!output) {
263
+ throw new DeclarationError(`Extension "${identity}" produced a value that is not plain data ${subject.phrase}. Return strings, numbers, booleans, null, arrays, and plain objects.`);
264
+ }
265
+ return output.data;
266
+ }
267
+ /** An `extensions` slot holds a list of values, so anything else is the same declaration fault. */
268
+ function readList(subject, declared) {
269
+ if (declared === undefined) {
270
+ return [];
271
+ }
272
+ if (!Array.isArray(declared)) {
273
+ throw new DeclarationError(`${subject.sentence} holds a value that is not an extension value. Supply the value returned by calling an extension.`);
274
+ }
275
+ return declared;
276
+ }
277
+ /** The value one entry carries, under the rules its own slot's target sets. */
278
+ function carriedValue(subject, target, entry) {
279
+ const carried = typeof entry === 'object' && entry !== null ? values.get(entry) : undefined;
280
+ if (!carried) {
281
+ throw new DeclarationError(`${subject.sentence} holds a value that is not an extension value. Supply the value returned by calling an extension.`);
282
+ }
283
+ if (carried.descriptor.target !== target) {
284
+ throw new DeclarationError(`${subject.sentence} holds extension "${carried.descriptor.identity}", which applies to ${applies[carried.descriptor.target]}. Supply an extension that applies to ${applies[target]}.`);
285
+ }
286
+ return carried;
287
+ }
288
+ /**
289
+ * The frozen record one declaration publishes: each carried value validated once, synchronously,
290
+ * and stored under its extension's identity. The descriptors that produced them are recorded beside
291
+ * the record, so a typed read compares by reference without the graph carrying a reference.
292
+ */
293
+ function buildExtensions(slot) {
294
+ const { subject } = slot;
295
+ const stored = [];
296
+ const defined = new Map();
297
+ for (const entry of readList(subject, slot.declared)) {
298
+ const carried = carriedValue(subject, slot.target, entry);
299
+ const { descriptor } = carried;
300
+ registerDescriptor(slot.descriptors, descriptor);
301
+ if (defined.has(descriptor.identity)) {
302
+ throw new DeclarationError(`${subject.sentence} holds extension "${descriptor.identity}" twice. Supply one value.`);
303
+ }
304
+ defined.set(descriptor.identity, descriptor);
305
+ stored.push([descriptor.identity, validateValue(subject, carried)]);
306
+ }
307
+ // `Object.fromEntries` defines each identity as an own data property, so an identity of
308
+ // `__proto__` is a key of the record and the record keeps `Object.prototype`.
309
+ const frozen = Object.freeze(Object.fromEntries(stored));
310
+ owners.set(frozen, defined);
311
+ return frozen;
312
+ }
313
+ export { buildExtensions, extension, isDescriptor, readExtension, registerDescriptor };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The `description` core fact: one line of prose every projection reads. A value that is not a
3
+ * string fails the same way a blank one does, because the author reads one rule for one fact.
4
+ * An omitted description is absent, not a fault, so it passes through as `undefined`.
5
+ */
6
+ export declare function checkDescription(subject: string, value: unknown): string | undefined;
7
+ /**
8
+ * The `hidden` core fact: whether a listing omits this member. It is a Boolean, because a listing
9
+ * asks one question of it, and an omitted declaration reads `false`. Routing selects and parsing
10
+ * binds without reading it, so a hidden member behaves as any other.
11
+ */
12
+ export declare function checkHidden(subject: string, value: unknown): boolean;
13
+ /**
14
+ * The `deprecated` core fact: the one-line migration message a listing shows beside the member.
15
+ * It answers the rule a description answers, because both are one line of prose a projection
16
+ * prints. A bare `true` is rejected with every other value that is not prose: a deprecation with
17
+ * no migration path leaves an operator or an agent with nothing to do.
18
+ */
19
+ export declare function checkDeprecated(subject: string, value: unknown): string | undefined;
20
+ /**
21
+ * The declarations that carry neither listing fact: an argument, which cannot leave the grammar it
22
+ * sits in, and the root, which is every page's entry point. The types remove both keys there, and
23
+ * a JavaScript author, or a TypeScript author whose argument config is inferred from a value,
24
+ * reaches this rule instead.
25
+ */
26
+ export declare function checkNoListingFacts(subject: string, declared: object): void;
27
+ /**
28
+ * The `version` core fact, which the Application alone carries. Core reads it as an opaque string,
29
+ * because the convention is the package manifest's own field and no scheme is imposed on it. An
30
+ * omitted version is `0.0.0`, which means unversioned, and core keeps no record of which one the
31
+ * author wrote.
32
+ */
33
+ export declare function checkVersion(value: unknown): string;
34
+ /**
35
+ * A structural value core reads as plain data: an object literal, and never a declaration that
36
+ * carries state of its own. The options slots read it to reject a value that is not an options
37
+ * object, and inspection reads it to copy a declared value faithfully.
38
+ */
39
+ export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
package/dist/facts.js ADDED
@@ -0,0 +1,95 @@
1
+ import { DeclarationError } from './errors.js';
2
+ /**
3
+ * One character outside Unicode `White_Space`, so a fact holds prose and not only spacing.
4
+ * The class covers the tab, the space, the line terminators, the no-break space, and every other
5
+ * space separator. A format character such as the zero-width space is outside it, so it is prose.
6
+ */
7
+ const prose = /\P{White_Space}/u;
8
+ /**
9
+ * Every character that ends a line, so a fact a projection prints on one line holds none.
10
+ * The seven are LF, VT, FF, CR, NEL, LS, and PS, each of them `White_Space` too.
11
+ */
12
+ const lineTerminator = /[\n\v\f\r\u0085\u2028\u2029]/u;
13
+ /**
14
+ * The `description` core fact: one line of prose every projection reads. A value that is not a
15
+ * string fails the same way a blank one does, because the author reads one rule for one fact.
16
+ * An omitted description is absent, not a fault, so it passes through as `undefined`.
17
+ */
18
+ export function checkDescription(subject, value) {
19
+ if (value === undefined) {
20
+ return undefined;
21
+ }
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.`);
24
+ }
25
+ return value;
26
+ }
27
+ /**
28
+ * The `hidden` core fact: whether a listing omits this member. It is a Boolean, because a listing
29
+ * asks one question of it, and an omitted declaration reads `false`. Routing selects and parsing
30
+ * binds without reading it, so a hidden member behaves as any other.
31
+ */
32
+ export function checkHidden(subject, value) {
33
+ if (value === undefined) {
34
+ return false;
35
+ }
36
+ if (typeof value !== 'boolean') {
37
+ throw new DeclarationError(`${subject} hidden must be a Boolean. Supply true or false, or omit it.`);
38
+ }
39
+ return value;
40
+ }
41
+ /**
42
+ * The `deprecated` core fact: the one-line migration message a listing shows beside the member.
43
+ * It answers the rule a description answers, because both are one line of prose a projection
44
+ * prints. A bare `true` is rejected with every other value that is not prose: a deprecation with
45
+ * no migration path leaves an operator or an agent with nothing to do.
46
+ */
47
+ export function checkDeprecated(subject, value) {
48
+ if (value === undefined) {
49
+ return undefined;
50
+ }
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.".`);
53
+ }
54
+ return value;
55
+ }
56
+ /**
57
+ * The declarations that carry neither listing fact: an argument, which cannot leave the grammar it
58
+ * sits in, and the root, which is every page's entry point. The types remove both keys there, and
59
+ * a JavaScript author, or a TypeScript author whose argument config is inferred from a value,
60
+ * reaches this rule instead.
61
+ */
62
+ export function checkNoListingFacts(subject, declared) {
63
+ for (const fact of ['hidden', 'deprecated']) {
64
+ if (fact in declared) {
65
+ throw new DeclarationError(`${subject} declares ${fact}, which applies to named Commands and options alone. Remove it.`);
66
+ }
67
+ }
68
+ }
69
+ /**
70
+ * The `version` core fact, which the Application alone carries. Core reads it as an opaque string,
71
+ * because the convention is the package manifest's own field and no scheme is imposed on it. An
72
+ * omitted version is `0.0.0`, which means unversioned, and core keeps no record of which one the
73
+ * author wrote.
74
+ */
75
+ export function checkVersion(value) {
76
+ if (value === undefined) {
77
+ return '0.0.0';
78
+ }
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".');
81
+ }
82
+ return value;
83
+ }
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,19 +1,51 @@
1
+ import { DeclarationError } from './errors.js';
1
2
  import { compileOptions } from './options.js';
3
+ import type { BuiltPlugin, PluginBuild } from './plugin.js';
2
4
  import type { DefaultConstraint, MultipleConstraint, NameConstraint, OptionConfig, OptionValue, ValidateOmittedConstraint } from './types.js';
3
- import type { InputDeclaration, ValidatedInputs } from './validation.js';
5
+ import type { InputDeclaration, OptionInput, ValidatedInputs } from './validation.js';
4
6
  /** Phantom key. It keeps the declared option types exact and holds no runtime value. */
5
7
  declare const declaredTypes: unique symbol;
6
- /** The compiled global table: one spelling map shared by every Command in the graph. */
8
+ /**
9
+ * Who declared one option that shares the globals table, or the Command that declares a local
10
+ * option colliding with it. Every collision sentence is derived from a pair of these, so the
11
+ * existing global-versus-local wording and the plugin wording read from one place.
12
+ */
13
+ type OptionOwner = {
14
+ kind: 'application';
15
+ } | {
16
+ kind: 'local';
17
+ subject: string;
18
+ } | {
19
+ kind: 'plugin';
20
+ identity: string;
21
+ order: number;
22
+ };
23
+ /** One side of a collision: the option's declared name under the scope that declared it. */
24
+ interface OptionSite {
25
+ name: string;
26
+ owner: OptionOwner;
27
+ }
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
+ /**
33
+ * The compiled global table: one spelling map shared by every Command in the graph. It holds the
34
+ * application's global options and every installed plugin's options, because the pre-scan reads one
35
+ * table. `inputs` holds the application's declarations alone, because a plugin option is never
36
+ * validated and never reaches an action.
37
+ */
7
38
  interface BuiltGlobals {
8
39
  inputs: readonly InputDeclaration[];
9
- names: ReadonlySet<string>;
40
+ names: ReadonlyMap<string, OptionOwner>;
10
41
  options: ReturnType<typeof compileOptions>;
42
+ plugins: readonly BuiltPlugin[];
11
43
  source: unknown;
12
44
  }
13
45
  declare class GlobalOptionsBuilder<Options> {
14
46
  #private;
15
47
  readonly [declaredTypes]: Options;
16
- constructor(inputs: readonly InputDeclaration[], bind: (values: ValidatedInputs) => Options);
48
+ constructor(inputs: readonly OptionInput[], bind: (values: ValidatedInputs) => Options);
17
49
  option<const Name extends string, const Config extends OptionConfig>(name: Name, config: Config & NameConstraint<Name> & NoInfer<DefaultConstraint<Config>> & NoInfer<MultipleConstraint<Config>> & NoInfer<ValidateOmittedConstraint<Config>>): GlobalOptions<Options & Record<Name, OptionValue<Config>>>;
18
50
  }
19
51
  /**
@@ -29,8 +61,8 @@ type GlobalOptions<Options = {}> = Pick<GlobalOptionsBuilder<Options>, typeof de
29
61
  */
30
62
  declare function isGlobalOptions(value: unknown): boolean;
31
63
  /** Absent globals compile to an empty table whose source is `undefined`, like the declarations. */
32
- declare function buildGlobals(globals: object | undefined): BuiltGlobals;
64
+ declare function buildGlobals(globals: object | undefined, plugins: readonly BuiltPlugin[], build: PluginBuild): BuiltGlobals;
33
65
  declare function bindGlobals<Options>(globals: GlobalOptions<Options> | undefined, values: ValidatedInputs): Options;
34
66
  declare const GlobalOptions: new () => GlobalOptions;
35
- export type { BuiltGlobals };
36
- export { bindGlobals, buildGlobals, GlobalOptions, isGlobalOptions };
67
+ export type { BuiltGlobals, OptionOwner, OptionSite };
68
+ export { bindGlobals, buildGlobals, GlobalOptions, isGlobalOptions, keyCollision, spellingCollision, };
package/dist/globals.js CHANGED
@@ -1,24 +1,71 @@
1
1
  import { DeclarationError } from './errors.js';
2
+ import { buildExtensions } from './extension.js';
3
+ import { checkDeprecated, checkDescription, checkHidden } from './facts.js';
2
4
  import { compileOptions } from './options.js';
3
5
  import { captureConfig } from './validation.js';
4
6
  const globalSubject = 'the global options';
7
+ /** A collision sentence names a plugin first, then the application's globals, then a local. */
8
+ const ranks = {
9
+ application: 1,
10
+ local: 2,
11
+ plugin: 0,
12
+ };
13
+ function ordered(first, second) {
14
+ const difference = ranks[first.owner.kind] - ranks[second.owner.kind];
15
+ if (difference !== 0) {
16
+ return difference < 0 ? [first, second] : [second, first];
17
+ }
18
+ if (first.owner.kind === 'plugin' && second.owner.kind === 'plugin') {
19
+ return first.owner.order <= second.owner.order ? [first, second] : [second, first];
20
+ }
21
+ return [first, second];
22
+ }
23
+ /** How one owner reads in a key collision. A repeated preposition is dropped after the first. */
24
+ function declaredBy(owner, leading) {
25
+ if (owner.kind === 'plugin') {
26
+ return `${leading ? 'by ' : ''}plugin "${owner.identity}"`;
27
+ }
28
+ return owner.kind === 'application'
29
+ ? 'as a global option'
30
+ : `as a local option on ${owner.subject}`;
31
+ }
32
+ /** How one owner reads in a spelling collision, where each side names its own option. */
33
+ function usedBy({ name, owner }) {
34
+ if (owner.kind === 'plugin') {
35
+ return `plugin "${owner.identity}" option "${name}"`;
36
+ }
37
+ return owner.kind === 'application'
38
+ ? `the global option "${name}"`
39
+ : `the local option "${name}" on ${owner.subject}`;
40
+ }
41
+ /** The correction each pair earns: a local is renamed, and two plugins are chosen between. */
42
+ function correction(first, second) {
43
+ if (first.kind === 'local' || second.kind === 'local') {
44
+ return 'Rename the local option.';
45
+ }
46
+ return first.kind === 'plugin' && second.kind === 'plugin'
47
+ ? 'Install one of them or rename the option.'
48
+ : 'Rename one declaration.';
49
+ }
50
+ /** One key claimed twice, whichever two scopes claimed it. */
51
+ function keyCollision(name, first, second) {
52
+ const [leading, trailing] = ordered({ name, owner: first }, { name, owner: second });
53
+ return new DeclarationError(`Option "${name}" is declared ${declaredBy(leading.owner, true)} and ${declaredBy(trailing.owner, false)}. ${correction(leading.owner, trailing.owner)}`);
54
+ }
55
+ /** One spelling claimed twice, whichever two scopes claimed it. */
56
+ function spellingCollision(spelling, first, second) {
57
+ const [leading, trailing] = ordered(first, second);
58
+ return new DeclarationError(`Option spelling "${spelling}" is used by ${usedBy(leading)} and ${usedBy(trailing)}. Change one declaration.`);
59
+ }
5
60
  /** Authored values register here, so the public type publishes no state to reach or replace. */
6
61
  const nodes = new WeakMap();
7
- function compileTable(inputs, source) {
8
- return {
9
- inputs,
10
- names: new Set(inputs.map((input) => input.name)),
11
- options: compileOptions(inputs.filter((input) => input.kind === 'option'), globalSubject),
12
- source,
13
- };
14
- }
15
62
  class GlobalOptionsBuilder {
16
63
  #bind;
17
64
  #inputs;
18
65
  constructor(inputs, bind) {
19
66
  this.#bind = bind;
20
67
  this.#inputs = inputs;
21
- nodes.set(this, { bind, build: () => compileTable(inputs, this) });
68
+ nodes.set(this, { bind, inputs, source: this });
22
69
  }
23
70
  option(name, config) {
24
71
  const input = {
@@ -49,9 +96,59 @@ function nodeOf(globals) {
49
96
  function isGlobalOptions(value) {
50
97
  return typeof value === 'object' && value !== null && nodes.has(value);
51
98
  }
99
+ /**
100
+ * The one table the pre-scan reads: the application's global options in authoring order, then each
101
+ * installed plugin's options in installation order. Every collision between the two scopes, by key
102
+ * or by spelling, is reported here, so the pre-scan meets a table with one owner per name.
103
+ */
104
+ function compileTable(node, plugins, build) {
105
+ const names = new Map();
106
+ const application = { kind: 'application' };
107
+ // A global option belongs to the application, not to one Command, so its facts read that way.
108
+ for (const input of node.inputs) {
109
+ const sentence = `Global option "${input.name}"`;
110
+ checkDescription(sentence, input.config.description);
111
+ checkHidden(sentence, input.config.hidden);
112
+ checkDeprecated(sentence, input.config.deprecated);
113
+ build.extensions.set(input, buildExtensions({
114
+ declared: input.config.extensions,
115
+ descriptors: build.descriptors,
116
+ subject: { phrase: `on the global option "${input.name}"`, sentence },
117
+ target: 'option',
118
+ }));
119
+ names.set(input.name, application);
120
+ }
121
+ const options = compileOptions(node.inputs, globalSubject);
122
+ plugins.forEach((installed, order) => {
123
+ join({ identity: installed.identity, kind: 'plugin', order }, installed.inputs, {
124
+ names,
125
+ options,
126
+ });
127
+ });
128
+ return { inputs: node.inputs, names, options, plugins, source: node.source };
129
+ }
130
+ /** One plugin's options joining the table the application's globals already hold. */
131
+ function join(owner, inputs, table) {
132
+ const { names, options } = table;
133
+ for (const input of inputs) {
134
+ const claimed = names.get(input.name);
135
+ if (claimed) {
136
+ throw keyCollision(input.name, owner, claimed);
137
+ }
138
+ names.set(input.name, owner);
139
+ }
140
+ for (const [spelling, option] of compileOptions(inputs, `plugin "${owner.identity}"`)) {
141
+ const claimed = options.get(spelling);
142
+ if (claimed) {
143
+ throw spellingCollision(spelling, { name: option.name, owner }, { name: claimed.name, owner: names.get(claimed.name) ?? { kind: 'application' } });
144
+ }
145
+ options.set(spelling, option);
146
+ }
147
+ }
52
148
  /** Absent globals compile to an empty table whose source is `undefined`, like the declarations. */
53
- function buildGlobals(globals) {
54
- return globals === undefined ? compileTable([], undefined) : nodeOf(globals).build();
149
+ function buildGlobals(globals, plugins, build) {
150
+ const node = globals === undefined ? { bind: () => ({}), inputs: [], source: undefined } : nodeOf(globals);
151
+ return compileTable(node, plugins, build);
55
152
  }
56
153
  function bindGlobals(globals, values) {
57
154
  const bound = globals === undefined ? {} : nodeOf(globals).bind(values);
@@ -68,4 +165,4 @@ const GlobalOptions = class extends GlobalOptionsBuilder {
68
165
  super([], () => ({}));
69
166
  }
70
167
  };
71
- export { bindGlobals, buildGlobals, GlobalOptions, isGlobalOptions };
168
+ export { bindGlobals, buildGlobals, GlobalOptions, isGlobalOptions, keyCollision, spellingCollision, };