@delegance/claude-autopilot 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/preflight.ts +8 -5
- package/src/cli/run.ts +7 -0
- package/src/core/static-rules/registry.ts +42 -0
- package/src/core/static-rules/rules/console-log.ts +42 -0
- package/src/core/static-rules/rules/hardcoded-secrets.ts +57 -0
- package/src/core/static-rules/rules/large-file.ts +37 -0
- package/src/core/static-rules/rules/missing-tests.ts +57 -0
- package/src/core/static-rules/rules/npm-audit.ts +38 -0
- package/src/core/static-rules/rules/package-lock-sync.ts +54 -0
- package/src/core/static-rules/rules/todo-fixme.ts +40 -0
package/package.json
CHANGED
package/src/cli/preflight.ts
CHANGED
|
@@ -87,14 +87,17 @@ export async function runDoctor(): Promise<DoctorResult> {
|
|
|
87
87
|
: undefined,
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
// 6. OPENAI_API_KEY
|
|
90
|
+
// 6. LLM API key (ANTHROPIC_API_KEY preferred, OPENAI_API_KEY as fallback)
|
|
91
91
|
const envVars = envFile ? loadEnvFile(envFile) : {};
|
|
92
|
+
const hasAnthropic = !!process.env.ANTHROPIC_API_KEY || !!envVars['ANTHROPIC_API_KEY'];
|
|
92
93
|
const hasOpenAI = !!process.env.OPENAI_API_KEY || !!envVars['OPENAI_API_KEY'];
|
|
94
|
+
const hasLLMKey = hasAnthropic || hasOpenAI;
|
|
95
|
+
const llmKeyName = hasAnthropic ? 'ANTHROPIC_API_KEY' : hasOpenAI ? 'OPENAI_API_KEY' : 'none';
|
|
93
96
|
checks.push({
|
|
94
|
-
name:
|
|
95
|
-
result:
|
|
96
|
-
message: !
|
|
97
|
-
? `
|
|
97
|
+
name: `LLM API key (${llmKeyName})`,
|
|
98
|
+
result: hasLLMKey ? 'pass' : 'warn',
|
|
99
|
+
message: !hasLLMKey
|
|
100
|
+
? `No LLM API key found — set ANTHROPIC_API_KEY (recommended) or OPENAI_API_KEY to enable review`
|
|
98
101
|
: undefined,
|
|
99
102
|
});
|
|
100
103
|
|
package/src/cli/run.ts
CHANGED
|
@@ -21,6 +21,7 @@ for (const f of ENV_FILES) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
import { loadConfig } from '../core/config/loader.ts';
|
|
24
|
+
import { loadRulesFromConfig } from '../core/static-rules/registry.ts';
|
|
24
25
|
import { resolvePreset } from '../core/config/preset-resolver.ts';
|
|
25
26
|
import { mergeConfigs } from '../core/config/preset-resolver.ts';
|
|
26
27
|
import { loadAdapter } from '../adapters/loader.ts';
|
|
@@ -127,11 +128,17 @@ export async function runCommand(options: RunCommandOptions = {}): Promise<numbe
|
|
|
127
128
|
}
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
// Load static rules from config
|
|
132
|
+
const staticRules = config.staticRules && config.staticRules.length > 0
|
|
133
|
+
? await loadRulesFromConfig(config.staticRules)
|
|
134
|
+
: [];
|
|
135
|
+
|
|
130
136
|
// Execute pipeline
|
|
131
137
|
const input: RunInput = {
|
|
132
138
|
touchedFiles,
|
|
133
139
|
config,
|
|
134
140
|
reviewEngine,
|
|
141
|
+
staticRules,
|
|
135
142
|
cwd,
|
|
136
143
|
};
|
|
137
144
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { StaticRule } from '../phases/static-rules.ts';
|
|
2
|
+
import type { StaticRuleReference } from '../config/types.ts';
|
|
3
|
+
|
|
4
|
+
// Built-in cross-stack rules
|
|
5
|
+
const BUILTIN: Record<string, () => Promise<StaticRule>> = {
|
|
6
|
+
'hardcoded-secrets': () => import('./rules/hardcoded-secrets.ts').then(m => m.hardcodedSecretsRule),
|
|
7
|
+
'npm-audit': () => import('./rules/npm-audit.ts').then(m => m.npmAuditRule),
|
|
8
|
+
'package-lock-sync': () => import('./rules/package-lock-sync.ts').then(m => m.packageLockSyncRule),
|
|
9
|
+
'console-log': () => import('./rules/console-log.ts').then(m => m.consoleLogRule),
|
|
10
|
+
'todo-fixme': () => import('./rules/todo-fixme.ts').then(m => m.todoFixmeRule),
|
|
11
|
+
'large-file': () => import('./rules/large-file.ts').then(m => m.largeFileRule),
|
|
12
|
+
'missing-tests': () => import('./rules/missing-tests.ts').then(m => m.missingTestsRule),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Preset-specific rules registered by name
|
|
16
|
+
const PRESET: Record<string, () => Promise<StaticRule>> = {
|
|
17
|
+
'supabase-rls-bypass': () => import('../../../presets/nextjs-supabase/rules/supabase-rls-bypass.ts').then(m => m.supabaseRlsBypassRule),
|
|
18
|
+
'go-sql-injection': () => import('../../../presets/go/rules/go-sql-injection.ts').then(m => m.goSqlInjectionRule),
|
|
19
|
+
'fastapi-missing-auth': () => import('../../../presets/python-fastapi/rules/fastapi-missing-auth.ts').then(m => m.fastapiMissingAuthRule),
|
|
20
|
+
't3-server-only': () => import('../../../presets/t3/rules/t3-server-only.ts').then(m => m.t3ServerOnlyRule),
|
|
21
|
+
'rails-sql-injection': () => import('../../../presets/rails-postgres/rules/rails-sql-injection.ts').then(m => m.railsSqlInjectionRule),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const ALL = { ...BUILTIN, ...PRESET };
|
|
25
|
+
|
|
26
|
+
export async function loadRulesFromConfig(refs: StaticRuleReference[]): Promise<StaticRule[]> {
|
|
27
|
+
const rules: StaticRule[] = [];
|
|
28
|
+
for (const ref of refs) {
|
|
29
|
+
const name = typeof ref === 'string' ? ref : ref.adapter;
|
|
30
|
+
const loader = ALL[name];
|
|
31
|
+
if (loader) {
|
|
32
|
+
rules.push(await loader());
|
|
33
|
+
} else {
|
|
34
|
+
process.stderr.write(`[autopilot] Unknown static rule: "${name}" — skipping\n`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return rules;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function listAvailableRules(): string[] {
|
|
41
|
+
return Object.keys(ALL);
|
|
42
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import type { StaticRule } from '../../phases/static-rules.ts';
|
|
3
|
+
import type { Finding } from '../../findings/types.ts';
|
|
4
|
+
|
|
5
|
+
const CONSOLE_CALLS = /\bconsole\.(log|debug|info)\s*\(/;
|
|
6
|
+
const CODE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs']);
|
|
7
|
+
const TEST_PATH = /(?:__tests__|\.test\.|\.spec\.|\/test\/|\/tests\/)/;
|
|
8
|
+
|
|
9
|
+
export const consoleLogRule: StaticRule = {
|
|
10
|
+
name: 'console-log',
|
|
11
|
+
severity: 'warning',
|
|
12
|
+
|
|
13
|
+
async check(touchedFiles: string[]): Promise<Finding[]> {
|
|
14
|
+
const findings: Finding[] = [];
|
|
15
|
+
for (const file of touchedFiles) {
|
|
16
|
+
const ext = file.slice(file.lastIndexOf('.'));
|
|
17
|
+
if (!CODE_EXTS.has(ext) || TEST_PATH.test(file)) continue;
|
|
18
|
+
let content: string;
|
|
19
|
+
try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }
|
|
20
|
+
const lines = content.split('\n');
|
|
21
|
+
for (let i = 0; i < lines.length; i++) {
|
|
22
|
+
const line = lines[i]!;
|
|
23
|
+
if (line.trim().startsWith('//')) continue;
|
|
24
|
+
if (CONSOLE_CALLS.test(line)) {
|
|
25
|
+
findings.push({
|
|
26
|
+
id: `console-log:${file}:${i + 1}`,
|
|
27
|
+
source: 'static-rules',
|
|
28
|
+
severity: 'warning',
|
|
29
|
+
category: 'console-log',
|
|
30
|
+
file,
|
|
31
|
+
line: i + 1,
|
|
32
|
+
message: 'console.log/debug/info left in production code',
|
|
33
|
+
suggestion: 'Remove or replace with a structured logger',
|
|
34
|
+
protectedPath: false,
|
|
35
|
+
createdAt: new Date().toISOString(),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return findings;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import type { StaticRule } from '../../phases/static-rules.ts';
|
|
3
|
+
import type { Finding } from '../../findings/types.ts';
|
|
4
|
+
|
|
5
|
+
const SECRET_PATTERNS: { regex: RegExp; label: string }[] = [
|
|
6
|
+
{ regex: /\bAKIA[0-9A-Z]{16}\b/, label: 'AWS Access Key ID' },
|
|
7
|
+
{ regex: /(?:password|passwd|pwd)\s*[:=]\s*['"][^'"]{6,}['"]/, label: 'Hardcoded password' },
|
|
8
|
+
{ regex: /(?:api_key|apikey|api-key)\s*[:=]\s*['"][^'"]{8,}['"]/, label: 'Hardcoded API key' },
|
|
9
|
+
{ regex: /(?:secret|secret_key|secretkey)\s*[:=]\s*['"][^'"]{8,}['"]/, label: 'Hardcoded secret' },
|
|
10
|
+
{ regex: /(?:access_token|accesstoken)\s*[:=]\s*['"][^'"]{8,}['"]/, label: 'Hardcoded access token' },
|
|
11
|
+
{ regex: /(?:private_key|privatekey)\s*[:=]\s*['"][^'"]{8,}['"]/, label: 'Hardcoded private key' },
|
|
12
|
+
{ regex: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/, label: 'Private key block' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
// Patterns that indicate a placeholder, not a real secret
|
|
16
|
+
const PLACEHOLDER = /(?:your[-_]?|xxx|placeholder|example|test|fake|dummy|changeme|<[^>]+>)/i;
|
|
17
|
+
const SKIP_EXTS = new Set(['.md', '.txt', '.yaml', '.yml', '.json', '.lock', '.snap']);
|
|
18
|
+
const TEST_PATH = /(?:__tests__|\.test\.|\.spec\.|\/test\/|\/tests\/)/;
|
|
19
|
+
|
|
20
|
+
export const hardcodedSecretsRule: StaticRule = {
|
|
21
|
+
name: 'hardcoded-secrets',
|
|
22
|
+
severity: 'critical',
|
|
23
|
+
|
|
24
|
+
async check(touchedFiles: string[]): Promise<Finding[]> {
|
|
25
|
+
const findings: Finding[] = [];
|
|
26
|
+
for (const file of touchedFiles) {
|
|
27
|
+
const ext = file.slice(file.lastIndexOf('.'));
|
|
28
|
+
if (SKIP_EXTS.has(ext) || TEST_PATH.test(file)) continue;
|
|
29
|
+
let content: string;
|
|
30
|
+
try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }
|
|
31
|
+
const lines = content.split('\n');
|
|
32
|
+
for (let i = 0; i < lines.length; i++) {
|
|
33
|
+
const line = lines[i]!;
|
|
34
|
+
if (line.trim().startsWith('//') || line.trim().startsWith('#')) continue;
|
|
35
|
+
for (const { regex, label } of SECRET_PATTERNS) {
|
|
36
|
+
const match = line.match(regex);
|
|
37
|
+
if (match && !PLACEHOLDER.test(match[0])) {
|
|
38
|
+
findings.push({
|
|
39
|
+
id: `hardcoded-secrets:${file}:${i + 1}`,
|
|
40
|
+
source: 'static-rules',
|
|
41
|
+
severity: 'critical',
|
|
42
|
+
category: 'hardcoded-secrets',
|
|
43
|
+
file,
|
|
44
|
+
line: i + 1,
|
|
45
|
+
message: `${label} appears hardcoded`,
|
|
46
|
+
suggestion: 'Move to environment variable and load via process.env',
|
|
47
|
+
protectedPath: false,
|
|
48
|
+
createdAt: new Date().toISOString(),
|
|
49
|
+
});
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return findings;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import type { StaticRule } from '../../phases/static-rules.ts';
|
|
3
|
+
import type { Finding } from '../../findings/types.ts';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_THRESHOLD = 500;
|
|
6
|
+
const SKIP_EXTS = new Set(['.lock', '.snap', '.map', '.min.js', '.min.css']);
|
|
7
|
+
|
|
8
|
+
export const largeFileRule: StaticRule = {
|
|
9
|
+
name: 'large-file',
|
|
10
|
+
severity: 'note',
|
|
11
|
+
|
|
12
|
+
async check(touchedFiles: string[]): Promise<Finding[]> {
|
|
13
|
+
const threshold = parseInt(process.env.AUTOPILOT_LARGE_FILE_LINES ?? '', 10) || DEFAULT_THRESHOLD;
|
|
14
|
+
const findings: Finding[] = [];
|
|
15
|
+
for (const file of touchedFiles) {
|
|
16
|
+
const ext = file.slice(file.lastIndexOf('.'));
|
|
17
|
+
if (SKIP_EXTS.has(ext)) continue;
|
|
18
|
+
let content: string;
|
|
19
|
+
try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }
|
|
20
|
+
const lines = content.split('\n').length;
|
|
21
|
+
if (lines > threshold) {
|
|
22
|
+
findings.push({
|
|
23
|
+
id: `large-file:${file}`,
|
|
24
|
+
source: 'static-rules',
|
|
25
|
+
severity: 'note',
|
|
26
|
+
category: 'large-file',
|
|
27
|
+
file,
|
|
28
|
+
message: `File is ${lines} lines (threshold: ${threshold})`,
|
|
29
|
+
suggestion: 'Consider splitting into smaller, focused modules',
|
|
30
|
+
protectedPath: false,
|
|
31
|
+
createdAt: new Date().toISOString(),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return findings;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import type { StaticRule } from '../../phases/static-rules.ts';
|
|
4
|
+
import type { Finding } from '../../findings/types.ts';
|
|
5
|
+
|
|
6
|
+
const SOURCE_DIRS = ['src/', 'app/', 'lib/', 'utils/'];
|
|
7
|
+
const SOURCE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx']);
|
|
8
|
+
const TEST_PATH = /(?:__tests__|\.test\.|\.spec\.|\/test\/|\/tests\/)/;
|
|
9
|
+
const INDEX_FILE = /(?:^|[/\\])index\.[tj]sx?$/;
|
|
10
|
+
|
|
11
|
+
function isSourceFile(f: string): boolean {
|
|
12
|
+
const ext = f.slice(f.lastIndexOf('.'));
|
|
13
|
+
return SOURCE_EXTS.has(ext) && !TEST_PATH.test(f) && SOURCE_DIRS.some(d => f.startsWith(d));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function hasTestCounterpart(file: string, touchedFiles: Set<string>): boolean {
|
|
17
|
+
const base = file.replace(/\.[tj]sx?$/, '');
|
|
18
|
+
const candidates = [
|
|
19
|
+
`${base}.test.ts`, `${base}.test.tsx`, `${base}.test.js`,
|
|
20
|
+
`${base}.spec.ts`, `${base}.spec.tsx`, `${base}.spec.js`,
|
|
21
|
+
];
|
|
22
|
+
const dir = path.dirname(file);
|
|
23
|
+
const name = path.basename(base);
|
|
24
|
+
candidates.push(
|
|
25
|
+
`${dir}/__tests__/${name}.test.ts`,
|
|
26
|
+
`${dir}/__tests__/${name}.test.tsx`,
|
|
27
|
+
`${dir}/__tests__/${name}.test.js`,
|
|
28
|
+
);
|
|
29
|
+
return candidates.some(c => touchedFiles.has(c) || fs.existsSync(c));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const missingTestsRule: StaticRule = {
|
|
33
|
+
name: 'missing-tests',
|
|
34
|
+
severity: 'note',
|
|
35
|
+
|
|
36
|
+
async check(touchedFiles: string[]): Promise<Finding[]> {
|
|
37
|
+
const touched = new Set(touchedFiles);
|
|
38
|
+
const findings: Finding[] = [];
|
|
39
|
+
for (const file of touchedFiles) {
|
|
40
|
+
if (!isSourceFile(file) || INDEX_FILE.test(file)) continue;
|
|
41
|
+
if (!hasTestCounterpart(file, touched)) {
|
|
42
|
+
findings.push({
|
|
43
|
+
id: `missing-tests:${file}`,
|
|
44
|
+
source: 'static-rules',
|
|
45
|
+
severity: 'note',
|
|
46
|
+
category: 'missing-tests',
|
|
47
|
+
file,
|
|
48
|
+
message: 'No test file found for this changed source file',
|
|
49
|
+
suggestion: `Add tests at ${file.replace(/\.[tj]sx?$/, '.test.ts')}`,
|
|
50
|
+
protectedPath: false,
|
|
51
|
+
createdAt: new Date().toISOString(),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return findings;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { runSafe } from '../../shell.ts';
|
|
4
|
+
import type { StaticRule } from '../../phases/static-rules.ts';
|
|
5
|
+
import type { Finding } from '../../findings/types.ts';
|
|
6
|
+
|
|
7
|
+
export const npmAuditRule: StaticRule = {
|
|
8
|
+
name: 'npm-audit',
|
|
9
|
+
severity: 'critical',
|
|
10
|
+
|
|
11
|
+
async check(touchedFiles: string[]): Promise<Finding[]> {
|
|
12
|
+
const cwd = process.cwd();
|
|
13
|
+
if (!fs.existsSync(path.join(cwd, 'package.json'))) return [];
|
|
14
|
+
|
|
15
|
+
const out = runSafe('npm', ['audit', '--json'], { cwd });
|
|
16
|
+
if (!out) return [];
|
|
17
|
+
|
|
18
|
+
let report: { vulnerabilities?: Record<string, { severity: string; name: string; via: unknown[] }> };
|
|
19
|
+
try { report = JSON.parse(out); } catch { return []; }
|
|
20
|
+
|
|
21
|
+
const findings: Finding[] = [];
|
|
22
|
+
for (const [, vuln] of Object.entries(report.vulnerabilities ?? {})) {
|
|
23
|
+
if (vuln.severity !== 'critical' && vuln.severity !== 'high') continue;
|
|
24
|
+
findings.push({
|
|
25
|
+
id: `npm-audit:${vuln.name}`,
|
|
26
|
+
source: 'static-rules',
|
|
27
|
+
severity: vuln.severity === 'critical' ? 'critical' : 'warning',
|
|
28
|
+
category: 'npm-audit',
|
|
29
|
+
file: 'package.json',
|
|
30
|
+
message: `${vuln.severity.toUpperCase()} vulnerability in ${vuln.name}`,
|
|
31
|
+
suggestion: `Run: npm audit fix`,
|
|
32
|
+
protectedPath: false,
|
|
33
|
+
createdAt: new Date().toISOString(),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return findings;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import type { StaticRule } from '../../phases/static-rules.ts';
|
|
4
|
+
import type { Finding } from '../../findings/types.ts';
|
|
5
|
+
|
|
6
|
+
export const packageLockSyncRule: StaticRule = {
|
|
7
|
+
name: 'package-lock-sync',
|
|
8
|
+
severity: 'warning',
|
|
9
|
+
|
|
10
|
+
async check(touchedFiles: string[]): Promise<Finding[]> {
|
|
11
|
+
const cwd = process.cwd();
|
|
12
|
+
const hasPkg = touchedFiles.some(f => f === 'package.json');
|
|
13
|
+
const hasLock = touchedFiles.some(f => f === 'package-lock.json');
|
|
14
|
+
|
|
15
|
+
if (!hasPkg && !hasLock) return [];
|
|
16
|
+
|
|
17
|
+
const pkgExists = fs.existsSync(path.join(cwd, 'package.json'));
|
|
18
|
+
const lockExists = fs.existsSync(path.join(cwd, 'package-lock.json'));
|
|
19
|
+
|
|
20
|
+
if (!pkgExists) return [];
|
|
21
|
+
|
|
22
|
+
// package.json changed but lock didn't
|
|
23
|
+
if (hasPkg && !hasLock && lockExists) {
|
|
24
|
+
return [{
|
|
25
|
+
id: 'package-lock-sync:package.json',
|
|
26
|
+
source: 'static-rules',
|
|
27
|
+
severity: 'warning',
|
|
28
|
+
category: 'package-lock-sync',
|
|
29
|
+
file: 'package.json',
|
|
30
|
+
message: 'package.json changed but package-lock.json was not updated',
|
|
31
|
+
suggestion: 'Run npm install to sync the lockfile',
|
|
32
|
+
protectedPath: false,
|
|
33
|
+
createdAt: new Date().toISOString(),
|
|
34
|
+
}];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// lock changed but package.json didn't (unusual, flag it)
|
|
38
|
+
if (hasLock && !hasPkg) {
|
|
39
|
+
return [{
|
|
40
|
+
id: 'package-lock-sync:package-lock.json',
|
|
41
|
+
source: 'static-rules',
|
|
42
|
+
severity: 'warning',
|
|
43
|
+
category: 'package-lock-sync',
|
|
44
|
+
file: 'package-lock.json',
|
|
45
|
+
message: 'package-lock.json changed without a corresponding package.json change',
|
|
46
|
+
suggestion: 'Verify this is intentional — lockfile-only changes can indicate manual edits',
|
|
47
|
+
protectedPath: false,
|
|
48
|
+
createdAt: new Date().toISOString(),
|
|
49
|
+
}];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return [];
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import type { StaticRule } from '../../phases/static-rules.ts';
|
|
3
|
+
import type { Finding } from '../../findings/types.ts';
|
|
4
|
+
|
|
5
|
+
const TODO_PATTERN = /\b(TODO|FIXME|HACK|XXX)\b/;
|
|
6
|
+
const SKIP_EXTS = new Set(['.lock', '.snap', '.png', '.jpg', '.svg', '.ico']);
|
|
7
|
+
|
|
8
|
+
export const todoFixmeRule: StaticRule = {
|
|
9
|
+
name: 'todo-fixme',
|
|
10
|
+
severity: 'note',
|
|
11
|
+
|
|
12
|
+
async check(touchedFiles: string[]): Promise<Finding[]> {
|
|
13
|
+
const findings: Finding[] = [];
|
|
14
|
+
for (const file of touchedFiles) {
|
|
15
|
+
const ext = file.slice(file.lastIndexOf('.'));
|
|
16
|
+
if (SKIP_EXTS.has(ext)) continue;
|
|
17
|
+
let content: string;
|
|
18
|
+
try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }
|
|
19
|
+
const lines = content.split('\n');
|
|
20
|
+
for (let i = 0; i < lines.length; i++) {
|
|
21
|
+
const match = lines[i]!.match(TODO_PATTERN);
|
|
22
|
+
if (match) {
|
|
23
|
+
findings.push({
|
|
24
|
+
id: `todo-fixme:${file}:${i + 1}`,
|
|
25
|
+
source: 'static-rules',
|
|
26
|
+
severity: 'note',
|
|
27
|
+
category: 'todo-fixme',
|
|
28
|
+
file,
|
|
29
|
+
line: i + 1,
|
|
30
|
+
message: `${match[1]} comment in changed file`,
|
|
31
|
+
suggestion: 'Resolve before merging or track in an issue',
|
|
32
|
+
protectedPath: false,
|
|
33
|
+
createdAt: new Date().toISOString(),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return findings;
|
|
39
|
+
},
|
|
40
|
+
};
|