@rahul05ranjan/dhruv-cli 1.3.0 โ 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/ENTERPRISE.md +275 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +45 -17
- package/.github/ISSUE_TEMPLATE/documentation_issue.md +61 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +61 -9
- package/.github/ISSUE_TEMPLATE/security_vulnerability.md +74 -0
- package/.github/dependabot.yml +42 -2
- package/.github/pull_request_template.md +13 -0
- package/.github/workflows/ci.yml +51 -38
- package/.github/workflows/contribution.yml +41 -34
- package/.github/workflows/dependabot-auto-merge.yml +62 -2
- package/.github/workflows/labeler.yml +1 -0
- package/.github/workflows/release.yml +229 -0
- package/.github/workflows/security.yml +201 -0
- package/.releaserc.json +50 -0
- package/AGENTS.md +13 -0
- package/CHANGELOG.md +13 -0
- package/README.md +145 -40
- package/__tests__/cli-contract.test.ts +104 -0
- package/__tests__/core.test.ts +439 -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 +62 -0
- package/__tests__/workflows.test.ts +118 -0
- package/dist/commands/explain.js +13 -44
- package/dist/commands/fix.js +13 -38
- package/dist/commands/generate.d.ts +6 -1
- package/dist/commands/generate.js +60 -55
- package/dist/commands/health.d.ts +4 -0
- package/dist/commands/health.js +419 -0
- package/dist/commands/init.js +56 -42
- package/dist/commands/menu.js +137 -24
- package/dist/commands/metrics.d.ts +5 -0
- package/dist/commands/metrics.js +80 -0
- package/dist/commands/optimize.js +42 -34
- package/dist/commands/review.d.ts +4 -1
- package/dist/commands/review.js +87 -43
- package/dist/commands/security-check.d.ts +4 -1
- package/dist/commands/security-check.js +109 -38
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/status.js +83 -0
- package/dist/commands/suggest.js +13 -39
- package/dist/config/config.d.ts +5 -1
- package/dist/config/config.js +53 -8
- package/dist/core/ai.d.ts +88 -2
- package/dist/core/ai.js +231 -30
- package/dist/core/command-catalog.d.ts +10 -0
- package/dist/core/command-catalog.js +27 -0
- package/dist/core/command-runner.d.ts +17 -0
- package/dist/core/command-runner.js +157 -0
- package/dist/core/logger.d.ts +40 -0
- package/dist/core/logger.js +139 -0
- package/dist/core/metrics.d.ts +62 -0
- package/dist/core/metrics.js +284 -0
- package/dist/core/prompts.d.ts +1 -0
- package/dist/core/prompts.js +121 -0
- package/dist/core/security.d.ts +34 -0
- package/dist/core/security.js +197 -0
- package/dist/index.js +84 -22
- package/dist/utils/projectType.d.ts +1 -1
- package/dist/utils/projectType.js +26 -7
- package/dist/utils/ux.d.ts +3 -0
- package/dist/utils/ux.js +15 -0
- package/docs/agents/domain.md +51 -0
- package/docs/agents/issue-tracker.md +45 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/api/.nojekyll +1 -0
- package/docs/api/assets/hierarchy.js +1 -0
- package/docs/api/assets/highlight.css +71 -0
- package/docs/api/assets/icons.js +18 -0
- package/docs/api/assets/icons.svg +1 -0
- package/docs/api/assets/main.js +60 -0
- package/docs/api/assets/navigation.js +1 -0
- package/docs/api/assets/search.js +1 -0
- package/docs/api/assets/style.css +1633 -0
- package/docs/api/hierarchy.html +1 -0
- package/docs/api/index.html +161 -0
- 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/api/modules.html +1 -0
- package/docs/dhruv-cli-preview.svg +42 -0
- package/docs/index.html +631 -533
- package/docs/publishing-fix.md +34 -0
- package/eslint.config.js +170 -0
- package/jest.config.json +37 -0
- package/lighthouserc.json +22 -0
- package/package.json +62 -8
- package/src/commands/explain.ts +13 -42
- package/src/commands/fix.ts +13 -31
- package/src/commands/generate.ts +68 -48
- package/src/commands/health.ts +485 -0
- package/src/commands/init.ts +57 -42
- package/src/commands/menu.ts +132 -24
- package/src/commands/metrics.ts +100 -0
- package/src/commands/optimize.ts +40 -28
- package/src/commands/review.ts +96 -37
- package/src/commands/security-check.ts +127 -33
- package/src/commands/status.ts +83 -0
- package/src/commands/suggest.ts +13 -32
- package/src/config/config.ts +60 -8
- package/src/core/ai.ts +265 -26
- package/src/core/command-catalog.ts +37 -0
- package/src/core/command-runner.ts +185 -0
- package/src/core/logger.ts +195 -0
- package/src/core/metrics.ts +335 -0
- package/src/core/prompts.ts +128 -0
- package/src/core/security.ts +243 -0
- package/src/index.ts +90 -22
- package/src/utils/projectType.ts +22 -7
- package/src/utils/ux.ts +18 -0
- package/test-suite.sh +147 -0
- package/tsconfig.json +4 -3
- package/typedoc.json +44 -0
- package/types/global.d.ts +13 -0
- package/validate-workflows.sh +270 -0
- package/.eslintignore +0 -1
- package/.eslintrc.cjs +0 -43
- package/.github/workflows/auto-assign.yml +0 -14
- package/src/core/ai.test.js +0 -40
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { jest } from '@jest/globals';
|
|
2
|
+
|
|
3
|
+
// Setup test environment
|
|
4
|
+
beforeAll(async () => {
|
|
5
|
+
// Set test environment
|
|
6
|
+
process.env.NODE_ENV = 'test';
|
|
7
|
+
|
|
8
|
+
// Mock console methods to reduce noise during tests
|
|
9
|
+
global.console = {
|
|
10
|
+
...console,
|
|
11
|
+
log: jest.fn(),
|
|
12
|
+
warn: jest.fn(),
|
|
13
|
+
error: jest.fn(),
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterAll(async () => {
|
|
18
|
+
// Cleanup
|
|
19
|
+
delete process.env.NODE_ENV;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Mock process.exit to prevent tests from exiting
|
|
23
|
+
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
|
|
24
|
+
throw new Error('process.exit() was called');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
mockExit.mockClear();
|
|
29
|
+
process.exitCode = undefined;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Global test utilities
|
|
33
|
+
(global as Record<string, unknown>).testUtils = {
|
|
34
|
+
// Helper to wait for async operations
|
|
35
|
+
wait: (ms: number) => new Promise(resolve => setTimeout(resolve, ms)),
|
|
36
|
+
|
|
37
|
+
// Helper to create temporary files for testing
|
|
38
|
+
createTempFile: (content: string, filename: string = 'test.txt') => {
|
|
39
|
+
const fs = require('fs');
|
|
40
|
+
const path = require('path');
|
|
41
|
+
const tempDir = path.join(process.cwd(), '.test-temp');
|
|
42
|
+
|
|
43
|
+
if (!fs.existsSync(tempDir)) {
|
|
44
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const filePath = path.join(tempDir, filename);
|
|
48
|
+
fs.writeFileSync(filePath, content);
|
|
49
|
+
return filePath;
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
// Helper to clean up temporary files
|
|
53
|
+
cleanupTempFiles: () => {
|
|
54
|
+
const fs = require('fs');
|
|
55
|
+
const path = require('path');
|
|
56
|
+
const tempDir = path.join(process.cwd(), '.test-temp');
|
|
57
|
+
|
|
58
|
+
if (fs.existsSync(tempDir)) {
|
|
59
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { parse } from 'yaml';
|
|
5
|
+
|
|
6
|
+
interface Step {
|
|
7
|
+
uses?: string;
|
|
8
|
+
run?: string;
|
|
9
|
+
with?: Record<string, string | number>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Workflow {
|
|
13
|
+
on: Record<string, unknown>;
|
|
14
|
+
env?: Record<string, string>;
|
|
15
|
+
permissions?: Record<string, string>;
|
|
16
|
+
jobs: Record<string, { steps: Step[]; if?: string }>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const root = resolve(__dirname, '..');
|
|
20
|
+
const workflow = (name: string): Workflow =>
|
|
21
|
+
parse(readFileSync(resolve(root, '.github/workflows', name), 'utf8'));
|
|
22
|
+
|
|
23
|
+
const atLeast = (version: string, minimum: string): boolean =>
|
|
24
|
+
/^\d+\.\d+\.\d+$/.test(version) && version.localeCompare(minimum, 'en', { numeric: true }) >= 0;
|
|
25
|
+
|
|
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
|
+
|
|
33
|
+
it('installs an OIDC-capable npm CLI where semantic-release looks for executables', () => {
|
|
34
|
+
// The plugin uses preferLocal: true. A global npm upgrade cannot fix an
|
|
35
|
+
// older CLI hoisted here by a conflicting @semantic-release/npm version.
|
|
36
|
+
const npm = JSON.parse(readFileSync(resolve(root, 'node_modules/npm/package.json'), 'utf8'));
|
|
37
|
+
expect(atLeast(npm.version, '11.5.1')).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it.each(['release.yml'])(
|
|
41
|
+
'%s provisions a supported Node and npm before publishing',
|
|
42
|
+
name => {
|
|
43
|
+
const config = workflow(name);
|
|
44
|
+
for (const job of Object.values(config.jobs)) {
|
|
45
|
+
const publishIndex = job.steps.findIndex(step =>
|
|
46
|
+
/npm publish|npx semantic-release/.test(step.run ?? ''));
|
|
47
|
+
if (publishIndex < 0) continue;
|
|
48
|
+
const setup = job.steps.slice(0, publishIndex).find(step =>
|
|
49
|
+
step.uses?.startsWith('actions/setup-node@'));
|
|
50
|
+
const nodeVersion = String(setup?.with?.['node-version']).replace(
|
|
51
|
+
/\$\{\{ env\.(\w+) \}\}/g, (_, key: string) => config.env?.[key] ?? '');
|
|
52
|
+
expect(atLeast(nodeVersion, '22.14.0')).toBe(true);
|
|
53
|
+
const npmSetup = job.steps.slice(0, publishIndex).find(step =>
|
|
54
|
+
/npm install --global npm@/.test(step.run ?? ''));
|
|
55
|
+
const npmVersion = npmSetup?.run?.match(/npm@(\d+\.\d+\.\d+)/)?.[1] ?? '0.0.0';
|
|
56
|
+
expect(atLeast(npmVersion, '11.5.1')).toBe(true);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('has one automatic publisher for main pushes', () => {
|
|
61
|
+
const publishers = ['ci.yml', 'release.yml']
|
|
62
|
+
.filter(name => {
|
|
63
|
+
const config = workflow(name);
|
|
64
|
+
return config.on.push && Object.values(config.jobs).some(job =>
|
|
65
|
+
job.steps.some(step => /npx semantic-release|npm publish(?! --dry-run)/.test(step.run ?? '')));
|
|
66
|
+
});
|
|
67
|
+
expect(publishers).toEqual(['release.yml']);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
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
|
+
|
|
88
|
+
describe('security workflow requirements', () => {
|
|
89
|
+
it('lets TruffleHog select the commit range for push, PR, schedule and manual events', () => {
|
|
90
|
+
const steps = workflow('security.yml').jobs['secret-scan'].steps;
|
|
91
|
+
const scanner = steps.find(step => step.uses?.startsWith('trufflesecurity/trufflehog@'));
|
|
92
|
+
expect(scanner).toBeDefined();
|
|
93
|
+
// Hard-coding main/HEAD makes push-to-main scans fail before scanning.
|
|
94
|
+
expect(scanner?.with?.base).toBeUndefined();
|
|
95
|
+
expect(scanner?.with?.head).toBeUndefined();
|
|
96
|
+
const checkout = steps.find(step => step.uses?.startsWith('actions/checkout@'));
|
|
97
|
+
expect(checkout?.with?.['fetch-depth']).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('uploads the Anchore report instead of uploading the Scorecard report twice', () => {
|
|
101
|
+
const steps = Object.values(workflow('security.yml').jobs).flatMap(job => job.steps);
|
|
102
|
+
const uploads = steps.filter(step => step.uses?.includes('/upload-sarif@'));
|
|
103
|
+
expect(uploads.map(step => step.with?.sarif_file)).toEqual([
|
|
104
|
+
'results.sarif', '${{ steps.scan.outputs.sarif }}',
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('meets Scorecard publishing restrictions on permissions and job isolation', () => {
|
|
109
|
+
const config = workflow('security.yml');
|
|
110
|
+
expect(Object.values(config.permissions ?? {})).not.toContain('write');
|
|
111
|
+
// Scorecard's results API accepts only these actions in the producing job.
|
|
112
|
+
const allowed = ['actions/checkout', 'actions/upload-artifact',
|
|
113
|
+
'github/codeql-action/upload-sarif', 'ossf/scorecard-action', 'step-security/harden-runner'];
|
|
114
|
+
for (const step of config.jobs['security-scorecard'].steps) {
|
|
115
|
+
expect(allowed).toContain(step.uses?.split('@')[0]);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
package/dist/commands/explain.js
CHANGED
|
@@ -1,46 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import chalk from 'chalk';
|
|
4
|
-
import { loadConfig } from '../config/config.js';
|
|
5
|
-
import { highlightCode, printError } from '../utils/ux.js';
|
|
1
|
+
import { runCommand } from '../core/command-runner.js';
|
|
2
|
+
import { getSystemMessage } from '../core/prompts.js';
|
|
6
3
|
export async function explain(query) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
onToken: (token) => {
|
|
19
|
-
streamed += token;
|
|
20
|
-
process.stdout.write(chalk.cyan(token));
|
|
21
|
-
}
|
|
22
|
-
});
|
|
23
|
-
process.stdout.write('\n');
|
|
24
|
-
if (typeof highlightCode === 'function' && streamed.match(/```[a-z]*[\s\S]*?```/)) {
|
|
25
|
-
const codeBlocks = streamed.match(/```([a-z]*)\n([\s\S]*?)```/g) || [];
|
|
26
|
-
for (const block of codeBlocks) {
|
|
27
|
-
const [, lang, code] = block.match(/```([a-z]*)\n([\s\S]*?)```/) || [];
|
|
28
|
-
if (code) {
|
|
29
|
-
try {
|
|
30
|
-
console.log(highlightCode(code, lang || 'js'));
|
|
31
|
-
}
|
|
32
|
-
catch (err) {
|
|
33
|
-
// Only log highlight errors in development
|
|
34
|
-
if (process.env.NODE_ENV === 'development') {
|
|
35
|
-
console.error('Highlight error:', err);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
catch (err) {
|
|
43
|
-
printError('Failed to get explanation.');
|
|
44
|
-
console.error(chalk.red(err.message));
|
|
45
|
-
}
|
|
4
|
+
await runCommand({
|
|
5
|
+
name: 'explain',
|
|
6
|
+
input: { query },
|
|
7
|
+
header: '๐ Explanation: ',
|
|
8
|
+
buildRequest: (input, model) => ({
|
|
9
|
+
prompt: input.query,
|
|
10
|
+
systemMessage: getSystemMessage('explain'),
|
|
11
|
+
model,
|
|
12
|
+
}),
|
|
13
|
+
footer: `๐ก Need more help? Try: dhruv suggest "${query}"`,
|
|
14
|
+
});
|
|
46
15
|
}
|
package/dist/commands/fix.js
CHANGED
|
@@ -1,40 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import chalk from 'chalk';
|
|
4
|
-
import { loadConfig } from '../config/config.js';
|
|
5
|
-
import { highlightCode, printError } from '../utils/ux.js';
|
|
1
|
+
import { runCommand } from '../core/command-runner.js';
|
|
2
|
+
import { getSystemMessage } from '../core/prompts.js';
|
|
6
3
|
export async function fix(query) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
process.stdout.write(chalk.cyan(token));
|
|
19
|
-
}
|
|
20
|
-
});
|
|
21
|
-
process.stdout.write('\n');
|
|
22
|
-
if (typeof highlightCode === 'function' && streamed.match(/```[a-z]*[\s\S]*?```/)) {
|
|
23
|
-
const codeBlocks = streamed.match(/```([a-z]*)\n([\s\S]*?)```/g) || [];
|
|
24
|
-
for (const block of codeBlocks) {
|
|
25
|
-
const [, lang, code] = block.match(/```([a-z]*)\n([\s\S]*?)```/) || [];
|
|
26
|
-
if (code)
|
|
27
|
-
try {
|
|
28
|
-
console.log(highlightCode(code, lang || 'js'));
|
|
29
|
-
}
|
|
30
|
-
catch (err) {
|
|
31
|
-
console.error('Highlight error:', err);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
catch (err) {
|
|
37
|
-
printError('Failed to get fix suggestion.');
|
|
38
|
-
console.error(chalk.red(err.message));
|
|
39
|
-
}
|
|
4
|
+
await runCommand({
|
|
5
|
+
name: 'fix',
|
|
6
|
+
input: { query },
|
|
7
|
+
header: '๐ง Fix Analysis: ',
|
|
8
|
+
buildRequest: (input, model) => ({
|
|
9
|
+
prompt: input.query,
|
|
10
|
+
systemMessage: getSystemMessage('fix'),
|
|
11
|
+
model,
|
|
12
|
+
}),
|
|
13
|
+
footer: `๐งช Want to test this? Try: dhruv generate tests <your-file>`,
|
|
14
|
+
});
|
|
40
15
|
}
|
|
@@ -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>;
|