tlc-claude-code 2.6.1 → 2.8.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/audit.md +18 -1
- package/.claude/commands/tlc/autofix.md +17 -1
- package/.claude/commands/tlc/build.md +37 -2
- package/.claude/commands/tlc/coverage.md +16 -0
- package/.claude/commands/tlc/discuss.md +15 -0
- package/.claude/commands/tlc/init.md +19 -0
- package/.claude/commands/tlc/plan.md +35 -6
- package/.claude/commands/tlc/preflight.md +16 -0
- package/.claude/commands/tlc/progress.md +41 -15
- package/.claude/commands/tlc/refactor.md +17 -1
- package/.claude/commands/tlc/review-pr.md +19 -10
- package/.claude/commands/tlc/review.md +16 -0
- package/.claude/commands/tlc/status.md +23 -3
- package/.claude/commands/tlc/tlc.md +32 -16
- package/.claude/hooks/tlc-session-init.sh +24 -0
- package/CLAUDE.md +14 -0
- package/bin/install.js +66 -0
- package/package.json +1 -1
- package/scripts/renumber-phases.js +283 -0
- package/scripts/renumber-phases.test.js +305 -0
- package/server/lib/workspace-manifest.js +138 -0
- package/server/lib/workspace-manifest.test.js +179 -0
package/CLAUDE.md
CHANGED
|
@@ -11,6 +11,20 @@
|
|
|
11
11
|
5. **No Co-Authored-By in commits.** The user is the author. Claude is a tool.
|
|
12
12
|
6. **Ask before `git push`.** Never push without explicit approval.
|
|
13
13
|
|
|
14
|
+
## CodeDB
|
|
15
|
+
|
|
16
|
+
When `.mcp.json` contains a `codedb` server entry, prefer CodeDB MCP tools over built-in equivalents:
|
|
17
|
+
|
|
18
|
+
| Prefer | Instead of | Use for |
|
|
19
|
+
|--------|------------|---------|
|
|
20
|
+
| `codedb_tree` | Glob | file/folder discovery |
|
|
21
|
+
| `codedb_search` / `codedb_word` | Grep | text/identifier search |
|
|
22
|
+
| `codedb_outline` | Read | understanding file structure (symbols, exports) |
|
|
23
|
+
| `codedb_deps` | manual import tracing | reverse dependency analysis |
|
|
24
|
+
| `codedb_bundle` | multiple separate calls | batch multiple queries into one call (up to 20 ops) |
|
|
25
|
+
|
|
26
|
+
If CodeDB MCP tools are not available (no `.mcp.json` or server unreachable), fall back to built-in Grep/Glob/Read.
|
|
27
|
+
|
|
14
28
|
## Command Dispatch
|
|
15
29
|
|
|
16
30
|
When the user says X → invoke `Skill(skill="tlc:...")`:
|
package/bin/install.js
CHANGED
|
@@ -190,6 +190,9 @@ function install(targetDir, installType) {
|
|
|
190
190
|
success(`Installed settings template with hook wiring`);
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
// Install CodeDB (codebase awareness — sub-ms code search, 1600x token reduction)
|
|
194
|
+
installCodedb();
|
|
195
|
+
|
|
193
196
|
// Fix ownership if running under sudo
|
|
194
197
|
if (isRunningAsSudo()) {
|
|
195
198
|
const claudeDir = path.dirname(targetDir);
|
|
@@ -215,6 +218,69 @@ function install(targetDir, installType) {
|
|
|
215
218
|
log('');
|
|
216
219
|
}
|
|
217
220
|
|
|
221
|
+
function installCodedb() {
|
|
222
|
+
const { execSync } = require('child_process');
|
|
223
|
+
|
|
224
|
+
// Check if already installed
|
|
225
|
+
try {
|
|
226
|
+
const version = execSync('codedb --version', { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
227
|
+
success(`CodeDB already installed: ${c.cyan}${version}${c.reset}`);
|
|
228
|
+
return;
|
|
229
|
+
} catch {
|
|
230
|
+
// Not installed — continue
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
log(`Installing ${c.cyan}CodeDB${c.reset} (codebase awareness)...`);
|
|
234
|
+
|
|
235
|
+
const platform = process.platform;
|
|
236
|
+
const arch = process.arch;
|
|
237
|
+
|
|
238
|
+
let asset;
|
|
239
|
+
if (platform === 'darwin' && arch === 'arm64') {
|
|
240
|
+
asset = 'codedb-darwin-arm64';
|
|
241
|
+
} else if (platform === 'linux' && arch === 'x64') {
|
|
242
|
+
asset = 'codedb-linux-x86_64';
|
|
243
|
+
} else {
|
|
244
|
+
log(`${c.yellow}CodeDB: no binary for ${platform}-${arch}. Skipping.${c.reset}`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const url = `https://github.com/justrach/codedb/releases/download/v0.2.4/${asset}`;
|
|
249
|
+
|
|
250
|
+
// Find writable bin directory
|
|
251
|
+
const binDirs = [
|
|
252
|
+
'/opt/homebrew/bin',
|
|
253
|
+
'/usr/local/bin',
|
|
254
|
+
path.join(process.env.HOME || '/root', '.local', 'bin'),
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
let targetBin;
|
|
258
|
+
for (const dir of binDirs) {
|
|
259
|
+
try {
|
|
260
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
261
|
+
fs.accessSync(dir, fs.constants.W_OK);
|
|
262
|
+
targetBin = path.join(dir, 'codedb');
|
|
263
|
+
break;
|
|
264
|
+
} catch {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (!targetBin) {
|
|
270
|
+
log(`${c.yellow}CodeDB: no writable bin directory found. Install manually: curl -fsSL https://codedb.codegraff.com/install.sh | sh${c.reset}`);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
execSync(`curl -fsSL -o "${targetBin}" "${url}"`, { stdio: 'pipe' });
|
|
276
|
+
fs.chmodSync(targetBin, 0o755);
|
|
277
|
+
const version = execSync(`"${targetBin}" --version`, { encoding: 'utf8', stdio: 'pipe' }).trim();
|
|
278
|
+
success(`CodeDB installed: ${c.cyan}${version}${c.reset} at ${c.dim}${targetBin}${c.reset}`);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
log(`${c.yellow}CodeDB: install failed (${err.message}). Install manually: curl -fsSL https://codedb.codegraff.com/install.sh | sh${c.reset}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
218
284
|
function installHooks(targetDir, packageRoot) {
|
|
219
285
|
// For local install: .claude/commands -> go up to .claude/hooks
|
|
220
286
|
// For global install: ~/.claude/commands -> go up to ~/.claude/hooks
|
package/package.json
CHANGED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const ALLOWED_SUFFIXES = new Set([
|
|
5
|
+
'PLAN',
|
|
6
|
+
'DISCUSSION',
|
|
7
|
+
'TESTS',
|
|
8
|
+
'VERIFIED',
|
|
9
|
+
'TEST-PLAN',
|
|
10
|
+
'ARCHITECTURE',
|
|
11
|
+
'COMPLETION-PLAN',
|
|
12
|
+
'NOTE',
|
|
13
|
+
'AUDIT-REPORT',
|
|
14
|
+
'RESEARCH',
|
|
15
|
+
'DISCUSSION-addendum',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const PHASE_FILE_PATTERN = /^([A-Za-z0-9]+)-([A-Za-z-]+)\.md(\.superseded)?$/;
|
|
19
|
+
|
|
20
|
+
function printUsage(stream = process.stderr) {
|
|
21
|
+
stream.write(
|
|
22
|
+
'Usage: node scripts/renumber-phases.js --repo-path <path> --prefix <PREFIX> [--dry-run]\n'
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseArgs(argv) {
|
|
27
|
+
const options = {
|
|
28
|
+
dryRun: false,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
32
|
+
const arg = argv[index];
|
|
33
|
+
|
|
34
|
+
if (arg === '--dry-run') {
|
|
35
|
+
options.dryRun = true;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (arg === '--repo-path' || arg === '--prefix') {
|
|
40
|
+
const value = argv[index + 1];
|
|
41
|
+
if (!value || value.startsWith('--')) {
|
|
42
|
+
throw new Error(`Missing value for ${arg}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (arg === '--repo-path') {
|
|
46
|
+
options.repoPath = value;
|
|
47
|
+
} else {
|
|
48
|
+
options.prefix = value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
index += 1;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!options.repoPath || !options.prefix) {
|
|
59
|
+
throw new Error('Missing required arguments');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return options;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function escapeRegExp(value) {
|
|
66
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createPhaseReferenceRegex(prefix) {
|
|
70
|
+
return new RegExp(`\\bPhase\\s+(${escapeRegExp(prefix)}-)?(\\d+[A-Za-z]?)\\b`, 'g');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function updatePhaseReferences(content, prefix) {
|
|
74
|
+
const regex = createPhaseReferenceRegex(prefix);
|
|
75
|
+
let replacements = 0;
|
|
76
|
+
|
|
77
|
+
const updated = content.replace(regex, (match, existingPrefix, phaseId) => {
|
|
78
|
+
if (existingPrefix) {
|
|
79
|
+
return match;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
replacements += 1;
|
|
83
|
+
return `Phase ${prefix}-${phaseId}`;
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
content: updated,
|
|
88
|
+
replacements,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isEligiblePhaseFile(name, prefix) {
|
|
93
|
+
const match = name.match(PHASE_FILE_PATTERN);
|
|
94
|
+
if (!match) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const [, phaseId, suffix, supersededExtension = ''] = match;
|
|
99
|
+
|
|
100
|
+
if (phaseId.startsWith(`${prefix}-`)) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!/^\d+[A-Za-z]?$/.test(phaseId)) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!ALLOWED_SUFFIXES.has(suffix)) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
phaseId,
|
|
114
|
+
suffix,
|
|
115
|
+
supersededExtension,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function collectOperations(repoPath, prefix) {
|
|
120
|
+
const phasesDir = path.join(repoPath, '.planning', 'phases');
|
|
121
|
+
if (!fs.existsSync(phasesDir) || !fs.statSync(phasesDir).isDirectory()) {
|
|
122
|
+
throw new Error(`Missing phases directory: ${phasesDir}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const roadmapPath = path.join(repoPath, '.planning', 'ROADMAP.md');
|
|
126
|
+
const operations = [];
|
|
127
|
+
const errors = [];
|
|
128
|
+
|
|
129
|
+
for (const entry of fs.readdirSync(phasesDir, { withFileTypes: true })) {
|
|
130
|
+
if (!entry.isFile()) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const fileInfo = isEligiblePhaseFile(entry.name, prefix);
|
|
135
|
+
if (!fileInfo) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const nextName = `${prefix}-${fileInfo.phaseId}-${fileInfo.suffix}.md${fileInfo.supersededExtension}`;
|
|
140
|
+
operations.push({
|
|
141
|
+
type: 'rename-file',
|
|
142
|
+
from: path.join(phasesDir, entry.name),
|
|
143
|
+
to: path.join(phasesDir, nextName),
|
|
144
|
+
phaseId: fileInfo.phaseId,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (fs.existsSync(roadmapPath)) {
|
|
149
|
+
const roadmapContent = fs.readFileSync(roadmapPath, 'utf8');
|
|
150
|
+
const updatedRoadmap = updatePhaseReferences(roadmapContent, prefix);
|
|
151
|
+
|
|
152
|
+
if (updatedRoadmap.replacements > 0) {
|
|
153
|
+
operations.push({
|
|
154
|
+
type: 'update-file',
|
|
155
|
+
target: roadmapPath,
|
|
156
|
+
nextContent: updatedRoadmap.content,
|
|
157
|
+
replacements: updatedRoadmap.replacements,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const renameOperation of operations.filter((operation) => operation.type === 'rename-file')) {
|
|
163
|
+
const content = fs.readFileSync(renameOperation.from, 'utf8');
|
|
164
|
+
const updated = updatePhaseReferences(content, prefix);
|
|
165
|
+
|
|
166
|
+
if (updated.replacements > 0) {
|
|
167
|
+
operations.push({
|
|
168
|
+
type: 'update-renamed-file',
|
|
169
|
+
target: renameOperation.to,
|
|
170
|
+
sourcePath: renameOperation.from,
|
|
171
|
+
nextContent: updated.content,
|
|
172
|
+
replacements: updated.replacements,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const renameOperation of operations.filter((operation) => operation.type === 'rename-file')) {
|
|
178
|
+
if (!fs.existsSync(renameOperation.to)) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
errors.push(
|
|
183
|
+
`Cannot rename ${path.basename(renameOperation.from)} because ${path.basename(renameOperation.to)} already exists`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
operations,
|
|
189
|
+
errors,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function applyOperations(operations, dryRun) {
|
|
194
|
+
const summary = {
|
|
195
|
+
renamed: 0,
|
|
196
|
+
updated: 0,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
for (const operation of operations) {
|
|
200
|
+
if (operation.type === 'rename-file') {
|
|
201
|
+
if (dryRun) {
|
|
202
|
+
console.log(`DRY-RUN rename ${operation.from} -> ${operation.to}`);
|
|
203
|
+
} else {
|
|
204
|
+
fs.renameSync(operation.from, operation.to);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
summary.renamed += 1;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (operation.type === 'update-file' || operation.type === 'update-renamed-file') {
|
|
212
|
+
if (dryRun) {
|
|
213
|
+
console.log(`DRY-RUN update ${operation.target} (${operation.replacements} references)`);
|
|
214
|
+
} else {
|
|
215
|
+
fs.writeFileSync(operation.target, operation.nextContent);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
summary.updated += operation.replacements;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return summary;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function renumberPhases({ repoPath, prefix, dryRun = false }) {
|
|
226
|
+
const resolvedRepoPath = path.resolve(repoPath);
|
|
227
|
+
const trimmedPrefix = prefix.trim();
|
|
228
|
+
|
|
229
|
+
if (!trimmedPrefix) {
|
|
230
|
+
throw new Error('Prefix must not be empty');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const { operations, errors } = collectOperations(resolvedRepoPath, trimmedPrefix);
|
|
234
|
+
const summary = applyOperations(operations, dryRun);
|
|
235
|
+
|
|
236
|
+
for (const error of errors) {
|
|
237
|
+
console.error(error);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const finalSummary = {
|
|
241
|
+
renamed: summary.renamed,
|
|
242
|
+
updated: summary.updated,
|
|
243
|
+
errors: errors.length,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
console.log(
|
|
247
|
+
`Renamed: ${finalSummary.renamed} files, Updated: ${finalSummary.updated} references, Errors: ${finalSummary.errors}`
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
return finalSummary;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function main(argv = process.argv.slice(2)) {
|
|
254
|
+
try {
|
|
255
|
+
const options = parseArgs(argv);
|
|
256
|
+
renumberPhases(options);
|
|
257
|
+
return 0;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
260
|
+
|
|
261
|
+
if (message.startsWith('Missing required arguments') || message.startsWith('Missing value for')) {
|
|
262
|
+
printUsage(process.stderr);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
console.error(message);
|
|
266
|
+
return 1;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (require.main === module) {
|
|
271
|
+
process.exit(main());
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = {
|
|
275
|
+
ALLOWED_SUFFIXES,
|
|
276
|
+
PHASE_FILE_PATTERN,
|
|
277
|
+
collectOperations,
|
|
278
|
+
main,
|
|
279
|
+
parseArgs,
|
|
280
|
+
printUsage,
|
|
281
|
+
renumberPhases,
|
|
282
|
+
updatePhaseReferences,
|
|
283
|
+
};
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { spawnSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import renumberModule from './renumber-phases.js';
|
|
9
|
+
|
|
10
|
+
const { main, parseArgs, renumberPhases, updatePhaseReferences } = renumberModule;
|
|
11
|
+
|
|
12
|
+
const SCRIPT_PATH = path.join(process.cwd(), 'scripts', 'renumber-phases.js');
|
|
13
|
+
const tempDirs = [];
|
|
14
|
+
|
|
15
|
+
function createTempRepo(structure = {}) {
|
|
16
|
+
const repoPath = fs.mkdtempSync(path.join(os.tmpdir(), 'renumber-phases-'));
|
|
17
|
+
tempDirs.push(repoPath);
|
|
18
|
+
|
|
19
|
+
for (const [relativePath, content] of Object.entries(structure)) {
|
|
20
|
+
const filePath = path.join(repoPath, relativePath);
|
|
21
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
22
|
+
fs.writeFileSync(filePath, content);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return repoPath;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readFile(repoPath, relativePath) {
|
|
29
|
+
return fs.readFileSync(path.join(repoPath, relativePath), 'utf8');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fileExists(repoPath, relativePath) {
|
|
33
|
+
return fs.existsSync(path.join(repoPath, relativePath));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createPlanningRepo() {
|
|
37
|
+
return createTempRepo({
|
|
38
|
+
'.planning/ROADMAP.md': [
|
|
39
|
+
'# Roadmap',
|
|
40
|
+
'### Phase 108: Build the tool',
|
|
41
|
+
'Depends on Phase 107 and Phase 101b.',
|
|
42
|
+
'Already migrated: Phase TLC-999.',
|
|
43
|
+
'',
|
|
44
|
+
].join('\n'),
|
|
45
|
+
'.planning/phases/108-PLAN.md': [
|
|
46
|
+
'# Phase 108',
|
|
47
|
+
'Depends on Phase 107.',
|
|
48
|
+
'Review Phase 101b before release.',
|
|
49
|
+
'Already references Phase TLC-105 correctly.',
|
|
50
|
+
'',
|
|
51
|
+
].join('\n'),
|
|
52
|
+
'.planning/phases/108-DISCUSSION.md': 'Phase 108 requires context from Phase 107.\n',
|
|
53
|
+
'.planning/phases/101b-RESEARCH.md': 'Research for Phase 101b.\n',
|
|
54
|
+
'.planning/phases/108-PLAN.md.superseded': 'Superseded Phase 108 draft.\n',
|
|
55
|
+
'.planning/phases/TLC-105-PLAN.md': 'Already migrated.\n',
|
|
56
|
+
'.planning/phases/77-OldPLAN.md': 'Should be ignored.\n',
|
|
57
|
+
'.planning/phases/77-PLAN.md.md': 'Should be ignored.\n',
|
|
58
|
+
'.planning/phases/notes.txt': 'Should be ignored.\n',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
vi.restoreAllMocks();
|
|
64
|
+
|
|
65
|
+
while (tempDirs.length > 0) {
|
|
66
|
+
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('updatePhaseReferences', () => {
|
|
71
|
+
it('adds the prefix to bare phase references and leaves prefixed ones unchanged', () => {
|
|
72
|
+
const source = 'Phase 108 follows Phase 101b. Phase TLC-42 stays as-is.';
|
|
73
|
+
|
|
74
|
+
const result = updatePhaseReferences(source, 'TLC');
|
|
75
|
+
|
|
76
|
+
expect(result.content).toBe(
|
|
77
|
+
'Phase TLC-108 follows Phase TLC-101b. Phase TLC-42 stays as-is.'
|
|
78
|
+
);
|
|
79
|
+
expect(result.replacements).toBe(2);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('parseArgs', () => {
|
|
84
|
+
it('parses repo path, prefix, and dry-run', () => {
|
|
85
|
+
expect(
|
|
86
|
+
parseArgs(['--repo-path', '/tmp/repo', '--prefix', 'TLC', '--dry-run'])
|
|
87
|
+
).toEqual({
|
|
88
|
+
dryRun: true,
|
|
89
|
+
repoPath: '/tmp/repo',
|
|
90
|
+
prefix: 'TLC',
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('throws when required arguments are missing', () => {
|
|
95
|
+
expect(() => parseArgs(['--prefix', 'TLC'])).toThrow('Missing required arguments');
|
|
96
|
+
expect(() => parseArgs(['--repo-path', '/tmp/repo'])).toThrow('Missing required arguments');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('renumberPhases', () => {
|
|
101
|
+
it('renames matching files, updates roadmap references, and preserves ignored files', () => {
|
|
102
|
+
const repoPath = createPlanningRepo();
|
|
103
|
+
const stdout = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
104
|
+
const stderr = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
105
|
+
|
|
106
|
+
const summary = renumberPhases({
|
|
107
|
+
repoPath,
|
|
108
|
+
prefix: 'TLC',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(summary).toEqual({
|
|
112
|
+
renamed: 4,
|
|
113
|
+
updated: 10,
|
|
114
|
+
errors: 0,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(fileExists(repoPath, '.planning/phases/TLC-108-PLAN.md')).toBe(true);
|
|
118
|
+
expect(fileExists(repoPath, '.planning/phases/TLC-108-DISCUSSION.md')).toBe(true);
|
|
119
|
+
expect(fileExists(repoPath, '.planning/phases/TLC-101b-RESEARCH.md')).toBe(true);
|
|
120
|
+
expect(fileExists(repoPath, '.planning/phases/TLC-108-PLAN.md.superseded')).toBe(true);
|
|
121
|
+
|
|
122
|
+
expect(fileExists(repoPath, '.planning/phases/108-PLAN.md')).toBe(false);
|
|
123
|
+
expect(fileExists(repoPath, '.planning/phases/108-DISCUSSION.md')).toBe(false);
|
|
124
|
+
expect(fileExists(repoPath, '.planning/phases/101b-RESEARCH.md')).toBe(false);
|
|
125
|
+
expect(fileExists(repoPath, '.planning/phases/108-PLAN.md.superseded')).toBe(false);
|
|
126
|
+
|
|
127
|
+
expect(fileExists(repoPath, '.planning/phases/TLC-105-PLAN.md')).toBe(true);
|
|
128
|
+
expect(fileExists(repoPath, '.planning/phases/77-OldPLAN.md')).toBe(true);
|
|
129
|
+
expect(fileExists(repoPath, '.planning/phases/77-PLAN.md.md')).toBe(true);
|
|
130
|
+
|
|
131
|
+
expect(readFile(repoPath, '.planning/ROADMAP.md')).toContain('### Phase TLC-108: Build the tool');
|
|
132
|
+
expect(readFile(repoPath, '.planning/ROADMAP.md')).toContain('Depends on Phase TLC-107 and Phase TLC-101b.');
|
|
133
|
+
expect(readFile(repoPath, '.planning/ROADMAP.md')).toContain('Already migrated: Phase TLC-999.');
|
|
134
|
+
|
|
135
|
+
expect(readFile(repoPath, '.planning/phases/TLC-108-PLAN.md')).toContain('Depends on Phase TLC-107.');
|
|
136
|
+
expect(readFile(repoPath, '.planning/phases/TLC-108-PLAN.md')).toContain(
|
|
137
|
+
'Review Phase TLC-101b before release.'
|
|
138
|
+
);
|
|
139
|
+
expect(readFile(repoPath, '.planning/phases/TLC-108-PLAN.md')).toContain(
|
|
140
|
+
'Already references Phase TLC-105 correctly.'
|
|
141
|
+
);
|
|
142
|
+
expect(readFile(repoPath, '.planning/phases/TLC-108-DISCUSSION.md')).toContain(
|
|
143
|
+
'Phase TLC-108 requires context from Phase TLC-107.'
|
|
144
|
+
);
|
|
145
|
+
expect(readFile(repoPath, '.planning/phases/TLC-108-PLAN.md.superseded')).toContain(
|
|
146
|
+
'Superseded Phase TLC-108 draft.'
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
expect(stdout).toHaveBeenLastCalledWith('Renamed: 4 files, Updated: 10 references, Errors: 0');
|
|
150
|
+
expect(stderr).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('supports dry-run without modifying files', () => {
|
|
154
|
+
const repoPath = createPlanningRepo();
|
|
155
|
+
const originalRoadmap = readFile(repoPath, '.planning/ROADMAP.md');
|
|
156
|
+
const originalPlan = readFile(repoPath, '.planning/phases/108-PLAN.md');
|
|
157
|
+
const stdout = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
158
|
+
|
|
159
|
+
const summary = renumberPhases({
|
|
160
|
+
repoPath,
|
|
161
|
+
prefix: 'TLC',
|
|
162
|
+
dryRun: true,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
expect(summary).toEqual({
|
|
166
|
+
renamed: 4,
|
|
167
|
+
updated: 10,
|
|
168
|
+
errors: 0,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(fileExists(repoPath, '.planning/phases/108-PLAN.md')).toBe(true);
|
|
172
|
+
expect(fileExists(repoPath, '.planning/phases/TLC-108-PLAN.md')).toBe(false);
|
|
173
|
+
expect(readFile(repoPath, '.planning/ROADMAP.md')).toBe(originalRoadmap);
|
|
174
|
+
expect(readFile(repoPath, '.planning/phases/108-PLAN.md')).toBe(originalPlan);
|
|
175
|
+
|
|
176
|
+
expect(stdout.mock.calls).toEqual(
|
|
177
|
+
expect.arrayContaining([
|
|
178
|
+
[expect.stringContaining('DRY-RUN rename')],
|
|
179
|
+
[expect.stringContaining('DRY-RUN update')],
|
|
180
|
+
['Renamed: 4 files, Updated: 10 references, Errors: 0'],
|
|
181
|
+
])
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('is idempotent when run again on already-prefixed files', () => {
|
|
186
|
+
const repoPath = createPlanningRepo();
|
|
187
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
188
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
189
|
+
|
|
190
|
+
const firstSummary = renumberPhases({
|
|
191
|
+
repoPath,
|
|
192
|
+
prefix: 'TLC',
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const secondSummary = renumberPhases({
|
|
196
|
+
repoPath,
|
|
197
|
+
prefix: 'TLC',
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(firstSummary).toEqual({
|
|
201
|
+
renamed: 4,
|
|
202
|
+
updated: 10,
|
|
203
|
+
errors: 0,
|
|
204
|
+
});
|
|
205
|
+
expect(secondSummary).toEqual({
|
|
206
|
+
renamed: 0,
|
|
207
|
+
updated: 0,
|
|
208
|
+
errors: 0,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(fileExists(repoPath, '.planning/phases/TLC-108-PLAN.md')).toBe(true);
|
|
212
|
+
expect(readFile(repoPath, '.planning/ROADMAP.md')).toContain('### Phase TLC-108: Build the tool');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('reports rename collisions as errors without overwriting files', () => {
|
|
216
|
+
const repoPath = createTempRepo({
|
|
217
|
+
'.planning/ROADMAP.md': '### Phase 108: Collision case\nPhase 108 only.\n',
|
|
218
|
+
'.planning/phases/108-PLAN.md': 'Phase 108.\n',
|
|
219
|
+
'.planning/phases/TLC-108-PLAN.md': 'Existing target.\n',
|
|
220
|
+
});
|
|
221
|
+
const stdout = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
222
|
+
const stderr = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
223
|
+
|
|
224
|
+
const summary = renumberPhases({
|
|
225
|
+
repoPath,
|
|
226
|
+
prefix: 'TLC',
|
|
227
|
+
dryRun: true,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(summary).toEqual({
|
|
231
|
+
renamed: 1,
|
|
232
|
+
updated: 3,
|
|
233
|
+
errors: 1,
|
|
234
|
+
});
|
|
235
|
+
expect(stderr).toHaveBeenCalledWith(
|
|
236
|
+
'Cannot rename 108-PLAN.md because TLC-108-PLAN.md already exists'
|
|
237
|
+
);
|
|
238
|
+
expect(stdout).toHaveBeenLastCalledWith('Renamed: 1 files, Updated: 3 references, Errors: 1');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('throws when the phases directory is missing', () => {
|
|
242
|
+
const repoPath = createTempRepo({
|
|
243
|
+
'.planning/ROADMAP.md': '### Phase 1: Missing phases dir\n',
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(() =>
|
|
247
|
+
renumberPhases({
|
|
248
|
+
repoPath,
|
|
249
|
+
prefix: 'TLC',
|
|
250
|
+
})
|
|
251
|
+
).toThrow(`Missing phases directory: ${path.join(repoPath, '.planning', 'phases')}`);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('main', () => {
|
|
256
|
+
it('returns 1 and prints usage for missing repo-path', () => {
|
|
257
|
+
const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
258
|
+
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
259
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
260
|
+
|
|
261
|
+
const exitCode = main(['--prefix', 'TLC']);
|
|
262
|
+
|
|
263
|
+
expect(exitCode).toBe(1);
|
|
264
|
+
expect(stdout).not.toHaveBeenCalled();
|
|
265
|
+
expect(stderr).toHaveBeenCalledWith(
|
|
266
|
+
'Usage: node scripts/renumber-phases.js --repo-path <path> --prefix <PREFIX> [--dry-run]\n'
|
|
267
|
+
);
|
|
268
|
+
expect(consoleError).toHaveBeenCalledWith('Missing required arguments');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('returns 1 when the phases directory does not exist', () => {
|
|
272
|
+
const repoPath = createTempRepo({
|
|
273
|
+
'.planning/ROADMAP.md': '### Phase 1: Missing phases dir\n',
|
|
274
|
+
});
|
|
275
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
276
|
+
|
|
277
|
+
const exitCode = main(['--repo-path', repoPath, '--prefix', 'TLC']);
|
|
278
|
+
|
|
279
|
+
expect(exitCode).toBe(1);
|
|
280
|
+
expect(consoleError).toHaveBeenCalledWith(
|
|
281
|
+
`Missing phases directory: ${path.join(repoPath, '.planning', 'phases')}`
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('works from the real CLI entry point', () => {
|
|
286
|
+
const repoPath = createTempRepo({
|
|
287
|
+
'.planning/ROADMAP.md': '### Phase 12: CLI check\nPhase 12 is ready.\n',
|
|
288
|
+
'.planning/phases/12-PLAN.md': 'Phase 12 is ready.\n',
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const result = spawnSync(
|
|
292
|
+
process.execPath,
|
|
293
|
+
[SCRIPT_PATH, '--repo-path', repoPath, '--prefix', 'TLC'],
|
|
294
|
+
{
|
|
295
|
+
encoding: 'utf8',
|
|
296
|
+
}
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
expect(result.status).toBe(0);
|
|
300
|
+
expect(result.stderr).toBe('');
|
|
301
|
+
expect(result.stdout).toContain('Renamed: 1 files, Updated: 3 references, Errors: 0');
|
|
302
|
+
expect(fileExists(repoPath, '.planning/phases/TLC-12-PLAN.md')).toBe(true);
|
|
303
|
+
expect(readFile(repoPath, '.planning/ROADMAP.md')).toContain('### Phase TLC-12: CLI check');
|
|
304
|
+
});
|
|
305
|
+
});
|