@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.
package/dist/plugin.js ADDED
@@ -0,0 +1,250 @@
1
+ import { DeclarationError } from './errors.js';
2
+ import { buildExtensions, isDescriptor, registerDescriptor } from './extension.js';
3
+ import { checkDeprecated, checkDescription, checkHidden, isPlainObject } from './facts.js';
4
+ import { isProcessSignal } from './signals.js';
5
+ import { captureConfig, checkDeclarations } from './validation.js';
6
+ /** Authored values register here, so the public type publishes no state to reach or replace. */
7
+ const nodes = new WeakMap();
8
+ /**
9
+ * The runtime value `plugin()` returns. `Options` appears in a read position alone, which makes it
10
+ * covariant: a `plugins` list holds plugins with different options the way `failures` holds
11
+ * renderers for different classes, and `Middleware` and `load` accept a narrower plugin.
12
+ */
13
+ class PluginDeclaration {
14
+ constructor(node) {
15
+ nodes.set(this, node);
16
+ Object.freeze(this);
17
+ }
18
+ }
19
+ /**
20
+ * One plugin: an identity and the declarations it contributes. The value performs no work when it
21
+ * is created and none when it is installed, so an installed plugin an invocation never reaches
22
+ * costs that invocation nothing.
23
+ */
24
+ function plugin(identity, definition) {
25
+ return new PluginDeclaration({ definition, identity });
26
+ }
27
+ /** How every plugin diagnostic names one plugin at the start of a sentence. */
28
+ function pluginSentence(identity) {
29
+ return `Plugin "${identity}"`;
30
+ }
31
+ /** Reads the declarations behind an installed value; anything else is a declaration error. */
32
+ function nodeOf(value) {
33
+ const node = typeof value === 'object' && value !== null ? nodes.get(value) : undefined;
34
+ if (!node) {
35
+ throw new DeclarationError('The Application holds a value that is not a plugin. Supply the value returned by plugin(identity, definition).');
36
+ }
37
+ return node;
38
+ }
39
+ /** The identity one installed value declares, which is a nonempty string installed once. */
40
+ function readIdentity(node, installed) {
41
+ const { identity } = node;
42
+ if (typeof identity !== 'string') {
43
+ throw new DeclarationError('A plugin declares an identity that is not a string. Supply a nonempty string, such as the package name.');
44
+ }
45
+ if (identity === '') {
46
+ throw new DeclarationError('A plugin declares an empty identity. Supply a nonempty string, such as the package name.');
47
+ }
48
+ if (installed.has(identity)) {
49
+ throw new DeclarationError(`The Application installs plugin "${identity}" twice. Install each plugin once.`);
50
+ }
51
+ return identity;
52
+ }
53
+ /** The declarations one plugin value carries, which a JavaScript author reaches as any value. */
54
+ function definitionOf(identity, node) {
55
+ const { definition } = node;
56
+ if (!isPlainObject(definition)) {
57
+ throw new DeclarationError(`${pluginSentence(identity)} declares a definition that is not an object. Supply { options, middleware, extensions, failures }.`);
58
+ }
59
+ return definition;
60
+ }
61
+ /**
62
+ * The installed list in composition order, with the rules that read the list itself. The slot is
63
+ * read defensively, because a JavaScript author reaches it with any value. Each plugin's own
64
+ * declarations are read by the build steps that consume them, in the order those steps run.
65
+ */
66
+ function installPlugins(plugins) {
67
+ if (!Array.isArray(plugins)) {
68
+ throw new DeclarationError('The Application plugins must be an array. Supply a list of plugin values.');
69
+ }
70
+ const installed = [];
71
+ const identities = new Set();
72
+ for (const value of plugins) {
73
+ const node = nodeOf(value);
74
+ const identity = readIdentity(node, identities);
75
+ identities.add(identity);
76
+ installed.push({ declaration: definitionOf(identity, node), identity });
77
+ }
78
+ return installed;
79
+ }
80
+ /** The keys a plugin option may not declare, in the order its diagnostic names them. */
81
+ const forbidden = ['validate', 'validateOmitted', 'required'];
82
+ /** The rules a plugin option answers before every rule an ordinary declaration carries. */
83
+ function checkPluginOption(sentence, config) {
84
+ if (!isPlainObject(config)) {
85
+ throw new DeclarationError(`${sentence} is not an option declaration. Supply { type, ... }.`);
86
+ }
87
+ const rejected = forbidden.find((key) => key in config);
88
+ if (rejected !== undefined) {
89
+ throw new DeclarationError(`${sentence} declares ${rejected}. Remove it; a plugin option carries no schema or presence rule, and the middleware interprets the value.`);
90
+ }
91
+ checkDescription(sentence, config.description);
92
+ checkHidden(sentence, config.hidden);
93
+ checkDeprecated(sentence, config.deprecated);
94
+ }
95
+ /**
96
+ * One plugin's option declarations, in declaration order. They join the globals table, so the rules
97
+ * that pair them with another scope's options belong to that table and not to this reading.
98
+ */
99
+ function readOptions(identity, declared, build) {
100
+ if (declared !== undefined && !isPlainObject(declared)) {
101
+ throw new DeclarationError(`${pluginSentence(identity)} declares options that are not an object. Supply a record of option declarations.`);
102
+ }
103
+ const inputs = [];
104
+ for (const [name, config] of Object.entries(declared ?? {})) {
105
+ const sentence = `${pluginSentence(identity)} option "${name}"`;
106
+ checkPluginOption(sentence, config);
107
+ const input = { config: captureConfig(config), kind: 'option', name };
108
+ // The shared rules name the plugin and the option, so a fault reads with its contributor.
109
+ checkDeclarations([input], sentence);
110
+ build.extensions.set(input, buildExtensions({
111
+ declared: config.extensions,
112
+ descriptors: build.descriptors,
113
+ subject: {
114
+ phrase: `on ${sentence.slice(0, 1).toLowerCase()}${sentence.slice(1)}`,
115
+ sentence,
116
+ },
117
+ target: 'option',
118
+ }));
119
+ inputs.push(input);
120
+ }
121
+ return inputs;
122
+ }
123
+ /** One activation name, which must be one of the plugin's own declared options. */
124
+ function readActivationName(identity, name, names) {
125
+ if (typeof name !== 'string' || !names.has(name)) {
126
+ throw new DeclarationError(`${pluginSentence(identity)} activates middleware on option "${String(name)}", which it does not declare. Name one of the plugin's own options.`);
127
+ }
128
+ return name;
129
+ }
130
+ /** The activation a middleware declares, checked against the options its own plugin declares. */
131
+ function readActivation(identity, declared, names) {
132
+ if (declared === 'always') {
133
+ return 'always';
134
+ }
135
+ if (!Array.isArray(declared)) {
136
+ throw new DeclarationError(`${pluginSentence(identity)} declares middleware with no activation. Supply activate: 'always' or a list of the plugin's own option names.`);
137
+ }
138
+ const list = declared;
139
+ if (list.length === 0) {
140
+ throw new DeclarationError(`${pluginSentence(identity)} declares middleware with an empty activation list. Name at least one of the plugin's options or use 'always'.`);
141
+ }
142
+ return list.map((name) => readActivationName(identity, name, names));
143
+ }
144
+ /**
145
+ * The loader a middleware declares. Core calls it with no arguments and reads whatever it resolves
146
+ * to, so being callable is the whole runtime claim this check makes.
147
+ */
148
+ function isLoader(value) {
149
+ return typeof value === 'function';
150
+ }
151
+ /** One plugin's middleware, or `undefined` for a plugin that declares none. */
152
+ function readMiddleware(identity, declared, names) {
153
+ if (declared === undefined) {
154
+ return undefined;
155
+ }
156
+ if (!isPlainObject(declared)) {
157
+ throw new DeclarationError(`${pluginSentence(identity)} declares middleware that is not an object. Supply { activate, load }.`);
158
+ }
159
+ const activate = readActivation(identity, declared.activate, names);
160
+ const { load } = declared;
161
+ if (!isLoader(load)) {
162
+ throw new DeclarationError(`${pluginSentence(identity)} declares middleware with no load function. Supply load: () => import('./middleware.js').`);
163
+ }
164
+ return { activate, load };
165
+ }
166
+ /**
167
+ * One plugin's claim on the signals slot, drawn from the closed set core installs listeners for.
168
+ * An empty list claims nothing, so it leaves the slot free for another plugin.
169
+ * Each signal is claimed once, because core installs one listener per entry and a second listener
170
+ * on one signal would take the force path on the first signal the run receives.
171
+ */
172
+ function readSignals(identity, declared) {
173
+ if (declared === undefined) {
174
+ return [];
175
+ }
176
+ if (!Array.isArray(declared)) {
177
+ throw new DeclarationError(`${pluginSentence(identity)} declares signals that are not an array. Supply a list of signal names.`);
178
+ }
179
+ const list = declared;
180
+ const claimed = new Set();
181
+ for (const value of list) {
182
+ if (!isProcessSignal(value)) {
183
+ throw new DeclarationError(`${pluginSentence(identity)} claims signal "${String(value)}". Claim SIGINT or SIGTERM.`);
184
+ }
185
+ if (claimed.has(value)) {
186
+ throw new DeclarationError(`${pluginSentence(identity)} claims signal "${value}" twice. Claim each signal once.`);
187
+ }
188
+ claimed.add(value);
189
+ }
190
+ return [...claimed];
191
+ }
192
+ /** A plugin's own list names the extensions it defines, before any declaration carries one. */
193
+ function defineExtensions(identity, declaration, build) {
194
+ const { extensions } = declaration;
195
+ if (extensions !== undefined && !Array.isArray(extensions)) {
196
+ throw new DeclarationError(`${pluginSentence(identity)} declares extensions that are not an array. Supply a list of extension descriptors.`);
197
+ }
198
+ for (const descriptor of extensions ?? []) {
199
+ if (!isDescriptor(descriptor)) {
200
+ throw new DeclarationError(`${pluginSentence(identity)} holds a value that is not an extension. Supply the value returned by extension(identity, config).`);
201
+ }
202
+ registerDescriptor(build.descriptors, descriptor);
203
+ }
204
+ }
205
+ /** One plugin's failure registrations, which are a list before any of them is read. */
206
+ function readFailures(identity, declared) {
207
+ if (declared === undefined) {
208
+ return [];
209
+ }
210
+ if (!Array.isArray(declared)) {
211
+ throw new DeclarationError(`${pluginSentence(identity)} declares failures that are not an array. Supply a list of renderFailure values.`);
212
+ }
213
+ return declared;
214
+ }
215
+ /**
216
+ * Every installed plugin's declarations, in installation order. A plugin's own extensions register
217
+ * before any declaration carries a value, so a duplicated package copy is reported from the list
218
+ * that installed it.
219
+ */
220
+ function buildPlugins(installed, build) {
221
+ /**
222
+ * The signals slot has one owner, so the first plugin to claim it names the second claimant's
223
+ * diagnostic. An empty claim leaves the slot free.
224
+ */
225
+ let owner = undefined;
226
+ return installed.map(({ declaration, identity }) => {
227
+ defineExtensions(identity, declaration, build);
228
+ const inputs = readOptions(identity, declaration.options, build);
229
+ const names = new Set(inputs.map((input) => input.name));
230
+ const signals = readSignals(identity, declaration.signals);
231
+ if (signals.length > 0) {
232
+ if (owner !== undefined) {
233
+ throw new DeclarationError(`${pluginSentence(identity)} claims the signals slot, which plugin "${owner}" already holds. Install one owner.`);
234
+ }
235
+ owner = identity;
236
+ }
237
+ return {
238
+ failures: readFailures(identity, declaration.failures),
239
+ identity,
240
+ inputs,
241
+ middleware: readMiddleware(identity, declaration.middleware, names),
242
+ signals,
243
+ };
244
+ });
245
+ }
246
+ /** The signals the one slot owner claimed, or none when no installed plugin claims the slot. */
247
+ function ownedSignals(plugins) {
248
+ return plugins.find((entry) => entry.signals.length > 0)?.signals ?? [];
249
+ }
250
+ export { buildPlugins, installPlugins, ownedSignals, plugin, pluginSentence };
@@ -0,0 +1,52 @@
1
+ /** The signals a plugin may claim, which is the closed set core installs process listeners for. */
2
+ type ProcessSignal = 'SIGINT' | 'SIGTERM';
3
+ /**
4
+ * What aborted one run's private controller. Core owns this value, so a middleware reads `source`
5
+ * and never infers a signal name; `cause` carries the caller's own `signal.reason` when a caller
6
+ * aborted. The first cause to abort fixes the reason and the code, and a later cause changes
7
+ * neither.
8
+ */
9
+ interface CancellationReason {
10
+ source: ProcessSignal | 'caller';
11
+ cause?: unknown;
12
+ }
13
+ /** The status each cause resolves. A script that saw 0 after an interrupt would carry on. */
14
+ declare const codes: {
15
+ readonly SIGINT: 130;
16
+ readonly SIGTERM: 143;
17
+ readonly caller: 130;
18
+ };
19
+ /** The status one cancelled run resolves, which is the part of `ExitCode` a signal decides. */
20
+ type CancellationCode = (typeof codes)[CancellationReason['source']];
21
+ /** Whether one declared value names a signal core installs a listener for. */
22
+ declare function isProcessSignal(value: unknown): value is ProcessSignal;
23
+ /** The status one cancelled run resolves, which the first cause to abort fixed. */
24
+ declare function cancellationCode(reason: CancellationReason): CancellationCode;
25
+ /**
26
+ * Whether one thrown value is the cancellation the run already reports: the reason core aborted
27
+ * with, which an API that rejects with `signal.reason` throws back, or an error every runtime
28
+ * names `AbortError`. The chain wraps an unexpected throw, so the wrapped cause reads the same.
29
+ */
30
+ declare function isCancellationEcho(thrown: unknown, reason: unknown): boolean;
31
+ /**
32
+ * The process listeners one run holds, and the caller subscription it opened at run entry. Install
33
+ * and removal sit together so the bracket a run keeps is readable in one place.
34
+ */
35
+ interface SignalBracket {
36
+ /** Installs one listener per claimed signal, once the graph has built and validated. */
37
+ install: (owned: readonly ProcessSignal[]) => void;
38
+ /** The reason the first cause fixed, or `undefined` while nothing has aborted the run. */
39
+ reason: () => CancellationReason | undefined;
40
+ /** Removes every listener this run holds. Called on the run's last exit path. */
41
+ finish: () => void;
42
+ }
43
+ /**
44
+ * The bracket for one run. Core subscribes to a caller's signal at run entry, and installs its own
45
+ * process listeners only for the plugin that owns the signals slot, only after the graph has built,
46
+ * and only until the run resolves. A process signal that arrives once the run is already cancelled
47
+ * is the force path: core removes its own listeners and re-raises, so the default disposition ends
48
+ * the process when no other listener remains. Core does not own the process.
49
+ */
50
+ declare function bracketRun(controller: AbortController, caller: AbortSignal | undefined): SignalBracket;
51
+ export type { CancellationCode, CancellationReason, ProcessSignal, SignalBracket };
52
+ export { bracketRun, cancellationCode, isCancellationEcho, isProcessSignal };
@@ -0,0 +1,85 @@
1
+ import { InternalError } from './errors.js';
2
+ /** The status each cause resolves. A script that saw 0 after an interrupt would carry on. */
3
+ const codes = { SIGINT: 130, SIGTERM: 143, caller: 130 };
4
+ /** The closed set a claim is drawn from, as the values a runtime signal name may take. */
5
+ const claimable = new Set(['SIGINT', 'SIGTERM']);
6
+ /** Whether one declared value names a signal core installs a listener for. */
7
+ function isProcessSignal(value) {
8
+ return typeof value === 'string' && claimable.has(value);
9
+ }
10
+ /** The status one cancelled run resolves, which the first cause to abort fixed. */
11
+ function cancellationCode(reason) {
12
+ return codes[reason.source];
13
+ }
14
+ /**
15
+ * Whether one thrown value is the cancellation the run already reports: the reason core aborted
16
+ * with, which an API that rejects with `signal.reason` throws back, or an error every runtime
17
+ * names `AbortError`. The chain wraps an unexpected throw, so the wrapped cause reads the same.
18
+ */
19
+ function isCancellationEcho(thrown, reason) {
20
+ if (thrown === reason) {
21
+ return true;
22
+ }
23
+ if (thrown instanceof Error && thrown.name === 'AbortError') {
24
+ return true;
25
+ }
26
+ return thrown instanceof InternalError && isCancellationEcho(thrown.cause, reason);
27
+ }
28
+ /**
29
+ * The bracket for one run. Core subscribes to a caller's signal at run entry, and installs its own
30
+ * process listeners only for the plugin that owns the signals slot, only after the graph has built,
31
+ * and only until the run resolves. A process signal that arrives once the run is already cancelled
32
+ * is the force path: core removes its own listeners and re-raises, so the default disposition ends
33
+ * the process when no other listener remains. Core does not own the process.
34
+ */
35
+ function bracketRun(controller, caller) {
36
+ let reason = undefined;
37
+ let held = [];
38
+ const release = () => {
39
+ for (const entry of held) {
40
+ process.off(entry.signal, entry.handler);
41
+ }
42
+ held = [];
43
+ };
44
+ const cancel = (next) => {
45
+ if (reason) {
46
+ return;
47
+ }
48
+ reason = next;
49
+ controller.abort(next);
50
+ };
51
+ const received = (signal) => {
52
+ if (reason) {
53
+ release();
54
+ process.kill(process.pid, signal);
55
+ return;
56
+ }
57
+ cancel({ source: signal });
58
+ };
59
+ const aborted = () => {
60
+ cancel({ cause: caller?.reason, source: 'caller' });
61
+ };
62
+ if (caller?.aborted === true) {
63
+ aborted();
64
+ }
65
+ else {
66
+ caller?.addEventListener('abort', aborted);
67
+ }
68
+ return {
69
+ finish: () => {
70
+ release();
71
+ caller?.removeEventListener('abort', aborted);
72
+ },
73
+ install: (owned) => {
74
+ for (const signal of owned) {
75
+ const handler = () => {
76
+ received(signal);
77
+ };
78
+ process.on(signal, handler);
79
+ held.push({ handler, signal });
80
+ }
81
+ },
82
+ reason: () => reason,
83
+ };
84
+ }
85
+ export { bracketRun, cancellationCode, isCancellationEcho, isProcessSignal };
package/dist/types.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /// <reference types="node" preserve="true" />
2
2
  import type { Readable, Writable } from 'node:stream';
3
3
  import type { StandardSchemaV1 } from '@standard-schema/spec';
4
+ import type { ExtensionValue } from './extension.js';
4
5
  type LowercaseLetter = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm' | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z';
5
6
  type ShortAlias = LowercaseLetter | Uppercase<LowercaseLetter>;
6
7
  type OptionSpelling = {
@@ -32,6 +33,31 @@ type Omission = {
32
33
  } | {
33
34
  validateOmitted?: false;
34
35
  };
36
+ /**
37
+ * The one-line summary every projection reads. It is a core fact: optional, and a string that holds
38
+ * a character other than whitespace and no line terminator.
39
+ */
40
+ interface Described {
41
+ description?: string;
42
+ }
43
+ /**
44
+ * The two core facts a listing reads on a named Command and on an option.
45
+ * `hidden` keeps the member off every listing, and an omitted one reads `false`.
46
+ * `deprecated` is the one-line migration message a listing shows beside the member.
47
+ * Neither belongs to an argument, which cannot leave the grammar it sits in, or to the root.
48
+ */
49
+ interface Listed {
50
+ hidden?: boolean;
51
+ deprecated?: string;
52
+ }
53
+ /** The extension values one option declaration carries, whatever scope declares the option. */
54
+ interface OptionExtensions {
55
+ extensions?: readonly ExtensionValue<'option'>[];
56
+ }
57
+ /** The same slot on an argument declaration, typed by the target its values must name. */
58
+ interface ArgumentExtensions {
59
+ extensions?: readonly ExtensionValue<'argument'>[];
60
+ }
35
61
  /**
36
62
  * The tokens one declaration collects before validation: one string, or the whole collection. A
37
63
  * multiple option and a variadic argument collect alike, so they share this raw shape.
@@ -54,7 +80,7 @@ export type GlobalNameConstraint<Name extends string, Globals> = Name extends ke
54
80
  } : unknown;
55
81
  /** A required record key excludes open strings; distribution rejects each union member. */
56
82
  export type NameConstraint<Name extends string, Whole extends string = Name> = {} extends Record<Name, unknown> ? LiteralNameFault : Name extends Whole ? [Whole] extends [Name] ? unknown : LiteralNameFault : LiteralNameFault;
57
- export type ExitCode = 0 | 1 | 2;
83
+ export type ExitCode = 0 | 1 | 2 | 130 | 143;
58
84
  export interface InputTerminal {
59
85
  isTTY: boolean;
60
86
  }
@@ -77,6 +103,11 @@ export interface Host {
77
103
  }
78
104
  export interface RunOptions {
79
105
  host?: Partial<Host>;
106
+ /**
107
+ * A caller-owned signal that cancels the run. Core subscribes to it at run entry and honors an
108
+ * abort at every phase boundary; it composes with an installed signals owner.
109
+ */
110
+ signal?: AbortSignal;
80
111
  }
81
112
  /** The declaration one schema call validates, under the name and the scope it was declared in. */
82
113
  export interface InputIdentity {
@@ -130,17 +161,17 @@ export interface Out {
130
161
  render<Data>(data: Data, renderer: Renderer<Data>): Promise<void>;
131
162
  fatal(message: string): never;
132
163
  }
133
- export type StringOption = OptionSpelling & Presence & Multiplicity & Omission & {
164
+ export type StringOption = OptionSpelling & Presence & Multiplicity & Omission & Described & Listed & OptionExtensions & {
134
165
  type: 'string';
135
166
  polarity?: never;
136
167
  validate?: StandardSchemaV1;
137
168
  };
138
169
  /** A variadic argument collects the remaining tokens, so it follows the multiple option rules. */
139
- export type VariadicArgument = Presence & {
170
+ export type VariadicArgument = Presence & Described & ArgumentExtensions & {
140
171
  variadic: true;
141
172
  validate?: StandardSchemaV1;
142
173
  };
143
- export type ScalarArgument = Presence & Omission & {
174
+ export type ScalarArgument = Presence & Omission & Described & ArgumentExtensions & {
144
175
  variadic?: false;
145
176
  validate?: StandardSchemaV1;
146
177
  };
@@ -198,7 +229,7 @@ export type ArgumentValue<Config extends ArgumentConfig> = Config extends {
198
229
  } | {
199
230
  validateOmitted: true;
200
231
  } ? never : undefined);
201
- export type BooleanOption = (OptionSpelling & {
232
+ export type BooleanOption = (OptionSpelling & Described & Listed & OptionExtensions & {
202
233
  type: 'boolean';
203
234
  validate?: never;
204
235
  default?: never;
@@ -206,7 +237,7 @@ export type BooleanOption = (OptionSpelling & {
206
237
  required?: never;
207
238
  validateOmitted?: never;
208
239
  polarity?: 'positive' | 'negative';
209
- }) | {
240
+ }) | (Described & Listed & OptionExtensions & {
210
241
  type: 'boolean';
211
242
  validate?: never;
212
243
  default?: never;
@@ -216,8 +247,23 @@ export type BooleanOption = (OptionSpelling & {
216
247
  polarity: 'both';
217
248
  short?: ShortAlias;
218
249
  shortOnly?: false;
219
- };
250
+ });
220
251
  export type OptionConfig = StringOption | BooleanOption;
252
+ /**
253
+ * The parsing part of a string option config, which is all a plugin option declares. A plugin
254
+ * option carries no schema and no presence rule, because it is read before local parsing, where the
255
+ * validation context every schema is promised cannot exist. Its middleware interprets the value.
256
+ * A Boolean plugin option is an ordinary `BooleanOption`, which already declares none of them.
257
+ */
258
+ export type PluginStringOption = OptionSpelling & Multiplicity & Described & Listed & OptionExtensions & {
259
+ type: 'string';
260
+ default?: string | string[];
261
+ polarity?: never;
262
+ required?: never;
263
+ validate?: never;
264
+ validateOmitted?: never;
265
+ };
266
+ export type PluginOptionConfig = PluginStringOption | BooleanOption;
221
267
  export type OptionValue<Config extends OptionConfig> = Config extends StringOption ? Config extends {
222
268
  multiple: true;
223
269
  } ? ValidatedValue<Config, string[]> : ValidatedValue<Config, string> | (Config extends {
@@ -233,6 +279,8 @@ export interface ActionContext<Args, Options = {}> {
233
279
  passthrough: string[];
234
280
  out: Out;
235
281
  host: Host;
282
+ /** The run's cancellation signal, which a caller or an installed signals owner aborts. */
283
+ signal: AbortSignal;
236
284
  }
237
285
  export type Action<Args, Options = {}> = (context: ActionContext<Args, Options>) => unknown;
238
286
  /** Phantom key. It keeps the inferred declaration types exact and holds no runtime value. */
@@ -72,9 +72,11 @@ export declare function issuePath(issue: StandardSchemaV1.Issue): string | undef
72
72
  /**
73
73
  * Every declaration rule that reads the declaration alone. It is synchronous, so `inspect()` and
74
74
  * `run()` apply exactly the same rules, and only validating a default through its schema, which
75
- * can be asynchronous, is left to `run()`.
75
+ * can be asynchronous, is left to `run()`. A contributor that declares under its own name, such as
76
+ * a plugin, supplies the subject its diagnostics read with; every other caller is named by the
77
+ * declaration itself.
76
78
  */
77
- export declare function checkDeclarations(inputs: readonly InputDeclaration[]): void;
79
+ export declare function checkDeclarations(inputs: readonly InputDeclaration[], named?: string): void;
78
80
  /**
79
81
  * Every declared default, validated before any token is read. The host is captured by then, so a
80
82
  * default's schema reads the same Host its action will, under the `default` phase.
@@ -1,5 +1,6 @@
1
1
  import { schemaOptions } from './context.js';
2
2
  import { DeclarationError, InputError } from './errors.js';
3
+ import { booleanValue } from './options.js';
3
4
  /** Every declaration in validation order: the globals first, then the reading Command's own. */
4
5
  function scoped(inputs) {
5
6
  return [
@@ -117,9 +118,8 @@ function holdsRawDefault(input) {
117
118
  * `validateOmitted: true` is the one way an omitted scalar reaches its schema, so every other rule
118
119
  * that already decides absence rejects it, and the flag needs a schema to receive the omission.
119
120
  */
120
- function checkOmissionValidation(input) {
121
+ function checkOmissionValidation(input, subject) {
121
122
  const { config } = input;
122
- const subject = declaredName(input);
123
123
  if (config.required) {
124
124
  throw new DeclarationError(`${subject} is required and declares validateOmitted. Remove validateOmitted or make the input optional.`);
125
125
  }
@@ -133,34 +133,34 @@ function checkOmissionValidation(input) {
133
133
  throw new DeclarationError(`${subject} declares validateOmitted without a schema. Add validate or remove validateOmitted.`);
134
134
  }
135
135
  }
136
- function checkDeclaration(input) {
136
+ function checkDeclaration(input, subject) {
137
137
  const { config } = input;
138
138
  if (input.kind === 'option' && input.config.type === 'boolean') {
139
139
  if ('validate' in config ||
140
140
  'default' in config ||
141
141
  'required' in config ||
142
142
  'validateOmitted' in config) {
143
- throw new DeclarationError(`${declaredName(input)} is Boolean. Remove validate, default, required, and validateOmitted; use polarity to control its absent value.`);
143
+ throw new DeclarationError(`${subject} is Boolean. Remove validate, default, required, and validateOmitted; use polarity to control its absent value.`);
144
144
  }
145
145
  return;
146
146
  }
147
147
  if (config.required !== undefined && typeof config.required !== 'boolean') {
148
- throw new DeclarationError(`${declaredName(input)} required must be Boolean. Use true or false.`);
148
+ throw new DeclarationError(`${subject} required must be Boolean. Use true or false.`);
149
149
  }
150
150
  if (input.kind === 'argument' &&
151
151
  input.config.variadic !== undefined &&
152
152
  typeof input.config.variadic !== 'boolean') {
153
- throw new DeclarationError(`${declaredName(input)} variadic must be Boolean. Use true or false.`);
153
+ throw new DeclarationError(`${subject} variadic must be Boolean. Use true or false.`);
154
154
  }
155
155
  // The test reads presence, not truth, so a declared `undefined` is a declaration to reject.
156
156
  if ('validateOmitted' in config && typeof config.validateOmitted !== 'boolean') {
157
- throw new DeclarationError(`${declaredName(input)} validateOmitted must be Boolean. Use true or false.`);
157
+ throw new DeclarationError(`${subject} validateOmitted must be Boolean. Use true or false.`);
158
158
  }
159
159
  if (config.required && Object.hasOwn(config, 'default')) {
160
- throw new DeclarationError(`${declaredName(input)} is required and declares a default. Remove the default or make the input optional.`);
160
+ throw new DeclarationError(`${subject} is required and declares a default. Remove the default or make the input optional.`);
161
161
  }
162
162
  if (validatesOmission(input)) {
163
- checkOmissionValidation(input);
163
+ checkOmissionValidation(input, subject);
164
164
  }
165
165
  const schema = config.validate;
166
166
  if (schema !== undefined &&
@@ -170,7 +170,7 @@ function checkDeclaration(input) {
170
170
  schema['~standard'].version !== 1 ||
171
171
  typeof schema['~standard'].vendor !== 'string' ||
172
172
  typeof schema['~standard'].validate !== 'function')) {
173
- throw new DeclarationError(`${declaredName(input)} validate must be a Standard Schema v1 object. Supply a compatible schema.`);
173
+ throw new DeclarationError(`${subject} validate must be a Standard Schema v1 object. Supply a compatible schema.`);
174
174
  }
175
175
  }
176
176
  function readIssue(issue) {
@@ -259,15 +259,17 @@ function hasDefault(input) {
259
259
  /**
260
260
  * Every declaration rule that reads the declaration alone. It is synchronous, so `inspect()` and
261
261
  * `run()` apply exactly the same rules, and only validating a default through its schema, which
262
- * can be asynchronous, is left to `run()`.
262
+ * can be asynchronous, is left to `run()`. A contributor that declares under its own name, such as
263
+ * a plugin, supplies the subject its diagnostics read with; every other caller is named by the
264
+ * declaration itself.
263
265
  */
264
- export function checkDeclarations(inputs) {
266
+ export function checkDeclarations(inputs, named) {
265
267
  for (const input of inputs) {
266
- checkDeclaration(input);
268
+ checkDeclaration(input, named ?? declaredName(input));
267
269
  }
268
270
  for (const input of inputs.filter((entry) => hasDefault(entry))) {
269
271
  if (input.config.validate === undefined && !holdsRawDefault(input)) {
270
- const subject = declaredName(input);
272
+ const subject = named ?? declaredName(input);
271
273
  throw new DeclarationError(collects(input)
272
274
  ? `${subject} default must be an array of strings without a schema. Supply a string array default.`
273
275
  : `${subject} default must be a string without a schema. Supply a string default.`);
@@ -366,7 +368,7 @@ export async function validateValues(invocation) {
366
368
  for (const entry of declarations) {
367
369
  const { input } = entry;
368
370
  if (input.kind === 'option' && input.config.type === 'boolean') {
369
- values.set(input, supplied.options.booleans.get(input.name) ?? input.config.polarity === 'negative');
371
+ values.set(input, booleanValue(supplied.options, input.name, input.config));
370
372
  }
371
373
  else {
372
374
  const collected = collects(input);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loomcli/core",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "The Loom CLI core package. Provides the scaffolding for creating new Loom CLI applications.",
5
5
  "license": "MIT",
6
6
  "repository": {