@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.
- package/README.md +226 -0
- package/package.json +42 -0
- package/src/commands/blocked.ts +31 -0
- package/src/commands/close.ts +80 -0
- package/src/commands/create.test.ts +507 -0
- package/src/commands/create.ts +103 -0
- package/src/commands/dep.test.ts +215 -0
- package/src/commands/dep.ts +108 -0
- package/src/commands/doctor.test.ts +524 -0
- package/src/commands/doctor.ts +732 -0
- package/src/commands/init.test.ts +86 -0
- package/src/commands/init.ts +49 -0
- package/src/commands/list.ts +69 -0
- package/src/commands/migrate.ts +117 -0
- package/src/commands/onboard.test.ts +155 -0
- package/src/commands/onboard.ts +140 -0
- package/src/commands/prime.test.ts +94 -0
- package/src/commands/prime.ts +146 -0
- package/src/commands/ready.ts +31 -0
- package/src/commands/show.ts +20 -0
- package/src/commands/stats.ts +58 -0
- package/src/commands/sync.ts +76 -0
- package/src/commands/tpl.test.ts +330 -0
- package/src/commands/tpl.ts +279 -0
- package/src/commands/update.ts +97 -0
- package/src/config.ts +39 -0
- package/src/id.test.ts +55 -0
- package/src/id.ts +22 -0
- package/src/index.ts +122 -0
- package/src/markers.test.ts +73 -0
- package/src/markers.ts +19 -0
- package/src/output.ts +63 -0
- package/src/store.test.ts +173 -0
- package/src/store.ts +143 -0
- package/src/types.ts +61 -0
- package/src/yaml.test.ts +79 -0
- package/src/yaml.ts +27 -0
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
let tmpDir: string;
|
|
8
|
+
|
|
9
|
+
const CLI = join(import.meta.dir, "../../src/index.ts");
|
|
10
|
+
|
|
11
|
+
async function run(
|
|
12
|
+
args: string[],
|
|
13
|
+
cwd: string,
|
|
14
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
15
|
+
const proc = Bun.spawn(["bun", "run", CLI, ...args], {
|
|
16
|
+
cwd,
|
|
17
|
+
stdout: "pipe",
|
|
18
|
+
stderr: "pipe",
|
|
19
|
+
});
|
|
20
|
+
const stdout = await new Response(proc.stdout).text();
|
|
21
|
+
const stderr = await new Response(proc.stderr).text();
|
|
22
|
+
const exitCode = await proc.exited;
|
|
23
|
+
return { stdout, stderr, exitCode };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function runJson<T = unknown>(args: string[], cwd: string): Promise<T> {
|
|
27
|
+
const { stdout } = await run([...args, "--json"], cwd);
|
|
28
|
+
return JSON.parse(stdout) as T;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface DoctorResult {
|
|
32
|
+
success: boolean;
|
|
33
|
+
command: string;
|
|
34
|
+
checks: Array<{
|
|
35
|
+
name: string;
|
|
36
|
+
status: "pass" | "warn" | "fail";
|
|
37
|
+
message: string;
|
|
38
|
+
details: string[];
|
|
39
|
+
fixable: boolean;
|
|
40
|
+
}>;
|
|
41
|
+
summary: { pass: number; warn: number; fail: number };
|
|
42
|
+
fixed?: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function seedsDir(dir: string): string {
|
|
46
|
+
return join(dir, ".seeds");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
beforeEach(async () => {
|
|
50
|
+
tmpDir = await mkdtemp(join(tmpdir(), "seeds-doctor-test-"));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(async () => {
|
|
54
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("doctor: config check", () => {
|
|
58
|
+
test("fails when .seeds/ directory is missing", async () => {
|
|
59
|
+
const { exitCode } = await run(["doctor"], tmpDir);
|
|
60
|
+
expect(exitCode).not.toBe(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("passes with valid config", async () => {
|
|
64
|
+
await run(["init"], tmpDir);
|
|
65
|
+
const result = await runJson<DoctorResult>(["doctor"], tmpDir);
|
|
66
|
+
const configCheck = result.checks.find((ch) => ch.name === "config");
|
|
67
|
+
expect(configCheck?.status).toBe("pass");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("doctor: jsonl-integrity check", () => {
|
|
72
|
+
test("fails on malformed JSON lines", async () => {
|
|
73
|
+
await run(["init"], tmpDir);
|
|
74
|
+
writeFileSync(join(seedsDir(tmpDir), "issues.jsonl"), '{"id":"a"}\nNOT JSON\n');
|
|
75
|
+
|
|
76
|
+
const result = await runJson<DoctorResult>(["doctor"], tmpDir);
|
|
77
|
+
const check = result.checks.find((ch) => ch.name === "jsonl-integrity");
|
|
78
|
+
expect(check?.status).toBe("fail");
|
|
79
|
+
expect(check?.details.length).toBeGreaterThan(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("passes with clean JSONL", async () => {
|
|
83
|
+
await run(["init"], tmpDir);
|
|
84
|
+
await run(["create", "--title", "Test issue"], tmpDir);
|
|
85
|
+
|
|
86
|
+
const result = await runJson<DoctorResult>(["doctor"], tmpDir);
|
|
87
|
+
const check = result.checks.find((ch) => ch.name === "jsonl-integrity");
|
|
88
|
+
expect(check?.status).toBe("pass");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("--fix removes malformed lines", async () => {
|
|
92
|
+
await run(["init"], tmpDir);
|
|
93
|
+
// Create a valid issue first, then corrupt the file
|
|
94
|
+
await run(["create", "--title", "Valid issue"], tmpDir);
|
|
95
|
+
const content = readFileSync(join(seedsDir(tmpDir), "issues.jsonl"), "utf8");
|
|
96
|
+
writeFileSync(join(seedsDir(tmpDir), "issues.jsonl"), `${content}BROKEN LINE\n`);
|
|
97
|
+
|
|
98
|
+
// Verify broken
|
|
99
|
+
const before = await runJson<DoctorResult>(["doctor"], tmpDir);
|
|
100
|
+
const beforeCheck = before.checks.find((ch) => ch.name === "jsonl-integrity");
|
|
101
|
+
expect(beforeCheck?.status).toBe("fail");
|
|
102
|
+
|
|
103
|
+
// Fix
|
|
104
|
+
const after = await runJson<DoctorResult>(["doctor", "--fix"], tmpDir);
|
|
105
|
+
const afterCheck = after.checks.find((ch) => ch.name === "jsonl-integrity");
|
|
106
|
+
expect(afterCheck?.status).toBe("pass");
|
|
107
|
+
expect(after.fixed).toBeDefined();
|
|
108
|
+
expect(after.fixed!.length).toBeGreaterThan(0);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("doctor: schema-validation check", () => {
|
|
113
|
+
test("fails with invalid status", async () => {
|
|
114
|
+
await run(["init"], tmpDir);
|
|
115
|
+
const now = new Date().toISOString();
|
|
116
|
+
const bad = JSON.stringify({
|
|
117
|
+
id: "test-0001",
|
|
118
|
+
title: "Bad",
|
|
119
|
+
status: "invalid_status",
|
|
120
|
+
type: "task",
|
|
121
|
+
priority: 2,
|
|
122
|
+
createdAt: now,
|
|
123
|
+
updatedAt: now,
|
|
124
|
+
});
|
|
125
|
+
writeFileSync(join(seedsDir(tmpDir), "issues.jsonl"), `${bad}\n`);
|
|
126
|
+
|
|
127
|
+
const result = await runJson<DoctorResult>(["doctor"], tmpDir);
|
|
128
|
+
const check = result.checks.find((ch) => ch.name === "schema-validation");
|
|
129
|
+
expect(check?.status).toBe("fail");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("fails with invalid type", async () => {
|
|
133
|
+
await run(["init"], tmpDir);
|
|
134
|
+
const now = new Date().toISOString();
|
|
135
|
+
const bad = JSON.stringify({
|
|
136
|
+
id: "test-0001",
|
|
137
|
+
title: "Bad",
|
|
138
|
+
status: "open",
|
|
139
|
+
type: "invalid_type",
|
|
140
|
+
priority: 2,
|
|
141
|
+
createdAt: now,
|
|
142
|
+
updatedAt: now,
|
|
143
|
+
});
|
|
144
|
+
writeFileSync(join(seedsDir(tmpDir), "issues.jsonl"), `${bad}\n`);
|
|
145
|
+
|
|
146
|
+
const result = await runJson<DoctorResult>(["doctor"], tmpDir);
|
|
147
|
+
const check = result.checks.find((ch) => ch.name === "schema-validation");
|
|
148
|
+
expect(check?.status).toBe("fail");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("fails with invalid priority", async () => {
|
|
152
|
+
await run(["init"], tmpDir);
|
|
153
|
+
const now = new Date().toISOString();
|
|
154
|
+
const bad = JSON.stringify({
|
|
155
|
+
id: "test-0001",
|
|
156
|
+
title: "Bad",
|
|
157
|
+
status: "open",
|
|
158
|
+
type: "task",
|
|
159
|
+
priority: 9,
|
|
160
|
+
createdAt: now,
|
|
161
|
+
updatedAt: now,
|
|
162
|
+
});
|
|
163
|
+
writeFileSync(join(seedsDir(tmpDir), "issues.jsonl"), `${bad}\n`);
|
|
164
|
+
|
|
165
|
+
const result = await runJson<DoctorResult>(["doctor"], tmpDir);
|
|
166
|
+
const check = result.checks.find((ch) => ch.name === "schema-validation");
|
|
167
|
+
expect(check?.status).toBe("fail");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("passes with valid issues", async () => {
|
|
171
|
+
await run(["init"], tmpDir);
|
|
172
|
+
await run(["create", "--title", "Valid issue"], tmpDir);
|
|
173
|
+
|
|
174
|
+
const result = await runJson<DoctorResult>(["doctor"], tmpDir);
|
|
175
|
+
const check = result.checks.find((ch) => ch.name === "schema-validation");
|
|
176
|
+
expect(check?.status).toBe("pass");
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("doctor: duplicate-ids check", () => {
|
|
181
|
+
test("warns on duplicate IDs", async () => {
|
|
182
|
+
await run(["init"], tmpDir);
|
|
183
|
+
const now = new Date().toISOString();
|
|
184
|
+
const issue = {
|
|
185
|
+
id: "test-0001",
|
|
186
|
+
title: "Dup",
|
|
187
|
+
status: "open",
|
|
188
|
+
type: "task",
|
|
189
|
+
priority: 2,
|
|
190
|
+
createdAt: now,
|
|
191
|
+
updatedAt: now,
|
|
192
|
+
};
|
|
193
|
+
const line = JSON.stringify(issue);
|
|
194
|
+
writeFileSync(join(seedsDir(tmpDir), "issues.jsonl"), `${line}\n${line}\n`);
|
|
195
|
+
|
|
196
|
+
const result = await runJson<DoctorResult>(["doctor"], tmpDir);
|
|
197
|
+
const check = result.checks.find((ch) => ch.name === "duplicate-ids");
|
|
198
|
+
expect(check?.status).toBe("warn");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("passes with unique IDs", async () => {
|
|
202
|
+
await run(["init"], tmpDir);
|
|
203
|
+
await run(["create", "--title", "Issue 1"], tmpDir);
|
|
204
|
+
await run(["create", "--title", "Issue 2"], tmpDir);
|
|
205
|
+
|
|
206
|
+
const result = await runJson<DoctorResult>(["doctor"], tmpDir);
|
|
207
|
+
const check = result.checks.find((ch) => ch.name === "duplicate-ids");
|
|
208
|
+
expect(check?.status).toBe("pass");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("--fix deduplicates", async () => {
|
|
212
|
+
await run(["init"], tmpDir);
|
|
213
|
+
const now = new Date().toISOString();
|
|
214
|
+
const issue = {
|
|
215
|
+
id: "test-0001",
|
|
216
|
+
title: "Dup",
|
|
217
|
+
status: "open",
|
|
218
|
+
type: "task",
|
|
219
|
+
priority: 2,
|
|
220
|
+
createdAt: now,
|
|
221
|
+
updatedAt: now,
|
|
222
|
+
};
|
|
223
|
+
const line = JSON.stringify(issue);
|
|
224
|
+
writeFileSync(join(seedsDir(tmpDir), "issues.jsonl"), `${line}\n${line}\n`);
|
|
225
|
+
|
|
226
|
+
const after = await runJson<DoctorResult>(["doctor", "--fix"], tmpDir);
|
|
227
|
+
const check = after.checks.find((ch) => ch.name === "duplicate-ids");
|
|
228
|
+
expect(check?.status).toBe("pass");
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("doctor: referential-integrity check", () => {
|
|
233
|
+
test("warns on dangling dependency reference", async () => {
|
|
234
|
+
await run(["init"], tmpDir);
|
|
235
|
+
const now = new Date().toISOString();
|
|
236
|
+
const issue = JSON.stringify({
|
|
237
|
+
id: "test-0001",
|
|
238
|
+
title: "Orphan ref",
|
|
239
|
+
status: "open",
|
|
240
|
+
type: "task",
|
|
241
|
+
priority: 2,
|
|
242
|
+
blockedBy: ["test-xxxx"],
|
|
243
|
+
createdAt: now,
|
|
244
|
+
updatedAt: now,
|
|
245
|
+
});
|
|
246
|
+
writeFileSync(join(seedsDir(tmpDir), "issues.jsonl"), `${issue}\n`);
|
|
247
|
+
|
|
248
|
+
const result = await runJson<DoctorResult>(["doctor"], tmpDir);
|
|
249
|
+
const check = result.checks.find((ch) => ch.name === "referential-integrity");
|
|
250
|
+
expect(check?.status).toBe("warn");
|
|
251
|
+
expect(check?.details[0]).toContain("test-xxxx");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("passes with valid references", async () => {
|
|
255
|
+
await run(["init"], tmpDir);
|
|
256
|
+
const c1 = await runJson<{ id: string }>(["create", "--title", "A"], tmpDir);
|
|
257
|
+
const c2 = await runJson<{ id: string }>(["create", "--title", "B"], tmpDir);
|
|
258
|
+
await run(["dep", "add", c2.id, c1.id], tmpDir);
|
|
259
|
+
|
|
260
|
+
const result = await runJson<DoctorResult>(["doctor"], tmpDir);
|
|
261
|
+
const check = result.checks.find((ch) => ch.name === "referential-integrity");
|
|
262
|
+
expect(check?.status).toBe("pass");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("--fix removes dangling references", async () => {
|
|
266
|
+
await run(["init"], tmpDir);
|
|
267
|
+
const now = new Date().toISOString();
|
|
268
|
+
const issue = JSON.stringify({
|
|
269
|
+
id: "test-0001",
|
|
270
|
+
title: "Orphan ref",
|
|
271
|
+
status: "open",
|
|
272
|
+
type: "task",
|
|
273
|
+
priority: 2,
|
|
274
|
+
blockedBy: ["test-xxxx"],
|
|
275
|
+
createdAt: now,
|
|
276
|
+
updatedAt: now,
|
|
277
|
+
});
|
|
278
|
+
writeFileSync(join(seedsDir(tmpDir), "issues.jsonl"), `${issue}\n`);
|
|
279
|
+
|
|
280
|
+
const after = await runJson<DoctorResult>(["doctor", "--fix"], tmpDir);
|
|
281
|
+
const check = after.checks.find((ch) => ch.name === "referential-integrity");
|
|
282
|
+
expect(check?.status).toBe("pass");
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("doctor: bidirectional-consistency check", () => {
|
|
287
|
+
test("warns when back-reference is missing", async () => {
|
|
288
|
+
await run(["init"], tmpDir);
|
|
289
|
+
const now = new Date().toISOString();
|
|
290
|
+
const issueA = JSON.stringify({
|
|
291
|
+
id: "test-0001",
|
|
292
|
+
title: "A",
|
|
293
|
+
status: "open",
|
|
294
|
+
type: "task",
|
|
295
|
+
priority: 2,
|
|
296
|
+
blockedBy: ["test-0002"],
|
|
297
|
+
createdAt: now,
|
|
298
|
+
updatedAt: now,
|
|
299
|
+
});
|
|
300
|
+
const issueB = JSON.stringify({
|
|
301
|
+
id: "test-0002",
|
|
302
|
+
title: "B",
|
|
303
|
+
status: "open",
|
|
304
|
+
type: "task",
|
|
305
|
+
priority: 2,
|
|
306
|
+
createdAt: now,
|
|
307
|
+
updatedAt: now,
|
|
308
|
+
});
|
|
309
|
+
writeFileSync(join(seedsDir(tmpDir), "issues.jsonl"), `${issueA}\n${issueB}\n`);
|
|
310
|
+
|
|
311
|
+
const result = await runJson<DoctorResult>(["doctor"], tmpDir);
|
|
312
|
+
const check = result.checks.find((ch) => ch.name === "bidirectional-consistency");
|
|
313
|
+
expect(check?.status).toBe("warn");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("--fix adds missing back-references", async () => {
|
|
317
|
+
await run(["init"], tmpDir);
|
|
318
|
+
const now = new Date().toISOString();
|
|
319
|
+
const issueA = JSON.stringify({
|
|
320
|
+
id: "test-0001",
|
|
321
|
+
title: "A",
|
|
322
|
+
status: "open",
|
|
323
|
+
type: "task",
|
|
324
|
+
priority: 2,
|
|
325
|
+
blockedBy: ["test-0002"],
|
|
326
|
+
createdAt: now,
|
|
327
|
+
updatedAt: now,
|
|
328
|
+
});
|
|
329
|
+
const issueB = JSON.stringify({
|
|
330
|
+
id: "test-0002",
|
|
331
|
+
title: "B",
|
|
332
|
+
status: "open",
|
|
333
|
+
type: "task",
|
|
334
|
+
priority: 2,
|
|
335
|
+
createdAt: now,
|
|
336
|
+
updatedAt: now,
|
|
337
|
+
});
|
|
338
|
+
writeFileSync(join(seedsDir(tmpDir), "issues.jsonl"), `${issueA}\n${issueB}\n`);
|
|
339
|
+
|
|
340
|
+
const after = await runJson<DoctorResult>(["doctor", "--fix"], tmpDir);
|
|
341
|
+
const check = after.checks.find((ch) => ch.name === "bidirectional-consistency");
|
|
342
|
+
expect(check?.status).toBe("pass");
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
describe("doctor: circular-dependencies check", () => {
|
|
347
|
+
test("warns on circular dependency", async () => {
|
|
348
|
+
await run(["init"], tmpDir);
|
|
349
|
+
const now = new Date().toISOString();
|
|
350
|
+
const issueA = JSON.stringify({
|
|
351
|
+
id: "test-0001",
|
|
352
|
+
title: "A",
|
|
353
|
+
status: "open",
|
|
354
|
+
type: "task",
|
|
355
|
+
priority: 2,
|
|
356
|
+
blockedBy: ["test-0002"],
|
|
357
|
+
blocks: ["test-0002"],
|
|
358
|
+
createdAt: now,
|
|
359
|
+
updatedAt: now,
|
|
360
|
+
});
|
|
361
|
+
const issueB = JSON.stringify({
|
|
362
|
+
id: "test-0002",
|
|
363
|
+
title: "B",
|
|
364
|
+
status: "open",
|
|
365
|
+
type: "task",
|
|
366
|
+
priority: 2,
|
|
367
|
+
blockedBy: ["test-0001"],
|
|
368
|
+
blocks: ["test-0001"],
|
|
369
|
+
createdAt: now,
|
|
370
|
+
updatedAt: now,
|
|
371
|
+
});
|
|
372
|
+
writeFileSync(join(seedsDir(tmpDir), "issues.jsonl"), `${issueA}\n${issueB}\n`);
|
|
373
|
+
|
|
374
|
+
const result = await runJson<DoctorResult>(["doctor"], tmpDir);
|
|
375
|
+
const check = result.checks.find((ch) => ch.name === "circular-dependencies");
|
|
376
|
+
expect(check?.status).toBe("warn");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("passes with DAG (no cycles)", async () => {
|
|
380
|
+
await run(["init"], tmpDir);
|
|
381
|
+
const c1 = await runJson<{ id: string }>(["create", "--title", "A"], tmpDir);
|
|
382
|
+
const c2 = await runJson<{ id: string }>(["create", "--title", "B"], tmpDir);
|
|
383
|
+
await run(["dep", "add", c2.id, c1.id], tmpDir);
|
|
384
|
+
|
|
385
|
+
const result = await runJson<DoctorResult>(["doctor"], tmpDir);
|
|
386
|
+
const check = result.checks.find((ch) => ch.name === "circular-dependencies");
|
|
387
|
+
expect(check?.status).toBe("pass");
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
describe("doctor: stale-locks check", () => {
|
|
392
|
+
test("warns on stale lock file", async () => {
|
|
393
|
+
await run(["init"], tmpDir);
|
|
394
|
+
const lockPath = join(seedsDir(tmpDir), "issues.jsonl.lock");
|
|
395
|
+
writeFileSync(lockPath, "");
|
|
396
|
+
// Set mtime to 60s ago to make it stale
|
|
397
|
+
const past = new Date(Date.now() - 60_000);
|
|
398
|
+
const { utimesSync } = await import("node:fs");
|
|
399
|
+
utimesSync(lockPath, past, past);
|
|
400
|
+
|
|
401
|
+
const result = await runJson<DoctorResult>(["doctor"], tmpDir);
|
|
402
|
+
const check = result.checks.find((ch) => ch.name === "stale-locks");
|
|
403
|
+
expect(check?.status).toBe("warn");
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("passes when no lock files", async () => {
|
|
407
|
+
await run(["init"], tmpDir);
|
|
408
|
+
|
|
409
|
+
const result = await runJson<DoctorResult>(["doctor"], tmpDir);
|
|
410
|
+
const check = result.checks.find((ch) => ch.name === "stale-locks");
|
|
411
|
+
expect(check?.status).toBe("pass");
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("--fix removes stale lock files", async () => {
|
|
415
|
+
await run(["init"], tmpDir);
|
|
416
|
+
const lockPath = join(seedsDir(tmpDir), "issues.jsonl.lock");
|
|
417
|
+
writeFileSync(lockPath, "");
|
|
418
|
+
const past = new Date(Date.now() - 60_000);
|
|
419
|
+
const { utimesSync } = await import("node:fs");
|
|
420
|
+
utimesSync(lockPath, past, past);
|
|
421
|
+
|
|
422
|
+
await runJson<DoctorResult>(["doctor", "--fix"], tmpDir);
|
|
423
|
+
expect(existsSync(lockPath)).toBe(false);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
describe("doctor: gitattributes check", () => {
|
|
428
|
+
test("warns when .gitattributes is missing", async () => {
|
|
429
|
+
await run(["init"], tmpDir);
|
|
430
|
+
// Remove the .gitattributes created by init
|
|
431
|
+
const gitattrsPath = join(tmpDir, ".gitattributes");
|
|
432
|
+
if (existsSync(gitattrsPath)) {
|
|
433
|
+
const { unlinkSync } = await import("node:fs");
|
|
434
|
+
unlinkSync(gitattrsPath);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const result = await runJson<DoctorResult>(["doctor"], tmpDir);
|
|
438
|
+
const check = result.checks.find((ch) => ch.name === "gitattributes");
|
|
439
|
+
expect(check?.status).toBe("warn");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("passes when entries are present", async () => {
|
|
443
|
+
await run(["init"], tmpDir);
|
|
444
|
+
|
|
445
|
+
const result = await runJson<DoctorResult>(["doctor"], tmpDir);
|
|
446
|
+
const check = result.checks.find((ch) => ch.name === "gitattributes");
|
|
447
|
+
expect(check?.status).toBe("pass");
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("--fix adds missing entries", async () => {
|
|
451
|
+
await run(["init"], tmpDir);
|
|
452
|
+
const gitattrsPath = join(tmpDir, ".gitattributes");
|
|
453
|
+
writeFileSync(gitattrsPath, "# empty\n");
|
|
454
|
+
|
|
455
|
+
const after = await runJson<DoctorResult>(["doctor", "--fix"], tmpDir);
|
|
456
|
+
const check = after.checks.find((ch) => ch.name === "gitattributes");
|
|
457
|
+
expect(check?.status).toBe("pass");
|
|
458
|
+
|
|
459
|
+
const content = readFileSync(gitattrsPath, "utf8");
|
|
460
|
+
expect(content).toContain(".seeds/issues.jsonl merge=union");
|
|
461
|
+
expect(content).toContain(".seeds/templates.jsonl merge=union");
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
describe("doctor: json output", () => {
|
|
466
|
+
test("--json returns structured output", async () => {
|
|
467
|
+
await run(["init"], tmpDir);
|
|
468
|
+
const result = await runJson<DoctorResult>(["doctor"], tmpDir);
|
|
469
|
+
|
|
470
|
+
expect(result.success).toBe(true);
|
|
471
|
+
expect(result.command).toBe("doctor");
|
|
472
|
+
expect(Array.isArray(result.checks)).toBe(true);
|
|
473
|
+
expect(result.checks.length).toBeGreaterThan(0);
|
|
474
|
+
expect(result.summary).toBeDefined();
|
|
475
|
+
expect(typeof result.summary.pass).toBe("number");
|
|
476
|
+
expect(typeof result.summary.warn).toBe("number");
|
|
477
|
+
expect(typeof result.summary.fail).toBe("number");
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("all checks pass on clean project", async () => {
|
|
481
|
+
await run(["init"], tmpDir);
|
|
482
|
+
const result = await runJson<DoctorResult>(["doctor"], tmpDir);
|
|
483
|
+
|
|
484
|
+
expect(result.success).toBe(true);
|
|
485
|
+
expect(result.summary.fail).toBe(0);
|
|
486
|
+
expect(result.summary.warn).toBe(0);
|
|
487
|
+
expect(result.summary.pass).toBe(9);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
describe("doctor: exit code", () => {
|
|
492
|
+
test("exits 0 when all pass", async () => {
|
|
493
|
+
await run(["init"], tmpDir);
|
|
494
|
+
const { exitCode } = await run(["doctor"], tmpDir);
|
|
495
|
+
expect(exitCode).toBe(0);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test("exits 0 when only warnings", async () => {
|
|
499
|
+
await run(["init"], tmpDir);
|
|
500
|
+
const now = new Date().toISOString();
|
|
501
|
+
const issue = JSON.stringify({
|
|
502
|
+
id: "test-0001",
|
|
503
|
+
title: "Orphan",
|
|
504
|
+
status: "open",
|
|
505
|
+
type: "task",
|
|
506
|
+
priority: 2,
|
|
507
|
+
blockedBy: ["test-xxxx"],
|
|
508
|
+
createdAt: now,
|
|
509
|
+
updatedAt: now,
|
|
510
|
+
});
|
|
511
|
+
writeFileSync(join(seedsDir(tmpDir), "issues.jsonl"), `${issue}\n`);
|
|
512
|
+
|
|
513
|
+
const { exitCode } = await run(["doctor"], tmpDir);
|
|
514
|
+
expect(exitCode).toBe(0);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("exits 1 when failures present", async () => {
|
|
518
|
+
await run(["init"], tmpDir);
|
|
519
|
+
writeFileSync(join(seedsDir(tmpDir), "issues.jsonl"), "NOT JSON\n");
|
|
520
|
+
|
|
521
|
+
const { exitCode } = await run(["doctor"], tmpDir);
|
|
522
|
+
expect(exitCode).toBe(1);
|
|
523
|
+
});
|
|
524
|
+
});
|