lean-spec 0.2.7 → 0.2.9-dev.20251205030455

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,741 @@
1
+ import { loadConfig, getSpec, loadAllSpecs } from './chunk-RF5PKL6L.js';
2
+ import { updateFrontmatter } from './chunk-VN5BUHTV.js';
3
+ import * as fs from 'fs/promises';
4
+ import * as path from 'path';
5
+ import { Command } from 'commander';
6
+ import { execSync } from 'child_process';
7
+ import matter from 'gray-matter';
8
+
9
+ function isGitRepository() {
10
+ try {
11
+ execSync("git rev-parse --is-inside-work-tree", {
12
+ stdio: "ignore",
13
+ encoding: "utf-8"
14
+ });
15
+ return true;
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+ function getFirstCommitTimestamp(filePath) {
21
+ try {
22
+ const timestamp = execSync(
23
+ `git log --follow --format="%aI" --diff-filter=A -- "${filePath}" | tail -1`,
24
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }
25
+ ).trim();
26
+ return timestamp || null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+ function getLastCommitTimestamp(filePath) {
32
+ try {
33
+ const timestamp = execSync(
34
+ `git log --format="%aI" -n 1 -- "${filePath}"`,
35
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }
36
+ ).trim();
37
+ return timestamp || null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+ function getCompletionTimestamp(filePath) {
43
+ try {
44
+ const gitLog = execSync(
45
+ `git log --format="%H|%aI" -p -- "${filePath}"`,
46
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }
47
+ );
48
+ const commits = gitLog.split("\ndiff --git").map((section) => section.trim());
49
+ for (const commit of commits) {
50
+ if (!commit) continue;
51
+ const headerMatch = commit.match(/^([a-f0-9]{40})\|([^\n]+)/);
52
+ if (!headerMatch) continue;
53
+ const [, , timestamp] = headerMatch;
54
+ if (/^\+status:\s*['"]?complete['"]?/m.test(commit) || /^\+\*\*Status\*\*:.*complete/mi.test(commit)) {
55
+ return timestamp;
56
+ }
57
+ }
58
+ return null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+ function getFirstCommitAuthor(filePath) {
64
+ try {
65
+ const author = execSync(
66
+ `git log --follow --format="%an" --diff-filter=A -- "${filePath}" | tail -1`,
67
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }
68
+ ).trim();
69
+ return author || null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+ function parseStatusTransitions(filePath) {
75
+ const transitions = [];
76
+ try {
77
+ const gitLog = execSync(
78
+ `git log --format="%H|%aI" -p --reverse -- "${filePath}"`,
79
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }
80
+ );
81
+ const commits = gitLog.split("\ndiff --git").map((section) => section.trim());
82
+ const validStatuses = ["planned", "in-progress", "complete", "archived"];
83
+ for (const commit of commits) {
84
+ if (!commit) continue;
85
+ const headerMatch = commit.match(/^([a-f0-9]{40})\|([^\n]+)/);
86
+ if (!headerMatch) continue;
87
+ const [, , timestamp] = headerMatch;
88
+ const statusMatch = commit.match(/^\+status:\s*['"]?(\w+(?:-\w+)?)['"]?/m);
89
+ if (statusMatch) {
90
+ const status = statusMatch[1];
91
+ if (validStatuses.includes(status)) {
92
+ const lastTransition = transitions[transitions.length - 1];
93
+ if (!lastTransition || lastTransition.status !== status) {
94
+ transitions.push({ status, at: timestamp });
95
+ }
96
+ }
97
+ }
98
+ }
99
+ return transitions;
100
+ } catch {
101
+ return [];
102
+ }
103
+ }
104
+ function extractGitTimestamps(filePath, options = {}) {
105
+ const data = {};
106
+ data.created_at = getFirstCommitTimestamp(filePath) ?? void 0;
107
+ data.updated_at = getLastCommitTimestamp(filePath) ?? void 0;
108
+ data.completed_at = getCompletionTimestamp(filePath) ?? void 0;
109
+ if (options.includeAssignee) {
110
+ const author = getFirstCommitAuthor(filePath);
111
+ if (author) {
112
+ data.assignee = author;
113
+ }
114
+ }
115
+ if (options.includeTransitions) {
116
+ const transitions = parseStatusTransitions(filePath);
117
+ if (transitions.length > 0) {
118
+ data.transitions = transitions;
119
+ }
120
+ }
121
+ return data;
122
+ }
123
+ function fileExistsInGit(filePath) {
124
+ try {
125
+ execSync(
126
+ `git log -n 1 -- "${filePath}"`,
127
+ { stdio: "ignore", encoding: "utf-8" }
128
+ );
129
+ return true;
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+ function createSpecDirPattern() {
135
+ return /(?:^|\D)(\d{2,4})-[^0-9-]/i;
136
+ }
137
+ async function getGlobalNextSeq(specsDir, digits) {
138
+ try {
139
+ const seqNumbers = [];
140
+ const specPattern = createSpecDirPattern();
141
+ async function scanDirectory(dir) {
142
+ try {
143
+ const entries = await fs.readdir(dir, { withFileTypes: true });
144
+ for (const entry of entries) {
145
+ if (!entry.isDirectory()) continue;
146
+ const match = entry.name.match(specPattern);
147
+ if (match) {
148
+ const seqNum = parseInt(match[1], 10);
149
+ if (!isNaN(seqNum) && seqNum > 0) {
150
+ seqNumbers.push(seqNum);
151
+ }
152
+ }
153
+ if (entry.name === "archived") continue;
154
+ const subDir = path.join(dir, entry.name);
155
+ await scanDirectory(subDir);
156
+ }
157
+ } catch {
158
+ }
159
+ }
160
+ await scanDirectory(specsDir);
161
+ if (seqNumbers.length === 0) {
162
+ return "1".padStart(digits, "0");
163
+ }
164
+ const maxSeq = Math.max(...seqNumbers);
165
+ return String(maxSeq + 1).padStart(digits, "0");
166
+ } catch {
167
+ return "1".padStart(digits, "0");
168
+ }
169
+ }
170
+ async function resolveSpecPath(specPath, cwd, specsDir) {
171
+ if (path.isAbsolute(specPath)) {
172
+ try {
173
+ await fs.access(specPath);
174
+ return specPath;
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+ const cwdPath = path.resolve(cwd, specPath);
180
+ try {
181
+ await fs.access(cwdPath);
182
+ return cwdPath;
183
+ } catch {
184
+ }
185
+ const specsPath = path.join(specsDir, specPath);
186
+ try {
187
+ await fs.access(specsPath);
188
+ return specsPath;
189
+ } catch {
190
+ }
191
+ const seqMatch = specPath.match(/^0*(\d+)$/);
192
+ if (seqMatch) {
193
+ const seqNum = parseInt(seqMatch[1], 10);
194
+ const result2 = await searchBySequence(specsDir, seqNum);
195
+ if (result2) return result2;
196
+ }
197
+ const specName = specPath.replace(/^.*\//, "");
198
+ const result = await searchInAllDirectories(specsDir, specName);
199
+ return result;
200
+ }
201
+ async function searchBySequence(specsDir, seqNum) {
202
+ const specPattern = createSpecDirPattern();
203
+ async function scanDirectory(dir) {
204
+ try {
205
+ const entries = await fs.readdir(dir, { withFileTypes: true });
206
+ for (const entry of entries) {
207
+ if (!entry.isDirectory()) continue;
208
+ const match = entry.name.match(specPattern);
209
+ if (match) {
210
+ const entrySeq = parseInt(match[1], 10);
211
+ if (entrySeq === seqNum) {
212
+ return path.join(dir, entry.name);
213
+ }
214
+ }
215
+ const subDir = path.join(dir, entry.name);
216
+ const result = await scanDirectory(subDir);
217
+ if (result) return result;
218
+ }
219
+ } catch {
220
+ }
221
+ return null;
222
+ }
223
+ return scanDirectory(specsDir);
224
+ }
225
+ async function searchInAllDirectories(specsDir, specName) {
226
+ async function scanDirectory(dir) {
227
+ try {
228
+ const entries = await fs.readdir(dir, { withFileTypes: true });
229
+ for (const entry of entries) {
230
+ if (!entry.isDirectory()) continue;
231
+ if (entry.name === specName) {
232
+ return path.join(dir, entry.name);
233
+ }
234
+ const subDir = path.join(dir, entry.name);
235
+ const result = await scanDirectory(subDir);
236
+ if (result) return result;
237
+ }
238
+ } catch {
239
+ }
240
+ return null;
241
+ }
242
+ return scanDirectory(specsDir);
243
+ }
244
+ async function loadSpecsForBootstrap(specsDir, options = {}) {
245
+ const specs = [];
246
+ const config = await loadConfig();
247
+ const specPattern = /^(\d{2,})-/;
248
+ async function scanDirectory(dir, relativePath = "") {
249
+ try {
250
+ const entries = await fs.readdir(dir, { withFileTypes: true });
251
+ for (const entry of entries) {
252
+ if (!entry.isDirectory()) continue;
253
+ if (entry.name === "archived" && relativePath === "") continue;
254
+ const entryPath = path.join(dir, entry.name);
255
+ const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
256
+ if (specPattern.test(entry.name)) {
257
+ if (options.targetSpecs && options.targetSpecs.length > 0) {
258
+ const matches = options.targetSpecs.some((target) => {
259
+ const targetNum = target.match(/^0*(\d+)$/)?.[1];
260
+ const entryNum = entry.name.match(/^(\d+)/)?.[1];
261
+ return entry.name === target || entry.name.includes(target) || targetNum && entryNum && parseInt(targetNum, 10) === parseInt(entryNum, 10);
262
+ });
263
+ if (!matches) continue;
264
+ }
265
+ const specFile = path.join(entryPath, config.structure.defaultFile);
266
+ try {
267
+ await fs.access(specFile);
268
+ const specInfo = await analyzeSpecFile(specFile, entryPath, entryRelativePath, entry.name);
269
+ specs.push(specInfo);
270
+ } catch {
271
+ }
272
+ } else {
273
+ await scanDirectory(entryPath, entryRelativePath);
274
+ }
275
+ }
276
+ } catch {
277
+ }
278
+ }
279
+ await scanDirectory(specsDir);
280
+ if (options.includeArchived) {
281
+ const archivedPath = path.join(specsDir, "archived");
282
+ await scanDirectory(archivedPath, "archived");
283
+ }
284
+ specs.sort((a, b) => {
285
+ const aNum = parseInt(a.name.match(/^(\d+)/)?.[1] || "0", 10);
286
+ const bNum = parseInt(b.name.match(/^(\d+)/)?.[1] || "0", 10);
287
+ return bNum - aNum;
288
+ });
289
+ return specs;
290
+ }
291
+ async function analyzeSpecFile(filePath, fullPath, relativePath, name) {
292
+ const content = await fs.readFile(filePath, "utf-8");
293
+ let hasFrontmatter = false;
294
+ let hasValidFrontmatter = false;
295
+ let existingFrontmatter;
296
+ try {
297
+ const parsed = matter(content);
298
+ if (parsed.data && Object.keys(parsed.data).length > 0) {
299
+ hasFrontmatter = true;
300
+ existingFrontmatter = parsed.data;
301
+ hasValidFrontmatter = !!(parsed.data.status && parsed.data.created);
302
+ }
303
+ } catch {
304
+ }
305
+ const inferredStatus = inferStatusFromContent(content) || inferStatusFromGit(filePath) || "planned";
306
+ const inferredCreated = inferCreatedFromContent(content) || inferCreatedFromGit(filePath) || (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
307
+ return {
308
+ path: relativePath,
309
+ fullPath,
310
+ filePath,
311
+ name,
312
+ content,
313
+ hasFrontmatter,
314
+ hasValidFrontmatter,
315
+ existingFrontmatter,
316
+ inferredStatus: existingFrontmatter?.status || inferredStatus,
317
+ inferredCreated: existingFrontmatter?.created || inferredCreated
318
+ };
319
+ }
320
+ function inferStatusFromContent(content) {
321
+ const inlineMatch = content.match(/\*\*Status\*\*:\s*(?:✅\s*|📅\s*|⏳\s*|📦\s*)?(\w+(?:-\w+)?)/i);
322
+ if (inlineMatch) {
323
+ return normalizeStatus(inlineMatch[1]);
324
+ }
325
+ const simpleMatch = content.match(/^Status:\s*(\w+(?:-\w+)?)/mi);
326
+ if (simpleMatch) {
327
+ return normalizeStatus(simpleMatch[1]);
328
+ }
329
+ if (/^\s*-\s*\[x\]\s*(done|complete|finished)/mi.test(content)) {
330
+ return "complete";
331
+ }
332
+ const adrMatch = content.match(/^##?\s*Status\s*\n+\s*(\w+)/mi);
333
+ if (adrMatch) {
334
+ const adrStatus = adrMatch[1].toLowerCase();
335
+ if (["accepted", "approved", "done"].includes(adrStatus)) {
336
+ return "complete";
337
+ }
338
+ if (["proposed", "pending", "draft"].includes(adrStatus)) {
339
+ return "planned";
340
+ }
341
+ if (["superseded", "deprecated", "rejected"].includes(adrStatus)) {
342
+ return "archived";
343
+ }
344
+ }
345
+ return null;
346
+ }
347
+ function inferStatusFromGit(filePath) {
348
+ if (!fileExistsInGit(filePath)) {
349
+ return null;
350
+ }
351
+ try {
352
+ const transitions = parseStatusTransitions(filePath);
353
+ if (transitions.length > 0) {
354
+ return transitions[transitions.length - 1].status;
355
+ }
356
+ } catch {
357
+ }
358
+ return null;
359
+ }
360
+ function normalizeStatus(status) {
361
+ const normalized = status.toLowerCase().trim().replace(/\s+/g, "-");
362
+ const statusMap = {
363
+ // Standard statuses
364
+ "planned": "planned",
365
+ "in-progress": "in-progress",
366
+ "inprogress": "in-progress",
367
+ "in_progress": "in-progress",
368
+ "wip": "in-progress",
369
+ "working": "in-progress",
370
+ "active": "in-progress",
371
+ "complete": "complete",
372
+ "completed": "complete",
373
+ "done": "complete",
374
+ "finished": "complete",
375
+ "implemented": "complete",
376
+ "archived": "archived",
377
+ "deprecated": "archived",
378
+ "superseded": "archived",
379
+ "rejected": "archived",
380
+ // ADR statuses
381
+ "accepted": "complete",
382
+ "approved": "complete",
383
+ "proposed": "planned",
384
+ "pending": "planned",
385
+ "draft": "planned"
386
+ };
387
+ return statusMap[normalized] || "planned";
388
+ }
389
+ function inferCreatedFromContent(content) {
390
+ const inlineMatch = content.match(/\*\*Created\*\*:\s*(\d{4}-\d{2}-\d{2})/);
391
+ if (inlineMatch) {
392
+ return inlineMatch[1];
393
+ }
394
+ const simpleMatch = content.match(/^Created:\s*(\d{4}-\d{2}-\d{2})/mi);
395
+ if (simpleMatch) {
396
+ return simpleMatch[1];
397
+ }
398
+ const dateMatch = content.match(/^Date:\s*(\d{4}-\d{2}-\d{2})/mi);
399
+ if (dateMatch) {
400
+ return dateMatch[1];
401
+ }
402
+ const adrMatch = content.match(/^##?\s*Date\s*\n+\s*(\w+\s+\d{1,2},?\s+\d{4})/mi);
403
+ if (adrMatch) {
404
+ try {
405
+ const date = new Date(adrMatch[1]);
406
+ if (!isNaN(date.getTime())) {
407
+ return date.toISOString().split("T")[0];
408
+ }
409
+ } catch {
410
+ }
411
+ }
412
+ const anyDateMatch = content.match(/(\d{4}-\d{2}-\d{2})/);
413
+ if (anyDateMatch) {
414
+ return anyDateMatch[1];
415
+ }
416
+ return null;
417
+ }
418
+ function inferCreatedFromGit(filePath) {
419
+ const timestamp = getFirstCommitTimestamp(filePath);
420
+ if (timestamp) {
421
+ return timestamp.split("T")[0];
422
+ }
423
+ return null;
424
+ }
425
+
426
+ // src/commands/backfill.ts
427
+ function backfillCommand() {
428
+ return new Command("backfill").description("Backfill timestamps from git history").argument("[specs...]", "Specific specs to backfill (optional)").option("--dry-run", "Show what would be updated without making changes").option("--force", "Overwrite existing timestamp values").option("--assignee", "Include assignee from first commit author").option("--transitions", "Include full status transition history").option("--all", "Include all optional fields (assignee + transitions)").option("--bootstrap", "Create frontmatter for files without valid frontmatter").option("--json", "Output as JSON").action(async (specs, options) => {
429
+ await backfillTimestamps({
430
+ dryRun: options.dryRun,
431
+ force: options.force,
432
+ includeAssignee: options.assignee || options.all,
433
+ includeTransitions: options.transitions || options.all,
434
+ specs: specs && specs.length > 0 ? specs : void 0,
435
+ json: options.json,
436
+ bootstrap: options.bootstrap
437
+ });
438
+ });
439
+ }
440
+ async function backfillTimestamps(options = {}) {
441
+ const results = [];
442
+ if (!isGitRepository()) {
443
+ console.error("\x1B[31mError:\x1B[0m Not in a git repository");
444
+ console.error("Git history is required for backfilling timestamps");
445
+ process.exit(1);
446
+ }
447
+ const config = await loadConfig();
448
+ const cwd = process.cwd();
449
+ const specsDir = path.join(cwd, config.specsDir);
450
+ if (options.bootstrap) {
451
+ console.log("\x1B[36m\u{1F527} Bootstrap mode - will create frontmatter for files without it\x1B[0m\n");
452
+ const bootstrapSpecs = await loadSpecsForBootstrap(specsDir, {
453
+ includeArchived: true,
454
+ targetSpecs: options.specs
455
+ });
456
+ if (bootstrapSpecs.length === 0) {
457
+ console.log("No specs found to bootstrap");
458
+ return results;
459
+ }
460
+ console.log(`Analyzing git history for ${bootstrapSpecs.length} spec${bootstrapSpecs.length === 1 ? "" : "s"}...
461
+ `);
462
+ for (const spec of bootstrapSpecs) {
463
+ const result = await bootstrapSpec(spec, options);
464
+ results.push(result);
465
+ }
466
+ printSummary(results, options);
467
+ return results;
468
+ }
469
+ let specs;
470
+ if (options.specs && options.specs.length > 0) {
471
+ specs = [];
472
+ for (const specPath of options.specs) {
473
+ const resolved = await resolveSpecPath(specPath, cwd, specsDir);
474
+ if (!resolved) {
475
+ console.warn(`\x1B[33mWarning:\x1B[0m Spec not found: ${specPath}`);
476
+ continue;
477
+ }
478
+ const spec = await getSpec(resolved);
479
+ if (spec) {
480
+ specs.push(spec);
481
+ }
482
+ }
483
+ } else {
484
+ specs = await loadAllSpecs({ includeArchived: true });
485
+ }
486
+ if (specs.length === 0) {
487
+ console.log("No specs found to backfill");
488
+ console.log("\x1B[36m\u2139\x1B[0m Use --bootstrap to create frontmatter for files without it");
489
+ return results;
490
+ }
491
+ if (options.dryRun) {
492
+ console.log("\x1B[36m\u{1F50D} Dry run mode - no changes will be made\x1B[0m\n");
493
+ }
494
+ console.log(`Analyzing git history for ${specs.length} spec${specs.length === 1 ? "" : "s"}...
495
+ `);
496
+ for (const spec of specs) {
497
+ const result = await backfillSpecTimestamps(spec, options);
498
+ results.push(result);
499
+ }
500
+ printSummary(results, options);
501
+ return results;
502
+ }
503
+ async function backfillSpecTimestamps(spec, options) {
504
+ const result = {
505
+ specPath: spec.path,
506
+ specName: spec.name,
507
+ source: "skipped"
508
+ };
509
+ if (!spec.frontmatter.status || !spec.frontmatter.created) {
510
+ result.reason = "Missing required frontmatter (status or created)";
511
+ console.log(`\x1B[33m\u2298\x1B[0m ${spec.name} - Missing required frontmatter`);
512
+ return result;
513
+ }
514
+ if (!fileExistsInGit(spec.filePath)) {
515
+ result.reason = "Not in git history";
516
+ console.log(`\x1B[33m\u2298\x1B[0m ${spec.name} - Not in git history`);
517
+ return result;
518
+ }
519
+ const gitData = extractGitTimestamps(spec.filePath, {
520
+ includeAssignee: options.includeAssignee,
521
+ includeTransitions: options.includeTransitions
522
+ });
523
+ const updates = {};
524
+ let hasUpdates = false;
525
+ if (gitData.created_at && (options.force || !spec.frontmatter.created_at)) {
526
+ updates.created_at = gitData.created_at;
527
+ result.created_at = gitData.created_at;
528
+ result.source = "git";
529
+ hasUpdates = true;
530
+ } else if (spec.frontmatter.created_at) {
531
+ result.created_at = spec.frontmatter.created_at;
532
+ result.source = "existing";
533
+ }
534
+ if (gitData.updated_at && (options.force || !spec.frontmatter.updated_at)) {
535
+ updates.updated_at = gitData.updated_at;
536
+ result.updated_at = gitData.updated_at;
537
+ result.source = "git";
538
+ hasUpdates = true;
539
+ } else if (spec.frontmatter.updated_at) {
540
+ result.updated_at = spec.frontmatter.updated_at;
541
+ result.source = "existing";
542
+ }
543
+ if (gitData.completed_at && (options.force || !spec.frontmatter.completed_at)) {
544
+ updates.completed_at = gitData.completed_at;
545
+ result.completed_at = gitData.completed_at;
546
+ result.source = "git";
547
+ hasUpdates = true;
548
+ } else if (spec.frontmatter.completed_at) {
549
+ result.completed_at = spec.frontmatter.completed_at;
550
+ result.source = "existing";
551
+ }
552
+ if (options.includeAssignee && gitData.assignee && (options.force || !spec.frontmatter.assignee)) {
553
+ updates.assignee = gitData.assignee;
554
+ result.assignee = gitData.assignee;
555
+ result.source = "git";
556
+ hasUpdates = true;
557
+ } else if (spec.frontmatter.assignee) {
558
+ result.assignee = spec.frontmatter.assignee;
559
+ }
560
+ if (options.includeTransitions && gitData.transitions && gitData.transitions.length > 0) {
561
+ if (options.force || !spec.frontmatter.transitions || spec.frontmatter.transitions.length === 0) {
562
+ updates.transitions = gitData.transitions;
563
+ result.transitionsCount = gitData.transitions.length;
564
+ result.source = "git";
565
+ hasUpdates = true;
566
+ } else {
567
+ result.transitionsCount = spec.frontmatter.transitions.length;
568
+ }
569
+ }
570
+ if (updates.updated_at && !updates.updated) {
571
+ updates.updated = updates.updated_at.split("T")[0];
572
+ }
573
+ if (!hasUpdates) {
574
+ result.reason = "Already has complete data";
575
+ console.log(`\x1B[90m\u2713\x1B[0m ${spec.name} - Already complete`);
576
+ return result;
577
+ }
578
+ if (!options.dryRun) {
579
+ try {
580
+ await updateFrontmatter(spec.filePath, updates);
581
+ console.log(`\x1B[32m\u2713\x1B[0m ${spec.name} - Updated`);
582
+ } catch (error) {
583
+ result.source = "skipped";
584
+ result.reason = `Error: ${error instanceof Error ? error.message : String(error)}`;
585
+ console.log(`\x1B[31m\u2717\x1B[0m ${spec.name} - Failed: ${result.reason}`);
586
+ }
587
+ } else {
588
+ console.log(`\x1B[36m\u2192\x1B[0m ${spec.name} - Would update`);
589
+ if (updates.created_at) console.log(` created_at: ${updates.created_at} (git)`);
590
+ if (updates.updated_at) console.log(` updated_at: ${updates.updated_at} (git)`);
591
+ if (updates.completed_at) console.log(` completed_at: ${updates.completed_at} (git)`);
592
+ if (updates.assignee) console.log(` assignee: ${updates.assignee} (git)`);
593
+ if (updates.transitions) console.log(` transitions: ${updates.transitions.length} status changes (git)`);
594
+ }
595
+ return result;
596
+ }
597
+ async function bootstrapSpec(spec, options) {
598
+ const result = {
599
+ specPath: spec.path,
600
+ specName: spec.name,
601
+ source: "skipped",
602
+ bootstrapped: false
603
+ };
604
+ if (spec.hasValidFrontmatter && !options.force) {
605
+ result.reason = "Already has valid frontmatter";
606
+ console.log(`\x1B[90m\u2713\x1B[0m ${spec.name} - Already valid`);
607
+ return result;
608
+ }
609
+ const frontmatter = {
610
+ ...spec.existingFrontmatter || {},
611
+ status: spec.inferredStatus || "planned",
612
+ created: spec.inferredCreated || (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
613
+ };
614
+ if (fileExistsInGit(spec.filePath)) {
615
+ const gitData = extractGitTimestamps(spec.filePath, {
616
+ includeAssignee: options.includeAssignee,
617
+ includeTransitions: options.includeTransitions
618
+ });
619
+ if (gitData.created_at) {
620
+ frontmatter.created_at = gitData.created_at;
621
+ result.created_at = gitData.created_at;
622
+ }
623
+ if (gitData.updated_at) {
624
+ frontmatter.updated_at = gitData.updated_at;
625
+ result.updated_at = gitData.updated_at;
626
+ }
627
+ if (gitData.completed_at) {
628
+ frontmatter.completed_at = gitData.completed_at;
629
+ result.completed_at = gitData.completed_at;
630
+ }
631
+ if (options.includeAssignee && gitData.assignee) {
632
+ frontmatter.assignee = gitData.assignee;
633
+ result.assignee = gitData.assignee;
634
+ }
635
+ if (options.includeTransitions && gitData.transitions && gitData.transitions.length > 0) {
636
+ frontmatter.transitions = gitData.transitions;
637
+ result.transitionsCount = gitData.transitions.length;
638
+ }
639
+ }
640
+ const statusSource = spec.existingFrontmatter?.status ? "existing" : spec.content.match(/\*\*Status\*\*/i) ? "content" : "default";
641
+ const createdSource = spec.existingFrontmatter?.created ? "existing" : spec.content.match(/\*\*Created\*\*/i) ? "content" : fileExistsInGit(spec.filePath) ? "git" : "default";
642
+ if (!options.dryRun) {
643
+ try {
644
+ await writeBootstrapFrontmatter(spec.filePath, spec.content, frontmatter);
645
+ result.source = "bootstrapped";
646
+ result.bootstrapped = true;
647
+ console.log(`\x1B[32m\u2713\x1B[0m ${spec.name} - Bootstrapped`);
648
+ console.log(` status: ${frontmatter.status} (${statusSource})`);
649
+ console.log(` created: ${frontmatter.created} (${createdSource})`);
650
+ } catch (error) {
651
+ result.source = "skipped";
652
+ result.reason = `Error: ${error instanceof Error ? error.message : String(error)}`;
653
+ console.log(`\x1B[31m\u2717\x1B[0m ${spec.name} - Failed: ${result.reason}`);
654
+ }
655
+ } else {
656
+ result.source = "bootstrapped";
657
+ result.bootstrapped = true;
658
+ console.log(`\x1B[36m\u2192\x1B[0m ${spec.name} - Would bootstrap`);
659
+ console.log(` status: ${frontmatter.status} (${statusSource})`);
660
+ console.log(` created: ${frontmatter.created} (${createdSource})`);
661
+ if (frontmatter.created_at) console.log(` created_at: ${frontmatter.created_at} (git)`);
662
+ if (frontmatter.updated_at) console.log(` updated_at: ${frontmatter.updated_at} (git)`);
663
+ if (frontmatter.completed_at) console.log(` completed_at: ${frontmatter.completed_at} (git)`);
664
+ if (frontmatter.assignee) console.log(` assignee: ${frontmatter.assignee} (git)`);
665
+ if (frontmatter.transitions) console.log(` transitions: ${frontmatter.transitions.length} (git)`);
666
+ }
667
+ return result;
668
+ }
669
+ async function writeBootstrapFrontmatter(filePath, originalContent, frontmatter) {
670
+ const matter2 = await import('gray-matter');
671
+ const parsed = matter2.default(originalContent);
672
+ const newData = { ...parsed.data, ...frontmatter };
673
+ const newContent = matter2.default.stringify(parsed.content, newData);
674
+ await fs.writeFile(filePath, newContent, "utf-8");
675
+ }
676
+ function printSummary(results, options) {
677
+ console.log("\n" + "\u2500".repeat(60));
678
+ console.log("\x1B[1mSummary:\x1B[0m\n");
679
+ const total = results.length;
680
+ const updated = results.filter((r) => r.source === "git").length;
681
+ const bootstrapped = results.filter((r) => r.source === "bootstrapped").length;
682
+ const existing = results.filter((r) => r.source === "existing").length;
683
+ const skipped = results.filter((r) => r.source === "skipped").length;
684
+ const timestampUpdates = results.filter(
685
+ (r) => (r.source === "git" || r.source === "bootstrapped") && (r.created_at || r.updated_at || r.completed_at)
686
+ ).length;
687
+ const assigneeUpdates = results.filter(
688
+ (r) => (r.source === "git" || r.source === "bootstrapped") && r.assignee
689
+ ).length;
690
+ const transitionUpdates = results.filter(
691
+ (r) => (r.source === "git" || r.source === "bootstrapped") && r.transitionsCount
692
+ ).length;
693
+ console.log(` ${total} specs analyzed`);
694
+ if (options.dryRun) {
695
+ if (options.bootstrap) {
696
+ console.log(` ${bootstrapped} would be bootstrapped`);
697
+ }
698
+ console.log(` ${updated} would be updated`);
699
+ if (timestampUpdates > 0) {
700
+ console.log(` \u2514\u2500 ${timestampUpdates} with timestamps`);
701
+ }
702
+ if (options.includeAssignee && assigneeUpdates > 0) {
703
+ console.log(` \u2514\u2500 ${assigneeUpdates} with assignee`);
704
+ }
705
+ if (options.includeTransitions && transitionUpdates > 0) {
706
+ console.log(` \u2514\u2500 ${transitionUpdates} with transitions`);
707
+ }
708
+ } else {
709
+ if (options.bootstrap) {
710
+ console.log(` ${bootstrapped} bootstrapped`);
711
+ }
712
+ console.log(` ${updated} updated`);
713
+ }
714
+ console.log(` ${existing} already complete`);
715
+ console.log(` ${skipped} skipped`);
716
+ const skipReasons = results.filter((r) => r.source === "skipped" && r.reason).map((r) => r.reason);
717
+ if (skipReasons.length > 0) {
718
+ console.log("\n\x1B[33mSkipped reasons:\x1B[0m");
719
+ const uniqueReasons = [...new Set(skipReasons)];
720
+ for (const reason of uniqueReasons) {
721
+ const count = skipReasons.filter((r) => r === reason).length;
722
+ console.log(` - ${reason} (${count})`);
723
+ }
724
+ }
725
+ if (options.dryRun) {
726
+ console.log("\n\x1B[36m\u2139\x1B[0m Run without --dry-run to apply changes");
727
+ if (!options.includeAssignee || !options.includeTransitions) {
728
+ console.log("\x1B[36m\u2139\x1B[0m Use --all to include optional fields (assignee, transitions)");
729
+ }
730
+ if (!options.bootstrap && skipped > 0) {
731
+ console.log("\x1B[36m\u2139\x1B[0m Use --bootstrap to create frontmatter for files without it");
732
+ }
733
+ } else if (updated > 0 || bootstrapped > 0) {
734
+ console.log("\n\x1B[32m\u2713\x1B[0m Backfill complete!");
735
+ console.log(" Run \x1B[36mlean-spec stats\x1B[0m to see velocity metrics");
736
+ }
737
+ }
738
+
739
+ export { backfillCommand, backfillTimestamps, createSpecDirPattern, getGlobalNextSeq, resolveSpecPath };
740
+ //# sourceMappingURL=chunk-H5MCUMBK.js.map
741
+ //# sourceMappingURL=chunk-H5MCUMBK.js.map