lua-cli 3.42.0-beta.1 → 3.42.0-beta.3

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,813 @@
1
+ /**
2
+ * U34 (LUA-1008) — the machine-readable platform catalogue (`catalog.json`), generated from source on a
3
+ * lua-cli release. Contract: `PlatformCatalog` in `@lua/shared-types` (`platform-catalog.types.ts`) and
4
+ * docs/agent-builder/wave2-contracts.md §U34.
5
+ *
6
+ * Pure functions only: every input (paths, the status manifest, the known flags, the clock, the CLI version)
7
+ * is a parameter, so the test drives it with fixtures. The CLI wrapper is `generate-catalog.ts`.
8
+ *
9
+ * Sources, all read through the TypeScript compiler API:
10
+ * - SDK exports and their signatures: `src/api-exports.ts` (type checker);
11
+ * - CLI commands, flags and command aliases: `src/cli/command-definitions.ts` (commander call chains);
12
+ * - positional value aliases: `src/utils/aliases.ts` (`ALIAS_MAP`);
13
+ * - primitives: exported classes whose constructor takes a `…Config`;
14
+ * - limits: constants annotated `@catalogLimit <key> [unit]` anywhere under `src/`;
15
+ * - feature status: the `catalog-status.json` manifest.
16
+ */
17
+ import fs from 'fs';
18
+ import path from 'path';
19
+ import ts from 'typescript';
20
+ import type {
21
+ PlatformCatalog,
22
+ PlatformCatalogArgumentValues,
23
+ PlatformCatalogCommand,
24
+ PlatformCatalogDiff,
25
+ PlatformCatalogFeature,
26
+ PlatformCatalogLimit,
27
+ PlatformCatalogSdkEntry,
28
+ PlatformFeatureStatus,
29
+ } from '@lua/shared-types';
30
+
31
+ /** Mirrors `PLATFORM_CATALOG_VERSION` (a type-only import keeps this file free of runtime workspace deps). */
32
+ export const CATALOG_VERSION = 1 as const;
33
+
34
+ const FEATURE_STATUSES: readonly PlatformFeatureStatus[] = ['ga', 'beta', 'flag'];
35
+
36
+ export interface CatalogStatusManifest {
37
+ features: Record<
38
+ string,
39
+ { status: PlatformFeatureStatus; flag?: string; description?: string; sdk?: string[]; commands?: string[] }
40
+ >;
41
+ }
42
+
43
+ export interface GenerateCatalogInput {
44
+ /** The lua-cli package directory; `source` paths are relative to it. */
45
+ packageDir: string;
46
+ /** Package-relative paths (defaults are the real lua-cli layout). */
47
+ apiExportsFile?: string;
48
+ commandDefinitionsFile?: string;
49
+ aliasesFile?: string;
50
+ /** Directory scanned for `@catalogLimit` constants. */
51
+ limitsDir?: string;
52
+ /** The tsconfig whose options resolve the SDK program (default `<packageDir>/tsconfig.json`). */
53
+ tsconfigFile?: string;
54
+ manifest: CatalogStatusManifest;
55
+ /** Env flag names a `flag` feature may name without a warning (`WAVE2_FLAGS` + `WAVE2_REUSED_FLAGS`). */
56
+ knownFlags: readonly string[];
57
+ cliVersion: string;
58
+ now: Date;
59
+ }
60
+
61
+ export interface GenerateCatalogResult {
62
+ catalog: PlatformCatalog;
63
+ warnings: string[];
64
+ }
65
+
66
+ export class CatalogError extends Error {
67
+ constructor(message: string) {
68
+ super(message);
69
+ this.name = 'CatalogError';
70
+ }
71
+ }
72
+
73
+ const toPosix = (p: string): string => p.split(path.sep).join('/');
74
+
75
+ const byKey =
76
+ <T>(key: (t: T) => string) =>
77
+ (a: T, b: T): number =>
78
+ key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0;
79
+
80
+ export function generateCatalog(input: GenerateCatalogInput): GenerateCatalogResult {
81
+ const warnings: string[] = [];
82
+ const pkg = input.packageDir;
83
+ const apiExportsFile = path.resolve(pkg, input.apiExportsFile ?? 'src/api-exports.ts');
84
+ const commandsFile = path.resolve(pkg, input.commandDefinitionsFile ?? 'src/cli/command-definitions.ts');
85
+ const aliasesFile = path.resolve(pkg, input.aliasesFile ?? 'src/utils/aliases.ts');
86
+ const limitsDir = path.resolve(pkg, input.limitsDir ?? 'src');
87
+
88
+ const program = createProgram(path.resolve(pkg, input.tsconfigFile ?? 'tsconfig.json'), [
89
+ apiExportsFile,
90
+ commandsFile,
91
+ aliasesFile,
92
+ ]);
93
+ const checker = program.getTypeChecker();
94
+
95
+ const { sdk, primitives } = extractSdk(program, checker, apiExportsFile);
96
+ const commands = extractCommands(requireSourceFile(program, commandsFile));
97
+ const argumentValues = extractArgumentValues(requireSourceFile(program, aliasesFile), checker);
98
+ const limits = extractLimits(limitsDir, pkg);
99
+ const features = applyManifest(input.manifest, sdk, commands, input.knownFlags, warnings);
100
+
101
+ const catalog: PlatformCatalog = {
102
+ catalogVersion: CATALOG_VERSION,
103
+ cliVersion: input.cliVersion,
104
+ generatedAt: input.now.toISOString(),
105
+ sdk,
106
+ commands,
107
+ primitives,
108
+ limits,
109
+ features,
110
+ argumentValues,
111
+ };
112
+ return { catalog, warnings };
113
+ }
114
+
115
+ export function serializeCatalog(catalog: PlatformCatalog | PlatformCatalogDiff): string {
116
+ return `${JSON.stringify(catalog, null, 2)}\n`;
117
+ }
118
+
119
+ // ─── Program ───
120
+
121
+ function createProgram(tsconfigFile: string, rootNames: string[]): ts.Program {
122
+ const read = ts.readConfigFile(tsconfigFile, ts.sys.readFile);
123
+ if (read.error) {
124
+ throw new CatalogError(
125
+ `cannot read ${tsconfigFile}: ${ts.flattenDiagnosticMessageText(read.error.messageText, '\n')}`
126
+ );
127
+ }
128
+ const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(tsconfigFile));
129
+ return ts.createProgram({ rootNames, options: { ...parsed.options, noEmit: true } });
130
+ }
131
+
132
+ function requireSourceFile(program: ts.Program, file: string): ts.SourceFile {
133
+ const sf = program.getSourceFile(file);
134
+ if (!sf) throw new CatalogError(`source file not found: ${file}`);
135
+ return sf;
136
+ }
137
+
138
+ // ─── SDK exports ───
139
+
140
+ const FORMAT = ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseSingleQuotesForStringLiteralType;
141
+
142
+ function extractSdk(
143
+ program: ts.Program,
144
+ checker: ts.TypeChecker,
145
+ entryFile: string
146
+ ): { sdk: PlatformCatalogSdkEntry[]; primitives: PlatformCatalog['primitives'] } {
147
+ const sf = requireSourceFile(program, entryFile);
148
+ // A missing or stale workspace build turns every imported type into `any` without an error of its own:
149
+ // refuse instead of publishing a catalogue of `any`s.
150
+ const unresolved = program
151
+ .getSemanticDiagnostics(sf)
152
+ .filter((d) => d.code === 2307 || d.code === 2305 || d.code === 2614);
153
+ if (unresolved.length > 0) {
154
+ const lines = unresolved.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' '));
155
+ throw new CatalogError(
156
+ `${path.basename(entryFile)} has unresolved imports (run \`pnpm run build:shared\` first):\n ${lines.join('\n ')}`
157
+ );
158
+ }
159
+ const moduleSymbol = checker.getSymbolAtLocation(sf);
160
+ if (!moduleSymbol) throw new CatalogError(`${entryFile} is not a module`);
161
+
162
+ const sdk: PlatformCatalogSdkEntry[] = [];
163
+ const primitives: PlatformCatalog['primitives'] = [];
164
+ for (const exported of checker.getExportsOfModule(moduleSymbol)) {
165
+ const symbol = exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported;
166
+ const name = exported.getName();
167
+ const kind = sdkKind(symbol);
168
+ if (!kind) continue;
169
+ const signature = stripImportQualifiers(sdkSignature(checker, symbol, kind, name, sf));
170
+ const entry: PlatformCatalogSdkEntry = { name, kind, signature };
171
+ const docs = firstParagraph(ts.displayPartsToString(symbol.getDocumentationComment(checker)));
172
+ if (docs) entry.docs = docs;
173
+ sdk.push(entry);
174
+ if (kind === 'class') {
175
+ const configType = primitiveConfigType(checker, symbol, sf);
176
+ if (configType) primitives.push({ name, configType });
177
+ }
178
+ }
179
+ sdk.sort(byKey((e) => e.name));
180
+ primitives.sort(byKey((p) => p.name));
181
+ return { sdk, primitives };
182
+ }
183
+
184
+ /**
185
+ * `import("/abs/path/dist/index").X` → `X`: the checker qualifies types it cannot name at the entry file with
186
+ * the declaring module's path, which differs per machine and would make every CI diff noisy.
187
+ */
188
+ export function stripImportQualifiers(signature: string): string {
189
+ return signature.replace(/import\("[^"]*"\)\./g, '');
190
+ }
191
+
192
+ function sdkKind(symbol: ts.Symbol): PlatformCatalogSdkEntry['kind'] | undefined {
193
+ const f = symbol.flags;
194
+ if (f & ts.SymbolFlags.Class) return 'class';
195
+ if (f & ts.SymbolFlags.Function) return 'function';
196
+ if (f & (ts.SymbolFlags.Variable | ts.SymbolFlags.Enum)) return 'const';
197
+ if (f & ts.SymbolFlags.Module) return 'namespace';
198
+ if (f & ts.SymbolFlags.Interface) return 'interface';
199
+ if (f & ts.SymbolFlags.TypeAlias) return 'type';
200
+ return undefined;
201
+ }
202
+
203
+ function typeParamsText(symbol: ts.Symbol): string {
204
+ const decl = symbol.declarations?.find(
205
+ (d): d is ts.ClassDeclaration | ts.InterfaceDeclaration | ts.TypeAliasDeclaration | ts.FunctionDeclaration =>
206
+ ts.isClassDeclaration(d) ||
207
+ ts.isInterfaceDeclaration(d) ||
208
+ ts.isTypeAliasDeclaration(d) ||
209
+ ts.isFunctionDeclaration(d)
210
+ );
211
+ if (!decl?.typeParameters?.length) return '';
212
+ return `<${decl.typeParameters.map((p) => normalizeWs(p.getText())).join(', ')}>`;
213
+ }
214
+
215
+ function memberLines(checker: ts.TypeChecker, type: ts.Type, at: ts.Node): string[] {
216
+ const lines: string[] = [];
217
+ for (const sig of type.getCallSignatures()) lines.push(checker.signatureToString(sig, at, FORMAT));
218
+ for (const sig of type.getConstructSignatures()) lines.push(`new ${checker.signatureToString(sig, at, FORMAT)}`);
219
+ for (const info of checker.getIndexInfosOfType(type)) {
220
+ lines.push(
221
+ `[key: ${checker.typeToString(info.keyType, at, FORMAT)}]: ${checker.typeToString(info.type, at, FORMAT)}`
222
+ );
223
+ }
224
+ for (const prop of checker.getPropertiesOfType(type)) {
225
+ const decl = prop.valueDeclaration ?? prop.declarations?.[0];
226
+ if (decl && ts.canHaveModifiers(decl)) {
227
+ const mods = ts.getModifiers(decl) ?? [];
228
+ if (mods.some((m) => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword))
229
+ continue;
230
+ }
231
+ if (prop.getName().startsWith('#')) continue;
232
+ const optional = prop.flags & ts.SymbolFlags.Optional ? '?' : '';
233
+ const readonly =
234
+ decl && ts.getCombinedModifierFlags(decl as ts.Declaration) & ts.ModifierFlags.Readonly ? 'readonly ' : '';
235
+ lines.push(
236
+ `${readonly}${prop.getName()}${optional}: ${checker.typeToString(checker.getTypeOfSymbolAtLocation(prop, at), at, FORMAT)}`
237
+ );
238
+ }
239
+ return lines;
240
+ }
241
+
242
+ function sdkSignature(
243
+ checker: ts.TypeChecker,
244
+ symbol: ts.Symbol,
245
+ kind: PlatformCatalogSdkEntry['kind'],
246
+ name: string,
247
+ at: ts.SourceFile
248
+ ): string {
249
+ switch (kind) {
250
+ case 'function': {
251
+ const type = checker.getTypeOfSymbolAtLocation(symbol, at);
252
+ return type
253
+ .getCallSignatures()
254
+ .map((s) => `function ${name}${checker.signatureToString(s, at, FORMAT)}`)
255
+ .join('\n');
256
+ }
257
+ case 'const':
258
+ return `const ${name}: ${checker.typeToString(checker.getTypeOfSymbolAtLocation(symbol, at), at, FORMAT)}`;
259
+ case 'class': {
260
+ const staticType = checker.getTypeOfSymbolAtLocation(symbol, at);
261
+ const ctors = staticType.getConstructSignatures().map((s) => `new ${checker.signatureToString(s, at, FORMAT)}`);
262
+ const members = memberLines(checker, checker.getDeclaredTypeOfSymbol(symbol), at);
263
+ return `class ${name}${typeParamsText(symbol)} { ${[...new Set(ctors), ...members].join('; ')} }`;
264
+ }
265
+ case 'interface':
266
+ return `interface ${name}${typeParamsText(symbol)} { ${memberLines(checker, checker.getDeclaredTypeOfSymbol(symbol), at).join('; ')} }`;
267
+ case 'type': {
268
+ const decl = symbol.declarations?.find(ts.isTypeAliasDeclaration);
269
+ const expanded = checker.typeToString(
270
+ checker.getDeclaredTypeOfSymbol(symbol),
271
+ at,
272
+ FORMAT | ts.TypeFormatFlags.InTypeAlias
273
+ );
274
+ const body = expanded !== name || !decl ? expanded : normalizeWs(decl.type.getText());
275
+ return `type ${name}${typeParamsText(symbol)} = ${body}`;
276
+ }
277
+ case 'namespace':
278
+ return `namespace ${name}`;
279
+ }
280
+ }
281
+
282
+ function primitiveConfigType(checker: ts.TypeChecker, symbol: ts.Symbol, at: ts.SourceFile): string | undefined {
283
+ const staticType = checker.getTypeOfSymbolAtLocation(symbol, at);
284
+ for (const sig of staticType.getConstructSignatures()) {
285
+ const first = sig.getParameters()[0];
286
+ if (!first) continue;
287
+ const t = checker.getTypeOfSymbolAtLocation(first, at);
288
+ const typeName = (t.aliasSymbol ?? t.getSymbol())?.getName();
289
+ if (typeName && /Config$/.test(typeName)) return typeName;
290
+ }
291
+ return undefined;
292
+ }
293
+
294
+ function firstParagraph(text: string): string | undefined {
295
+ const para = text
296
+ .trim()
297
+ .split(/\n\s*\n/)[0]
298
+ ?.trim();
299
+ return para ? normalizeWs(para) : undefined;
300
+ }
301
+
302
+ function normalizeWs(text: string): string {
303
+ return text.replace(/\s+/g, ' ').trim();
304
+ }
305
+
306
+ // ─── Commands (commander call chains) ───
307
+
308
+ interface ChainLink {
309
+ method: string;
310
+ args: ts.NodeArray<ts.Expression>;
311
+ }
312
+
313
+ /** Unwinds `a.b(x).c(y)` into its root expression and `[b(x), c(y)]`. */
314
+ function flattenChain(call: ts.CallExpression): { root: ts.Expression; links: ChainLink[] } | undefined {
315
+ const links: ChainLink[] = [];
316
+ let expr: ts.Expression = call;
317
+ while (ts.isCallExpression(expr) && ts.isPropertyAccessExpression(expr.expression)) {
318
+ links.unshift({ method: expr.expression.name.text, args: expr.arguments });
319
+ expr = expr.expression.expression;
320
+ }
321
+ if (links.length === 0) return undefined;
322
+ return { root: expr, links };
323
+ }
324
+
325
+ function stringValue(node: ts.Expression | undefined): string | undefined {
326
+ if (!node) return undefined;
327
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
328
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
329
+ const l = stringValue(node.left);
330
+ const r = stringValue(node.right);
331
+ return l !== undefined && r !== undefined ? l + r : undefined;
332
+ }
333
+ if (ts.isParenthesizedExpression(node)) return stringValue(node.expression);
334
+ return undefined;
335
+ }
336
+
337
+ function parseCommandSpec(spec: string): { name: string; args: NonNullable<PlatformCatalogCommand['arguments']> } {
338
+ const [name, ...rest] = spec.trim().split(/\s+/);
339
+ return { name, args: rest.map(parseArgumentToken).filter((a): a is NonNullable<typeof a> => !!a) };
340
+ }
341
+
342
+ function parseArgumentToken(token: string): { name: string; required: boolean; variadic?: boolean } | undefined {
343
+ const m = /^([<[])(.+?)(\.\.\.)?([>\]])$/.exec(token);
344
+ if (!m) return undefined;
345
+ const arg: { name: string; required: boolean; variadic?: boolean } = { name: m[2], required: m[1] === '<' };
346
+ if (m[3]) arg.variadic = true;
347
+ return arg;
348
+ }
349
+
350
+ function parseFlags(
351
+ spec: string,
352
+ description: string | undefined,
353
+ required: boolean
354
+ ): PlatformCatalogCommand['flags'][number] {
355
+ const tokens = spec.split(/[\s,|]+/).filter(Boolean);
356
+ const longs = tokens.filter((t) => t.startsWith('--'));
357
+ const shorts = tokens.filter((t) => /^-[^-]/.test(t));
358
+ const takesValue = tokens.some((t) => /^[<[]/.test(t));
359
+ const name = longs[0] ?? shorts[0] ?? spec;
360
+ const aliases = [...longs.slice(1), ...shorts].filter((a) => a !== name);
361
+ const flag: PlatformCatalogCommand['flags'][number] = { name, takesValue };
362
+ if (aliases.length) flag.aliases = aliases;
363
+ if (description) flag.description = normalizeWs(description);
364
+ if (required) flag.required = true;
365
+ return flag;
366
+ }
367
+
368
+ export function extractCommands(sf: ts.SourceFile): PlatformCatalogCommand[] {
369
+ const commands = new Map<string, PlatformCatalogCommand>();
370
+ // Command variables are tracked per enclosing function (`const logsGroup = program.command('logs')`).
371
+ const scopes = new Map<ts.Node, Map<string, string[]>>();
372
+
373
+ const scopeOf = (node: ts.Node): ts.Node => {
374
+ let n: ts.Node | undefined = node.parent;
375
+ while (n && !ts.isFunctionLike(n) && !ts.isSourceFile(n)) n = n.parent;
376
+ return n ?? sf;
377
+ };
378
+ const lookup = (node: ts.Node, name: string): string[] | undefined => {
379
+ let scope: ts.Node | undefined = scopeOf(node);
380
+ while (scope) {
381
+ const hit = scopes.get(scope)?.get(name);
382
+ if (hit) return hit;
383
+ if (ts.isSourceFile(scope)) break;
384
+ scope = scopeOf(scope);
385
+ }
386
+ return undefined;
387
+ };
388
+ const ensure = (p: string[]): PlatformCatalogCommand => {
389
+ const key = p.join(' ');
390
+ let cmd = commands.get(key);
391
+ if (!cmd) {
392
+ cmd = { name: key, aliases: [], flags: [] };
393
+ commands.set(key, cmd);
394
+ }
395
+ return cmd;
396
+ };
397
+
398
+ const processChain = (call: ts.CallExpression): string[] | undefined => {
399
+ const chain = flattenChain(call);
400
+ if (!chain) return undefined;
401
+ const createsCommand = chain.links.some((l) => l.method === 'command' && stringValue(l.args[0]) !== undefined);
402
+ const rootPath = ts.isIdentifier(chain.root) ? lookup(call, chain.root.text) : undefined;
403
+ // Only commander chains: rooted at a known command variable, or creating a command (then an unknown
404
+ // root identifier is the `program` parameter).
405
+ if (!rootPath && !(createsCommand && ts.isIdentifier(chain.root))) return undefined;
406
+ let current: string[] = rootPath ?? [];
407
+ for (const link of chain.links) {
408
+ const first = link.args[0];
409
+ switch (link.method) {
410
+ case 'command': {
411
+ const spec = stringValue(first);
412
+ if (spec === undefined) return undefined;
413
+ const { name, args } = parseCommandSpec(spec);
414
+ current = [...current, name];
415
+ const cmd = ensure(current);
416
+ if (args.length) cmd.arguments = args;
417
+ break;
418
+ }
419
+ case 'description': {
420
+ const text = stringValue(first);
421
+ if (text !== undefined && current.length) ensure(current).description = normalizeWs(text);
422
+ break;
423
+ }
424
+ case 'alias': {
425
+ const alias = stringValue(first);
426
+ if (alias !== undefined && current.length) ensure(current).aliases.push(alias);
427
+ break;
428
+ }
429
+ case 'aliases': {
430
+ if (first && ts.isArrayLiteralExpression(first) && current.length) {
431
+ for (const el of first.elements) {
432
+ const alias = stringValue(el);
433
+ if (alias !== undefined) ensure(current).aliases.push(alias);
434
+ }
435
+ }
436
+ break;
437
+ }
438
+ case 'argument': {
439
+ const spec = stringValue(first);
440
+ const arg = spec ? parseArgumentToken(spec.trim()) : undefined;
441
+ if (arg && current.length) {
442
+ const cmd = ensure(current);
443
+ cmd.arguments = [...(cmd.arguments ?? []), arg];
444
+ }
445
+ break;
446
+ }
447
+ case 'option':
448
+ case 'requiredOption': {
449
+ const spec = stringValue(first);
450
+ if (spec !== undefined && current.length) {
451
+ ensure(current).flags.push(parseFlags(spec, stringValue(link.args[1]), link.method === 'requiredOption'));
452
+ }
453
+ break;
454
+ }
455
+ case 'addOption': {
456
+ if (first && ts.isNewExpression(first) && current.length) {
457
+ const spec = stringValue(first.arguments?.[0]);
458
+ if (spec !== undefined)
459
+ ensure(current).flags.push(parseFlags(spec, stringValue(first.arguments?.[1]), false));
460
+ }
461
+ break;
462
+ }
463
+ default:
464
+ break;
465
+ }
466
+ }
467
+ return current.length ? current : undefined;
468
+ };
469
+
470
+ const visit = (node: ts.Node): void => {
471
+ // The outermost call of a chain: its parent is not `.x` on it, and it is not the callee of another call.
472
+ if (ts.isCallExpression(node) && !ts.isPropertyAccessExpression(node.parent)) {
473
+ const result = processChain(node);
474
+ if (result && ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name)) {
475
+ const scope = scopeOf(node.parent);
476
+ if (!scopes.has(scope)) scopes.set(scope, new Map());
477
+ scopes.get(scope)!.set(node.parent.name.text, result);
478
+ }
479
+ }
480
+ ts.forEachChild(node, visit);
481
+ };
482
+ visit(sf);
483
+
484
+ const out = [...commands.values()];
485
+ for (const cmd of out) {
486
+ cmd.aliases = [...new Set(cmd.aliases)].sort();
487
+ const seen = new Set<string>();
488
+ cmd.flags = cmd.flags
489
+ .filter((f) => (seen.has(f.name) ? false : (seen.add(f.name), true)))
490
+ .sort(byKey((f) => f.name));
491
+ }
492
+ return out.sort(byKey((c) => c.name));
493
+ }
494
+
495
+ // ─── Positional value aliases (`ALIAS_MAP`) ───
496
+
497
+ function stringsOfType(checker: ts.TypeChecker, type: ts.Type): string[] | undefined {
498
+ if (type.isStringLiteral()) return [type.value];
499
+ if (type.isUnion()) {
500
+ const parts = type.types.map((t) => stringsOfType(checker, t));
501
+ return parts.every((p): p is string[] => !!p) ? parts.flat() : undefined;
502
+ }
503
+ if (checker.isTupleType(type) || checker.isArrayType(type)) {
504
+ const args = checker.getTypeArguments(type as ts.TypeReference);
505
+ const parts = args.map((t) => stringsOfType(checker, t));
506
+ return parts.every((p): p is string[] => !!p) ? parts.flat() : undefined;
507
+ }
508
+ return undefined;
509
+ }
510
+
511
+ function evalStringArray(node: ts.Expression, checker: ts.TypeChecker): string[] | undefined {
512
+ if (!ts.isArrayLiteralExpression(node)) return stringsOfType(checker, checker.getTypeAtLocation(node));
513
+ const out: string[] = [];
514
+ for (const el of node.elements) {
515
+ const s = stringValue(el as ts.Expression);
516
+ if (s !== undefined) {
517
+ out.push(s);
518
+ continue;
519
+ }
520
+ const inner = ts.isSpreadElement(el) ? el.expression : el;
521
+ const resolved = stringsOfType(checker, checker.getTypeAtLocation(inner));
522
+ if (!resolved) return undefined;
523
+ out.push(...resolved);
524
+ }
525
+ return out;
526
+ }
527
+
528
+ function propertyKey(name: ts.PropertyName): string | undefined {
529
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text;
530
+ return undefined;
531
+ }
532
+
533
+ export function extractArgumentValues(
534
+ sf: ts.SourceFile,
535
+ checker: ts.TypeChecker,
536
+ mapName = 'ALIAS_MAP'
537
+ ): PlatformCatalogArgumentValues[] {
538
+ const decls = new Map<string, ts.Expression>();
539
+ const collect = (node: ts.Node): void => {
540
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
541
+ decls.set(node.name.text, node.initializer);
542
+ }
543
+ ts.forEachChild(node, collect);
544
+ };
545
+ collect(sf);
546
+ const map = decls.get(mapName);
547
+ if (!map || !ts.isObjectLiteralExpression(map)) {
548
+ throw new CatalogError(`${sf.fileName}: no \`${mapName}\` object literal`);
549
+ }
550
+ const resolveObject = (expr: ts.Expression): ts.ObjectLiteralExpression | undefined => {
551
+ let e: ts.Expression = expr;
552
+ while (ts.isAsExpression(e) || ts.isSatisfiesExpression(e) || ts.isParenthesizedExpression(e)) e = e.expression;
553
+ if (ts.isObjectLiteralExpression(e)) return e;
554
+ if (ts.isIdentifier(e)) {
555
+ const target = decls.get(e.text);
556
+ return target ? resolveObject(target) : undefined;
557
+ }
558
+ return undefined;
559
+ };
560
+
561
+ const out: PlatformCatalogArgumentValues[] = [];
562
+ for (const prop of map.properties) {
563
+ if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue;
564
+ const group = propertyKey(prop.name);
565
+ if (group === undefined) continue;
566
+ const entry = resolveObject(ts.isPropertyAssignment(prop) ? prop.initializer : prop.name);
567
+ if (!entry) throw new CatalogError(`${mapName}['${group}']: cannot resolve the entry statically`);
568
+ let canonical: string[] | undefined;
569
+ const aliases: Record<string, string> = {};
570
+ for (const field of entry.properties) {
571
+ if (!ts.isPropertyAssignment(field)) continue;
572
+ const key = propertyKey(field.name);
573
+ if (key === 'canonical') canonical = evalStringArray(field.initializer, checker);
574
+ if (key === 'aliases') {
575
+ let obj: ts.Expression = field.initializer;
576
+ let lower = false;
577
+ if (ts.isCallExpression(obj) && obj.arguments[0]) {
578
+ lower = ts.isIdentifier(obj.expression) && obj.expression.text === 'lowerKeys';
579
+ obj = obj.arguments[0];
580
+ }
581
+ const literal = resolveObject(obj);
582
+ if (!literal) throw new CatalogError(`${mapName}['${group}'].aliases: cannot resolve statically`);
583
+ for (const a of literal.properties) {
584
+ if (!ts.isPropertyAssignment(a)) continue;
585
+ const k = propertyKey(a.name);
586
+ const v = stringValue(a.initializer);
587
+ if (k !== undefined && v !== undefined) aliases[lower ? k.toLowerCase() : k] = v;
588
+ }
589
+ }
590
+ }
591
+ if (!canonical) throw new CatalogError(`${mapName}['${group}'].canonical: cannot resolve statically`);
592
+ out.push({ group, canonical: [...new Set(canonical)].sort(), aliases: sortRecord(aliases) });
593
+ }
594
+ return out.sort(byKey((g) => g.group));
595
+ }
596
+
597
+ function sortRecord<T>(rec: Record<string, T>): Record<string, T> {
598
+ return Object.fromEntries(Object.entries(rec).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
599
+ }
600
+
601
+ // ─── Limits (`@catalogLimit <key> [unit]`) ───
602
+
603
+ function listTsFiles(dir: string): string[] {
604
+ const out: string[] = [];
605
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
606
+ const full = path.join(dir, entry.name);
607
+ if (entry.isDirectory()) {
608
+ if (entry.name === '__tests__' || entry.name === 'node_modules') continue;
609
+ out.push(...listTsFiles(full));
610
+ } else if (/\.ts$/.test(entry.name) && !/\.(d|spec|test)\.ts$/.test(entry.name)) {
611
+ out.push(full);
612
+ }
613
+ }
614
+ return out.sort();
615
+ }
616
+
617
+ /** Evaluates a constant initializer: number/string literals, unary minus, + - * / %, parens, `as const`. */
618
+ export function evalConstant(node: ts.Expression): number | string | undefined {
619
+ if (ts.isNumericLiteral(node)) return Number(node.text.replace(/_/g, ''));
620
+ const s = stringValue(node);
621
+ if (s !== undefined) return s;
622
+ if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) {
623
+ return evalConstant(node.expression);
624
+ }
625
+ if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.MinusToken) {
626
+ const v = evalConstant(node.operand);
627
+ return typeof v === 'number' ? -v : undefined;
628
+ }
629
+ if (ts.isBinaryExpression(node)) {
630
+ const l = evalConstant(node.left);
631
+ const r = evalConstant(node.right);
632
+ if (typeof l !== 'number' || typeof r !== 'number') return undefined;
633
+ switch (node.operatorToken.kind) {
634
+ case ts.SyntaxKind.PlusToken:
635
+ return l + r;
636
+ case ts.SyntaxKind.MinusToken:
637
+ return l - r;
638
+ case ts.SyntaxKind.AsteriskToken:
639
+ return l * r;
640
+ case ts.SyntaxKind.SlashToken:
641
+ return l / r;
642
+ case ts.SyntaxKind.PercentToken:
643
+ return l % r;
644
+ default:
645
+ return undefined;
646
+ }
647
+ }
648
+ return undefined;
649
+ }
650
+
651
+ export function extractLimits(dir: string, packageDir: string): PlatformCatalogLimit[] {
652
+ const limits = new Map<string, PlatformCatalogLimit>();
653
+ for (const file of listTsFiles(dir)) {
654
+ const text = fs.readFileSync(file, 'utf8');
655
+ if (!text.includes('@catalogLimit')) continue;
656
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
657
+ const rel = toPosix(path.relative(packageDir, file));
658
+ const visit = (node: ts.Node): void => {
659
+ if (ts.isVariableDeclaration(node)) {
660
+ const tag = ts.getJSDocTags(node).find((t) => t.tagName.text === 'catalogLimit');
661
+ if (tag) {
662
+ const comment = typeof tag.comment === 'string' ? tag.comment : ts.getTextOfJSDocComment(tag.comment);
663
+ const [key, unit] = (comment ?? '').trim().split(/\s+/);
664
+ const where = `${rel}:${sf.getLineAndCharacterOfPosition(node.getStart()).line + 1}`;
665
+ if (!key) throw new CatalogError(`${where}: @catalogLimit needs a key (\`@catalogLimit <key> [unit]\`)`);
666
+ const value = node.initializer ? evalConstant(node.initializer) : undefined;
667
+ if (value === undefined)
668
+ throw new CatalogError(`${where}: @catalogLimit ${key}: initializer is not a constant`);
669
+ if (limits.has(key)) throw new CatalogError(`${where}: duplicate @catalogLimit key '${key}'`);
670
+ const limit: PlatformCatalogLimit = { key, value, source: rel };
671
+ if (unit) limit.unit = unit;
672
+ const jsDoc = ts.getJSDocCommentsAndTags(node).find(ts.isJSDoc);
673
+ const description = jsDoc?.comment
674
+ ? firstParagraph(ts.getTextOfJSDocComment(jsDoc.comment) ?? '')
675
+ : undefined;
676
+ if (description) limit.description = description;
677
+ limits.set(key, limit);
678
+ }
679
+ }
680
+ ts.forEachChild(node, visit);
681
+ };
682
+ visit(sf);
683
+ }
684
+ return [...limits.values()].sort(byKey((l) => l.key));
685
+ }
686
+
687
+ // ─── Feature status (`catalog-status.json`) ───
688
+
689
+ export function applyManifest(
690
+ manifest: CatalogStatusManifest,
691
+ sdk: PlatformCatalogSdkEntry[],
692
+ commands: PlatformCatalogCommand[],
693
+ knownFlags: readonly string[],
694
+ warnings: string[]
695
+ ): PlatformCatalogFeature[] {
696
+ if (!manifest || typeof manifest.features !== 'object' || manifest.features === null) {
697
+ throw new CatalogError('catalog-status.json: `features` must be an object');
698
+ }
699
+ const sdkByName = new Map(sdk.map((e) => [e.name, e]));
700
+ const commandNames = new Set(commands.map((c) => c.name));
701
+ const known = new Set(knownFlags);
702
+ const errors: string[] = [];
703
+ const features: PlatformCatalogFeature[] = [];
704
+
705
+ for (const [key, spec] of Object.entries(manifest.features)) {
706
+ if (!FEATURE_STATUSES.includes(spec.status)) {
707
+ errors.push(
708
+ `feature '${key}': status must be one of ${FEATURE_STATUSES.join(', ')} (got '${String(spec.status)}')`
709
+ );
710
+ continue;
711
+ }
712
+ if (spec.status === 'flag' && !spec.flag) errors.push(`feature '${key}': status 'flag' needs a \`flag\``);
713
+ if (spec.status !== 'flag' && spec.flag) errors.push(`feature '${key}': \`flag\` is only valid with status 'flag'`);
714
+ if (spec.flag && !known.has(spec.flag)) {
715
+ warnings.push(`feature '${key}': flag '${spec.flag}' is not a WAVE2_FLAGS / WAVE2_REUSED_FLAGS name`);
716
+ }
717
+ const feature: PlatformCatalogFeature = { key, status: spec.status };
718
+ if (spec.flag) feature.flag = spec.flag;
719
+ if (spec.description) feature.description = normalizeWs(spec.description);
720
+ if (spec.sdk?.length) {
721
+ for (const name of spec.sdk) {
722
+ const entry = sdkByName.get(name);
723
+ if (!entry) {
724
+ errors.push(`feature '${key}': sdk export '${name}' does not exist in api-exports.ts`);
725
+ continue;
726
+ }
727
+ if (entry.status && entry.status !== spec.status) {
728
+ errors.push(`sdk export '${name}' is claimed by two features with different statuses`);
729
+ }
730
+ entry.status = spec.status;
731
+ }
732
+ feature.sdk = [...spec.sdk].sort();
733
+ }
734
+ if (spec.commands?.length) {
735
+ for (const name of spec.commands) {
736
+ if (!commandNames.has(name)) errors.push(`feature '${key}': command '${name}' does not exist`);
737
+ }
738
+ feature.commands = [...spec.commands].sort();
739
+ }
740
+ features.push(feature);
741
+ }
742
+ if (errors.length) throw new CatalogError(`catalog-status.json is invalid:\n ${errors.join('\n ')}`);
743
+ return features.sort(byKey((f) => f.key));
744
+ }
745
+
746
+ // ─── Diff ───
747
+
748
+ function stableStringify(value: unknown): string {
749
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
750
+ if (value && typeof value === 'object') {
751
+ return `{${Object.keys(value as Record<string, unknown>)
752
+ .sort()
753
+ .filter((k) => (value as Record<string, unknown>)[k] !== undefined)
754
+ .map((k) => `${JSON.stringify(k)}:${stableStringify((value as Record<string, unknown>)[k])}`)
755
+ .join(',')}}`;
756
+ }
757
+ return JSON.stringify(value);
758
+ }
759
+
760
+ /** Flattens a catalogue into stable ids → comparable JSON. */
761
+ export function catalogIndex(catalog: PlatformCatalog): Map<string, string> {
762
+ const index = new Map<string, string>();
763
+ for (const e of catalog.sdk ?? []) index.set(`sdk:${e.name}`, stableStringify(e));
764
+ for (const c of catalog.commands ?? []) {
765
+ const { flags, ...rest } = c;
766
+ index.set(`command:${c.name}`, stableStringify(rest));
767
+ for (const f of flags ?? []) index.set(`flag:${c.name} ${f.name}`, stableStringify(f));
768
+ }
769
+ for (const g of catalog.argumentValues ?? []) index.set(`args:${g.group}`, stableStringify(g));
770
+ for (const p of catalog.primitives ?? []) index.set(`primitive:${p.name}`, stableStringify(p));
771
+ for (const l of catalog.limits ?? []) index.set(`limit:${l.key}`, stableStringify(l));
772
+ for (const f of catalog.features ?? []) index.set(`feature:${f.key}`, stableStringify(f));
773
+ return index;
774
+ }
775
+
776
+ export function diffCatalogs(previous: PlatformCatalog | null, next: PlatformCatalog): PlatformCatalogDiff {
777
+ const before = previous ? catalogIndex(previous) : new Map<string, string>();
778
+ const after = catalogIndex(next);
779
+ const added: string[] = [];
780
+ const removed: string[] = [];
781
+ const changed: string[] = [];
782
+ for (const [id, value] of after) {
783
+ if (!before.has(id)) added.push(id);
784
+ else if (before.get(id) !== value) changed.push(id);
785
+ }
786
+ for (const id of before.keys()) if (!after.has(id)) removed.push(id);
787
+ return {
788
+ fromVersion: previous?.cliVersion ?? '',
789
+ toVersion: next.cliVersion,
790
+ added: added.sort(),
791
+ removed: removed.sort(),
792
+ changed: changed.sort(),
793
+ };
794
+ }
795
+
796
+ export function renderDiffMarkdown(diff: PlatformCatalogDiff, maxPerSection = 200): string {
797
+ const lines = [
798
+ `### Platform catalogue: ${diff.fromVersion ? `v${diff.fromVersion}` : '(no previous catalogue)'} → v${diff.toVersion}`,
799
+ '',
800
+ `${diff.added.length} added, ${diff.removed.length} removed, ${diff.changed.length} changed.`,
801
+ ];
802
+ for (const [title, ids] of [
803
+ ['Added', diff.added],
804
+ ['Removed', diff.removed],
805
+ ['Changed', diff.changed],
806
+ ] as const) {
807
+ if (!ids.length) continue;
808
+ lines.push('', `#### ${title}`, '');
809
+ for (const id of ids.slice(0, maxPerSection)) lines.push(`- \`${id}\``);
810
+ if (ids.length > maxPerSection) lines.push(`- … and ${ids.length - maxPerSection} more`);
811
+ }
812
+ return `${lines.join('\n')}\n`;
813
+ }