tlc-claude-code 2.4.10 → 2.6.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/.claude/commands/tlc/autofix.md +34 -1
- package/.claude/commands/tlc/build.md +203 -27
- package/.claude/commands/tlc/ci.md +178 -414
- package/.claude/commands/tlc/coverage.md +34 -0
- package/.claude/commands/tlc/deploy.md +19 -6
- package/.claude/commands/tlc/discuss.md +34 -0
- package/.claude/commands/tlc/docs.md +35 -1
- package/.claude/commands/tlc/e2e.md +300 -0
- package/.claude/commands/tlc/edge-cases.md +35 -1
- package/.claude/commands/tlc/init.md +38 -8
- package/.claude/commands/tlc/issues.md +46 -0
- package/.claude/commands/tlc/new-project.md +46 -4
- package/.claude/commands/tlc/plan.md +76 -0
- package/.claude/commands/tlc/quick.md +33 -0
- package/.claude/commands/tlc/release.md +85 -135
- package/.claude/commands/tlc/restore.md +14 -0
- package/.claude/commands/tlc/review.md +80 -1
- package/.claude/commands/tlc/tlc.md +134 -0
- package/.claude/commands/tlc/verify.md +64 -65
- package/.claude/commands/tlc/watchci.md +10 -0
- package/.claude/hooks/tlc-block-tools.sh +13 -0
- package/.claude/hooks/tlc-session-init.sh +9 -0
- package/CODING-STANDARDS.md +35 -10
- package/package.json +1 -1
- package/server/lib/block-tools-hook.js +23 -0
- package/server/lib/e2e/acceptance-parser.js +132 -0
- package/server/lib/e2e/acceptance-parser.test.js +110 -0
- package/server/lib/e2e/framework-detector.js +47 -0
- package/server/lib/e2e/framework-detector.test.js +94 -0
- package/server/lib/e2e/log-assertions.js +107 -0
- package/server/lib/e2e/log-assertions.test.js +68 -0
- package/server/lib/e2e/test-generator.js +159 -0
- package/server/lib/e2e/test-generator.test.js +121 -0
- package/server/lib/e2e/verify-runner.js +191 -0
- package/server/lib/e2e/verify-runner.test.js +167 -0
- package/server/lib/github/config.js +458 -0
- package/server/lib/github/config.test.js +385 -0
- package/server/lib/github/gh-client.js +303 -0
- package/server/lib/github/gh-client.test.js +499 -0
- package/server/lib/github/gh-projects.js +594 -0
- package/server/lib/github/gh-projects.test.js +583 -0
- package/server/lib/github/index.js +19 -0
- package/server/lib/github/plan-sync.js +456 -0
- package/server/lib/github/plan-sync.test.js +805 -0
- package/server/lib/hooks/block-tools-hook.test.js +54 -0
- package/server/lib/orchestration/cli-dispatch.js +16 -1
- package/server/lib/orchestration/cli-dispatch.test.js +94 -8
- package/server/lib/orchestration/completion-checker.js +101 -0
- package/server/lib/orchestration/completion-checker.test.js +177 -0
- package/server/lib/orchestration/result-verifier.js +143 -0
- package/server/lib/orchestration/result-verifier.test.js +291 -0
- package/server/lib/orchestration/session-dispatcher.js +99 -0
- package/server/lib/orchestration/session-dispatcher.test.js +215 -0
- package/server/lib/orchestration/session-status.js +147 -0
- package/server/lib/orchestration/session-status.test.js +130 -0
- package/server/lib/release/agent-runner-updates.js +24 -0
- package/server/lib/release/agent-runner-updates.test.js +22 -0
- package/server/lib/release/changelog-generator.js +142 -0
- package/server/lib/release/changelog-generator.test.js +113 -0
- package/server/lib/release/ci-watcher.js +83 -0
- package/server/lib/release/ci-watcher.test.js +81 -0
- package/server/lib/release/health-checker.js +111 -0
- package/server/lib/release/health-checker.test.js +121 -0
- package/server/lib/release/release-pipeline.js +187 -0
- package/server/lib/release/release-pipeline.test.js +262 -0
- package/server/lib/release/version-bumper.js +183 -0
- package/server/lib/release/version-bumper.test.js +142 -0
- package/server/lib/routing-preamble.integration.test.js +12 -0
- package/server/lib/routing-preamble.js +13 -2
- package/server/lib/routing-preamble.test.js +49 -0
- package/server/lib/scaffolding/ci-detector.js +139 -0
- package/server/lib/scaffolding/ci-detector.test.js +198 -0
- package/server/lib/scaffolding/ci-scaffolder.js +347 -0
- package/server/lib/scaffolding/ci-scaffolder.test.js +157 -0
- package/server/lib/scaffolding/deploy-detector.js +135 -0
- package/server/lib/scaffolding/deploy-detector.test.js +106 -0
- package/server/lib/scaffolding/health-scaffold.js +374 -0
- package/server/lib/scaffolding/health-scaffold.test.js +99 -0
- package/server/lib/scaffolding/logger-scaffold.js +196 -0
- package/server/lib/scaffolding/logger-scaffold.test.js +146 -0
- package/server/lib/scaffolding/migration-detector.js +78 -0
- package/server/lib/scaffolding/migration-detector.test.js +127 -0
- package/server/lib/scaffolding/snapshot-manager.js +142 -0
- package/server/lib/scaffolding/snapshot-manager.test.js +225 -0
- package/server/lib/task-router-config.js +50 -20
- package/server/lib/task-router-config.test.js +29 -15
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import fsp from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
|
|
7
|
+
let runRelease;
|
|
8
|
+
let ciWatcher;
|
|
9
|
+
let versionBumper;
|
|
10
|
+
let changelogGenerator;
|
|
11
|
+
let healthChecker;
|
|
12
|
+
|
|
13
|
+
describe('release/release-pipeline', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.resetModules();
|
|
16
|
+
vi.clearAllMocks();
|
|
17
|
+
|
|
18
|
+
ciWatcher = require('./ci-watcher.js');
|
|
19
|
+
versionBumper = require('./version-bumper.js');
|
|
20
|
+
changelogGenerator = require('./changelog-generator.js');
|
|
21
|
+
healthChecker = require('./health-checker.js');
|
|
22
|
+
|
|
23
|
+
vi.spyOn(fsp, 'readFile').mockImplementation(async (filePath) => {
|
|
24
|
+
if (String(filePath).endsWith('package.json')) {
|
|
25
|
+
return JSON.stringify({ name: 'tlc-server', version: '0.1.2' });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const error = new Error('missing');
|
|
29
|
+
error.code = 'ENOENT';
|
|
30
|
+
throw error;
|
|
31
|
+
});
|
|
32
|
+
vi.spyOn(fsp, 'mkdir').mockResolvedValue(undefined);
|
|
33
|
+
vi.spyOn(fsp, 'writeFile').mockResolvedValue(undefined);
|
|
34
|
+
|
|
35
|
+
vi.spyOn(ciWatcher, 'watchCi').mockResolvedValue('green');
|
|
36
|
+
vi.spyOn(versionBumper, 'determineNextVersion').mockReturnValue('0.2.0');
|
|
37
|
+
vi.spyOn(versionBumper, 'applyVersionBump').mockResolvedValue({ version: '0.2.0' });
|
|
38
|
+
vi.spyOn(changelogGenerator, 'generateChangelog')
|
|
39
|
+
.mockReturnValue('## v0.2.0 - 2026-03-31\n\n- Release notes');
|
|
40
|
+
vi.spyOn(changelogGenerator, 'prependToChangelog')
|
|
41
|
+
.mockReturnValue('# Changelog\n\n## v0.2.0 - 2026-03-31\n\n- Release notes\n');
|
|
42
|
+
vi.spyOn(healthChecker, 'checkHealth').mockResolvedValue({
|
|
43
|
+
ok: true,
|
|
44
|
+
attempts: 1,
|
|
45
|
+
status: 200,
|
|
46
|
+
error: null,
|
|
47
|
+
});
|
|
48
|
+
vi.spyOn(healthChecker, 'rollback').mockResolvedValue({
|
|
49
|
+
ok: true,
|
|
50
|
+
branch: 'main',
|
|
51
|
+
previousSha: 'abc123',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
({ runRelease } = require('./release-pipeline.js'));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('runs the full release pipeline and records a successful completion', async () => {
|
|
58
|
+
const exec = vi.fn(async (command) => {
|
|
59
|
+
if (command === 'git rev-parse HEAD') {
|
|
60
|
+
return { stdout: 'abc123\n' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (command === 'git log -20 --pretty=format:%H%x09%s') {
|
|
64
|
+
return {
|
|
65
|
+
stdout: [
|
|
66
|
+
'1111111\tfeat: add automated release pipeline',
|
|
67
|
+
'2222222\tfix: handle flaky health polling',
|
|
68
|
+
].join('\n'),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { stdout: '' };
|
|
73
|
+
});
|
|
74
|
+
const fetch = vi.fn();
|
|
75
|
+
|
|
76
|
+
const result = await runRelease({
|
|
77
|
+
branch: 'main',
|
|
78
|
+
config: {
|
|
79
|
+
deploy: { healthUrl: 'https://example.com/health' },
|
|
80
|
+
publish: { npm: true },
|
|
81
|
+
},
|
|
82
|
+
exec,
|
|
83
|
+
fetch,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(ciWatcher.watchCi).toHaveBeenCalledWith({ branch: 'main', exec });
|
|
87
|
+
expect(versionBumper.determineNextVersion).toHaveBeenCalledWith({
|
|
88
|
+
currentVersion: '0.1.2',
|
|
89
|
+
commits: [
|
|
90
|
+
{ sha: '1111111', message: 'feat: add automated release pipeline' },
|
|
91
|
+
{ sha: '2222222', message: 'fix: handle flaky health polling' },
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
expect(changelogGenerator.generateChangelog).toHaveBeenCalledWith({
|
|
95
|
+
version: 'v0.2.0',
|
|
96
|
+
date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
|
|
97
|
+
commits: [
|
|
98
|
+
{ sha: '1111111', message: 'feat: add automated release pipeline' },
|
|
99
|
+
{ sha: '2222222', message: 'fix: handle flaky health polling' },
|
|
100
|
+
],
|
|
101
|
+
});
|
|
102
|
+
expect(versionBumper.applyVersionBump).toHaveBeenCalledWith({
|
|
103
|
+
version: '0.2.0',
|
|
104
|
+
packageJsonPath: expect.stringContaining('/server/package.json'),
|
|
105
|
+
fs: expect.objectContaining({
|
|
106
|
+
readFile: expect.any(Function),
|
|
107
|
+
writeFile: expect.any(Function),
|
|
108
|
+
}),
|
|
109
|
+
});
|
|
110
|
+
expect(changelogGenerator.prependToChangelog).toHaveBeenCalledWith({
|
|
111
|
+
changelogPath: expect.stringContaining('/server/CHANGELOG.md'),
|
|
112
|
+
content: '## v0.2.0 - 2026-03-31\n\n- Release notes',
|
|
113
|
+
fs: expect.any(Object),
|
|
114
|
+
});
|
|
115
|
+
expect(exec).toHaveBeenCalledWith(expect.stringContaining("git add"));
|
|
116
|
+
expect(exec).toHaveBeenCalledWith("git commit -m 'chore(release): v0.2.0'");
|
|
117
|
+
expect(exec).toHaveBeenCalledWith('git tag v0.2.0');
|
|
118
|
+
expect(exec).toHaveBeenCalledWith("git push origin 'main'");
|
|
119
|
+
expect(exec).toHaveBeenCalledWith('git push origin v0.2.0');
|
|
120
|
+
expect(exec).toHaveBeenCalledWith('npm publish');
|
|
121
|
+
expect(healthChecker.checkHealth).toHaveBeenCalledWith({
|
|
122
|
+
healthUrl: 'https://example.com/health',
|
|
123
|
+
fetch,
|
|
124
|
+
});
|
|
125
|
+
expect(healthChecker.rollback).not.toHaveBeenCalled();
|
|
126
|
+
expect(fsp.mkdir).toHaveBeenCalledWith(expect.stringContaining('/server/.tlc'), { recursive: true });
|
|
127
|
+
expect(fsp.writeFile).toHaveBeenCalledWith(
|
|
128
|
+
expect.stringContaining('/server/.tlc/.agent-completions.json'),
|
|
129
|
+
expect.stringContaining('"status": "released"'),
|
|
130
|
+
'utf8'
|
|
131
|
+
);
|
|
132
|
+
expect(result).toEqual({
|
|
133
|
+
ok: true,
|
|
134
|
+
status: 'released',
|
|
135
|
+
branch: 'main',
|
|
136
|
+
version: '0.2.0',
|
|
137
|
+
ciStatus: 'green',
|
|
138
|
+
published: true,
|
|
139
|
+
health: {
|
|
140
|
+
ok: true,
|
|
141
|
+
attempts: 1,
|
|
142
|
+
status: 200,
|
|
143
|
+
error: null,
|
|
144
|
+
},
|
|
145
|
+
summary: 'Release completed for v0.2.0',
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('returns a stuck summary when CI does not go green', async () => {
|
|
150
|
+
const exec = vi.fn().mockResolvedValue({ stdout: 'abc123\n' });
|
|
151
|
+
const fetch = vi.fn();
|
|
152
|
+
|
|
153
|
+
ciWatcher.watchCi.mockResolvedValue('stuck');
|
|
154
|
+
|
|
155
|
+
const result = await runRelease({
|
|
156
|
+
branch: 'release/next',
|
|
157
|
+
config: {
|
|
158
|
+
deploy: { healthUrl: 'https://example.com/health' },
|
|
159
|
+
publish: { npm: false },
|
|
160
|
+
},
|
|
161
|
+
exec,
|
|
162
|
+
fetch,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
expect(versionBumper.determineNextVersion).not.toHaveBeenCalled();
|
|
166
|
+
expect(healthChecker.checkHealth).not.toHaveBeenCalled();
|
|
167
|
+
expect(result).toEqual({
|
|
168
|
+
ok: false,
|
|
169
|
+
status: 'stuck',
|
|
170
|
+
branch: 'release/next',
|
|
171
|
+
ciStatus: 'stuck',
|
|
172
|
+
summary: 'Release halted because CI is stuck',
|
|
173
|
+
});
|
|
174
|
+
expect(fsp.writeFile).toHaveBeenCalledWith(
|
|
175
|
+
expect.stringContaining('/server/.tlc/.agent-completions.json'),
|
|
176
|
+
expect.stringContaining('"status": "stuck"'),
|
|
177
|
+
'utf8'
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('rolls back when the post-release health check fails', async () => {
|
|
182
|
+
const exec = vi.fn(async (command) => {
|
|
183
|
+
if (command === 'git rev-parse HEAD') {
|
|
184
|
+
return { stdout: 'deadbeef\n' };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (command === 'git log -20 --pretty=format:%H%x09%s') {
|
|
188
|
+
return {
|
|
189
|
+
stdout: '3333333\tfix: patch release regression',
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { stdout: '' };
|
|
194
|
+
});
|
|
195
|
+
const fetch = vi.fn();
|
|
196
|
+
|
|
197
|
+
healthChecker.checkHealth.mockResolvedValue({
|
|
198
|
+
ok: false,
|
|
199
|
+
attempts: 3,
|
|
200
|
+
status: null,
|
|
201
|
+
error: 'connection refused',
|
|
202
|
+
});
|
|
203
|
+
healthChecker.rollback.mockResolvedValue({
|
|
204
|
+
ok: true,
|
|
205
|
+
branch: 'main',
|
|
206
|
+
previousSha: 'deadbeef',
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const result = await runRelease({
|
|
210
|
+
branch: 'main',
|
|
211
|
+
config: {
|
|
212
|
+
deploy: { healthUrl: 'https://example.com/health' },
|
|
213
|
+
publish: { npm: false },
|
|
214
|
+
},
|
|
215
|
+
exec,
|
|
216
|
+
fetch,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(healthChecker.rollback).toHaveBeenCalledWith({
|
|
220
|
+
branch: 'main',
|
|
221
|
+
previousSha: 'deadbeef',
|
|
222
|
+
exec,
|
|
223
|
+
});
|
|
224
|
+
expect(result).toEqual({
|
|
225
|
+
ok: false,
|
|
226
|
+
status: 'rolled_back',
|
|
227
|
+
branch: 'main',
|
|
228
|
+
version: '0.2.0',
|
|
229
|
+
ciStatus: 'green',
|
|
230
|
+
published: false,
|
|
231
|
+
health: {
|
|
232
|
+
ok: false,
|
|
233
|
+
attempts: 3,
|
|
234
|
+
status: null,
|
|
235
|
+
error: 'connection refused',
|
|
236
|
+
},
|
|
237
|
+
rollback: {
|
|
238
|
+
ok: true,
|
|
239
|
+
branch: 'main',
|
|
240
|
+
previousSha: 'deadbeef',
|
|
241
|
+
},
|
|
242
|
+
summary: 'Release rolled back after failing health checks for v0.2.0',
|
|
243
|
+
});
|
|
244
|
+
expect(fsp.writeFile).toHaveBeenCalledWith(
|
|
245
|
+
expect.stringContaining('/server/.tlc/.agent-completions.json'),
|
|
246
|
+
expect.stringContaining('"status": "rolled_back"'),
|
|
247
|
+
'utf8'
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('validates that deploy.healthUrl is configured', async () => {
|
|
252
|
+
await expect(runRelease({
|
|
253
|
+
branch: 'main',
|
|
254
|
+
config: {
|
|
255
|
+
deploy: {},
|
|
256
|
+
publish: { npm: false },
|
|
257
|
+
},
|
|
258
|
+
exec: vi.fn(),
|
|
259
|
+
fetch: vi.fn(),
|
|
260
|
+
})).rejects.toThrow('config.deploy.healthUrl is required');
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Determine the next semantic version from conventional commits and apply it
|
|
3
|
+
* to a package.json file through injected filesystem helpers.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)$/;
|
|
7
|
+
const BREAKING_CHANGE_PATTERN = /^BREAKING[ -]CHANGE:/m;
|
|
8
|
+
const HEADER_PATTERN = /^([a-z]+)(\([^)]+\))?(!)?:\s+(.+)$/i;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse a simple semver string.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} version
|
|
14
|
+
* @returns {{major: number, minor: number, patch: number}}
|
|
15
|
+
*/
|
|
16
|
+
function parseVersion(version) {
|
|
17
|
+
if (typeof version !== 'string') {
|
|
18
|
+
throw new TypeError('currentVersion must be a semver string');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const match = version.trim().match(SEMVER_PATTERN);
|
|
22
|
+
if (!match) {
|
|
23
|
+
throw new Error(`Invalid semver version: ${version}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
major: Number(match[1]),
|
|
28
|
+
minor: Number(match[2]),
|
|
29
|
+
patch: Number(match[3]),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Normalize a commit item into a message string.
|
|
35
|
+
*
|
|
36
|
+
* @param {string|object} commit
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
function getCommitMessage(commit) {
|
|
40
|
+
if (typeof commit === 'string') {
|
|
41
|
+
return commit;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (commit && typeof commit.message === 'string') {
|
|
45
|
+
return commit.message;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (commit && typeof commit.subject === 'string') {
|
|
49
|
+
return commit.subject;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Determine the bump level represented by a single commit.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} message
|
|
59
|
+
* @returns {'major'|'minor'|'patch'|null}
|
|
60
|
+
*/
|
|
61
|
+
function getCommitBump(message) {
|
|
62
|
+
const normalized = message.trim();
|
|
63
|
+
if (!normalized) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (BREAKING_CHANGE_PATTERN.test(normalized)) {
|
|
68
|
+
return 'major';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const header = normalized.split('\n', 1)[0];
|
|
72
|
+
const match = header.match(HEADER_PATTERN);
|
|
73
|
+
if (!match) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const type = match[1].toLowerCase();
|
|
78
|
+
const breaking = Boolean(match[3]);
|
|
79
|
+
|
|
80
|
+
if (breaking) {
|
|
81
|
+
return 'major';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (type === 'feat') {
|
|
85
|
+
return 'minor';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (type === 'fix' || type === 'perf') {
|
|
89
|
+
return 'patch';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Bump a parsed version by a bump level.
|
|
97
|
+
*
|
|
98
|
+
* @param {{major: number, minor: number, patch: number}} version
|
|
99
|
+
* @param {'major'|'minor'|'patch'|null} bump
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
function bumpVersion(version, bump) {
|
|
103
|
+
if (bump === 'major') {
|
|
104
|
+
return `${version.major + 1}.0.0`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (bump === 'minor') {
|
|
108
|
+
return `${version.major}.${version.minor + 1}.0`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (bump === 'patch') {
|
|
112
|
+
return `${version.major}.${version.minor}.${version.patch + 1}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return `${version.major}.${version.minor}.${version.patch}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Determine the next semantic version from a list of conventional commits.
|
|
120
|
+
*
|
|
121
|
+
* @param {{currentVersion: string, commits: Array<string|{message?: string, subject?: string}>}} params
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
function determineNextVersion({ currentVersion, commits }) {
|
|
125
|
+
const version = parseVersion(currentVersion);
|
|
126
|
+
|
|
127
|
+
if (!Array.isArray(commits)) {
|
|
128
|
+
throw new TypeError('commits must be an array');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let highestBump = null;
|
|
132
|
+
|
|
133
|
+
for (const commit of commits) {
|
|
134
|
+
const bump = getCommitBump(getCommitMessage(commit));
|
|
135
|
+
|
|
136
|
+
if (bump === 'major') {
|
|
137
|
+
highestBump = 'major';
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (bump === 'minor') {
|
|
142
|
+
highestBump = highestBump === 'patch' ? 'minor' : 'minor';
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (bump === 'patch' && highestBump === null) {
|
|
147
|
+
highestBump = 'patch';
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return bumpVersion(version, highestBump);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Update the version field in a package.json file.
|
|
156
|
+
*
|
|
157
|
+
* @param {{version: string, packageJsonPath: string, fs: {readFile: Function, writeFile: Function}}} params
|
|
158
|
+
* @returns {Promise<object>}
|
|
159
|
+
*/
|
|
160
|
+
async function applyVersionBump({ version, packageJsonPath, fs }) {
|
|
161
|
+
parseVersion(version);
|
|
162
|
+
|
|
163
|
+
if (!packageJsonPath || typeof packageJsonPath !== 'string') {
|
|
164
|
+
throw new TypeError('packageJsonPath must be a string');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!fs || typeof fs.readFile !== 'function' || typeof fs.writeFile !== 'function') {
|
|
168
|
+
throw new TypeError('fs must provide readFile and writeFile functions');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const packageJson = await fs.readFile(packageJsonPath, 'utf8');
|
|
172
|
+
const packageData = JSON.parse(packageJson);
|
|
173
|
+
packageData.version = version;
|
|
174
|
+
|
|
175
|
+
await fs.writeFile(packageJsonPath, `${JSON.stringify(packageData, null, 2)}\n`, 'utf8');
|
|
176
|
+
|
|
177
|
+
return packageData;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = {
|
|
181
|
+
determineNextVersion,
|
|
182
|
+
applyVersionBump,
|
|
183
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import versionBumper from './version-bumper.js';
|
|
3
|
+
|
|
4
|
+
const { determineNextVersion, applyVersionBump } = versionBumper;
|
|
5
|
+
|
|
6
|
+
describe('version-bumper', () => {
|
|
7
|
+
describe('determineNextVersion', () => {
|
|
8
|
+
it('returns the same version when no release-worthy commits are present', () => {
|
|
9
|
+
const nextVersion = determineNextVersion({
|
|
10
|
+
currentVersion: '1.2.3',
|
|
11
|
+
commits: ['docs: update release notes', 'chore: refresh snapshots'],
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
expect(nextVersion).toBe('1.2.3');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('bumps patch for fix commits', () => {
|
|
18
|
+
const nextVersion = determineNextVersion({
|
|
19
|
+
currentVersion: '1.2.3',
|
|
20
|
+
commits: ['fix: handle missing session state'],
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(nextVersion).toBe('1.2.4');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('treats perf commits as patch releases', () => {
|
|
27
|
+
const nextVersion = determineNextVersion({
|
|
28
|
+
currentVersion: '1.2.3',
|
|
29
|
+
commits: ['perf: reduce websocket reconnect churn'],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(nextVersion).toBe('1.2.4');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('bumps minor for feat commits', () => {
|
|
36
|
+
const nextVersion = determineNextVersion({
|
|
37
|
+
currentVersion: '1.2.3',
|
|
38
|
+
commits: ['feat(api): add release audit endpoint', 'fix: log errors'],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(nextVersion).toBe('1.3.0');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('bumps major for bang syntax breaking changes', () => {
|
|
45
|
+
const nextVersion = determineNextVersion({
|
|
46
|
+
currentVersion: '1.2.3',
|
|
47
|
+
commits: ['feat!: remove deprecated release payload'],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(nextVersion).toBe('2.0.0');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('bumps major for BREAKING CHANGE footers', () => {
|
|
54
|
+
const nextVersion = determineNextVersion({
|
|
55
|
+
currentVersion: '1.2.3',
|
|
56
|
+
commits: [
|
|
57
|
+
{
|
|
58
|
+
message: [
|
|
59
|
+
'refactor: simplify release config loading',
|
|
60
|
+
'',
|
|
61
|
+
'BREAKING CHANGE: release config now requires explicit tier names',
|
|
62
|
+
].join('\n'),
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(nextVersion).toBe('2.0.0');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('accepts versions with a leading v', () => {
|
|
71
|
+
const nextVersion = determineNextVersion({
|
|
72
|
+
currentVersion: 'v1.2.3',
|
|
73
|
+
commits: ['fix: repair preview URL generation'],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(nextVersion).toBe('1.2.4');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('throws for invalid inputs', () => {
|
|
80
|
+
expect(() => determineNextVersion({
|
|
81
|
+
currentVersion: '1.2',
|
|
82
|
+
commits: [],
|
|
83
|
+
})).toThrow(/invalid semver/i);
|
|
84
|
+
|
|
85
|
+
expect(() => determineNextVersion({
|
|
86
|
+
currentVersion: '1.2.3',
|
|
87
|
+
commits: 'fix: nope',
|
|
88
|
+
})).toThrow(/commits must be an array/i);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('applyVersionBump', () => {
|
|
93
|
+
it('writes the bumped version back to package.json through injected fs', async () => {
|
|
94
|
+
const fs = {
|
|
95
|
+
readFile: vi.fn().mockResolvedValue('{"name":"tlc-server","version":"0.1.2"}'),
|
|
96
|
+
writeFile: vi.fn().mockResolvedValue(undefined),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const updated = await applyVersionBump({
|
|
100
|
+
version: '0.2.0',
|
|
101
|
+
packageJsonPath: '/tmp/package.json',
|
|
102
|
+
fs,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(updated).toEqual({
|
|
106
|
+
name: 'tlc-server',
|
|
107
|
+
version: '0.2.0',
|
|
108
|
+
});
|
|
109
|
+
expect(fs.readFile).toHaveBeenCalledWith('/tmp/package.json', 'utf8');
|
|
110
|
+
expect(fs.writeFile).toHaveBeenCalledWith(
|
|
111
|
+
'/tmp/package.json',
|
|
112
|
+
'{\n "name": "tlc-server",\n "version": "0.2.0"\n}\n',
|
|
113
|
+
'utf8'
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('validates inputs before writing', async () => {
|
|
118
|
+
const fs = {
|
|
119
|
+
readFile: vi.fn(),
|
|
120
|
+
writeFile: vi.fn(),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
await expect(applyVersionBump({
|
|
124
|
+
version: '1.0',
|
|
125
|
+
packageJsonPath: '/tmp/package.json',
|
|
126
|
+
fs,
|
|
127
|
+
})).rejects.toThrow(/invalid semver/i);
|
|
128
|
+
|
|
129
|
+
await expect(applyVersionBump({
|
|
130
|
+
version: '1.0.0',
|
|
131
|
+
packageJsonPath: '',
|
|
132
|
+
fs,
|
|
133
|
+
})).rejects.toThrow(/packageJsonPath must be a string/i);
|
|
134
|
+
|
|
135
|
+
await expect(applyVersionBump({
|
|
136
|
+
version: '1.0.0',
|
|
137
|
+
packageJsonPath: '/tmp/package.json',
|
|
138
|
+
fs: {},
|
|
139
|
+
})).rejects.toThrow(/fs must provide readFile and writeFile/i);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -92,6 +92,7 @@ describe('routing-preamble integration', () => {
|
|
|
92
92
|
expect(runPreamble({ commandName: 'build' }).result).toEqual({
|
|
93
93
|
models: ['claude'],
|
|
94
94
|
strategy: 'single',
|
|
95
|
+
mode: 'interactive',
|
|
95
96
|
source: 'shipped-defaults',
|
|
96
97
|
warnings: [],
|
|
97
98
|
});
|
|
@@ -110,6 +111,7 @@ describe('routing-preamble integration', () => {
|
|
|
110
111
|
).toEqual({
|
|
111
112
|
models: ['codex'],
|
|
112
113
|
strategy: 'single',
|
|
114
|
+
mode: 'interactive',
|
|
113
115
|
source: 'personal-config',
|
|
114
116
|
warnings: [expect.stringMatching(/models/i)],
|
|
115
117
|
});
|
|
@@ -128,6 +130,7 @@ describe('routing-preamble integration', () => {
|
|
|
128
130
|
).toEqual({
|
|
129
131
|
models: ['codex', 'claude'],
|
|
130
132
|
strategy: 'single',
|
|
133
|
+
mode: 'exec',
|
|
131
134
|
source: 'personal-config',
|
|
132
135
|
warnings: [],
|
|
133
136
|
});
|
|
@@ -151,6 +154,7 @@ describe('routing-preamble integration', () => {
|
|
|
151
154
|
).toEqual({
|
|
152
155
|
models: ['claude'],
|
|
153
156
|
strategy: 'parallel',
|
|
157
|
+
mode: 'inline',
|
|
154
158
|
source: 'project-override',
|
|
155
159
|
warnings: [expect.stringMatching(/models/i), expect.stringMatching(/models/i)],
|
|
156
160
|
});
|
|
@@ -174,6 +178,7 @@ describe('routing-preamble integration', () => {
|
|
|
174
178
|
).toEqual({
|
|
175
179
|
models: ['claude'],
|
|
176
180
|
strategy: 'single',
|
|
181
|
+
mode: 'inline',
|
|
177
182
|
source: 'project-override',
|
|
178
183
|
warnings: [],
|
|
179
184
|
});
|
|
@@ -198,6 +203,7 @@ describe('routing-preamble integration', () => {
|
|
|
198
203
|
).toEqual({
|
|
199
204
|
models: ['local-model'],
|
|
200
205
|
strategy: 'single',
|
|
206
|
+
mode: 'interactive',
|
|
201
207
|
source: 'flag-override',
|
|
202
208
|
warnings: [expect.stringMatching(/models/i), expect.stringMatching(/models/i)],
|
|
203
209
|
});
|
|
@@ -222,6 +228,7 @@ describe('routing-preamble integration', () => {
|
|
|
222
228
|
).toEqual({
|
|
223
229
|
models: ['codex'],
|
|
224
230
|
strategy: 'single',
|
|
231
|
+
mode: 'interactive',
|
|
225
232
|
source: 'personal-config',
|
|
226
233
|
providers,
|
|
227
234
|
warnings: [expect.stringMatching(/models/i)],
|
|
@@ -237,6 +244,7 @@ describe('routing-preamble integration', () => {
|
|
|
237
244
|
expect(result).toEqual({
|
|
238
245
|
models: ['claude'],
|
|
239
246
|
strategy: 'single',
|
|
247
|
+
mode: 'interactive',
|
|
240
248
|
source: 'shipped-defaults',
|
|
241
249
|
warnings: [expect.stringContaining(path.join(homeDir, '.tlc', 'config.json'))],
|
|
242
250
|
});
|
|
@@ -252,6 +260,7 @@ describe('routing-preamble integration', () => {
|
|
|
252
260
|
expect(result).toEqual({
|
|
253
261
|
models: ['claude'],
|
|
254
262
|
strategy: 'single',
|
|
263
|
+
mode: 'interactive',
|
|
255
264
|
source: 'shipped-defaults',
|
|
256
265
|
warnings: [expect.stringContaining(path.join(cwd, '.tlc.json'))],
|
|
257
266
|
});
|
|
@@ -272,6 +281,7 @@ describe('routing-preamble integration', () => {
|
|
|
272
281
|
).toEqual({
|
|
273
282
|
models: ['codex'],
|
|
274
283
|
strategy: 'single',
|
|
284
|
+
mode: 'interactive',
|
|
275
285
|
source: 'personal-config',
|
|
276
286
|
warnings: [expect.stringMatching(/models/i)],
|
|
277
287
|
});
|
|
@@ -290,6 +300,7 @@ describe('routing-preamble integration', () => {
|
|
|
290
300
|
).toEqual({
|
|
291
301
|
models: ['codex'],
|
|
292
302
|
strategy: 'parallel',
|
|
303
|
+
mode: 'inline',
|
|
293
304
|
source: 'personal-config',
|
|
294
305
|
warnings: [expect.stringMatching(/models/i)],
|
|
295
306
|
});
|
|
@@ -312,6 +323,7 @@ describe('routing-preamble integration', () => {
|
|
|
312
323
|
).toEqual({
|
|
313
324
|
models: ['codex', 'claude'],
|
|
314
325
|
strategy: 'parallel',
|
|
326
|
+
mode: 'interactive',
|
|
315
327
|
source: 'personal-config',
|
|
316
328
|
warnings: [],
|
|
317
329
|
});
|