@rahul05ranjan/dhruv-cli 1.4.6 ā 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +18 -238
- package/.github/workflows/contribution.yml +6 -141
- package/.github/workflows/dependabot-auto-merge.yml +1 -0
- package/.github/workflows/labeler.yml +1 -0
- package/.github/workflows/security.yml +3 -0
- package/CHANGELOG.md +9 -4
- package/README.md +145 -40
- package/__tests__/cli-contract.test.ts +104 -0
- package/__tests__/core.test.ts +121 -0
- package/__tests__/diagnostics.test.ts +155 -0
- package/__tests__/file-workflows.test.ts +195 -0
- package/__tests__/interactive.test.ts +119 -0
- package/__tests__/setup.ts +1 -0
- package/__tests__/workflows.test.ts +27 -4
- package/dist/commands/generate.d.ts +6 -1
- package/dist/commands/generate.js +18 -4
- package/dist/commands/health.d.ts +4 -1
- package/dist/commands/health.js +59 -16
- package/dist/commands/init.js +11 -2
- package/dist/commands/menu.js +125 -100
- package/dist/commands/metrics.d.ts +5 -1
- package/dist/commands/metrics.js +37 -8
- package/dist/commands/optimize.js +1 -1
- package/dist/commands/review.d.ts +4 -1
- package/dist/commands/review.js +48 -8
- package/dist/commands/security-check.d.ts +4 -1
- package/dist/commands/security-check.js +67 -6
- package/dist/commands/status.js +40 -2
- package/dist/config/config.d.ts +5 -1
- package/dist/config/config.js +24 -7
- package/dist/core/ai.d.ts +11 -0
- package/dist/core/ai.js +33 -9
- package/dist/core/command-catalog.d.ts +10 -0
- package/dist/core/command-catalog.js +27 -0
- package/dist/core/command-runner.js +100 -21
- package/dist/core/logger.js +1 -0
- package/dist/core/metrics.d.ts +28 -0
- package/dist/core/metrics.js +78 -0
- package/dist/index.js +46 -24
- package/dist/utils/projectType.d.ts +1 -1
- package/dist/utils/projectType.js +26 -7
- package/docs/api/assets/highlight.css +4 -4
- package/docs/api/index.html +161 -39
- package/docs/api/media/CONTRIBUTING.md +60 -0
- package/docs/api/media/SECURITY.md +8 -0
- package/docs/api/media/dhruv-cli-preview.svg +42 -0
- package/docs/api/media/publishing-fix.md +34 -0
- package/docs/dhruv-cli-preview.svg +42 -0
- package/docs/index.html +631 -533
- package/docs/publishing-fix.md +34 -0
- package/package.json +1 -1
- package/src/commands/generate.ts +23 -4
- package/src/commands/health.ts +62 -17
- package/src/commands/init.ts +11 -2
- package/src/commands/menu.ts +54 -30
- package/src/commands/metrics.ts +42 -7
- package/src/commands/optimize.ts +1 -1
- package/src/commands/review.ts +53 -8
- package/src/commands/security-check.ts +80 -6
- package/src/commands/status.ts +39 -3
- package/src/config/config.ts +26 -7
- package/src/core/ai.ts +36 -8
- package/src/core/command-catalog.ts +37 -0
- package/src/core/command-runner.ts +102 -22
- package/src/core/logger.ts +1 -0
- package/src/core/metrics.ts +103 -0
- package/src/index.ts +45 -24
- package/src/utils/projectType.ts +22 -7
- package/tsconfig.json +1 -1
- package/.github/workflows/auto-assign.yml +0 -14
- package/.github/workflows/build-publish.yml +0 -154
- package/.github/workflows/deploy.yml +0 -336
- package/.github/workflows/monitoring.yml +0 -270
- package/PUBLISHING_FIX.md +0 -92
- package/logs/.8a99b6cf655346317fdbf29f4fffcf91131432f3-audit.json +0 -15
- package/logs/.eee104bf8fff5ecd38a6a2842df260de6470a7c3-audit.json +0 -15
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, expect, it, jest, beforeEach, afterEach } from '@jest/globals';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { review } from '../src/commands/review';
|
|
6
|
+
import { securityCheck } from '../src/commands/security-check';
|
|
7
|
+
import { generate } from '../src/commands/generate';
|
|
8
|
+
import { optimize } from '../src/commands/optimize';
|
|
9
|
+
import { setAIClient } from '../src/core/ai';
|
|
10
|
+
import type { AIClient, AIRequest } from '../src/core/ai';
|
|
11
|
+
|
|
12
|
+
jest.mock('chalk', () => {
|
|
13
|
+
const identity = (value: unknown) => String(value);
|
|
14
|
+
const makeChalk = (): unknown => new Proxy(identity, {
|
|
15
|
+
get: (_target, property: string | symbol) => property === 'level' ? 0 : makeChalk(),
|
|
16
|
+
apply: (_target, _thisArg, args: unknown[]) => String(args[0]),
|
|
17
|
+
});
|
|
18
|
+
const chalk = makeChalk() as Record<string, unknown>;
|
|
19
|
+
return { __esModule: true, default: chalk, ...chalk };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
jest.mock('ora', () => ({
|
|
23
|
+
__esModule: true,
|
|
24
|
+
default: jest.fn(() => ({
|
|
25
|
+
start: jest.fn().mockReturnThis(),
|
|
26
|
+
stop: jest.fn().mockReturnThis(),
|
|
27
|
+
})),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
jest.mock('../src/utils/ux', () => ({
|
|
31
|
+
printError: jest.fn(),
|
|
32
|
+
printSuccess: jest.fn(),
|
|
33
|
+
printWarning: jest.fn(),
|
|
34
|
+
printInfo: jest.fn(),
|
|
35
|
+
createSpinner: jest.fn(),
|
|
36
|
+
themed: jest.fn((value: string) => value),
|
|
37
|
+
highlightCode: jest.fn((value: string) => value),
|
|
38
|
+
createProgressBar: jest.fn(),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
jest.mock('../src/core/logger', () => ({
|
|
42
|
+
logger: {
|
|
43
|
+
info: jest.fn(),
|
|
44
|
+
warn: jest.fn(),
|
|
45
|
+
error: jest.fn(),
|
|
46
|
+
debug: jest.fn(),
|
|
47
|
+
command: jest.fn(),
|
|
48
|
+
performance: jest.fn(),
|
|
49
|
+
security: jest.fn(),
|
|
50
|
+
getSessionId: jest.fn(() => 'test-session'),
|
|
51
|
+
flush: jest.fn(),
|
|
52
|
+
},
|
|
53
|
+
logCommand: jest.fn(),
|
|
54
|
+
logPerformance: jest.fn(),
|
|
55
|
+
logSecurity: jest.fn(),
|
|
56
|
+
logError: jest.fn(),
|
|
57
|
+
logInfo: jest.fn(),
|
|
58
|
+
logWarn: jest.fn(),
|
|
59
|
+
logDebug: jest.fn(),
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
class RecordingClient implements AIClient {
|
|
63
|
+
requests: AIRequest[] = [];
|
|
64
|
+
|
|
65
|
+
async ask(request: AIRequest): Promise<string> {
|
|
66
|
+
this.requests.push(request);
|
|
67
|
+
return 'review complete';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async listModels(): Promise<string[]> {
|
|
71
|
+
return ['test-model'];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('file analysis commands', () => {
|
|
76
|
+
let root: string;
|
|
77
|
+
let client: RecordingClient;
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), 'dhruv-review-'));
|
|
81
|
+
client = new RecordingClient();
|
|
82
|
+
setAIClient(client);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('reviews nested source files without sending dependency directories', async () => {
|
|
90
|
+
fs.mkdirSync(path.join(root, 'src', 'nested'), { recursive: true });
|
|
91
|
+
fs.mkdirSync(path.join(root, 'node_modules', 'library'), { recursive: true });
|
|
92
|
+
fs.writeFileSync(path.join(root, 'src', 'nested', 'feature.ts'), 'export const feature = true;');
|
|
93
|
+
fs.writeFileSync(path.join(root, 'node_modules', 'library', 'ignored.ts'), 'export const ignored = true;');
|
|
94
|
+
|
|
95
|
+
await review(root);
|
|
96
|
+
|
|
97
|
+
expect(client.requests).toHaveLength(1);
|
|
98
|
+
expect(client.requests[0].prompt).toContain('src/nested/feature.ts');
|
|
99
|
+
expect(client.requests[0].prompt).not.toContain('node_modules/library/ignored.ts');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('includes detected project context in review requests', async () => {
|
|
103
|
+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ devDependencies: { typescript: '^5.0.0' } }));
|
|
104
|
+
fs.writeFileSync(path.join(root, 'index.ts'), 'export const value = 1;');
|
|
105
|
+
|
|
106
|
+
await review(root);
|
|
107
|
+
|
|
108
|
+
expect(client.requests[0].prompt).toContain('node-typescript');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('redacts credential-like values before security analysis', async () => {
|
|
112
|
+
const source = path.join(root, 'config.ts');
|
|
113
|
+
fs.writeFileSync(source, 'const API_KEY = "sk-live-super-secret";');
|
|
114
|
+
|
|
115
|
+
await securityCheck(source);
|
|
116
|
+
|
|
117
|
+
expect(client.requests).toHaveLength(1);
|
|
118
|
+
expect(client.requests[0].prompt).not.toContain('sk-live-super-secret');
|
|
119
|
+
expect(client.requests[0].prompt).toContain('[REDACTED]');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('includes location and remediation for deterministic security findings', async () => {
|
|
123
|
+
const source = path.join(root, 'config.ts');
|
|
124
|
+
fs.writeFileSync(source, 'const API_KEY = "sk-live-super-secret";');
|
|
125
|
+
|
|
126
|
+
await securityCheck(source);
|
|
127
|
+
|
|
128
|
+
expect(client.requests[0].prompt).toContain('line 1');
|
|
129
|
+
expect(client.requests[0].prompt).toContain('rotate the credential');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('scans nested project files without sending dependency directories', async () => {
|
|
133
|
+
fs.mkdirSync(path.join(root, 'src', 'nested'), { recursive: true });
|
|
134
|
+
fs.mkdirSync(path.join(root, 'node_modules', 'library'), { recursive: true });
|
|
135
|
+
fs.writeFileSync(path.join(root, 'src', 'nested', 'config.ts'), 'const API_KEY = "sk-live-super-secret";');
|
|
136
|
+
fs.writeFileSync(path.join(root, 'node_modules', 'library', 'ignored.ts'), 'const API_KEY = "sk-dependency-secret";');
|
|
137
|
+
|
|
138
|
+
await securityCheck(root);
|
|
139
|
+
|
|
140
|
+
expect(client.requests[0].prompt).toContain('src/nested/config.ts');
|
|
141
|
+
expect(client.requests[0].prompt).not.toContain('sk-dependency-secret');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('sets a failing exit code in strict mode for high-confidence findings', async () => {
|
|
145
|
+
const source = path.join(root, 'unsafe.ts');
|
|
146
|
+
fs.writeFileSync(source, 'const API_KEY = "sk-live-super-secret";');
|
|
147
|
+
process.exitCode = undefined;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
await securityCheck(source, { strict: true });
|
|
151
|
+
expect(process.exitCode).toBe(1);
|
|
152
|
+
} finally {
|
|
153
|
+
process.exitCode = undefined;
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('does not overwrite an existing generated file', async () => {
|
|
158
|
+
const source = path.join(root, 'sample.ts');
|
|
159
|
+
const generated = path.join(root, 'sample.test.ts');
|
|
160
|
+
fs.writeFileSync(source, 'export const value = 1;');
|
|
161
|
+
fs.writeFileSync(generated, 'keep this work');
|
|
162
|
+
|
|
163
|
+
await generate('tests', source, { apply: true });
|
|
164
|
+
|
|
165
|
+
expect(fs.readFileSync(generated, 'utf8')).toBe('keep this work');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('preserves the target language when generating tests', async () => {
|
|
169
|
+
const source = path.join(root, 'sample.ts');
|
|
170
|
+
fs.writeFileSync(source, 'export const value = 1;');
|
|
171
|
+
|
|
172
|
+
await generate('tests', source, { apply: true });
|
|
173
|
+
|
|
174
|
+
expect(fs.existsSync(path.join(root, 'sample.test.ts'))).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('previews generated tests without writing by default', async () => {
|
|
178
|
+
const source = path.join(root, 'preview.ts');
|
|
179
|
+
fs.writeFileSync(source, 'export const value = 1;');
|
|
180
|
+
|
|
181
|
+
await generate('tests', source);
|
|
182
|
+
|
|
183
|
+
expect(fs.existsSync(path.join(root, 'preview.test.ts'))).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('asks optimization responses to explain impact and trade-offs', async () => {
|
|
187
|
+
const source = path.join(root, 'sample.ts');
|
|
188
|
+
fs.writeFileSync(source, 'export const value = 1;');
|
|
189
|
+
|
|
190
|
+
await optimize(source);
|
|
191
|
+
|
|
192
|
+
expect(client.requests[0].prompt).toContain('expected impact');
|
|
193
|
+
expect(client.requests[0].prompt).toContain('trade-offs');
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from '@jest/globals';
|
|
2
|
+
import inquirer from 'inquirer';
|
|
3
|
+
import { menu } from '../src/commands/menu';
|
|
4
|
+
import { init } from '../src/commands/init';
|
|
5
|
+
import { listModels } from '../src/core/ai';
|
|
6
|
+
|
|
7
|
+
jest.mock('chalk', () => {
|
|
8
|
+
const identity = (value: unknown) => String(value);
|
|
9
|
+
const makeChalk = (): unknown => new Proxy(identity, {
|
|
10
|
+
get: (_target, property: string | symbol) => property === 'level' ? 0 : makeChalk(),
|
|
11
|
+
apply: (_target, _thisArg, args: unknown[]) => String(args[0]),
|
|
12
|
+
});
|
|
13
|
+
const chalk = makeChalk() as Record<string, unknown>;
|
|
14
|
+
return { __esModule: true, default: chalk, ...chalk };
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
jest.mock('ora', () => ({
|
|
18
|
+
__esModule: true,
|
|
19
|
+
default: jest.fn(() => ({
|
|
20
|
+
start: jest.fn().mockReturnThis(),
|
|
21
|
+
stop: jest.fn().mockReturnThis(),
|
|
22
|
+
})),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
jest.mock('../src/utils/ux', () => ({
|
|
26
|
+
printError: jest.fn(),
|
|
27
|
+
printSuccess: jest.fn(),
|
|
28
|
+
printWarning: jest.fn(),
|
|
29
|
+
printInfo: jest.fn(),
|
|
30
|
+
createSpinner: jest.fn(),
|
|
31
|
+
themed: jest.fn((value: string) => value),
|
|
32
|
+
highlightCode: jest.fn((value: string) => value),
|
|
33
|
+
createProgressBar: jest.fn(),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
jest.mock('../src/core/ai', () => ({
|
|
37
|
+
listModels: jest.fn(),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
jest.mock('inquirer', () => ({
|
|
41
|
+
__esModule: true,
|
|
42
|
+
default: { prompt: jest.fn() },
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
describe('interactive commands', () => {
|
|
46
|
+
it('handles menu cancellation without an unhandled rejection', async () => {
|
|
47
|
+
const prompt = jest.mocked(inquirer.prompt);
|
|
48
|
+
prompt.mockRejectedValueOnce(new Error('User force closed the prompt with 0 null'));
|
|
49
|
+
process.exitCode = undefined;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await menu();
|
|
53
|
+
expect(process.exitCode).toBe(130);
|
|
54
|
+
} finally {
|
|
55
|
+
prompt.mockReset();
|
|
56
|
+
process.exitCode = undefined;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('handles init cancellation with a cancellation exit code', async () => {
|
|
61
|
+
jest.mocked(listModels).mockResolvedValue(['test-model']);
|
|
62
|
+
const prompt = jest.mocked(inquirer.prompt);
|
|
63
|
+
prompt.mockRejectedValueOnce(new Error('User force closed the prompt with 0 null'));
|
|
64
|
+
process.exitCode = undefined;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
await init();
|
|
68
|
+
expect(process.exitCode).toBe(130);
|
|
69
|
+
} finally {
|
|
70
|
+
prompt.mockReset();
|
|
71
|
+
process.exitCode = undefined;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('asks whether init should save project-local or user-global settings', async () => {
|
|
76
|
+
jest.mocked(listModels).mockResolvedValue(['test-model']);
|
|
77
|
+
const prompt = jest.mocked(inquirer.prompt);
|
|
78
|
+
prompt.mockResolvedValueOnce({
|
|
79
|
+
model: 'test-model',
|
|
80
|
+
responseFormat: 'text',
|
|
81
|
+
verbose: false,
|
|
82
|
+
theme: 'default',
|
|
83
|
+
scope: 'local',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await init();
|
|
87
|
+
|
|
88
|
+
const questions = prompt.mock.calls[0][0] as unknown as Array<{ name: string; choices?: string[] }>;
|
|
89
|
+
expect(questions.find((question) => question.name === 'scope')?.choices).toEqual(['local', 'global']);
|
|
90
|
+
prompt.mockReset();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('offers diagnostic commands from the interactive menu', async () => {
|
|
94
|
+
const prompt = jest.mocked(inquirer.prompt);
|
|
95
|
+
prompt.mockResolvedValueOnce({ filter: '' }).mockResolvedValueOnce({ cmd: 'exit' });
|
|
96
|
+
|
|
97
|
+
await menu();
|
|
98
|
+
|
|
99
|
+
const choices = (prompt.mock.calls[1][0] as unknown as Array<{ choices: Array<{ value: string }> }>)[0].choices;
|
|
100
|
+
expect(choices.map(choice => choice.value)).toEqual(expect.arrayContaining([
|
|
101
|
+
'status',
|
|
102
|
+
'health',
|
|
103
|
+
'metrics',
|
|
104
|
+
'completion',
|
|
105
|
+
]));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('offers command filtering before opening the menu', async () => {
|
|
109
|
+
const prompt = jest.mocked(inquirer.prompt);
|
|
110
|
+
prompt.mockRejectedValueOnce(new Error('User force closed the prompt with 0 null'));
|
|
111
|
+
|
|
112
|
+
await menu();
|
|
113
|
+
|
|
114
|
+
expect((prompt.mock.calls[0][0] as unknown as Array<{ name: string; type: string }>)[0]).toMatchObject({
|
|
115
|
+
name: 'filter',
|
|
116
|
+
type: 'input',
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
package/__tests__/setup.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from '@jest/globals';
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { resolve } from 'node:path';
|
|
4
4
|
import { parse } from 'yaml';
|
|
5
5
|
|
|
@@ -13,7 +13,7 @@ interface Workflow {
|
|
|
13
13
|
on: Record<string, unknown>;
|
|
14
14
|
env?: Record<string, string>;
|
|
15
15
|
permissions?: Record<string, string>;
|
|
16
|
-
jobs: Record<string, { steps: Step[] }>;
|
|
16
|
+
jobs: Record<string, { steps: Step[]; if?: string }>;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
const root = resolve(__dirname, '..');
|
|
@@ -24,6 +24,12 @@ const atLeast = (version: string, minimum: string): boolean =>
|
|
|
24
24
|
/^\d+\.\d+\.\d+$/.test(version) && version.localeCompare(minimum, 'en', { numeric: true }) >= 0;
|
|
25
25
|
|
|
26
26
|
describe('release workflow requirements', () => {
|
|
27
|
+
it('does not retain redundant publishing or deployment workflows', () => {
|
|
28
|
+
for (const name of ['auto-assign.yml', 'build-publish.yml', 'deploy.yml', 'monitoring.yml']) {
|
|
29
|
+
expect(existsSync(resolve(root, '.github/workflows', name))).toBe(false);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
27
33
|
it('installs an OIDC-capable npm CLI where semantic-release looks for executables', () => {
|
|
28
34
|
// The plugin uses preferLocal: true. A global npm upgrade cannot fix an
|
|
29
35
|
// older CLI hoisted here by a conflicting @semantic-release/npm version.
|
|
@@ -31,7 +37,7 @@ describe('release workflow requirements', () => {
|
|
|
31
37
|
expect(atLeast(npm.version, '11.5.1')).toBe(true);
|
|
32
38
|
});
|
|
33
39
|
|
|
34
|
-
it.each(['
|
|
40
|
+
it.each(['release.yml'])(
|
|
35
41
|
'%s provisions a supported Node and npm before publishing',
|
|
36
42
|
name => {
|
|
37
43
|
const config = workflow(name);
|
|
@@ -52,7 +58,7 @@ describe('release workflow requirements', () => {
|
|
|
52
58
|
});
|
|
53
59
|
|
|
54
60
|
it('has one automatic publisher for main pushes', () => {
|
|
55
|
-
const publishers = ['ci.yml', '
|
|
61
|
+
const publishers = ['ci.yml', 'release.yml']
|
|
56
62
|
.filter(name => {
|
|
57
63
|
const config = workflow(name);
|
|
58
64
|
return config.on.push && Object.values(config.jobs).some(job =>
|
|
@@ -62,6 +68,23 @@ describe('release workflow requirements', () => {
|
|
|
62
68
|
});
|
|
63
69
|
});
|
|
64
70
|
|
|
71
|
+
describe('workflow trigger boundaries', () => {
|
|
72
|
+
it('runs branch and pull-request validation only for the default branch', () => {
|
|
73
|
+
for (const name of ['ci.yml', 'contribution.yml', 'labeler.yml', 'dependabot-auto-merge.yml']) {
|
|
74
|
+
const config = workflow(name);
|
|
75
|
+
const event = config.on[name === 'ci.yml' ? 'push' : 'pull_request'] as { branches?: string[] };
|
|
76
|
+
expect(event.branches).toEqual(['main']);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('keeps expensive security jobs off pull-request runs', () => {
|
|
81
|
+
const config = workflow('security.yml');
|
|
82
|
+
for (const name of ['license-check', 'supply-chain', 'sbom-generation']) {
|
|
83
|
+
expect(config.jobs[name]?.if).toBe("github.event_name != 'pull_request'");
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
65
88
|
describe('security workflow requirements', () => {
|
|
66
89
|
it('lets TruffleHog select the commit range for push, PR, schedule and manual events', () => {
|
|
67
90
|
const steps = workflow('security.yml').jobs['secret-scan'].steps;
|
|
@@ -1 +1,6 @@
|
|
|
1
|
-
export
|
|
1
|
+
export interface GenerateOptions {
|
|
2
|
+
apply?: boolean;
|
|
3
|
+
output?: string;
|
|
4
|
+
overwrite?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare function generate(type: string, target: string, options?: GenerateOptions): Promise<void>;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
2
3
|
import { runCommand } from '../core/command-runner.js';
|
|
3
4
|
import { getSystemMessage } from '../core/prompts.js';
|
|
4
|
-
import { printError, printSuccess } from '../utils/ux.js';
|
|
5
|
+
import { printError, printSuccess, printInfo } from '../utils/ux.js';
|
|
6
|
+
import { loadConfig } from '../config/config.js';
|
|
5
7
|
function buildPrompt(type, content) {
|
|
6
8
|
if (type === 'tests' || type === 'test') {
|
|
7
9
|
return `Generate comprehensive unit tests for the following JavaScript code. Use Jest or Mocha syntax. Only return the test code without explanations:\n\n${content}`;
|
|
@@ -21,7 +23,7 @@ function extractTestCode(response) {
|
|
|
21
23
|
.replace(/```[a-z]*\n?/g, '')
|
|
22
24
|
.trim();
|
|
23
25
|
}
|
|
24
|
-
export async function generate(type, target) {
|
|
26
|
+
export async function generate(type, target, options = {}) {
|
|
25
27
|
if (!fs.existsSync(target)) {
|
|
26
28
|
printError(`Target file "${target}" does not exist.`);
|
|
27
29
|
return;
|
|
@@ -44,9 +46,21 @@ export async function generate(type, target) {
|
|
|
44
46
|
printError('No valid test code generated.');
|
|
45
47
|
return;
|
|
46
48
|
}
|
|
47
|
-
const
|
|
49
|
+
const extension = path.extname(target) || '.js';
|
|
50
|
+
const testFile = options.output ?? target.replace(/\.[^.]+$/, `.test${extension}`);
|
|
51
|
+
if (!options.apply && !options.output) {
|
|
52
|
+
if (loadConfig().responseFormat !== 'json') {
|
|
53
|
+
printInfo(`Preview only. Use --apply to write ${testFile}, or --output <path> to choose a destination.`);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (fs.existsSync(testFile) && !options.overwrite) {
|
|
58
|
+
printError(`Test file "${testFile}" already exists. Use --overwrite to replace it.`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
48
61
|
fs.writeFileSync(testFile, codeToSave);
|
|
49
|
-
|
|
62
|
+
if (loadConfig().responseFormat !== 'json')
|
|
63
|
+
printSuccess(`Test file saved: ${testFile}`);
|
|
50
64
|
},
|
|
51
65
|
footer: `š Want a review? Try: dhruv review ${target}`,
|
|
52
66
|
});
|
package/dist/commands/health.js
CHANGED
|
@@ -7,18 +7,21 @@ import { securityManager } from '../core/security.js';
|
|
|
7
7
|
import fs from 'fs';
|
|
8
8
|
import path from 'path';
|
|
9
9
|
import os from 'os';
|
|
10
|
-
export async function health() {
|
|
11
|
-
|
|
10
|
+
export async function health(options = {}) {
|
|
11
|
+
const jsonOutput = loadConfig().responseFormat === 'json';
|
|
12
12
|
const results = [];
|
|
13
13
|
const startTime = Date.now();
|
|
14
14
|
try {
|
|
15
15
|
// System Information
|
|
16
16
|
const systemInfo = getSystemInfo();
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
console.log(
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
if (!jsonOutput) {
|
|
18
|
+
console.log(chalk.blue.bold('š Dhruv CLI Health Check\n'));
|
|
19
|
+
console.log(chalk.cyan('š System Information:'));
|
|
20
|
+
Object.entries(systemInfo).forEach(([key, value]) => {
|
|
21
|
+
console.log(` ${key}: ${chalk.yellow(value)}`);
|
|
22
|
+
});
|
|
23
|
+
console.log();
|
|
24
|
+
}
|
|
22
25
|
// Configuration Check
|
|
23
26
|
results.push(...await checkConfiguration());
|
|
24
27
|
// Dependencies Check
|
|
@@ -33,14 +36,40 @@ export async function health() {
|
|
|
33
36
|
results.push(...await checkFileSystem());
|
|
34
37
|
// Plugin System Check
|
|
35
38
|
results.push(...await checkPlugins());
|
|
36
|
-
// Display Results
|
|
37
|
-
displayResults(results);
|
|
38
39
|
const duration = Date.now() - startTime;
|
|
40
|
+
const summary = summarizeResults(results);
|
|
41
|
+
if (jsonOutput) {
|
|
42
|
+
process.exitCode = summary.fail > 0 ? 1 : 0;
|
|
43
|
+
process.stdout.write(`${JSON.stringify({
|
|
44
|
+
ok: summary.fail === 0,
|
|
45
|
+
command: 'health',
|
|
46
|
+
system: systemInfo,
|
|
47
|
+
results,
|
|
48
|
+
summary,
|
|
49
|
+
durationMs: duration,
|
|
50
|
+
})}\n`);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
if (options.details)
|
|
54
|
+
displayResults(results);
|
|
55
|
+
else
|
|
56
|
+
displayConciseResults(results);
|
|
57
|
+
}
|
|
39
58
|
logger.info('Health check completed', { duration, results: results.length });
|
|
40
59
|
}
|
|
41
60
|
catch (error) {
|
|
42
|
-
|
|
43
|
-
|
|
61
|
+
process.exitCode = 1;
|
|
62
|
+
if (jsonOutput) {
|
|
63
|
+
process.stdout.write(`${JSON.stringify({
|
|
64
|
+
ok: false,
|
|
65
|
+
command: 'health',
|
|
66
|
+
error: error.message,
|
|
67
|
+
})}\n`);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
printError('Health check failed');
|
|
71
|
+
console.error(chalk.red(error.message));
|
|
72
|
+
}
|
|
44
73
|
logger.error('Health check failed', error);
|
|
45
74
|
}
|
|
46
75
|
}
|
|
@@ -360,11 +389,7 @@ function displayResults(results) {
|
|
|
360
389
|
}
|
|
361
390
|
});
|
|
362
391
|
// Summary
|
|
363
|
-
const summary =
|
|
364
|
-
pass: results.filter(r => r.status === 'pass').length,
|
|
365
|
-
warn: results.filter(r => r.status === 'warn').length,
|
|
366
|
-
fail: results.filter(r => r.status === 'fail').length
|
|
367
|
-
};
|
|
392
|
+
const summary = summarizeResults(results);
|
|
368
393
|
console.log(chalk.blue.bold('\nš Summary:'));
|
|
369
394
|
console.log(` ā
Passed: ${chalk.green(summary.pass)}`);
|
|
370
395
|
console.log(` ā ļø Warnings: ${chalk.yellow(summary.warn)}`);
|
|
@@ -374,3 +399,21 @@ function displayResults(results) {
|
|
|
374
399
|
const statusColor = overallStatus === 'pass' ? chalk.green : overallStatus === 'warn' ? chalk.yellow : chalk.red;
|
|
375
400
|
console.log(`\n${statusIcon} ${statusColor('Overall Status: ' + overallStatus.toUpperCase())}`);
|
|
376
401
|
}
|
|
402
|
+
function displayConciseResults(results) {
|
|
403
|
+
const summary = summarizeResults(results);
|
|
404
|
+
const overallStatus = summary.fail > 0 ? 'fail' : summary.warn > 0 ? 'warn' : 'pass';
|
|
405
|
+
const icon = overallStatus === 'pass' ? 'ā
' : overallStatus === 'warn' ? 'ā ļø' : 'ā';
|
|
406
|
+
console.log(chalk.blue.bold(`\n${icon} Health: ${overallStatus.toUpperCase()}`));
|
|
407
|
+
console.log(` ${chalk.green(`${summary.pass} passed`)}, ${chalk.yellow(`${summary.warn} warnings`)}, ${chalk.red(`${summary.fail} failed`)}`);
|
|
408
|
+
results
|
|
409
|
+
.filter((result) => result.status !== 'pass')
|
|
410
|
+
.forEach((result) => console.log(` ${result.category}: ${result.message}${result.recommendation ? ` ā ${result.recommendation}` : ''}`));
|
|
411
|
+
console.log(chalk.dim('Run `dhruv health --details` for full diagnostics.'));
|
|
412
|
+
}
|
|
413
|
+
function summarizeResults(results) {
|
|
414
|
+
return {
|
|
415
|
+
pass: results.filter(r => r.status === 'pass').length,
|
|
416
|
+
warn: results.filter(r => r.status === 'warn').length,
|
|
417
|
+
fail: results.filter(r => r.status === 'fail').length,
|
|
418
|
+
};
|
|
419
|
+
}
|
package/dist/commands/init.js
CHANGED
|
@@ -24,6 +24,13 @@ export async function init() {
|
|
|
24
24
|
choices: modelChoices,
|
|
25
25
|
default: current.model,
|
|
26
26
|
},
|
|
27
|
+
{
|
|
28
|
+
type: 'list',
|
|
29
|
+
name: 'scope',
|
|
30
|
+
message: 'Where should Dhruv save these settings?',
|
|
31
|
+
choices: ['local', 'global'],
|
|
32
|
+
default: 'local',
|
|
33
|
+
},
|
|
27
34
|
{
|
|
28
35
|
type: 'list',
|
|
29
36
|
name: 'responseFormat',
|
|
@@ -45,10 +52,12 @@ export async function init() {
|
|
|
45
52
|
default: current.theme || 'default',
|
|
46
53
|
},
|
|
47
54
|
]);
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
const { scope, ...settings } = answers;
|
|
56
|
+
saveConfig(settings, { scope });
|
|
57
|
+
console.log(chalk.green(`Configuration saved ${scope === 'global' ? 'for your user account' : 'in this project'}!`));
|
|
50
58
|
}
|
|
51
59
|
catch (error) {
|
|
60
|
+
process.exitCode = 130;
|
|
52
61
|
if (error?.isTtyError) {
|
|
53
62
|
console.log(chalk.red('This command requires an interactive terminal.'));
|
|
54
63
|
}
|