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.
@@ -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
  };