@rigour-labs/cli 3.0.4 → 3.0.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.
@@ -0,0 +1,279 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import yaml from 'yaml';
5
+ import { globby } from 'globby';
6
+ import { GateRunner, ConfigSchema, DiscoveryService, FixPacketService, recordScore, getScoreTrend, } from '@rigour-labs/core';
7
+ // Exit codes per spec
8
+ const EXIT_PASS = 0;
9
+ const EXIT_FAIL = 1;
10
+ const EXIT_CONFIG_ERROR = 2;
11
+ const EXIT_INTERNAL_ERROR = 3;
12
+ const LANGUAGE_PATTERNS = {
13
+ 'TypeScript': ['**/*.ts', '**/*.tsx'],
14
+ 'JavaScript': ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'],
15
+ 'Python': ['**/*.py'],
16
+ 'Go': ['**/*.go'],
17
+ 'Java': ['**/*.java'],
18
+ 'Kotlin': ['**/*.kt'],
19
+ 'C#': ['**/*.cs'],
20
+ 'Ruby': ['**/*.rb', '**/*.rake'],
21
+ 'Rust': ['**/*.rs'],
22
+ };
23
+ const COMMON_IGNORE = [
24
+ '**/node_modules/**',
25
+ '**/.git/**',
26
+ '**/dist/**',
27
+ '**/build/**',
28
+ '**/.next/**',
29
+ '**/coverage/**',
30
+ '**/vendor/**',
31
+ '**/.venv/**',
32
+ '**/venv/**',
33
+ '**/target/**',
34
+ '**/.terraform/**',
35
+ '**/*.min.js',
36
+ ];
37
+ const HEADLINE_GATE_SUPPORT = {
38
+ 'hallucinated-imports': ['TypeScript', 'JavaScript', 'Python', 'Go', 'Ruby', 'C#', 'Rust', 'Java', 'Kotlin'],
39
+ 'phantom-apis': ['TypeScript', 'JavaScript', 'Python', 'Go', 'C#', 'Java', 'Kotlin'],
40
+ 'deprecated-apis': ['TypeScript', 'JavaScript', 'Python', 'Go', 'C#', 'Java', 'Kotlin'],
41
+ 'promise-safety': ['TypeScript', 'JavaScript', 'Python', 'Go', 'Ruby', 'C#'],
42
+ 'security-patterns': ['TypeScript', 'JavaScript', 'Python', 'Go', 'Java', 'Kotlin'],
43
+ 'duplication-drift': ['TypeScript', 'JavaScript', 'Python'],
44
+ 'inconsistent-error-handling': ['TypeScript', 'JavaScript'],
45
+ 'context-window-artifacts': ['TypeScript', 'JavaScript', 'Python'],
46
+ };
47
+ export async function scanCommand(cwd, files = [], options = {}) {
48
+ try {
49
+ const scanCtx = await resolveScanConfig(cwd, options);
50
+ const stackSignals = await detectStackSignals(cwd);
51
+ if (!options.ci && !options.json) {
52
+ renderScanHeader(scanCtx, stackSignals);
53
+ }
54
+ const runner = new GateRunner(scanCtx.config);
55
+ const report = await runner.run(cwd, files.length > 0 ? files : undefined);
56
+ // Write machine report and score history
57
+ const reportPath = path.join(cwd, scanCtx.config.output.report_path);
58
+ await fs.writeJson(reportPath, report, { spaces: 2 });
59
+ recordScore(cwd, report);
60
+ // Generate fix packet on failure
61
+ if (report.status === 'FAIL') {
62
+ const fixPacketService = new FixPacketService();
63
+ const fixPacket = fixPacketService.generate(report, scanCtx.config);
64
+ const fixPacketPath = path.join(cwd, 'rigour-fix-packet.json');
65
+ await fs.writeJson(fixPacketPath, fixPacket, { spaces: 2 });
66
+ }
67
+ if (options.json) {
68
+ process.stdout.write(JSON.stringify({
69
+ mode: scanCtx.mode,
70
+ preset: scanCtx.detectedPreset ?? scanCtx.config.preset,
71
+ paradigm: scanCtx.detectedParadigm ?? scanCtx.config.paradigm,
72
+ stack: stackSignals,
73
+ report,
74
+ }, null, 2) + '\n');
75
+ process.exit(report.status === 'PASS' ? EXIT_PASS : EXIT_FAIL);
76
+ }
77
+ if (options.ci) {
78
+ const score = report.stats.score ?? 0;
79
+ if (report.status === 'PASS') {
80
+ console.log(`PASS (${score}/100)`);
81
+ }
82
+ else {
83
+ console.log(`FAIL: ${report.failures.length} violation(s) | Score: ${score}/100`);
84
+ }
85
+ process.exit(report.status === 'PASS' ? EXIT_PASS : EXIT_FAIL);
86
+ }
87
+ renderScanResults(report, stackSignals, scanCtx.config.output.report_path, cwd);
88
+ process.exit(report.status === 'PASS' ? EXIT_PASS : EXIT_FAIL);
89
+ }
90
+ catch (error) {
91
+ if (error.name === 'ZodError') {
92
+ console.error(chalk.red('\nInvalid configuration for scan mode:'));
93
+ error.issues.forEach((issue) => {
94
+ console.error(chalk.red(` • ${issue.path.join('.')}: ${issue.message}`));
95
+ });
96
+ process.exit(EXIT_CONFIG_ERROR);
97
+ }
98
+ console.error(chalk.red(`Internal error: ${error.message}`));
99
+ process.exit(EXIT_INTERNAL_ERROR);
100
+ }
101
+ }
102
+ async function resolveScanConfig(cwd, options) {
103
+ const explicitConfig = options.config ? path.resolve(cwd, options.config) : undefined;
104
+ const defaultConfig = path.join(cwd, 'rigour.yml');
105
+ const configPath = explicitConfig || defaultConfig;
106
+ if (await fs.pathExists(configPath)) {
107
+ const configContent = await fs.readFile(configPath, 'utf-8');
108
+ const rawConfig = yaml.parse(configContent);
109
+ const config = ConfigSchema.parse(rawConfig);
110
+ return {
111
+ mode: 'existing-config',
112
+ config,
113
+ configPath,
114
+ detectedPreset: config.preset,
115
+ detectedParadigm: config.paradigm,
116
+ };
117
+ }
118
+ const discovery = new DiscoveryService();
119
+ const discovered = await discovery.discover(cwd);
120
+ return {
121
+ mode: 'auto-discovered',
122
+ config: ConfigSchema.parse(discovered.config),
123
+ detectedPreset: discovered.matches.preset?.name,
124
+ detectedParadigm: discovered.matches.paradigm?.name,
125
+ };
126
+ }
127
+ async function detectStackSignals(cwd) {
128
+ const languageChecks = await Promise.all(Object.entries(LANGUAGE_PATTERNS).map(async ([language, patterns]) => {
129
+ const matches = await globby(patterns, { cwd, gitignore: true, ignore: COMMON_IGNORE });
130
+ return { language, found: matches.length > 0 };
131
+ }));
132
+ const languages = languageChecks.filter(item => item.found).map(item => item.language);
133
+ const [dockerMatches, terraformMatches, sqlMatches] = await Promise.all([
134
+ globby(['**/Dockerfile', '**/docker-compose*.yml', '**/*.dockerfile'], { cwd, gitignore: true, ignore: COMMON_IGNORE }),
135
+ globby(['**/*.tf', '**/*.tfvars', '**/*.hcl'], { cwd, gitignore: true, ignore: COMMON_IGNORE }),
136
+ globby(['**/*.sql'], { cwd, gitignore: true, ignore: COMMON_IGNORE }),
137
+ ]);
138
+ return {
139
+ languages,
140
+ hasDocker: dockerMatches.length > 0,
141
+ hasTerraform: terraformMatches.length > 0,
142
+ hasSql: sqlMatches.length > 0,
143
+ };
144
+ }
145
+ function renderScanHeader(scanCtx, stackSignals) {
146
+ console.log(chalk.bold.cyan('\nRigour Scan'));
147
+ console.log(chalk.dim('Zero-config security and AI-drift sweep using existing Rigour gates.\n'));
148
+ const modeLabel = scanCtx.mode === 'existing-config'
149
+ ? `Using existing config: ${path.basename(scanCtx.configPath || 'rigour.yml')}`
150
+ : 'Auto-discovered config (no rigour.yml required)';
151
+ const preset = scanCtx.detectedPreset || scanCtx.config.preset || 'universal';
152
+ const paradigm = scanCtx.detectedParadigm || scanCtx.config.paradigm || 'general';
153
+ console.log(chalk.bold(`Mode:`) + ` ${modeLabel}`);
154
+ console.log(chalk.bold(`Detected profile:`) + ` preset=${preset}, paradigm=${paradigm}`);
155
+ console.log(chalk.bold(`Detected stack:`) + ` ${stackSignals.languages.join(', ') || 'No major language signatures detected'}`);
156
+ console.log('');
157
+ }
158
+ function renderScanResults(report, stackSignals, reportPath, cwd) {
159
+ const fakePackages = extractHallucinatedImports(report.failures);
160
+ const criticalSecrets = report.failures.filter(f => f.id === 'security-patterns' && f.severity === 'critical');
161
+ const phantomApis = report.failures.filter(f => f.id === 'phantom-apis');
162
+ const ignoredErrors = report.failures.filter(f => f.id === 'promise-safety' && (f.severity === 'high' || f.severity === 'critical'));
163
+ // --- Scary headlines for the worst findings ---
164
+ let scaryHeadlines = 0;
165
+ if (criticalSecrets.length > 0) {
166
+ console.log(chalk.red.bold(`🔑 HARDCODED SECRETS: ${criticalSecrets.length} credential(s) exposed in plain text`));
167
+ const firstFile = criticalSecrets[0].files?.[0];
168
+ if (firstFile)
169
+ console.log(chalk.dim(` First hit: ${firstFile}`));
170
+ scaryHeadlines++;
171
+ }
172
+ if (fakePackages.length > 0) {
173
+ const unique = [...new Set(fakePackages)];
174
+ console.log(chalk.red.bold(`📦 HALLUCINATED PACKAGES: ${unique.length} import(s) don't exist — will crash at runtime`));
175
+ console.log(chalk.dim(` Examples: ${unique.slice(0, 4).join(', ')}${unique.length > 4 ? `, +${unique.length - 4} more` : ''}`));
176
+ scaryHeadlines++;
177
+ }
178
+ if (phantomApis.length > 0) {
179
+ console.log(chalk.red.bold(`👻 PHANTOM APIs: ${phantomApis.length} call(s) to methods that don't exist in stdlib`));
180
+ scaryHeadlines++;
181
+ }
182
+ if (ignoredErrors.length > 0) {
183
+ console.log(chalk.yellow.bold(`🔇 SILENT FAILURES: ${ignoredErrors.length} async error(s) swallowed — failures will vanish without a trace`));
184
+ scaryHeadlines++;
185
+ }
186
+ if (scaryHeadlines > 0)
187
+ console.log('');
188
+ const statusColor = report.status === 'PASS' ? chalk.green.bold : chalk.red.bold;
189
+ const statusLabel = report.status === 'PASS' ? 'PASS' : 'FAIL';
190
+ const score = report.stats.score ?? 0;
191
+ const aiHealth = report.stats.ai_health_score ?? 0;
192
+ const structural = report.stats.structural_score ?? 0;
193
+ console.log(statusColor(`${statusLabel} | Score ${score}/100 | AI Health ${aiHealth}/100 | Structural ${structural}/100`));
194
+ const severity = report.stats.severity_breakdown || {};
195
+ const sevParts = ['critical', 'high', 'medium', 'low', 'info']
196
+ .filter(level => (severity[level] || 0) > 0)
197
+ .map(level => `${level}: ${severity[level]}`);
198
+ if (sevParts.length > 0) {
199
+ console.log(`Severity: ${sevParts.join(', ')}`);
200
+ }
201
+ renderCoverageWarnings(stackSignals);
202
+ console.log('');
203
+ if (report.status === 'FAIL') {
204
+ // Sort by severity so critical findings appear first
205
+ const SEVERITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
206
+ const sorted = [...report.failures].sort((a, b) => (SEVERITY_ORDER[a.severity ?? 'medium'] ?? 2) - (SEVERITY_ORDER[b.severity ?? 'medium'] ?? 2));
207
+ const topFindings = sorted.slice(0, 8);
208
+ for (const failure of topFindings) {
209
+ const sev = failure.severity ?? 'medium';
210
+ const sevColor = sev === 'critical' ? chalk.red.bold
211
+ : sev === 'high' ? chalk.yellow.bold
212
+ : sev === 'medium' ? chalk.white
213
+ : chalk.dim;
214
+ console.log(sevColor(`${sev.toUpperCase().padEnd(8)} [${failure.id}] ${failure.title}`));
215
+ if (failure.files && failure.files.length > 0) {
216
+ console.log(chalk.dim(` ${failure.files.slice(0, 2).join(', ')}`));
217
+ }
218
+ }
219
+ if (report.failures.length > topFindings.length) {
220
+ console.log(chalk.dim(`\n...and ${report.failures.length - topFindings.length} more. See full report.`));
221
+ }
222
+ }
223
+ const trend = getScoreTrend(cwd);
224
+ if (trend && trend.recentScores.length >= 3) {
225
+ const arrow = trend.direction === 'improving' ? '↑' : trend.direction === 'degrading' ? '↓' : '→';
226
+ const color = trend.direction === 'improving' ? chalk.green : trend.direction === 'degrading' ? chalk.red : chalk.dim;
227
+ console.log(color(`\nTrend: ${trend.recentScores.join(' → ')} ${arrow}`));
228
+ }
229
+ console.log(chalk.yellow(`\nFull report: ${reportPath}`));
230
+ if (report.status === 'FAIL') {
231
+ console.log(chalk.yellow('Fix packet: rigour-fix-packet.json'));
232
+ }
233
+ console.log(chalk.dim(`Finished in ${report.stats.duration_ms}ms`));
234
+ // --- Next steps ---
235
+ console.log('');
236
+ if (report.status === 'FAIL') {
237
+ console.log(chalk.bold('Next steps:'));
238
+ console.log(` ${chalk.cyan('rigour explain')} — get plain-English fix suggestions`);
239
+ console.log(` ${chalk.cyan('rigour init')} — add quality gates to your project (blocks AI from repeating this)`);
240
+ console.log(` ${chalk.cyan('rigour check --ci')} — enforce in CI/CD pipeline`);
241
+ }
242
+ else {
243
+ console.log(chalk.green.bold('✓ This repo is clean. Add it to CI to keep it that way:'));
244
+ console.log(` ${chalk.cyan('rigour init')} — write quality gates to rigour.yml + CI config`);
245
+ }
246
+ }
247
+ function renderCoverageWarnings(stackSignals) {
248
+ const gaps = [];
249
+ for (const language of stackSignals.languages) {
250
+ const supportedBy = Object.entries(HEADLINE_GATE_SUPPORT)
251
+ .filter(([, languages]) => languages.includes(language))
252
+ .map(([gateId]) => gateId);
253
+ if (supportedBy.length < 3) {
254
+ gaps.push(`${language}: partial support (${supportedBy.join(', ') || 'none'})`);
255
+ }
256
+ }
257
+ if (stackSignals.hasDocker || stackSignals.hasTerraform) {
258
+ gaps.push('Infra files detected (Docker/Terraform) but no dedicated vulnerability/drift gate yet');
259
+ }
260
+ if (stackSignals.hasSql) {
261
+ gaps.push('SQL files detected but no dedicated .sql static gate yet (string-level SQL checks only)');
262
+ }
263
+ if (gaps.length > 0) {
264
+ console.log(chalk.yellow('Coverage gaps to close:'));
265
+ gaps.forEach(gap => console.log(chalk.yellow(` - ${gap}`)));
266
+ }
267
+ }
268
+ function extractHallucinatedImports(failures) {
269
+ const fakeImports = [];
270
+ for (const failure of failures) {
271
+ if (failure.id !== 'hallucinated-imports')
272
+ continue;
273
+ const matches = failure.details.matchAll(/import '([^']+)'/g);
274
+ for (const match of matches) {
275
+ fakeImports.push(match[1]);
276
+ }
277
+ }
278
+ return fakeImports;
279
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import { scanCommand } from './scan.js';
6
+ describe('scanCommand', () => {
7
+ let testDir;
8
+ beforeEach(async () => {
9
+ testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'rigour-scan-test-'));
10
+ vi.spyOn(process, 'exit').mockImplementation(() => undefined);
11
+ vi.spyOn(console, 'log').mockImplementation(() => { });
12
+ vi.spyOn(console, 'error').mockImplementation(() => { });
13
+ });
14
+ afterEach(async () => {
15
+ await fs.remove(testDir);
16
+ vi.restoreAllMocks();
17
+ });
18
+ it('runs in zero-config mode without rigour.yml', async () => {
19
+ await fs.writeJson(path.join(testDir, 'package.json'), { name: 'scan-zero-config-test' });
20
+ await fs.writeFile(path.join(testDir, 'index.js'), "import fake from 'totally-fake-package';\nconsole.log(fake);\n");
21
+ await expect(scanCommand(testDir, [], {})).resolves.not.toThrow();
22
+ expect(process.exit).toHaveBeenCalled();
23
+ expect(await fs.pathExists(path.join(testDir, 'rigour-report.json'))).toBe(true);
24
+ }, 30_000);
25
+ it('uses provided config path when passed', async () => {
26
+ await fs.writeFile(path.join(testDir, 'app.js'), "export const ok = 42;\n");
27
+ await fs.writeFile(path.join(testDir, 'scan-config.yml'), `
28
+ version: 1
29
+ gates:
30
+ required_files: []
31
+ forbid_todos: false
32
+ forbid_fixme: false
33
+ context:
34
+ enabled: false
35
+ environment:
36
+ enabled: false
37
+ retry_loop_breaker:
38
+ enabled: false
39
+ security:
40
+ enabled: false
41
+ duplication_drift:
42
+ enabled: false
43
+ hallucinated_imports:
44
+ enabled: false
45
+ inconsistent_error_handling:
46
+ enabled: false
47
+ context_window_artifacts:
48
+ enabled: false
49
+ promise_safety:
50
+ enabled: false
51
+ phantom_apis:
52
+ enabled: false
53
+ deprecated_apis:
54
+ enabled: false
55
+ test_quality:
56
+ enabled: false
57
+ output:
58
+ report_path: scan-report.json
59
+ `);
60
+ await expect(scanCommand(testDir, [], { config: 'scan-config.yml' })).resolves.not.toThrow();
61
+ expect(process.exit).toHaveBeenCalledWith(0);
62
+ expect(await fs.pathExists(path.join(testDir, 'scan-report.json'))).toBe(true);
63
+ }, 30_000);
64
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/cli",
3
- "version": "3.0.4",
3
+ "version": "3.0.6",
4
4
  "description": "CLI quality gates for AI-generated code. Forces AI agents (Claude, Cursor, Copilot) to meet strict engineering standards with PASS/FAIL enforcement.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://rigour.run",
@@ -44,7 +44,7 @@
44
44
  "inquirer": "9.2.16",
45
45
  "ora": "^8.0.1",
46
46
  "yaml": "^2.8.2",
47
- "@rigour-labs/core": "3.0.4"
47
+ "@rigour-labs/core": "3.0.6"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@types/fs-extra": "^11.0.4",