@rahul05ranjan/dhruv-cli 1.5.0 ā 1.6.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/CHANGELOG.md +2 -9
- package/__tests__/cli-contract.test.ts +100 -1
- package/__tests__/core.test.ts +131 -2
- package/__tests__/diagnostics.test.ts +24 -0
- package/__tests__/file-workflows.test.ts +39 -0
- package/__tests__/interactive.test.ts +15 -0
- package/dist/commands/generate.js +26 -7
- package/dist/commands/init.js +8 -6
- package/dist/commands/metrics.js +12 -2
- package/dist/commands/review.js +19 -2
- package/dist/commands/security-check.js +35 -2
- package/dist/commands/status.js +4 -2
- package/dist/config/config.d.ts +3 -0
- package/dist/config/config.js +22 -6
- package/dist/core/command-runner.js +8 -2
- package/dist/core/metrics.js +3 -2
- package/dist/index.js +65 -13
- package/dist/utils/projectType.d.ts +6 -0
- package/dist/utils/projectType.js +76 -17
- package/package.json +1 -1
- package/src/commands/generate.ts +27 -7
- package/src/commands/init.ts +8 -6
- package/src/commands/metrics.ts +11 -2
- package/src/commands/review.ts +20 -2
- package/src/commands/security-check.ts +33 -2
- package/src/commands/status.ts +4 -2
- package/src/config/config.ts +24 -4
- package/src/core/command-runner.ts +8 -2
- package/src/core/metrics.ts +3 -1
- package/src/index.ts +65 -13
- package/src/utils/projectType.ts +75 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,16 +1,9 @@
|
|
|
1
|
-
# [1.4.0](https://github.com/rahul05ranjan/dhruv-cli/compare/v1.
|
|
1
|
+
# [1.4.0](https://github.com/rahul05ranjan/dhruv-cli/compare/v1.6.0...v1.4.0) (2026-09-21)
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
### Bug Fixes
|
|
5
5
|
|
|
6
|
-
*
|
|
7
|
-
* drop unused menu loop flag ([51eeec6](https://github.com/rahul05ranjan/dhruv-cli/commit/51eeec6a607299120ce3e1f15af9f9dc62396ce7))
|
|
8
|
-
* stabilize health and CLI contract tests on CI ([3b3e650](https://github.com/rahul05ranjan/dhruv-cli/commit/3b3e6503591c8c33d36f1887e94358dafe6163ce))
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
### Features
|
|
12
|
-
|
|
13
|
-
* improve cli experience across commands ([ce41913](https://github.com/rahul05ranjan/dhruv-cli/commit/ce4191350ca3d1c5f49b371508e66de9421f6d20))
|
|
6
|
+
* **config:** keep one-off global flags in-memory and avoid writing local config (closes [#92](https://github.com/rahul05ranjan/dhruv-cli/issues/92)) ([3dd3f99](https://github.com/rahul05ranjan/dhruv-cli/commit/3dd3f99babe42d3804bfa438f38a3ddd6e1f1d72))
|
|
14
7
|
# Changelog
|
|
15
8
|
|
|
16
9
|
## [1.1.1](https://github.com/rahul05ranjan/dhruv-cli/compare/v1.1.0...v1.1.1) (2025-06-29)
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from '@jest/globals';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
|
-
import { resolve } from 'node:path';
|
|
4
|
+
import { resolve, join } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
5
8
|
|
|
6
9
|
const execFileAsync = promisify(execFile);
|
|
7
10
|
const repoRoot = resolve(__dirname, '..');
|
|
@@ -37,6 +40,27 @@ describe('CLI output contract', () => {
|
|
|
37
40
|
|
|
38
41
|
expect(result.stdout).toContain('status');
|
|
39
42
|
expect(result.stdout).toContain('--json');
|
|
43
|
+
expect(result.stdout).toContain('tests documentation docs component');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('generates valid zsh and fish completion scripts with subcommands', async () => {
|
|
47
|
+
const zshResult = await execFileAsync(
|
|
48
|
+
process.execPath,
|
|
49
|
+
['--loader', loaderEntry, sourceEntry, 'completion', 'zsh'],
|
|
50
|
+
{ cwd: repoRoot, env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' } },
|
|
51
|
+
);
|
|
52
|
+
expect(zshResult.stdout).toContain('#compdef dhruv');
|
|
53
|
+
expect(zshResult.stdout).toContain('generate');
|
|
54
|
+
expect(zshResult.stdout).not.toContain('\u001b[');
|
|
55
|
+
|
|
56
|
+
const fishResult = await execFileAsync(
|
|
57
|
+
process.execPath,
|
|
58
|
+
['--loader', loaderEntry, sourceEntry, 'completion', 'fish'],
|
|
59
|
+
{ cwd: repoRoot, env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' } },
|
|
60
|
+
);
|
|
61
|
+
expect(fishResult.stdout).toContain('complete -c dhruv');
|
|
62
|
+
expect(fishResult.stdout).toContain('__fish_seen_subcommand_from generate');
|
|
63
|
+
expect(fishResult.stdout).not.toContain('\u001b[');
|
|
40
64
|
});
|
|
41
65
|
|
|
42
66
|
it('rejects unsupported completion shells', async () => {
|
|
@@ -101,4 +125,79 @@ describe('CLI output contract', () => {
|
|
|
101
125
|
|
|
102
126
|
expect(result.stdout).toContain('--details');
|
|
103
127
|
});
|
|
128
|
+
|
|
129
|
+
it('outputs exactly one valid JSON document on stdout with no ANSI or extra text', async () => {
|
|
130
|
+
const result = await execFileAsync(
|
|
131
|
+
process.execPath,
|
|
132
|
+
['--loader', loaderEntry, sourceEntry, 'metrics', '--json'],
|
|
133
|
+
{
|
|
134
|
+
cwd: repoRoot,
|
|
135
|
+
env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' },
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(result.stdout).not.toContain('\u001b[');
|
|
140
|
+
const parsed = JSON.parse(result.stdout.trim()) as Record<string, unknown>;
|
|
141
|
+
expect(parsed).toMatchObject({
|
|
142
|
+
ok: true,
|
|
143
|
+
command: 'metrics',
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('outputs valid JSON for health command with expected top-level schema', async () => {
|
|
148
|
+
let stdout = '';
|
|
149
|
+
try {
|
|
150
|
+
const result = await execFileAsync(
|
|
151
|
+
process.execPath,
|
|
152
|
+
['--loader', loaderEntry, sourceEntry, 'health', '--json'],
|
|
153
|
+
{
|
|
154
|
+
cwd: repoRoot,
|
|
155
|
+
env: { ...process.env, DHRUV_METRICS_ENABLED: 'false' },
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
stdout = result.stdout;
|
|
159
|
+
} catch (err: unknown) {
|
|
160
|
+
const execError = err as { stdout?: string; code?: number };
|
|
161
|
+
if (typeof execError?.stdout === 'string' && execError.stdout.trim().length > 0) {
|
|
162
|
+
stdout = execError.stdout;
|
|
163
|
+
} else {
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
expect(stdout).not.toContain('\u001b[');
|
|
169
|
+
const parsed = JSON.parse(stdout.trim()) as Record<string, unknown>;
|
|
170
|
+
expect(parsed).toHaveProperty('ok');
|
|
171
|
+
expect(parsed.command).toBe('health');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('does not persist global flags (--json, --model, --verbose, --timeout) to .dhruv-config.json', async () => {
|
|
175
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'dhruv-session-flags-'));
|
|
176
|
+
const tsNodeLoader = pathToFileURL(resolve(repoRoot, 'node_modules/ts-node/esm.mjs')).href;
|
|
177
|
+
try {
|
|
178
|
+
const result = await execFileAsync(
|
|
179
|
+
process.execPath,
|
|
180
|
+
['--loader', tsNodeLoader, resolve(repoRoot, sourceEntry), 'metrics', '--json'],
|
|
181
|
+
{
|
|
182
|
+
cwd: tempDir,
|
|
183
|
+
env: {
|
|
184
|
+
...process.env,
|
|
185
|
+
TS_NODE_PROJECT: resolve(repoRoot, 'tsconfig.json'),
|
|
186
|
+
DHRUV_METRICS_ENABLED: 'false',
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const parsed = JSON.parse(result.stdout.trim()) as Record<string, unknown>;
|
|
192
|
+
expect(parsed).toMatchObject({
|
|
193
|
+
ok: true,
|
|
194
|
+
command: 'metrics',
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const localConfigFile = join(tempDir, '.dhruv-config.json');
|
|
198
|
+
expect(existsSync(localConfigFile)).toBe(false);
|
|
199
|
+
} finally {
|
|
200
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
104
203
|
});
|
package/__tests__/core.test.ts
CHANGED
|
@@ -5,9 +5,15 @@ import {
|
|
|
5
5
|
setAIClient,
|
|
6
6
|
InMemoryAIClient,
|
|
7
7
|
} from '../src/core/ai';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
loadConfig,
|
|
10
|
+
saveConfig,
|
|
11
|
+
setSessionConfig,
|
|
12
|
+
resetSessionConfig,
|
|
13
|
+
getSessionConfig,
|
|
14
|
+
} from '../src/config/config';
|
|
9
15
|
import { createSpinner } from '../src/utils/ux';
|
|
10
|
-
import { detectProjectType } from '../src/utils/projectType';
|
|
16
|
+
import { detectProjectType, detectProjectDetails } from '../src/utils/projectType';
|
|
11
17
|
import { getSystemMessage } from '../src/core/prompts';
|
|
12
18
|
import { runCommand } from '../src/core/command-runner';
|
|
13
19
|
import fs from 'fs';
|
|
@@ -134,6 +140,58 @@ describe('Dhruv CLI Core Systems', () => {
|
|
|
134
140
|
fs.unlinkSync(configPath);
|
|
135
141
|
}
|
|
136
142
|
});
|
|
143
|
+
|
|
144
|
+
it('should apply in-memory session overrides without writing to disk', () => {
|
|
145
|
+
resetSessionConfig();
|
|
146
|
+
const configPath = path.join(process.cwd(), '.dhruv-config.json');
|
|
147
|
+
const existedBefore = fs.existsSync(configPath);
|
|
148
|
+
|
|
149
|
+
setSessionConfig({
|
|
150
|
+
model: 'session-model',
|
|
151
|
+
responseFormat: 'json',
|
|
152
|
+
verbose: true,
|
|
153
|
+
timeoutMs: 12345,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(getSessionConfig()).toEqual({
|
|
157
|
+
model: 'session-model',
|
|
158
|
+
responseFormat: 'json',
|
|
159
|
+
verbose: true,
|
|
160
|
+
timeoutMs: 12345,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const loaded = loadConfig();
|
|
164
|
+
expect(loaded.model).toBe('session-model');
|
|
165
|
+
expect(loaded.responseFormat).toBe('json');
|
|
166
|
+
expect(loaded.verbose).toBe(true);
|
|
167
|
+
expect(loaded.timeoutMs).toBe(12345);
|
|
168
|
+
|
|
169
|
+
if (!existedBefore) {
|
|
170
|
+
expect(fs.existsSync(configPath)).toBe(false);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
resetSessionConfig();
|
|
174
|
+
expect(getSessionConfig()).toEqual({});
|
|
175
|
+
const afterReset = loadConfig();
|
|
176
|
+
expect(afterReset.model).not.toBe('session-model');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should not persist session overrides when saving configuration', () => {
|
|
180
|
+
resetSessionConfig();
|
|
181
|
+
const configPath = path.join(process.cwd(), '.dhruv-config.json');
|
|
182
|
+
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
|
|
183
|
+
|
|
184
|
+
setSessionConfig({ responseFormat: 'json' });
|
|
185
|
+
saveConfig({ model: 'persistent-model' });
|
|
186
|
+
|
|
187
|
+
const fileContent = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
188
|
+
expect(fileContent.model).toBe('persistent-model');
|
|
189
|
+
// Must not snapshot the in-memory responseFormat into the saved file
|
|
190
|
+
expect(fileContent.responseFormat).not.toBe('json');
|
|
191
|
+
|
|
192
|
+
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
|
|
193
|
+
resetSessionConfig();
|
|
194
|
+
});
|
|
137
195
|
});
|
|
138
196
|
|
|
139
197
|
describe('Project Type Detection', () => {
|
|
@@ -204,11 +262,56 @@ describe('Dhruv CLI Core Systems', () => {
|
|
|
204
262
|
const mockReadFileSync = jest.spyOn(fs, 'readFileSync');
|
|
205
263
|
mockReadFileSync.mockReturnValue('{ malformed');
|
|
206
264
|
|
|
265
|
+
const details = detectProjectDetails();
|
|
266
|
+
expect(details.type).toBe('unknown');
|
|
267
|
+
expect(details.diagnostic).toContain('Malformed package.json');
|
|
207
268
|
expect(detectProjectType()).toBe('unknown');
|
|
208
269
|
|
|
209
270
|
mockExistsSync.mockRestore();
|
|
210
271
|
mockReadFileSync.mockRestore();
|
|
211
272
|
});
|
|
273
|
+
|
|
274
|
+
it('should detect Python project with FastAPI framework', () => {
|
|
275
|
+
const mockExistsSync = jest.spyOn(fs, 'existsSync');
|
|
276
|
+
mockExistsSync.mockImplementation((filePath: fs.PathLike) => path.basename(filePath.toString()) === 'requirements.txt');
|
|
277
|
+
const mockReadFileSync = jest.spyOn(fs, 'readFileSync');
|
|
278
|
+
mockReadFileSync.mockReturnValue('fastapi>=0.100.0\nuvicorn>=0.20.0');
|
|
279
|
+
|
|
280
|
+
expect(detectProjectType()).toBe('python-fastapi');
|
|
281
|
+
|
|
282
|
+
mockExistsSync.mockRestore();
|
|
283
|
+
mockReadFileSync.mockRestore();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should detect Go project from go.mod', () => {
|
|
287
|
+
const mockExistsSync = jest.spyOn(fs, 'existsSync');
|
|
288
|
+
mockExistsSync.mockImplementation((filePath: fs.PathLike) => path.basename(filePath.toString()) === 'go.mod');
|
|
289
|
+
|
|
290
|
+
expect(detectProjectType()).toBe('go');
|
|
291
|
+
|
|
292
|
+
mockExistsSync.mockRestore();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should detect Rust project from Cargo.toml', () => {
|
|
296
|
+
const mockExistsSync = jest.spyOn(fs, 'existsSync');
|
|
297
|
+
mockExistsSync.mockImplementation((filePath: fs.PathLike) => path.basename(filePath.toString()) === 'Cargo.toml');
|
|
298
|
+
|
|
299
|
+
expect(detectProjectType()).toBe('rust');
|
|
300
|
+
|
|
301
|
+
mockExistsSync.mockRestore();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('should detect Java Spring Boot project', () => {
|
|
305
|
+
const mockExistsSync = jest.spyOn(fs, 'existsSync');
|
|
306
|
+
mockExistsSync.mockImplementation((filePath: fs.PathLike) => path.basename(filePath.toString()) === 'pom.xml');
|
|
307
|
+
const mockReadFileSync = jest.spyOn(fs, 'readFileSync');
|
|
308
|
+
mockReadFileSync.mockReturnValue('<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency>');
|
|
309
|
+
|
|
310
|
+
expect(detectProjectType()).toBe('java-spring-boot');
|
|
311
|
+
|
|
312
|
+
mockExistsSync.mockRestore();
|
|
313
|
+
mockReadFileSync.mockRestore();
|
|
314
|
+
});
|
|
212
315
|
});
|
|
213
316
|
|
|
214
317
|
describe('System Message Templates', () => {
|
|
@@ -366,6 +469,32 @@ describe('Dhruv CLI Core Systems', () => {
|
|
|
366
469
|
expect(jest.mocked(printError).mock.calls.length).toBeGreaterThan(0);
|
|
367
470
|
});
|
|
368
471
|
|
|
472
|
+
it('maps an untyped ECONNREFUSED error to the ollama-serve hint', async () => {
|
|
473
|
+
setAIClient({
|
|
474
|
+
ask: async () => { throw new Error('connect ECONNREFUSED 127.0.0.1:11434'); },
|
|
475
|
+
listModels: async () => [],
|
|
476
|
+
});
|
|
477
|
+
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
478
|
+
await runCommand(makeSpec());
|
|
479
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('ollama serve'));
|
|
480
|
+
consoleSpy.mockRestore();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('displays completed response when streaming is unavailable', async () => {
|
|
484
|
+
setAIClient({
|
|
485
|
+
ask: async () => 'static completed response',
|
|
486
|
+
listModels: async () => [],
|
|
487
|
+
});
|
|
488
|
+
const output: string[] = [];
|
|
489
|
+
const writeSpy = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
|
|
490
|
+
output.push(String(chunk));
|
|
491
|
+
return true;
|
|
492
|
+
});
|
|
493
|
+
await runCommand(makeSpec());
|
|
494
|
+
expect(output.join('')).toContain('static completed response');
|
|
495
|
+
writeSpy.mockRestore();
|
|
496
|
+
});
|
|
497
|
+
|
|
369
498
|
it('marks the process unsuccessful when an AI command fails', async () => {
|
|
370
499
|
const originalExitCode = process.exitCode;
|
|
371
500
|
process.exitCode = undefined;
|
|
@@ -152,4 +152,28 @@ describe('diagnostic commands', () => {
|
|
|
152
152
|
metricsCollector.resetPersistent();
|
|
153
153
|
}
|
|
154
154
|
});
|
|
155
|
+
|
|
156
|
+
it('tolerates filesystem storage failures without throwing during recording', () => {
|
|
157
|
+
const fs = require('node:fs');
|
|
158
|
+
const writeSpy = jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {
|
|
159
|
+
const err = new Error('EACCES: permission denied');
|
|
160
|
+
(err as NodeJS.ErrnoException).code = 'EACCES';
|
|
161
|
+
throw err;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(() => {
|
|
165
|
+
metricsCollector.recordCommand('explain', 100, true);
|
|
166
|
+
}).not.toThrow();
|
|
167
|
+
|
|
168
|
+
writeSpy.mockRestore();
|
|
169
|
+
metricsCollector.resetPersistent();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('resets local metrics intentionally and clears persisted state', () => {
|
|
173
|
+
metricsCollector.recordCommand('explain', 200, true);
|
|
174
|
+
expect(metricsCollector.getSummary().commands.explain.runs).toBeGreaterThan(0);
|
|
175
|
+
|
|
176
|
+
metricsCollector.resetPersistent();
|
|
177
|
+
expect(metricsCollector.getSummary().commands.explain).toBeUndefined();
|
|
178
|
+
});
|
|
155
179
|
});
|
|
@@ -99,6 +99,22 @@ describe('file analysis commands', () => {
|
|
|
99
99
|
expect(client.requests[0].prompt).not.toContain('node_modules/library/ignored.ts');
|
|
100
100
|
});
|
|
101
101
|
|
|
102
|
+
it('excludes build and framework cache directories during recursive review', async () => {
|
|
103
|
+
fs.mkdirSync(path.join(root, '.next'), { recursive: true });
|
|
104
|
+
fs.mkdirSync(path.join(root, 'vendor'), { recursive: true });
|
|
105
|
+
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
|
|
106
|
+
fs.writeFileSync(path.join(root, '.next', 'bundle.js'), 'console.log("cached");');
|
|
107
|
+
fs.writeFileSync(path.join(root, 'vendor', 'lib.go'), 'package vendor');
|
|
108
|
+
fs.writeFileSync(path.join(root, 'src', 'main.ts'), 'export const main = 1;');
|
|
109
|
+
|
|
110
|
+
await review(root);
|
|
111
|
+
|
|
112
|
+
expect(client.requests).toHaveLength(1);
|
|
113
|
+
expect(client.requests[0].prompt).toContain('src/main.ts');
|
|
114
|
+
expect(client.requests[0].prompt).not.toContain('.next/bundle.js');
|
|
115
|
+
expect(client.requests[0].prompt).not.toContain('vendor/lib.go');
|
|
116
|
+
});
|
|
117
|
+
|
|
102
118
|
it('includes detected project context in review requests', async () => {
|
|
103
119
|
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ devDependencies: { typescript: '^5.0.0' } }));
|
|
104
120
|
fs.writeFileSync(path.join(root, 'index.ts'), 'export const value = 1;');
|
|
@@ -129,6 +145,18 @@ describe('file analysis commands', () => {
|
|
|
129
145
|
expect(client.requests[0].prompt).toContain('rotate the credential');
|
|
130
146
|
});
|
|
131
147
|
|
|
148
|
+
it('redacts GitHub tokens and AWS keys with remediation guidance', async () => {
|
|
149
|
+
const source = path.join(root, 'secrets.ts');
|
|
150
|
+
fs.writeFileSync(source, 'const GITHUB_TOKEN = "ghp_1234567890abcdefghijklmnopqrstuvwxyz";\nconst AWS_KEY = "AKIA1234567890ABCDEF";');
|
|
151
|
+
|
|
152
|
+
await securityCheck(source);
|
|
153
|
+
|
|
154
|
+
expect(client.requests[0].prompt).not.toContain('ghp_1234567890abcdefghijklmnopqrstuvwxyz');
|
|
155
|
+
expect(client.requests[0].prompt).not.toContain('AKIA1234567890ABCDEF');
|
|
156
|
+
expect(client.requests[0].prompt).toContain('GitHub token detected');
|
|
157
|
+
expect(client.requests[0].prompt).toContain('AWS access key ID detected');
|
|
158
|
+
});
|
|
159
|
+
|
|
132
160
|
it('scans nested project files without sending dependency directories', async () => {
|
|
133
161
|
fs.mkdirSync(path.join(root, 'src', 'nested'), { recursive: true });
|
|
134
162
|
fs.mkdirSync(path.join(root, 'node_modules', 'library'), { recursive: true });
|
|
@@ -174,6 +202,17 @@ describe('file analysis commands', () => {
|
|
|
174
202
|
expect(fs.existsSync(path.join(root, 'sample.test.ts'))).toBe(true);
|
|
175
203
|
});
|
|
176
204
|
|
|
205
|
+
it('preserves the Python language and conventions when generating tests', async () => {
|
|
206
|
+
const source = path.join(root, 'math_utils.py');
|
|
207
|
+
fs.writeFileSync(source, 'def add(a, b):\n return a + b\n');
|
|
208
|
+
|
|
209
|
+
await generate('tests', source, { apply: true });
|
|
210
|
+
|
|
211
|
+
expect(client.requests[0].prompt).toContain('Python code');
|
|
212
|
+
expect(client.requests[0].prompt).toContain('pytest');
|
|
213
|
+
expect(fs.existsSync(path.join(root, 'math_utils.test.py'))).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
|
|
177
216
|
it('previews generated tests without writing by default', async () => {
|
|
178
217
|
const source = path.join(root, 'preview.ts');
|
|
179
218
|
fs.writeFileSync(source, 'export const value = 1;');
|
|
@@ -72,6 +72,21 @@ describe('interactive commands', () => {
|
|
|
72
72
|
}
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
it('handles init non-interactive TTY failure with failure exit code', async () => {
|
|
76
|
+
jest.mocked(listModels).mockResolvedValue(['test-model']);
|
|
77
|
+
const prompt = jest.mocked(inquirer.prompt);
|
|
78
|
+
prompt.mockRejectedValueOnce({ isTtyError: true, message: 'TTY required' });
|
|
79
|
+
process.exitCode = undefined;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
await init();
|
|
83
|
+
expect(process.exitCode).toBe(1);
|
|
84
|
+
} finally {
|
|
85
|
+
prompt.mockReset();
|
|
86
|
+
process.exitCode = undefined;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
75
90
|
it('asks whether init should save project-local or user-global settings', async () => {
|
|
76
91
|
jest.mocked(listModels).mockResolvedValue(['test-model']);
|
|
77
92
|
const prompt = jest.mocked(inquirer.prompt);
|
|
@@ -4,23 +4,42 @@ import { runCommand } from '../core/command-runner.js';
|
|
|
4
4
|
import { getSystemMessage } from '../core/prompts.js';
|
|
5
5
|
import { printError, printSuccess, printInfo } from '../utils/ux.js';
|
|
6
6
|
import { loadConfig } from '../config/config.js';
|
|
7
|
-
function
|
|
7
|
+
function getLanguageForFile(target) {
|
|
8
|
+
const ext = path.extname(target).toLowerCase();
|
|
9
|
+
switch (ext) {
|
|
10
|
+
case '.py':
|
|
11
|
+
return { name: 'Python', testFramework: 'pytest or unittest' };
|
|
12
|
+
case '.go':
|
|
13
|
+
return { name: 'Go', testFramework: 'standard testing package' };
|
|
14
|
+
case '.rs':
|
|
15
|
+
return { name: 'Rust', testFramework: 'standard Rust test framework' };
|
|
16
|
+
case '.ts':
|
|
17
|
+
case '.tsx':
|
|
18
|
+
return { name: 'TypeScript', testFramework: 'Jest or Vitest' };
|
|
19
|
+
case '.java':
|
|
20
|
+
return { name: 'Java', testFramework: 'JUnit 5' };
|
|
21
|
+
default:
|
|
22
|
+
return { name: 'JavaScript', testFramework: 'Jest or Mocha' };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function buildPrompt(type, content, target) {
|
|
26
|
+
const lang = getLanguageForFile(target);
|
|
8
27
|
if (type === 'tests' || type === 'test') {
|
|
9
|
-
return `Generate comprehensive unit tests for the following
|
|
28
|
+
return `Generate comprehensive unit tests for the following ${lang.name} code. Use ${lang.testFramework} syntax. Only return the test code without explanations:\n\n${content}`;
|
|
10
29
|
}
|
|
11
30
|
if (type === 'documentation' || type === 'docs') {
|
|
12
|
-
return `Generate JSDoc
|
|
31
|
+
return `Generate ${lang.name === 'Python' ? 'docstrings' : 'JSDoc/documentation'} for the following code:\n\n${content}`;
|
|
13
32
|
}
|
|
14
33
|
return `Generate ${type} for this code:\n\n${content}`;
|
|
15
34
|
}
|
|
16
35
|
/** Extracts test code from the response: a fenced block if present, else the raw response. */
|
|
17
36
|
function extractTestCode(response) {
|
|
18
|
-
const fenced = response.match(/```(?:javascript|js)?\s*\n([\s\S]*?)```/);
|
|
37
|
+
const fenced = response.match(/```(?:javascript|js|typescript|ts|python|py|go|rust|rs|java)?\s*\n([\s\S]*?)```/i);
|
|
19
38
|
if (fenced?.[1])
|
|
20
39
|
return fenced[1].trim();
|
|
21
40
|
return response
|
|
22
|
-
.replace(/^.*?(?=const|describe|test|it\s*\()/s, '')
|
|
23
|
-
.replace(/```[a-z]*\n?/
|
|
41
|
+
.replace(/^.*?(?=const|describe|test|it\s*\(|def test_|func Test|#\[test\])/s, '')
|
|
42
|
+
.replace(/```[a-z]*\n?/gi, '')
|
|
24
43
|
.trim();
|
|
25
44
|
}
|
|
26
45
|
export async function generate(type, target, options = {}) {
|
|
@@ -34,7 +53,7 @@ export async function generate(type, target, options = {}) {
|
|
|
34
53
|
input: { type, target },
|
|
35
54
|
header: `šØ Generating ${type}: `,
|
|
36
55
|
buildRequest: (input, model) => ({
|
|
37
|
-
prompt: buildPrompt(input.type, content),
|
|
56
|
+
prompt: buildPrompt(input.type, content, input.target),
|
|
38
57
|
systemMessage: getSystemMessage('generate'),
|
|
39
58
|
model,
|
|
40
59
|
}),
|
package/dist/commands/init.js
CHANGED
|
@@ -12,15 +12,16 @@ export async function init() {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
catch {
|
|
15
|
-
console.log(chalk.yellow('Warning: Could not
|
|
16
|
-
console.log(chalk.yellow('
|
|
15
|
+
console.log(chalk.yellow('Warning: Could not connect to Ollama.'));
|
|
16
|
+
console.log(chalk.yellow('š” Start Ollama with: ollama serve'));
|
|
17
|
+
console.log(chalk.yellow(`š” Install default model with: ollama pull ${current.model}\n`));
|
|
17
18
|
}
|
|
18
19
|
try {
|
|
19
20
|
const answers = await inquirer.prompt([
|
|
20
21
|
{
|
|
21
22
|
type: 'list',
|
|
22
23
|
name: 'model',
|
|
23
|
-
message:
|
|
24
|
+
message: `Which Ollama model do you want to use? (default: ${current.model})`,
|
|
24
25
|
choices: modelChoices,
|
|
25
26
|
default: current.model,
|
|
26
27
|
},
|
|
@@ -57,12 +58,13 @@ export async function init() {
|
|
|
57
58
|
console.log(chalk.green(`Configuration saved ${scope === 'global' ? 'for your user account' : 'in this project'}!`));
|
|
58
59
|
}
|
|
59
60
|
catch (error) {
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
const isTty = Boolean(error?.isTtyError);
|
|
62
|
+
process.exitCode = isTty ? 1 : 130;
|
|
63
|
+
if (isTty) {
|
|
62
64
|
console.log(chalk.red('This command requires an interactive terminal.'));
|
|
63
65
|
}
|
|
64
66
|
else {
|
|
65
|
-
console.log(chalk.red('Configuration cancelled
|
|
67
|
+
console.log(chalk.red('Configuration cancelled.'));
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
70
|
}
|
package/dist/commands/metrics.js
CHANGED
|
@@ -73,8 +73,18 @@ export async function metrics(options = {}) {
|
|
|
73
73
|
logger.info('Metrics displayed successfully', { metricsCount: metricsData.length });
|
|
74
74
|
}
|
|
75
75
|
catch (error) {
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
if (loadConfig().responseFormat === 'json') {
|
|
78
|
+
process.stdout.write(`${JSON.stringify({
|
|
79
|
+
ok: false,
|
|
80
|
+
command: 'metrics',
|
|
81
|
+
error: error.message,
|
|
82
|
+
})}\n`);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
printError('Failed to retrieve metrics');
|
|
86
|
+
console.error(chalk.red(error.message));
|
|
87
|
+
}
|
|
78
88
|
logger.error('Metrics command failed', error);
|
|
79
89
|
}
|
|
80
90
|
}
|
package/dist/commands/review.js
CHANGED
|
@@ -3,10 +3,24 @@ import path from 'path';
|
|
|
3
3
|
import { execFileSync } from 'child_process';
|
|
4
4
|
import { runCommand } from '../core/command-runner.js';
|
|
5
5
|
import { getSystemMessage } from '../core/prompts.js';
|
|
6
|
-
import { printError } from '../utils/ux.js';
|
|
6
|
+
import { printError, printInfo } from '../utils/ux.js';
|
|
7
7
|
import { detectProjectType } from '../utils/projectType.js';
|
|
8
8
|
const CODE_FILE = /\.(js|ts|jsx|tsx|py|java|cpp|c|go|rs|rb|php)$/;
|
|
9
|
-
const IGNORED_DIRECTORIES = new Set([
|
|
9
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
10
|
+
'.git',
|
|
11
|
+
'node_modules',
|
|
12
|
+
'dist',
|
|
13
|
+
'build',
|
|
14
|
+
'coverage',
|
|
15
|
+
'.dhruv-cache',
|
|
16
|
+
'logs',
|
|
17
|
+
'.next',
|
|
18
|
+
'.turbo',
|
|
19
|
+
'__pycache__',
|
|
20
|
+
'.pytest_cache',
|
|
21
|
+
'target',
|
|
22
|
+
'vendor',
|
|
23
|
+
]);
|
|
10
24
|
/** Reads a file or up to 10 code files from a directory tree. */
|
|
11
25
|
function readCode(fileOrDir) {
|
|
12
26
|
// Read first, branch on the error: no separate existence check to race against.
|
|
@@ -47,6 +61,9 @@ function readDirectory(dir) {
|
|
|
47
61
|
printError(`No code files found in directory "${dir}".`);
|
|
48
62
|
return undefined;
|
|
49
63
|
}
|
|
64
|
+
if (files.length >= 10) {
|
|
65
|
+
printInfo('Note: Directory review is capped at the first 10 source files.');
|
|
66
|
+
}
|
|
50
67
|
let code = '';
|
|
51
68
|
for (const f of files) {
|
|
52
69
|
try {
|
|
@@ -4,12 +4,29 @@ import { runCommand } from '../core/command-runner.js';
|
|
|
4
4
|
import { getSystemMessage } from '../core/prompts.js';
|
|
5
5
|
import { printError } from '../utils/ux.js';
|
|
6
6
|
const CODE_FILE = /\.(js|ts|jsx|tsx|py|java|cpp|c|go|rs|rb|php)$/;
|
|
7
|
-
const IGNORED_DIRECTORIES = new Set([
|
|
7
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
8
|
+
'.git',
|
|
9
|
+
'node_modules',
|
|
10
|
+
'dist',
|
|
11
|
+
'build',
|
|
12
|
+
'coverage',
|
|
13
|
+
'.dhruv-cache',
|
|
14
|
+
'logs',
|
|
15
|
+
'.next',
|
|
16
|
+
'.turbo',
|
|
17
|
+
'__pycache__',
|
|
18
|
+
'.pytest_cache',
|
|
19
|
+
'target',
|
|
20
|
+
'vendor',
|
|
21
|
+
]);
|
|
8
22
|
function redactSensitiveContent(content) {
|
|
9
23
|
return content
|
|
10
24
|
.replace(/(\b(?:api[_-]?key|secret|token|password|authorization)\s*[:=]\s*["'`])[^"'`\r\n]+(["'`])/gi, '$1[REDACTED]$2')
|
|
11
25
|
.replace(/\b(?:sk|pk)-[a-z0-9_-]{8,}\b/gi, '[REDACTED]')
|
|
12
|
-
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]')
|
|
26
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]')
|
|
27
|
+
.replace(/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}\b/g, '[REDACTED]')
|
|
28
|
+
.replace(/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED]')
|
|
29
|
+
.replace(/-----BEGIN (?:RSA|OPENSSH|EC|PGP|DSA)? PRIVATE KEY-----[\s\S]*?-----END (?:RSA|OPENSSH|EC|PGP|DSA)? PRIVATE KEY-----/g, '[REDACTED PRIVATE KEY]');
|
|
13
30
|
}
|
|
14
31
|
function findHighConfidenceFindings(content) {
|
|
15
32
|
const findings = [];
|
|
@@ -39,6 +56,22 @@ function findHighConfidenceFindings(content) {
|
|
|
39
56
|
remediation: 'revoke the token and use a secure runtime secret store',
|
|
40
57
|
});
|
|
41
58
|
}
|
|
59
|
+
else if (/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}\b/.test(line)) {
|
|
60
|
+
findings.push({
|
|
61
|
+
line: index + 1,
|
|
62
|
+
severity: 'high',
|
|
63
|
+
description: 'GitHub token detected',
|
|
64
|
+
remediation: 'revoke the GitHub token and store it in GitHub Secrets or environment variables',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
else if (/\bAKIA[0-9A-Z]{16}\b/.test(line)) {
|
|
68
|
+
findings.push({
|
|
69
|
+
line: index + 1,
|
|
70
|
+
severity: 'high',
|
|
71
|
+
description: 'AWS access key ID detected',
|
|
72
|
+
remediation: 'rotate the AWS access key and use IAM roles or AWS Secrets Manager',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
42
75
|
});
|
|
43
76
|
return findings;
|
|
44
77
|
}
|
package/dist/commands/status.js
CHANGED
|
@@ -69,15 +69,17 @@ export async function status() {
|
|
|
69
69
|
else {
|
|
70
70
|
process.exitCode = 1;
|
|
71
71
|
printError(`ā Configured model '${config.model}' is not available`);
|
|
72
|
+
console.log(chalk.yellow(`š” Install the model: ollama pull ${config.model}`));
|
|
72
73
|
if (models.length > 0) {
|
|
73
74
|
console.log(chalk.yellow(`Available models: ${models.join(', ')}`));
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
78
|
catch (error) {
|
|
79
|
+
process.exitCode = 1;
|
|
78
80
|
printError('ā Ollama connection failed');
|
|
79
81
|
console.log(chalk.red(error.message));
|
|
80
|
-
console.log(chalk.yellow('\
|
|
81
|
-
console.log(chalk.yellow(
|
|
82
|
+
console.log(chalk.yellow('\nš” To start Ollama, run: ollama serve'));
|
|
83
|
+
console.log(chalk.yellow(`š” To install the configured model, run: ollama pull ${config.model}`));
|
|
82
84
|
}
|
|
83
85
|
}
|
package/dist/config/config.d.ts
CHANGED
|
@@ -6,6 +6,9 @@ export interface DhruvConfig {
|
|
|
6
6
|
timeoutMs: number;
|
|
7
7
|
theme?: 'default' | 'dark' | 'light' | 'mono';
|
|
8
8
|
}
|
|
9
|
+
export declare function setSessionConfig(config: Partial<DhruvConfig>): void;
|
|
10
|
+
export declare function resetSessionConfig(): void;
|
|
11
|
+
export declare function getSessionConfig(): Partial<DhruvConfig>;
|
|
9
12
|
export declare function loadConfig(): DhruvConfig;
|
|
10
13
|
export declare function saveConfig(config: Partial<DhruvConfig>, options?: {
|
|
11
14
|
scope?: ConfigScope;
|