@rahul05ranjan/dhruv-cli 1.2.4 โ†’ 1.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/.github/ENTERPRISE.md +275 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.md +45 -17
  3. package/.github/ISSUE_TEMPLATE/documentation_issue.md +61 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.md +61 -9
  5. package/.github/ISSUE_TEMPLATE/security_vulnerability.md +74 -0
  6. package/.github/dependabot.yml +42 -2
  7. package/.github/pull_request_template.md +13 -0
  8. package/.github/workflows/build-publish.yml +154 -0
  9. package/.github/workflows/ci.yml +251 -18
  10. package/.github/workflows/contribution.yml +169 -27
  11. package/.github/workflows/dependabot-auto-merge.yml +61 -2
  12. package/.github/workflows/deploy.yml +336 -0
  13. package/.github/workflows/monitoring.yml +270 -0
  14. package/.github/workflows/release.yml +229 -0
  15. package/.github/workflows/security.yml +198 -0
  16. package/.releaserc.json +50 -0
  17. package/AGENTS.md +13 -0
  18. package/CHANGELOG.md +8 -0
  19. package/PUBLISHING_FIX.md +92 -0
  20. package/__tests__/core.test.ts +318 -0
  21. package/__tests__/setup.ts +61 -0
  22. package/__tests__/workflows.test.ts +95 -0
  23. package/dist/commands/explain.js +13 -44
  24. package/dist/commands/fix.js +13 -38
  25. package/dist/commands/generate.js +45 -54
  26. package/dist/commands/health.d.ts +1 -0
  27. package/dist/commands/health.js +376 -0
  28. package/dist/commands/init.js +47 -42
  29. package/dist/commands/menu.js +90 -2
  30. package/dist/commands/metrics.d.ts +1 -0
  31. package/dist/commands/metrics.js +51 -0
  32. package/dist/commands/optimize.js +42 -34
  33. package/dist/commands/review.js +52 -48
  34. package/dist/commands/security-check.js +52 -42
  35. package/dist/commands/status.d.ts +1 -0
  36. package/dist/commands/status.js +45 -0
  37. package/dist/commands/suggest.js +13 -39
  38. package/dist/config/config.js +30 -2
  39. package/dist/core/ai.d.ts +77 -2
  40. package/dist/core/ai.js +207 -30
  41. package/dist/core/command-runner.d.ts +17 -0
  42. package/dist/core/command-runner.js +78 -0
  43. package/dist/core/logger.d.ts +40 -0
  44. package/dist/core/logger.js +138 -0
  45. package/dist/core/metrics.d.ts +34 -0
  46. package/dist/core/metrics.js +206 -0
  47. package/dist/core/prompts.d.ts +1 -0
  48. package/dist/core/prompts.js +121 -0
  49. package/dist/core/security.d.ts +34 -0
  50. package/dist/core/security.js +197 -0
  51. package/dist/index.js +40 -2
  52. package/dist/utils/ux.d.ts +3 -0
  53. package/dist/utils/ux.js +15 -0
  54. package/docs/agents/domain.md +51 -0
  55. package/docs/agents/issue-tracker.md +45 -0
  56. package/docs/agents/triage-labels.md +15 -0
  57. package/docs/api/.nojekyll +1 -0
  58. package/docs/api/assets/hierarchy.js +1 -0
  59. package/docs/api/assets/highlight.css +71 -0
  60. package/docs/api/assets/icons.js +18 -0
  61. package/docs/api/assets/icons.svg +1 -0
  62. package/docs/api/assets/main.js +60 -0
  63. package/docs/api/assets/navigation.js +1 -0
  64. package/docs/api/assets/search.js +1 -0
  65. package/docs/api/assets/style.css +1633 -0
  66. package/docs/api/hierarchy.html +1 -0
  67. package/docs/api/index.html +39 -0
  68. package/docs/api/modules.html +1 -0
  69. package/eslint.config.js +170 -0
  70. package/jest.config.json +37 -0
  71. package/lighthouserc.json +22 -0
  72. package/logs/.8a99b6cf655346317fdbf29f4fffcf91131432f3-audit.json +15 -0
  73. package/logs/.eee104bf8fff5ecd38a6a2842df260de6470a7c3-audit.json +15 -0
  74. package/package.json +62 -8
  75. package/src/commands/explain.ts +13 -42
  76. package/src/commands/fix.ts +13 -31
  77. package/src/commands/generate.ts +47 -46
  78. package/src/commands/health.ts +440 -0
  79. package/src/commands/init.ts +48 -42
  80. package/src/commands/menu.ts +86 -2
  81. package/src/commands/metrics.ts +65 -0
  82. package/src/commands/optimize.ts +40 -28
  83. package/src/commands/review.ts +54 -40
  84. package/src/commands/security-check.ts +54 -34
  85. package/src/commands/status.ts +47 -0
  86. package/src/commands/suggest.ts +13 -32
  87. package/src/config/config.ts +35 -2
  88. package/src/core/ai.ts +237 -26
  89. package/src/core/command-runner.ts +105 -0
  90. package/src/core/logger.ts +194 -0
  91. package/src/core/metrics.ts +232 -0
  92. package/src/core/prompts.ts +128 -0
  93. package/src/core/security.ts +243 -0
  94. package/src/index.ts +47 -2
  95. package/src/utils/ux.ts +18 -0
  96. package/test-suite.sh +147 -0
  97. package/tsconfig.json +3 -2
  98. package/typedoc.json +44 -0
  99. package/types/global.d.ts +13 -0
  100. package/validate-workflows.sh +270 -0
  101. package/.eslintignore +0 -1
  102. package/.eslintrc.cjs +0 -43
  103. package/src/core/ai.test.js +0 -40
@@ -0,0 +1,318 @@
1
+ import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
2
+ import {
3
+ ask,
4
+ listModels,
5
+ setAIClient,
6
+ InMemoryAIClient,
7
+ } from '../src/core/ai';
8
+ import { loadConfig, saveConfig } from '../src/config/config';
9
+ import { createSpinner } from '../src/utils/ux';
10
+ import { detectProjectType } from '../src/utils/projectType';
11
+ import { getSystemMessage } from '../src/core/prompts';
12
+ import { runCommand } from '../src/core/command-runner';
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+
16
+ // chalk and ora are ESM-only and can't be loaded by the CJS test runtime.
17
+ // Identity stubs stand in: the tests assert pipeline behavior, not coloring.
18
+ jest.mock('chalk', () => {
19
+ // Self-chaining identity: chalk.green.bold('x') === 'x', any chain depth.
20
+ const identity = (s: unknown) => String(s);
21
+ const makeChalk = (): unknown =>
22
+ new Proxy(identity, {
23
+ get: (_target: unknown, prop: string | symbol) => {
24
+ if (prop === 'level') return 0;
25
+ if (prop === Symbol.toPrimitive) return () => '';
26
+ return makeChalk();
27
+ },
28
+ apply: (_target: unknown, _thisArg: unknown, args: unknown[]) => String(args[0]),
29
+ });
30
+ const chalk = makeChalk() as unknown as Record<string, unknown>;
31
+ return { __esModule: true, default: chalk, ...chalk };
32
+ });
33
+
34
+ jest.mock('ora', () => ({
35
+ __esModule: true,
36
+ default: jest.fn(() => ({
37
+ start: jest.fn().mockReturnThis(),
38
+ stop: jest.fn().mockReturnThis(),
39
+ succeed: jest.fn().mockReturnThis(),
40
+ fail: jest.fn().mockReturnThis(),
41
+ })),
42
+ }));
43
+
44
+ // UX output is mocked at its own module edge โ€” the runner's pipeline behavior
45
+ // is what's under test, not console formatting.
46
+ jest.mock('../src/utils/ux', () => ({
47
+ printError: jest.fn(),
48
+ printSuccess: jest.fn(),
49
+ printWarning: jest.fn(),
50
+ printInfo: jest.fn(),
51
+ createSpinner: jest.fn(() => ({
52
+ start: jest.fn().mockReturnThis(),
53
+ stop: jest.fn().mockReturnThis(),
54
+ succeed: jest.fn().mockReturnThis(),
55
+ fail: jest.fn().mockReturnThis(),
56
+ })),
57
+ themed: jest.fn((text: string) => text),
58
+ highlightCode: jest.fn((code: string) => code),
59
+ createProgressBar: jest.fn(() => ({
60
+ increment: jest.fn(),
61
+ stop: jest.fn(),
62
+ })),
63
+ }));
64
+
65
+ jest.mock('../src/core/logger', () => ({
66
+ logger: {
67
+ info: jest.fn(),
68
+ warn: jest.fn(),
69
+ error: jest.fn(),
70
+ debug: jest.fn(),
71
+ command: jest.fn(),
72
+ performance: jest.fn(),
73
+ security: jest.fn(),
74
+ getSessionId: jest.fn(() => 'test-session'),
75
+ flush: jest.fn(),
76
+ },
77
+ logCommand: jest.fn(),
78
+ logPerformance: jest.fn(),
79
+ logSecurity: jest.fn(),
80
+ logError: jest.fn(),
81
+ logInfo: jest.fn(),
82
+ logWarn: jest.fn(),
83
+ logDebug: jest.fn(),
84
+ }));
85
+
86
+ describe('Dhruv CLI Core Systems', () => {
87
+ const originalEnv = process.env;
88
+
89
+ beforeEach(() => {
90
+ process.env = { ...originalEnv };
91
+ jest.clearAllMocks();
92
+ });
93
+
94
+ afterEach(() => {
95
+ process.env = originalEnv;
96
+ });
97
+
98
+ describe('Configuration Management', () => {
99
+ it('should load default configuration', () => {
100
+ const config = loadConfig();
101
+ expect(config).toHaveProperty('model');
102
+ expect(config).toHaveProperty('verbose');
103
+ expect(config).toHaveProperty('responseFormat');
104
+ expect(config).toHaveProperty('theme');
105
+ });
106
+
107
+ it('should validate and merge configuration', () => {
108
+ const testConfig = {
109
+ model: 'test-model',
110
+ verbose: true,
111
+ responseFormat: 'json' as const,
112
+ theme: 'dark' as const,
113
+ };
114
+
115
+ saveConfig(testConfig);
116
+ const loadedConfig = loadConfig();
117
+
118
+ expect(loadedConfig.model).toBe('test-model');
119
+ expect(loadedConfig.verbose).toBe(true);
120
+ expect(loadedConfig.responseFormat).toBe('json');
121
+ expect(loadedConfig.theme).toBe('dark');
122
+ });
123
+
124
+ it('should handle invalid configuration gracefully', () => {
125
+ const configPath = path.join(process.cwd(), '.dhruv-config.json');
126
+ fs.writeFileSync(configPath, 'invalid json');
127
+
128
+ const config = loadConfig();
129
+
130
+ expect(config).toHaveProperty('model');
131
+ expect(config.verbose).toBe(false);
132
+
133
+ if (fs.existsSync(configPath)) {
134
+ fs.unlinkSync(configPath);
135
+ }
136
+ });
137
+ });
138
+
139
+ describe('Project Type Detection', () => {
140
+ it('should detect Node.js project', () => {
141
+ const mockExistsSync = jest.spyOn(fs, 'existsSync');
142
+ mockExistsSync.mockImplementation((filePath: fs.PathLike) => {
143
+ return path.basename(filePath.toString()) === 'package.json';
144
+ });
145
+
146
+ const mockReadFileSync = jest.spyOn(fs, 'readFileSync');
147
+ mockReadFileSync.mockReturnValue(JSON.stringify({
148
+ name: 'test-project',
149
+ dependencies: { express: '^4.0.0' }
150
+ }));
151
+
152
+ const projectType = detectProjectType();
153
+ expect(projectType).toBe('node-express');
154
+
155
+ mockExistsSync.mockRestore();
156
+ mockReadFileSync.mockRestore();
157
+ });
158
+
159
+ it('should detect React project', () => {
160
+ const mockExistsSync = jest.spyOn(fs, 'existsSync');
161
+ mockExistsSync.mockImplementation((filePath: fs.PathLike) => {
162
+ const basename = path.basename(filePath.toString());
163
+ return basename === 'package.json' || basename === 'src';
164
+ });
165
+
166
+ const mockReadFileSync = jest.spyOn(fs, 'readFileSync');
167
+ mockReadFileSync.mockReturnValue(JSON.stringify({
168
+ name: 'test-react-app',
169
+ dependencies: { 'react': '^18.0.0', 'react-dom': '^18.0.0' }
170
+ }));
171
+
172
+ const projectType = detectProjectType();
173
+ expect(projectType).toBe('react');
174
+
175
+ mockExistsSync.mockRestore();
176
+ mockReadFileSync.mockRestore();
177
+ });
178
+
179
+ it('should return unknown for unrecognized projects', () => {
180
+ const mockExistsSync = jest.spyOn(fs, 'existsSync');
181
+ mockExistsSync.mockReturnValue(false);
182
+
183
+ const projectType = detectProjectType();
184
+ expect(projectType).toBe('unknown');
185
+
186
+ mockExistsSync.mockRestore();
187
+ });
188
+ });
189
+
190
+ describe('System Message Templates', () => {
191
+ it('should return system message for valid type', () => {
192
+ const explainMessage = getSystemMessage('explain');
193
+ expect(explainMessage).toContain('programming instructor');
194
+ expect(explainMessage).toContain('technical expert');
195
+
196
+ const suggestMessage = getSystemMessage('suggest');
197
+ expect(suggestMessage).toContain('software architect');
198
+ expect(suggestMessage).toContain('best practices');
199
+ });
200
+
201
+ it('should return default message for invalid type', () => {
202
+ const defaultMessage = getSystemMessage('invalid');
203
+ const explainMessage = getSystemMessage('explain');
204
+ expect(defaultMessage).toBe(explainMessage);
205
+ });
206
+ });
207
+
208
+ describe('AI module through its interface (in-memory adapter)', () => {
209
+ let client: InMemoryAIClient;
210
+
211
+ beforeEach(() => {
212
+ client = new InMemoryAIClient(new Map([['hello', 'cached answer']]));
213
+ setAIClient(client);
214
+ });
215
+
216
+ it('streams tokens and returns the full response', async () => {
217
+ const tokens: string[] = [];
218
+ const response = await ask({ prompt: 'hello', onToken: (t) => tokens.push(t) });
219
+ expect(response).toBe('cached answer');
220
+ expect(tokens).toEqual(['cached answer']);
221
+ });
222
+
223
+ it('returns the same response for a repeated request without recomputing', async () => {
224
+ const first = await ask({ prompt: 'hello' });
225
+ const second = await ask({ prompt: 'hello' });
226
+ expect(first).toBe(second);
227
+ expect(client.computations).toBe(1);
228
+ });
229
+
230
+ it('recomputes when the cache entry expires', async () => {
231
+ await ask({ prompt: 'hello' });
232
+ const before = client.computations;
233
+
234
+ // Simulate the entry aging past the TTL.
235
+ const originalNow = client.now;
236
+ client.now = () => originalNow() + 25 * 60 * 60 * 1000;
237
+
238
+ await ask({ prompt: 'hello' });
239
+ expect(client.computations).toBe(before + 1);
240
+ });
241
+
242
+ it('surfaces model-not-found as a typed error', async () => {
243
+ client.failures.set('missing-model', { kind: 'model-not-found', model: 'nope' });
244
+ await expect(ask({ prompt: 'missing-model please' })).rejects.toMatchObject({
245
+ kind: 'model-not-found',
246
+ });
247
+ });
248
+
249
+ it('surfaces connection failure as a typed error', async () => {
250
+ client.failures.set('down', { kind: 'connection', cause: 'ECONNREFUSED' });
251
+ await expect(ask({ prompt: 'down service' })).rejects.toMatchObject({
252
+ kind: 'connection',
253
+ });
254
+ });
255
+
256
+ it('lists models through the interface', async () => {
257
+ const models = await listModels();
258
+ expect(models).toContain('test-model');
259
+ });
260
+ });
261
+
262
+ describe('Command runner through its seam (fake AI adapter injected)', () => {
263
+ let client: InMemoryAIClient;
264
+
265
+ beforeEach(() => {
266
+ client = new InMemoryAIClient(new Map([['happy', 'the answer']]));
267
+ setAIClient(client);
268
+ });
269
+
270
+ function makeSpec(overrides: Partial<Parameters<typeof runCommand>[0]> = {}) {
271
+ return {
272
+ name: 'explain',
273
+ input: { query: 'happy' },
274
+ header: '๐Ÿ“š Explanation: ',
275
+ buildRequest: (input: Record<string, string>, model: string) => ({
276
+ prompt: input.query,
277
+ systemMessage: 'sys',
278
+ model,
279
+ }),
280
+ ...overrides,
281
+ };
282
+ }
283
+
284
+ it('runs the full pipeline for a happy path', async () => {
285
+ let completed: string | undefined;
286
+ await runCommand(makeSpec({ onComplete: (response) => { completed = response; } }));
287
+ expect(completed).toBe('the answer');
288
+ });
289
+
290
+ it('short-circuits on validation failure before the AI call', async () => {
291
+ const computationsBefore = client.computations;
292
+ await runCommand(makeSpec({ input: { query: '<script>alert(1)</script>' } }));
293
+ expect(client.computations).toBe(computationsBefore);
294
+ });
295
+
296
+ it('maps a typed connection error to the ollama-serve hint', async () => {
297
+ client.failures.set('happy', { kind: 'connection', cause: 'ECONNREFUSED' });
298
+ await runCommand(makeSpec());
299
+ const { printError } = await import('../src/utils/ux');
300
+ expect(jest.mocked(printError).mock.calls.length).toBeGreaterThan(0);
301
+ });
302
+
303
+ it('maps a typed model-not-found error to the pull hint', async () => {
304
+ client.failures.set('happy', { kind: 'model-not-found', model: 'nope' });
305
+ await runCommand(makeSpec());
306
+ const { printError } = await import('../src/utils/ux');
307
+ expect(jest.mocked(printError).mock.calls.length).toBeGreaterThan(0);
308
+ });
309
+ });
310
+
311
+ describe('UX Utilities', () => {
312
+ it('should create spinner correctly', () => {
313
+ const spinner = createSpinner('Testing...');
314
+ expect(spinner).toHaveProperty('start');
315
+ expect(spinner).toHaveProperty('stop');
316
+ });
317
+ });
318
+ });
@@ -0,0 +1,61 @@
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
+ });
30
+
31
+ // Global test utilities
32
+ (global as Record<string, unknown>).testUtils = {
33
+ // Helper to wait for async operations
34
+ wait: (ms: number) => new Promise(resolve => setTimeout(resolve, ms)),
35
+
36
+ // Helper to create temporary files for testing
37
+ createTempFile: (content: string, filename: string = 'test.txt') => {
38
+ const fs = require('fs');
39
+ const path = require('path');
40
+ const tempDir = path.join(process.cwd(), '.test-temp');
41
+
42
+ if (!fs.existsSync(tempDir)) {
43
+ fs.mkdirSync(tempDir, { recursive: true });
44
+ }
45
+
46
+ const filePath = path.join(tempDir, filename);
47
+ fs.writeFileSync(filePath, content);
48
+ return filePath;
49
+ },
50
+
51
+ // Helper to clean up temporary files
52
+ cleanupTempFiles: () => {
53
+ const fs = require('fs');
54
+ const path = require('path');
55
+ const tempDir = path.join(process.cwd(), '.test-temp');
56
+
57
+ if (fs.existsSync(tempDir)) {
58
+ fs.rmSync(tempDir, { recursive: true, force: true });
59
+ }
60
+ }
61
+ };
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from '@jest/globals';
2
+ import { 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[] }>;
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('installs an OIDC-capable npm CLI where semantic-release looks for executables', () => {
28
+ // The plugin uses preferLocal: true. A global npm upgrade cannot fix an
29
+ // older CLI hoisted here by a conflicting @semantic-release/npm version.
30
+ const npm = JSON.parse(readFileSync(resolve(root, 'node_modules/npm/package.json'), 'utf8'));
31
+ expect(atLeast(npm.version, '11.5.1')).toBe(true);
32
+ });
33
+
34
+ it.each(['build-publish.yml', 'release.yml', 'deploy.yml'])(
35
+ '%s provisions a supported Node and npm before publishing',
36
+ name => {
37
+ const config = workflow(name);
38
+ for (const job of Object.values(config.jobs)) {
39
+ const publishIndex = job.steps.findIndex(step =>
40
+ /npm publish|npx semantic-release/.test(step.run ?? ''));
41
+ if (publishIndex < 0) continue;
42
+ const setup = job.steps.slice(0, publishIndex).find(step =>
43
+ step.uses?.startsWith('actions/setup-node@'));
44
+ const nodeVersion = String(setup?.with?.['node-version']).replace(
45
+ /\$\{\{ env\.(\w+) \}\}/g, (_, key: string) => config.env?.[key] ?? '');
46
+ expect(atLeast(nodeVersion, '22.14.0')).toBe(true);
47
+ const npmSetup = job.steps.slice(0, publishIndex).find(step =>
48
+ /npm install --global npm@/.test(step.run ?? ''));
49
+ const npmVersion = npmSetup?.run?.match(/npm@(\d+\.\d+\.\d+)/)?.[1] ?? '0.0.0';
50
+ expect(atLeast(npmVersion, '11.5.1')).toBe(true);
51
+ }
52
+ });
53
+
54
+ it('has one automatic publisher for main pushes', () => {
55
+ const publishers = ['ci.yml', 'build-publish.yml', 'release.yml']
56
+ .filter(name => {
57
+ const config = workflow(name);
58
+ return config.on.push && Object.values(config.jobs).some(job =>
59
+ job.steps.some(step => /npx semantic-release|npm publish(?! --dry-run)/.test(step.run ?? '')));
60
+ });
61
+ expect(publishers).toEqual(['release.yml']);
62
+ });
63
+ });
64
+
65
+ describe('security workflow requirements', () => {
66
+ it('lets TruffleHog select the commit range for push, PR, schedule and manual events', () => {
67
+ const steps = workflow('security.yml').jobs['secret-scan'].steps;
68
+ const scanner = steps.find(step => step.uses?.startsWith('trufflesecurity/trufflehog@'));
69
+ expect(scanner).toBeDefined();
70
+ // Hard-coding main/HEAD makes push-to-main scans fail before scanning.
71
+ expect(scanner?.with?.base).toBeUndefined();
72
+ expect(scanner?.with?.head).toBeUndefined();
73
+ const checkout = steps.find(step => step.uses?.startsWith('actions/checkout@'));
74
+ expect(checkout?.with?.['fetch-depth']).toBe(0);
75
+ });
76
+
77
+ it('uploads the Anchore report instead of uploading the Scorecard report twice', () => {
78
+ const steps = Object.values(workflow('security.yml').jobs).flatMap(job => job.steps);
79
+ const uploads = steps.filter(step => step.uses?.includes('/upload-sarif@'));
80
+ expect(uploads.map(step => step.with?.sarif_file)).toEqual([
81
+ 'results.sarif', '${{ steps.scan.outputs.sarif }}',
82
+ ]);
83
+ });
84
+
85
+ it('meets Scorecard publishing restrictions on permissions and job isolation', () => {
86
+ const config = workflow('security.yml');
87
+ expect(Object.values(config.permissions ?? {})).not.toContain('write');
88
+ // Scorecard's results API accepts only these actions in the producing job.
89
+ const allowed = ['actions/checkout', 'actions/upload-artifact',
90
+ 'github/codeql-action/upload-sarif', 'ossf/scorecard-action', 'step-security/harden-runner'];
91
+ for (const step of config.jobs['security-scorecard'].steps) {
92
+ expect(allowed).toContain(step.uses?.split('@')[0]);
93
+ }
94
+ });
95
+ });
@@ -1,46 +1,15 @@
1
- import { askOllama } from '../core/ai.js';
2
- import ora from 'ora';
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
- const config = loadConfig();
8
- const spinner = ora('Thinking...').start();
9
- let streamed = '';
10
- const dhruvIntro = chalk.yellowBright('Dhruv CLI: Your AI-powered CLI assistant for developers using Ollama.\n');
11
- try {
12
- spinner.stop();
13
- process.stdout.write(dhruvIntro); // Print Dhruv intro before explanation
14
- process.stdout.write(chalk.green('Explanation: '));
15
- await askOllama({
16
- prompt: `Explain: ${query}`,
17
- model: config.model,
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
  }
@@ -1,40 +1,15 @@
1
- import { askOllama } from '../core/ai.js';
2
- import ora from 'ora';
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
- const config = loadConfig();
8
- const spinner = ora('Analyzing issue...').start();
9
- let streamed = '';
10
- try {
11
- spinner.stop();
12
- process.stdout.write(chalk.green('Fix suggestion: '));
13
- await askOllama({
14
- prompt: `Fix: ${query}`,
15
- model: config.model,
16
- onToken: (token) => {
17
- streamed += token;
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
  }