@wirecat/cli-base 0.61.3 → 0.62.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/CHANGELOG.md CHANGED
@@ -8,6 +8,24 @@ Versions before 0.8.0 are in the [git tags](https://github.com/WireCatLabs/cli-b
8
8
  Every entry says what changed as a caller sees it, why, and what to watch for — the rules are
9
9
  [`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog).
10
10
 
11
+ ## 0.62.0 — 26.10.2026
12
+
13
+ ### Added
14
+
15
+ - **`describeAlone(command, known)` describes a command as if it had no parent, reusing what `known` already holds.**
16
+ The shared-command check uses it to describe each command once per pass. Keep `known` for one pass only: a map kept
17
+ longer returns descriptions of commands that have since changed.
18
+
19
+ ### Fixed
20
+
21
+ - **The shared-command check describes each command once per pass, not once for itself and again for every group
22
+ above it.** It runs when a shared command is built and before each command runs, so every command of every CLI paid
23
+ for it: about 60% of tg's setup, even for `tg --version`. On tg's real program one check now takes about 5 ms
24
+ instead of 11, building the program about 35 ms instead of 46, and a warm `run(["--version"])` about 30 ms instead
25
+ of 47 ([measurement](https://github.com/WireCatLabs/cli-testing/blob/main/performance/cli/shared-contract-check/README.md)).
26
+ The check still covers every protected subcommand, with the same errors and overrides, and `sharedContracts` returns
27
+ the same contracts as before.
28
+
11
29
  ## 0.61.3 — 26.10.2026
12
30
 
13
31
  ### Fixed
@@ -1,25 +1,22 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
2
  import { Command } from "commander";
3
3
  import { CliError } from "../errors.js";
4
- import { describeProgram } from "./registry.js";
4
+ import { describeAlone } from "./registry.js";
5
5
  const protectedCommands = new WeakMap();
6
6
  export const hasSharedContract = (command) => protectedCommands.has(command);
7
- const snapshot = (command, children) => {
8
- const root = new Command("contract");
9
- const parent = command.parent;
10
- root.addCommand(command);
11
- const described = describeProgram(root)[0];
12
- command.parent = parent;
13
- if (!described)
7
+ const hidden = (command) => Boolean(command._hidden);
8
+ /** Shares arrays with the live command and with `known`: clone it before keeping it past the pass. */
9
+ const snapshot = (command, known = new Map(), children) => {
10
+ if (hidden(command))
14
11
  throw new Error("a hidden command cannot own a shared contract");
15
- const { commands: _, usage: __, path: ___, ...contract } = described;
16
- return structuredClone({
12
+ const { commands: _, usage: __, path: ___, ...contract } = describeAlone(command, known);
13
+ return {
17
14
  ...contract,
18
15
  aliases: [...command.aliases()],
19
16
  children: (children ?? protectedCommands.get(command)?.originalChildren ?? command.commands)
20
- .filter((child) => command.commands.includes(child) && !child._hidden)
17
+ .filter((child) => command.commands.includes(child) && !hidden(child))
21
18
  .map((child) => child.name()),
22
- });
19
+ };
23
20
  };
24
21
  const same = isDeepStrictEqual;
25
22
  const visit = (root, fn) => {
@@ -36,11 +33,12 @@ const pathOf = (command) => {
36
33
  const differences = (before, after) => [...new Set([...Object.keys(before), ...Object.keys(after)])].filter((field) => !same(before[field], after[field]));
37
34
  export const assertSharedContracts = (root) => {
38
35
  const changed = [];
36
+ const known = new Map();
39
37
  visit(root, (command) => {
40
38
  const protectedContract = protectedCommands.get(command);
41
39
  if (!protectedContract)
42
40
  return;
43
- const fields = differences(protectedContract.contract, snapshot(command));
41
+ const fields = differences(protectedContract.contract, snapshot(command, known));
44
42
  if (fields.length)
45
43
  changed.push({ command: pathOf(command).join(" "), fields });
46
44
  });
@@ -55,9 +53,9 @@ export const overrideSharedContract = (command, reason, change) => {
55
53
  if (!protectedContract)
56
54
  throw new Error("only a core-owned command contract can be overridden");
57
55
  assertSharedContracts(command);
58
- const before = snapshot(command);
56
+ const before = structuredClone(snapshot(command));
59
57
  change(command);
60
- const after = snapshot(command);
58
+ const after = structuredClone(snapshot(command));
61
59
  const fields = differences(before, after);
62
60
  if (fields.length === 0)
63
61
  throw new Error("a shared contract override must change its named command");
@@ -119,14 +117,18 @@ export const sharedCommand = (factory) => (...args) => {
119
117
  if (!command.summary() && first.length > 80)
120
118
  command.summary(`${first.slice(0, 77).replace(/\s+\S*$/, "")}…`);
121
119
  });
120
+ const known = new Map();
122
121
  visit(shared, (command) => {
123
- if (!command._hidden && !protectedCommands.has(command))
124
- protectedCommands.set(command, {
125
- contract: snapshot(command),
126
- reference: snapshot(command),
127
- overrides: [],
128
- originalChildren: [...command.commands],
129
- });
122
+ if (hidden(command) || protectedCommands.has(command))
123
+ return;
124
+ // One object for both: `contract` is only ever replaced, and `reference` is cloned on the way out.
125
+ const contract = structuredClone(snapshot(command, known));
126
+ protectedCommands.set(command, {
127
+ contract,
128
+ reference: contract,
129
+ overrides: [],
130
+ originalChildren: [...command.commands],
131
+ });
130
132
  });
131
133
  shared.hook("preAction", () => {
132
134
  let root = shared;
@@ -147,6 +149,7 @@ export const customizeSharedCommand = (reference, reason, build, expectedOverrid
147
149
  const command = build();
148
150
  assertSharedContracts(command);
149
151
  const pending = [];
152
+ const known = new Map();
150
153
  const edits = [];
151
154
  const bindingPath = (own) => {
152
155
  const path = [own.name()];
@@ -163,7 +166,7 @@ export const customizeSharedCommand = (reference, reason, build, expectedOverrid
163
166
  return;
164
167
  if (!protectedCommands.has(own)) {
165
168
  const originalChildren = own.commands.filter((child) => inherited.reference.children.includes(child.name()));
166
- const actual = snapshot(own, originalChildren);
169
+ const actual = structuredClone(snapshot(own, known, originalChildren));
167
170
  const overrides = differences(inherited.contract, actual).map((field) => ({
168
171
  command: bindingPath(own),
169
172
  field,
@@ -119,3 +119,8 @@ export declare const flatten: (commands: readonly CommandInfo[]) => CommandInfo[
119
119
  * in snake_case. Hand it to `stepOf` and `errorObjectOf` as `targets`.
120
120
  */
121
121
  export declare const stepTargets: (root: Command) => ((command: string) => StepTarget | undefined);
122
+ /**
123
+ * The command as if it had no parent, so it inherits nothing, and its subcommands the same way. `known` holds what one
124
+ * pass has described already, so a whole tree costs one description per command; keep it no longer than that pass.
125
+ */
126
+ export declare const describeAlone: (command: Command, known: Map<Command, CommandInfo>) => CommandInfo;
@@ -40,7 +40,19 @@ export const stepTargets = (root) => {
40
40
  visit(child, [], root.options);
41
41
  return (command) => found.get(command);
42
42
  };
43
- const describe = (command, cli, parents, above) => {
43
+ /**
44
+ * The command as if it had no parent, so it inherits nothing, and its subcommands the same way. `known` holds what one
45
+ * pass has described already, so a whole tree costs one description per command; keep it no longer than that pass.
46
+ */
47
+ export const describeAlone = (command, known) => {
48
+ let found = known.get(command);
49
+ if (!found) {
50
+ found = describe(command, "", [], { offMcp: false, noOffline: false }, known);
51
+ known.set(command, found);
52
+ }
53
+ return found;
54
+ };
55
+ const describe = (command, cli, parents, above, known) => {
44
56
  const path = [...parents, command.name()];
45
57
  const args = command.registeredArguments.map(describeArgument);
46
58
  const options = describeOptions(command);
@@ -68,7 +80,7 @@ const describe = (command, cli, parents, above) => {
68
80
  ...(visible(command).length > 0 && hasAction(command) ? { runnable: true } : {}),
69
81
  arguments: args,
70
82
  options,
71
- commands: visible(command).map((child) => describe(child, cli, path, here)),
83
+ commands: visible(command).map((child) => (known ? describeAlone(child, known) : describe(child, cli, path, here))),
72
84
  };
73
85
  };
74
86
  // Commander has no public getter for the action either; without one, a group prints its help.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-base",
3
- "version": "0.61.3",
3
+ "version": "0.62.0",
4
4
  "description": "The parts every command line tool needs: output modes, terminal rendering, error model, exit codes, credentials, clocks",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",