code-as-plan 2.0.0 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/install.js +58 -126
- package/cap/bin/lib/cap-migrate.cjs +513 -0
- package/cap/bin/lib/cap-tag-scanner.cjs +72 -0
- package/cap/workflows/update.md +24 -24
- package/commands/cap/migrate.md +177 -0
- package/commands/cap/update.md +48 -0
- package/hooks/dist/gsd-check-update.js +6 -6
- package/package.json +1 -1
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
// @cap-feature(feature:F-MIGRATE) GSD-to-CAP migration utility -- converts @gsd-* tags, planning artifacts, and session format to CAP v2.0.
|
|
2
|
+
// @cap-todo decision: Regex-based tag replacement (not AST) -- language-agnostic, zero dependencies, handles all comment styles.
|
|
3
|
+
// @cap-todo risk: Destructive file writes -- dry-run mode is the default safety net.
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const fs = require('node:fs');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
|
|
10
|
+
// --- Constants ---
|
|
11
|
+
|
|
12
|
+
const GSD_TAG_RE = /(@gsd-(feature|todo|risk|decision|context|status|depends|ref|pattern|api|constraint))(\([^)]*\))?\s*(.*)/;
|
|
13
|
+
|
|
14
|
+
const SUPPORTED_EXTENSIONS = ['.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.py', '.rb', '.go', '.rs', '.sh', '.md'];
|
|
15
|
+
const EXCLUDE_DIRS = ['node_modules', '.git', '.cap', 'dist', 'build', 'coverage'];
|
|
16
|
+
|
|
17
|
+
const GSD_ARTIFACTS = [
|
|
18
|
+
'.planning/FEATURES.md',
|
|
19
|
+
'.planning/REQUIREMENTS.md',
|
|
20
|
+
'.planning/PRD.md',
|
|
21
|
+
'.planning/ROADMAP.md',
|
|
22
|
+
'.planning/STATE.md',
|
|
23
|
+
'.planning/CODE-INVENTORY.md',
|
|
24
|
+
'.planning/BRAINSTORM-LEDGER.md',
|
|
25
|
+
'.planning/SESSION.json',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// --- Tag migration ---
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {Object} TagChange
|
|
32
|
+
* @property {string} file - Relative file path
|
|
33
|
+
* @property {number} line - 1-based line number
|
|
34
|
+
* @property {string} original - Original line content
|
|
35
|
+
* @property {string} replaced - Replacement line content (or null if removed)
|
|
36
|
+
* @property {string} action - 'converted' | 'removed' | 'plain-comment'
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Apply tag migration to a single line.
|
|
41
|
+
* @param {string} line - Source line
|
|
42
|
+
* @returns {{ replaced: string, action: string } | null} - null if no @gsd- tag found
|
|
43
|
+
*/
|
|
44
|
+
function migrateLineTag(line) {
|
|
45
|
+
const match = line.match(GSD_TAG_RE);
|
|
46
|
+
if (!match) return null;
|
|
47
|
+
|
|
48
|
+
const fullTag = match[1]; // e.g., @gsd-feature
|
|
49
|
+
const tagType = match[2]; // e.g., feature
|
|
50
|
+
const metadata = match[3] || ''; // e.g., (ref:AC-20)
|
|
51
|
+
const description = match[4] || '';
|
|
52
|
+
|
|
53
|
+
switch (tagType) {
|
|
54
|
+
case 'feature':
|
|
55
|
+
return {
|
|
56
|
+
replaced: line.replace(fullTag, '@cap-feature'),
|
|
57
|
+
action: 'converted',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
case 'todo':
|
|
61
|
+
return {
|
|
62
|
+
replaced: line.replace(fullTag, '@cap-todo'),
|
|
63
|
+
action: 'converted',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
case 'risk':
|
|
67
|
+
// @gsd-risk Some risk → @cap-todo risk: Some risk
|
|
68
|
+
return {
|
|
69
|
+
replaced: line.replace(fullTag + metadata + (description ? ' ' : ''), '@cap-todo' + metadata + ' risk: ').replace(/ +/g, ' '),
|
|
70
|
+
action: 'converted',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
case 'decision':
|
|
74
|
+
// @gsd-decision Some decision → @cap-todo decision: Some decision
|
|
75
|
+
return {
|
|
76
|
+
replaced: line.replace(fullTag + metadata + (description ? ' ' : ''), '@cap-todo' + metadata + ' decision: ').replace(/ +/g, ' '),
|
|
77
|
+
action: 'converted',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
case 'constraint':
|
|
81
|
+
// @gsd-constraint Some constraint → @cap-todo risk: [constraint] Some constraint
|
|
82
|
+
return {
|
|
83
|
+
replaced: line.replace(fullTag + metadata + (description ? ' ' : ''), '@cap-todo' + metadata + ' risk: [constraint] ').replace(/ +/g, ' '),
|
|
84
|
+
action: 'converted',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
case 'context':
|
|
88
|
+
// @gsd-context Some context → plain comment (remove the tag)
|
|
89
|
+
return {
|
|
90
|
+
replaced: line.replace(fullTag + metadata + ' ', '').replace(fullTag + metadata, ''),
|
|
91
|
+
action: 'plain-comment',
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
case 'status':
|
|
95
|
+
case 'depends':
|
|
96
|
+
// Remove entirely (convert to plain comment to avoid losing info)
|
|
97
|
+
return {
|
|
98
|
+
replaced: line.replace(fullTag + metadata + ' ', '').replace(fullTag + metadata, ''),
|
|
99
|
+
action: 'removed',
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
case 'ref':
|
|
103
|
+
// Keep as @cap-ref if it has content, otherwise remove
|
|
104
|
+
if (description.trim()) {
|
|
105
|
+
return {
|
|
106
|
+
replaced: line.replace(fullTag, '@cap-ref'),
|
|
107
|
+
action: 'converted',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
replaced: line.replace(fullTag + metadata + ' ', '').replace(fullTag + metadata, ''),
|
|
112
|
+
action: 'removed',
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
case 'pattern':
|
|
116
|
+
case 'api':
|
|
117
|
+
// Convert to plain comment (remove the tag prefix)
|
|
118
|
+
return {
|
|
119
|
+
replaced: line.replace(fullTag + metadata + ' ', '').replace(fullTag + metadata, ''),
|
|
120
|
+
action: 'plain-comment',
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
default:
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Scan all source files and replace @gsd-* tags with @cap-* equivalents.
|
|
130
|
+
*
|
|
131
|
+
* Mapping:
|
|
132
|
+
* @gsd-feature → @cap-feature
|
|
133
|
+
* @gsd-todo → @cap-todo
|
|
134
|
+
* @gsd-risk → @cap-todo risk:
|
|
135
|
+
* @gsd-decision → @cap-todo decision:
|
|
136
|
+
* @gsd-context → plain comment (tag removed)
|
|
137
|
+
* @gsd-status → plain comment (tag removed)
|
|
138
|
+
* @gsd-depends → plain comment (tag removed)
|
|
139
|
+
* @gsd-ref → @cap-ref (if content exists) or removed
|
|
140
|
+
* @gsd-pattern → plain comment (tag removed)
|
|
141
|
+
* @gsd-api → plain comment (tag removed)
|
|
142
|
+
* @gsd-constraint → @cap-todo risk: [constraint]
|
|
143
|
+
*
|
|
144
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
145
|
+
* @param {Object} [options]
|
|
146
|
+
* @param {boolean} [options.dryRun] - If true, report changes without writing
|
|
147
|
+
* @param {string[]} [options.extensions] - File extensions to process
|
|
148
|
+
* @returns {{ filesScanned: number, filesModified: number, tagsConverted: number, tagsRemoved: number, changes: TagChange[] }}
|
|
149
|
+
*/
|
|
150
|
+
function migrateTags(projectRoot, options = {}) {
|
|
151
|
+
const dryRun = options.dryRun || false;
|
|
152
|
+
const extensions = options.extensions || SUPPORTED_EXTENSIONS;
|
|
153
|
+
const result = {
|
|
154
|
+
filesScanned: 0,
|
|
155
|
+
filesModified: 0,
|
|
156
|
+
tagsConverted: 0,
|
|
157
|
+
tagsRemoved: 0,
|
|
158
|
+
changes: [],
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
function walk(dir) {
|
|
162
|
+
let entries;
|
|
163
|
+
try {
|
|
164
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
165
|
+
} catch (_e) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
for (const entry of entries) {
|
|
169
|
+
const fullPath = path.join(dir, entry.name);
|
|
170
|
+
if (entry.isDirectory()) {
|
|
171
|
+
if (EXCLUDE_DIRS.includes(entry.name)) continue;
|
|
172
|
+
walk(fullPath);
|
|
173
|
+
} else if (entry.isFile()) {
|
|
174
|
+
const ext = path.extname(entry.name);
|
|
175
|
+
if (!extensions.includes(ext)) continue;
|
|
176
|
+
processFile(fullPath);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function processFile(filePath) {
|
|
182
|
+
let content;
|
|
183
|
+
try {
|
|
184
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
185
|
+
} catch (_e) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
result.filesScanned++;
|
|
190
|
+
const relativePath = path.relative(projectRoot, filePath);
|
|
191
|
+
const lines = content.split('\n');
|
|
192
|
+
let modified = false;
|
|
193
|
+
|
|
194
|
+
for (let i = 0; i < lines.length; i++) {
|
|
195
|
+
const migration = migrateLineTag(lines[i]);
|
|
196
|
+
if (!migration) continue;
|
|
197
|
+
|
|
198
|
+
const change = {
|
|
199
|
+
file: relativePath,
|
|
200
|
+
line: i + 1,
|
|
201
|
+
original: lines[i],
|
|
202
|
+
replaced: migration.replaced,
|
|
203
|
+
action: migration.action,
|
|
204
|
+
};
|
|
205
|
+
result.changes.push(change);
|
|
206
|
+
|
|
207
|
+
if (migration.action === 'converted') {
|
|
208
|
+
result.tagsConverted++;
|
|
209
|
+
} else {
|
|
210
|
+
result.tagsRemoved++;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
lines[i] = migration.replaced;
|
|
214
|
+
modified = true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (modified) {
|
|
218
|
+
result.filesModified++;
|
|
219
|
+
if (!dryRun) {
|
|
220
|
+
fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
walk(projectRoot);
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --- Artifact migration ---
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Convert .planning/FEATURES.md or REQUIREMENTS.md into FEATURE-MAP.md format.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
235
|
+
* @param {Object} [options]
|
|
236
|
+
* @param {boolean} [options.dryRun] - If true, report without writing
|
|
237
|
+
* @returns {{ featuresFound: number, featureMapCreated: boolean, source: string }}
|
|
238
|
+
*/
|
|
239
|
+
function migrateArtifacts(projectRoot, options = {}) {
|
|
240
|
+
const dryRun = options.dryRun || false;
|
|
241
|
+
const result = { featuresFound: 0, featureMapCreated: false, source: 'none' };
|
|
242
|
+
|
|
243
|
+
// Check if FEATURE-MAP.md already exists
|
|
244
|
+
const featureMapPath = path.join(projectRoot, 'FEATURE-MAP.md');
|
|
245
|
+
const featureMapExists = fs.existsSync(featureMapPath);
|
|
246
|
+
|
|
247
|
+
// Try reading source artifacts in priority order
|
|
248
|
+
let sourceContent = null;
|
|
249
|
+
let sourceName = null;
|
|
250
|
+
|
|
251
|
+
const sources = [
|
|
252
|
+
{ file: '.planning/FEATURES.md', name: 'FEATURES.md' },
|
|
253
|
+
{ file: '.planning/REQUIREMENTS.md', name: 'REQUIREMENTS.md' },
|
|
254
|
+
{ file: '.planning/PRD.md', name: 'PRD.md' },
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
for (const src of sources) {
|
|
258
|
+
const srcPath = path.join(projectRoot, src.file);
|
|
259
|
+
if (fs.existsSync(srcPath)) {
|
|
260
|
+
try {
|
|
261
|
+
sourceContent = fs.readFileSync(srcPath, 'utf8');
|
|
262
|
+
sourceName = src.name;
|
|
263
|
+
result.source = src.name;
|
|
264
|
+
break;
|
|
265
|
+
} catch (_e) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!sourceContent) return result;
|
|
272
|
+
|
|
273
|
+
// Extract features from the source artifact
|
|
274
|
+
const features = extractFeaturesFromLegacy(sourceContent);
|
|
275
|
+
result.featuresFound = features.length;
|
|
276
|
+
|
|
277
|
+
if (features.length === 0) return result;
|
|
278
|
+
|
|
279
|
+
if (featureMapExists) {
|
|
280
|
+
// Merge into existing Feature Map
|
|
281
|
+
const capFeatureMap = require('./cap-feature-map.cjs');
|
|
282
|
+
if (!dryRun) {
|
|
283
|
+
const existing = capFeatureMap.readFeatureMap(projectRoot);
|
|
284
|
+
const existingTitles = new Set(existing.features.map(f => f.title.toLowerCase()));
|
|
285
|
+
|
|
286
|
+
for (const feature of features) {
|
|
287
|
+
if (!existingTitles.has(feature.title.toLowerCase())) {
|
|
288
|
+
capFeatureMap.addFeature(projectRoot, feature);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
result.featureMapCreated = true;
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
// Create new Feature Map
|
|
295
|
+
if (!dryRun) {
|
|
296
|
+
const capFeatureMap = require('./cap-feature-map.cjs');
|
|
297
|
+
const template = capFeatureMap.generateTemplate();
|
|
298
|
+
fs.writeFileSync(featureMapPath, template, 'utf8');
|
|
299
|
+
for (const feature of features) {
|
|
300
|
+
capFeatureMap.addFeature(projectRoot, feature);
|
|
301
|
+
}
|
|
302
|
+
result.featureMapCreated = true;
|
|
303
|
+
} else {
|
|
304
|
+
result.featureMapCreated = true; // Would be created
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Extract feature entries from legacy GSD planning artifacts.
|
|
313
|
+
* Looks for markdown headings, list items with feature-like patterns.
|
|
314
|
+
*
|
|
315
|
+
* @param {string} content - Markdown content of legacy artifact
|
|
316
|
+
* @returns {{ title: string, acs: Array, dependencies: string[] }[]}
|
|
317
|
+
*/
|
|
318
|
+
function extractFeaturesFromLegacy(content) {
|
|
319
|
+
const features = [];
|
|
320
|
+
const lines = content.split('\n');
|
|
321
|
+
|
|
322
|
+
// Match headings that look like features: ## Feature Name, ### Feature Name, ## 1. Feature Name
|
|
323
|
+
const featureHeadingRE = /^#{2,4}\s+(?:\d+\.\s*)?(?:Feature:\s*)?(.+?)(?:\s*\[.*\])?\s*$/;
|
|
324
|
+
// Match list items that look like acceptance criteria: - [ ] description, - [x] description
|
|
325
|
+
const acRE = /^[-*]\s+\[([x ])\]\s+(.+)/i;
|
|
326
|
+
// Match plain list items as potential ACs
|
|
327
|
+
const plainListRE = /^[-*]\s+(?!#)(.+)/;
|
|
328
|
+
|
|
329
|
+
let currentFeature = null;
|
|
330
|
+
let acCounter = 0;
|
|
331
|
+
|
|
332
|
+
for (const line of lines) {
|
|
333
|
+
const headingMatch = line.match(featureHeadingRE);
|
|
334
|
+
if (headingMatch) {
|
|
335
|
+
if (currentFeature && currentFeature.title) {
|
|
336
|
+
features.push(currentFeature);
|
|
337
|
+
}
|
|
338
|
+
currentFeature = {
|
|
339
|
+
title: headingMatch[1].trim(),
|
|
340
|
+
acs: [],
|
|
341
|
+
dependencies: [],
|
|
342
|
+
};
|
|
343
|
+
acCounter = 0;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (currentFeature) {
|
|
348
|
+
const acMatch = line.match(acRE);
|
|
349
|
+
if (acMatch) {
|
|
350
|
+
acCounter++;
|
|
351
|
+
currentFeature.acs.push({
|
|
352
|
+
id: `AC-${acCounter}`,
|
|
353
|
+
description: acMatch[2].trim(),
|
|
354
|
+
status: acMatch[1] === 'x' || acMatch[1] === 'X' ? 'implemented' : 'pending',
|
|
355
|
+
});
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Empty line after ACs but before next heading -- stop collecting ACs
|
|
360
|
+
if (line.trim() === '' && currentFeature.acs.length > 0) {
|
|
361
|
+
// Keep collecting -- next heading or feature resets
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (currentFeature && currentFeature.title) {
|
|
367
|
+
features.push(currentFeature);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return features;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// --- Session migration ---
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Migrate .planning/SESSION.json to .cap/SESSION.json format.
|
|
377
|
+
*
|
|
378
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
379
|
+
* @param {Object} [options]
|
|
380
|
+
* @param {boolean} [options.dryRun] - If true, report without writing
|
|
381
|
+
* @returns {{ migrated: boolean, oldFormat: string, newFormat: string }}
|
|
382
|
+
*/
|
|
383
|
+
function migrateSession(projectRoot, options = {}) {
|
|
384
|
+
const dryRun = options.dryRun || false;
|
|
385
|
+
const result = { migrated: false, oldFormat: 'none', newFormat: 'none' };
|
|
386
|
+
|
|
387
|
+
const oldSessionPath = path.join(projectRoot, '.planning', 'SESSION.json');
|
|
388
|
+
if (!fs.existsSync(oldSessionPath)) return result;
|
|
389
|
+
|
|
390
|
+
let oldSession;
|
|
391
|
+
try {
|
|
392
|
+
const content = fs.readFileSync(oldSessionPath, 'utf8');
|
|
393
|
+
oldSession = JSON.parse(content);
|
|
394
|
+
result.oldFormat = 'v1.x';
|
|
395
|
+
} catch (_e) {
|
|
396
|
+
result.oldFormat = 'corrupt';
|
|
397
|
+
return result;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Map old session fields to new CAP session format
|
|
401
|
+
const capSession = require('./cap-session.cjs');
|
|
402
|
+
const newSession = capSession.getDefaultSession();
|
|
403
|
+
|
|
404
|
+
// Map known v1.x fields
|
|
405
|
+
if (oldSession.current_app) {
|
|
406
|
+
newSession.metadata.legacyApp = oldSession.current_app;
|
|
407
|
+
}
|
|
408
|
+
if (oldSession.current_phase) {
|
|
409
|
+
newSession.step = `legacy-phase-${oldSession.current_phase}`;
|
|
410
|
+
}
|
|
411
|
+
if (oldSession.started_at || oldSession.startedAt) {
|
|
412
|
+
newSession.startedAt = oldSession.started_at || oldSession.startedAt;
|
|
413
|
+
}
|
|
414
|
+
if (oldSession.last_command || oldSession.lastCommand) {
|
|
415
|
+
newSession.lastCommand = oldSession.last_command || oldSession.lastCommand;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Preserve all old fields as metadata for reference
|
|
419
|
+
for (const [key, value] of Object.entries(oldSession)) {
|
|
420
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
421
|
+
newSession.metadata[`gsd_${key}`] = String(value);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
result.newFormat = 'v2.0';
|
|
426
|
+
|
|
427
|
+
if (!dryRun) {
|
|
428
|
+
capSession.initCapDirectory(projectRoot);
|
|
429
|
+
capSession.saveSession(projectRoot, newSession);
|
|
430
|
+
result.migrated = true;
|
|
431
|
+
} else {
|
|
432
|
+
result.migrated = true; // Would be migrated
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return result;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// --- Analysis ---
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Generate a migration report summarizing what was found and what needs attention.
|
|
442
|
+
*
|
|
443
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
444
|
+
* @returns {{ gsdTagCount: number, gsdArtifacts: string[], planningDir: boolean, sessionJson: boolean, recommendations: string[] }}
|
|
445
|
+
*/
|
|
446
|
+
function analyzeMigration(projectRoot) {
|
|
447
|
+
const result = {
|
|
448
|
+
gsdTagCount: 0,
|
|
449
|
+
gsdArtifacts: [],
|
|
450
|
+
planningDir: false,
|
|
451
|
+
sessionJson: false,
|
|
452
|
+
recommendations: [],
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
// Check for .planning/ directory
|
|
456
|
+
const planningDir = path.join(projectRoot, '.planning');
|
|
457
|
+
result.planningDir = fs.existsSync(planningDir);
|
|
458
|
+
|
|
459
|
+
// Check for known GSD artifacts
|
|
460
|
+
for (const artifact of GSD_ARTIFACTS) {
|
|
461
|
+
const artifactPath = path.join(projectRoot, artifact);
|
|
462
|
+
if (fs.existsSync(artifactPath)) {
|
|
463
|
+
result.gsdArtifacts.push(artifact);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Check for .planning/SESSION.json specifically
|
|
468
|
+
result.sessionJson = fs.existsSync(path.join(projectRoot, '.planning', 'SESSION.json'));
|
|
469
|
+
|
|
470
|
+
// Count @gsd-* tags in source files
|
|
471
|
+
const tagResult = migrateTags(projectRoot, { dryRun: true });
|
|
472
|
+
result.gsdTagCount = tagResult.tagsConverted + tagResult.tagsRemoved;
|
|
473
|
+
|
|
474
|
+
// Build recommendations
|
|
475
|
+
if (result.gsdTagCount > 0) {
|
|
476
|
+
result.recommendations.push(`Found ${result.gsdTagCount} @gsd-* tags to migrate. Run /cap:migrate to convert them to @cap-* tags.`);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (result.gsdArtifacts.length > 0) {
|
|
480
|
+
result.recommendations.push(`Found ${result.gsdArtifacts.length} legacy planning artifacts: ${result.gsdArtifacts.join(', ')}. These can be converted to FEATURE-MAP.md entries.`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (result.sessionJson) {
|
|
484
|
+
result.recommendations.push('Found .planning/SESSION.json. This can be migrated to .cap/SESSION.json format.');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (!fs.existsSync(path.join(projectRoot, 'FEATURE-MAP.md'))) {
|
|
488
|
+
result.recommendations.push('No FEATURE-MAP.md found. Migration will create one from existing artifacts.');
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (!fs.existsSync(path.join(projectRoot, '.cap'))) {
|
|
492
|
+
result.recommendations.push('No .cap/ directory found. Migration will initialize it.');
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (result.gsdTagCount === 0 && result.gsdArtifacts.length === 0 && !result.sessionJson) {
|
|
496
|
+
result.recommendations.push('No GSD v1.x artifacts detected. This project may already be using CAP v2.0 or is a fresh project.');
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return result;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
module.exports = {
|
|
503
|
+
GSD_TAG_RE,
|
|
504
|
+
SUPPORTED_EXTENSIONS,
|
|
505
|
+
EXCLUDE_DIRS,
|
|
506
|
+
GSD_ARTIFACTS,
|
|
507
|
+
migrateLineTag,
|
|
508
|
+
migrateTags,
|
|
509
|
+
migrateArtifacts,
|
|
510
|
+
extractFeaturesFromLegacy,
|
|
511
|
+
migrateSession,
|
|
512
|
+
analyzeMigration,
|
|
513
|
+
};
|
|
@@ -439,11 +439,82 @@ function groupByPackage(tags, packages) {
|
|
|
439
439
|
return groups;
|
|
440
440
|
}
|
|
441
441
|
|
|
442
|
+
// @cap-todo Detect legacy @gsd-* tags and recommend /cap:migrate
|
|
443
|
+
const LEGACY_TAG_RE = /^[ \t]*(?:\/\/|\/\*|\*|#|--|"""|''')[ \t]*@gsd-(feature|todo|risk|decision|context|status|depends|ref|pattern|api|constraint)/;
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Detect legacy @gsd-* tags in scanned files.
|
|
447
|
+
* Re-scans source files for @gsd-* patterns that the primary scanner ignores.
|
|
448
|
+
*
|
|
449
|
+
* @param {string} projectRoot - Absolute path to project root
|
|
450
|
+
* @param {Object} [options]
|
|
451
|
+
* @param {string[]} [options.extensions] - File extensions to include
|
|
452
|
+
* @param {string[]} [options.exclude] - Directory names to exclude
|
|
453
|
+
* @returns {{ count: number, files: string[], recommendation: string }}
|
|
454
|
+
*/
|
|
455
|
+
function detectLegacyTags(projectRoot, options = {}) {
|
|
456
|
+
const extensions = options.extensions || SUPPORTED_EXTENSIONS;
|
|
457
|
+
const exclude = options.exclude || DEFAULT_EXCLUDE;
|
|
458
|
+
const result = { count: 0, files: [], recommendation: '' };
|
|
459
|
+
const fileSet = new Set();
|
|
460
|
+
|
|
461
|
+
function walk(dir) {
|
|
462
|
+
let entries;
|
|
463
|
+
try {
|
|
464
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
465
|
+
} catch (_e) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
for (const entry of entries) {
|
|
469
|
+
const fullPath = path.join(dir, entry.name);
|
|
470
|
+
if (entry.isDirectory()) {
|
|
471
|
+
if (exclude.includes(entry.name)) continue;
|
|
472
|
+
walk(fullPath);
|
|
473
|
+
} else if (entry.isFile()) {
|
|
474
|
+
const ext = path.extname(entry.name);
|
|
475
|
+
if (!extensions.includes(ext)) continue;
|
|
476
|
+
scanFileForLegacy(fullPath);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function scanFileForLegacy(filePath) {
|
|
482
|
+
let content;
|
|
483
|
+
try {
|
|
484
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
485
|
+
} catch (_e) {
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const lines = content.split('\n');
|
|
489
|
+
let found = false;
|
|
490
|
+
for (const line of lines) {
|
|
491
|
+
if (LEGACY_TAG_RE.test(line)) {
|
|
492
|
+
result.count++;
|
|
493
|
+
found = true;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (found) {
|
|
497
|
+
const relativePath = path.relative(projectRoot, filePath);
|
|
498
|
+
fileSet.add(relativePath);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
walk(projectRoot);
|
|
503
|
+
result.files = Array.from(fileSet).sort();
|
|
504
|
+
|
|
505
|
+
if (result.count > 0) {
|
|
506
|
+
result.recommendation = `Found ${result.count} legacy @gsd-* tag(s) in ${result.files.length} file(s). Run /cap:migrate to convert them to @cap-* format.`;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return result;
|
|
510
|
+
}
|
|
511
|
+
|
|
442
512
|
module.exports = {
|
|
443
513
|
CAP_TAG_TYPES,
|
|
444
514
|
CAP_TAG_RE,
|
|
445
515
|
SUPPORTED_EXTENSIONS,
|
|
446
516
|
DEFAULT_EXCLUDE,
|
|
517
|
+
LEGACY_TAG_RE,
|
|
447
518
|
scanFile,
|
|
448
519
|
scanDirectory,
|
|
449
520
|
extractTags,
|
|
@@ -455,4 +526,5 @@ module.exports = {
|
|
|
455
526
|
resolveWorkspaceGlobs,
|
|
456
527
|
scanMonorepo,
|
|
457
528
|
groupByPackage,
|
|
529
|
+
detectLegacyTags,
|
|
458
530
|
};
|