@mainahq/core 0.2.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/README.md +31 -0
- package/package.json +37 -0
- package/src/ai/__tests__/ai.test.ts +207 -0
- package/src/ai/__tests__/design-approaches.test.ts +192 -0
- package/src/ai/__tests__/spec-questions.test.ts +191 -0
- package/src/ai/__tests__/tiers.test.ts +110 -0
- package/src/ai/commit-msg.ts +28 -0
- package/src/ai/design-approaches.ts +76 -0
- package/src/ai/index.ts +205 -0
- package/src/ai/pr-summary.ts +60 -0
- package/src/ai/spec-questions.ts +74 -0
- package/src/ai/tiers.ts +52 -0
- package/src/ai/try-generate.ts +89 -0
- package/src/ai/validate.ts +66 -0
- package/src/benchmark/__tests__/reporter.test.ts +525 -0
- package/src/benchmark/__tests__/runner.test.ts +113 -0
- package/src/benchmark/__tests__/story-loader.test.ts +152 -0
- package/src/benchmark/reporter.ts +332 -0
- package/src/benchmark/runner.ts +91 -0
- package/src/benchmark/story-loader.ts +88 -0
- package/src/benchmark/types.ts +95 -0
- package/src/cache/__tests__/keys.test.ts +97 -0
- package/src/cache/__tests__/manager.test.ts +312 -0
- package/src/cache/__tests__/ttl.test.ts +94 -0
- package/src/cache/keys.ts +44 -0
- package/src/cache/manager.ts +231 -0
- package/src/cache/ttl.ts +77 -0
- package/src/config/__tests__/config.test.ts +376 -0
- package/src/config/index.ts +198 -0
- package/src/context/__tests__/budget.test.ts +179 -0
- package/src/context/__tests__/engine.test.ts +163 -0
- package/src/context/__tests__/episodic.test.ts +291 -0
- package/src/context/__tests__/relevance.test.ts +323 -0
- package/src/context/__tests__/retrieval.test.ts +143 -0
- package/src/context/__tests__/selector.test.ts +174 -0
- package/src/context/__tests__/semantic.test.ts +252 -0
- package/src/context/__tests__/treesitter.test.ts +229 -0
- package/src/context/__tests__/working.test.ts +236 -0
- package/src/context/budget.ts +130 -0
- package/src/context/engine.ts +394 -0
- package/src/context/episodic.ts +251 -0
- package/src/context/relevance.ts +325 -0
- package/src/context/retrieval.ts +325 -0
- package/src/context/selector.ts +93 -0
- package/src/context/semantic.ts +331 -0
- package/src/context/treesitter.ts +216 -0
- package/src/context/working.ts +192 -0
- package/src/db/__tests__/db.test.ts +151 -0
- package/src/db/index.ts +211 -0
- package/src/db/schema.ts +84 -0
- package/src/design/__tests__/design.test.ts +310 -0
- package/src/design/__tests__/generate-hld-lld.test.ts +109 -0
- package/src/design/__tests__/review.test.ts +561 -0
- package/src/design/index.ts +297 -0
- package/src/design/review.ts +327 -0
- package/src/explain/__tests__/explain.test.ts +173 -0
- package/src/explain/index.ts +181 -0
- package/src/features/__tests__/analyzer.test.ts +358 -0
- package/src/features/__tests__/checklist.test.ts +454 -0
- package/src/features/__tests__/numbering.test.ts +319 -0
- package/src/features/__tests__/quality.test.ts +295 -0
- package/src/features/__tests__/traceability.test.ts +147 -0
- package/src/features/analyzer.ts +445 -0
- package/src/features/checklist.ts +366 -0
- package/src/features/index.ts +18 -0
- package/src/features/numbering.ts +404 -0
- package/src/features/quality.ts +349 -0
- package/src/features/test-stubs.ts +157 -0
- package/src/features/traceability.ts +260 -0
- package/src/feedback/__tests__/async-feedback.test.ts +52 -0
- package/src/feedback/__tests__/collector.test.ts +219 -0
- package/src/feedback/__tests__/compress.test.ts +150 -0
- package/src/feedback/__tests__/preferences.test.ts +169 -0
- package/src/feedback/collector.ts +135 -0
- package/src/feedback/compress.ts +92 -0
- package/src/feedback/preferences.ts +108 -0
- package/src/git/__tests__/git.test.ts +62 -0
- package/src/git/index.ts +110 -0
- package/src/hooks/__tests__/runner.test.ts +266 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/runner.ts +130 -0
- package/src/index.ts +356 -0
- package/src/init/__tests__/init.test.ts +228 -0
- package/src/init/index.ts +364 -0
- package/src/language/__tests__/detect.test.ts +77 -0
- package/src/language/__tests__/profile.test.ts +51 -0
- package/src/language/detect.ts +70 -0
- package/src/language/profile.ts +110 -0
- package/src/prompts/__tests__/defaults.test.ts +52 -0
- package/src/prompts/__tests__/engine.test.ts +183 -0
- package/src/prompts/__tests__/evolution-resolve.test.ts +169 -0
- package/src/prompts/__tests__/evolution.test.ts +187 -0
- package/src/prompts/__tests__/loader.test.ts +105 -0
- package/src/prompts/candidates/review-v2.md +55 -0
- package/src/prompts/defaults/ai-review.md +49 -0
- package/src/prompts/defaults/commit.md +30 -0
- package/src/prompts/defaults/context.md +26 -0
- package/src/prompts/defaults/design-approaches.md +57 -0
- package/src/prompts/defaults/design-hld-lld.md +55 -0
- package/src/prompts/defaults/design.md +53 -0
- package/src/prompts/defaults/explain.md +31 -0
- package/src/prompts/defaults/fix.md +32 -0
- package/src/prompts/defaults/index.ts +38 -0
- package/src/prompts/defaults/review.md +41 -0
- package/src/prompts/defaults/spec-questions.md +59 -0
- package/src/prompts/defaults/tests.md +72 -0
- package/src/prompts/engine.ts +137 -0
- package/src/prompts/evolution.ts +409 -0
- package/src/prompts/loader.ts +71 -0
- package/src/review/__tests__/review.test.ts +288 -0
- package/src/review/comprehensive.ts +362 -0
- package/src/review/index.ts +417 -0
- package/src/stats/__tests__/tracker.test.ts +323 -0
- package/src/stats/index.ts +11 -0
- package/src/stats/tracker.ts +492 -0
- package/src/ticket/__tests__/ticket.test.ts +273 -0
- package/src/ticket/index.ts +185 -0
- package/src/utils.ts +87 -0
- package/src/verify/__tests__/ai-review.test.ts +242 -0
- package/src/verify/__tests__/coverage.test.ts +83 -0
- package/src/verify/__tests__/detect.test.ts +175 -0
- package/src/verify/__tests__/diff-filter.test.ts +338 -0
- package/src/verify/__tests__/fix.test.ts +478 -0
- package/src/verify/__tests__/linters/clippy.test.ts +45 -0
- package/src/verify/__tests__/linters/go-vet.test.ts +27 -0
- package/src/verify/__tests__/linters/ruff.test.ts +64 -0
- package/src/verify/__tests__/mutation.test.ts +141 -0
- package/src/verify/__tests__/pipeline.test.ts +553 -0
- package/src/verify/__tests__/proof.test.ts +97 -0
- package/src/verify/__tests__/secretlint.test.ts +190 -0
- package/src/verify/__tests__/semgrep.test.ts +217 -0
- package/src/verify/__tests__/slop.test.ts +366 -0
- package/src/verify/__tests__/sonar.test.ts +113 -0
- package/src/verify/__tests__/syntax-guard.test.ts +227 -0
- package/src/verify/__tests__/trivy.test.ts +191 -0
- package/src/verify/__tests__/visual.test.ts +139 -0
- package/src/verify/ai-review.ts +276 -0
- package/src/verify/coverage.ts +134 -0
- package/src/verify/detect.ts +171 -0
- package/src/verify/diff-filter.ts +183 -0
- package/src/verify/fix.ts +317 -0
- package/src/verify/linters/clippy.ts +52 -0
- package/src/verify/linters/go-vet.ts +32 -0
- package/src/verify/linters/ruff.ts +47 -0
- package/src/verify/mutation.ts +143 -0
- package/src/verify/pipeline.ts +328 -0
- package/src/verify/proof.ts +277 -0
- package/src/verify/secretlint.ts +168 -0
- package/src/verify/semgrep.ts +170 -0
- package/src/verify/slop.ts +493 -0
- package/src/verify/sonar.ts +146 -0
- package/src/verify/syntax-guard.ts +251 -0
- package/src/verify/trivy.ts +161 -0
- package/src/verify/visual.ts +460 -0
- package/src/workflow/__tests__/context.test.ts +110 -0
- package/src/workflow/context.ts +81 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slop Detector — catches common AI-generated code patterns.
|
|
3
|
+
*
|
|
4
|
+
* Detects patterns that slip through linters: empty function bodies,
|
|
5
|
+
* hallucinated imports, console.log in production code, TODOs without
|
|
6
|
+
* ticket references, and large blocks of commented-out code.
|
|
7
|
+
*
|
|
8
|
+
* Pattern/regex-based detection. AST-based detection (tree-sitter) is
|
|
9
|
+
* a future improvement — the key is detecting the patterns correctly.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
14
|
+
import type { CacheManager } from "../cache/manager";
|
|
15
|
+
import type { LanguageProfile } from "../language/profile";
|
|
16
|
+
import { TYPESCRIPT_PROFILE } from "../language/profile";
|
|
17
|
+
import type { Finding } from "./diff-filter";
|
|
18
|
+
|
|
19
|
+
// ─── Types ────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export type SlopRule =
|
|
22
|
+
| "empty-body"
|
|
23
|
+
| "hallucinated-import"
|
|
24
|
+
| "console-log"
|
|
25
|
+
| "todo-without-ticket"
|
|
26
|
+
| "commented-code";
|
|
27
|
+
|
|
28
|
+
export interface SlopResult {
|
|
29
|
+
findings: Finding[];
|
|
30
|
+
cached: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
function hashContent(content: string): string {
|
|
36
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
37
|
+
hasher.update(content);
|
|
38
|
+
return hasher.digest("hex");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Bump this when detection logic changes to invalidate stale cache entries
|
|
42
|
+
const SLOP_CACHE_VERSION = 2;
|
|
43
|
+
|
|
44
|
+
function cacheKey(fileHash: string): string {
|
|
45
|
+
return `slop:v${SLOP_CACHE_VERSION}:${fileHash}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Individual Detectors ─────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Detect empty function/method/arrow bodies.
|
|
52
|
+
*
|
|
53
|
+
* Looks for patterns like `function name() { }`, `() => { }`, `method() { }`.
|
|
54
|
+
* Does NOT flag bodies that contain comments.
|
|
55
|
+
* Does NOT flag object literals or array literals.
|
|
56
|
+
*/
|
|
57
|
+
export function detectEmptyBodies(
|
|
58
|
+
content: string,
|
|
59
|
+
file: string,
|
|
60
|
+
profile?: LanguageProfile,
|
|
61
|
+
): Finding[] {
|
|
62
|
+
const lang = profile ?? TYPESCRIPT_PROFILE;
|
|
63
|
+
// Skip test files — mocks/stubs intentionally use empty bodies
|
|
64
|
+
if (lang.testFilePattern.test(file)) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const findings: Finding[] = [];
|
|
69
|
+
const lines = content.split("\n");
|
|
70
|
+
|
|
71
|
+
// Strategy: find lines with `{}` or multiline open/close brace patterns
|
|
72
|
+
// that are part of function/method/arrow declarations.
|
|
73
|
+
|
|
74
|
+
for (let i = 0; i < lines.length; i++) {
|
|
75
|
+
const line = lines[i] ?? "";
|
|
76
|
+
const trimmed = line.trim();
|
|
77
|
+
|
|
78
|
+
// Skip obvious non-function empty braces
|
|
79
|
+
if (
|
|
80
|
+
/(?:const|let|var|type|interface|enum)\s+\w+.*=\s*\{/.test(trimmed) &&
|
|
81
|
+
!trimmed.includes("=>")
|
|
82
|
+
) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check for single-line empty function body
|
|
87
|
+
const emptyBraces = /\{\s*\}/;
|
|
88
|
+
if (emptyBraces.test(trimmed)) {
|
|
89
|
+
// Skip lines where the empty braces are inside a regex literal or string
|
|
90
|
+
if (
|
|
91
|
+
/\/.*\{\\s\*\}.*\//.test(trimmed) ||
|
|
92
|
+
/['"`].*\{\s*\}.*['"`]/.test(trimmed)
|
|
93
|
+
) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const fnDeclPattern =
|
|
98
|
+
/function\s+\w+\s*\([^)]*\)\s*(?::\s*[^{]+)?\{\s*\}/;
|
|
99
|
+
const arrowPattern = /=>\s*\{\s*\}/;
|
|
100
|
+
const methodPattern =
|
|
101
|
+
/^\s*(?:(?:public|private|protected|static|async|get|set|override)\s+)*\w+\s*\([^)]*\)\s*(?::\s*[^{]+)?\{\s*\}/;
|
|
102
|
+
const nonFnPattern =
|
|
103
|
+
/(?:const|let|var|type|interface|enum|import|export\s+(?:type|interface))\s/;
|
|
104
|
+
|
|
105
|
+
const isFunctionLike =
|
|
106
|
+
fnDeclPattern.test(trimmed) ||
|
|
107
|
+
arrowPattern.test(trimmed) ||
|
|
108
|
+
(methodPattern.test(trimmed) && !nonFnPattern.test(trimmed));
|
|
109
|
+
|
|
110
|
+
if (isFunctionLike) {
|
|
111
|
+
findings.push({
|
|
112
|
+
tool: "slop",
|
|
113
|
+
file,
|
|
114
|
+
line: i + 1,
|
|
115
|
+
message: "Empty function/method body detected",
|
|
116
|
+
severity: "warning",
|
|
117
|
+
ruleId: "slop/empty-body",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Multi-line empty body: opening brace on one line, closing on next,
|
|
124
|
+
// with nothing in between
|
|
125
|
+
if (trimmed.endsWith("{")) {
|
|
126
|
+
const nextLine = lines[i + 1]?.trim() ?? "";
|
|
127
|
+
if (nextLine === "}") {
|
|
128
|
+
// Check if this line is a function/method declaration
|
|
129
|
+
const isFunctionLike =
|
|
130
|
+
/function\s+\w+\s*\(/.test(trimmed) ||
|
|
131
|
+
/=>\s*\{$/.test(trimmed) ||
|
|
132
|
+
(/^\s*(?:(?:public|private|protected|static|async|get|set|override)\s+)*\w+\s*\([^)]*\)\s*(?::\s*[^{]+)?\{$/.test(
|
|
133
|
+
trimmed,
|
|
134
|
+
) &&
|
|
135
|
+
!/(?:const|let|var|type|interface|enum|import|class|if|else|for|while|switch|try|catch)\s/.test(
|
|
136
|
+
trimmed,
|
|
137
|
+
));
|
|
138
|
+
|
|
139
|
+
if (isFunctionLike) {
|
|
140
|
+
findings.push({
|
|
141
|
+
tool: "slop",
|
|
142
|
+
file,
|
|
143
|
+
line: i + 1,
|
|
144
|
+
message: "Empty function/method body detected",
|
|
145
|
+
severity: "warning",
|
|
146
|
+
ruleId: "slop/empty-body",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return findings;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Detect hallucinated imports — imports that reference non-existent modules.
|
|
158
|
+
*
|
|
159
|
+
* Only checks relative imports (./foo, ../bar). Package imports (react, zod,
|
|
160
|
+
* node:path, bun:test) are skipped since they could be valid packages.
|
|
161
|
+
*/
|
|
162
|
+
export function detectHallucinatedImports(
|
|
163
|
+
content: string,
|
|
164
|
+
file: string,
|
|
165
|
+
cwd: string,
|
|
166
|
+
profile?: LanguageProfile,
|
|
167
|
+
): Finding[] {
|
|
168
|
+
const lang = profile ?? TYPESCRIPT_PROFILE;
|
|
169
|
+
// Skip test files and markdown files — code blocks in .md trigger false positives
|
|
170
|
+
if (lang.testFilePattern.test(file) || file.endsWith(".md")) {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const findings: Finding[] = [];
|
|
175
|
+
const lines = content.split("\n");
|
|
176
|
+
|
|
177
|
+
// Determine the directory of the file being checked
|
|
178
|
+
const fileDir = dirname(isAbsolute(file) ? file : resolve(cwd, file));
|
|
179
|
+
|
|
180
|
+
// Match import statements with relative paths
|
|
181
|
+
const importPattern =
|
|
182
|
+
/(?:import\s+.*\s+from\s+|import\s+|require\s*\()['"](\.[^'"]+)['"]/;
|
|
183
|
+
|
|
184
|
+
for (let i = 0; i < lines.length; i++) {
|
|
185
|
+
const line = lines[i] ?? "";
|
|
186
|
+
const match = importPattern.exec(line);
|
|
187
|
+
if (!match) continue;
|
|
188
|
+
|
|
189
|
+
const importPath = match[1];
|
|
190
|
+
if (!importPath) continue;
|
|
191
|
+
|
|
192
|
+
// Only check relative imports
|
|
193
|
+
if (!importPath.startsWith(".")) continue;
|
|
194
|
+
|
|
195
|
+
// Skip placeholder/ellipsis imports (e.g. "..." in dynamic import docs)
|
|
196
|
+
if (/^\.{2,}$/.test(importPath)) continue;
|
|
197
|
+
|
|
198
|
+
const resolvedBase = resolve(fileDir, importPath);
|
|
199
|
+
|
|
200
|
+
// Check common extensions and index files
|
|
201
|
+
const candidates = [
|
|
202
|
+
resolvedBase,
|
|
203
|
+
`${resolvedBase}.ts`,
|
|
204
|
+
`${resolvedBase}.tsx`,
|
|
205
|
+
`${resolvedBase}.js`,
|
|
206
|
+
`${resolvedBase}.jsx`,
|
|
207
|
+
`${resolvedBase}.json`,
|
|
208
|
+
join(resolvedBase, "index.ts"),
|
|
209
|
+
join(resolvedBase, "index.tsx"),
|
|
210
|
+
join(resolvedBase, "index.js"),
|
|
211
|
+
join(resolvedBase, "index.jsx"),
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
const found = candidates.some((candidate) => existsSync(candidate));
|
|
215
|
+
|
|
216
|
+
if (!found) {
|
|
217
|
+
findings.push({
|
|
218
|
+
tool: "slop",
|
|
219
|
+
file,
|
|
220
|
+
line: i + 1,
|
|
221
|
+
message: `Import "${importPath}" does not resolve to an existing file`,
|
|
222
|
+
severity: "error",
|
|
223
|
+
ruleId: "slop/hallucinated-import",
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return findings;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Detect console.log/warn/error/debug/info in production code.
|
|
233
|
+
*
|
|
234
|
+
* Skips test files (*.test.ts, *.spec.ts).
|
|
235
|
+
* Accepts an optional LanguageProfile for language-specific patterns.
|
|
236
|
+
* Defaults to TYPESCRIPT_PROFILE for backward compatibility.
|
|
237
|
+
*/
|
|
238
|
+
export function detectConsoleLogs(
|
|
239
|
+
content: string,
|
|
240
|
+
file: string,
|
|
241
|
+
profile?: LanguageProfile,
|
|
242
|
+
): Finding[] {
|
|
243
|
+
const lang = profile ?? TYPESCRIPT_PROFILE;
|
|
244
|
+
|
|
245
|
+
// Skip test files using language-specific pattern
|
|
246
|
+
if (lang.testFilePattern.test(file)) {
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const findings: Finding[] = [];
|
|
251
|
+
const lines = content.split("\n");
|
|
252
|
+
|
|
253
|
+
for (let i = 0; i < lines.length; i++) {
|
|
254
|
+
const line = lines[i] ?? "";
|
|
255
|
+
// Respect lint-ignore directives on preceding line
|
|
256
|
+
const prevLine = i > 0 ? (lines[i - 1] ?? "") : "";
|
|
257
|
+
if (lang.lintIgnorePattern.test(prevLine)) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const match = lang.printPattern.exec(line);
|
|
261
|
+
if (match) {
|
|
262
|
+
findings.push({
|
|
263
|
+
tool: "slop",
|
|
264
|
+
file,
|
|
265
|
+
line: i + 1,
|
|
266
|
+
column: (match.index ?? 0) + 1,
|
|
267
|
+
message: "Print/log statement found in production code",
|
|
268
|
+
severity: "warning",
|
|
269
|
+
ruleId: "slop/console-log",
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return findings;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Detect TODO/FIXME comments without a ticket reference.
|
|
279
|
+
*
|
|
280
|
+
* A ticket reference is a pattern like #123, PROJ-123, or [#123].
|
|
281
|
+
*/
|
|
282
|
+
export function detectTodosWithoutTickets(
|
|
283
|
+
content: string,
|
|
284
|
+
file: string,
|
|
285
|
+
profile?: LanguageProfile,
|
|
286
|
+
): Finding[] {
|
|
287
|
+
const lang = profile ?? TYPESCRIPT_PROFILE;
|
|
288
|
+
// Skip test files — fixtures legitimately contain TODO patterns as test data
|
|
289
|
+
if (lang.testFilePattern.test(file)) {
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const findings: Finding[] = [];
|
|
294
|
+
const lines = content.split("\n");
|
|
295
|
+
|
|
296
|
+
// Match TODO or FIXME in comments (case-sensitive — these are always uppercase)
|
|
297
|
+
const todoPattern = /(?:\/\/|\/\*|\*)\s*(?:TODO|FIXME)\b/;
|
|
298
|
+
// Ticket reference patterns: #123, PROJ-123, [#123], (PROJ-123)
|
|
299
|
+
const ticketPattern = /#\d+|\b[A-Z][A-Z0-9]+-\d+/;
|
|
300
|
+
|
|
301
|
+
for (let i = 0; i < lines.length; i++) {
|
|
302
|
+
const line = lines[i] ?? "";
|
|
303
|
+
if (todoPattern.test(line) && !ticketPattern.test(line)) {
|
|
304
|
+
findings.push({
|
|
305
|
+
tool: "slop",
|
|
306
|
+
file,
|
|
307
|
+
line: i + 1,
|
|
308
|
+
message: "TODO/FIXME without ticket reference",
|
|
309
|
+
severity: "info",
|
|
310
|
+
ruleId: "slop/todo-without-ticket",
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return findings;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Detect commented-out code blocks (3+ consecutive comment lines with code patterns).
|
|
320
|
+
*
|
|
321
|
+
* Distinguishes code comments from documentation comments by looking for
|
|
322
|
+
* code-like patterns: keywords, semicolons, brackets, import/export, assignments.
|
|
323
|
+
*
|
|
324
|
+
* JSDoc-style comments (starting with /**) are treated as documentation and skipped.
|
|
325
|
+
*/
|
|
326
|
+
export function detectCommentedCode(
|
|
327
|
+
content: string,
|
|
328
|
+
file: string,
|
|
329
|
+
profile?: LanguageProfile,
|
|
330
|
+
): Finding[] {
|
|
331
|
+
const lang = profile ?? TYPESCRIPT_PROFILE;
|
|
332
|
+
// Skip test files — fixtures contain intentional commented-out code as test data
|
|
333
|
+
if (lang.testFilePattern.test(file)) {
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const findings: Finding[] = [];
|
|
338
|
+
const lines = content.split("\n");
|
|
339
|
+
|
|
340
|
+
// Code-like patterns in comments
|
|
341
|
+
const codePatterns = [
|
|
342
|
+
/(?:const|let|var|function|class|import|export|return|if|else|for|while|switch|case|break|continue|throw|try|catch)\s/,
|
|
343
|
+
/[=;{}()[\]]/,
|
|
344
|
+
/=>/,
|
|
345
|
+
/require\s*\(/,
|
|
346
|
+
/\.\w+\s*\(/,
|
|
347
|
+
];
|
|
348
|
+
|
|
349
|
+
function looksLikeCode(line: string): boolean {
|
|
350
|
+
// Strip the comment prefix
|
|
351
|
+
const stripped = line
|
|
352
|
+
.replace(/^\s*\/\/\s?/, "")
|
|
353
|
+
.replace(/^\s*\*\s?/, "")
|
|
354
|
+
.replace(/^\s*\/\*\s?/, "")
|
|
355
|
+
.trim();
|
|
356
|
+
if (stripped.length === 0) return false;
|
|
357
|
+
|
|
358
|
+
return codePatterns.some((p) => p.test(stripped));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
let blockStart = -1;
|
|
362
|
+
let blockCount = 0;
|
|
363
|
+
let inJsDoc = false;
|
|
364
|
+
|
|
365
|
+
for (let i = 0; i < lines.length; i++) {
|
|
366
|
+
const trimmed = (lines[i] ?? "").trim();
|
|
367
|
+
|
|
368
|
+
// Track JSDoc blocks
|
|
369
|
+
if (trimmed.startsWith("/**")) {
|
|
370
|
+
inJsDoc = true;
|
|
371
|
+
blockStart = -1;
|
|
372
|
+
blockCount = 0;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (inJsDoc) {
|
|
376
|
+
if (trimmed.includes("*/")) {
|
|
377
|
+
inJsDoc = false;
|
|
378
|
+
}
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Single-line comment
|
|
383
|
+
const isSingleLineComment = trimmed.startsWith("//");
|
|
384
|
+
|
|
385
|
+
if (isSingleLineComment && looksLikeCode(trimmed)) {
|
|
386
|
+
if (blockStart === -1) {
|
|
387
|
+
blockStart = i;
|
|
388
|
+
blockCount = 1;
|
|
389
|
+
} else {
|
|
390
|
+
blockCount++;
|
|
391
|
+
}
|
|
392
|
+
} else {
|
|
393
|
+
// End of consecutive comment block
|
|
394
|
+
if (blockCount >= 3) {
|
|
395
|
+
findings.push({
|
|
396
|
+
tool: "slop",
|
|
397
|
+
file,
|
|
398
|
+
line: blockStart + 1,
|
|
399
|
+
message: `${blockCount} consecutive lines of commented-out code`,
|
|
400
|
+
severity: "warning",
|
|
401
|
+
ruleId: "slop/commented-code",
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
blockStart = -1;
|
|
405
|
+
blockCount = 0;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Check trailing block
|
|
410
|
+
if (blockCount >= 3) {
|
|
411
|
+
findings.push({
|
|
412
|
+
tool: "slop",
|
|
413
|
+
file,
|
|
414
|
+
line: blockStart + 1,
|
|
415
|
+
message: `${blockCount} consecutive lines of commented-out code`,
|
|
416
|
+
severity: "warning",
|
|
417
|
+
ruleId: "slop/commented-code",
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return findings;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ─── Main Entry Point ─────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Run slop detection on the given files.
|
|
428
|
+
*
|
|
429
|
+
* Checks for: empty function bodies, hallucinated imports, console.log,
|
|
430
|
+
* bare TODOs missing ticket references, and commented-out code blocks.
|
|
431
|
+
*
|
|
432
|
+
* Results are cached by file content hash when a CacheManager is provided.
|
|
433
|
+
*/
|
|
434
|
+
export async function detectSlop(
|
|
435
|
+
files: string[],
|
|
436
|
+
options?: {
|
|
437
|
+
cache?: CacheManager;
|
|
438
|
+
cwd?: string;
|
|
439
|
+
},
|
|
440
|
+
): Promise<SlopResult> {
|
|
441
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
442
|
+
const cache = options?.cache;
|
|
443
|
+
|
|
444
|
+
const allFindings: Finding[] = [];
|
|
445
|
+
let allCached = files.length > 0;
|
|
446
|
+
|
|
447
|
+
for (const file of files) {
|
|
448
|
+
const filePath = isAbsolute(file) ? file : resolve(cwd, file);
|
|
449
|
+
let content: string;
|
|
450
|
+
try {
|
|
451
|
+
content = await Bun.file(filePath).text();
|
|
452
|
+
} catch {
|
|
453
|
+
// File doesn't exist or can't be read — skip
|
|
454
|
+
allCached = false;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const hash = hashContent(content);
|
|
459
|
+
const key = cacheKey(hash);
|
|
460
|
+
|
|
461
|
+
// Check cache
|
|
462
|
+
if (cache) {
|
|
463
|
+
const cached = cache.get(key);
|
|
464
|
+
if (cached) {
|
|
465
|
+
const cachedFindings: Finding[] = JSON.parse(cached.value);
|
|
466
|
+
allFindings.push(...cachedFindings);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Not cached — run all detectors
|
|
472
|
+
allCached = false;
|
|
473
|
+
const fileFindings: Finding[] = [
|
|
474
|
+
...detectEmptyBodies(content, file),
|
|
475
|
+
...detectHallucinatedImports(content, file, cwd),
|
|
476
|
+
...detectConsoleLogs(content, file),
|
|
477
|
+
...detectTodosWithoutTickets(content, file),
|
|
478
|
+
...detectCommentedCode(content, file),
|
|
479
|
+
];
|
|
480
|
+
|
|
481
|
+
// Store in cache
|
|
482
|
+
if (cache) {
|
|
483
|
+
cache.set(key, JSON.stringify(fileFindings));
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
allFindings.push(...fileFindings);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
findings: allFindings,
|
|
491
|
+
cached: allCached,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SonarQube Integration for the Verify Engine.
|
|
3
|
+
*
|
|
4
|
+
* Runs sonar-scanner and parses the JSON report into unified Findings.
|
|
5
|
+
* Gracefully skips if sonar-scanner is not installed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { isToolAvailable } from "./detect";
|
|
9
|
+
import type { Finding } from "./diff-filter";
|
|
10
|
+
|
|
11
|
+
// ─── Types ────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface SonarOptions {
|
|
14
|
+
cwd?: string;
|
|
15
|
+
/** Pre-resolved availability — skips redundant detection if provided. */
|
|
16
|
+
available?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SonarResult {
|
|
20
|
+
findings: Finding[];
|
|
21
|
+
skipped: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─── JSON Parsing ─────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Map SonarQube severity to unified severity.
|
|
28
|
+
*/
|
|
29
|
+
function mapSonarSeverity(severity: string): "error" | "warning" | "info" {
|
|
30
|
+
switch (severity.toUpperCase()) {
|
|
31
|
+
case "BLOCKER":
|
|
32
|
+
case "CRITICAL":
|
|
33
|
+
return "error";
|
|
34
|
+
case "MAJOR":
|
|
35
|
+
case "MINOR":
|
|
36
|
+
return "warning";
|
|
37
|
+
case "INFO":
|
|
38
|
+
return "info";
|
|
39
|
+
default:
|
|
40
|
+
return "warning";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse SonarQube JSON report into Finding[].
|
|
46
|
+
*
|
|
47
|
+
* Expected format:
|
|
48
|
+
* ```json
|
|
49
|
+
* {
|
|
50
|
+
* "issues": [{
|
|
51
|
+
* "rule": "typescript:S1854",
|
|
52
|
+
* "severity": "MAJOR",
|
|
53
|
+
* "component": "src/app.ts",
|
|
54
|
+
* "line": 42,
|
|
55
|
+
* "message": "Description of the issue"
|
|
56
|
+
* }]
|
|
57
|
+
* }
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* Handles malformed JSON and unexpected structures gracefully.
|
|
61
|
+
*/
|
|
62
|
+
export function parseSonarReport(json: string): Finding[] {
|
|
63
|
+
let parsed: Record<string, unknown>;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(json) as Record<string, unknown>;
|
|
66
|
+
} catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const issues = parsed.issues;
|
|
71
|
+
if (!Array.isArray(issues)) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const findings: Finding[] = [];
|
|
76
|
+
|
|
77
|
+
for (const issue of issues) {
|
|
78
|
+
const i = issue as Record<string, unknown>;
|
|
79
|
+
const rule = (i.rule as string) ?? undefined;
|
|
80
|
+
const severity = (i.severity as string) ?? "MAJOR";
|
|
81
|
+
const component = (i.component as string) ?? "";
|
|
82
|
+
const line = (i.line as number) ?? 0;
|
|
83
|
+
const message = (i.message as string) ?? "";
|
|
84
|
+
|
|
85
|
+
findings.push({
|
|
86
|
+
tool: "sonarqube",
|
|
87
|
+
file: component,
|
|
88
|
+
line,
|
|
89
|
+
message,
|
|
90
|
+
severity: mapSonarSeverity(severity),
|
|
91
|
+
ruleId: rule,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return findings;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── Runner ───────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Run SonarQube scanner and return parsed findings.
|
|
102
|
+
*
|
|
103
|
+
* If sonar-scanner is not installed, returns `{ findings: [], skipped: true }`.
|
|
104
|
+
* If sonar-scanner fails, returns `{ findings: [], skipped: false }`.
|
|
105
|
+
*/
|
|
106
|
+
export async function runSonar(options?: SonarOptions): Promise<SonarResult> {
|
|
107
|
+
const toolAvailable =
|
|
108
|
+
options?.available ?? (await isToolAvailable("sonarqube"));
|
|
109
|
+
if (!toolAvailable) {
|
|
110
|
+
return { findings: [], skipped: true };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
114
|
+
|
|
115
|
+
const args = [
|
|
116
|
+
"sonar-scanner",
|
|
117
|
+
"-Dsonar.analysis.mode=issues",
|
|
118
|
+
"-Dsonar.report.export.path=sonar-report.json",
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const proc = Bun.spawn(args, {
|
|
123
|
+
cwd,
|
|
124
|
+
stdout: "pipe",
|
|
125
|
+
stderr: "pipe",
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await new Response(proc.stdout).text();
|
|
129
|
+
await new Response(proc.stderr).text();
|
|
130
|
+
await proc.exited;
|
|
131
|
+
|
|
132
|
+
// Read the generated report file
|
|
133
|
+
const reportPath = `${cwd}/.scannerwork/sonar-report.json`;
|
|
134
|
+
const reportFile = Bun.file(reportPath);
|
|
135
|
+
const exists = await reportFile.exists();
|
|
136
|
+
if (!exists) {
|
|
137
|
+
return { findings: [], skipped: false };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const reportJson = await reportFile.text();
|
|
141
|
+
const findings = parseSonarReport(reportJson);
|
|
142
|
+
return { findings, skipped: false };
|
|
143
|
+
} catch {
|
|
144
|
+
return { findings: [], skipped: false };
|
|
145
|
+
}
|
|
146
|
+
}
|