@rahul05ranjan/dhruv-cli 1.4.6 → 1.6.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.
Files changed (76) hide show
  1. package/.github/workflows/ci.yml +18 -238
  2. package/.github/workflows/contribution.yml +6 -141
  3. package/.github/workflows/dependabot-auto-merge.yml +1 -0
  4. package/.github/workflows/labeler.yml +1 -0
  5. package/.github/workflows/security.yml +3 -0
  6. package/CHANGELOG.md +16 -4
  7. package/README.md +145 -40
  8. package/__tests__/cli-contract.test.ts +170 -0
  9. package/__tests__/core.test.ts +193 -1
  10. package/__tests__/diagnostics.test.ts +179 -0
  11. package/__tests__/file-workflows.test.ts +234 -0
  12. package/__tests__/interactive.test.ts +134 -0
  13. package/__tests__/setup.ts +1 -0
  14. package/__tests__/workflows.test.ts +27 -4
  15. package/dist/commands/generate.d.ts +6 -1
  16. package/dist/commands/generate.js +44 -11
  17. package/dist/commands/health.d.ts +4 -1
  18. package/dist/commands/health.js +59 -16
  19. package/dist/commands/init.js +18 -7
  20. package/dist/commands/menu.js +125 -100
  21. package/dist/commands/metrics.d.ts +5 -1
  22. package/dist/commands/metrics.js +49 -10
  23. package/dist/commands/optimize.js +1 -1
  24. package/dist/commands/review.d.ts +4 -1
  25. package/dist/commands/review.js +66 -9
  26. package/dist/commands/security-check.d.ts +4 -1
  27. package/dist/commands/security-check.js +100 -6
  28. package/dist/commands/status.js +44 -4
  29. package/dist/config/config.d.ts +5 -1
  30. package/dist/config/config.js +24 -7
  31. package/dist/core/ai.d.ts +11 -0
  32. package/dist/core/ai.js +33 -9
  33. package/dist/core/command-catalog.d.ts +10 -0
  34. package/dist/core/command-catalog.js +27 -0
  35. package/dist/core/command-runner.js +106 -21
  36. package/dist/core/logger.js +1 -0
  37. package/dist/core/metrics.d.ts +28 -0
  38. package/dist/core/metrics.js +79 -0
  39. package/dist/index.js +98 -24
  40. package/dist/utils/projectType.d.ts +7 -1
  41. package/dist/utils/projectType.js +91 -13
  42. package/docs/api/assets/highlight.css +4 -4
  43. package/docs/api/index.html +161 -39
  44. package/docs/api/media/CONTRIBUTING.md +60 -0
  45. package/docs/api/media/SECURITY.md +8 -0
  46. package/docs/api/media/dhruv-cli-preview.svg +42 -0
  47. package/docs/api/media/publishing-fix.md +34 -0
  48. package/docs/dhruv-cli-preview.svg +42 -0
  49. package/docs/index.html +631 -533
  50. package/docs/publishing-fix.md +34 -0
  51. package/package.json +1 -1
  52. package/src/commands/generate.ts +50 -11
  53. package/src/commands/health.ts +62 -17
  54. package/src/commands/init.ts +18 -7
  55. package/src/commands/menu.ts +54 -30
  56. package/src/commands/metrics.ts +53 -9
  57. package/src/commands/optimize.ts +1 -1
  58. package/src/commands/review.ts +72 -9
  59. package/src/commands/security-check.ts +111 -6
  60. package/src/commands/status.ts +43 -5
  61. package/src/config/config.ts +26 -7
  62. package/src/core/ai.ts +36 -8
  63. package/src/core/command-catalog.ts +37 -0
  64. package/src/core/command-runner.ts +108 -22
  65. package/src/core/logger.ts +1 -0
  66. package/src/core/metrics.ts +105 -0
  67. package/src/index.ts +97 -24
  68. package/src/utils/projectType.ts +85 -9
  69. package/tsconfig.json +1 -1
  70. package/.github/workflows/auto-assign.yml +0 -14
  71. package/.github/workflows/build-publish.yml +0 -154
  72. package/.github/workflows/deploy.yml +0 -336
  73. package/.github/workflows/monitoring.yml +0 -270
  74. package/PUBLISHING_FIX.md +0 -92
  75. package/logs/.8a99b6cf655346317fdbf29f4fffcf91131432f3-audit.json +0 -15
  76. package/logs/.eee104bf8fff5ecd38a6a2842df260de6470a7c3-audit.json +0 -15
@@ -0,0 +1,179 @@
1
+ import { describe, expect, it, jest } from '@jest/globals';
2
+ import { saveConfig } from '../src/config/config';
3
+ import { status } from '../src/commands/status';
4
+ import { health } from '../src/commands/health';
5
+ import { metrics } from '../src/commands/metrics';
6
+ import { getOllamaStatus, listModels } from '../src/core/ai';
7
+ import { metricsCollector } from '../src/core/metrics';
8
+
9
+ jest.mock('chalk', () => {
10
+ const identity = (value: unknown) => String(value);
11
+ const makeChalk = (): unknown => new Proxy(identity, {
12
+ get: (_target, property: string | symbol) => property === 'level' ? 0 : makeChalk(),
13
+ apply: (_target, _thisArg, args: unknown[]) => String(args[0]),
14
+ });
15
+ const chalk = makeChalk() as Record<string, unknown>;
16
+ return { __esModule: true, default: chalk, ...chalk };
17
+ });
18
+
19
+ jest.mock('../src/utils/ux', () => ({
20
+ printError: jest.fn(),
21
+ printSuccess: jest.fn(),
22
+ printWarning: jest.fn(),
23
+ printInfo: jest.fn(),
24
+ createSpinner: jest.fn(),
25
+ themed: jest.fn((value: string) => value),
26
+ highlightCode: jest.fn((value: string) => value),
27
+ createProgressBar: jest.fn(),
28
+ }));
29
+
30
+ jest.mock('../src/core/ai', () => ({
31
+ listModels: jest.fn(),
32
+ getOllamaStatus: jest.fn(),
33
+ }));
34
+
35
+ jest.mock('../src/core/logger', () => ({
36
+ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
37
+ logInfo: jest.fn(),
38
+ logError: jest.fn(),
39
+ }));
40
+
41
+ describe('diagnostic commands', () => {
42
+ it('emits structured status output in JSON mode', async () => {
43
+ jest.mocked(listModels).mockResolvedValue(['test-model']);
44
+ jest.mocked(getOllamaStatus).mockResolvedValue({ endpoint: 'http://127.0.0.1:11434', version: '0.12.3' });
45
+ saveConfig({ model: 'test-model', responseFormat: 'json' });
46
+ const output: string[] = [];
47
+ const write = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
48
+ output.push(String(chunk));
49
+ return true;
50
+ });
51
+
52
+ try {
53
+ await status();
54
+ expect(JSON.parse(output.join(''))).toMatchObject({
55
+ ok: true,
56
+ command: 'status',
57
+ model: 'test-model',
58
+ availableModels: ['test-model'],
59
+ endpoint: 'http://127.0.0.1:11434',
60
+ version: '0.12.3',
61
+ });
62
+ } finally {
63
+ write.mockRestore();
64
+ saveConfig({ responseFormat: 'text' });
65
+ }
66
+ });
67
+
68
+ it('emits structured health output in JSON mode', async () => {
69
+ jest.mocked(listModels).mockResolvedValue(['test-model']);
70
+ saveConfig({ model: 'test-model', responseFormat: 'json' });
71
+ const output: string[] = [];
72
+ const write = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
73
+ output.push(String(chunk));
74
+ return true;
75
+ });
76
+
77
+ const memory = jest.spyOn(process, 'memoryUsage').mockReturnValue({
78
+ rss: 40 * 1024 * 1024,
79
+ heapTotal: 30 * 1024 * 1024,
80
+ heapUsed: 20 * 1024 * 1024,
81
+ external: 1024 * 1024,
82
+ arrayBuffers: 0,
83
+ });
84
+
85
+ try {
86
+ await health();
87
+ const result = JSON.parse(output.join('')) as Record<string, unknown>;
88
+ expect(result).toMatchObject({ ok: true, command: 'health' });
89
+ expect(result).toHaveProperty('results');
90
+ expect(result).toHaveProperty('summary');
91
+ } finally {
92
+ memory.mockRestore();
93
+ write.mockRestore();
94
+ saveConfig({ responseFormat: 'text' });
95
+ }
96
+ });
97
+
98
+ it('retains a local command summary across collector reads', () => {
99
+ metricsCollector.resetPersistent();
100
+ metricsCollector.recordCommand('explain', 1250, true);
101
+
102
+ expect(metricsCollector.getSummary()).toMatchObject({
103
+ commands: {
104
+ explain: {
105
+ runs: 1,
106
+ successes: 1,
107
+ failures: 0,
108
+ durationMs: 1250,
109
+ },
110
+ },
111
+ });
112
+
113
+ metricsCollector.resetPersistent();
114
+ });
115
+
116
+ it('retains model and cache activity in the local summary', () => {
117
+ metricsCollector.resetPersistent();
118
+ metricsCollector.recordAIRequest('test-model', 'explain', 250, true);
119
+ metricsCollector.recordCacheHit('ai-response');
120
+ metricsCollector.recordCacheMiss('ai-response');
121
+
122
+ expect(metricsCollector.getSummary()).toMatchObject({
123
+ models: {
124
+ 'test-model': { requests: 1, successes: 1, failures: 0, durationMs: 250 },
125
+ },
126
+ cache: { hits: 1, misses: 1 },
127
+ });
128
+
129
+ metricsCollector.resetPersistent();
130
+ });
131
+
132
+ it('emits persisted metrics in JSON mode', async () => {
133
+ metricsCollector.resetPersistent();
134
+ metricsCollector.recordCommand('status', 50, true);
135
+ saveConfig({ responseFormat: 'json' });
136
+ const output: string[] = [];
137
+ const write = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => {
138
+ output.push(String(chunk));
139
+ return true;
140
+ });
141
+
142
+ try {
143
+ await metrics();
144
+ expect(JSON.parse(output.join(''))).toMatchObject({
145
+ ok: true,
146
+ command: 'metrics',
147
+ summary: { commands: { status: { runs: 1 } } },
148
+ });
149
+ } finally {
150
+ write.mockRestore();
151
+ saveConfig({ responseFormat: 'text' });
152
+ metricsCollector.resetPersistent();
153
+ }
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
+ });
179
+ });
@@ -0,0 +1,234 @@
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('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
+
118
+ it('includes detected project context in review requests', async () => {
119
+ fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ devDependencies: { typescript: '^5.0.0' } }));
120
+ fs.writeFileSync(path.join(root, 'index.ts'), 'export const value = 1;');
121
+
122
+ await review(root);
123
+
124
+ expect(client.requests[0].prompt).toContain('node-typescript');
125
+ });
126
+
127
+ it('redacts credential-like values before security analysis', async () => {
128
+ const source = path.join(root, 'config.ts');
129
+ fs.writeFileSync(source, 'const API_KEY = "sk-live-super-secret";');
130
+
131
+ await securityCheck(source);
132
+
133
+ expect(client.requests).toHaveLength(1);
134
+ expect(client.requests[0].prompt).not.toContain('sk-live-super-secret');
135
+ expect(client.requests[0].prompt).toContain('[REDACTED]');
136
+ });
137
+
138
+ it('includes location and remediation for deterministic security findings', async () => {
139
+ const source = path.join(root, 'config.ts');
140
+ fs.writeFileSync(source, 'const API_KEY = "sk-live-super-secret";');
141
+
142
+ await securityCheck(source);
143
+
144
+ expect(client.requests[0].prompt).toContain('line 1');
145
+ expect(client.requests[0].prompt).toContain('rotate the credential');
146
+ });
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
+
160
+ it('scans nested project files without sending dependency directories', async () => {
161
+ fs.mkdirSync(path.join(root, 'src', 'nested'), { recursive: true });
162
+ fs.mkdirSync(path.join(root, 'node_modules', 'library'), { recursive: true });
163
+ fs.writeFileSync(path.join(root, 'src', 'nested', 'config.ts'), 'const API_KEY = "sk-live-super-secret";');
164
+ fs.writeFileSync(path.join(root, 'node_modules', 'library', 'ignored.ts'), 'const API_KEY = "sk-dependency-secret";');
165
+
166
+ await securityCheck(root);
167
+
168
+ expect(client.requests[0].prompt).toContain('src/nested/config.ts');
169
+ expect(client.requests[0].prompt).not.toContain('sk-dependency-secret');
170
+ });
171
+
172
+ it('sets a failing exit code in strict mode for high-confidence findings', async () => {
173
+ const source = path.join(root, 'unsafe.ts');
174
+ fs.writeFileSync(source, 'const API_KEY = "sk-live-super-secret";');
175
+ process.exitCode = undefined;
176
+
177
+ try {
178
+ await securityCheck(source, { strict: true });
179
+ expect(process.exitCode).toBe(1);
180
+ } finally {
181
+ process.exitCode = undefined;
182
+ }
183
+ });
184
+
185
+ it('does not overwrite an existing generated file', async () => {
186
+ const source = path.join(root, 'sample.ts');
187
+ const generated = path.join(root, 'sample.test.ts');
188
+ fs.writeFileSync(source, 'export const value = 1;');
189
+ fs.writeFileSync(generated, 'keep this work');
190
+
191
+ await generate('tests', source, { apply: true });
192
+
193
+ expect(fs.readFileSync(generated, 'utf8')).toBe('keep this work');
194
+ });
195
+
196
+ it('preserves the target language when generating tests', async () => {
197
+ const source = path.join(root, 'sample.ts');
198
+ fs.writeFileSync(source, 'export const value = 1;');
199
+
200
+ await generate('tests', source, { apply: true });
201
+
202
+ expect(fs.existsSync(path.join(root, 'sample.test.ts'))).toBe(true);
203
+ });
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
+
216
+ it('previews generated tests without writing by default', async () => {
217
+ const source = path.join(root, 'preview.ts');
218
+ fs.writeFileSync(source, 'export const value = 1;');
219
+
220
+ await generate('tests', source);
221
+
222
+ expect(fs.existsSync(path.join(root, 'preview.test.ts'))).toBe(false);
223
+ });
224
+
225
+ it('asks optimization responses to explain impact and trade-offs', async () => {
226
+ const source = path.join(root, 'sample.ts');
227
+ fs.writeFileSync(source, 'export const value = 1;');
228
+
229
+ await optimize(source);
230
+
231
+ expect(client.requests[0].prompt).toContain('expected impact');
232
+ expect(client.requests[0].prompt).toContain('trade-offs');
233
+ });
234
+ });
@@ -0,0 +1,134 @@
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('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
+
90
+ it('asks whether init should save project-local or user-global settings', async () => {
91
+ jest.mocked(listModels).mockResolvedValue(['test-model']);
92
+ const prompt = jest.mocked(inquirer.prompt);
93
+ prompt.mockResolvedValueOnce({
94
+ model: 'test-model',
95
+ responseFormat: 'text',
96
+ verbose: false,
97
+ theme: 'default',
98
+ scope: 'local',
99
+ });
100
+
101
+ await init();
102
+
103
+ const questions = prompt.mock.calls[0][0] as unknown as Array<{ name: string; choices?: string[] }>;
104
+ expect(questions.find((question) => question.name === 'scope')?.choices).toEqual(['local', 'global']);
105
+ prompt.mockReset();
106
+ });
107
+
108
+ it('offers diagnostic commands from the interactive menu', async () => {
109
+ const prompt = jest.mocked(inquirer.prompt);
110
+ prompt.mockResolvedValueOnce({ filter: '' }).mockResolvedValueOnce({ cmd: 'exit' });
111
+
112
+ await menu();
113
+
114
+ const choices = (prompt.mock.calls[1][0] as unknown as Array<{ choices: Array<{ value: string }> }>)[0].choices;
115
+ expect(choices.map(choice => choice.value)).toEqual(expect.arrayContaining([
116
+ 'status',
117
+ 'health',
118
+ 'metrics',
119
+ 'completion',
120
+ ]));
121
+ });
122
+
123
+ it('offers command filtering before opening the menu', async () => {
124
+ const prompt = jest.mocked(inquirer.prompt);
125
+ prompt.mockRejectedValueOnce(new Error('User force closed the prompt with 0 null'));
126
+
127
+ await menu();
128
+
129
+ expect((prompt.mock.calls[0][0] as unknown as Array<{ name: string; type: string }>)[0]).toMatchObject({
130
+ name: 'filter',
131
+ type: 'input',
132
+ });
133
+ });
134
+ });
@@ -26,6 +26,7 @@ const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
26
26
 
27
27
  afterEach(() => {
28
28
  mockExit.mockClear();
29
+ process.exitCode = undefined;
29
30
  });
30
31
 
31
32
  // Global test utilities
@@ -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(['build-publish.yml', 'release.yml', 'deploy.yml'])(
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', 'build-publish.yml', 'release.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 declare function generate(type: string, target: string): Promise<void>;
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>;