@loomcli/core 0.1.0 → 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/chain.js ADDED
@@ -0,0 +1,288 @@
1
+ import { prepareDispatch, routeInvocation } from './command.js';
2
+ import { InternalError, reasonOf, toFailure } from './errors.js';
3
+ import { inspectGraph } from './inspect.js';
4
+ import { booleanValue } from './options.js';
5
+ import { pluginSentence } from './plugin.js';
6
+ /**
7
+ * The default export a loader must resolve to. A loaded module is data core never declared, so the
8
+ * check is the one runtime fact that decides it: the export is callable. Core calls it with the
9
+ * context it owns and ignores whatever it returns.
10
+ */
11
+ function isMiddlewareExport(value) {
12
+ return typeof value === 'function';
13
+ }
14
+ /** Whether one option name was supplied as a token, in any spelling a declaration accepts. */
15
+ function supplied(scan, name) {
16
+ return scan.strings.has(name) || scan.lists.has(name) || scan.booleans.has(name);
17
+ }
18
+ /** A collected value, or the declared array default, as this run's own copy. */
19
+ function collectedValue(collected, declared) {
20
+ if (collected) {
21
+ return [...collected];
22
+ }
23
+ return Array.isArray(declared) ? [...declared] : [];
24
+ }
25
+ /**
26
+ * One plugin's own option values for one run: what the pre-scan produced, or the declared default,
27
+ * filled without validation. A collected value and an array default are copied, so a middleware
28
+ * that writes to what it received changes neither the declaration nor the next run.
29
+ */
30
+ function pluginValues(inputs, scan) {
31
+ const values = {};
32
+ for (const { config, name } of inputs) {
33
+ const declared = config.default;
34
+ if (config.type === 'boolean') {
35
+ values[name] = booleanValue(scan, name, config);
36
+ }
37
+ else if (config.multiple === true) {
38
+ values[name] = collectedValue(scan.lists.get(name), declared);
39
+ }
40
+ else {
41
+ // Build already proved that a string option without a schema declares a string default.
42
+ values[name] =
43
+ scan.strings.get(name) ?? (typeof declared === 'string' ? declared : undefined);
44
+ }
45
+ }
46
+ return values;
47
+ }
48
+ /** Whether one plugin's declared activation matched the tokens the pre-scan consumed. */
49
+ function activates(installed, scan) {
50
+ const { middleware } = installed;
51
+ if (!middleware) {
52
+ return false;
53
+ }
54
+ return (middleware.activate === 'always' || middleware.activate.some((name) => supplied(scan, name)));
55
+ }
56
+ /**
57
+ * The chain for one invocation: each installed plugin whose activation matched, in installation
58
+ * order. Activation is read from the pre-scan, before any plugin code loads, so a plugin whose
59
+ * option was never supplied is not in the chain and its loader is never called.
60
+ */
61
+ function activatedEntries(plugins, scan) {
62
+ return plugins
63
+ .filter((installed) => activates(installed, scan))
64
+ .map((installed) => ({
65
+ identity: installed.identity,
66
+ // Activation proved the middleware exists, so the empty loader is never the one core calls.
67
+ load: installed.middleware?.load ?? (() => undefined),
68
+ options: pluginValues(installed.inputs, scan),
69
+ }));
70
+ }
71
+ /**
72
+ * The routed node inside the inspected graph, which routing already proved reachable. A missing
73
+ * segment means the two readings of one graph disagree, so the chain stops rather than hand a
74
+ * middleware the wrong Command.
75
+ */
76
+ function nodeAt(graph, path) {
77
+ let node = graph.root;
78
+ for (const name of path) {
79
+ const child = node.children.find((entry) => entry.name === name);
80
+ if (!child) {
81
+ throw new InternalError(`The routed command "${path.join(' ')}" is not in the inspected graph.`, undefined);
82
+ }
83
+ node = child;
84
+ }
85
+ return node;
86
+ }
87
+ /** A downstream promise core awaits for its completion alone; its outcome was recorded already. */
88
+ async function quiet(pending) {
89
+ if (pending) {
90
+ try {
91
+ await pending;
92
+ }
93
+ catch {
94
+ // The rejection was recorded where it crossed the `next()` boundary.
95
+ }
96
+ }
97
+ }
98
+ /**
99
+ * The outcome one entry reports to its caller. `'cancelled'` wins over `'taken-over'`, so a later
100
+ * middleware that returned because it saw the abort reports as cancelled, the order the exit codes
101
+ * follow. An action that already ran still reports as dispatched.
102
+ */
103
+ function reported(chain, outcome) {
104
+ return outcome === 'taken-over' && chain.cancelled() ? 'cancelled' : outcome;
105
+ }
106
+ /** A `next()` call that is no longer live: it dispatches nothing and rejects. */
107
+ function misuse(turn) {
108
+ const fault = new InternalError(`${pluginSentence(turn.entry.identity)} called next() ${turn.state.returned ? 'after its middleware returned' : 'twice'}.`, undefined);
109
+ turn.chain.report(fault);
110
+ const rejected = Promise.reject(fault);
111
+ void rejected.catch(() => undefined);
112
+ return rejected;
113
+ }
114
+ /**
115
+ * The `next` one middleware receives. It is live until that middleware's own result settles, so a
116
+ * second call, or a call after the middleware returned, rejects and continues nothing.
117
+ */
118
+ function nextOf(turn) {
119
+ const { chain, state } = turn;
120
+ return () => {
121
+ if (state.returned || state.calls > 0) {
122
+ return misuse(turn);
123
+ }
124
+ state.calls += 1;
125
+ const pending = chain.step(turn.index + 1).then((outcome) => {
126
+ state.outcome = outcome;
127
+ state.settled = true;
128
+ return outcome;
129
+ }, (error) => {
130
+ state.rejection = { value: error };
131
+ state.settled = true;
132
+ chain.record(error);
133
+ throw error;
134
+ });
135
+ state.downstream = pending;
136
+ // Core awaits the downstream promise itself, so a middleware that never awaits `next()` still
137
+ // Holds the chain open and never ends the run with an unobserved rejection.
138
+ void pending.catch(() => undefined);
139
+ return pending;
140
+ };
141
+ }
142
+ /** The middleware's own result as a value, so the decision below reads one shape. */
143
+ async function call(middleware, context) {
144
+ try {
145
+ await middleware(context);
146
+ return undefined;
147
+ }
148
+ catch (error) {
149
+ return { value: error };
150
+ }
151
+ }
152
+ /**
153
+ * What one entry reports to its caller once its own result has settled. A throw before `next()`
154
+ * settled, or without calling it, is this invocation's failure; a throw during unwinding is an
155
+ * internal error reported after the primary outcome, which keeps its own code.
156
+ */
157
+ async function settle(turn, thrown) {
158
+ const { chain, state } = turn;
159
+ if (thrown) {
160
+ const propagated = state.rejection !== undefined && thrown.value === state.rejection.value;
161
+ if (propagated || !state.settled) {
162
+ await quiet(state.downstream);
163
+ throw thrown.value;
164
+ }
165
+ /**
166
+ * A fault core recorded where it was raised, such as a misused `next()`, reaches this point
167
+ * again when the middleware let it escape. It keeps the one report it already has.
168
+ */
169
+ if (!chain.announced(thrown.value)) {
170
+ chain.report(new InternalError(reasonOf(thrown.value), thrown.value));
171
+ }
172
+ }
173
+ if (state.calls === 0) {
174
+ return reported(chain, 'taken-over');
175
+ }
176
+ await quiet(state.downstream);
177
+ // A middleware that caught the rejection reports what the chain reached; the recorded failure
178
+ // Still decides the exit code.
179
+ return reported(chain, state.outcome ?? (chain.invoked() ? 'dispatched' : 'taken-over'));
180
+ }
181
+ /** The module one loader answers with, whether it throws where it is called or rejects later. */
182
+ async function loadModule(entry) {
183
+ try {
184
+ return await entry.load();
185
+ }
186
+ catch (error) {
187
+ throw new InternalError(`Loading plugin "${entry.identity}" failed: ${reasonOf(error)}`, error);
188
+ }
189
+ }
190
+ /** A plugin's module is loaded when the chain reaches it, never before. */
191
+ async function loadMiddleware(entry) {
192
+ const module = await loadModule(entry);
193
+ const handler = module !== null && typeof module === 'object' && 'default' in module
194
+ ? module.default
195
+ : undefined;
196
+ if (!isMiddlewareExport(handler)) {
197
+ throw new InternalError(`Loading plugin "${entry.identity}" failed: the module exports no default middleware function.`, undefined);
198
+ }
199
+ return handler;
200
+ }
201
+ /** One entry's turn: its module loads here, when the chain reaches it and never before. */
202
+ async function runEntry(entry, index, chain) {
203
+ const middleware = await loadMiddleware(entry);
204
+ if (chain.cancelled()) {
205
+ /**
206
+ * A module import cannot be aborted, so a loader already in flight settles and core starts
207
+ * nothing with it: the middleware it resolved to is skipped.
208
+ */
209
+ return 'cancelled';
210
+ }
211
+ const state = { calls: 0, returned: false, settled: false };
212
+ const turn = { chain, entry, index, state };
213
+ const thrown = await call(middleware, chain.context(entry, nextOf(turn)));
214
+ state.returned = true;
215
+ return settle(turn, thrown);
216
+ }
217
+ /** The whole chain, answering with the failure it raised when a middleware caught that failure. */
218
+ async function runChain(invocation, routed, entries) {
219
+ const run = { invoked: false, raised: undefined };
220
+ const terminal = async () => {
221
+ const dispatch = await prepareDispatch(invocation.graph, routed, invocation);
222
+ run.invoked = true;
223
+ await dispatch();
224
+ return 'dispatched';
225
+ };
226
+ const cancelled = () => invocation.signal.aborted;
227
+ if (entries.length === 0) {
228
+ if (!cancelled()) {
229
+ await terminal();
230
+ }
231
+ return undefined;
232
+ }
233
+ // The graph a middleware reads is the one `inspect()` returns, built once for the run.
234
+ const graph = inspectGraph(invocation.name, invocation.graph, invocation.facts);
235
+ const command = nodeAt(graph, routed.path);
236
+ // Every fault this chain has reported, so the same one raised again carries no second report.
237
+ const announced = new WeakSet();
238
+ const chain = {
239
+ announced: (value) => typeof value === 'object' && value !== null && announced.has(value),
240
+ cancelled,
241
+ context: (entry, next) => ({
242
+ command,
243
+ graph,
244
+ host: invocation.host,
245
+ next,
246
+ options: entry.options,
247
+ out: invocation.out,
248
+ signal: invocation.signal,
249
+ }),
250
+ invoked: () => run.invoked,
251
+ record: (error) => {
252
+ run.raised ??= toFailure(error);
253
+ },
254
+ report: (fault) => {
255
+ announced.add(fault);
256
+ invocation.report(fault);
257
+ },
258
+ step: (index) => {
259
+ if (cancelled()) {
260
+ /**
261
+ * Core starts nothing new after cancellation: a middleware the chain has not reached and
262
+ * an action not yet dispatched are skipped, and the entries already running unwind.
263
+ */
264
+ return Promise.resolve('cancelled');
265
+ }
266
+ const entry = entries[index];
267
+ return entry ? runEntry(entry, index, chain) : terminal();
268
+ },
269
+ };
270
+ await chain.step(0);
271
+ return run.raised;
272
+ }
273
+ /**
274
+ * Runs one invocation: the global pre-scan, routing, the middleware chain, and the phases the chain
275
+ * terminates in. A middleware that returns without calling `next()` has taken over, so the
276
+ * remaining tokens are never parsed and nothing later in the chain runs.
277
+ */
278
+ async function runInvocation(invocation) {
279
+ const routed = routeInvocation(invocation.graph, invocation.host.argv);
280
+ const entries = activatedEntries(invocation.plugins, routed.scan);
281
+ const raised = await runChain(invocation, routed, entries);
282
+ if (raised) {
283
+ // The chain resolved because a middleware caught the rejection. The failure it caught still
284
+ // Decides the exit code, the rule an action's caught output rejection already follows.
285
+ throw raised;
286
+ }
287
+ }
288
+ export { runInvocation };
package/dist/command.d.ts CHANGED
@@ -1,5 +1,8 @@
1
+ import type { DescriptorRegistry, ExtensionRecords, ExtensionValue } from './extension.js';
1
2
  import type { BuiltGlobals, GlobalOptions } from './globals.js';
2
3
  import { compileOptions } from './options.js';
4
+ import type { OptionValues } from './options.js';
5
+ import type { BuiltPlugin, PluginBuild } from './plugin.js';
3
6
  import type { Action, ArgumentConfig, ArgumentValue, declaredTypes, DeclaredTypes, DefaultConstraint, GlobalNameConstraint, Host, MultipleConstraint, NameConstraint, OptionConfig, OptionValue, Out, ValidateOmittedConstraint } from './types.js';
4
7
  import type { ArgumentInput, DefaultValues, InputDeclaration, OptionInput, ValidatedInputs } from './validation.js';
5
8
  /** One positional slot: the declaration it fills and whether it takes the remaining tokens. */
@@ -12,10 +15,11 @@ export interface DispatchInput {
12
15
  host: Host;
13
16
  out: Out;
14
17
  passthrough: string[];
18
+ signal: AbortSignal;
15
19
  values: ValidatedInputs;
16
20
  }
17
21
  /**
18
- * One child a bare token reaches, under its canonical name or one of its hidden aliases.
22
+ * One child a bare token reaches, under its canonical name or one of its aliases.
19
23
  * `name` repeats the key `children` holds, because `BuiltCommand.name` is `string | null` for the
20
24
  * root and the routed path a child extends holds strings alone.
21
25
  */
@@ -26,13 +30,17 @@ export interface RoutedChild {
26
30
  /**
27
31
  * A group registers no action, so its `dispatch` is `undefined` and selection rejects it.
28
32
  * `children` is keyed by canonical name, so every candidate list and every walk of the graph reads
29
- * it, and `routes` adds the hidden aliases, so routing alone resolves them.
33
+ * it, and `routes` adds the aliases, so routing alone resolves them.
30
34
  */
31
35
  export interface BuiltCommand {
32
36
  aliases: readonly string[];
33
37
  arguments: readonly ArgumentSlot[];
34
38
  children: ReadonlyMap<string, BuiltCommand>;
39
+ deprecated: string | undefined;
40
+ description: string | undefined;
35
41
  dispatch: ((input: DispatchInput) => unknown) | undefined;
42
+ extensions: Readonly<Record<string, unknown>>;
43
+ hidden: boolean;
36
44
  inputs: readonly InputDeclaration[];
37
45
  name: string | null;
38
46
  options: ReturnType<typeof compileOptions>;
@@ -81,6 +89,8 @@ interface AttachedCommand {
81
89
  * owner is the parent whose attachment the walk meets first.
82
90
  */
83
91
  interface BuildContext {
92
+ descriptors: DescriptorRegistry;
93
+ extensions: ExtensionRecords;
84
94
  globals: BuiltGlobals;
85
95
  owners: Map<AttachedCommand, string | null>;
86
96
  }
@@ -97,13 +107,30 @@ export interface CommandState<Args, Options, Globals> {
97
107
  options: Options;
98
108
  };
99
109
  children: readonly object[];
110
+ deprecated: unknown;
111
+ description: unknown;
112
+ extensions: unknown;
113
+ hidden: unknown;
100
114
  globals: GlobalOptions<Globals> | undefined;
101
115
  inputs: readonly InputDeclaration[];
102
116
  late: readonly LateDeclaration[];
103
117
  name: string | null;
118
+ options: unknown;
104
119
  }
105
- /** The state every declaration starts from. The unnamed root and each named Command share it. */
106
- export declare function freshState<Globals>(name: string | null, globals: GlobalOptions<Globals> | undefined): CommandState<{}, {}, Globals>;
120
+ /**
121
+ * The state every declaration starts from. The unnamed root and each named Command share it.
122
+ * The declaration values arrive captured, because a later change to the options object the author
123
+ * passed changes nothing the declaration holds.
124
+ */
125
+ export declare function freshState<Globals>(declaration: {
126
+ deprecated: unknown;
127
+ description: unknown;
128
+ extensions: unknown;
129
+ globals: GlobalOptions<Globals> | undefined;
130
+ hidden: unknown;
131
+ name: string | null;
132
+ options: unknown;
133
+ }): CommandState<{}, {}, Globals>;
107
134
  /** The declared value joins `args` under its literal name, typed by its own config. */
108
135
  export declare function declareArgument<Args, Options, Globals, Name extends string, Config extends ArgumentConfig>(state: CommandState<Args, Options, Globals>, input: ArgumentInput<Name, Config>): CommandState<Args & Record<Name, ArgumentValue<Config>>, Options, Globals>;
109
136
  /** The declared value joins `options` under its literal name, typed by its own config. */
@@ -115,11 +142,16 @@ export declare function declareAction<Args, Options, Globals>(state: CommandStat
115
142
  export declare function attachChild<Args, Options, Globals>(state: CommandState<Args, Options, Globals>, child: object): CommandState<Args, Options, Globals>;
116
143
  /** Validates one declaration against the shared globals table and compiles it for dispatch. */
117
144
  export declare function buildCommand<Args, Options, Globals>(state: CommandState<Args, Options, Globals>, context: BuildContext): BuiltCommand;
118
- /** The globals table and the owners record each compile once per invocation and the whole graph shares them. */
119
- export declare function buildGraph<Args, Options, Globals>(root: CommandState<Args, Options, Globals>): {
145
+ /** One built graph: the shared globals table, the root Command, and the facts each node carries. */
146
+ export interface BuiltGraph {
147
+ extensions: ExtensionRecords;
120
148
  globals: BuiltGlobals;
121
149
  root: BuiltCommand;
122
- };
150
+ }
151
+ /** The globals table and the owners record each compile once per invocation and the whole graph shares them. */
152
+ export declare function buildGraph<Args, Options, Globals>(root: CommandState<Args, Options, Globals>, install: PluginBuild & {
153
+ plugins: readonly BuiltPlugin[];
154
+ }): BuiltGraph;
123
155
  export declare class CommandBuilder<Args, Options, Globals, State extends CommandMethod = CommandMethod> {
124
156
  #private;
125
157
  readonly [commandValue]: true;
@@ -129,8 +161,8 @@ export declare class CommandBuilder<Args, Options, Globals, State extends Comman
129
161
  argument<const Name extends string, const Config extends ArgumentConfig>(name: Name, config: Config & NameConstraint<Name> & NoInfer<DefaultConstraint<Config>> & NoInfer<ValidateOmittedConstraint<Config>>): Command<Args & Record<Name, ArgumentValue<Config>>, Options, Globals, AfterArgument<State>>;
130
162
  option<const Name extends string, const Config extends OptionConfig>(name: Name, config: Config & NameConstraint<Name> & GlobalNameConstraint<Name, Globals> & NoInfer<DefaultConstraint<Config>> & NoInfer<MultipleConstraint<Config>> & NoInfer<ValidateOmittedConstraint<Config>>): Command<Args, Options & Record<Name, OptionValue<Config>>, Globals, State>;
131
163
  /**
132
- * Hidden aliases are other bare tokens that route to this Command. They invalidate no call, and
133
- * the tuple rest parameter rejects a call that names none.
164
+ * Aliases are other bare tokens that route to this Command. They invalidate no call, and the
165
+ * tuple rest parameter rejects a call that names none.
134
166
  */
135
167
  alias(...names: [string, ...string[]]): Command<Args, Options, Globals, State>;
136
168
  /** A child arrives in any type state, because its own action is the call that finished it. */
@@ -147,11 +179,22 @@ export declare class CommandBuilder<Args, Options, Globals, State extends Comman
147
179
  * calls, so `Command<A, O, G>` accepts a Command in any state, a finished one included.
148
180
  */
149
181
  export type Command<Args = {}, Options = {}, Globals = {}, State extends CommandMethod = AfterAction> = Pick<CommandBuilder<Args, Options, Globals, State>, typeof commandValue | typeof declaredTypes | State>;
182
+ /**
183
+ * Everything a Command configures beside its declarations: the options value every Command in one
184
+ * application shares, and the core facts the declaration carries.
185
+ */
186
+ export interface CommandOptions<Globals = {}> {
187
+ globals?: GlobalOptions<Globals>;
188
+ description?: string;
189
+ hidden?: boolean;
190
+ deprecated?: string;
191
+ extensions?: readonly ExtensionValue<'command'>[];
192
+ }
150
193
  interface CommandConstructor {
151
194
  new (name: string): Command<{}, {}, {}, CommandMethod>;
152
- new <Globals>(name: string, globals: GlobalOptions<Globals>): Command<{}, {}, Globals, CommandMethod>;
195
+ new <Globals = {}>(name: string, options: CommandOptions<Globals>): Command<{}, {}, Globals, CommandMethod>;
153
196
  }
154
- /** The public constructor requires a name and narrows the globals type to the supplied value. */
197
+ /** The public constructor takes a name and one options object, as the Application does. */
155
198
  export declare const Command: CommandConstructor;
156
199
  /** Every declaration in the graph, so defaults are validated before any token is read. */
157
200
  export declare function collectInputs(command: BuiltCommand): InputDeclaration[];
@@ -161,16 +204,24 @@ export declare function route(root: BuiltCommand, tokens: readonly string[]): {
161
204
  path: string[];
162
205
  tokens: string[];
163
206
  };
164
- /** Consumes globals, routes to a Command, then validates globals and locals in one pass. */
165
- export declare function selectCommand(graph: {
166
- globals: BuiltGlobals;
167
- root: BuiltCommand;
168
- }, invocation: {
207
+ /** One invocation after the pre-scan and routing, which the middleware chain runs on top of. */
208
+ export interface RoutedInvocation {
209
+ command: BuiltCommand;
210
+ path: readonly string[];
211
+ scan: OptionValues;
212
+ tokens: readonly string[];
213
+ }
214
+ /** Consumes the globals table, then routes the remaining bare tokens to a Command. */
215
+ export declare function routeInvocation(graph: BuiltGraph, argv: readonly string[]): RoutedInvocation;
216
+ /**
217
+ * The phases the middleware chain terminates in: the callable check, local parsing, validation, and
218
+ * the action. It answers with the call that dispatches, so the caller records that the action was
219
+ * invoked at the moment it invokes it and no earlier failure reads as a dispatch.
220
+ */
221
+ export declare function prepareDispatch(graph: BuiltGraph, routed: RoutedInvocation, invocation: {
169
222
  defaults: DefaultValues;
170
223
  host: Host;
171
- }): Promise<{
172
- dispatch: (input: DispatchInput) => unknown;
173
- passthrough: string[];
174
- values: ValidatedInputs;
175
- }>;
224
+ out: Out;
225
+ signal: AbortSignal;
226
+ }): Promise<() => unknown>;
176
227
  export {};