Skip to content

Commit 2bde450

Browse files
committed
feat(cli): add --emit-declarations option for build command
Add --emit-declarations flag to generate TypeScript declaration files during persona build. Declaration files are written to declarations/ subdirectory adjacent to the output markdown file. Changes: - Add emitDeclarations option to BuildOptions interface - Add writeDeclarationFiles() helper for declaration output - Refactor writeBuildOutput() to handle markdown, report, and declarations - Register -d, --emit-declarations flag in CLI Also writes build report JSON alongside markdown output. See UMS v2.2 Specification Section 6.2.
1 parent 5a6ebb2 commit 2bde450

File tree

5 files changed

+295
-27
lines changed

5 files changed

+295
-27
lines changed

packages/ums-cli/src/commands/build.test.ts

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { mkdir } from 'node:fs/promises';
23
import { writeOutputFile } from '../utils/file-operations.js';
34
import { handleBuild } from './build.js';
45
import type * as UmsSdk from 'ums-sdk';
@@ -10,6 +11,11 @@ import {
1011
type BuildReport,
1112
} from 'ums-sdk';
1213

14+
// Mock node:fs/promises
15+
vi.mock('node:fs/promises', () => ({
16+
mkdir: vi.fn(),
17+
}));
18+
1319
// Mock dependencies
1420
vi.mock('chalk', () => ({
1521
default: {
@@ -295,4 +301,200 @@ describe('build command', () => {
295301

296302
mockConsoleLog.mockRestore();
297303
});
304+
305+
describe('emit-declarations flag (v2.2)', () => {
306+
const mockMkdir = vi.mocked(mkdir);
307+
308+
beforeEach(() => {
309+
mockMkdir.mockResolvedValue(undefined);
310+
});
311+
312+
it('should pass emitDeclarations to SDK when flag is set', async () => {
313+
// Arrange
314+
const options = {
315+
persona: 'test.persona.ts',
316+
output: 'output.md',
317+
verbose: false,
318+
emitDeclarations: true,
319+
};
320+
321+
// Act
322+
await handleBuild(options);
323+
324+
// Assert
325+
expect(mockBuildPersona).toHaveBeenCalledWith('test.persona.ts', {
326+
includeStandard: true,
327+
emitDeclarations: true,
328+
});
329+
});
330+
331+
it('should not pass emitDeclarations when flag is false', async () => {
332+
// Arrange
333+
const options = {
334+
persona: 'test.persona.ts',
335+
output: 'output.md',
336+
verbose: false,
337+
emitDeclarations: false,
338+
};
339+
340+
// Act
341+
await handleBuild(options);
342+
343+
// Assert
344+
expect(mockBuildPersona).toHaveBeenCalledWith('test.persona.ts', {
345+
includeStandard: true,
346+
});
347+
});
348+
349+
it('should write declaration files to declarations/ subdirectory', async () => {
350+
// Arrange
351+
const mockDeclarations = [
352+
{
353+
path: '/path/to/module1.module.d.ts',
354+
content: 'declare const module1: Module;',
355+
},
356+
{
357+
path: '/path/to/module2.module.d.ts',
358+
content: 'declare const module2: Module;',
359+
},
360+
];
361+
362+
const resultWithDeclarations: BuildResult = {
363+
markdown: '# Test Persona',
364+
persona: mockPersona,
365+
modules: mockModules,
366+
buildReport: mockBuildReport,
367+
warnings: [],
368+
declarations: mockDeclarations,
369+
};
370+
371+
mockBuildPersona.mockResolvedValue(resultWithDeclarations);
372+
373+
const options = {
374+
persona: 'test.persona.ts',
375+
output: '/output/persona.md',
376+
verbose: false,
377+
emitDeclarations: true,
378+
};
379+
380+
// Act
381+
await handleBuild(options);
382+
383+
// Assert
384+
expect(mockMkdir).toHaveBeenCalledWith('/output/declarations', {
385+
recursive: true,
386+
});
387+
expect(mockWriteOutputFile).toHaveBeenCalledWith(
388+
'/output/declarations/module1.module.d.ts',
389+
'declare const module1: Module;'
390+
);
391+
expect(mockWriteOutputFile).toHaveBeenCalledWith(
392+
'/output/declarations/module2.module.d.ts',
393+
'declare const module2: Module;'
394+
);
395+
});
396+
397+
it('should not write declarations when emitDeclarations is false', async () => {
398+
// Arrange
399+
const options = {
400+
persona: 'test.persona.ts',
401+
output: '/output/persona.md',
402+
verbose: false,
403+
emitDeclarations: false,
404+
};
405+
406+
// Act
407+
await handleBuild(options);
408+
409+
// Assert - should only write markdown and build report, not declarations
410+
expect(mockWriteOutputFile).toHaveBeenCalledTimes(2);
411+
expect(mockMkdir).not.toHaveBeenCalled();
412+
});
413+
414+
it('should not write declarations when no output path specified', async () => {
415+
// Arrange
416+
const mockDeclarations = [
417+
{
418+
path: '/path/to/module1.module.d.ts',
419+
content: 'declare const module1: Module;',
420+
},
421+
];
422+
423+
const resultWithDeclarations: BuildResult = {
424+
markdown: '# Test Persona',
425+
persona: mockPersona,
426+
modules: mockModules,
427+
buildReport: mockBuildReport,
428+
warnings: [],
429+
declarations: mockDeclarations,
430+
};
431+
432+
mockBuildPersona.mockResolvedValue(resultWithDeclarations);
433+
434+
const mockConsoleLog = vi
435+
.spyOn(console, 'log')
436+
.mockImplementation(() => {});
437+
438+
const options = {
439+
persona: 'test.persona.ts',
440+
verbose: false,
441+
emitDeclarations: true,
442+
// No output - writes to stdout
443+
};
444+
445+
// Act
446+
await handleBuild(options);
447+
448+
// Assert - declarations not written when no output path
449+
expect(mockMkdir).not.toHaveBeenCalled();
450+
expect(mockWriteOutputFile).not.toHaveBeenCalled();
451+
452+
mockConsoleLog.mockRestore();
453+
});
454+
455+
it('should log generated declaration files in verbose mode', async () => {
456+
// Arrange
457+
const mockDeclarations = [
458+
{
459+
path: '/path/to/module1.module.d.ts',
460+
content: 'declare const module1: Module;',
461+
},
462+
];
463+
464+
const resultWithDeclarations: BuildResult = {
465+
markdown: '# Test Persona',
466+
persona: mockPersona,
467+
modules: mockModules,
468+
buildReport: mockBuildReport,
469+
warnings: [],
470+
declarations: mockDeclarations,
471+
};
472+
473+
mockBuildPersona.mockResolvedValue(resultWithDeclarations);
474+
475+
const mockConsoleLog = vi
476+
.spyOn(console, 'log')
477+
.mockImplementation(() => {});
478+
479+
const options = {
480+
persona: 'test.persona.ts',
481+
output: '/output/persona.md',
482+
verbose: true,
483+
emitDeclarations: true,
484+
};
485+
486+
// Act
487+
await handleBuild(options);
488+
489+
// Assert
490+
expect(mockConsoleLog).toHaveBeenCalledWith(
491+
expect.stringContaining('Generated:')
492+
);
493+
expect(mockConsoleLog).toHaveBeenCalledWith(
494+
expect.stringContaining('declaration files')
495+
);
496+
497+
mockConsoleLog.mockRestore();
498+
});
499+
});
298500
});

packages/ums-cli/src/commands/build.ts

Lines changed: 77 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
* Uses SDK's buildPersona() for all build orchestration.
77
*/
88

9+
import { mkdir } from 'node:fs/promises';
10+
import { dirname, join, basename } from 'node:path';
911
import chalk from 'chalk';
1012
import { handleError } from '../utils/error-handler.js';
1113
import { buildPersona } from 'ums-sdk';
@@ -22,13 +24,76 @@ export interface BuildOptions {
2224
output?: string;
2325
/** Enable verbose output */
2426
verbose?: boolean;
27+
/** Emit TypeScript declaration files (.d.ts) for modules */
28+
emitDeclarations?: boolean;
29+
}
30+
31+
/**
32+
* Write declaration files to declarations/ subdirectory
33+
*/
34+
async function writeDeclarationFiles(
35+
declarations: { path: string; content: string }[],
36+
outputPath: string,
37+
verbose: boolean
38+
): Promise<void> {
39+
const declarationsDir = join(dirname(outputPath), 'declarations');
40+
await mkdir(declarationsDir, { recursive: true });
41+
42+
for (const decl of declarations) {
43+
const declPath = join(declarationsDir, basename(decl.path));
44+
await writeOutputFile(declPath, decl.content);
45+
if (verbose) console.log(chalk.gray(` Generated: ${declPath}`));
46+
}
47+
console.log(
48+
chalk.green(`✓ Generated ${declarations.length} declaration files`)
49+
);
50+
}
51+
52+
/**
53+
* Write build output files (markdown, report, declarations)
54+
*/
55+
async function writeBuildOutput(
56+
result: Awaited<ReturnType<typeof buildPersona>>,
57+
outputPath: string,
58+
emitDeclarations: boolean,
59+
verbose: boolean
60+
): Promise<void> {
61+
// Write markdown file
62+
await writeOutputFile(outputPath, result.markdown);
63+
console.log(chalk.green(`✓ Persona instructions written to: ${outputPath}`));
64+
65+
// Write build report JSON file
66+
const buildReportPath = outputPath.replace(/\.md$/, '.build.json');
67+
await writeOutputFile(
68+
buildReportPath,
69+
JSON.stringify(result.buildReport, null, 2)
70+
);
71+
console.log(chalk.green(`✓ Build report written to: ${buildReportPath}`));
72+
73+
// Write declaration files (v2.2)
74+
if (emitDeclarations && result.declarations?.length) {
75+
await writeDeclarationFiles(result.declarations, outputPath, verbose);
76+
}
77+
78+
if (verbose) {
79+
console.log(
80+
chalk.gray(
81+
`[INFO] build: Generated ${result.markdown.length} characters of Markdown`
82+
)
83+
);
84+
}
2585
}
2686

2787
/**
2888
* Handles the 'build' command
2989
*/
3090
export async function handleBuild(options: BuildOptions): Promise<void> {
31-
const { persona: personaPath, output: outputPath, verbose } = options;
91+
const {
92+
persona: personaPath,
93+
output: outputPath,
94+
verbose,
95+
emitDeclarations,
96+
} = options;
3297
const progress = createBuildProgress('build', verbose);
3398

3499
try {
@@ -45,6 +110,7 @@ export async function handleBuild(options: BuildOptions): Promise<void> {
45110
// Use SDK's buildPersona() for all orchestration
46111
const result = await buildPersona(personaPath, {
47112
includeStandard: true,
113+
...(emitDeclarations && { emitDeclarations }),
48114
});
49115

50116
if (verbose) {
@@ -66,26 +132,17 @@ export async function handleBuild(options: BuildOptions): Promise<void> {
66132

67133
// Generate output files
68134
if (outputPath) {
69-
// Write markdown file
70-
await writeOutputFile(outputPath, result.markdown);
71-
console.log(
72-
chalk.green(`✓ Persona instructions written to: ${outputPath}`)
73-
);
74-
75-
// Write build report JSON file
76-
const buildReportPath = outputPath.replace(/\.md$/, '.build.json');
77-
await writeOutputFile(
78-
buildReportPath,
79-
JSON.stringify(result.buildReport, null, 2)
80-
);
81-
console.log(chalk.green(`✓ Build report written to: ${buildReportPath}`));
82-
83-
if (verbose) {
84-
console.log(
85-
chalk.gray(
86-
`[INFO] build: Generated ${result.markdown.length} characters of Markdown`
87-
)
135+
try {
136+
await writeBuildOutput(
137+
result,
138+
outputPath,
139+
emitDeclarations ?? false,
140+
verbose ?? false
88141
);
142+
} catch (writeError) {
143+
const errorMessage =
144+
writeError instanceof Error ? writeError.message : String(writeError);
145+
throw new Error(`Failed to write output files: ${errorMessage}`);
89146
}
90147
} else {
91148
// Write to stdout

packages/ums-cli/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ program
3535
)
3636
.option('-o, --output <file>', 'Specify the output file for the build')
3737
.option('-v, --verbose', 'Enable verbose output')
38+
.option(
39+
'-e, --emit-declarations',
40+
'Emit TypeScript declaration files (.d.ts) for modules'
41+
)
3842
.addHelpText(
3943
'after',
4044
` Examples:
@@ -49,18 +53,21 @@ program
4953
persona?: string;
5054
output?: string;
5155
verbose?: boolean;
56+
emitDeclarations?: boolean;
5257
}) => {
5358
if (!options.persona) {
5459
console.error('Error: --persona <file> is required');
5560
process.exit(1);
5661
}
5762

5863
const verbose = options.verbose ?? false;
64+
const emitDeclarations = options.emitDeclarations ?? false;
5965

6066
await handleBuild({
6167
persona: options.persona,
6268
...(options.output && { output: options.output }),
6369
verbose,
70+
emitDeclarations,
6471
});
6572
}
6673
);

0 commit comments

Comments
 (0)