popeye-cli 1.8.0 → 1.9.1
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 +47 -3
- package/cheatsheet.md +33 -0
- package/dist/cli/commands/index.d.ts +1 -0
- package/dist/cli/commands/index.d.ts.map +1 -1
- package/dist/cli/commands/index.js +1 -0
- package/dist/cli/commands/index.js.map +1 -1
- package/dist/cli/commands/review.d.ts +31 -0
- package/dist/cli/commands/review.d.ts.map +1 -0
- package/dist/cli/commands/review.js +156 -0
- package/dist/cli/commands/review.js.map +1 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/interactive.d.ts.map +1 -1
- package/dist/cli/interactive.js +122 -61
- package/dist/cli/interactive.js.map +1 -1
- package/dist/types/audit.d.ts +623 -0
- package/dist/types/audit.d.ts.map +1 -0
- package/dist/types/audit.js +240 -0
- package/dist/types/audit.js.map +1 -0
- package/dist/types/workflow.d.ts +15 -0
- package/dist/types/workflow.d.ts.map +1 -1
- package/dist/types/workflow.js +5 -0
- package/dist/types/workflow.js.map +1 -1
- package/dist/workflow/audit-analyzer.d.ts +58 -0
- package/dist/workflow/audit-analyzer.d.ts.map +1 -0
- package/dist/workflow/audit-analyzer.js +438 -0
- package/dist/workflow/audit-analyzer.js.map +1 -0
- package/dist/workflow/audit-mode.d.ts +28 -0
- package/dist/workflow/audit-mode.d.ts.map +1 -0
- package/dist/workflow/audit-mode.js +169 -0
- package/dist/workflow/audit-mode.js.map +1 -0
- package/dist/workflow/audit-recovery.d.ts +61 -0
- package/dist/workflow/audit-recovery.d.ts.map +1 -0
- package/dist/workflow/audit-recovery.js +242 -0
- package/dist/workflow/audit-recovery.js.map +1 -0
- package/dist/workflow/audit-reporter.d.ts +65 -0
- package/dist/workflow/audit-reporter.d.ts.map +1 -0
- package/dist/workflow/audit-reporter.js +301 -0
- package/dist/workflow/audit-reporter.js.map +1 -0
- package/dist/workflow/audit-scanner.d.ts +87 -0
- package/dist/workflow/audit-scanner.d.ts.map +1 -0
- package/dist/workflow/audit-scanner.js +768 -0
- package/dist/workflow/audit-scanner.js.map +1 -0
- package/dist/workflow/index.d.ts +5 -0
- package/dist/workflow/index.d.ts.map +1 -1
- package/dist/workflow/index.js +5 -0
- package/dist/workflow/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/index.ts +1 -0
- package/src/cli/commands/review.ts +187 -0
- package/src/cli/index.ts +2 -0
- package/src/cli/interactive.ts +72 -4
- package/src/types/audit.ts +294 -0
- package/src/types/workflow.ts +15 -0
- package/src/workflow/audit-analyzer.ts +510 -0
- package/src/workflow/audit-mode.ts +240 -0
- package/src/workflow/audit-recovery.ts +284 -0
- package/src/workflow/audit-reporter.ts +370 -0
- package/src/workflow/audit-scanner.ts +873 -0
- package/src/workflow/index.ts +5 -0
- package/tests/cli/commands/review.test.ts +52 -0
- package/tests/types/audit.test.ts +250 -0
- package/tests/workflow/audit-analyzer.test.ts +281 -0
- package/tests/workflow/audit-mode.test.ts +114 -0
- package/tests/workflow/audit-recovery.test.ts +237 -0
- package/tests/workflow/audit-reporter.test.ts +254 -0
- package/tests/workflow/audit-scanner.test.ts +270 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the audit mode orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* These tests mock the AI analyzer and state modules to validate
|
|
5
|
+
* the orchestration flow without requiring real AI calls.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
8
|
+
import type { AuditModeRunOptions } from '../../src/workflow/audit-mode.js';
|
|
9
|
+
|
|
10
|
+
// Mock the external dependencies before importing the module under test
|
|
11
|
+
vi.mock('../../src/state/index.js', () => ({
|
|
12
|
+
loadProject: vi.fn().mockResolvedValue({
|
|
13
|
+
id: 'test-id',
|
|
14
|
+
name: 'Test Project',
|
|
15
|
+
idea: 'A test project',
|
|
16
|
+
language: 'typescript',
|
|
17
|
+
openaiModel: 'gpt-4',
|
|
18
|
+
phase: 'complete',
|
|
19
|
+
status: 'complete',
|
|
20
|
+
specification: 'Build a todo app',
|
|
21
|
+
milestones: [],
|
|
22
|
+
currentMilestone: null,
|
|
23
|
+
currentTask: null,
|
|
24
|
+
consensusHistory: [],
|
|
25
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
26
|
+
updatedAt: '2024-01-01T00:00:00Z',
|
|
27
|
+
}),
|
|
28
|
+
updateState: vi.fn().mockResolvedValue({}),
|
|
29
|
+
addMilestones: vi.fn().mockResolvedValue({}),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock('../../src/adapters/claude.js', () => ({
|
|
33
|
+
executePrompt: vi.fn().mockResolvedValue({
|
|
34
|
+
success: true,
|
|
35
|
+
response: '```json\n[]\n```',
|
|
36
|
+
toolCalls: [],
|
|
37
|
+
}),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
// Import after mocks are set up
|
|
41
|
+
const { runAuditMode } = await import('../../src/workflow/audit-mode.js');
|
|
42
|
+
const { loadProject, updateState } = await import('../../src/state/index.js');
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
vi.clearAllMocks();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function makeOptions(overrides: Partial<AuditModeRunOptions> = {}): AuditModeRunOptions {
|
|
49
|
+
return {
|
|
50
|
+
projectDir: '/tmp/fake-project',
|
|
51
|
+
depth: 2,
|
|
52
|
+
runTests: false,
|
|
53
|
+
strict: false,
|
|
54
|
+
format: 'json',
|
|
55
|
+
autoRecover: false,
|
|
56
|
+
target: 'all',
|
|
57
|
+
...overrides,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// runAuditMode
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
describe('runAuditMode', () => {
|
|
66
|
+
it('should return error result when project cannot be loaded', async () => {
|
|
67
|
+
vi.mocked(loadProject).mockRejectedValueOnce(new Error('Project not found'));
|
|
68
|
+
|
|
69
|
+
const result = await runAuditMode(makeOptions());
|
|
70
|
+
expect(result.success).toBe(false);
|
|
71
|
+
expect(result.error).toContain('Project not found');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should call progress callbacks through stages', async () => {
|
|
75
|
+
const messages: Array<{ stage: string; msg: string }> = [];
|
|
76
|
+
const result = await runAuditMode(
|
|
77
|
+
makeOptions({
|
|
78
|
+
onProgress: (stage, msg) => messages.push({ stage, msg }),
|
|
79
|
+
})
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// Should have stage-1, stage-2, and stage-3 messages
|
|
83
|
+
expect(messages.some((m) => m.stage === 'stage-1')).toBe(true);
|
|
84
|
+
expect(messages.some((m) => m.stage === 'stage-2')).toBe(true);
|
|
85
|
+
expect(messages.some((m) => m.stage === 'stage-3')).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should update state with audit report path', async () => {
|
|
89
|
+
await runAuditMode(makeOptions({ format: 'json' }));
|
|
90
|
+
expect(updateState).toHaveBeenCalledWith(
|
|
91
|
+
'/tmp/fake-project',
|
|
92
|
+
expect.objectContaining({
|
|
93
|
+
auditRunId: expect.any(String),
|
|
94
|
+
auditLastRunAt: expect.any(String),
|
|
95
|
+
})
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should not generate recovery when no trigger conditions met', async () => {
|
|
100
|
+
const result = await runAuditMode(makeOptions());
|
|
101
|
+
// With empty findings from mock, no recovery should be triggered
|
|
102
|
+
expect(result.recovery).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should have a valid audit report structure', async () => {
|
|
106
|
+
const result = await runAuditMode(makeOptions());
|
|
107
|
+
expect(result.success).toBe(true);
|
|
108
|
+
expect(result.audit).toBeDefined();
|
|
109
|
+
expect(result.audit.projectName).toBe('Test Project');
|
|
110
|
+
expect(result.audit.auditRunId).toBeTruthy();
|
|
111
|
+
expect(result.summary).toBeDefined();
|
|
112
|
+
expect(result.summary.projectName).toBe('Test Project');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the audit recovery module.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
shouldTriggerRecovery,
|
|
7
|
+
generateRecoveryPlan,
|
|
8
|
+
recoveryToMilestones,
|
|
9
|
+
} from '../../src/workflow/audit-recovery.js';
|
|
10
|
+
import type { ProjectAuditReport, AuditFinding, SearchMetadata, AuditCategory } from '../../src/types/audit.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Minimal audit report for testing.
|
|
14
|
+
*/
|
|
15
|
+
function makeReport(overrides: Partial<ProjectAuditReport> = {}): ProjectAuditReport {
|
|
16
|
+
return {
|
|
17
|
+
projectName: 'Test',
|
|
18
|
+
language: 'typescript',
|
|
19
|
+
auditedAt: '2024-01-01T00:00:00Z',
|
|
20
|
+
auditRunId: 'run-test',
|
|
21
|
+
summary: {
|
|
22
|
+
projectName: 'Test',
|
|
23
|
+
language: 'typescript',
|
|
24
|
+
totalSourceFiles: 10,
|
|
25
|
+
totalTestFiles: 3,
|
|
26
|
+
totalLinesOfCode: 500,
|
|
27
|
+
totalLinesOfTests: 100,
|
|
28
|
+
componentCount: 1,
|
|
29
|
+
detectedComposition: ['frontend'],
|
|
30
|
+
entryPointCount: 1,
|
|
31
|
+
routeCount: 1,
|
|
32
|
+
dependencyCount: 5,
|
|
33
|
+
hasDocker: false,
|
|
34
|
+
hasEnvExample: true,
|
|
35
|
+
hasCiConfig: false,
|
|
36
|
+
},
|
|
37
|
+
findings: [],
|
|
38
|
+
overallScore: 90,
|
|
39
|
+
categoryScores: {} as Record<AuditCategory, number>,
|
|
40
|
+
criticalCount: 0,
|
|
41
|
+
majorCount: 0,
|
|
42
|
+
minorCount: 0,
|
|
43
|
+
infoCount: 0,
|
|
44
|
+
passedChecks: [],
|
|
45
|
+
searchMetadata: {
|
|
46
|
+
serenaUsed: false,
|
|
47
|
+
serenaRetries: 0,
|
|
48
|
+
serenaErrors: [],
|
|
49
|
+
fallbackUsed: false,
|
|
50
|
+
fallbackTool: '',
|
|
51
|
+
searchQueries: [],
|
|
52
|
+
},
|
|
53
|
+
recommendation: 'pass',
|
|
54
|
+
...overrides,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeFinding(overrides: Partial<AuditFinding> = {}): AuditFinding {
|
|
59
|
+
return {
|
|
60
|
+
id: 'AUD-001',
|
|
61
|
+
category: 'test-coverage',
|
|
62
|
+
severity: 'major',
|
|
63
|
+
title: 'Low test coverage',
|
|
64
|
+
description: 'Coverage is below threshold.',
|
|
65
|
+
evidence: [{ file: 'src/auth/login.ts', description: 'No tests' }],
|
|
66
|
+
recommendation: 'Add tests for auth module.',
|
|
67
|
+
autoFixable: false,
|
|
68
|
+
...overrides,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// shouldTriggerRecovery
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
describe('shouldTriggerRecovery', () => {
|
|
77
|
+
it('should trigger when critical findings exist', () => {
|
|
78
|
+
const report = makeReport({ criticalCount: 1, overallScore: 80 });
|
|
79
|
+
expect(shouldTriggerRecovery(report, false)).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should trigger in strict mode when major findings exist', () => {
|
|
83
|
+
const report = makeReport({ majorCount: 1, criticalCount: 0, overallScore: 85 });
|
|
84
|
+
expect(shouldTriggerRecovery(report, true)).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should NOT trigger in non-strict mode for major-only findings with high score', () => {
|
|
88
|
+
const report = makeReport({ majorCount: 1, criticalCount: 0, overallScore: 85 });
|
|
89
|
+
expect(shouldTriggerRecovery(report, false)).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should trigger when score is low and actionable findings exist', () => {
|
|
93
|
+
const findings = [makeFinding({ autoFixable: true })];
|
|
94
|
+
const report = makeReport({ overallScore: 60, findings, criticalCount: 0, majorCount: 0 });
|
|
95
|
+
expect(shouldTriggerRecovery(report, false)).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should trigger when score is low and category is actionable', () => {
|
|
99
|
+
const findings = [makeFinding({ category: 'integration-wiring', autoFixable: false })];
|
|
100
|
+
const report = makeReport({ overallScore: 60, findings, criticalCount: 0, majorCount: 0 });
|
|
101
|
+
expect(shouldTriggerRecovery(report, false)).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should NOT trigger for info-only findings', () => {
|
|
105
|
+
const findings = [makeFinding({ severity: 'info', category: 'documentation' })];
|
|
106
|
+
const report = makeReport({ overallScore: 90, findings, infoCount: 1, criticalCount: 0, majorCount: 0 });
|
|
107
|
+
expect(shouldTriggerRecovery(report, false)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should NOT trigger when everything passes', () => {
|
|
111
|
+
const report = makeReport({ overallScore: 95, criticalCount: 0, majorCount: 0 });
|
|
112
|
+
expect(shouldTriggerRecovery(report, false)).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// generateRecoveryPlan
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
describe('generateRecoveryPlan', () => {
|
|
121
|
+
it('should create separate milestone for critical findings', () => {
|
|
122
|
+
const findings = [
|
|
123
|
+
makeFinding({ id: 'AUD-001', severity: 'critical', title: 'XSS vulnerability' }),
|
|
124
|
+
makeFinding({ id: 'AUD-002', severity: 'major', title: 'Low coverage' }),
|
|
125
|
+
];
|
|
126
|
+
const report = makeReport({ findings, criticalCount: 1, majorCount: 1 });
|
|
127
|
+
const plan = generateRecoveryPlan(report);
|
|
128
|
+
|
|
129
|
+
expect(plan.milestones.length).toBeGreaterThanOrEqual(2);
|
|
130
|
+
expect(plan.milestones[0].name).toContain('[RECOVERY] Critical Fixes');
|
|
131
|
+
expect(plan.milestones[0].tasks).toHaveLength(1);
|
|
132
|
+
expect(plan.milestones[1].name).toContain('[RECOVERY] Major Improvements');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should include polish milestone for auto-fixable minor findings', () => {
|
|
136
|
+
const findings = [
|
|
137
|
+
makeFinding({ id: 'AUD-001', severity: 'minor', autoFixable: true, title: 'Missing README' }),
|
|
138
|
+
];
|
|
139
|
+
const report = makeReport({ findings, minorCount: 1 });
|
|
140
|
+
const plan = generateRecoveryPlan(report);
|
|
141
|
+
|
|
142
|
+
const polishMs = plan.milestones.find((m) => m.name.includes('Polish'));
|
|
143
|
+
expect(polishMs).toBeDefined();
|
|
144
|
+
expect(polishMs?.tasks).toHaveLength(1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should NOT include non-autoFixable minor findings in polish', () => {
|
|
148
|
+
const findings = [
|
|
149
|
+
makeFinding({ id: 'AUD-001', severity: 'minor', autoFixable: false }),
|
|
150
|
+
];
|
|
151
|
+
const report = makeReport({ findings, minorCount: 1 });
|
|
152
|
+
const plan = generateRecoveryPlan(report);
|
|
153
|
+
|
|
154
|
+
expect(plan.milestones).toHaveLength(0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should set appTarget on every recovery task', () => {
|
|
158
|
+
const findings = [
|
|
159
|
+
makeFinding({
|
|
160
|
+
id: 'AUD-001',
|
|
161
|
+
severity: 'critical',
|
|
162
|
+
evidence: [{ file: 'apps/backend/main.py', description: 'Issue here' }],
|
|
163
|
+
}),
|
|
164
|
+
];
|
|
165
|
+
const report = makeReport({ findings, criticalCount: 1 });
|
|
166
|
+
const plan = generateRecoveryPlan(report);
|
|
167
|
+
|
|
168
|
+
for (const ms of plan.milestones) {
|
|
169
|
+
for (const task of ms.tasks) {
|
|
170
|
+
expect(task.appTarget).toBeTruthy();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
expect(plan.milestones[0].tasks[0].appTarget).toBe('backend');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should provide estimated effort', () => {
|
|
177
|
+
const findings = [
|
|
178
|
+
makeFinding({ id: 'AUD-001', severity: 'critical' }),
|
|
179
|
+
];
|
|
180
|
+
const report = makeReport({ findings, criticalCount: 1 });
|
|
181
|
+
const plan = generateRecoveryPlan(report);
|
|
182
|
+
expect(plan.estimatedEffort).toBeTruthy();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// recoveryToMilestones
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
describe('recoveryToMilestones', () => {
|
|
191
|
+
it('should convert recovery milestones to the correct shape', () => {
|
|
192
|
+
const findings = [
|
|
193
|
+
makeFinding({ id: 'AUD-001', severity: 'critical', title: 'Fix auth' }),
|
|
194
|
+
];
|
|
195
|
+
const report = makeReport({ findings, criticalCount: 1 });
|
|
196
|
+
const plan = generateRecoveryPlan(report);
|
|
197
|
+
const milestones = recoveryToMilestones(plan, 'typescript');
|
|
198
|
+
|
|
199
|
+
expect(milestones).toHaveLength(1);
|
|
200
|
+
expect(milestones[0].name).toContain('[RECOVERY]');
|
|
201
|
+
expect(milestones[0].status).toBe('pending');
|
|
202
|
+
expect(milestones[0].tasks.length).toBeGreaterThan(0);
|
|
203
|
+
|
|
204
|
+
for (const task of milestones[0].tasks) {
|
|
205
|
+
expect(task.status).toBe('pending');
|
|
206
|
+
expect(task.name).toBeTruthy();
|
|
207
|
+
expect(task.description).toBeTruthy();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should preserve [RECOVERY] prefix on milestone names', () => {
|
|
212
|
+
const findings = [
|
|
213
|
+
makeFinding({ id: 'AUD-001', severity: 'major' }),
|
|
214
|
+
makeFinding({ id: 'AUD-002', severity: 'minor', autoFixable: true }),
|
|
215
|
+
];
|
|
216
|
+
const report = makeReport({ findings, majorCount: 1, minorCount: 1 });
|
|
217
|
+
const plan = generateRecoveryPlan(report);
|
|
218
|
+
const milestones = recoveryToMilestones(plan, 'python');
|
|
219
|
+
|
|
220
|
+
for (const ms of milestones) {
|
|
221
|
+
expect(ms.name.startsWith('[RECOVERY]')).toBe(true);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should include acceptance criteria in task description', () => {
|
|
226
|
+
const findings = [
|
|
227
|
+
makeFinding({ id: 'AUD-001', severity: 'critical', recommendation: 'Use parameterized queries' }),
|
|
228
|
+
];
|
|
229
|
+
const report = makeReport({ findings, criticalCount: 1 });
|
|
230
|
+
const plan = generateRecoveryPlan(report);
|
|
231
|
+
const milestones = recoveryToMilestones(plan, 'typescript');
|
|
232
|
+
|
|
233
|
+
const taskDesc = milestones[0].tasks[0].description;
|
|
234
|
+
expect(taskDesc).toContain('Acceptance criteria');
|
|
235
|
+
expect(taskDesc).toContain('parameterized queries');
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the audit reporter module.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
import { promises as fs } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import {
|
|
9
|
+
buildSummaryReport,
|
|
10
|
+
buildAuditReport,
|
|
11
|
+
writeAuditMarkdown,
|
|
12
|
+
writeAuditJson,
|
|
13
|
+
writeRecoveryMarkdown,
|
|
14
|
+
writeRecoveryJson,
|
|
15
|
+
} from '../../src/workflow/audit-reporter.js';
|
|
16
|
+
import type { ProjectScanResult, AuditFinding, SearchMetadata, RecoveryPlan } from '../../src/types/audit.js';
|
|
17
|
+
import type { ProjectState } from '../../src/types/workflow.js';
|
|
18
|
+
|
|
19
|
+
let tmpDir: string;
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'audit-report-'));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function makeScan(overrides: Partial<ProjectScanResult> = {}): ProjectScanResult {
|
|
30
|
+
return {
|
|
31
|
+
tree: 'src/',
|
|
32
|
+
components: [],
|
|
33
|
+
detectedComposition: ['frontend'],
|
|
34
|
+
stateLanguage: 'typescript',
|
|
35
|
+
compositionMismatch: false,
|
|
36
|
+
sourceFiles: [],
|
|
37
|
+
testFiles: [],
|
|
38
|
+
configFiles: ['package.json', 'docker-compose.yml'],
|
|
39
|
+
entryPoints: ['src/main.ts'],
|
|
40
|
+
routeFiles: ['src/routes.ts'],
|
|
41
|
+
dependencies: [
|
|
42
|
+
{ file: 'package.json', type: 'package.json', dependencies: { react: '^18' }, devDependencies: { vitest: '^1' } },
|
|
43
|
+
],
|
|
44
|
+
totalSourceFiles: 15,
|
|
45
|
+
totalTestFiles: 5,
|
|
46
|
+
totalLinesOfCode: 800,
|
|
47
|
+
totalLinesOfTests: 200,
|
|
48
|
+
language: 'typescript',
|
|
49
|
+
docsIndex: [],
|
|
50
|
+
keyFileSnippets: [],
|
|
51
|
+
...overrides,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeState(): ProjectState {
|
|
56
|
+
return {
|
|
57
|
+
id: 'test-id',
|
|
58
|
+
name: 'Test Project',
|
|
59
|
+
idea: 'A test project',
|
|
60
|
+
language: 'typescript',
|
|
61
|
+
openaiModel: 'gpt-4',
|
|
62
|
+
phase: 'complete',
|
|
63
|
+
status: 'complete',
|
|
64
|
+
milestones: [],
|
|
65
|
+
currentMilestone: null,
|
|
66
|
+
currentTask: null,
|
|
67
|
+
consensusHistory: [],
|
|
68
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
69
|
+
updatedAt: '2024-01-01T00:00:00Z',
|
|
70
|
+
} as ProjectState;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeMeta(): SearchMetadata {
|
|
74
|
+
return {
|
|
75
|
+
serenaUsed: true,
|
|
76
|
+
serenaRetries: 0,
|
|
77
|
+
serenaErrors: [],
|
|
78
|
+
fallbackUsed: false,
|
|
79
|
+
fallbackTool: '',
|
|
80
|
+
searchQueries: ['audit-analysis'],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// buildSummaryReport
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
describe('buildSummaryReport', () => {
|
|
89
|
+
it('should produce a correct summary', () => {
|
|
90
|
+
const summary = buildSummaryReport(makeScan(), makeState());
|
|
91
|
+
expect(summary.projectName).toBe('Test Project');
|
|
92
|
+
expect(summary.totalSourceFiles).toBe(15);
|
|
93
|
+
expect(summary.totalTestFiles).toBe(5);
|
|
94
|
+
expect(summary.dependencyCount).toBe(2); // react + vitest
|
|
95
|
+
expect(summary.hasDocker).toBe(true);
|
|
96
|
+
expect(summary.entryPointCount).toBe(1);
|
|
97
|
+
expect(summary.routeCount).toBe(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should include AI overview when provided', () => {
|
|
101
|
+
const summary = buildSummaryReport(makeScan(), makeState(), 'Looks good overall.');
|
|
102
|
+
expect(summary.aiOverview).toBe('Looks good overall.');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// buildAuditReport
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
describe('buildAuditReport', () => {
|
|
111
|
+
it('should produce a report with correct severity counts', () => {
|
|
112
|
+
const findings: AuditFinding[] = [
|
|
113
|
+
{ id: 'AUD-001', category: 'security', severity: 'critical', title: 'XSS', description: 'Found XSS.', evidence: [], recommendation: 'Sanitize.', autoFixable: false },
|
|
114
|
+
{ id: 'AUD-002', category: 'test-coverage', severity: 'major', title: 'Low coverage', description: 'Only 30%.', evidence: [], recommendation: 'Add tests.', autoFixable: false },
|
|
115
|
+
{ id: 'AUD-003', category: 'documentation', severity: 'info', title: 'Missing comments', description: 'No JSDoc.', evidence: [], recommendation: 'Add JSDoc.', autoFixable: true },
|
|
116
|
+
];
|
|
117
|
+
const scores = { overallScore: 72, categoryScores: {} as Record<string, number> };
|
|
118
|
+
const report = buildAuditReport(
|
|
119
|
+
buildSummaryReport(makeScan(), makeState()),
|
|
120
|
+
findings, scores as any, makeMeta(), { strict: false }, 'run-123'
|
|
121
|
+
);
|
|
122
|
+
expect(report.criticalCount).toBe(1);
|
|
123
|
+
expect(report.majorCount).toBe(1);
|
|
124
|
+
expect(report.infoCount).toBe(1);
|
|
125
|
+
expect(report.auditRunId).toBe('run-123');
|
|
126
|
+
expect(report.recommendation).toBe('fix-and-recheck');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should recommend pass when no critical and few major findings', () => {
|
|
130
|
+
const findings: AuditFinding[] = [
|
|
131
|
+
{ id: 'AUD-001', category: 'documentation', severity: 'minor', title: 'Minor', description: 'Small.', evidence: [], recommendation: 'OK.', autoFixable: true },
|
|
132
|
+
];
|
|
133
|
+
const scores = { overallScore: 95, categoryScores: {} as Record<string, number> };
|
|
134
|
+
const report = buildAuditReport(
|
|
135
|
+
buildSummaryReport(makeScan(), makeState()),
|
|
136
|
+
findings, scores as any, makeMeta(), { strict: false }, 'run-456'
|
|
137
|
+
);
|
|
138
|
+
expect(report.recommendation).toBe('pass');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should recommend major-rework for many critical findings', () => {
|
|
142
|
+
const findings = Array.from({ length: 4 }, (_, i) => ({
|
|
143
|
+
id: `AUD-${i}`,
|
|
144
|
+
category: 'security' as const,
|
|
145
|
+
severity: 'critical' as const,
|
|
146
|
+
title: `Critical ${i}`,
|
|
147
|
+
description: 'Bad.',
|
|
148
|
+
evidence: [],
|
|
149
|
+
recommendation: 'Fix.',
|
|
150
|
+
autoFixable: false,
|
|
151
|
+
}));
|
|
152
|
+
const scores = { overallScore: 30, categoryScores: {} as Record<string, number> };
|
|
153
|
+
const report = buildAuditReport(
|
|
154
|
+
buildSummaryReport(makeScan(), makeState()),
|
|
155
|
+
findings, scores as any, makeMeta(), { strict: false }, 'run-789'
|
|
156
|
+
);
|
|
157
|
+
expect(report.recommendation).toBe('major-rework');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should include search metadata in report', () => {
|
|
161
|
+
const meta = makeMeta();
|
|
162
|
+
meta.serenaUsed = true;
|
|
163
|
+
meta.serenaRetries = 1;
|
|
164
|
+
const report = buildAuditReport(
|
|
165
|
+
buildSummaryReport(makeScan(), makeState()),
|
|
166
|
+
[], { overallScore: 100, categoryScores: {} as any }, meta, { strict: false }, 'run-x'
|
|
167
|
+
);
|
|
168
|
+
expect(report.searchMetadata.serenaUsed).toBe(true);
|
|
169
|
+
expect(report.searchMetadata.serenaRetries).toBe(1);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// File writers
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
describe('writeAuditMarkdown', () => {
|
|
178
|
+
it('should write a valid markdown file', async () => {
|
|
179
|
+
const report = buildAuditReport(
|
|
180
|
+
buildSummaryReport(makeScan(), makeState()),
|
|
181
|
+
[], { overallScore: 100, categoryScores: {} as any }, makeMeta(), { strict: false }, 'run-1'
|
|
182
|
+
);
|
|
183
|
+
const filePath = await writeAuditMarkdown(tmpDir, report);
|
|
184
|
+
expect(filePath).toContain('popeye.audit.md');
|
|
185
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
186
|
+
expect(content).toContain('# Audit Report');
|
|
187
|
+
expect(content).toContain('Test Project');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('writeAuditJson', () => {
|
|
192
|
+
it('should write valid JSON', async () => {
|
|
193
|
+
const report = buildAuditReport(
|
|
194
|
+
buildSummaryReport(makeScan(), makeState()),
|
|
195
|
+
[], { overallScore: 100, categoryScores: {} as any }, makeMeta(), { strict: false }, 'run-2'
|
|
196
|
+
);
|
|
197
|
+
const filePath = await writeAuditJson(tmpDir, report);
|
|
198
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
199
|
+
const parsed = JSON.parse(content);
|
|
200
|
+
expect(parsed.projectName).toBe('Test Project');
|
|
201
|
+
expect(parsed.overallScore).toBe(100);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('writeRecoveryMarkdown', () => {
|
|
206
|
+
it('should write recovery plan markdown', async () => {
|
|
207
|
+
const recovery: RecoveryPlan = {
|
|
208
|
+
generatedAt: new Date().toISOString(),
|
|
209
|
+
auditScore: 60,
|
|
210
|
+
auditRunId: 'run-3',
|
|
211
|
+
totalFindings: 5,
|
|
212
|
+
criticalFindings: 1,
|
|
213
|
+
milestones: [
|
|
214
|
+
{
|
|
215
|
+
name: '[RECOVERY] Critical Fixes',
|
|
216
|
+
description: 'Fix critical issues.',
|
|
217
|
+
tasks: [
|
|
218
|
+
{
|
|
219
|
+
name: 'Fix SQL injection',
|
|
220
|
+
description: 'Sanitize inputs.',
|
|
221
|
+
findingIds: ['AUD-001'],
|
|
222
|
+
acceptanceCriteria: ['No raw SQL in handlers'],
|
|
223
|
+
appTarget: 'backend',
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
estimatedEffort: '2-4 hours',
|
|
229
|
+
};
|
|
230
|
+
const filePath = await writeRecoveryMarkdown(tmpDir, recovery);
|
|
231
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
232
|
+
expect(content).toContain('# Recovery Plan');
|
|
233
|
+
expect(content).toContain('[RECOVERY] Critical Fixes');
|
|
234
|
+
expect(content).toContain('Fix SQL injection');
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('writeRecoveryJson', () => {
|
|
239
|
+
it('should write valid recovery JSON', async () => {
|
|
240
|
+
const recovery: RecoveryPlan = {
|
|
241
|
+
generatedAt: new Date().toISOString(),
|
|
242
|
+
auditScore: 60,
|
|
243
|
+
auditRunId: 'run-4',
|
|
244
|
+
totalFindings: 2,
|
|
245
|
+
criticalFindings: 0,
|
|
246
|
+
milestones: [],
|
|
247
|
+
estimatedEffort: '1 hour',
|
|
248
|
+
};
|
|
249
|
+
const filePath = await writeRecoveryJson(tmpDir, recovery);
|
|
250
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
251
|
+
const parsed = JSON.parse(content);
|
|
252
|
+
expect(parsed.auditRunId).toBe('run-4');
|
|
253
|
+
});
|
|
254
|
+
});
|