burgee 0.10.0 → 0.11.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/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
  <a href="https://www.npmjs.com/package/burgee"><img src="https://img.shields.io/npm/v/burgee?style=flat-square&color=0a6b47" alt="npm version" /></a>
16
16
  <a href="https://www.npmjs.com/package/burgee"><img src="https://img.shields.io/npm/dm/burgee?style=flat-square" alt="npm downloads" /></a>
17
17
  <img src="https://img.shields.io/badge/dependencies-5%20in--family-0a6b47?style=flat-square" alt="Five dependencies, all in this repository: bellpull, closeout, linegauge, roundel, seniority" />
18
- <img src="https://img.shields.io/badge/Node.js-24+-green.svg?style=flat-square" alt="Node.js 24+" />
18
+ <img src="https://img.shields.io/badge/Node.js-20.19%2B%20%7C%2022.13%2B-green.svg?style=flat-square" alt="Node.js 20.19+ or 22.13+" />
19
19
  <img src="https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square" alt="License: MIT" />
20
20
  </p>
21
21
 
@@ -164,7 +164,7 @@ so nothing reaches an agent by accident. Register it with any stdio client:
164
164
 
165
165
  ### Does it have dependencies?
166
166
 
167
- None outside this repository. `burgee` installs five packages from its own family —
167
+ None outside the burgee family. `burgee` installs five packages from that family —
168
168
  `bellpull`, `closeout`, `linegauge`, `roundel` and `seniority` — and each of those takes
169
169
  nothing from outside it either: one repository, one release pipeline, one supply chain to
170
170
  audit.
@@ -58,6 +58,22 @@ export interface OutputContext {
58
58
  error?: boolean;
59
59
  }
60
60
  export type HookEvent = 'preSubcommand' | 'preAction' | 'postAction';
61
+ /**
62
+ * commander's option-value types, as `typings/index.d.ts` declares them. `any` is
63
+ * commander's choice and the point: `program.opts().port` is usable without a cast, and
64
+ * `opts<T>()` narrows it — a program written against commander's types relies on both;
65
+ * `unknown` here breaks every `opts().x` such a program reads.
66
+ */
67
+ export type OptionValues = Record<string, any>;
68
+ /** Where an option's value came from. A string, so an author can define their own; the known ones autocomplete. */
69
+ export type OptionValueSource = 'default' | 'config' | 'env' | 'cli' | 'implied' | (string & Record<never, never>) | undefined;
70
+ /** What `.configureHelp()` takes: any subset of `Help`'s methods and settings. */
71
+ export type HelpConfiguration = Partial<Help>;
72
+ /** `.parseOptions()`'s split of an argv into operands and unknown options. */
73
+ export interface ParseOptionsResult {
74
+ operands: string[];
75
+ unknown: string[];
76
+ }
61
77
  export type HookListener = (thisCommand: Command, actionCommand: Command) => void | Promise<void>;
62
78
  export type AddHelpTextPosition = 'beforeAll' | 'before' | 'after' | 'afterAll';
63
79
  export type AddHelpTextContext = {
@@ -236,14 +252,11 @@ export declare class Command extends EventEmitter {
236
252
  * sub --unknown uuu op => [sub], [--unknown uuu op]
237
253
  * sub -- --unknown uuu op => [sub --unknown uuu op], []
238
254
  */
239
- parseOptions(args: string[]): {
240
- operands: string[];
241
- unknown: string[];
242
- };
243
- /** Local option values as key-value pairs. */
244
- opts(): Record<string, unknown>;
255
+ parseOptions(args: string[]): ParseOptionsResult;
256
+ /** Local option values as key-value pairs; `opts<T>()` types them, as commander's own declaration does. */
257
+ opts<T extends OptionValues = OptionValues>(): T;
245
258
  /** Merged local and global option values; globals overwrite locals. */
246
- optsWithGlobals(): Record<string, unknown>;
259
+ optsWithGlobals<T extends OptionValues = OptionValues>(): T;
247
260
  /** Display an error message and exit (or call exitOverride). */
248
261
  error(message: string, errorOptions?: ErrorOptions): never;
249
262
  /** One failure as the envelope (E3, G5); `fix` only when one candidate was named. */
@@ -4,13 +4,19 @@
4
4
  * `.sdlc/intents/commander-compat/spec.md` and `npm run compat`.
5
5
  */
6
6
  import { Argument } from './commander/argument.js';
7
- import { Command } from './commander/command.js';
7
+ import { Command, type OutputConfiguration as ResolvedOutputConfiguration } from './commander/command.js';
8
8
  import { Option } from './commander/option.js';
9
9
  export { Argument, humanReadableArgName } from './commander/argument.js';
10
- export { Command, useColor, type AddHelpTextContext, type AddHelpTextPosition, type BurgeeParseOptions, type CommandOptions, type ErrorOptions, type ExecutableCommandOptions, type HookEvent, type HookListener, type OutputConfiguration, type OutputContext, type ParseOptions, } from './commander/command.js';
10
+ export { Command, useColor, type AddHelpTextContext, type AddHelpTextPosition, type BurgeeParseOptions, type CommandOptions, type ErrorOptions, type ExecutableCommandOptions, type HelpConfiguration, type HookEvent, type HookListener, type OptionValueSource, type OptionValues, type OutputContext, type ParseOptions, type ParseOptionsResult, } from './commander/command.js';
11
11
  export { CommanderError, InvalidArgumentError, InvalidArgumentError as InvalidOptionArgumentError } from './commander/error.js';
12
12
  export { Help, type HelpContext } from './commander/help.js';
13
13
  export { DualOptions, Option } from './commander/option.js';
14
+ /**
15
+ * commander's `OutputConfiguration`: every member optional, because `.configureOutput()`
16
+ * takes any subset of them and a typed program annotates exactly that subset. What
17
+ * `.configureOutput()` hands back is the resolved, complete one.
18
+ */
19
+ export type OutputConfiguration = Partial<ResolvedOutputConfiguration>;
14
20
  /** The root command, for programs that never construct their own. */
15
21
  export declare const program: Command;
16
22
  export declare const createCommand: (name?: string) => Command;
package/dist/migrate.d.ts CHANGED
@@ -8,8 +8,24 @@ import { type Graded } from './compat.js';
8
8
  export declare const MAPPING: Readonly<Record<string, string>>;
9
9
  /** The packages a project depends on that this command is about (A1). */
10
10
  export declare const HOSTS: readonly ["commander", "yargs"];
11
- /** Why a file was left untouched. Both are named positions, never a guess (A4). */
12
- export type RefusalReason = 'deep-import' | 'non-literal-specifier';
11
+ /**
12
+ * Every name each target exports — values and types alike — so a rewrite that moves
13
+ * `import { Argv } from 'yargs'` can first ask whether `burgee/yargs` has an `Argv`.
14
+ *
15
+ * A specifier-only rewrite (D-050) cannot tell a value from a type, and does not need to:
16
+ * a name the target does not export breaks the build either way. This is data rather than
17
+ * a lookup because the lookup is the TypeScript checker, which is the dependency this
18
+ * command exists not to have; `facade-types.test.ts` holds the table equal to what the
19
+ * checker sees in `dist/*.d.ts`, so it cannot drift from the façades it describes.
20
+ */
21
+ export declare const FACADE_EXPORTS: Readonly<Record<string, readonly string[]>>;
22
+ /**
23
+ * Why a file was left untouched. Each is a named position, never a guess (A4).
24
+ *
25
+ * `unknown-export` is a named import the target does not export — rewriting it would turn a
26
+ * working import into TS2305 or a `SyntaxError` at load, so the file stays as it was.
27
+ */
28
+ export type RefusalReason = 'deep-import' | 'non-literal-specifier' | 'unknown-export';
13
29
  export interface Refusal {
14
30
  /** Relative to the directory being migrated, with forward slashes on every platform. */
15
31
  file: string;
@@ -17,6 +33,23 @@ export interface Refusal {
17
33
  /** The specifier as written, or `''` for a dynamic specifier that is not a literal. */
18
34
  specifier: string;
19
35
  reason: RefusalReason;
36
+ /** For `unknown-export`: the names the target does not export. */
37
+ names?: string[];
38
+ }
39
+ /**
40
+ * A type-only import left on the incumbent because the façade does not export every name in
41
+ * it. Types are erased, so the file's values still move to burgee and the program runs on it;
42
+ * the types keep compiling against the incumbent's declarations, which is why the incumbent
43
+ * is then not reported removable.
44
+ */
45
+ export interface Kept {
46
+ file: string;
47
+ line: number;
48
+ specifier: string;
49
+ /** The names the façade does not export. */
50
+ names: string[];
51
+ /** The note a reader acts on. */
52
+ note: string;
20
53
  }
21
54
  /** One rewritten specifier, for the per-mapping rollup the report prints. */
22
55
  export interface Mapped {
@@ -36,6 +69,8 @@ export interface MigrationReport {
36
69
  imports: number;
37
70
  mapped: Mapped[];
38
71
  refused: Refusal[];
72
+ /** Type-only imports left pointing at the incumbent, each with the note that says why. */
73
+ kept: Kept[];
39
74
  detected: Detection;
40
75
  dependencies: {
41
76
  before: string[];
@@ -57,6 +92,11 @@ interface Site {
57
92
  start: number;
58
93
  end: number;
59
94
  line: number;
95
+ /**
96
+ * For `import … from` and `export … from`: the code tokens between the keyword and `from`
97
+ * — the import clause, which is all `bindingsOf` needs. Absent for the other three positions.
98
+ */
99
+ clause?: string[];
60
100
  }
61
101
  interface Scan {
62
102
  sites: Site[];
@@ -80,6 +120,7 @@ export interface Rewrite {
80
120
  to: string;
81
121
  }[];
82
122
  refused: Omit<Refusal, 'file'>[];
123
+ kept: Omit<Kept, 'file'>[];
83
124
  /** Whether this file references a host at all. A file that does not is not "untouched", it is unrelated. */
84
125
  relevant: boolean;
85
126
  }
@@ -93,6 +134,19 @@ export interface Rewrite {
93
134
  * on a `Buffer` it also skips decoding 3 MB of UTF-8 that nothing was going to read.
94
135
  */
95
136
  export declare function mentionsAHost(source: string | Buffer): boolean;
137
+ /**
138
+ * The names an import or re-export clause asks the module for, and whether the whole
139
+ * statement is type-only (`import type …`, `export type …`).
140
+ *
141
+ * Read off the tokens the scan already has: `{ a, type b, c as d }` asks for `a`, `b` and
142
+ * `c`; a default or namespace binding asks for no name a façade could lack (`default` is
143
+ * the factory, and `* as ns` is checked where it is used, which a scan cannot see). A name
144
+ * written as a string (`{ 'a-b' as c }`) is left unverified rather than guessed at.
145
+ */
146
+ export declare function bindingsOf(clause: readonly string[]): {
147
+ typeOnly: boolean;
148
+ names: string[];
149
+ };
96
150
  /**
97
151
  * A2/A5 — map every host specifier in one file, or map none of them.
98
152
  *
package/dist/migrate.js CHANGED
@@ -11,6 +11,89 @@ export const MAPPING = {
11
11
  'yargs/helpers': 'burgee/yargs/helpers',
12
12
  };
13
13
  export const HOSTS = ['commander', 'yargs'];
14
+ export const FACADE_EXPORTS = {
15
+ 'burgee/commander': [
16
+ 'AddHelpTextContext',
17
+ 'AddHelpTextPosition',
18
+ 'Argument',
19
+ 'BurgeeParseOptions',
20
+ 'Command',
21
+ 'CommandOptions',
22
+ 'CommanderError',
23
+ 'DualOptions',
24
+ 'ErrorOptions',
25
+ 'ExecutableCommandOptions',
26
+ 'Help',
27
+ 'HelpConfiguration',
28
+ 'HelpContext',
29
+ 'HookEvent',
30
+ 'HookListener',
31
+ 'InvalidArgumentError',
32
+ 'InvalidOptionArgumentError',
33
+ 'Option',
34
+ 'OptionValueSource',
35
+ 'OptionValues',
36
+ 'OutputConfiguration',
37
+ 'OutputContext',
38
+ 'ParseOptions',
39
+ 'ParseOptionsResult',
40
+ 'createArgument',
41
+ 'createCommand',
42
+ 'createOption',
43
+ 'humanReadableArgName',
44
+ 'program',
45
+ 'useColor',
46
+ ],
47
+ 'burgee/yargs': [
48
+ 'Arguments',
49
+ 'ArgumentsCamelCase',
50
+ 'Argv',
51
+ 'AsyncCompletionFunction',
52
+ 'BuilderArguments',
53
+ 'BuilderCallback',
54
+ 'Choices',
55
+ 'CommandBuilder',
56
+ 'CommandModule',
57
+ 'CompletionCallback',
58
+ 'Defined',
59
+ 'DetailedArguments',
60
+ 'FallbackCompletionFunction',
61
+ 'InferredOptionType',
62
+ 'InferredOptionTypeInner',
63
+ 'InferredOptionTypePrimitive',
64
+ 'InferredOptionTypes',
65
+ 'MiddlewareFunction',
66
+ 'Options',
67
+ 'ParseCallback',
68
+ 'ParsedCommand',
69
+ 'Parser',
70
+ 'ParserConfigurationOptions',
71
+ 'PlatformShim',
72
+ 'PositionalOptions',
73
+ 'PositionalOptionsType',
74
+ 'PromiseCompletionFunction',
75
+ 'RequireDirectoryOptions',
76
+ 'SyncCompletionFunction',
77
+ 'ToArray',
78
+ 'ToNumber',
79
+ 'ToString',
80
+ 'YError',
81
+ 'YargsInstance',
82
+ 'applyExtends',
83
+ 'argsert',
84
+ 'camelCase',
85
+ 'decamelize',
86
+ 'default',
87
+ 'hideBin',
88
+ 'isPromise',
89
+ 'isYargsInstance',
90
+ 'looksLikeNumber',
91
+ 'objFilter',
92
+ 'parseCommand',
93
+ 'platformShim',
94
+ ],
95
+ 'burgee/yargs/helpers': ['Parser', 'applyExtends', 'hideBin'],
96
+ };
14
97
  const WORD = /[A-Za-z0-9_$]/;
15
98
  const DIVIDES = new Set([')', ']', '}']);
16
99
  function startsRegex(previous) {
@@ -123,12 +206,25 @@ function push(tokens, text, line) {
123
206
  if (tokens.pending && text !== QUOTED && text !== ')')
124
207
  tokens.nonLiteral.push(line);
125
208
  tokens.pending = text === '(' && (tokens.previous === 'import' || tokens.previous === 'require');
209
+ if (text === 'import' || text === 'export')
210
+ tokens.clause = [];
211
+ else if (text === '(' || text === '=' || tokens.previous === 'from')
212
+ tokens.clause = undefined;
213
+ else
214
+ tokens.clause?.push(text);
126
215
  tokens.before = tokens.previous;
127
216
  tokens.previous = text;
128
217
  }
218
+ function siteOf(source, at, tokens) {
219
+ const { open, close, line } = at;
220
+ const site = { specifier: source.slice(open + 1, close - 1), start: open + 1, end: close - 1, line };
221
+ if (tokens.previous === 'from' && tokens.clause !== undefined)
222
+ site.clause = tokens.clause.slice(0, -1);
223
+ return site;
224
+ }
129
225
  export function scan(source) {
130
226
  const sites = [];
131
- const tokens = { previous: '', before: '', pending: false, nonLiteral: [] };
227
+ const tokens = { previous: '', before: '', pending: false, nonLiteral: [], clause: undefined };
132
228
  let line = 1;
133
229
  let i = 0;
134
230
  while (i < source.length) {
@@ -143,7 +239,7 @@ export function scan(source) {
143
239
  const c = source.charAt(i);
144
240
  const quoted = c === "'" || c === '"';
145
241
  if (quoted && isSpecifier(tokens.previous, tokens.before))
146
- sites.push({ specifier: source.slice(i + 1, literal - 1), start: i + 1, end: literal - 1, line });
242
+ sites.push(siteOf(source, { open: i, close: literal, line }, tokens));
147
243
  push(tokens, quoted ? QUOTED : 'lit', line);
148
244
  line += newlines(source, i, literal);
149
245
  i = literal;
@@ -169,27 +265,71 @@ function isDeep(specifier) {
169
265
  return false;
170
266
  return HOSTS.some((host) => specifier.startsWith(`${host}/`));
171
267
  }
268
+ export function bindingsOf(clause) {
269
+ const typeOnly = clause[0] === 'type' && clause.length > 1;
270
+ const open = clause.indexOf('{');
271
+ const close = clause.indexOf('}', open);
272
+ if (open === -1 || close === -1)
273
+ return { typeOnly, names: [] };
274
+ const names = [];
275
+ let element = [];
276
+ for (const token of [...clause.slice(open + 1, close), ',']) {
277
+ if (token !== ',') {
278
+ element.push(token);
279
+ continue;
280
+ }
281
+ const modifier = element[0] === 'type' && element.length > 1 && element[1] !== 'as';
282
+ const name = modifier ? element[1] : element[0];
283
+ if (name !== undefined && name !== 'default' && name !== QUOTED && WORD.test(name.charAt(0)))
284
+ names.push(name);
285
+ element = [];
286
+ }
287
+ return { typeOnly, names };
288
+ }
289
+ function missingFrom(site, to) {
290
+ const exported = FACADE_EXPORTS[to];
291
+ if (site.clause === undefined || exported === undefined)
292
+ return { typeOnly: false, missing: [] };
293
+ const { typeOnly, names } = bindingsOf(site.clause);
294
+ return { typeOnly, missing: names.filter((name) => !exported.includes(name)) };
295
+ }
172
296
  export function rewriteSource(source) {
173
297
  if (!mentionsAHost(source))
174
- return { source, mapped: [], refused: [], relevant: false };
298
+ return { source, mapped: [], refused: [], kept: [], relevant: false };
175
299
  const { sites, nonLiteral } = scan(source);
176
300
  const hits = sites.filter((s) => MAPPING[s.specifier] !== undefined || isDeep(s.specifier));
177
301
  if (hits.length === 0)
178
- return { source, mapped: [], refused: [], relevant: false };
302
+ return { source, mapped: [], refused: [], kept: [], relevant: false };
303
+ const kept = [];
304
+ const unknown = [];
305
+ const moving = [];
306
+ for (const site of hits) {
307
+ const to = MAPPING[site.specifier];
308
+ if (to === undefined)
309
+ continue;
310
+ const { typeOnly, missing } = missingFrom(site, to);
311
+ if (missing.length === 0)
312
+ moving.push(site);
313
+ else if (typeOnly)
314
+ kept.push({ line: site.line, specifier: site.specifier, names: missing, note: `${to} does not export ${missing.join(', ')}; this type-only import stays on '${site.specifier}', so keep its types installed` });
315
+ else
316
+ unknown.push({ line: site.line, specifier: site.specifier, reason: 'unknown-export', names: missing });
317
+ }
179
318
  const refused = [
180
319
  ...hits.filter((s) => isDeep(s.specifier)).map((s) => ({ line: s.line, specifier: s.specifier, reason: 'deep-import' })),
181
320
  ...nonLiteral.map((line) => ({ line, specifier: '', reason: 'non-literal-specifier' })),
321
+ ...unknown,
182
322
  ].sort((a, b) => a.line - b.line);
183
323
  if (refused.length > 0)
184
- return { source, mapped: [], refused, relevant: true };
324
+ return { source, mapped: [], refused, kept: [], relevant: true };
185
325
  let out = source;
186
326
  const mapped = [];
187
- for (const site of [...hits].sort((a, b) => b.start - a.start)) {
327
+ for (const site of moving.sort((a, b) => b.start - a.start)) {
188
328
  const to = MAPPING[site.specifier];
189
329
  out = `${out.slice(0, site.start)}${to}${out.slice(site.end)}`;
190
330
  mapped.push({ from: site.specifier, to });
191
331
  }
192
- return { source: out, mapped: mapped.reverse(), refused: [], relevant: true };
332
+ return { source: out, mapped: mapped.reverse(), refused: [], kept, relevant: true };
193
333
  }
194
334
  const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts']);
195
335
  const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.turbo', '.next', '.output', '.cache', '.vercel']);
@@ -230,7 +370,7 @@ export class DirtyTreeError extends Error {
230
370
  this.name = 'DirtyTreeError';
231
371
  }
232
372
  }
233
- const EMPTY = { source: '', mapped: [], refused: [], relevant: false };
373
+ const EMPTY = { source: '', mapped: [], refused: [], kept: [], relevant: false };
234
374
  async function migrateBatch(dir, batch, write) {
235
375
  const sources = await Promise.all(batch.map(async (file) => await readFile(join(dir, file))));
236
376
  const results = sources.map((bytes) => (mentionsAHost(bytes) ? rewriteSource(bytes.toString('utf8')) : EMPTY));
@@ -264,9 +404,10 @@ export async function migrate(options) {
264
404
  }
265
405
  const all = results.flatMap(({ file, result }) => result.mapped.map((m) => ({ ...m, file })));
266
406
  const refused = results.flatMap(({ file, result }) => result.refused.map((r) => ({ file, ...r })));
267
- const imported = [...new Set(results.flatMap(({ result }) => (result.relevant ? result.mapped.map((m) => m.from) : [])))];
407
+ const kept = results.flatMap(({ file, result }) => result.kept.map((k) => ({ file, ...k })));
408
+ const imported = [...new Set(results.flatMap(({ result }) => (result.relevant ? [...result.mapped.map((m) => m.from), ...result.kept.map((k) => k.specifier)] : [])))];
268
409
  const declared = await declaredHosts(dir);
269
- const stillUsed = new Set(refused.map((r) => r.specifier.split('/')[0] ?? ''));
410
+ const stillUsed = new Set([...refused, ...kept].map((r) => r.specifier.split('/')[0] ?? ''));
270
411
  const removable = declared.filter((host) => !stillUsed.has(host));
271
412
  const touched = [...new Set(all.map((m) => m.file))];
272
413
  return {
@@ -274,6 +415,7 @@ export async function migrate(options) {
274
415
  imports: all.length,
275
416
  mapped: rollup(all),
276
417
  refused,
418
+ kept,
277
419
  detected: { declared, imported: [...new Set(imported.map((s) => s.split('/')[0] ?? s))].sort() },
278
420
  dependencies: { before: declared, removable, after: declared.length - removable.length },
279
421
  graded: gradedFor([...new Set([...declared, ...imported.map((s) => s.split('/')[0] ?? s)])].sort()),