sofia-cli 0.1.2 → 0.1.4

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 (136) hide show
  1. package/README.md +42 -20
  2. package/dist/infra/deploy.sh +193 -0
  3. package/dist/infra/gather-env.sh +211 -0
  4. package/dist/infra/infra/deploy.sh +193 -0
  5. package/dist/infra/infra/gather-env.sh +211 -0
  6. package/dist/infra/infra/main.bicep +90 -0
  7. package/dist/infra/infra/main.bicepparam +18 -0
  8. package/dist/infra/infra/resources.bicep +134 -0
  9. package/dist/infra/infra/teardown.sh +114 -0
  10. package/dist/infra/main.bicep +90 -0
  11. package/dist/infra/main.bicepparam +18 -0
  12. package/dist/infra/resources.bicep +134 -0
  13. package/dist/infra/teardown.sh +114 -0
  14. package/dist/src/cli/developCommand.js +0 -2
  15. package/dist/src/cli/index.js +8 -1
  16. package/dist/src/cli/workshopCommand.js +1 -1
  17. package/dist/src/develop/index.js +1 -1
  18. package/dist/src/develop/pocUtils.js +228 -0
  19. package/dist/src/develop/ralphLoop.js +8 -27
  20. package/dist/src/shared/data/cards.json +655 -670
  21. package/docs/architecture.md +2 -1
  22. package/package.json +5 -3
  23. package/src/cli/developCommand.ts +1 -3
  24. package/src/cli/index.ts +11 -1
  25. package/src/cli/workshopCommand.ts +21 -17
  26. package/src/develop/dynamicScaffolder.ts +36 -30
  27. package/src/develop/index.ts +13 -2
  28. package/src/develop/pocUtils.ts +296 -0
  29. package/src/develop/ralphLoop.ts +8 -28
  30. package/src/develop/templateRegistry.ts +19 -18
  31. package/src/shared/data/cards.json +655 -670
  32. package/tests/e2e/developE2e.spec.ts +3 -61
  33. package/tests/e2e/developFailureE2e.spec.ts +34 -38
  34. package/tests/integration/pocGithubMcp.spec.ts +29 -39
  35. package/tests/integration/pocLocalFallback.spec.ts +29 -39
  36. package/tests/integration/ralphLoopFlow.spec.ts +46 -66
  37. package/tests/integration/ralphLoopPartial.spec.ts +30 -37
  38. package/tests/unit/develop/githubMcpAdapter.spec.ts +0 -134
  39. package/tests/unit/develop/outputValidator.spec.ts +45 -21
  40. package/tests/unit/develop/ralphLoop.spec.ts +58 -94
  41. package/tsconfig.json +2 -1
  42. package/vitest.workspace.ts +5 -0
  43. package/dist/src/develop/pocScaffolder.js +0 -542
  44. package/dist/tests/e2e/developE2e.spec.js +0 -126
  45. package/dist/tests/e2e/developFailureE2e.spec.js +0 -247
  46. package/dist/tests/e2e/developPty.spec.js +0 -75
  47. package/dist/tests/e2e/discoveryWebSearchRelevance.spec.js +0 -84
  48. package/dist/tests/e2e/harness.spec.js +0 -83
  49. package/dist/tests/e2e/mcpLive.spec.js +0 -120
  50. package/dist/tests/e2e/newSession.e2e.spec.js +0 -177
  51. package/dist/tests/e2e/ralphLoopEnrichmentComparison.spec.js +0 -62
  52. package/dist/tests/e2e/workiqEnrichment.spec.js +0 -56
  53. package/dist/tests/e2e/zavaSimulation.spec.js +0 -452
  54. package/dist/tests/fixtures/test-fixture-project/src/add.js +0 -3
  55. package/dist/tests/fixtures/test-fixture-project/tests/failing.test.js +0 -6
  56. package/dist/tests/fixtures/test-fixture-project/tests/hanging.test.js +0 -8
  57. package/dist/tests/fixtures/test-fixture-project/tests/passing.test.js +0 -10
  58. package/dist/tests/fixtures/test-fixture-project/vitest.config.js +0 -6
  59. package/dist/tests/integration/autoStartConversation.spec.js +0 -138
  60. package/dist/tests/integration/defaultCommand.spec.js +0 -147
  61. package/dist/tests/integration/directCommandNonTty.spec.js +0 -224
  62. package/dist/tests/integration/directCommandTty.spec.js +0 -151
  63. package/dist/tests/integration/discoveryEnrichmentFlow.spec.js +0 -175
  64. package/dist/tests/integration/exportArtifacts.spec.js +0 -202
  65. package/dist/tests/integration/exportFallbackFlow.spec.js +0 -99
  66. package/dist/tests/integration/mcpDegradationFlow.spec.js +0 -190
  67. package/dist/tests/integration/mcpTransportFlow.spec.js +0 -139
  68. package/dist/tests/integration/newSessionFlow.spec.js +0 -343
  69. package/dist/tests/integration/pocGithubMcp.spec.js +0 -186
  70. package/dist/tests/integration/pocLocalFallback.spec.js +0 -171
  71. package/dist/tests/integration/pocScaffold.spec.js +0 -163
  72. package/dist/tests/integration/ralphLoopFlow.spec.js +0 -359
  73. package/dist/tests/integration/ralphLoopPartial.spec.js +0 -368
  74. package/dist/tests/integration/resumeAndBacktrack.spec.js +0 -247
  75. package/dist/tests/integration/spinnerLifecycle.spec.js +0 -220
  76. package/dist/tests/integration/summarizationFlow.spec.js +0 -115
  77. package/dist/tests/integration/testRunnerReal.spec.js +0 -52
  78. package/dist/tests/integration/webSearchAgent.spec.js +0 -128
  79. package/dist/tests/live/copilotSdkLive.spec.js +0 -107
  80. package/dist/tests/live/zavaFullWorkshop.spec.js +0 -392
  81. package/dist/tests/setup/loadEnv.js +0 -3
  82. package/dist/tests/unit/cli/developCommand.spec.js +0 -567
  83. package/dist/tests/unit/cli/directCommands.spec.js +0 -279
  84. package/dist/tests/unit/cli/envLoader.spec.js +0 -58
  85. package/dist/tests/unit/cli/ioContext.spec.js +0 -119
  86. package/dist/tests/unit/cli/preflight.spec.js +0 -108
  87. package/dist/tests/unit/cli/statusCommand.spec.js +0 -111
  88. package/dist/tests/unit/cli/workshopClientFallback.spec.js +0 -80
  89. package/dist/tests/unit/cli/workshopCommand.spec.js +0 -328
  90. package/dist/tests/unit/config/vitestEnvSetup.spec.js +0 -13
  91. package/dist/tests/unit/develop/checkpointState.spec.js +0 -315
  92. package/dist/tests/unit/develop/codeGenerator.spec.js +0 -355
  93. package/dist/tests/unit/develop/githubMcpAdapter.spec.js +0 -231
  94. package/dist/tests/unit/develop/mcpContextEnricher.spec.js +0 -433
  95. package/dist/tests/unit/develop/outputValidator.spec.js +0 -119
  96. package/dist/tests/unit/develop/pocScaffolder.spec.js +0 -353
  97. package/dist/tests/unit/develop/ralphLoop.spec.js +0 -1248
  98. package/dist/tests/unit/develop/templateRegistry.spec.js +0 -85
  99. package/dist/tests/unit/develop/testRunner.spec.js +0 -249
  100. package/dist/tests/unit/infraBicep.spec.js +0 -92
  101. package/dist/tests/unit/infraDeploy.spec.js +0 -82
  102. package/dist/tests/unit/infraTeardown.spec.js +0 -63
  103. package/dist/tests/unit/logging/logger.spec.js +0 -43
  104. package/dist/tests/unit/loop/conversationLoop.spec.js +0 -592
  105. package/dist/tests/unit/loop/phaseSummarizer.spec.js +0 -141
  106. package/dist/tests/unit/loop/streamingMarkdown.spec.js +0 -147
  107. package/dist/tests/unit/mcp/mcpManager.spec.js +0 -279
  108. package/dist/tests/unit/mcp/mcpTransport.spec.js +0 -529
  109. package/dist/tests/unit/mcp/retryPolicy.spec.js +0 -218
  110. package/dist/tests/unit/mcp/timeoutValidation.spec.js +0 -46
  111. package/dist/tests/unit/mcp/webSearch.spec.js +0 -567
  112. package/dist/tests/unit/phases/contextSummarizer.spec.js +0 -140
  113. package/dist/tests/unit/phases/discoveryEnricher.repeatCalls.spec.js +0 -93
  114. package/dist/tests/unit/phases/discoveryEnricher.spec.js +0 -411
  115. package/dist/tests/unit/phases/phaseExtractors.spec.js +0 -352
  116. package/dist/tests/unit/phases/phaseHandlers.spec.js +0 -425
  117. package/dist/tests/unit/prompts/promptLoader.spec.js +0 -118
  118. package/dist/tests/unit/schemas/pocSchemas.spec.js +0 -412
  119. package/dist/tests/unit/schemas/session.spec.js +0 -257
  120. package/dist/tests/unit/sessions/exportPaths.spec.js +0 -31
  121. package/dist/tests/unit/sessions/exportWriter.spec.js +0 -655
  122. package/dist/tests/unit/sessions/sessionManager.spec.js +0 -151
  123. package/dist/tests/unit/sessions/sessionStore.spec.js +0 -116
  124. package/dist/tests/unit/shared/activitySpinner.spec.js +0 -175
  125. package/dist/tests/unit/shared/cardsLoader.spec.js +0 -76
  126. package/dist/tests/unit/shared/copilotClient.spec.js +0 -155
  127. package/dist/tests/unit/shared/errorClassifier.spec.js +0 -131
  128. package/dist/tests/unit/shared/events.spec.js +0 -55
  129. package/dist/tests/unit/shared/markdownRenderer.spec.js +0 -35
  130. package/dist/tests/unit/shared/markdownRendererChunks.spec.js +0 -70
  131. package/dist/tests/unit/shared/tableRenderer.spec.js +0 -34
  132. package/dist/vitest.config.js +0 -14
  133. package/dist/vitest.live.config.js +0 -18
  134. package/src/develop/pocScaffolder.ts +0 -646
  135. package/tests/integration/pocScaffold.spec.ts +0 -220
  136. package/tests/unit/develop/pocScaffolder.spec.ts +0 -451
@@ -1,315 +0,0 @@
1
- /**
2
- * Unit tests for deriveCheckpointState.
3
- *
4
- * T012: Verify correct state for no-poc, completed, partial, interrupted sessions
5
- * T068: Corrupted iterations cause safe fallback
6
- * T069: Metadata integrity mismatch triggers warning
7
- */
8
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
9
- import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
10
- import { join } from 'node:path';
11
- import { tmpdir } from 'node:os';
12
- import { deriveCheckpointState } from '../../../src/develop/checkpointState.js';
13
- // ── Helpers ───────────────────────────────────────────────────────────────────
14
- function makeSession(overrides) {
15
- const now = new Date().toISOString();
16
- return {
17
- sessionId: 'cp-test-session',
18
- schemaVersion: '1.0.0',
19
- createdAt: now,
20
- updatedAt: now,
21
- phase: 'Develop',
22
- status: 'Active',
23
- participants: [],
24
- artifacts: { generatedFiles: [] },
25
- ...overrides,
26
- };
27
- }
28
- // ── Tests ─────────────────────────────────────────────────────────────────────
29
- describe('deriveCheckpointState', () => {
30
- let tmpDir;
31
- beforeEach(() => {
32
- tmpDir = mkdtempSync(join(tmpdir(), 'checkpoint-'));
33
- });
34
- afterEach(() => {
35
- rmSync(tmpDir, { recursive: true, force: true });
36
- });
37
- it('returns fresh state when session has no poc', () => {
38
- const session = makeSession();
39
- const state = deriveCheckpointState(session, tmpDir);
40
- expect(state.hasPriorRun).toBe(false);
41
- expect(state.completedIterations).toBe(0);
42
- expect(state.lastIterationIncomplete).toBe(false);
43
- expect(state.resumeFromIteration).toBe(1);
44
- expect(state.canSkipScaffold).toBe(false);
45
- expect(state.priorFinalStatus).toBeUndefined();
46
- expect(state.priorIterations).toEqual([]);
47
- });
48
- it('returns fresh state when poc has empty iterations', () => {
49
- const session = makeSession({
50
- poc: { repoSource: 'local', iterations: [] },
51
- });
52
- const state = deriveCheckpointState(session, tmpDir);
53
- expect(state.hasPriorRun).toBe(false);
54
- expect(state.resumeFromIteration).toBe(1);
55
- });
56
- it('returns completed state for sessions with all iterations having testResults', () => {
57
- const session = makeSession({
58
- poc: {
59
- repoSource: 'local',
60
- iterations: [
61
- {
62
- iteration: 1,
63
- startedAt: new Date().toISOString(),
64
- endedAt: new Date().toISOString(),
65
- outcome: 'scaffold',
66
- filesChanged: [],
67
- },
68
- {
69
- iteration: 2,
70
- startedAt: new Date().toISOString(),
71
- endedAt: new Date().toISOString(),
72
- outcome: 'tests-failing',
73
- filesChanged: ['src/index.ts'],
74
- testResults: {
75
- passed: 1,
76
- failed: 1,
77
- skipped: 0,
78
- total: 2,
79
- durationMs: 100,
80
- failures: [],
81
- },
82
- },
83
- ],
84
- finalStatus: 'partial',
85
- },
86
- });
87
- const state = deriveCheckpointState(session, tmpDir);
88
- expect(state.hasPriorRun).toBe(true);
89
- expect(state.completedIterations).toBe(2);
90
- expect(state.lastIterationIncomplete).toBe(false);
91
- expect(state.resumeFromIteration).toBe(3);
92
- expect(state.priorFinalStatus).toBe('partial');
93
- expect(state.priorIterations).toHaveLength(2);
94
- });
95
- it('detects incomplete last iteration (no testResults, not scaffold)', () => {
96
- const session = makeSession({
97
- poc: {
98
- repoSource: 'local',
99
- iterations: [
100
- {
101
- iteration: 1,
102
- startedAt: new Date().toISOString(),
103
- endedAt: new Date().toISOString(),
104
- outcome: 'scaffold',
105
- filesChanged: [],
106
- },
107
- {
108
- iteration: 2,
109
- startedAt: new Date().toISOString(),
110
- outcome: 'tests-failing',
111
- filesChanged: [],
112
- // No testResults — interrupted
113
- },
114
- ],
115
- },
116
- });
117
- const state = deriveCheckpointState(session, tmpDir);
118
- expect(state.hasPriorRun).toBe(true);
119
- expect(state.lastIterationIncomplete).toBe(true);
120
- expect(state.completedIterations).toBe(1);
121
- expect(state.resumeFromIteration).toBe(2);
122
- expect(state.priorIterations).toHaveLength(1);
123
- });
124
- it('sets canSkipScaffold when metadata file exists with matching sessionId', () => {
125
- mkdirSync(tmpDir, { recursive: true });
126
- writeFileSync(join(tmpDir, '.sofia-metadata.json'), JSON.stringify({ sessionId: 'cp-test-session' }));
127
- const session = makeSession({
128
- poc: {
129
- repoSource: 'local',
130
- iterations: [
131
- {
132
- iteration: 1,
133
- startedAt: new Date().toISOString(),
134
- endedAt: new Date().toISOString(),
135
- outcome: 'scaffold',
136
- filesChanged: [],
137
- },
138
- ],
139
- },
140
- });
141
- const state = deriveCheckpointState(session, tmpDir);
142
- expect(state.canSkipScaffold).toBe(true);
143
- });
144
- it('sets canSkipScaffold false when metadata file has mismatched sessionId (T069)', () => {
145
- mkdirSync(tmpDir, { recursive: true });
146
- writeFileSync(join(tmpDir, '.sofia-metadata.json'), JSON.stringify({ sessionId: 'different-session' }));
147
- const session = makeSession({
148
- poc: {
149
- repoSource: 'local',
150
- iterations: [
151
- {
152
- iteration: 1,
153
- startedAt: new Date().toISOString(),
154
- endedAt: new Date().toISOString(),
155
- outcome: 'scaffold',
156
- filesChanged: [],
157
- },
158
- ],
159
- },
160
- });
161
- const state = deriveCheckpointState(session, tmpDir);
162
- expect(state.canSkipScaffold).toBe(false);
163
- });
164
- it('sets canSkipScaffold false when metadata file is corrupt JSON (T069)', () => {
165
- mkdirSync(tmpDir, { recursive: true });
166
- writeFileSync(join(tmpDir, '.sofia-metadata.json'), '{invalid json');
167
- const session = makeSession({
168
- poc: {
169
- repoSource: 'local',
170
- iterations: [
171
- {
172
- iteration: 1,
173
- startedAt: new Date().toISOString(),
174
- endedAt: new Date().toISOString(),
175
- outcome: 'scaffold',
176
- filesChanged: [],
177
- },
178
- ],
179
- },
180
- });
181
- const state = deriveCheckpointState(session, tmpDir);
182
- expect(state.canSkipScaffold).toBe(false);
183
- });
184
- it('falls back to fresh run when iterations have corrupt entries (T068)', () => {
185
- const session = makeSession({
186
- poc: {
187
- repoSource: 'local',
188
- iterations: [
189
- {
190
- iteration: 1,
191
- startedAt: '', // Empty string — invalid
192
- outcome: 'scaffold',
193
- filesChanged: [],
194
- },
195
- ],
196
- },
197
- });
198
- const state = deriveCheckpointState(session, tmpDir);
199
- expect(state.hasPriorRun).toBe(false);
200
- expect(state.resumeFromIteration).toBe(1);
201
- });
202
- it('falls back to fresh run when iteration has non-number iteration field (T068)', () => {
203
- const session = makeSession({
204
- poc: {
205
- repoSource: 'local',
206
- iterations: [
207
- {
208
- iteration: 'not a number',
209
- startedAt: new Date().toISOString(),
210
- outcome: 'scaffold',
211
- filesChanged: [],
212
- },
213
- ],
214
- },
215
- });
216
- const state = deriveCheckpointState(session, tmpDir);
217
- expect(state.hasPriorRun).toBe(false);
218
- expect(state.resumeFromIteration).toBe(1);
219
- });
220
- it('returns success priorFinalStatus for successful sessions', () => {
221
- const session = makeSession({
222
- poc: {
223
- repoSource: 'local',
224
- iterations: [
225
- {
226
- iteration: 1,
227
- startedAt: new Date().toISOString(),
228
- endedAt: new Date().toISOString(),
229
- outcome: 'tests-passing',
230
- filesChanged: [],
231
- testResults: {
232
- passed: 3,
233
- failed: 0,
234
- skipped: 0,
235
- total: 3,
236
- durationMs: 100,
237
- failures: [],
238
- },
239
- },
240
- ],
241
- finalStatus: 'success',
242
- },
243
- });
244
- const state = deriveCheckpointState(session, tmpDir);
245
- expect(state.priorFinalStatus).toBe('success');
246
- });
247
- // ── T075: Validation — fresh vs resumed run quality comparison ────────────
248
- describe('fresh vs resumed run quality (T075)', () => {
249
- it('preserves iteration count consistency across fresh and resumed checkpoint derivations', () => {
250
- // Fresh session (no prior run)
251
- const freshSession = makeSession();
252
- const freshState = deriveCheckpointState(freshSession, tmpDir);
253
- expect(freshState.hasPriorRun).toBe(false);
254
- expect(freshState.resumeFromIteration).toBe(1);
255
- expect(freshState.completedIterations).toBe(0);
256
- // Session with 3 completed iterations (simulating prior run)
257
- const resumedSession = makeSession({
258
- poc: {
259
- iterations: [
260
- { iteration: 1, startedAt: '2026-01-01T12:00:00Z', endedAt: '2026-01-01T12:01:00Z', outcome: 'tests-failing', filesChanged: ['src/index.ts'], testResults: { passed: 0, failed: 1, skipped: 0, total: 1, durationMs: 100, failures: [{ testName: 'a', message: 'fail' }] } },
261
- { iteration: 2, startedAt: '2026-01-01T12:01:00Z', endedAt: '2026-01-01T12:02:00Z', outcome: 'tests-failing', filesChanged: ['src/index.ts'], testResults: { passed: 1, failed: 1, skipped: 0, total: 2, durationMs: 100, failures: [{ testName: 'b', message: 'fail' }] } },
262
- { iteration: 3, startedAt: '2026-01-01T12:02:00Z', endedAt: '2026-01-01T12:03:00Z', outcome: 'tests-passing', filesChanged: ['src/index.ts'], testResults: { passed: 2, failed: 0, skipped: 0, total: 2, durationMs: 100, failures: [] } },
263
- ],
264
- finalStatus: 'success',
265
- repoSource: 'local',
266
- },
267
- });
268
- writeFileSync(join(tmpDir, '.sofia-metadata.json'), JSON.stringify({ sessionId: 'cp-test-session' }));
269
- const resumeState = deriveCheckpointState(resumedSession, tmpDir);
270
- expect(resumeState.hasPriorRun).toBe(true);
271
- expect(resumeState.completedIterations).toBe(3);
272
- expect(resumeState.resumeFromIteration).toBe(4);
273
- // Quality validation: resumed state preserves iteration history
274
- expect(resumeState.priorIterations).toHaveLength(3);
275
- // Test pass counts increase across iterations (quality signal)
276
- expect(resumeState.priorIterations[0].testResults.passed).toBe(0);
277
- expect(resumeState.priorIterations[2].testResults.passed).toBe(2);
278
- });
279
- });
280
- // ── T076: Benchmark — resume detection overhead <500ms ────────────────────
281
- describe('resume detection performance (T076)', () => {
282
- it('deriveCheckpointState completes within 500ms even with many iterations', () => {
283
- // Create a session with 50 iterations to stress-test derivation
284
- const iterations = Array.from({ length: 50 }, (_, i) => ({
285
- iteration: i + 1,
286
- startedAt: '2026-01-01T12:00:00Z',
287
- endedAt: '2026-01-01T12:01:00Z',
288
- outcome: 'tests-failing',
289
- filesChanged: ['src/index.ts'],
290
- testResults: {
291
- passed: i,
292
- failed: 50 - i,
293
- skipped: 0,
294
- total: 50,
295
- durationMs: 100,
296
- failures: [{ testName: `test-${i}`, message: 'fail' }],
297
- },
298
- }));
299
- const session = makeSession({
300
- poc: {
301
- iterations,
302
- finalStatus: 'failed',
303
- repoSource: 'local',
304
- },
305
- });
306
- writeFileSync(join(tmpDir, '.sofia-metadata.json'), JSON.stringify({ sessionId: 'cp-test-session' }));
307
- const start = performance.now();
308
- const state = deriveCheckpointState(session, tmpDir);
309
- const elapsed = performance.now() - start;
310
- expect(elapsed).toBeLessThan(500);
311
- expect(state.completedIterations).toBe(50);
312
- expect(state.resumeFromIteration).toBe(51);
313
- });
314
- });
315
- });
@@ -1,355 +0,0 @@
1
- /**
2
- * T013: Unit tests for CodeGenerator.
3
- *
4
- * Verifies:
5
- * - Parses fenced code blocks with `file=path` from LLM response
6
- * - Writes files to outputDir
7
- * - Handles empty response gracefully
8
- * - Builds iteration prompt with test failures context
9
- */
10
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
11
- import { mkdtemp, rm, readFile } from 'node:fs/promises';
12
- import { join } from 'node:path';
13
- import { tmpdir } from 'node:os';
14
- import { CodeGenerator, parseFencedCodeBlocks, buildFileTree, isUnsafePath, isPathWithinDirectory, } from '../../../src/develop/codeGenerator.js';
15
- // ── Fixtures ──────────────────────────────────────────────────────────────────
16
- function makeTestResults(overrides) {
17
- return {
18
- passed: 1,
19
- failed: 2,
20
- skipped: 0,
21
- total: 3,
22
- durationMs: 500,
23
- failures: [
24
- {
25
- testName: 'suite > test A',
26
- message: 'Expected 3 but got 5',
27
- file: 'tests/a.test.ts',
28
- line: 10,
29
- },
30
- {
31
- testName: 'suite > test B',
32
- message: 'Cannot read properties of undefined',
33
- file: 'tests/b.test.ts',
34
- },
35
- ],
36
- rawOutput: 'FAIL tests/a.test.ts\nFAIL tests/b.test.ts\n',
37
- ...overrides,
38
- };
39
- }
40
- // ── parseFencedCodeBlocks ─────────────────────────────────────────────────────
41
- describe('parseFencedCodeBlocks', () => {
42
- it('parses a single typescript code block with file=', () => {
43
- const response = `Here are the changes:\n\n\`\`\`typescript file=src/index.ts\nexport function main() {\n return 42;\n}\n\`\`\`\n`;
44
- const files = parseFencedCodeBlocks(response);
45
- expect(files).toHaveLength(1);
46
- expect(files[0].path).toBe('src/index.ts');
47
- expect(files[0].content).toContain('export function main()');
48
- expect(files[0].language).toBe('typescript');
49
- });
50
- it('parses multiple code blocks', () => {
51
- const response = `\`\`\`typescript file=src/index.ts\nexport function a() {}\n\`\`\`\n\n\`\`\`typescript file=tests/index.test.ts\nimport { test } from "vitest";\n\`\`\`\n`;
52
- const files = parseFencedCodeBlocks(response);
53
- expect(files).toHaveLength(2);
54
- expect(files[0].path).toBe('src/index.ts');
55
- expect(files[1].path).toBe('tests/index.test.ts');
56
- });
57
- it('handles code block with ./prefix in path', () => {
58
- const response = `\`\`\`typescript file=./src/index.ts\nexport const x = 1;\n\`\`\`\n`;
59
- const files = parseFencedCodeBlocks(response);
60
- expect(files).toHaveLength(1);
61
- expect(files[0].path).toBe('src/index.ts'); // normalized
62
- });
63
- it('handles empty LLM response gracefully', () => {
64
- const files = parseFencedCodeBlocks('');
65
- expect(files).toHaveLength(0);
66
- });
67
- it('ignores code blocks without file= annotation', () => {
68
- const response = `\`\`\`typescript\nexport function main() {}\n\`\`\`\n`;
69
- const files = parseFencedCodeBlocks(response);
70
- expect(files).toHaveLength(0);
71
- });
72
- it('handles ts shorthand language tag', () => {
73
- const response = `\`\`\`ts file=src/index.ts\nexport const x = 1;\n\`\`\`\n`;
74
- const files = parseFencedCodeBlocks(response);
75
- expect(files).toHaveLength(1);
76
- expect(files[0].language).toBe('ts');
77
- });
78
- it('handles json file type', () => {
79
- const response = `\`\`\`json file=package.json\n{"name":"test"}\n\`\`\`\n`;
80
- const files = parseFencedCodeBlocks(response);
81
- expect(files).toHaveLength(1);
82
- expect(files[0].path).toBe('package.json');
83
- });
84
- });
85
- // ── buildFileTree ─────────────────────────────────────────────────────────────
86
- describe('buildFileTree', () => {
87
- let tmpDir;
88
- beforeEach(async () => {
89
- tmpDir = await mkdtemp(join(tmpdir(), 'sofia-filetree-test-'));
90
- });
91
- afterEach(async () => {
92
- await rm(tmpDir, { recursive: true, force: true });
93
- });
94
- it('returns empty array for non-existent directory', () => {
95
- const tree = buildFileTree('/non/existent/path');
96
- expect(tree).toEqual([]);
97
- });
98
- it('lists files in directory', async () => {
99
- const { writeFile } = await import('node:fs/promises');
100
- await writeFile(join(tmpDir, 'index.ts'), '', 'utf-8');
101
- await writeFile(join(tmpDir, 'helper.ts'), '', 'utf-8');
102
- const tree = buildFileTree(tmpDir);
103
- expect(tree).toContain('helper.ts');
104
- expect(tree).toContain('index.ts');
105
- });
106
- it('excludes node_modules and dist', async () => {
107
- const { mkdir, writeFile } = await import('node:fs/promises');
108
- await mkdir(join(tmpDir, 'node_modules'), { recursive: true });
109
- await writeFile(join(tmpDir, 'node_modules', 'pkg.js'), '', 'utf-8');
110
- await mkdir(join(tmpDir, 'dist'), { recursive: true });
111
- await writeFile(join(tmpDir, 'dist', 'index.js'), '', 'utf-8');
112
- await writeFile(join(tmpDir, 'index.ts'), '', 'utf-8');
113
- const tree = buildFileTree(tmpDir);
114
- expect(tree).toContain('index.ts');
115
- expect(tree.some((f) => f.includes('node_modules'))).toBe(false);
116
- expect(tree.some((f) => f.includes('dist'))).toBe(false);
117
- });
118
- it('recursively lists subdirectories', async () => {
119
- const { mkdir, writeFile } = await import('node:fs/promises');
120
- await mkdir(join(tmpDir, 'src'), { recursive: true });
121
- await writeFile(join(tmpDir, 'src', 'index.ts'), '', 'utf-8');
122
- const tree = buildFileTree(tmpDir);
123
- expect(tree).toContain('src/');
124
- expect(tree).toContain(' index.ts');
125
- });
126
- });
127
- // ── isUnsafePath ──────────────────────────────────────────────────────────────
128
- describe('isUnsafePath', () => {
129
- it('returns true for POSIX absolute paths', () => {
130
- expect(isUnsafePath('/etc/passwd')).toBe(true);
131
- expect(isUnsafePath('/tmp/evil')).toBe(true);
132
- });
133
- it('returns true for Windows drive-letter paths', () => {
134
- expect(isUnsafePath('C:\\Windows\\System32\\evil.ts')).toBe(true);
135
- expect(isUnsafePath('c:/Windows/evil.ts')).toBe(true);
136
- });
137
- it('returns true for UNC paths (backslash and forward-slash)', () => {
138
- expect(isUnsafePath('\\\\server\\share\\evil.ts')).toBe(true);
139
- expect(isUnsafePath('//server/share/evil.ts')).toBe(true);
140
- });
141
- it('returns true for path traversal segments', () => {
142
- expect(isUnsafePath('../../etc/passwd')).toBe(true);
143
- expect(isUnsafePath('src/../../../evil')).toBe(true);
144
- });
145
- it('returns false for normal relative paths', () => {
146
- expect(isUnsafePath('src/index.ts')).toBe(false);
147
- expect(isUnsafePath('tests/index.test.ts')).toBe(false);
148
- expect(isUnsafePath('package.json')).toBe(false);
149
- });
150
- });
151
- // ── isPathWithinDirectory ─────────────────────────────────────────────────────
152
- describe('isPathWithinDirectory', () => {
153
- it('returns true for relative paths inside the directory', () => {
154
- expect(isPathWithinDirectory('src/index.ts', '/tmp/poc')).toBe(true);
155
- expect(isPathWithinDirectory('package.json', '/tmp/poc')).toBe(true);
156
- });
157
- it('returns false for paths that resolve outside the directory', () => {
158
- // On POSIX, path.resolve('/tmp/poc', '../../etc/passwd') → '/etc/passwd'
159
- expect(isPathWithinDirectory('../../etc/passwd', '/tmp/poc')).toBe(false);
160
- });
161
- });
162
- // ── CodeGenerator ─────────────────────────────────────────────────────────────
163
- describe('CodeGenerator', () => {
164
- let tmpDir;
165
- let generator;
166
- beforeEach(async () => {
167
- tmpDir = await mkdtemp(join(tmpdir(), 'sofia-codegen-test-'));
168
- generator = new CodeGenerator(tmpDir);
169
- });
170
- afterEach(async () => {
171
- await rm(tmpDir, { recursive: true, force: true });
172
- });
173
- describe('buildIterationPrompt', () => {
174
- it('includes iteration number and max', () => {
175
- const prompt = generator.buildIterationPrompt({
176
- iteration: 3,
177
- maxIterations: 10,
178
- previousOutcome: 'tests-failing',
179
- testResults: makeTestResults(),
180
- filesInPoc: ['src/index.ts'],
181
- });
182
- expect(prompt).toContain('Iteration: 3 of 10');
183
- expect(prompt).toContain('tests-failing');
184
- });
185
- it('includes failing test details', () => {
186
- const prompt = generator.buildIterationPrompt({
187
- iteration: 2,
188
- maxIterations: 5,
189
- previousOutcome: 'scaffold',
190
- testResults: makeTestResults(),
191
- filesInPoc: ['src/index.ts'],
192
- });
193
- expect(prompt).toContain('suite > test A');
194
- expect(prompt).toContain('Expected 3 but got 5');
195
- expect(prompt).toContain('tests/a.test.ts');
196
- });
197
- it('includes files in PoC', () => {
198
- const prompt = generator.buildIterationPrompt({
199
- iteration: 2,
200
- maxIterations: 5,
201
- previousOutcome: 'scaffold',
202
- testResults: makeTestResults({ failed: 0, failures: [], total: 1, passed: 1 }),
203
- filesInPoc: ['src/index.ts', 'tests/index.test.ts', 'package.json'],
204
- });
205
- expect(prompt).toContain('src/index.ts');
206
- expect(prompt).toContain('tests/index.test.ts');
207
- });
208
- it('includes MCP context when provided', () => {
209
- const prompt = generator.buildIterationPrompt({
210
- iteration: 2,
211
- maxIterations: 5,
212
- previousOutcome: 'tests-failing',
213
- testResults: makeTestResults(),
214
- filesInPoc: [],
215
- mcpContext: 'express@5.0.0 API docs: use app.get()',
216
- });
217
- expect(prompt).toContain('express@5.0.0 API docs');
218
- expect(prompt).toContain('MCP Context');
219
- });
220
- it('includes raw test output', () => {
221
- const prompt = generator.buildIterationPrompt({
222
- iteration: 2,
223
- maxIterations: 5,
224
- previousOutcome: 'tests-failing',
225
- testResults: makeTestResults({ rawOutput: 'FAIL tests/a.test.ts\n' }),
226
- filesInPoc: [],
227
- });
228
- expect(prompt).toContain('FAIL tests/a.test.ts');
229
- });
230
- it('shows "0 failing tests" task when no failures', () => {
231
- const prompt = generator.buildIterationPrompt({
232
- iteration: 3,
233
- maxIterations: 10,
234
- previousOutcome: 'tests-passing',
235
- testResults: makeTestResults({ passed: 3, failed: 0, failures: [], total: 3 }),
236
- filesInPoc: [],
237
- });
238
- expect(prompt).toContain('0 failing tests');
239
- });
240
- it('includes file contents in ## Current Code section when fileContents provided', () => {
241
- const prompt = generator.buildIterationPrompt({
242
- iteration: 2,
243
- maxIterations: 5,
244
- previousOutcome: 'tests-failing',
245
- testResults: makeTestResults(),
246
- filesInPoc: ['src/index.ts'],
247
- fileContents: [
248
- { path: 'src/index.ts', content: 'export function main() { return 42; }' },
249
- { path: 'tests/index.test.ts', content: 'import { test } from "vitest";' },
250
- ],
251
- });
252
- expect(prompt).toContain('## Current Code');
253
- expect(prompt).toContain('### src/index.ts');
254
- expect(prompt).toContain('export function main() { return 42; }');
255
- expect(prompt).toContain('### tests/index.test.ts');
256
- });
257
- it('omits ## Current Code section when fileContents is empty or absent', () => {
258
- const prompt = generator.buildIterationPrompt({
259
- iteration: 2,
260
- maxIterations: 5,
261
- previousOutcome: 'tests-failing',
262
- testResults: makeTestResults(),
263
- filesInPoc: ['src/index.ts'],
264
- });
265
- expect(prompt).not.toContain('## Current Code');
266
- });
267
- });
268
- describe('buildPromptContextSummary', () => {
269
- it('returns a compact summary for auditability', () => {
270
- const summary = generator.buildPromptContextSummary({
271
- iteration: 3,
272
- maxIterations: 10,
273
- previousOutcome: 'tests-failing',
274
- testResults: makeTestResults({ failed: 2 }),
275
- filesInPoc: ['src/index.ts', 'tests/index.test.ts'],
276
- });
277
- expect(summary).toContain('Iteration 3 of 10');
278
- expect(summary).toContain('2 failures');
279
- expect(summary).toContain('src/index.ts');
280
- });
281
- });
282
- describe('applyChanges', () => {
283
- it('writes parsed files to outputDir', async () => {
284
- const llmResponse = `\`\`\`typescript file=src/index.ts\nexport function hello() { return 'hello'; }\n\`\`\`\n`;
285
- await generator.applyChanges(llmResponse);
286
- const content = await readFile(join(tmpDir, 'src', 'index.ts'), 'utf-8');
287
- expect(content).toContain('export function hello()');
288
- });
289
- it('handles empty LLM response gracefully', async () => {
290
- const result = await generator.applyChanges('');
291
- expect(result.writtenFiles).toHaveLength(0);
292
- expect(result.dependenciesChanged).toBe(false);
293
- });
294
- it('creates parent directories for nested files', async () => {
295
- const llmResponse = `\`\`\`typescript file=src/utils/helper.ts\nexport function help() {}\n\`\`\`\n`;
296
- const result = await generator.applyChanges(llmResponse);
297
- expect(result.writtenFiles).toContain('src/utils/helper.ts');
298
- const content = await readFile(join(tmpDir, 'src', 'utils', 'helper.ts'), 'utf-8');
299
- expect(content).toContain('export function help()');
300
- });
301
- it('rejects paths with path traversal', async () => {
302
- const llmResponse = `\`\`\`typescript file=../../etc/passwd\nmalicious content\n\`\`\`\n`;
303
- const result = await generator.applyChanges(llmResponse);
304
- expect(result.writtenFiles).toHaveLength(0);
305
- });
306
- it('rejects Windows absolute paths (drive-letter)', async () => {
307
- const llmResponse = `\`\`\`typescript file=C:\\Windows\\System32\\malicious.ts\nevil\n\`\`\`\n`;
308
- const result = await generator.applyChanges(llmResponse);
309
- expect(result.writtenFiles).toHaveLength(0);
310
- });
311
- it('rejects Windows UNC paths', async () => {
312
- const llmResponse = `\`\`\`typescript file=\\\\server\\share\\malicious.ts\nevil\n\`\`\`\n`;
313
- const result = await generator.applyChanges(llmResponse);
314
- expect(result.writtenFiles).toHaveLength(0);
315
- });
316
- it('detects dependency changes when package.json is updated', async () => {
317
- const { writeFile } = await import('node:fs/promises');
318
- // Create initial package.json
319
- await writeFile(join(tmpDir, 'package.json'), JSON.stringify({ dependencies: { express: '^4.0.0' }, devDependencies: {} }), 'utf-8');
320
- // Update with new dependency
321
- const llmResponse = `\`\`\`json file=package.json\n{"dependencies":{"express":"^4.0.0","axios":"^1.0.0"},"devDependencies":{}}\n\`\`\`\n`;
322
- const result = await generator.applyChanges(llmResponse);
323
- expect(result.dependenciesChanged).toBe(true);
324
- expect(result.newDependencies['axios']).toBe('^1.0.0');
325
- });
326
- it('returns dependenciesChanged=false when package.json unchanged', async () => {
327
- const { writeFile } = await import('node:fs/promises');
328
- await writeFile(join(tmpDir, 'package.json'), JSON.stringify({ dependencies: { express: '^4.0.0' }, devDependencies: {} }), 'utf-8');
329
- // Same package.json
330
- const llmResponse = `\`\`\`json file=package.json\n{"dependencies":{"express":"^4.0.0"},"devDependencies":{}}\n\`\`\`\n`;
331
- const result = await generator.applyChanges(llmResponse);
332
- expect(result.dependenciesChanged).toBe(false);
333
- });
334
- it('returns writtenFiles list', async () => {
335
- const llmResponse = [
336
- '```typescript file=src/index.ts\nexport const x = 1;\n```',
337
- '```typescript file=tests/index.test.ts\nimport { test } from "vitest";\n```',
338
- ].join('\n');
339
- const result = await generator.applyChanges(llmResponse);
340
- expect(result.writtenFiles).toContain('src/index.ts');
341
- expect(result.writtenFiles).toContain('tests/index.test.ts');
342
- });
343
- });
344
- describe('getFilesInPoc', () => {
345
- it('returns list of files in the output directory', async () => {
346
- const { writeFile, mkdir } = await import('node:fs/promises');
347
- await mkdir(join(tmpDir, 'src'), { recursive: true });
348
- await writeFile(join(tmpDir, 'src', 'index.ts'), '', 'utf-8');
349
- await writeFile(join(tmpDir, 'package.json'), '{}', 'utf-8');
350
- const files = generator.getFilesInPoc();
351
- expect(files.some((f) => f.includes('index.ts'))).toBe(true);
352
- expect(files.some((f) => f.includes('package.json'))).toBe(true);
353
- });
354
- });
355
- });