gsd-pi 2.5.1 → 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.
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Tests for getManifestStatus() — the S01→S02 boundary contract.
3
+ *
4
+ * Verifies that manifest entries are correctly categorized into
5
+ * pending, collected, skipped, and existing arrays based on
6
+ * manifest status and environment presence.
7
+ *
8
+ * Uses temp directories with real .gsd/milestones/M001/ structure.
9
+ */
10
+
11
+ import test from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { tmpdir } from 'node:os';
16
+ import { getManifestStatus } from '../files.ts';
17
+
18
+ function makeTempDir(prefix: string): string {
19
+ const dir = join(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
20
+ mkdirSync(dir, { recursive: true });
21
+ return dir;
22
+ }
23
+
24
+ /** Create the .gsd/milestones/M001/ directory structure and write a secrets manifest. */
25
+ function writeManifest(base: string, content: string): void {
26
+ const mDir = join(base, '.gsd', 'milestones', 'M001');
27
+ mkdirSync(mDir, { recursive: true });
28
+ writeFileSync(join(mDir, 'M001-SECRETS.md'), content);
29
+ }
30
+
31
+ // ─── Mixed statuses ──────────────────────────────────────────────────────────
32
+
33
+ test('getManifestStatus: mixed statuses — categorizes entries correctly', async () => {
34
+ const tmp = makeTempDir('manifest-mixed');
35
+ const savedVal = process.env.GSD_TEST_EXISTING_KEY_001;
36
+ try {
37
+ process.env.GSD_TEST_EXISTING_KEY_001 = 'some-value';
38
+
39
+ writeManifest(tmp, `# Secrets Manifest
40
+
41
+ **Milestone:** M001
42
+ **Generated:** 2025-06-20T10:00:00Z
43
+
44
+ ### PENDING_KEY
45
+
46
+ **Service:** SomeService
47
+ **Status:** pending
48
+ **Destination:** dotenv
49
+
50
+ 1. Get the key
51
+
52
+ ### COLLECTED_KEY
53
+
54
+ **Service:** AnotherService
55
+ **Status:** collected
56
+ **Destination:** dotenv
57
+
58
+ 1. Already collected
59
+
60
+ ### SKIPPED_KEY
61
+
62
+ **Service:** OptionalService
63
+ **Status:** skipped
64
+ **Destination:** dotenv
65
+
66
+ 1. Not needed
67
+
68
+ ### GSD_TEST_EXISTING_KEY_001
69
+
70
+ **Service:** EnvService
71
+ **Status:** pending
72
+ **Destination:** dotenv
73
+
74
+ 1. Already in env
75
+ `);
76
+
77
+ const result = await getManifestStatus(tmp, 'M001');
78
+ assert.notStrictEqual(result, null, 'should not be null');
79
+ assert.deepStrictEqual(result!.pending, ['PENDING_KEY']);
80
+ assert.deepStrictEqual(result!.collected, ['COLLECTED_KEY']);
81
+ assert.deepStrictEqual(result!.skipped, ['SKIPPED_KEY']);
82
+ assert.deepStrictEqual(result!.existing, ['GSD_TEST_EXISTING_KEY_001']);
83
+ } finally {
84
+ delete process.env.GSD_TEST_EXISTING_KEY_001;
85
+ if (savedVal !== undefined) process.env.GSD_TEST_EXISTING_KEY_001 = savedVal;
86
+ rmSync(tmp, { recursive: true, force: true });
87
+ }
88
+ });
89
+
90
+ // ─── All pending ─────────────────────────────────────────────────────────────
91
+
92
+ test('getManifestStatus: all pending — 3 pending entries, none in env', async () => {
93
+ const tmp = makeTempDir('manifest-pending');
94
+ try {
95
+ // Ensure none of these are in process.env
96
+ delete process.env.PEND_A;
97
+ delete process.env.PEND_B;
98
+ delete process.env.PEND_C;
99
+
100
+ writeManifest(tmp, `# Secrets Manifest
101
+
102
+ **Milestone:** M001
103
+ **Generated:** 2025-06-20T10:00:00Z
104
+
105
+ ### PEND_A
106
+
107
+ **Service:** A
108
+ **Status:** pending
109
+ **Destination:** dotenv
110
+
111
+ 1. Step one
112
+
113
+ ### PEND_B
114
+
115
+ **Service:** B
116
+ **Status:** pending
117
+ **Destination:** dotenv
118
+
119
+ 1. Step one
120
+
121
+ ### PEND_C
122
+
123
+ **Service:** C
124
+ **Status:** pending
125
+ **Destination:** dotenv
126
+
127
+ 1. Step one
128
+ `);
129
+
130
+ const result = await getManifestStatus(tmp, 'M001');
131
+ assert.notStrictEqual(result, null);
132
+ assert.deepStrictEqual(result!.pending, ['PEND_A', 'PEND_B', 'PEND_C']);
133
+ assert.deepStrictEqual(result!.collected, []);
134
+ assert.deepStrictEqual(result!.skipped, []);
135
+ assert.deepStrictEqual(result!.existing, []);
136
+ } finally {
137
+ rmSync(tmp, { recursive: true, force: true });
138
+ }
139
+ });
140
+
141
+ // ─── All collected ───────────────────────────────────────────────────────────
142
+
143
+ test('getManifestStatus: all collected — 2 collected entries, none in env', async () => {
144
+ const tmp = makeTempDir('manifest-collected');
145
+ try {
146
+ delete process.env.COLL_X;
147
+ delete process.env.COLL_Y;
148
+
149
+ writeManifest(tmp, `# Secrets Manifest
150
+
151
+ **Milestone:** M001
152
+ **Generated:** 2025-06-20T10:00:00Z
153
+
154
+ ### COLL_X
155
+
156
+ **Service:** X
157
+ **Status:** collected
158
+ **Destination:** dotenv
159
+
160
+ 1. Done
161
+
162
+ ### COLL_Y
163
+
164
+ **Service:** Y
165
+ **Status:** collected
166
+ **Destination:** dotenv
167
+
168
+ 1. Done
169
+ `);
170
+
171
+ const result = await getManifestStatus(tmp, 'M001');
172
+ assert.notStrictEqual(result, null);
173
+ assert.deepStrictEqual(result!.pending, []);
174
+ assert.deepStrictEqual(result!.collected, ['COLL_X', 'COLL_Y']);
175
+ assert.deepStrictEqual(result!.skipped, []);
176
+ assert.deepStrictEqual(result!.existing, []);
177
+ } finally {
178
+ rmSync(tmp, { recursive: true, force: true });
179
+ }
180
+ });
181
+
182
+ // ─── Key in env overrides manifest status ────────────────────────────────────
183
+
184
+ test('getManifestStatus: key in env overrides manifest status — collected key in env goes to existing', async () => {
185
+ const tmp = makeTempDir('manifest-override');
186
+ const savedVal = process.env.GSD_TEST_OVERRIDE_KEY;
187
+ try {
188
+ process.env.GSD_TEST_OVERRIDE_KEY = 'already-here';
189
+
190
+ writeManifest(tmp, `# Secrets Manifest
191
+
192
+ **Milestone:** M001
193
+ **Generated:** 2025-06-20T10:00:00Z
194
+
195
+ ### GSD_TEST_OVERRIDE_KEY
196
+
197
+ **Service:** Override
198
+ **Status:** collected
199
+ **Destination:** dotenv
200
+
201
+ 1. Was collected but now in env
202
+ `);
203
+
204
+ const result = await getManifestStatus(tmp, 'M001');
205
+ assert.notStrictEqual(result, null);
206
+ assert.deepStrictEqual(result!.pending, []);
207
+ assert.deepStrictEqual(result!.collected, []);
208
+ assert.deepStrictEqual(result!.skipped, []);
209
+ assert.deepStrictEqual(result!.existing, ['GSD_TEST_OVERRIDE_KEY']);
210
+ } finally {
211
+ delete process.env.GSD_TEST_OVERRIDE_KEY;
212
+ if (savedVal !== undefined) process.env.GSD_TEST_OVERRIDE_KEY = savedVal;
213
+ rmSync(tmp, { recursive: true, force: true });
214
+ }
215
+ });
216
+
217
+ // ─── Missing manifest ────────────────────────────────────────────────────────
218
+
219
+ test('getManifestStatus: missing manifest — returns null', async () => {
220
+ const tmp = makeTempDir('manifest-missing');
221
+ try {
222
+ // No .gsd directory at all
223
+ const result = await getManifestStatus(tmp, 'M001');
224
+ assert.strictEqual(result, null);
225
+ } finally {
226
+ rmSync(tmp, { recursive: true, force: true });
227
+ }
228
+ });
229
+
230
+ // ─── Empty manifest (no entries) ─────────────────────────────────────────────
231
+
232
+ test('getManifestStatus: empty manifest — exists but no H3 sections', async () => {
233
+ const tmp = makeTempDir('manifest-empty');
234
+ try {
235
+ writeManifest(tmp, `# Secrets Manifest
236
+
237
+ **Milestone:** M001
238
+ **Generated:** 2025-06-20T10:00:00Z
239
+ `);
240
+
241
+ const result = await getManifestStatus(tmp, 'M001');
242
+ assert.notStrictEqual(result, null);
243
+ assert.deepStrictEqual(result!.pending, []);
244
+ assert.deepStrictEqual(result!.collected, []);
245
+ assert.deepStrictEqual(result!.skipped, []);
246
+ assert.deepStrictEqual(result!.existing, []);
247
+ } finally {
248
+ rmSync(tmp, { recursive: true, force: true });
249
+ }
250
+ });
251
+
252
+ // ─── Env via .env file (not just process.env) ────────────────────────────────
253
+
254
+ test('getManifestStatus: key in .env file counts as existing', async () => {
255
+ const tmp = makeTempDir('manifest-dotenv');
256
+ try {
257
+ delete process.env.DOTENV_ONLY_KEY;
258
+
259
+ writeManifest(tmp, `# Secrets Manifest
260
+
261
+ **Milestone:** M001
262
+ **Generated:** 2025-06-20T10:00:00Z
263
+
264
+ ### DOTENV_ONLY_KEY
265
+
266
+ **Service:** DotenvService
267
+ **Status:** pending
268
+ **Destination:** dotenv
269
+
270
+ 1. Get key
271
+ `);
272
+
273
+ // Write a .env file at the project root with the key
274
+ writeFileSync(join(tmp, '.env'), 'DOTENV_ONLY_KEY=from-dotenv-file\n');
275
+
276
+ const result = await getManifestStatus(tmp, 'M001');
277
+ assert.notStrictEqual(result, null);
278
+ assert.deepStrictEqual(result!.existing, ['DOTENV_ONLY_KEY']);
279
+ assert.deepStrictEqual(result!.pending, []);
280
+ } finally {
281
+ rmSync(tmp, { recursive: true, force: true });
282
+ }
283
+ });
@@ -1488,6 +1488,196 @@ console.log('\n=== parseSecretsManifest + formatSecretsManifest: round-trip ==='
1488
1488
  }
1489
1489
  }
1490
1490
 
1491
+ // ═══════════════════════════════════════════════════════════════════════════
1492
+ // LLM-style round-trip tests — realistic manifest variations
1493
+ // ═══════════════════════════════════════════════════════════════════════════
1494
+
1495
+ console.log('\n=== LLM round-trip: extra whitespace ===');
1496
+ {
1497
+ // LLMs often produce inconsistent indentation and trailing spaces
1498
+ const messy = `# Secrets Manifest
1499
+
1500
+ **Milestone:** M010
1501
+ **Generated:** 2025-07-01T12:00:00Z
1502
+
1503
+ ### OPENAI_API_KEY
1504
+
1505
+ **Service:** OpenAI
1506
+ **Dashboard:** https://platform.openai.com/api-keys
1507
+ **Format hint:** starts with sk-
1508
+ **Status:** pending
1509
+ **Destination:** dotenv
1510
+
1511
+ 1. Go to the API keys page
1512
+ 2. Create a new key
1513
+
1514
+ ### REDIS_URL
1515
+
1516
+ **Service:** Upstash
1517
+ **Status:** collected
1518
+ **Destination:** vercel
1519
+
1520
+ 1. Open console
1521
+ `;
1522
+
1523
+ const parsed1 = parseSecretsManifest(messy);
1524
+ const formatted = formatSecretsManifest(parsed1);
1525
+ const parsed2 = parseSecretsManifest(formatted);
1526
+
1527
+ assertEq(parsed2.milestone, parsed1.milestone, 'whitespace round-trip milestone');
1528
+ assertEq(parsed2.generatedAt, parsed1.generatedAt, 'whitespace round-trip generatedAt');
1529
+ assertEq(parsed2.entries.length, parsed1.entries.length, 'whitespace round-trip entry count');
1530
+ assertEq(parsed2.entries.length, 2, 'whitespace: two entries parsed');
1531
+
1532
+ for (let i = 0; i < parsed1.entries.length; i++) {
1533
+ const e1 = parsed1.entries[i];
1534
+ const e2 = parsed2.entries[i];
1535
+ assertEq(e2.key, e1.key, `whitespace round-trip entry ${i} key`);
1536
+ assertEq(e2.service, e1.service, `whitespace round-trip entry ${i} service`);
1537
+ assertEq(e2.dashboardUrl, e1.dashboardUrl, `whitespace round-trip entry ${i} dashboardUrl`);
1538
+ assertEq(e2.formatHint, e1.formatHint, `whitespace round-trip entry ${i} formatHint`);
1539
+ assertEq(e2.status, e1.status, `whitespace round-trip entry ${i} status`);
1540
+ assertEq(e2.destination, e1.destination, `whitespace round-trip entry ${i} destination`);
1541
+ assertEq(e2.guidance.length, e1.guidance.length, `whitespace round-trip entry ${i} guidance length`);
1542
+ for (let j = 0; j < e1.guidance.length; j++) {
1543
+ assertEq(e2.guidance[j], e1.guidance[j], `whitespace round-trip entry ${i} guidance[${j}]`);
1544
+ }
1545
+ }
1546
+
1547
+ // Verify the parser correctly stripped trailing whitespace
1548
+ assertEq(parsed1.milestone, 'M010', 'whitespace: milestone trimmed');
1549
+ assertEq(parsed1.entries[0].key, 'OPENAI_API_KEY', 'whitespace: key trimmed');
1550
+ assertEq(parsed1.entries[0].service, 'OpenAI', 'whitespace: service trimmed');
1551
+ }
1552
+
1553
+ console.log('\n=== LLM round-trip: missing optional fields ===');
1554
+ {
1555
+ // LLMs may omit Dashboard and Format hint lines entirely
1556
+ const minimal = `# Secrets Manifest
1557
+
1558
+ **Milestone:** M011
1559
+ **Generated:** 2025-07-02T08:00:00Z
1560
+
1561
+ ### DATABASE_URL
1562
+
1563
+ **Service:** Neon
1564
+ **Status:** pending
1565
+ **Destination:** dotenv
1566
+
1567
+ 1. Create a Neon project
1568
+ 2. Copy connection string
1569
+
1570
+ ### WEBHOOK_SECRET
1571
+
1572
+ **Service:** Stripe
1573
+ **Status:** collected
1574
+ **Destination:** dotenv
1575
+
1576
+ 1. Go to webhooks
1577
+ `;
1578
+
1579
+ const parsed1 = parseSecretsManifest(minimal);
1580
+
1581
+ // Verify missing optional fields get defaults
1582
+ assertEq(parsed1.entries[0].dashboardUrl, '', 'missing-optional: no dashboard → empty string');
1583
+ assertEq(parsed1.entries[0].formatHint, '', 'missing-optional: no format hint → empty string');
1584
+ assertEq(parsed1.entries[1].dashboardUrl, '', 'missing-optional: entry 2 no dashboard → empty string');
1585
+ assertEq(parsed1.entries[1].formatHint, '', 'missing-optional: entry 2 no format hint → empty string');
1586
+
1587
+ // Round-trip: formatter omits empty optional fields, re-parse preserves defaults
1588
+ const formatted = formatSecretsManifest(parsed1);
1589
+ const parsed2 = parseSecretsManifest(formatted);
1590
+
1591
+ assertEq(parsed2.entries.length, parsed1.entries.length, 'missing-optional round-trip entry count');
1592
+
1593
+ for (let i = 0; i < parsed1.entries.length; i++) {
1594
+ const e1 = parsed1.entries[i];
1595
+ const e2 = parsed2.entries[i];
1596
+ assertEq(e2.key, e1.key, `missing-optional round-trip entry ${i} key`);
1597
+ assertEq(e2.service, e1.service, `missing-optional round-trip entry ${i} service`);
1598
+ assertEq(e2.dashboardUrl, e1.dashboardUrl, `missing-optional round-trip entry ${i} dashboardUrl`);
1599
+ assertEq(e2.formatHint, e1.formatHint, `missing-optional round-trip entry ${i} formatHint`);
1600
+ assertEq(e2.status, e1.status, `missing-optional round-trip entry ${i} status`);
1601
+ assertEq(e2.destination, e1.destination, `missing-optional round-trip entry ${i} destination`);
1602
+ assertEq(e2.guidance.length, e1.guidance.length, `missing-optional round-trip entry ${i} guidance length`);
1603
+ }
1604
+ }
1605
+
1606
+ console.log('\n=== LLM round-trip: extra blank lines ===');
1607
+ {
1608
+ // LLMs sometimes insert excessive blank lines between sections
1609
+ const blanky = `# Secrets Manifest
1610
+
1611
+
1612
+ **Milestone:** M012
1613
+ **Generated:** 2025-07-03T14:00:00Z
1614
+
1615
+
1616
+
1617
+ ### API_KEY_ONE
1618
+
1619
+
1620
+ **Service:** ServiceOne
1621
+ **Dashboard:** https://one.example.com
1622
+
1623
+
1624
+ **Format hint:** key_...
1625
+ **Status:** pending
1626
+ **Destination:** dotenv
1627
+
1628
+
1629
+
1630
+ 1. Go to settings
1631
+
1632
+
1633
+ 2. Generate key
1634
+
1635
+
1636
+
1637
+ ### API_KEY_TWO
1638
+
1639
+
1640
+
1641
+ **Service:** ServiceTwo
1642
+ **Status:** skipped
1643
+ **Destination:** dotenv
1644
+
1645
+
1646
+ 1. Not needed
1647
+ `;
1648
+
1649
+ const parsed1 = parseSecretsManifest(blanky);
1650
+
1651
+ assertEq(parsed1.entries.length, 2, 'blank-lines: two entries parsed');
1652
+ assertEq(parsed1.milestone, 'M012', 'blank-lines: milestone parsed');
1653
+ assertEq(parsed1.entries[0].key, 'API_KEY_ONE', 'blank-lines: first key');
1654
+ assertEq(parsed1.entries[0].guidance.length, 2, 'blank-lines: first entry guidance count');
1655
+ assertEq(parsed1.entries[1].key, 'API_KEY_TWO', 'blank-lines: second key');
1656
+ assertEq(parsed1.entries[1].status, 'skipped', 'blank-lines: second entry status');
1657
+
1658
+ // Round-trip produces clean output
1659
+ const formatted = formatSecretsManifest(parsed1);
1660
+ const parsed2 = parseSecretsManifest(formatted);
1661
+
1662
+ assertEq(parsed2.entries.length, parsed1.entries.length, 'blank-lines round-trip entry count');
1663
+
1664
+ for (let i = 0; i < parsed1.entries.length; i++) {
1665
+ const e1 = parsed1.entries[i];
1666
+ const e2 = parsed2.entries[i];
1667
+ assertEq(e2.key, e1.key, `blank-lines round-trip entry ${i} key`);
1668
+ assertEq(e2.service, e1.service, `blank-lines round-trip entry ${i} service`);
1669
+ assertEq(e2.dashboardUrl, e1.dashboardUrl, `blank-lines round-trip entry ${i} dashboardUrl`);
1670
+ assertEq(e2.formatHint, e1.formatHint, `blank-lines round-trip entry ${i} formatHint`);
1671
+ assertEq(e2.status, e1.status, `blank-lines round-trip entry ${i} status`);
1672
+ assertEq(e2.destination, e1.destination, `blank-lines round-trip entry ${i} destination`);
1673
+ assertEq(e2.guidance.length, e1.guidance.length, `blank-lines round-trip entry ${i} guidance length`);
1674
+ }
1675
+
1676
+ // Verify the formatted output is cleaner (fewer consecutive blank lines)
1677
+ const consecutiveBlanks = formatted.match(/\n{4,}/g);
1678
+ assert(consecutiveBlanks === null, 'blank-lines: formatted output has no 4+ consecutive newlines');
1679
+ }
1680
+
1491
1681
  // ═══════════════════════════════════════════════════════════════════════════
1492
1682
  // Results
1493
1683
  // ═══════════════════════════════════════════════════════════════════════════
@@ -136,6 +136,13 @@ export interface SecretsManifest {
136
136
  entries: SecretsManifestEntry[];
137
137
  }
138
138
 
139
+ export interface ManifestStatus {
140
+ pending: string[]; // manifest status = pending AND not in env
141
+ collected: string[]; // manifest status = collected AND not in env
142
+ skipped: string[]; // manifest status = skipped
143
+ existing: string[]; // key present in .env or process.env (regardless of manifest status)
144
+ }
145
+
139
146
  // ─── GSD State (Derived Dashboard) ────────────────────────────────────────
140
147
 
141
148
  export interface ActiveRef {