gsd-pi 2.5.0 → 2.6.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.
Files changed (33) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.js +7 -1
  3. package/dist/loader.js +21 -3
  4. package/dist/logo.d.ts +3 -3
  5. package/dist/logo.js +2 -2
  6. package/package.json +1 -1
  7. package/src/resources/extensions/get-secrets-from-user.ts +331 -59
  8. package/src/resources/extensions/gsd/auto.ts +80 -18
  9. package/src/resources/extensions/gsd/docs/preferences-reference.md +1 -0
  10. package/src/resources/extensions/gsd/doctor.ts +23 -4
  11. package/src/resources/extensions/gsd/files.ts +115 -1
  12. package/src/resources/extensions/gsd/git-service.ts +67 -105
  13. package/src/resources/extensions/gsd/gitignore.ts +1 -0
  14. package/src/resources/extensions/gsd/guided-flow.ts +6 -3
  15. package/src/resources/extensions/gsd/preferences.ts +8 -0
  16. package/src/resources/extensions/gsd/prompts/complete-slice.md +7 -5
  17. package/src/resources/extensions/gsd/prompts/discuss.md +7 -15
  18. package/src/resources/extensions/gsd/prompts/execute-task.md +2 -6
  19. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -0
  20. package/src/resources/extensions/gsd/prompts/plan-milestone.md +33 -1
  21. package/src/resources/extensions/gsd/prompts/plan-slice.md +24 -32
  22. package/src/resources/extensions/gsd/session-forensics.ts +19 -6
  23. package/src/resources/extensions/gsd/templates/plan.md +8 -10
  24. package/src/resources/extensions/gsd/templates/secrets-manifest.md +22 -0
  25. package/src/resources/extensions/gsd/templates/task-plan.md +6 -6
  26. package/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts +196 -0
  27. package/src/resources/extensions/gsd/tests/collect-from-manifest.test.ts +469 -0
  28. package/src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts +170 -0
  29. package/src/resources/extensions/gsd/tests/git-service.test.ts +106 -0
  30. package/src/resources/extensions/gsd/tests/manifest-status.test.ts +283 -0
  31. package/src/resources/extensions/gsd/tests/parsers.test.ts +401 -65
  32. package/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +185 -0
  33. package/src/resources/extensions/gsd/types.ts +27 -0
@@ -0,0 +1,469 @@
1
+ /**
2
+ * Tests for S02 Enhanced Collection TUI functions:
3
+ * - collectSecretsFromManifest() orchestrator categorization and flow
4
+ * - showSecretsSummary() render output
5
+ * - collectOneSecret() guidance rendering
6
+ *
7
+ * These tests import functions that don't exist yet (T02/T03 will build them).
8
+ * They are expected to fail until implementation is complete.
9
+ *
10
+ * Uses dynamic imports so individual tests fail with clear messages
11
+ * instead of the entire file crashing at import time.
12
+ */
13
+
14
+ import test from "node:test";
15
+ import assert from "node:assert/strict";
16
+ import { mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { tmpdir } from "node:os";
19
+ import type { SecretsManifest, SecretsManifestEntry } from "../types.ts";
20
+
21
+ // Dynamic imports for files.ts functions to avoid cascading failure
22
+ // when paths.js isn't available (files.ts statically imports paths.js)
23
+ async function loadFilesExports(): Promise<{
24
+ formatSecretsManifest: (m: SecretsManifest) => string;
25
+ parseSecretsManifest: (content: string) => SecretsManifest;
26
+ }> {
27
+ const mod = await import("../files.ts");
28
+ return {
29
+ formatSecretsManifest: mod.formatSecretsManifest,
30
+ parseSecretsManifest: mod.parseSecretsManifest,
31
+ };
32
+ }
33
+
34
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
35
+
36
+ function makeTempDir(prefix: string): string {
37
+ const dir = join(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
38
+ mkdirSync(dir, { recursive: true });
39
+ return dir;
40
+ }
41
+
42
+ function makeManifest(entries: Partial<SecretsManifestEntry>[]): SecretsManifest {
43
+ return {
44
+ milestone: "M001",
45
+ generatedAt: "2026-03-12T00:00:00Z",
46
+ entries: entries.map((e) => ({
47
+ key: e.key ?? "TEST_KEY",
48
+ service: e.service ?? "TestService",
49
+ dashboardUrl: e.dashboardUrl ?? "",
50
+ guidance: e.guidance ?? [],
51
+ formatHint: e.formatHint ?? "",
52
+ status: e.status ?? "pending",
53
+ destination: e.destination ?? "dotenv",
54
+ })),
55
+ };
56
+ }
57
+
58
+ async function writeManifestFile(dir: string, manifest: SecretsManifest): Promise<string> {
59
+ const { formatSecretsManifest } = await loadFilesExports();
60
+ const milestoneDir = join(dir, ".gsd", "milestones", "M001");
61
+ mkdirSync(milestoneDir, { recursive: true });
62
+ const filePath = join(milestoneDir, "M001-SECRETS.md");
63
+ writeFileSync(filePath, formatSecretsManifest(manifest));
64
+ return filePath;
65
+ }
66
+
67
+ async function loadOrchestrator(): Promise<{
68
+ collectSecretsFromManifest: Function;
69
+ showSecretsSummary: Function;
70
+ }> {
71
+ const mod = await import("../../get-secrets-from-user.ts");
72
+ if (typeof mod.collectSecretsFromManifest !== "function") {
73
+ throw new Error("collectSecretsFromManifest is not exported from get-secrets-from-user.ts — T03 will implement this");
74
+ }
75
+ if (typeof mod.showSecretsSummary !== "function") {
76
+ throw new Error("showSecretsSummary is not exported from get-secrets-from-user.ts — T03 will implement this");
77
+ }
78
+ return {
79
+ collectSecretsFromManifest: mod.collectSecretsFromManifest,
80
+ showSecretsSummary: mod.showSecretsSummary,
81
+ };
82
+ }
83
+
84
+ async function loadGuidanceExport(): Promise<{ collectOneSecretWithGuidance: Function }> {
85
+ const mod = await import("../../get-secrets-from-user.ts");
86
+ if (typeof mod.collectOneSecretWithGuidance !== "function") {
87
+ throw new Error("collectOneSecretWithGuidance is not exported from get-secrets-from-user.ts — T02 will implement this");
88
+ }
89
+ return { collectOneSecretWithGuidance: mod.collectOneSecretWithGuidance };
90
+ }
91
+
92
+ // ─── collectSecretsFromManifest: categorization ───────────────────────────────
93
+
94
+ test("collectSecretsFromManifest: categorizes entries — pending keys need collection, existing keys are skipped", async () => {
95
+ const { collectSecretsFromManifest } = await loadOrchestrator();
96
+
97
+ const tmp = makeTempDir("manifest-collect");
98
+ const savedA = process.env.EXISTING_KEY_A;
99
+ try {
100
+ process.env.EXISTING_KEY_A = "already-set";
101
+
102
+ const manifest = makeManifest([
103
+ { key: "EXISTING_KEY_A", status: "pending" },
104
+ { key: "PENDING_KEY_B", status: "pending", guidance: ["Step 1: Go to dashboard", "Step 2: Click create key"] },
105
+ { key: "SKIPPED_KEY_C", status: "skipped" },
106
+ ]);
107
+ await writeManifestFile(tmp, manifest);
108
+
109
+ let callIndex = 0;
110
+ const mockCtx = {
111
+ cwd: tmp,
112
+ hasUI: true,
113
+ ui: {
114
+ custom: async (_factory: any) => {
115
+ callIndex++;
116
+ if (callIndex <= 1) return null; // summary screen dismiss
117
+ return "mock-secret-value"; // collect pending key
118
+ },
119
+ },
120
+ };
121
+
122
+ const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
123
+
124
+ // EXISTING_KEY_A should be in existingSkipped (it's in process.env)
125
+ assert.ok(result.existingSkipped?.includes("EXISTING_KEY_A"),
126
+ "EXISTING_KEY_A should be in existingSkipped");
127
+
128
+ // PENDING_KEY_B should have been collected (applied)
129
+ assert.ok(result.applied.includes("PENDING_KEY_B"),
130
+ "PENDING_KEY_B should be in applied");
131
+
132
+ // SKIPPED_KEY_C should remain skipped
133
+ assert.ok(result.skipped.includes("SKIPPED_KEY_C"),
134
+ "SKIPPED_KEY_C should be in skipped");
135
+ } finally {
136
+ delete process.env.EXISTING_KEY_A;
137
+ if (savedA !== undefined) process.env.EXISTING_KEY_A = savedA;
138
+ rmSync(tmp, { recursive: true, force: true });
139
+ }
140
+ });
141
+
142
+ test("collectSecretsFromManifest: existing keys are excluded from the collection list — not prompted", async () => {
143
+ const { collectSecretsFromManifest } = await loadOrchestrator();
144
+
145
+ const tmp = makeTempDir("manifest-collect-skip");
146
+ const savedA = process.env.ALREADY_SET_KEY;
147
+ try {
148
+ process.env.ALREADY_SET_KEY = "present";
149
+
150
+ const manifest = makeManifest([
151
+ { key: "ALREADY_SET_KEY", status: "pending" },
152
+ { key: "NEEDS_COLLECTION", status: "pending" },
153
+ ]);
154
+ await writeManifestFile(tmp, manifest);
155
+
156
+ const collectedKeyNames: string[] = [];
157
+ let summaryShown = false;
158
+ const mockCtx = {
159
+ cwd: tmp,
160
+ hasUI: true,
161
+ ui: {
162
+ custom: async (factory: any) => {
163
+ // Intercept the factory to check what key is being collected
164
+ if (!summaryShown) {
165
+ summaryShown = true;
166
+ return null; // dismiss summary
167
+ }
168
+ collectedKeyNames.push("prompted");
169
+ return "mock-value";
170
+ },
171
+ },
172
+ };
173
+
174
+ const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
175
+
176
+ // ALREADY_SET_KEY should not have been prompted — only NEEDS_COLLECTION should
177
+ assert.ok(!result.applied.includes("ALREADY_SET_KEY"),
178
+ "ALREADY_SET_KEY should not be in applied (it was auto-skipped)");
179
+ assert.ok(result.existingSkipped?.includes("ALREADY_SET_KEY"),
180
+ "ALREADY_SET_KEY should be in existingSkipped");
181
+ } finally {
182
+ delete process.env.ALREADY_SET_KEY;
183
+ if (savedA !== undefined) process.env.ALREADY_SET_KEY = savedA;
184
+ rmSync(tmp, { recursive: true, force: true });
185
+ }
186
+ });
187
+
188
+ test("collectSecretsFromManifest: manifest statuses are updated after collection", async () => {
189
+ const { collectSecretsFromManifest } = await loadOrchestrator();
190
+
191
+ const tmp = makeTempDir("manifest-update");
192
+ try {
193
+ const manifest = makeManifest([
194
+ { key: "KEY_TO_COLLECT", status: "pending" },
195
+ { key: "KEY_TO_SKIP", status: "pending" },
196
+ ]);
197
+ const manifestPath = await writeManifestFile(tmp, manifest);
198
+
199
+ let callIndex = 0;
200
+ const mockCtx = {
201
+ cwd: tmp,
202
+ hasUI: true,
203
+ ui: {
204
+ custom: async (_factory: any) => {
205
+ callIndex++;
206
+ if (callIndex <= 1) return null; // summary screen dismiss
207
+ if (callIndex === 2) return "secret-value"; // KEY_TO_COLLECT
208
+ return null; // KEY_TO_SKIP — user skips
209
+ },
210
+ },
211
+ };
212
+
213
+ await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
214
+
215
+ // Read back the manifest file and verify statuses were updated
216
+ const { parseSecretsManifest } = await loadFilesExports();
217
+ const updatedContent = readFileSync(manifestPath, "utf8");
218
+ const updatedManifest = parseSecretsManifest(updatedContent);
219
+
220
+ const keyToCollect = updatedManifest.entries.find(e => e.key === "KEY_TO_COLLECT");
221
+ const keyToSkip = updatedManifest.entries.find(e => e.key === "KEY_TO_SKIP");
222
+
223
+ assert.equal(keyToCollect?.status, "collected",
224
+ "KEY_TO_COLLECT should have status 'collected' after providing a value");
225
+ assert.equal(keyToSkip?.status, "skipped",
226
+ "KEY_TO_SKIP should have status 'skipped' after user skipped it");
227
+ } finally {
228
+ rmSync(tmp, { recursive: true, force: true });
229
+ }
230
+ });
231
+
232
+ // ─── showSecretsSummary: render output ────────────────────────────────────────
233
+
234
+ test("showSecretsSummary: produces lines with correct status glyphs for each entry status", async () => {
235
+ const { showSecretsSummary } = await loadOrchestrator();
236
+
237
+ const entries: SecretsManifestEntry[] = [
238
+ { key: "PENDING_KEY", service: "Svc", dashboardUrl: "", guidance: [], formatHint: "", status: "pending", destination: "dotenv" },
239
+ { key: "COLLECTED_KEY", service: "Svc", dashboardUrl: "", guidance: [], formatHint: "", status: "collected", destination: "dotenv" },
240
+ { key: "SKIPPED_KEY", service: "Svc", dashboardUrl: "", guidance: [], formatHint: "", status: "skipped", destination: "dotenv" },
241
+ ];
242
+
243
+ // showSecretsSummary renders a ctx.ui.custom screen. We capture the render output.
244
+ let renderFn: ((width: number) => string[]) | undefined;
245
+ const mockCtx = {
246
+ hasUI: true,
247
+ ui: {
248
+ custom: async (factory: any) => {
249
+ const mockTheme = {
250
+ fg: (_color: string, text: string) => text,
251
+ bold: (text: string) => text,
252
+ };
253
+ const mockTui = { requestRender: () => {}, terminal: { rows: 24, columns: 80 } };
254
+ const component = factory(mockTui, mockTheme, {}, () => {});
255
+ renderFn = component.render;
256
+ // Simulate immediate dismiss
257
+ component.handleInput("\x1b"); // escape
258
+ },
259
+ },
260
+ };
261
+
262
+ await showSecretsSummary(mockCtx as any, entries, []);
263
+
264
+ assert.ok(renderFn, "render function should have been captured from factory");
265
+ const lines = renderFn!(80);
266
+
267
+ // Verify each key appears in the output
268
+ const output = lines.join("\n");
269
+ assert.ok(output.includes("PENDING_KEY"), "should include PENDING_KEY");
270
+ assert.ok(output.includes("COLLECTED_KEY"), "should include COLLECTED_KEY");
271
+ assert.ok(output.includes("SKIPPED_KEY"), "should include SKIPPED_KEY");
272
+
273
+ // Verify we have at least one line per entry plus header/footer
274
+ assert.ok(lines.length >= 5, `should have at least 5 lines (got ${lines.length})`);
275
+ });
276
+
277
+ test("showSecretsSummary: existing keys shown with distinct status indicator", async () => {
278
+ const { showSecretsSummary } = await loadOrchestrator();
279
+
280
+ const entries: SecretsManifestEntry[] = [
281
+ { key: "NEW_KEY", service: "Svc", dashboardUrl: "", guidance: [], formatHint: "", status: "pending", destination: "dotenv" },
282
+ { key: "OLD_KEY", service: "Svc", dashboardUrl: "", guidance: [], formatHint: "", status: "collected", destination: "dotenv" },
283
+ ];
284
+ const existingKeys = ["OLD_KEY"];
285
+
286
+ let renderFn: ((width: number) => string[]) | undefined;
287
+ const mockCtx = {
288
+ hasUI: true,
289
+ ui: {
290
+ custom: async (factory: any) => {
291
+ const mockTheme = {
292
+ fg: (_color: string, text: string) => text,
293
+ bold: (text: string) => text,
294
+ };
295
+ const mockTui = { requestRender: () => {}, terminal: { rows: 24, columns: 80 } };
296
+ const component = factory(mockTui, mockTheme, {}, () => {});
297
+ renderFn = component.render;
298
+ component.handleInput("\x1b");
299
+ },
300
+ },
301
+ };
302
+
303
+ await showSecretsSummary(mockCtx as any, entries, existingKeys);
304
+
305
+ assert.ok(renderFn, "render function should have been captured");
306
+ const lines = renderFn!(80);
307
+ const output = lines.join("\n");
308
+
309
+ assert.ok(output.includes("NEW_KEY"), "should include NEW_KEY");
310
+ assert.ok(output.includes("OLD_KEY"), "should include OLD_KEY");
311
+ });
312
+
313
+ // ─── collectOneSecret: guidance rendering ─────────────────────────────────────
314
+
315
+ test("collectOneSecret: guidance lines appear in render output when guidance is provided", async () => {
316
+ const { collectOneSecretWithGuidance } = await loadGuidanceExport();
317
+
318
+ const guidanceSteps = [
319
+ "Navigate to https://platform.openai.com/api-keys",
320
+ "Click 'Create new secret key'",
321
+ "Copy the key value",
322
+ ];
323
+
324
+ // Use the exported test helper to capture render output with guidance
325
+ let renderFn: ((width: number) => string[]) | undefined;
326
+ const mockCtx = {
327
+ hasUI: true,
328
+ ui: {
329
+ custom: async (factory: any) => {
330
+ const mockTheme = {
331
+ fg: (_color: string, text: string) => text,
332
+ bold: (text: string) => text,
333
+ };
334
+ const mockTui = { requestRender: () => {}, terminal: { rows: 24, columns: 80 } };
335
+ const component = factory(mockTui, mockTheme, {}, () => {});
336
+ renderFn = component.render;
337
+ component.handleInput("\x1b"); // escape to dismiss
338
+ },
339
+ },
340
+ };
341
+
342
+ await collectOneSecretWithGuidance(mockCtx, 0, 1, "OPENAI_API_KEY", "starts with sk-", guidanceSteps);
343
+
344
+ assert.ok(renderFn, "render function should have been captured");
345
+ const lines = renderFn!(80);
346
+ const output = lines.join("\n");
347
+
348
+ // Verify guidance steps appear in the output
349
+ assert.ok(output.includes("Navigate to"), "should include first guidance step");
350
+ assert.ok(output.includes("Create new secret key"), "should include second guidance step");
351
+ assert.ok(output.includes("Copy the key value"), "should include third guidance step");
352
+ });
353
+
354
+ test("collectOneSecret: guidance lines wrap long URLs instead of truncating", async () => {
355
+ const { collectOneSecretWithGuidance } = await loadGuidanceExport();
356
+
357
+ const longGuidance = [
358
+ "Navigate to https://platform.openai.com/account/api-keys and click 'Create new secret key'",
359
+ ];
360
+
361
+ let renderFn: ((width: number) => string[]) | undefined;
362
+ const mockCtx = {
363
+ hasUI: true,
364
+ ui: {
365
+ custom: async (factory: any) => {
366
+ const mockTheme = {
367
+ fg: (_color: string, text: string) => text,
368
+ bold: (text: string) => text,
369
+ };
370
+ const mockTui = { requestRender: () => {}, terminal: { rows: 24, columns: 80 } };
371
+ const component = factory(mockTui, mockTheme, {}, () => {});
372
+ renderFn = component.render;
373
+ component.handleInput("\x1b");
374
+ },
375
+ },
376
+ };
377
+
378
+ await collectOneSecretWithGuidance(mockCtx, 0, 1, "TEST_KEY", undefined, longGuidance);
379
+
380
+ assert.ok(renderFn, "render function should have been captured");
381
+ // Render at narrow width to force wrapping
382
+ const lines = renderFn!(50);
383
+ const output = lines.join("\n");
384
+
385
+ // The full URL should be present (wrapped, not truncated)
386
+ assert.ok(output.includes("platform.openai.com"), "URL should not be truncated");
387
+ assert.ok(output.includes("Create new secret key"), "text after URL should not be truncated");
388
+ });
389
+
390
+ test("collectOneSecret: no guidance provided — render output has no guidance section", async () => {
391
+ const { collectOneSecretWithGuidance } = await loadGuidanceExport();
392
+
393
+ let renderFn: ((width: number) => string[]) | undefined;
394
+ const mockCtx = {
395
+ hasUI: true,
396
+ ui: {
397
+ custom: async (factory: any) => {
398
+ const mockTheme = {
399
+ fg: (_color: string, text: string) => text,
400
+ bold: (text: string) => text,
401
+ };
402
+ const mockTui = { requestRender: () => {}, terminal: { rows: 24, columns: 80 } };
403
+ const component = factory(mockTui, mockTheme, {}, () => {});
404
+ renderFn = component.render;
405
+ component.handleInput("\x1b");
406
+ },
407
+ },
408
+ };
409
+
410
+ // Call without guidance (undefined)
411
+ await collectOneSecretWithGuidance(mockCtx, 0, 1, "SOME_KEY", "hint text", undefined);
412
+
413
+ assert.ok(renderFn, "render function should have been captured");
414
+ const lines = renderFn!(80);
415
+ const output = lines.join("\n");
416
+
417
+ // Should include the key name and hint but no numbered guidance steps
418
+ assert.ok(output.includes("SOME_KEY"), "should include key name");
419
+ assert.ok(output.includes("hint text"), "should include hint");
420
+ // Should NOT have numbered step indicators (1., 2., etc.) for guidance
421
+ assert.ok(!output.match(/^\s*1\.\s/m), "should not have numbered guidance steps when no guidance provided");
422
+ });
423
+
424
+ // ─── collectSecretsFromManifest: returns structured result ────────────────────
425
+
426
+ test("collectSecretsFromManifest: returns result with applied, skipped, and existingSkipped arrays", async () => {
427
+ const { collectSecretsFromManifest } = await loadOrchestrator();
428
+
429
+ const tmp = makeTempDir("manifest-result");
430
+ const savedKey = process.env.RESULT_TEST_EXISTING;
431
+ try {
432
+ process.env.RESULT_TEST_EXISTING = "already-here";
433
+
434
+ const manifest = makeManifest([
435
+ { key: "RESULT_TEST_EXISTING", status: "pending" },
436
+ { key: "RESULT_TEST_NEW", status: "pending" },
437
+ ]);
438
+ await writeManifestFile(tmp, manifest);
439
+
440
+ let callIndex = 0;
441
+ const mockCtx = {
442
+ cwd: tmp,
443
+ hasUI: true,
444
+ ui: {
445
+ custom: async (_factory: any) => {
446
+ callIndex++;
447
+ if (callIndex <= 1) return null; // summary dismiss
448
+ return "secret-value"; // collect the pending key
449
+ },
450
+ },
451
+ };
452
+
453
+ const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any);
454
+
455
+ // Verify result shape
456
+ assert.ok(Array.isArray(result.applied), "result should have applied array");
457
+ assert.ok(Array.isArray(result.skipped), "result should have skipped array");
458
+ assert.ok(Array.isArray(result.existingSkipped), "result should have existingSkipped array");
459
+
460
+ assert.ok(result.existingSkipped.includes("RESULT_TEST_EXISTING"),
461
+ "existing key should be in existingSkipped");
462
+ assert.ok(result.applied.includes("RESULT_TEST_NEW"),
463
+ "collected key should be in applied");
464
+ } finally {
465
+ delete process.env.RESULT_TEST_EXISTING;
466
+ if (savedKey !== undefined) process.env.RESULT_TEST_EXISTING = savedKey;
467
+ rmSync(tmp, { recursive: true, force: true });
468
+ }
469
+ });
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Tests that doctor's fixLevel option correctly separates task-level
3
+ * bookkeeping from completion state transitions.
4
+ *
5
+ * fixLevel:"task" — fixes task checkboxes, does NOT create slice summary
6
+ * stubs, UAT stubs, or mark slices done in the roadmap.
7
+ * fixLevel:"all" (default) — fixes everything including completion transitions.
8
+ */
9
+
10
+ import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { tmpdir } from "node:os";
13
+ import test from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { runGSDDoctor } from "../doctor.ts";
16
+
17
+ function makeTmp(name: string): string {
18
+ const dir = join(tmpdir(), `doctor-fixlevel-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
19
+ mkdirSync(dir, { recursive: true });
20
+ return dir;
21
+ }
22
+
23
+ /**
24
+ * Build a minimal .gsd structure: milestone with one slice, one task
25
+ * marked done with a summary — but no slice summary and roadmap unchecked.
26
+ * This is exactly the state after the last task completes.
27
+ */
28
+ function buildScaffold(base: string) {
29
+ const gsd = join(base, ".gsd");
30
+ const m = join(gsd, "milestones", "M001");
31
+ const s = join(m, "slices", "S01", "tasks");
32
+ mkdirSync(s, { recursive: true });
33
+
34
+ writeFileSync(join(m, "M001-ROADMAP.md"), `# M001: Test
35
+
36
+ ## Slices
37
+
38
+ - [ ] **S01: Test Slice** \`risk:low\` \`depends:[]\`
39
+ > Demo text
40
+ `);
41
+
42
+ writeFileSync(join(m, "slices", "S01", "S01-PLAN.md"), `# S01: Test Slice
43
+
44
+ **Goal:** test
45
+
46
+ ## Tasks
47
+
48
+ - [x] **T01: Do stuff** \`est:5m\`
49
+ `);
50
+
51
+ writeFileSync(join(s, "T01-SUMMARY.md"), `---
52
+ id: T01
53
+ parent: S01
54
+ milestone: M001
55
+ duration: 5m
56
+ verification_result: passed
57
+ completed_at: 2026-01-01
58
+ ---
59
+
60
+ # T01: Do stuff
61
+
62
+ Done.
63
+ `);
64
+ }
65
+
66
+ test("fixLevel:task — detects completion issues but does NOT create summary stub or mark roadmap", async () => {
67
+ const tmp = makeTmp("task-level");
68
+ try {
69
+ buildScaffold(tmp);
70
+
71
+ const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
72
+
73
+ // Should detect the issues
74
+ const codes = report.issues.map(i => i.code);
75
+ assert.ok(codes.includes("all_tasks_done_missing_slice_summary"), "should detect missing summary");
76
+ assert.ok(codes.includes("all_tasks_done_roadmap_not_checked"), "should detect unchecked roadmap");
77
+
78
+ // Should NOT have fixed them
79
+ const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
80
+ assert.ok(!existsSync(sliceSummaryPath), "should NOT have created summary stub");
81
+
82
+ const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8");
83
+ assert.ok(roadmapContent.includes("- [ ] **S01"), "roadmap should still show S01 as unchecked");
84
+
85
+ // Fixes applied should NOT include completion artifacts
86
+ for (const f of report.fixesApplied) {
87
+ assert.ok(!f.includes("SUMMARY"), `should not have fixed summary: ${f}`);
88
+ assert.ok(!f.includes("roadmap"), `should not have fixed roadmap: ${f}`);
89
+ }
90
+ } finally {
91
+ rmSync(tmp, { recursive: true, force: true });
92
+ }
93
+ });
94
+
95
+ test("fixLevel:all (default) — detects AND fixes completion issues", async () => {
96
+ const tmp = makeTmp("all-level");
97
+ try {
98
+ buildScaffold(tmp);
99
+
100
+ const report = await runGSDDoctor(tmp, { fix: true });
101
+
102
+ // Should detect the issues
103
+ const codes = report.issues.map(i => i.code);
104
+ assert.ok(codes.includes("all_tasks_done_missing_slice_summary"), "should detect missing summary");
105
+ assert.ok(codes.includes("all_tasks_done_roadmap_not_checked"), "should detect unchecked roadmap");
106
+
107
+ // SHOULD have fixed them
108
+ const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
109
+ assert.ok(existsSync(sliceSummaryPath), "should have created summary stub");
110
+
111
+ const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8");
112
+ assert.ok(roadmapContent.includes("- [x] **S01"), "roadmap should show S01 as checked");
113
+ } finally {
114
+ rmSync(tmp, { recursive: true, force: true });
115
+ }
116
+ });
117
+
118
+ test("fixLevel:task — still fixes task-level bookkeeping (checkbox marking)", async () => {
119
+ const tmp = makeTmp("task-checkbox");
120
+ try {
121
+ const gsd = join(tmp, ".gsd");
122
+ const m = join(gsd, "milestones", "M001");
123
+ const s = join(m, "slices", "S01", "tasks");
124
+ mkdirSync(s, { recursive: true });
125
+
126
+ writeFileSync(join(m, "M001-ROADMAP.md"), `# M001: Test
127
+
128
+ ## Slices
129
+
130
+ - [ ] **S01: Test Slice** \`risk:low\` \`depends:[]\`
131
+ > Demo text
132
+ `);
133
+
134
+ // Task NOT checked in plan but has a summary — doctor should mark it done
135
+ writeFileSync(join(m, "slices", "S01", "S01-PLAN.md"), `# S01: Test Slice
136
+
137
+ **Goal:** test
138
+
139
+ ## Tasks
140
+
141
+ - [ ] **T01: Do stuff** \`est:5m\`
142
+ `);
143
+
144
+ writeFileSync(join(s, "T01-SUMMARY.md"), `---
145
+ id: T01
146
+ parent: S01
147
+ milestone: M001
148
+ duration: 5m
149
+ verification_result: passed
150
+ completed_at: 2026-01-01
151
+ ---
152
+
153
+ # T01: Do stuff
154
+
155
+ Done.
156
+ `);
157
+
158
+ const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" });
159
+
160
+ // Should have fixed the task checkbox
161
+ const planContent = readFileSync(join(m, "slices", "S01", "S01-PLAN.md"), "utf8");
162
+ assert.ok(planContent.includes("- [x] **T01"), "should have marked T01 done in plan");
163
+
164
+ // Should NOT have touched slice-level completion
165
+ const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
166
+ assert.ok(!existsSync(sliceSummaryPath), "should NOT have created summary stub");
167
+ } finally {
168
+ rmSync(tmp, { recursive: true, force: true });
169
+ }
170
+ });