@versatiles/release-tool 2.6.0 → 2.7.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 +37 -27
- package/dist/commands/check.d.ts +24 -0
- package/dist/commands/check.js +25 -1
- package/dist/commands/deps-graph.d.ts +22 -0
- package/dist/commands/deps-graph.js +23 -1
- package/dist/commands/doc-typescript.d.ts +39 -3
- package/dist/commands/doc-typescript.js +24 -0
- package/dist/commands/markdown.d.ts +9 -1
- package/dist/commands/markdown.js +141 -28
- package/dist/commands/release-npm.d.ts +43 -0
- package/dist/commands/release-npm.js +154 -29
- package/dist/lib/benchmark.d.ts +119 -0
- package/dist/lib/benchmark.js +148 -0
- package/dist/lib/changelog.d.ts +23 -0
- package/dist/lib/changelog.js +117 -0
- package/dist/lib/errors.d.ts +32 -0
- package/dist/lib/errors.js +47 -0
- package/dist/lib/git.d.ts +92 -0
- package/dist/lib/git.js +112 -0
- package/dist/lib/log.d.ts +57 -0
- package/dist/lib/log.js +63 -1
- package/dist/lib/retry.d.ts +24 -0
- package/dist/lib/retry.js +44 -0
- package/dist/lib/shell.d.ts +131 -16
- package/dist/lib/shell.js +106 -2
- package/dist/lib/utils.d.ts +29 -0
- package/dist/lib/utils.js +29 -0
- package/package.json +10 -3
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env npx tsx
|
|
2
2
|
import { readFileSync, writeFileSync } from 'fs';
|
|
3
|
+
import { resolve } from 'path';
|
|
3
4
|
import select from '@inquirer/select';
|
|
5
|
+
import { updateChangelog } from '../lib/changelog.js';
|
|
6
|
+
import { releaseError, validationError } from '../lib/errors.js';
|
|
7
|
+
import { COMMIT_TYPES, getGit, getSuggestedBump, groupCommitsByType, parseConventionalCommit, } from '../lib/git.js';
|
|
4
8
|
import { check, info, panic, warn } from '../lib/log.js';
|
|
9
|
+
import { withRetry } from '../lib/retry.js';
|
|
5
10
|
import { Shell } from '../lib/shell.js';
|
|
6
|
-
|
|
7
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Type guard to validate package.json structure.
|
|
13
|
+
*/
|
|
8
14
|
function isValidPackageJson(pkg) {
|
|
9
15
|
if (typeof pkg !== 'object' || pkg === null)
|
|
10
16
|
return false;
|
|
@@ -14,6 +20,38 @@ function isValidPackageJson(pkg) {
|
|
|
14
20
|
return false;
|
|
15
21
|
return true;
|
|
16
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Executes the npm release process.
|
|
25
|
+
*
|
|
26
|
+
* This function performs a complete release workflow:
|
|
27
|
+
* 1. Validates git state (correct branch, no uncommitted changes)
|
|
28
|
+
* 2. Pulls latest changes from remote
|
|
29
|
+
* 3. Verifies npm authentication
|
|
30
|
+
* 4. Prompts for new version (with suggestion based on conventional commits)
|
|
31
|
+
* 5. Runs project checks
|
|
32
|
+
* 6. Updates package.json version
|
|
33
|
+
* 7. Updates CHANGELOG.md
|
|
34
|
+
* 8. Publishes to npm (if not private)
|
|
35
|
+
* 9. Creates git commit and tag
|
|
36
|
+
* 10. Pushes to remote and creates GitHub release
|
|
37
|
+
*
|
|
38
|
+
* @param directory - The project directory containing package.json
|
|
39
|
+
* @param branch - The git branch to release from (default: 'main')
|
|
40
|
+
* @param dryRun - If true, simulate the release without making changes
|
|
41
|
+
* @throws {VrtError} If any step in the release process fails
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* // Standard release from main branch
|
|
46
|
+
* await release('/path/to/project');
|
|
47
|
+
*
|
|
48
|
+
* // Dry run to preview release
|
|
49
|
+
* await release('/path/to/project', 'main', true);
|
|
50
|
+
*
|
|
51
|
+
* // Release from a different branch
|
|
52
|
+
* await release('/path/to/project', 'release');
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
17
55
|
export async function release(directory, branch = 'main', dryRun = false) {
|
|
18
56
|
const shell = new Shell(directory);
|
|
19
57
|
const { getCommitsBetween, getCurrentGitHubCommit, getLastGitHubTag } = getGit(directory);
|
|
@@ -24,8 +62,10 @@ export async function release(directory, branch = 'main', dryRun = false) {
|
|
|
24
62
|
panic(`current branch is "${currentBranch}" but should be "${branch}"`);
|
|
25
63
|
// git: check if no changes
|
|
26
64
|
await check('are all changes committed?', checkThatNoUncommittedChanges());
|
|
27
|
-
// git: pull
|
|
28
|
-
await check('git pull', shell.run('git pull -t')
|
|
65
|
+
// git: pull (with retry for transient network failures)
|
|
66
|
+
await check('git pull', withRetry(() => shell.run('git pull -t'), {
|
|
67
|
+
onRetry: (attempt, error) => warn(`git pull failed (attempt ${attempt}): ${error.message}, retrying...`),
|
|
68
|
+
}));
|
|
29
69
|
// check package.json
|
|
30
70
|
const pkgRaw = JSON.parse(readFileSync(resolve(directory, 'package.json'), 'utf8'));
|
|
31
71
|
if (!isValidPackageJson(pkgRaw))
|
|
@@ -35,6 +75,11 @@ export async function release(directory, branch = 'main', dryRun = false) {
|
|
|
35
75
|
if (!('prepack' in pkgRaw.scripts))
|
|
36
76
|
panic('missing npm script "prepack" in package.json');
|
|
37
77
|
const pkg = pkgRaw;
|
|
78
|
+
const isPrivatePackage = pkg.private === true;
|
|
79
|
+
// npm: verify authentication (skip for private packages)
|
|
80
|
+
if (!isPrivatePackage) {
|
|
81
|
+
await check('verify npm authentication', verifyNpmAuth());
|
|
82
|
+
}
|
|
38
83
|
// get last version
|
|
39
84
|
const tag = await check('get last github tag', getLastGitHubTag());
|
|
40
85
|
const shaLast = tag?.sha;
|
|
@@ -44,10 +89,14 @@ export async function release(directory, branch = 'main', dryRun = false) {
|
|
|
44
89
|
warn(`versions differ in package.json (${versionLastPackage}) and last GitHub tag (${versionLastGithub})`);
|
|
45
90
|
// get current sha
|
|
46
91
|
const { sha: shaCurrent } = await check('get current github commit', getCurrentGitHubCommit());
|
|
47
|
-
//
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
92
|
+
// get and parse commits for conventional commit support
|
|
93
|
+
const commits = await check('get commits since last release', getCommitsBetween(shaLast, shaCurrent));
|
|
94
|
+
const parsedCommits = commits.map(parseConventionalCommit);
|
|
95
|
+
// handle version (with suggested bump based on conventional commits)
|
|
96
|
+
const nextVersion = await getNewVersion(versionLastPackage, parsedCommits);
|
|
97
|
+
// prepare release notes (grouped by conventional commit type)
|
|
98
|
+
const releaseNotes = getReleaseNotes(nextVersion, parsedCommits);
|
|
99
|
+
info('prepared release notes');
|
|
51
100
|
if (dryRun) {
|
|
52
101
|
info('Dry-run mode - the following actions would be performed:');
|
|
53
102
|
info(` Version: ${versionLastPackage} -> ${nextVersion}`);
|
|
@@ -56,7 +105,8 @@ export async function release(directory, branch = 'main', dryRun = false) {
|
|
|
56
105
|
info(' Commands that would be executed:');
|
|
57
106
|
info(' npm run check');
|
|
58
107
|
info(' npm i --package-lock-only');
|
|
59
|
-
|
|
108
|
+
info(' Update CHANGELOG.md');
|
|
109
|
+
if (!isPrivatePackage) {
|
|
60
110
|
info(' npm publish --access public');
|
|
61
111
|
}
|
|
62
112
|
info(' git add .');
|
|
@@ -71,29 +121,58 @@ export async function release(directory, branch = 'main', dryRun = false) {
|
|
|
71
121
|
await check('run checks', shell.run('npm run check'));
|
|
72
122
|
// update version
|
|
73
123
|
await check('update version', setNextVersion(nextVersion));
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
124
|
+
// update changelog
|
|
125
|
+
const changelogResult = updateChangelog(directory, nextVersion, parsedCommits);
|
|
126
|
+
if (changelogResult.created) {
|
|
127
|
+
info('created CHANGELOG.md');
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
info('updated CHANGELOG.md');
|
|
131
|
+
}
|
|
132
|
+
if (!isPrivatePackage) {
|
|
133
|
+
// npm publish (with retry for transient network failures)
|
|
134
|
+
await check('npm publish', withRetry(() => shell.runInteractive('npm publish --access public'), {
|
|
135
|
+
onRetry: (attempt, error) => warn(`npm publish failed (attempt ${attempt}): ${error.message}, retrying...`),
|
|
136
|
+
}));
|
|
77
137
|
}
|
|
78
138
|
// git push
|
|
79
139
|
await check('git add', shell.run('git add .'));
|
|
80
140
|
await check('git commit', shell.run(`git commit -m "v${nextVersion}"`, false));
|
|
81
141
|
await check('git tag', shell.run(`git tag -f -a "v${nextVersion}" -m "new release: v${nextVersion}"`));
|
|
82
|
-
await check('git push', shell.run('git push --no-verify --follow-tags')
|
|
83
|
-
|
|
142
|
+
await check('git push', withRetry(() => shell.run('git push --no-verify --follow-tags'), {
|
|
143
|
+
onRetry: (attempt, error) => warn(`git push failed (attempt ${attempt}): ${error.message}, retrying...`),
|
|
144
|
+
}));
|
|
145
|
+
// github release (with retry for transient network failures)
|
|
84
146
|
const releaseTag = `v${nextVersion}`;
|
|
85
147
|
if (await check('check github release', shell.ok(`gh release view ${releaseTag}`))) {
|
|
86
|
-
await check('edit release', shell.exec('gh', ['release', 'edit', releaseTag, '--notes', releaseNotes])
|
|
148
|
+
await check('edit release', withRetry(() => shell.exec('gh', ['release', 'edit', releaseTag, '--notes', releaseNotes]), {
|
|
149
|
+
onRetry: (attempt, error) => warn(`gh release edit failed (attempt ${attempt}): ${error.message}, retrying...`),
|
|
150
|
+
}));
|
|
87
151
|
}
|
|
88
152
|
else {
|
|
89
|
-
await check('create release', shell.exec('gh', ['release', 'create', releaseTag, '--notes', releaseNotes])
|
|
153
|
+
await check('create release', withRetry(() => shell.exec('gh', ['release', 'create', releaseTag, '--notes', releaseNotes]), {
|
|
154
|
+
onRetry: (attempt, error) => warn(`gh release create failed (attempt ${attempt}): ${error.message}, retrying...`),
|
|
155
|
+
}));
|
|
90
156
|
}
|
|
91
157
|
info('Finished');
|
|
92
158
|
return;
|
|
159
|
+
async function verifyNpmAuth() {
|
|
160
|
+
try {
|
|
161
|
+
const username = await shell.stdout('npm whoami');
|
|
162
|
+
if (!username || username.trim().length === 0) {
|
|
163
|
+
throw releaseError('npm authentication failed: no username returned');
|
|
164
|
+
}
|
|
165
|
+
info(`authenticated as npm user: ${username.trim()}`);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
throw releaseError('npm authentication required. Please run "npm login" first.\n' +
|
|
169
|
+
'If you are using a CI environment, ensure NPM_TOKEN is set.');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
93
172
|
async function checkThatNoUncommittedChanges() {
|
|
94
173
|
if ((await shell.stdout('git status --porcelain')).length < 3)
|
|
95
174
|
return;
|
|
96
|
-
throw
|
|
175
|
+
throw releaseError('please commit all changes before releasing');
|
|
97
176
|
}
|
|
98
177
|
async function setNextVersion(version) {
|
|
99
178
|
// set new version in package.json
|
|
@@ -103,30 +182,76 @@ export async function release(directory, branch = 'main', dryRun = false) {
|
|
|
103
182
|
// rebuild package.json
|
|
104
183
|
await shell.run('npm i --package-lock-only');
|
|
105
184
|
}
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
185
|
+
function getReleaseNotes(version, commits) {
|
|
186
|
+
const reversed = [...commits].reverse();
|
|
187
|
+
const grouped = groupCommitsByType(reversed);
|
|
188
|
+
let notes = `# Release v${version}\n\n`;
|
|
189
|
+
// Order: breaking changes first, then features, fixes, and others
|
|
190
|
+
const typeOrder = [
|
|
191
|
+
'feat',
|
|
192
|
+
'fix',
|
|
193
|
+
'perf',
|
|
194
|
+
'refactor',
|
|
195
|
+
'docs',
|
|
196
|
+
'test',
|
|
197
|
+
'build',
|
|
198
|
+
'ci',
|
|
199
|
+
'chore',
|
|
200
|
+
'style',
|
|
201
|
+
'revert',
|
|
202
|
+
'other',
|
|
203
|
+
];
|
|
204
|
+
// Add breaking changes section if any
|
|
205
|
+
const breakingCommits = reversed.filter((c) => c.breaking);
|
|
206
|
+
if (breakingCommits.length > 0) {
|
|
207
|
+
notes += '## Breaking Changes\n\n';
|
|
208
|
+
for (const commit of breakingCommits) {
|
|
209
|
+
notes += `- ${commit.description.replace(/\s+/g, ' ')}\n`;
|
|
210
|
+
}
|
|
211
|
+
notes += '\n';
|
|
212
|
+
}
|
|
213
|
+
// Add grouped commits
|
|
214
|
+
for (const type of typeOrder) {
|
|
215
|
+
const typeCommits = grouped.get(type);
|
|
216
|
+
if (!typeCommits || typeCommits.length === 0)
|
|
217
|
+
continue;
|
|
218
|
+
// Skip commits already shown in breaking changes
|
|
219
|
+
const nonBreaking = typeCommits.filter((c) => !c.breaking);
|
|
220
|
+
if (nonBreaking.length === 0)
|
|
221
|
+
continue;
|
|
222
|
+
const label = type === 'other' ? 'Other Changes' : COMMIT_TYPES[type];
|
|
223
|
+
notes += `## ${label}\n\n`;
|
|
224
|
+
for (const commit of nonBreaking) {
|
|
225
|
+
const scope = commit.scope ? `**${commit.scope}:** ` : '';
|
|
226
|
+
notes += `- ${scope}${commit.description.replace(/\s+/g, ' ')}\n`;
|
|
227
|
+
}
|
|
228
|
+
notes += '\n';
|
|
229
|
+
}
|
|
113
230
|
return notes;
|
|
114
231
|
}
|
|
115
|
-
async function getNewVersion(versionPackage) {
|
|
116
|
-
//
|
|
232
|
+
async function getNewVersion(versionPackage, commits) {
|
|
233
|
+
// Determine suggested bump based on conventional commits
|
|
234
|
+
const suggestedBump = getSuggestedBump(commits);
|
|
235
|
+
// choices: [current, patch, minor, major] -> indices [0, 1, 2, 3]
|
|
236
|
+
const suggestedIndex = suggestedBump === 'major' ? 3 : suggestedBump === 'minor' ? 2 : 1;
|
|
117
237
|
const choices = [{ value: versionPackage }, { ...bump(2) }, { ...bump(1) }, { ...bump(0) }];
|
|
238
|
+
// Add recommendation label to suggested version
|
|
239
|
+
const suggestedChoice = choices[suggestedIndex];
|
|
240
|
+
if (suggestedChoice && 'name' in suggestedChoice) {
|
|
241
|
+
suggestedChoice.name += ' (recommended)';
|
|
242
|
+
}
|
|
118
243
|
const versionNew = await select({
|
|
119
244
|
message: 'What should be the new version?',
|
|
120
245
|
choices,
|
|
121
|
-
default: choices[1].value,
|
|
246
|
+
default: suggestedChoice?.value ?? choices[1].value,
|
|
122
247
|
});
|
|
123
248
|
if (!versionNew)
|
|
124
|
-
throw
|
|
249
|
+
throw releaseError('no version selected');
|
|
125
250
|
return versionNew;
|
|
126
251
|
function bump(index) {
|
|
127
252
|
const p = versionPackage.split('.').map((v) => parseInt(v, 10));
|
|
128
253
|
if (p.length !== 3)
|
|
129
|
-
throw
|
|
254
|
+
throw validationError('invalid version format, expected x.y.z');
|
|
130
255
|
switch (index) {
|
|
131
256
|
case 0:
|
|
132
257
|
p[0]++;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Performance benchmarking utilities for CLI operations.
|
|
3
|
+
*
|
|
4
|
+
* Provides timing measurement and optional logging for tracking
|
|
5
|
+
* execution duration of key operations.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Result of a benchmarked operation
|
|
9
|
+
*/
|
|
10
|
+
export interface BenchmarkResult<T> {
|
|
11
|
+
/** The result returned by the benchmarked function */
|
|
12
|
+
result: T;
|
|
13
|
+
/** Execution duration in milliseconds */
|
|
14
|
+
durationMs: number;
|
|
15
|
+
/** Human-readable duration string */
|
|
16
|
+
durationFormatted: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Options for benchmark execution
|
|
20
|
+
*/
|
|
21
|
+
export interface BenchmarkOptions {
|
|
22
|
+
/** Label for the operation being benchmarked */
|
|
23
|
+
label?: string;
|
|
24
|
+
/** Whether to log timing to console */
|
|
25
|
+
log?: boolean;
|
|
26
|
+
/** Custom logger function (defaults to console.log) */
|
|
27
|
+
logger?: (message: string) => void;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Formats a duration in milliseconds to a human-readable string.
|
|
31
|
+
*
|
|
32
|
+
* @param ms - Duration in milliseconds
|
|
33
|
+
* @returns Formatted duration string (e.g., "1.23s", "456ms")
|
|
34
|
+
*/
|
|
35
|
+
export declare function formatDuration(ms: number): string;
|
|
36
|
+
/**
|
|
37
|
+
* Measures the execution time of a synchronous function.
|
|
38
|
+
*
|
|
39
|
+
* @param fn - The function to benchmark
|
|
40
|
+
* @param options - Optional benchmark configuration
|
|
41
|
+
* @returns The function result along with timing information
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* const { result, durationMs } = benchmarkSync(() => {
|
|
46
|
+
* return heavyComputation();
|
|
47
|
+
* }, { label: 'Heavy computation', log: true });
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export declare function benchmarkSync<T>(fn: () => T, options?: BenchmarkOptions): BenchmarkResult<T>;
|
|
51
|
+
/**
|
|
52
|
+
* Measures the execution time of an asynchronous function.
|
|
53
|
+
*
|
|
54
|
+
* @param fn - The async function to benchmark
|
|
55
|
+
* @param options - Optional benchmark configuration
|
|
56
|
+
* @returns Promise resolving to the function result along with timing information
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```ts
|
|
60
|
+
* const { result, durationMs } = await benchmark(async () => {
|
|
61
|
+
* return await fetchData();
|
|
62
|
+
* }, { label: 'API fetch', log: true });
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export declare function benchmark<T>(fn: () => Promise<T>, options?: BenchmarkOptions): Promise<BenchmarkResult<T>>;
|
|
66
|
+
/**
|
|
67
|
+
* Creates a timer for manual timing control.
|
|
68
|
+
*
|
|
69
|
+
* @returns Timer object with start, stop, and elapsed methods
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```ts
|
|
73
|
+
* const timer = createTimer();
|
|
74
|
+
* timer.start();
|
|
75
|
+
* // ... do work ...
|
|
76
|
+
* const elapsed = timer.stop();
|
|
77
|
+
* console.log(`Operation took ${elapsed.durationFormatted}`);
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export declare function createTimer(): {
|
|
81
|
+
start: () => void;
|
|
82
|
+
stop: () => {
|
|
83
|
+
durationMs: number;
|
|
84
|
+
durationFormatted: string;
|
|
85
|
+
};
|
|
86
|
+
elapsed: () => number;
|
|
87
|
+
isRunning: () => boolean;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Aggregates multiple benchmark results for statistical analysis.
|
|
91
|
+
*/
|
|
92
|
+
export interface BenchmarkStats {
|
|
93
|
+
/** Number of samples */
|
|
94
|
+
count: number;
|
|
95
|
+
/** Total duration in milliseconds */
|
|
96
|
+
totalMs: number;
|
|
97
|
+
/** Average duration in milliseconds */
|
|
98
|
+
avgMs: number;
|
|
99
|
+
/** Minimum duration in milliseconds */
|
|
100
|
+
minMs: number;
|
|
101
|
+
/** Maximum duration in milliseconds */
|
|
102
|
+
maxMs: number;
|
|
103
|
+
/** Formatted average duration */
|
|
104
|
+
avgFormatted: string;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Calculates statistics from an array of benchmark durations.
|
|
108
|
+
*
|
|
109
|
+
* @param durations - Array of duration values in milliseconds
|
|
110
|
+
* @returns Statistical summary of the benchmarks
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* ```ts
|
|
114
|
+
* const durations = [100, 150, 120, 130, 110];
|
|
115
|
+
* const stats = calculateStats(durations);
|
|
116
|
+
* console.log(`Average: ${stats.avgFormatted}`);
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
export declare function calculateStats(durations: number[]): BenchmarkStats;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Performance benchmarking utilities for CLI operations.
|
|
3
|
+
*
|
|
4
|
+
* Provides timing measurement and optional logging for tracking
|
|
5
|
+
* execution duration of key operations.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Formats a duration in milliseconds to a human-readable string.
|
|
9
|
+
*
|
|
10
|
+
* @param ms - Duration in milliseconds
|
|
11
|
+
* @returns Formatted duration string (e.g., "1.23s", "456ms")
|
|
12
|
+
*/
|
|
13
|
+
export function formatDuration(ms) {
|
|
14
|
+
if (ms >= 1000) {
|
|
15
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
16
|
+
}
|
|
17
|
+
return `${Math.round(ms)}ms`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Measures the execution time of a synchronous function.
|
|
21
|
+
*
|
|
22
|
+
* @param fn - The function to benchmark
|
|
23
|
+
* @param options - Optional benchmark configuration
|
|
24
|
+
* @returns The function result along with timing information
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* const { result, durationMs } = benchmarkSync(() => {
|
|
29
|
+
* return heavyComputation();
|
|
30
|
+
* }, { label: 'Heavy computation', log: true });
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export function benchmarkSync(fn, options = {}) {
|
|
34
|
+
const { label, log = false, logger = console.log } = options;
|
|
35
|
+
const start = performance.now();
|
|
36
|
+
const result = fn();
|
|
37
|
+
const end = performance.now();
|
|
38
|
+
const durationMs = end - start;
|
|
39
|
+
const durationFormatted = formatDuration(durationMs);
|
|
40
|
+
if (log && label) {
|
|
41
|
+
logger(`[benchmark] ${label}: ${durationFormatted}`);
|
|
42
|
+
}
|
|
43
|
+
return { result, durationMs, durationFormatted };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Measures the execution time of an asynchronous function.
|
|
47
|
+
*
|
|
48
|
+
* @param fn - The async function to benchmark
|
|
49
|
+
* @param options - Optional benchmark configuration
|
|
50
|
+
* @returns Promise resolving to the function result along with timing information
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```ts
|
|
54
|
+
* const { result, durationMs } = await benchmark(async () => {
|
|
55
|
+
* return await fetchData();
|
|
56
|
+
* }, { label: 'API fetch', log: true });
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export async function benchmark(fn, options = {}) {
|
|
60
|
+
const { label, log = false, logger = console.log } = options;
|
|
61
|
+
const start = performance.now();
|
|
62
|
+
const result = await fn();
|
|
63
|
+
const end = performance.now();
|
|
64
|
+
const durationMs = end - start;
|
|
65
|
+
const durationFormatted = formatDuration(durationMs);
|
|
66
|
+
if (log && label) {
|
|
67
|
+
logger(`[benchmark] ${label}: ${durationFormatted}`);
|
|
68
|
+
}
|
|
69
|
+
return { result, durationMs, durationFormatted };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Creates a timer for manual timing control.
|
|
73
|
+
*
|
|
74
|
+
* @returns Timer object with start, stop, and elapsed methods
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```ts
|
|
78
|
+
* const timer = createTimer();
|
|
79
|
+
* timer.start();
|
|
80
|
+
* // ... do work ...
|
|
81
|
+
* const elapsed = timer.stop();
|
|
82
|
+
* console.log(`Operation took ${elapsed.durationFormatted}`);
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export function createTimer() {
|
|
86
|
+
let startTime = null;
|
|
87
|
+
let endTime = null;
|
|
88
|
+
return {
|
|
89
|
+
start() {
|
|
90
|
+
startTime = performance.now();
|
|
91
|
+
endTime = null;
|
|
92
|
+
},
|
|
93
|
+
stop() {
|
|
94
|
+
if (startTime === null) {
|
|
95
|
+
throw new Error('Timer was not started');
|
|
96
|
+
}
|
|
97
|
+
endTime = performance.now();
|
|
98
|
+
const durationMs = endTime - startTime;
|
|
99
|
+
return { durationMs, durationFormatted: formatDuration(durationMs) };
|
|
100
|
+
},
|
|
101
|
+
elapsed() {
|
|
102
|
+
if (startTime === null) {
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
const end = endTime ?? performance.now();
|
|
106
|
+
return end - startTime;
|
|
107
|
+
},
|
|
108
|
+
isRunning() {
|
|
109
|
+
return startTime !== null && endTime === null;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Calculates statistics from an array of benchmark durations.
|
|
115
|
+
*
|
|
116
|
+
* @param durations - Array of duration values in milliseconds
|
|
117
|
+
* @returns Statistical summary of the benchmarks
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```ts
|
|
121
|
+
* const durations = [100, 150, 120, 130, 110];
|
|
122
|
+
* const stats = calculateStats(durations);
|
|
123
|
+
* console.log(`Average: ${stats.avgFormatted}`);
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
export function calculateStats(durations) {
|
|
127
|
+
if (durations.length === 0) {
|
|
128
|
+
return {
|
|
129
|
+
count: 0,
|
|
130
|
+
totalMs: 0,
|
|
131
|
+
avgMs: 0,
|
|
132
|
+
minMs: 0,
|
|
133
|
+
maxMs: 0,
|
|
134
|
+
avgFormatted: '0ms',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const totalMs = durations.reduce((sum, d) => sum + d, 0);
|
|
138
|
+
const avgMs = totalMs / durations.length;
|
|
139
|
+
return {
|
|
140
|
+
count: durations.length,
|
|
141
|
+
totalMs,
|
|
142
|
+
avgMs,
|
|
143
|
+
minMs: Math.min(...durations),
|
|
144
|
+
maxMs: Math.max(...durations),
|
|
145
|
+
avgFormatted: formatDuration(avgMs),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=benchmark.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ParsedCommit } from './git.js';
|
|
2
|
+
/**
|
|
3
|
+
* Generates a changelog entry for a release from parsed commits.
|
|
4
|
+
*
|
|
5
|
+
* @param version - The version being released
|
|
6
|
+
* @param commits - Array of parsed conventional commits
|
|
7
|
+
* @param date - The release date (defaults to today)
|
|
8
|
+
* @returns Formatted changelog entry string
|
|
9
|
+
*/
|
|
10
|
+
export declare function generateChangelogEntry(version: string, commits: ParsedCommit[], date?: Date): string;
|
|
11
|
+
/**
|
|
12
|
+
* Updates or creates a CHANGELOG.md file with a new release entry.
|
|
13
|
+
*
|
|
14
|
+
* @param directory - The project directory containing CHANGELOG.md
|
|
15
|
+
* @param version - The version being released
|
|
16
|
+
* @param commits - Array of parsed conventional commits
|
|
17
|
+
* @param date - The release date (defaults to today)
|
|
18
|
+
* @returns Object indicating whether the file was created or updated
|
|
19
|
+
*/
|
|
20
|
+
export declare function updateChangelog(directory: string, version: string, commits: ParsedCommit[], date?: Date): {
|
|
21
|
+
created: boolean;
|
|
22
|
+
path: string;
|
|
23
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { COMMIT_TYPES, groupCommitsByType } from './git.js';
|
|
4
|
+
/**
|
|
5
|
+
* Generates a changelog entry for a release from parsed commits.
|
|
6
|
+
*
|
|
7
|
+
* @param version - The version being released
|
|
8
|
+
* @param commits - Array of parsed conventional commits
|
|
9
|
+
* @param date - The release date (defaults to today)
|
|
10
|
+
* @returns Formatted changelog entry string
|
|
11
|
+
*/
|
|
12
|
+
export function generateChangelogEntry(version, commits, date = new Date()) {
|
|
13
|
+
const dateStr = date.toISOString().split('T')[0];
|
|
14
|
+
const reversed = [...commits].reverse();
|
|
15
|
+
const grouped = groupCommitsByType(reversed);
|
|
16
|
+
let entry = `## [${version}] - ${dateStr}\n\n`;
|
|
17
|
+
// Order: breaking changes first, then features, fixes, and others
|
|
18
|
+
const typeOrder = [
|
|
19
|
+
'feat',
|
|
20
|
+
'fix',
|
|
21
|
+
'perf',
|
|
22
|
+
'refactor',
|
|
23
|
+
'docs',
|
|
24
|
+
'test',
|
|
25
|
+
'build',
|
|
26
|
+
'ci',
|
|
27
|
+
'chore',
|
|
28
|
+
'style',
|
|
29
|
+
'revert',
|
|
30
|
+
'other',
|
|
31
|
+
];
|
|
32
|
+
// Add breaking changes section if any
|
|
33
|
+
const breakingCommits = reversed.filter((c) => c.breaking);
|
|
34
|
+
if (breakingCommits.length > 0) {
|
|
35
|
+
entry += '### Breaking Changes\n\n';
|
|
36
|
+
for (const commit of breakingCommits) {
|
|
37
|
+
entry += `- ${commit.description.replace(/\s+/g, ' ')}\n`;
|
|
38
|
+
}
|
|
39
|
+
entry += '\n';
|
|
40
|
+
}
|
|
41
|
+
// Add grouped commits
|
|
42
|
+
for (const type of typeOrder) {
|
|
43
|
+
const typeCommits = grouped.get(type);
|
|
44
|
+
if (!typeCommits || typeCommits.length === 0)
|
|
45
|
+
continue;
|
|
46
|
+
// Skip commits already shown in breaking changes
|
|
47
|
+
const nonBreaking = typeCommits.filter((c) => !c.breaking);
|
|
48
|
+
if (nonBreaking.length === 0)
|
|
49
|
+
continue;
|
|
50
|
+
const label = type === 'other' ? 'Other Changes' : COMMIT_TYPES[type];
|
|
51
|
+
entry += `### ${label}\n\n`;
|
|
52
|
+
for (const commit of nonBreaking) {
|
|
53
|
+
const scope = commit.scope ? `**${commit.scope}:** ` : '';
|
|
54
|
+
entry += `- ${scope}${commit.description.replace(/\s+/g, ' ')}\n`;
|
|
55
|
+
}
|
|
56
|
+
entry += '\n';
|
|
57
|
+
}
|
|
58
|
+
return entry;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Default changelog header for new CHANGELOG.md files
|
|
62
|
+
*/
|
|
63
|
+
const DEFAULT_CHANGELOG_HEADER = `# Changelog
|
|
64
|
+
|
|
65
|
+
All notable changes to this project will be documented in this file.
|
|
66
|
+
|
|
67
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
68
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
69
|
+
|
|
70
|
+
`;
|
|
71
|
+
/**
|
|
72
|
+
* Updates or creates a CHANGELOG.md file with a new release entry.
|
|
73
|
+
*
|
|
74
|
+
* @param directory - The project directory containing CHANGELOG.md
|
|
75
|
+
* @param version - The version being released
|
|
76
|
+
* @param commits - Array of parsed conventional commits
|
|
77
|
+
* @param date - The release date (defaults to today)
|
|
78
|
+
* @returns Object indicating whether the file was created or updated
|
|
79
|
+
*/
|
|
80
|
+
export function updateChangelog(directory, version, commits, date = new Date()) {
|
|
81
|
+
const changelogPath = resolve(directory, 'CHANGELOG.md');
|
|
82
|
+
const entry = generateChangelogEntry(version, commits, date);
|
|
83
|
+
let content;
|
|
84
|
+
let created = false;
|
|
85
|
+
if (existsSync(changelogPath)) {
|
|
86
|
+
const existing = readFileSync(changelogPath, 'utf8');
|
|
87
|
+
// Insert new entry after the header (after the first double newline or at the start if no header)
|
|
88
|
+
const headerEndIndex = findHeaderEnd(existing);
|
|
89
|
+
content = existing.slice(0, headerEndIndex) + entry + existing.slice(headerEndIndex);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
content = DEFAULT_CHANGELOG_HEADER + entry;
|
|
93
|
+
created = true;
|
|
94
|
+
}
|
|
95
|
+
writeFileSync(changelogPath, content);
|
|
96
|
+
return { created, path: changelogPath };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Finds the end of the changelog header section.
|
|
100
|
+
* Looks for patterns like "# Changelog" followed by description text,
|
|
101
|
+
* ending before the first "## " section header.
|
|
102
|
+
*/
|
|
103
|
+
function findHeaderEnd(content) {
|
|
104
|
+
// Look for the first version section (## [x.y.z] or ## x.y.z)
|
|
105
|
+
const versionSectionMatch = content.match(/^## \[?\d+\.\d+\.\d+\]?/m);
|
|
106
|
+
if (versionSectionMatch?.index !== undefined) {
|
|
107
|
+
return versionSectionMatch.index;
|
|
108
|
+
}
|
|
109
|
+
// If no version section found, look for any ## heading
|
|
110
|
+
const anySectionMatch = content.match(/^## /m);
|
|
111
|
+
if (anySectionMatch?.index !== undefined) {
|
|
112
|
+
return anySectionMatch.index;
|
|
113
|
+
}
|
|
114
|
+
// No sections found, append at end (after ensuring trailing newline)
|
|
115
|
+
return content.endsWith('\n') ? content.length : content.length;
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=changelog.js.map
|