@os-eco/seeds-cli 0.2.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.
@@ -0,0 +1,732 @@
1
+ import { existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { findSeedsDir, projectRootFromSeedsDir, readConfig } from "../config.ts";
4
+ import { c, outputJson } from "../output.ts";
5
+ import { issuesPath, readIssues, templatesPath, writeIssues, writeTemplates } from "../store.ts";
6
+ import type { Issue, Template } from "../types.ts";
7
+ import {
8
+ ISSUES_FILE,
9
+ LOCK_STALE_MS,
10
+ TEMPLATES_FILE,
11
+ VALID_STATUSES,
12
+ VALID_TYPES,
13
+ } from "../types.ts";
14
+
15
+ interface DoctorCheck {
16
+ name: string;
17
+ status: "pass" | "warn" | "fail";
18
+ message: string;
19
+ details: string[];
20
+ fixable: boolean;
21
+ }
22
+
23
+ interface RawLine {
24
+ lineNumber: number;
25
+ text: string;
26
+ parsed?: unknown;
27
+ error?: string;
28
+ }
29
+
30
+ function readRawLines(filePath: string): RawLine[] {
31
+ if (!existsSync(filePath)) return [];
32
+ const content = readFileSync(filePath, "utf8");
33
+ const lines: RawLine[] = [];
34
+ for (const [i, raw] of content.split("\n").entries()) {
35
+ const text = raw.trim();
36
+ if (!text) continue;
37
+ try {
38
+ lines.push({ lineNumber: i + 1, text, parsed: JSON.parse(text) });
39
+ } catch (err: unknown) {
40
+ const msg = err instanceof Error ? err.message : String(err);
41
+ lines.push({ lineNumber: i + 1, text, error: msg });
42
+ }
43
+ }
44
+ return lines;
45
+ }
46
+
47
+ function checkConfig(seedsDir: string, config: { project: string } | null): DoctorCheck {
48
+ if (!existsSync(seedsDir)) {
49
+ return {
50
+ name: "config",
51
+ status: "fail",
52
+ message: ".seeds/ directory not found",
53
+ details: [],
54
+ fixable: false,
55
+ };
56
+ }
57
+ if (!config) {
58
+ return {
59
+ name: "config",
60
+ status: "fail",
61
+ message: "config.yaml is missing or unparseable",
62
+ details: [],
63
+ fixable: false,
64
+ };
65
+ }
66
+ if (!config.project) {
67
+ return {
68
+ name: "config",
69
+ status: "fail",
70
+ message: "config.yaml missing required 'project' field",
71
+ details: [],
72
+ fixable: false,
73
+ };
74
+ }
75
+ return {
76
+ name: "config",
77
+ status: "pass",
78
+ message: "Config is valid",
79
+ details: [],
80
+ fixable: false,
81
+ };
82
+ }
83
+
84
+ function checkJsonlIntegrity(seedsDir: string): DoctorCheck {
85
+ const details: string[] = [];
86
+ for (const file of [ISSUES_FILE, TEMPLATES_FILE]) {
87
+ const lines = readRawLines(join(seedsDir, file));
88
+ for (const line of lines) {
89
+ if (line.error) {
90
+ details.push(`${file} line ${String(line.lineNumber)}: ${line.error}`);
91
+ }
92
+ }
93
+ }
94
+ if (details.length > 0) {
95
+ return {
96
+ name: "jsonl-integrity",
97
+ status: "fail",
98
+ message: `${String(details.length)} malformed line(s) in JSONL files`,
99
+ details,
100
+ fixable: true,
101
+ };
102
+ }
103
+ return {
104
+ name: "jsonl-integrity",
105
+ status: "pass",
106
+ message: "All JSONL lines parse correctly",
107
+ details: [],
108
+ fixable: false,
109
+ };
110
+ }
111
+
112
+ function checkSchemaValidation(seedsDir: string): DoctorCheck {
113
+ const details: string[] = [];
114
+ const lines = readRawLines(join(seedsDir, ISSUES_FILE));
115
+ for (const line of lines) {
116
+ if (!line.parsed) continue;
117
+ const issue = line.parsed as Record<string, unknown>;
118
+ const id = typeof issue.id === "string" ? issue.id : `line ${String(line.lineNumber)}`;
119
+ if (!issue.id || typeof issue.id !== "string") {
120
+ details.push(`${id}: missing or invalid 'id'`);
121
+ }
122
+ if (!issue.title || typeof issue.title !== "string") {
123
+ details.push(`${id}: missing or invalid 'title'`);
124
+ }
125
+ if (!issue.createdAt || typeof issue.createdAt !== "string") {
126
+ details.push(`${id}: missing or invalid 'createdAt'`);
127
+ }
128
+ if (!issue.updatedAt || typeof issue.updatedAt !== "string") {
129
+ details.push(`${id}: missing or invalid 'updatedAt'`);
130
+ }
131
+ if (
132
+ typeof issue.status === "string" &&
133
+ !(VALID_STATUSES as readonly string[]).includes(issue.status)
134
+ ) {
135
+ details.push(`${id}: invalid status '${issue.status}'`);
136
+ }
137
+ if (
138
+ typeof issue.type === "string" &&
139
+ !(VALID_TYPES as readonly string[]).includes(issue.type)
140
+ ) {
141
+ details.push(`${id}: invalid type '${issue.type}'`);
142
+ }
143
+ if (typeof issue.priority === "number" && (issue.priority < 0 || issue.priority > 4)) {
144
+ details.push(`${id}: invalid priority ${String(issue.priority)} (must be 0-4)`);
145
+ }
146
+ }
147
+ if (details.length > 0) {
148
+ return {
149
+ name: "schema-validation",
150
+ status: "fail",
151
+ message: `${String(details.length)} schema violation(s)`,
152
+ details,
153
+ fixable: false,
154
+ };
155
+ }
156
+ return {
157
+ name: "schema-validation",
158
+ status: "pass",
159
+ message: "All issues have valid schema",
160
+ details: [],
161
+ fixable: false,
162
+ };
163
+ }
164
+
165
+ function checkDuplicateIds(seedsDir: string): DoctorCheck {
166
+ const details: string[] = [];
167
+ for (const file of [ISSUES_FILE, TEMPLATES_FILE]) {
168
+ const lines = readRawLines(join(seedsDir, file));
169
+ const counts = new Map<string, number>();
170
+ for (const line of lines) {
171
+ if (!line.parsed) continue;
172
+ const item = line.parsed as { id?: string };
173
+ if (typeof item.id === "string") {
174
+ counts.set(item.id, (counts.get(item.id) ?? 0) + 1);
175
+ }
176
+ }
177
+ for (const [id, count] of counts) {
178
+ if (count > 1) {
179
+ details.push(`${id} appears ${String(count)} times in ${file}`);
180
+ }
181
+ }
182
+ }
183
+ if (details.length > 0) {
184
+ return {
185
+ name: "duplicate-ids",
186
+ status: "warn",
187
+ message: `${String(details.length)} duplicate ID(s) found`,
188
+ details,
189
+ fixable: true,
190
+ };
191
+ }
192
+ return {
193
+ name: "duplicate-ids",
194
+ status: "pass",
195
+ message: "No duplicate IDs",
196
+ details: [],
197
+ fixable: false,
198
+ };
199
+ }
200
+
201
+ function checkReferentialIntegrity(issues: Issue[]): DoctorCheck {
202
+ const ids = new Set(issues.map((i) => i.id));
203
+ const details: string[] = [];
204
+ for (const issue of issues) {
205
+ for (const ref of issue.blockedBy ?? []) {
206
+ if (!ids.has(ref)) {
207
+ details.push(`${issue.id}.blockedBy → ${ref} (not found)`);
208
+ }
209
+ }
210
+ for (const ref of issue.blocks ?? []) {
211
+ if (!ids.has(ref)) {
212
+ details.push(`${issue.id}.blocks → ${ref} (not found)`);
213
+ }
214
+ }
215
+ }
216
+ if (details.length > 0) {
217
+ return {
218
+ name: "referential-integrity",
219
+ status: "warn",
220
+ message: `${String(details.length)} dangling dependency reference(s)`,
221
+ details,
222
+ fixable: true,
223
+ };
224
+ }
225
+ return {
226
+ name: "referential-integrity",
227
+ status: "pass",
228
+ message: "All dependency references are valid",
229
+ details: [],
230
+ fixable: false,
231
+ };
232
+ }
233
+
234
+ function checkBidirectionalConsistency(issues: Issue[]): DoctorCheck {
235
+ const byId = new Map<string, Issue>();
236
+ for (const issue of issues) {
237
+ byId.set(issue.id, issue);
238
+ }
239
+ const details: string[] = [];
240
+ for (const issue of issues) {
241
+ for (const ref of issue.blockedBy ?? []) {
242
+ const target = byId.get(ref);
243
+ if (target && !(target.blocks ?? []).includes(issue.id)) {
244
+ details.push(`${issue.id}.blockedBy has ${ref}, but ${ref}.blocks missing ${issue.id}`);
245
+ }
246
+ }
247
+ for (const ref of issue.blocks ?? []) {
248
+ const target = byId.get(ref);
249
+ if (target && !(target.blockedBy ?? []).includes(issue.id)) {
250
+ details.push(`${issue.id}.blocks has ${ref}, but ${ref}.blockedBy missing ${issue.id}`);
251
+ }
252
+ }
253
+ }
254
+ if (details.length > 0) {
255
+ return {
256
+ name: "bidirectional-consistency",
257
+ status: "warn",
258
+ message: `${String(details.length)} bidirectional mismatch(es)`,
259
+ details,
260
+ fixable: true,
261
+ };
262
+ }
263
+ return {
264
+ name: "bidirectional-consistency",
265
+ status: "pass",
266
+ message: "All dependency links are bidirectional",
267
+ details: [],
268
+ fixable: false,
269
+ };
270
+ }
271
+
272
+ function checkCircularDependencies(issues: Issue[]): DoctorCheck {
273
+ const graph = new Map<string, string[]>();
274
+ for (const issue of issues) {
275
+ graph.set(issue.id, issue.blockedBy ?? []);
276
+ }
277
+ const visited = new Set<string>();
278
+ const inStack = new Set<string>();
279
+ const cycles: string[][] = [];
280
+
281
+ function dfs(node: string, path: string[]): void {
282
+ if (inStack.has(node)) {
283
+ const cycleStart = path.indexOf(node);
284
+ if (cycleStart >= 0) {
285
+ cycles.push(path.slice(cycleStart).concat(node));
286
+ }
287
+ return;
288
+ }
289
+ if (visited.has(node)) return;
290
+ visited.add(node);
291
+ inStack.add(node);
292
+ for (const dep of graph.get(node) ?? []) {
293
+ dfs(dep, [...path, node]);
294
+ }
295
+ inStack.delete(node);
296
+ }
297
+
298
+ for (const id of graph.keys()) {
299
+ dfs(id, []);
300
+ }
301
+
302
+ if (cycles.length > 0) {
303
+ const details = cycles.map((cycle) => cycle.join(" → "));
304
+ return {
305
+ name: "circular-dependencies",
306
+ status: "warn",
307
+ message: `${String(cycles.length)} circular dependency chain(s) found`,
308
+ details,
309
+ fixable: false,
310
+ };
311
+ }
312
+ return {
313
+ name: "circular-dependencies",
314
+ status: "pass",
315
+ message: "No circular dependencies",
316
+ details: [],
317
+ fixable: false,
318
+ };
319
+ }
320
+
321
+ function checkStaleLocks(seedsDir: string): DoctorCheck {
322
+ const details: string[] = [];
323
+ for (const file of [ISSUES_FILE, TEMPLATES_FILE]) {
324
+ const lockPath = join(seedsDir, `${file}.lock`);
325
+ if (existsSync(lockPath)) {
326
+ try {
327
+ const st = statSync(lockPath);
328
+ const age = Date.now() - st.mtimeMs;
329
+ if (age > LOCK_STALE_MS) {
330
+ details.push(`${file}.lock is stale (${String(Math.round(age / 1000))}s old)`);
331
+ } else {
332
+ details.push(
333
+ `${file}.lock exists (${String(Math.round(age / 1000))}s old, may be active)`,
334
+ );
335
+ }
336
+ } catch {
337
+ details.push(`${file}.lock exists but cannot stat`);
338
+ }
339
+ }
340
+ }
341
+ if (details.length > 0) {
342
+ return {
343
+ name: "stale-locks",
344
+ status: "warn",
345
+ message: `${String(details.length)} lock file(s) found`,
346
+ details,
347
+ fixable: true,
348
+ };
349
+ }
350
+ return {
351
+ name: "stale-locks",
352
+ status: "pass",
353
+ message: "No stale lock files",
354
+ details: [],
355
+ fixable: false,
356
+ };
357
+ }
358
+
359
+ function checkGitattributes(seedsDir: string): DoctorCheck {
360
+ const projectRoot = projectRootFromSeedsDir(seedsDir);
361
+ const gitattrsPath = join(projectRoot, ".gitattributes");
362
+ const details: string[] = [];
363
+
364
+ if (!existsSync(gitattrsPath)) {
365
+ details.push(".gitattributes file not found");
366
+ } else {
367
+ const content = readFileSync(gitattrsPath, "utf8");
368
+ if (!content.includes(".seeds/issues.jsonl merge=union")) {
369
+ details.push("Missing: .seeds/issues.jsonl merge=union");
370
+ }
371
+ if (!content.includes(".seeds/templates.jsonl merge=union")) {
372
+ details.push("Missing: .seeds/templates.jsonl merge=union");
373
+ }
374
+ }
375
+
376
+ if (details.length > 0) {
377
+ return {
378
+ name: "gitattributes",
379
+ status: "warn",
380
+ message: "Missing merge=union gitattributes entries",
381
+ details,
382
+ fixable: true,
383
+ };
384
+ }
385
+ return {
386
+ name: "gitattributes",
387
+ status: "pass",
388
+ message: "Gitattributes configured correctly",
389
+ details: [],
390
+ fixable: false,
391
+ };
392
+ }
393
+
394
+ function applyFixes(seedsDir: string, checks: DoctorCheck[]): string[] {
395
+ const fixed: string[] = [];
396
+
397
+ for (const check of checks) {
398
+ if (check.status === "pass" || !check.fixable) continue;
399
+
400
+ switch (check.name) {
401
+ case "jsonl-integrity": {
402
+ for (const file of [ISSUES_FILE, TEMPLATES_FILE]) {
403
+ const filePath = join(seedsDir, file);
404
+ if (!existsSync(filePath)) continue;
405
+ const lines = readRawLines(filePath);
406
+ const validLines = lines.filter((l) => !l.error);
407
+ if (validLines.length < lines.length) {
408
+ const content =
409
+ validLines.length > 0 ? `${validLines.map((l) => l.text).join("\n")}\n` : "";
410
+ writeFileSync(filePath, content);
411
+ fixed.push(
412
+ `Removed ${String(lines.length - validLines.length)} malformed line(s) from ${file}`,
413
+ );
414
+ }
415
+ }
416
+ break;
417
+ }
418
+ case "duplicate-ids": {
419
+ // Read, dedup (last wins), and rewrite via store functions
420
+ fixDuplicates(seedsDir, fixed);
421
+ break;
422
+ }
423
+ case "referential-integrity": {
424
+ fixDanglingRefs(seedsDir, fixed);
425
+ break;
426
+ }
427
+ case "bidirectional-consistency": {
428
+ fixBidirectional(seedsDir, fixed);
429
+ break;
430
+ }
431
+ case "stale-locks": {
432
+ for (const file of [ISSUES_FILE, TEMPLATES_FILE]) {
433
+ const lockPath = join(seedsDir, `${file}.lock`);
434
+ if (existsSync(lockPath)) {
435
+ try {
436
+ const st = statSync(lockPath);
437
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
438
+ unlinkSync(lockPath);
439
+ fixed.push(`Removed stale ${file}.lock`);
440
+ }
441
+ } catch {
442
+ // best-effort
443
+ }
444
+ }
445
+ }
446
+ break;
447
+ }
448
+ case "gitattributes": {
449
+ fixGitattributes(seedsDir, fixed);
450
+ break;
451
+ }
452
+ }
453
+ }
454
+ return fixed;
455
+ }
456
+
457
+ function fixDuplicates(seedsDir: string, fixed: string[]): void {
458
+ // Issues
459
+ const issueLines = readRawLines(join(seedsDir, ISSUES_FILE));
460
+ const issueMap = new Map<string, Issue>();
461
+ for (const line of issueLines) {
462
+ if (!line.parsed) continue;
463
+ const issue = line.parsed as Issue;
464
+ if (typeof issue.id === "string") {
465
+ issueMap.set(issue.id, issue);
466
+ }
467
+ }
468
+ if (issueMap.size < issueLines.filter((l) => l.parsed).length) {
469
+ const content = `${Array.from(issueMap.values())
470
+ .map((i) => JSON.stringify(i))
471
+ .join("\n")}\n`;
472
+ writeFileSync(join(seedsDir, ISSUES_FILE), content);
473
+ fixed.push("Deduplicated issues.jsonl");
474
+ }
475
+
476
+ // Templates
477
+ const tplLines = readRawLines(join(seedsDir, TEMPLATES_FILE));
478
+ const tplMap = new Map<string, Template>();
479
+ for (const line of tplLines) {
480
+ if (!line.parsed) continue;
481
+ const tpl = line.parsed as Template;
482
+ if (typeof tpl.id === "string") {
483
+ tplMap.set(tpl.id, tpl);
484
+ }
485
+ }
486
+ if (tplMap.size < tplLines.filter((l) => l.parsed).length) {
487
+ const content = `${Array.from(tplMap.values())
488
+ .map((t) => JSON.stringify(t))
489
+ .join("\n")}\n`;
490
+ writeFileSync(join(seedsDir, TEMPLATES_FILE), content);
491
+ fixed.push("Deduplicated templates.jsonl");
492
+ }
493
+ }
494
+
495
+ function fixDanglingRefs(seedsDir: string, fixed: string[]): void {
496
+ const lines = readRawLines(join(seedsDir, ISSUES_FILE));
497
+ const issues: Issue[] = [];
498
+ const idMap = new Map<string, Issue>();
499
+ for (const line of lines) {
500
+ if (!line.parsed) continue;
501
+ const issue = line.parsed as Issue;
502
+ if (typeof issue.id === "string") {
503
+ idMap.set(issue.id, issue);
504
+ }
505
+ }
506
+ // Dedup: last wins
507
+ const deduped = Array.from(idMap.values());
508
+ const ids = new Set(deduped.map((i) => i.id));
509
+ let changed = false;
510
+ for (const issue of deduped) {
511
+ const origBlockedBy = issue.blockedBy?.length ?? 0;
512
+ const origBlocks = issue.blocks?.length ?? 0;
513
+ if (issue.blockedBy) {
514
+ issue.blockedBy = issue.blockedBy.filter((ref) => ids.has(ref));
515
+ if (issue.blockedBy.length === 0) issue.blockedBy = undefined;
516
+ }
517
+ if (issue.blocks) {
518
+ issue.blocks = issue.blocks.filter((ref) => ids.has(ref));
519
+ if (issue.blocks.length === 0) issue.blocks = undefined;
520
+ }
521
+ if (
522
+ (issue.blockedBy?.length ?? 0) !== origBlockedBy ||
523
+ (issue.blocks?.length ?? 0) !== origBlocks
524
+ ) {
525
+ changed = true;
526
+ }
527
+ issues.push(issue);
528
+ }
529
+ if (changed) {
530
+ const content = `${issues.map((i) => JSON.stringify(i)).join("\n")}\n`;
531
+ writeFileSync(join(seedsDir, ISSUES_FILE), content);
532
+ fixed.push("Removed dangling dependency references");
533
+ }
534
+ }
535
+
536
+ function fixBidirectional(seedsDir: string, fixed: string[]): void {
537
+ const lines = readRawLines(join(seedsDir, ISSUES_FILE));
538
+ const idMap = new Map<string, Issue>();
539
+ for (const line of lines) {
540
+ if (!line.parsed) continue;
541
+ const issue = line.parsed as Issue;
542
+ if (typeof issue.id === "string") {
543
+ idMap.set(issue.id, issue);
544
+ }
545
+ }
546
+ const issues = Array.from(idMap.values());
547
+ let changed = false;
548
+
549
+ for (const issue of issues) {
550
+ for (const ref of issue.blockedBy ?? []) {
551
+ const target = idMap.get(ref);
552
+ if (target && !(target.blocks ?? []).includes(issue.id)) {
553
+ target.blocks = [...(target.blocks ?? []), issue.id];
554
+ changed = true;
555
+ }
556
+ }
557
+ for (const ref of issue.blocks ?? []) {
558
+ const target = idMap.get(ref);
559
+ if (target && !(target.blockedBy ?? []).includes(issue.id)) {
560
+ target.blockedBy = [...(target.blockedBy ?? []), issue.id];
561
+ changed = true;
562
+ }
563
+ }
564
+ }
565
+
566
+ if (changed) {
567
+ const content = `${issues.map((i) => JSON.stringify(i)).join("\n")}\n`;
568
+ writeFileSync(join(seedsDir, ISSUES_FILE), content);
569
+ fixed.push("Added missing bidirectional dependency links");
570
+ }
571
+ }
572
+
573
+ function fixGitattributes(seedsDir: string, fixed: string[]): void {
574
+ const projectRoot = projectRootFromSeedsDir(seedsDir);
575
+ const gitattrsPath = join(projectRoot, ".gitattributes");
576
+ const issueEntry = ".seeds/issues.jsonl merge=union";
577
+ const tplEntry = ".seeds/templates.jsonl merge=union";
578
+
579
+ if (!existsSync(gitattrsPath)) {
580
+ writeFileSync(gitattrsPath, `${issueEntry}\n${tplEntry}\n`);
581
+ fixed.push("Created .gitattributes with merge=union entries");
582
+ return;
583
+ }
584
+
585
+ const content = readFileSync(gitattrsPath, "utf8");
586
+ const missing: string[] = [];
587
+ if (!content.includes(issueEntry)) missing.push(issueEntry);
588
+ if (!content.includes(tplEntry)) missing.push(tplEntry);
589
+
590
+ if (missing.length > 0) {
591
+ const suffix = missing.map((e) => `${e}\n`).join("");
592
+ const separator = content.endsWith("\n") ? "" : "\n";
593
+ writeFileSync(gitattrsPath, `${content}${separator}${suffix}`);
594
+ fixed.push("Added missing merge=union entries to .gitattributes");
595
+ }
596
+ }
597
+
598
+ function printCheck(check: DoctorCheck, verbose: boolean): void {
599
+ if (check.status === "pass" && !verbose) return;
600
+
601
+ const icon =
602
+ check.status === "pass"
603
+ ? `${c.green}✓${c.reset}`
604
+ : check.status === "warn"
605
+ ? `${c.yellow}⚠${c.reset}`
606
+ : `${c.red}✗${c.reset}`;
607
+
608
+ console.log(` ${icon} ${check.message}`);
609
+ for (const detail of check.details) {
610
+ console.log(` ${c.dim}${detail}${c.reset}`);
611
+ }
612
+ }
613
+
614
+ export async function run(args: string[], seedsDir?: string): Promise<void> {
615
+ const jsonMode = args.includes("--json");
616
+ const fixMode = args.includes("--fix");
617
+ const verbose = args.includes("--verbose");
618
+
619
+ const dir = seedsDir ?? (await findSeedsDir());
620
+
621
+ // Load config
622
+ let config: { project: string } | null = null;
623
+ try {
624
+ config = await readConfig(dir);
625
+ } catch {
626
+ config = null;
627
+ }
628
+
629
+ // Run checks
630
+ const checks: DoctorCheck[] = [];
631
+
632
+ const configCheck = checkConfig(dir, config);
633
+ checks.push(configCheck);
634
+
635
+ // If config fails, skip remaining checks
636
+ if (configCheck.status === "fail") {
637
+ return reportResults(checks, jsonMode, verbose, fixMode, dir);
638
+ }
639
+
640
+ checks.push(checkJsonlIntegrity(dir));
641
+ checks.push(checkSchemaValidation(dir));
642
+ checks.push(checkDuplicateIds(dir));
643
+
644
+ // Load deduped issues for dependency checks
645
+ const issues = await readIssues(dir);
646
+ checks.push(checkReferentialIntegrity(issues));
647
+ checks.push(checkBidirectionalConsistency(issues));
648
+ checks.push(checkCircularDependencies(issues));
649
+ checks.push(checkStaleLocks(dir));
650
+ checks.push(checkGitattributes(dir));
651
+
652
+ // Apply fixes if requested
653
+ if (fixMode) {
654
+ const fixableFailures = checks.filter((ch) => ch.fixable && ch.status !== "pass");
655
+ if (fixableFailures.length > 0) {
656
+ const fixedItems = applyFixes(dir, checks);
657
+
658
+ // Re-run all checks after fixes
659
+ const reChecks: DoctorCheck[] = [];
660
+ let reConfig: { project: string } | null = null;
661
+ try {
662
+ reConfig = await readConfig(dir);
663
+ } catch {
664
+ reConfig = null;
665
+ }
666
+ reChecks.push(checkConfig(dir, reConfig));
667
+ if (reChecks[0]?.status !== "fail") {
668
+ reChecks.push(checkJsonlIntegrity(dir));
669
+ reChecks.push(checkSchemaValidation(dir));
670
+ reChecks.push(checkDuplicateIds(dir));
671
+ const reIssues = await readIssues(dir);
672
+ reChecks.push(checkReferentialIntegrity(reIssues));
673
+ reChecks.push(checkBidirectionalConsistency(reIssues));
674
+ reChecks.push(checkCircularDependencies(reIssues));
675
+ reChecks.push(checkStaleLocks(dir));
676
+ reChecks.push(checkGitattributes(dir));
677
+ }
678
+ return reportResults(reChecks, jsonMode, verbose, fixMode, dir, fixedItems);
679
+ }
680
+ }
681
+
682
+ return reportResults(checks, jsonMode, verbose, fixMode, dir);
683
+ }
684
+
685
+ function reportResults(
686
+ checks: DoctorCheck[],
687
+ jsonMode: boolean,
688
+ verbose: boolean,
689
+ fixMode: boolean,
690
+ _seedsDir: string,
691
+ fixedItems?: string[],
692
+ ): void {
693
+ const summary = {
694
+ pass: checks.filter((ch) => ch.status === "pass").length,
695
+ warn: checks.filter((ch) => ch.status === "warn").length,
696
+ fail: checks.filter((ch) => ch.status === "fail").length,
697
+ };
698
+
699
+ if (jsonMode) {
700
+ outputJson({
701
+ success: summary.fail === 0,
702
+ command: "doctor",
703
+ checks: checks.map((ch) => ({
704
+ name: ch.name,
705
+ status: ch.status,
706
+ message: ch.message,
707
+ details: ch.details,
708
+ fixable: ch.fixable,
709
+ })),
710
+ summary,
711
+ ...(fixedItems && fixedItems.length > 0 ? { fixed: fixedItems } : {}),
712
+ });
713
+ } else {
714
+ console.log(`\n${c.bold}Seeds Doctor${c.reset}\n`);
715
+ for (const check of checks) {
716
+ printCheck(check, verbose);
717
+ }
718
+ console.log(
719
+ `\n${String(summary.pass)} passed, ${String(summary.warn)} warning(s), ${String(summary.fail)} failure(s)`,
720
+ );
721
+ if (fixedItems && fixedItems.length > 0) {
722
+ console.log(`\n${c.bold}Fixed:${c.reset}`);
723
+ for (const item of fixedItems) {
724
+ console.log(` ${c.green}✓${c.reset} ${item}`);
725
+ }
726
+ }
727
+ }
728
+
729
+ if (summary.fail > 0) {
730
+ process.exit(1);
731
+ }
732
+ }