@open330/oac 2026.2.5
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/CHANGELOG.md +115 -0
- package/LICENSE +21 -0
- package/README.md +597 -0
- package/dist/budget/index.d.ts +117 -0
- package/dist/budget/index.js +23 -0
- package/dist/budget/index.js.map +1 -0
- package/dist/chunk-4IUL7ECC.js +3152 -0
- package/dist/chunk-4IUL7ECC.js.map +1 -0
- package/dist/chunk-5GAUWC3L.js +469 -0
- package/dist/chunk-5GAUWC3L.js.map +1 -0
- package/dist/chunk-6A37SKAJ.js +58 -0
- package/dist/chunk-6A37SKAJ.js.map +1 -0
- package/dist/chunk-7C7SC4TZ.js +358 -0
- package/dist/chunk-7C7SC4TZ.js.map +1 -0
- package/dist/chunk-CJAJ4MBO.js +475 -0
- package/dist/chunk-CJAJ4MBO.js.map +1 -0
- package/dist/chunk-LQC5DLT7.js +317 -0
- package/dist/chunk-LQC5DLT7.js.map +1 -0
- package/dist/chunk-OTPXGXO7.js +2368 -0
- package/dist/chunk-OTPXGXO7.js.map +1 -0
- package/dist/chunk-QPVNC7S4.js +1833 -0
- package/dist/chunk-QPVNC7S4.js.map +1 -0
- package/dist/cli/cli.d.ts +13 -0
- package/dist/cli/cli.js +16 -0
- package/dist/cli/cli.js.map +1 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +22 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/completion/index.d.ts +91 -0
- package/dist/completion/index.js +587 -0
- package/dist/completion/index.js.map +1 -0
- package/dist/config-DequKoFA.d.ts +1468 -0
- package/dist/core/index.d.ts +64 -0
- package/dist/core/index.js +87 -0
- package/dist/core/index.js.map +1 -0
- package/dist/dashboard/index.d.ts +14 -0
- package/dist/dashboard/index.js +1253 -0
- package/dist/dashboard/index.js.map +1 -0
- package/dist/discovery/index.d.ts +285 -0
- package/dist/discovery/index.js +50 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/event-bus-KiuR6e3P.d.ts +91 -0
- package/dist/execution/index.d.ts +215 -0
- package/dist/execution/index.js +27 -0
- package/dist/execution/index.js.map +1 -0
- package/dist/repo/index.d.ts +33 -0
- package/dist/repo/index.js +19 -0
- package/dist/repo/index.js.map +1 -0
- package/dist/tracking/index.d.ts +357 -0
- package/dist/tracking/index.js +15 -0
- package/dist/tracking/index.js.map +1 -0
- package/dist/types-CYCwgojB.d.ts +34 -0
- package/dist/types-Ck7IucqK.d.ts +195 -0
- package/docs/config-reference.md +271 -0
- package/docs/multi-agent-support-technical-spec.md +312 -0
- package/package.json +82 -0
|
@@ -0,0 +1,2368 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createMemoryMonitor,
|
|
3
|
+
truncate
|
|
4
|
+
} from "./chunk-6A37SKAJ.js";
|
|
5
|
+
|
|
6
|
+
// src/discovery/scanners/todo-scanner.ts
|
|
7
|
+
import { spawn } from "child_process";
|
|
8
|
+
import { createHash } from "crypto";
|
|
9
|
+
import { readFile, readdir } from "fs/promises";
|
|
10
|
+
import { resolve, sep } from "path";
|
|
11
|
+
var DEFAULT_TIMEOUT_MS = 6e4;
|
|
12
|
+
var TODO_GROUPING_WINDOW = 10;
|
|
13
|
+
var TODO_RG_PATTERN = "\\b(TODO|FIXME|HACK|XXX)\\b";
|
|
14
|
+
var TODO_KEYWORD_PATTERN = /\b(TODO|FIXME|HACK|XXX)\b/i;
|
|
15
|
+
var TODO_TEXT_PATTERN = /\b(TODO|FIXME|HACK|XXX)\b[:\s-]?(.*)$/i;
|
|
16
|
+
var COMMENT_CONTINUATION_PATTERN = /^\s*(?:\/\/|\/\*+|\*|#|--)/;
|
|
17
|
+
var MAX_FUNCTION_LOOKBACK_LINES = 80;
|
|
18
|
+
var DEFAULT_EXCLUDES = [".git", "node_modules", "dist", "build", "coverage"];
|
|
19
|
+
var FUNCTION_PATTERNS = [
|
|
20
|
+
/^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/,
|
|
21
|
+
/^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/,
|
|
22
|
+
/^\s*(?:public|private|protected|static|readonly|\s)*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*[:{]/,
|
|
23
|
+
/^\s*def\s+([A-Za-z_]\w*)\s*\(/
|
|
24
|
+
];
|
|
25
|
+
var TodoScanner = class {
|
|
26
|
+
id = "todo";
|
|
27
|
+
name = "TODO Scanner";
|
|
28
|
+
async scan(repoPath, options = {}) {
|
|
29
|
+
const matches = await this.findTodoMatches(repoPath, options);
|
|
30
|
+
if (matches.length === 0) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
const grouped = groupTodoMatches(matches, TODO_GROUPING_WINDOW);
|
|
34
|
+
const fileCache = /* @__PURE__ */ new Map();
|
|
35
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
36
|
+
const tasks = [];
|
|
37
|
+
for (const cluster of grouped) {
|
|
38
|
+
const fileLines = await getFileLines(repoPath, cluster.filePath, fileCache);
|
|
39
|
+
const task = buildTodoTask(cluster, fileLines, now);
|
|
40
|
+
tasks.push(task);
|
|
41
|
+
}
|
|
42
|
+
if (typeof options.maxTasks === "number" && options.maxTasks >= 0) {
|
|
43
|
+
return tasks.slice(0, options.maxTasks);
|
|
44
|
+
}
|
|
45
|
+
return tasks;
|
|
46
|
+
}
|
|
47
|
+
async findTodoMatches(repoPath, options) {
|
|
48
|
+
try {
|
|
49
|
+
return await findTodoMatchesWithRipgrep(repoPath, options);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (isCommandNotFound(error)) {
|
|
52
|
+
return findTodoMatchesWithFsFallback(repoPath, options);
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
async function findTodoMatchesWithRipgrep(repoPath, options) {
|
|
59
|
+
const args = ["--json", "--line-number", "--column"];
|
|
60
|
+
if (options.includeHidden) {
|
|
61
|
+
args.push("--hidden");
|
|
62
|
+
}
|
|
63
|
+
const excludes = mergeExcludes(options.exclude);
|
|
64
|
+
for (const pattern of excludes) {
|
|
65
|
+
args.push("--glob", toRgExclude(pattern));
|
|
66
|
+
}
|
|
67
|
+
args.push("-e", TODO_RG_PATTERN, ".");
|
|
68
|
+
const result = await runCommand("rg", args, {
|
|
69
|
+
cwd: repoPath,
|
|
70
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
71
|
+
signal: options.signal
|
|
72
|
+
});
|
|
73
|
+
if (result.timedOut) {
|
|
74
|
+
throw new Error(`TODO scanner timed out after ${options.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`);
|
|
75
|
+
}
|
|
76
|
+
if (result.exitCode === 1 && result.stdout.trim().length === 0) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
if (result.exitCode !== 0 && result.exitCode !== 1) {
|
|
80
|
+
throw new Error(`ripgrep failed: ${result.stderr || result.stdout}`);
|
|
81
|
+
}
|
|
82
|
+
return parseRipgrepJson(result.stdout);
|
|
83
|
+
}
|
|
84
|
+
function parseRipgrepJson(output) {
|
|
85
|
+
const matches = [];
|
|
86
|
+
for (const line of output.split(/\r?\n/)) {
|
|
87
|
+
if (line.trim().length === 0) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(line);
|
|
93
|
+
} catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const record = toRecord(parsed);
|
|
97
|
+
if (record.type !== "match") {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const data = toRecord(record.data);
|
|
101
|
+
const pathRecord = toRecord(data.path);
|
|
102
|
+
const linesRecord = toRecord(data.lines);
|
|
103
|
+
const rawFilePath = asString(pathRecord.text);
|
|
104
|
+
const rawText = asString(linesRecord.text);
|
|
105
|
+
const filePath = rawFilePath ? normalizeRelativePath(rawFilePath) : void 0;
|
|
106
|
+
const lineNumber = asNumber(data.line_number);
|
|
107
|
+
const text = rawText ? sanitizeLine(rawText) : void 0;
|
|
108
|
+
const submatches = asArray(data.submatches);
|
|
109
|
+
const firstSubmatch = toRecord(submatches.at(0));
|
|
110
|
+
const column = (asNumber(firstSubmatch.start) ?? 0) + 1;
|
|
111
|
+
if (!filePath || !lineNumber || !text) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const keyword = extractTodoKeyword(text) ?? "TODO";
|
|
115
|
+
matches.push({
|
|
116
|
+
filePath,
|
|
117
|
+
line: lineNumber,
|
|
118
|
+
column,
|
|
119
|
+
keyword,
|
|
120
|
+
text
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return matches;
|
|
124
|
+
}
|
|
125
|
+
async function findTodoMatchesWithFsFallback(repoPath, options) {
|
|
126
|
+
const excludes = mergeExcludes(options.exclude);
|
|
127
|
+
const files = await collectFiles(repoPath, excludes);
|
|
128
|
+
const matches = [];
|
|
129
|
+
for (const filePath of files) {
|
|
130
|
+
const absolutePath = resolve(repoPath, filePath);
|
|
131
|
+
let content = "";
|
|
132
|
+
try {
|
|
133
|
+
content = await readFile(absolutePath, "utf8");
|
|
134
|
+
} catch {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const lines = content.split(/\r?\n/);
|
|
138
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
139
|
+
const lineText = lines[index] ?? "";
|
|
140
|
+
if (!TODO_KEYWORD_PATTERN.test(lineText)) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const keyword = extractTodoKeyword(lineText) ?? "TODO";
|
|
144
|
+
const columnIndex = lineText.search(TODO_KEYWORD_PATTERN);
|
|
145
|
+
matches.push({
|
|
146
|
+
filePath,
|
|
147
|
+
line: index + 1,
|
|
148
|
+
column: columnIndex >= 0 ? columnIndex + 1 : 1,
|
|
149
|
+
keyword,
|
|
150
|
+
text: sanitizeLine(lineText)
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return matches;
|
|
155
|
+
}
|
|
156
|
+
function groupTodoMatches(matches, lineWindow) {
|
|
157
|
+
const sorted = [...matches].sort((left, right) => {
|
|
158
|
+
const byFile = left.filePath.localeCompare(right.filePath);
|
|
159
|
+
if (byFile !== 0) {
|
|
160
|
+
return byFile;
|
|
161
|
+
}
|
|
162
|
+
return left.line - right.line;
|
|
163
|
+
});
|
|
164
|
+
const groups = [];
|
|
165
|
+
let active;
|
|
166
|
+
for (const match of sorted) {
|
|
167
|
+
if (!active) {
|
|
168
|
+
active = { filePath: match.filePath, matches: [match] };
|
|
169
|
+
groups.push(active);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const last = active.matches[active.matches.length - 1];
|
|
173
|
+
const sameFile = active.filePath === match.filePath;
|
|
174
|
+
if (sameFile && last && match.line - last.line <= lineWindow) {
|
|
175
|
+
active.matches.push(match);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
active = { filePath: match.filePath, matches: [match] };
|
|
179
|
+
groups.push(active);
|
|
180
|
+
}
|
|
181
|
+
return groups;
|
|
182
|
+
}
|
|
183
|
+
async function getFileLines(repoPath, filePath, cache) {
|
|
184
|
+
const cached = cache.get(filePath);
|
|
185
|
+
if (cached) {
|
|
186
|
+
return cached;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
const text = await readFile(resolve(repoPath, filePath), "utf8");
|
|
190
|
+
const lines = text.split(/\r?\n/);
|
|
191
|
+
cache.set(filePath, lines);
|
|
192
|
+
return lines;
|
|
193
|
+
} catch {
|
|
194
|
+
const empty = [];
|
|
195
|
+
cache.set(filePath, empty);
|
|
196
|
+
return empty;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function buildTodoTask(cluster, fileLines, discoveredAt) {
|
|
200
|
+
const first = cluster.matches[0];
|
|
201
|
+
const last = cluster.matches[cluster.matches.length - 1];
|
|
202
|
+
const functionName = first ? findNearestFunctionName(fileLines, first.line) : void 0;
|
|
203
|
+
const isMultiLine = cluster.matches.some((match) => isMultiLineTodo(match, fileLines));
|
|
204
|
+
const complexity = cluster.matches.length > 1 || isMultiLine ? "simple" : "trivial";
|
|
205
|
+
const title = first ? `Address TODO comments in ${cluster.filePath}:${first.line}` : `Address TODO comments in ${cluster.filePath}`;
|
|
206
|
+
const todoSummary = cluster.matches.map((match) => `- ${match.keyword} at line ${match.line}: ${truncate(match.text, 140)}`).join("\n");
|
|
207
|
+
const descriptionParts = [
|
|
208
|
+
`Resolve TODO-style markers in \`${cluster.filePath}\`.`,
|
|
209
|
+
functionName ? `Nearest function context: \`${functionName}\`.` : void 0,
|
|
210
|
+
"Markers discovered:",
|
|
211
|
+
todoSummary
|
|
212
|
+
].filter((part) => Boolean(part));
|
|
213
|
+
const description = descriptionParts.join("\n\n");
|
|
214
|
+
const uniqueKeywords = Array.from(
|
|
215
|
+
new Set(cluster.matches.map((match) => match.keyword.toUpperCase()))
|
|
216
|
+
);
|
|
217
|
+
const stableHashInput = [
|
|
218
|
+
cluster.filePath,
|
|
219
|
+
String(first?.line ?? 0),
|
|
220
|
+
String(last?.line ?? 0),
|
|
221
|
+
uniqueKeywords.join(","),
|
|
222
|
+
cluster.matches.map((match) => match.text).join("\n")
|
|
223
|
+
].join("::");
|
|
224
|
+
const task = {
|
|
225
|
+
id: createTaskId("todo", [cluster.filePath], title, stableHashInput),
|
|
226
|
+
source: "todo",
|
|
227
|
+
title,
|
|
228
|
+
description,
|
|
229
|
+
targetFiles: [cluster.filePath],
|
|
230
|
+
priority: 0,
|
|
231
|
+
complexity,
|
|
232
|
+
executionMode: "new-pr",
|
|
233
|
+
metadata: {
|
|
234
|
+
scannerId: "todo",
|
|
235
|
+
filePath: cluster.filePath,
|
|
236
|
+
startLine: first?.line ?? null,
|
|
237
|
+
endLine: last?.line ?? null,
|
|
238
|
+
functionName: functionName ?? null,
|
|
239
|
+
keywordSet: uniqueKeywords,
|
|
240
|
+
matchCount: cluster.matches.length,
|
|
241
|
+
matches: cluster.matches.map((match) => ({
|
|
242
|
+
line: match.line,
|
|
243
|
+
column: match.column,
|
|
244
|
+
keyword: match.keyword,
|
|
245
|
+
text: match.text
|
|
246
|
+
}))
|
|
247
|
+
},
|
|
248
|
+
discoveredAt
|
|
249
|
+
};
|
|
250
|
+
return task;
|
|
251
|
+
}
|
|
252
|
+
function findNearestFunctionName(fileLines, lineNumber) {
|
|
253
|
+
if (lineNumber <= 0 || fileLines.length === 0) {
|
|
254
|
+
return void 0;
|
|
255
|
+
}
|
|
256
|
+
const startIndex = Math.max(0, lineNumber - 1 - MAX_FUNCTION_LOOKBACK_LINES);
|
|
257
|
+
for (let index = lineNumber - 1; index >= startIndex; index -= 1) {
|
|
258
|
+
const candidate = fileLines[index]?.trim();
|
|
259
|
+
if (!candidate || candidate.startsWith("//")) {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
for (const pattern of FUNCTION_PATTERNS) {
|
|
263
|
+
const match = candidate.match(pattern);
|
|
264
|
+
if (match?.[1]) {
|
|
265
|
+
return match[1];
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return void 0;
|
|
270
|
+
}
|
|
271
|
+
function isMultiLineTodo(match, fileLines) {
|
|
272
|
+
const baseIndex = match.line - 1;
|
|
273
|
+
const line = fileLines[baseIndex + 1];
|
|
274
|
+
if (line === void 0) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
const trimmed = line.trim();
|
|
278
|
+
if (trimmed.length === 0 || TODO_KEYWORD_PATTERN.test(trimmed)) {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
if (COMMENT_CONTINUATION_PATTERN.test(trimmed)) {
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
function extractTodoKeyword(lineText) {
|
|
287
|
+
const match = lineText.match(TODO_TEXT_PATTERN);
|
|
288
|
+
if (!match?.[1]) {
|
|
289
|
+
return void 0;
|
|
290
|
+
}
|
|
291
|
+
return match[1].toUpperCase();
|
|
292
|
+
}
|
|
293
|
+
function createTaskId(source, targetFiles, title, suffix) {
|
|
294
|
+
const base = [source, [...targetFiles].sort().join(","), title, suffix].join("::");
|
|
295
|
+
return createHash("sha256").update(base).digest("hex").slice(0, 16);
|
|
296
|
+
}
|
|
297
|
+
function mergeExcludes(exclude) {
|
|
298
|
+
return Array.from(new Set([...DEFAULT_EXCLUDES, ...exclude ?? []].filter(Boolean)));
|
|
299
|
+
}
|
|
300
|
+
function toRgExclude(pattern) {
|
|
301
|
+
const trimmed = pattern.trim();
|
|
302
|
+
if (trimmed.startsWith("!")) {
|
|
303
|
+
return trimmed;
|
|
304
|
+
}
|
|
305
|
+
return `!${trimmed}`;
|
|
306
|
+
}
|
|
307
|
+
async function collectFiles(rootDir, excludes) {
|
|
308
|
+
const files = [];
|
|
309
|
+
const compiledExcludes = excludes.map(compileGlobMatcher);
|
|
310
|
+
async function walk(relativeDir) {
|
|
311
|
+
const absoluteDir = resolve(rootDir, relativeDir);
|
|
312
|
+
let entries;
|
|
313
|
+
try {
|
|
314
|
+
entries = await readdir(absoluteDir, { withFileTypes: true, encoding: "utf8" });
|
|
315
|
+
} catch {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const subdirs = [];
|
|
319
|
+
for (const entry of entries) {
|
|
320
|
+
const entryName = String(entry.name);
|
|
321
|
+
const relPath = normalizeRelativePath(
|
|
322
|
+
relativeDir ? `${relativeDir}/${entryName}` : entryName
|
|
323
|
+
);
|
|
324
|
+
if (compiledExcludes.some((matches) => matches(relPath))) {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (entry.isDirectory()) {
|
|
328
|
+
subdirs.push(walk(relPath));
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (entry.isFile()) {
|
|
332
|
+
files.push(relPath);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
await Promise.all(subdirs);
|
|
336
|
+
}
|
|
337
|
+
await walk("");
|
|
338
|
+
return files;
|
|
339
|
+
}
|
|
340
|
+
function compileGlobMatcher(pattern) {
|
|
341
|
+
const normalized = normalizeRelativePath(pattern.replace(/^!+/, "").trim());
|
|
342
|
+
if (!normalized) {
|
|
343
|
+
return () => false;
|
|
344
|
+
}
|
|
345
|
+
if (!normalized.includes("*")) {
|
|
346
|
+
const prefix = normalized.endsWith("/") ? normalized : `${normalized}/`;
|
|
347
|
+
return (filePath) => filePath === normalized || filePath.startsWith(prefix) || filePath.endsWith(`/${normalized}`);
|
|
348
|
+
}
|
|
349
|
+
const escaped = normalized.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "__DOUBLE_STAR__").replace(/\*/g, "[^/]*").replace(/__DOUBLE_STAR__/g, ".*");
|
|
350
|
+
const regex = new RegExp(`^${escaped}$`);
|
|
351
|
+
return (filePath) => regex.test(filePath);
|
|
352
|
+
}
|
|
353
|
+
function normalizeRelativePath(filePath) {
|
|
354
|
+
return filePath.split(sep).join("/");
|
|
355
|
+
}
|
|
356
|
+
function sanitizeLine(line) {
|
|
357
|
+
return line.replace(/\r?\n/g, "").trim();
|
|
358
|
+
}
|
|
359
|
+
function toRecord(value) {
|
|
360
|
+
if (value && typeof value === "object") {
|
|
361
|
+
return value;
|
|
362
|
+
}
|
|
363
|
+
return {};
|
|
364
|
+
}
|
|
365
|
+
function asString(value) {
|
|
366
|
+
return typeof value === "string" ? value : void 0;
|
|
367
|
+
}
|
|
368
|
+
function asNumber(value) {
|
|
369
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
370
|
+
}
|
|
371
|
+
function asArray(value) {
|
|
372
|
+
return Array.isArray(value) ? value : [];
|
|
373
|
+
}
|
|
374
|
+
function isCommandNotFound(error) {
|
|
375
|
+
if (!(error instanceof Error)) {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
const maybeNodeError = error;
|
|
379
|
+
return maybeNodeError.code === "ENOENT";
|
|
380
|
+
}
|
|
381
|
+
function runCommand(command, args, options) {
|
|
382
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
383
|
+
const child = spawn(command, args, {
|
|
384
|
+
cwd: options.cwd,
|
|
385
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
386
|
+
signal: options.signal
|
|
387
|
+
});
|
|
388
|
+
let stdout = "";
|
|
389
|
+
let stderr = "";
|
|
390
|
+
let timedOut = false;
|
|
391
|
+
let killHandle;
|
|
392
|
+
child.stdout.on("data", (chunk) => {
|
|
393
|
+
stdout += chunk.toString();
|
|
394
|
+
});
|
|
395
|
+
child.stderr.on("data", (chunk) => {
|
|
396
|
+
stderr += chunk.toString();
|
|
397
|
+
});
|
|
398
|
+
const timeoutHandle = setTimeout(() => {
|
|
399
|
+
timedOut = true;
|
|
400
|
+
child.kill("SIGTERM");
|
|
401
|
+
killHandle = setTimeout(() => child.kill("SIGKILL"), 2e3);
|
|
402
|
+
}, options.timeoutMs);
|
|
403
|
+
child.on("error", (error) => {
|
|
404
|
+
clearTimeout(timeoutHandle);
|
|
405
|
+
if (killHandle) {
|
|
406
|
+
clearTimeout(killHandle);
|
|
407
|
+
}
|
|
408
|
+
rejectPromise(error);
|
|
409
|
+
});
|
|
410
|
+
child.on("close", (exitCode, signal) => {
|
|
411
|
+
clearTimeout(timeoutHandle);
|
|
412
|
+
if (killHandle) {
|
|
413
|
+
clearTimeout(killHandle);
|
|
414
|
+
}
|
|
415
|
+
resolvePromise({
|
|
416
|
+
stdout,
|
|
417
|
+
stderr,
|
|
418
|
+
exitCode,
|
|
419
|
+
signal,
|
|
420
|
+
timedOut
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// src/discovery/scanners/lint-scanner.ts
|
|
427
|
+
import { spawn as spawn2 } from "child_process";
|
|
428
|
+
import { createHash as createHash2 } from "crypto";
|
|
429
|
+
import { access, readFile as readFile2 } from "fs/promises";
|
|
430
|
+
import { relative as relative2, resolve as resolve2, sep as sep2 } from "path";
|
|
431
|
+
var DEFAULT_TIMEOUT_MS2 = 6e4;
|
|
432
|
+
var LintScanner = class {
|
|
433
|
+
id = "lint";
|
|
434
|
+
name = "Lint Scanner";
|
|
435
|
+
async scan(repoPath, options = {}) {
|
|
436
|
+
const detection = await detectLinter(repoPath);
|
|
437
|
+
if (detection.kind === "none") {
|
|
438
|
+
return [];
|
|
439
|
+
}
|
|
440
|
+
const result = await runLinter(repoPath, detection, options);
|
|
441
|
+
const findings = detection.kind === "eslint" ? parseEslintFindings(result.stdout, repoPath) : parseBiomeFindings(result.stdout, repoPath);
|
|
442
|
+
if (findings.length === 0) {
|
|
443
|
+
return [];
|
|
444
|
+
}
|
|
445
|
+
const tasks = buildLintTasks(findings, detection.kind);
|
|
446
|
+
if (typeof options.maxTasks === "number" && options.maxTasks >= 0) {
|
|
447
|
+
return tasks.slice(0, options.maxTasks);
|
|
448
|
+
}
|
|
449
|
+
return tasks;
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
async function detectLinter(repoPath) {
|
|
453
|
+
const packageManager = await detectPackageManager(repoPath);
|
|
454
|
+
const packageJson = await readPackageJson(repoPath);
|
|
455
|
+
const scriptLint = asString2(toRecord2(packageJson.scripts).lint)?.toLowerCase() ?? "";
|
|
456
|
+
const dependencies = collectDependencyNames(packageJson);
|
|
457
|
+
if (scriptLint.includes("biome")) {
|
|
458
|
+
return { kind: "biome", packageManager };
|
|
459
|
+
}
|
|
460
|
+
if (scriptLint.includes("eslint")) {
|
|
461
|
+
return { kind: "eslint", packageManager };
|
|
462
|
+
}
|
|
463
|
+
if (dependencies.has("eslint") || await hasAnyFile(repoPath, ESLINT_CONFIG_FILES)) {
|
|
464
|
+
return { kind: "eslint", packageManager };
|
|
465
|
+
}
|
|
466
|
+
if (dependencies.has("@biomejs/biome") || await hasAnyFile(repoPath, BIOME_CONFIG_FILES)) {
|
|
467
|
+
return { kind: "biome", packageManager };
|
|
468
|
+
}
|
|
469
|
+
return { kind: "none", packageManager };
|
|
470
|
+
}
|
|
471
|
+
var ESLINT_CONFIG_FILES = [
|
|
472
|
+
".eslintrc",
|
|
473
|
+
".eslintrc.json",
|
|
474
|
+
".eslintrc.cjs",
|
|
475
|
+
".eslintrc.js",
|
|
476
|
+
"eslint.config.js",
|
|
477
|
+
"eslint.config.mjs",
|
|
478
|
+
"eslint.config.cjs"
|
|
479
|
+
];
|
|
480
|
+
var BIOME_CONFIG_FILES = ["biome.json", "biome.jsonc"];
|
|
481
|
+
async function detectPackageManager(repoPath) {
|
|
482
|
+
const checks = [
|
|
483
|
+
{ file: "pnpm-lock.yaml", manager: "pnpm" },
|
|
484
|
+
{ file: "bun.lockb", manager: "bun" },
|
|
485
|
+
{ file: "bun.lock", manager: "bun" },
|
|
486
|
+
{ file: "yarn.lock", manager: "yarn" },
|
|
487
|
+
{ file: "package-lock.json", manager: "npm" }
|
|
488
|
+
];
|
|
489
|
+
for (const check of checks) {
|
|
490
|
+
if (await fileExists(resolve2(repoPath, check.file))) {
|
|
491
|
+
return check.manager;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return "npm";
|
|
495
|
+
}
|
|
496
|
+
async function runLinter(repoPath, detection, options) {
|
|
497
|
+
const command = buildLintCommand(detection, options);
|
|
498
|
+
const result = await runCommand2(command.command, command.args, {
|
|
499
|
+
cwd: repoPath,
|
|
500
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS2,
|
|
501
|
+
signal: options.signal
|
|
502
|
+
});
|
|
503
|
+
if (result.timedOut) {
|
|
504
|
+
throw new Error(`Lint scanner timed out after ${options.timeoutMs ?? DEFAULT_TIMEOUT_MS2}ms`);
|
|
505
|
+
}
|
|
506
|
+
const output = result.stdout.trim();
|
|
507
|
+
if (result.exitCode !== 0 && output.length === 0) {
|
|
508
|
+
return {
|
|
509
|
+
...result,
|
|
510
|
+
stdout: normalizeJsonText(result.stderr)
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
return {
|
|
514
|
+
...result,
|
|
515
|
+
stdout: normalizeJsonText(result.stdout)
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
function buildLintCommand(detection, options) {
|
|
519
|
+
const excludes = options.exclude ?? [];
|
|
520
|
+
if (detection.kind === "eslint") {
|
|
521
|
+
const eslintArgs = ["eslint", ".", "--format", "json", "--no-error-on-unmatched-pattern"];
|
|
522
|
+
for (const pattern of excludes) {
|
|
523
|
+
eslintArgs.push("--ignore-pattern", pattern);
|
|
524
|
+
}
|
|
525
|
+
return withPackageManagerRunner(detection.packageManager, eslintArgs);
|
|
526
|
+
}
|
|
527
|
+
const biomeArgs = ["biome", "check", ".", "--reporter=json"];
|
|
528
|
+
return withPackageManagerRunner(detection.packageManager, biomeArgs);
|
|
529
|
+
}
|
|
530
|
+
function withPackageManagerRunner(packageManager, commandArgs) {
|
|
531
|
+
if (packageManager === "pnpm") {
|
|
532
|
+
return { command: "pnpm", args: ["exec", ...commandArgs] };
|
|
533
|
+
}
|
|
534
|
+
if (packageManager === "yarn") {
|
|
535
|
+
return { command: "yarn", args: commandArgs };
|
|
536
|
+
}
|
|
537
|
+
if (packageManager === "bun") {
|
|
538
|
+
return { command: "bunx", args: commandArgs };
|
|
539
|
+
}
|
|
540
|
+
return { command: "npx", args: ["--no-install", ...commandArgs] };
|
|
541
|
+
}
|
|
542
|
+
function parseEslintFindings(output, repoPath) {
|
|
543
|
+
const parsed = parseJson(output);
|
|
544
|
+
if (!Array.isArray(parsed)) {
|
|
545
|
+
return [];
|
|
546
|
+
}
|
|
547
|
+
const findings = [];
|
|
548
|
+
for (const result of parsed) {
|
|
549
|
+
const resultRecord = toRecord2(result);
|
|
550
|
+
const filePathValue = asString2(resultRecord.filePath);
|
|
551
|
+
const filePath = normalizeFilePath(filePathValue, repoPath);
|
|
552
|
+
if (!filePath) {
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
const messages = asArray2(resultRecord.messages);
|
|
556
|
+
for (const message of messages) {
|
|
557
|
+
const messageRecord = toRecord2(message);
|
|
558
|
+
const ruleId = asString2(messageRecord.ruleId) ?? "unknown";
|
|
559
|
+
const text = asString2(messageRecord.message);
|
|
560
|
+
if (!text) {
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
findings.push({
|
|
564
|
+
filePath,
|
|
565
|
+
line: asNumber2(messageRecord.line),
|
|
566
|
+
column: asNumber2(messageRecord.column),
|
|
567
|
+
ruleId,
|
|
568
|
+
message: text,
|
|
569
|
+
fixable: messageRecord.fix !== void 0,
|
|
570
|
+
severity: asNumber2(messageRecord.severity)
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return findings;
|
|
575
|
+
}
|
|
576
|
+
function parseBiomeFindings(output, repoPath) {
|
|
577
|
+
const parsed = parseJson(output);
|
|
578
|
+
if (parsed === void 0) {
|
|
579
|
+
return [];
|
|
580
|
+
}
|
|
581
|
+
const diagnostics = collectBiomeDiagnostics(parsed);
|
|
582
|
+
const findings = [];
|
|
583
|
+
for (const diagnostic of diagnostics) {
|
|
584
|
+
const path = extractBiomePath(diagnostic);
|
|
585
|
+
const filePath = normalizeFilePath(path, repoPath);
|
|
586
|
+
if (!filePath) {
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
const message = asString2(diagnostic.description) ?? asString2(diagnostic.message) ?? asString2(diagnostic.reason);
|
|
590
|
+
if (!message) {
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
const category = asString2(diagnostic.category) ?? "unknown";
|
|
594
|
+
const position = extractBiomePosition(diagnostic);
|
|
595
|
+
const tags = asArray2(diagnostic.tags).map((value) => String(value).toLowerCase());
|
|
596
|
+
findings.push({
|
|
597
|
+
filePath,
|
|
598
|
+
line: position?.line,
|
|
599
|
+
column: position?.column,
|
|
600
|
+
ruleId: category,
|
|
601
|
+
message,
|
|
602
|
+
fixable: tags.includes("fixable") || tags.includes("quickfix") || diagnostic.suggestedFixes !== void 0,
|
|
603
|
+
severity: normalizeBiomeSeverity(asString2(diagnostic.severity))
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
return findings;
|
|
607
|
+
}
|
|
608
|
+
function collectBiomeDiagnostics(value) {
|
|
609
|
+
const diagnostics = [];
|
|
610
|
+
const queue = [value];
|
|
611
|
+
while (queue.length > 0) {
|
|
612
|
+
const current = queue.shift();
|
|
613
|
+
if (current === void 0 || current === null) {
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
if (Array.isArray(current)) {
|
|
617
|
+
for (const item of current) {
|
|
618
|
+
queue.push(item);
|
|
619
|
+
}
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
if (typeof current !== "object") {
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
const record = current;
|
|
626
|
+
if (looksLikeBiomeDiagnostic(record)) {
|
|
627
|
+
diagnostics.push(record);
|
|
628
|
+
}
|
|
629
|
+
for (const value2 of Object.values(record)) {
|
|
630
|
+
if (Array.isArray(value2) || value2 && typeof value2 === "object") {
|
|
631
|
+
queue.push(value2);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return diagnostics;
|
|
636
|
+
}
|
|
637
|
+
function looksLikeBiomeDiagnostic(record) {
|
|
638
|
+
if (record.location !== void 0 && record.category !== void 0) {
|
|
639
|
+
return true;
|
|
640
|
+
}
|
|
641
|
+
if (record.path !== void 0 && (record.description !== void 0 || record.message !== void 0)) {
|
|
642
|
+
return true;
|
|
643
|
+
}
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
function extractBiomePath(diagnostic) {
|
|
647
|
+
const location = toRecord2(diagnostic.location);
|
|
648
|
+
const pathValue = location.path;
|
|
649
|
+
if (typeof pathValue === "string") {
|
|
650
|
+
return pathValue;
|
|
651
|
+
}
|
|
652
|
+
const pathRecord = toRecord2(pathValue);
|
|
653
|
+
const file = asString2(pathRecord.file);
|
|
654
|
+
if (file) {
|
|
655
|
+
return file;
|
|
656
|
+
}
|
|
657
|
+
return asString2(diagnostic.filePath);
|
|
658
|
+
}
|
|
659
|
+
function extractBiomePosition(diagnostic) {
|
|
660
|
+
const location = toRecord2(diagnostic.location);
|
|
661
|
+
const span = toRecord2(location.span);
|
|
662
|
+
const start = toRecord2(span.start);
|
|
663
|
+
const line = asNumber2(start.line);
|
|
664
|
+
const column = asNumber2(start.column);
|
|
665
|
+
if (line !== void 0 || column !== void 0) {
|
|
666
|
+
return { line, column };
|
|
667
|
+
}
|
|
668
|
+
const lineFallback = asNumber2(location.line) ?? asNumber2(diagnostic.line);
|
|
669
|
+
const columnFallback = asNumber2(location.column) ?? asNumber2(diagnostic.column);
|
|
670
|
+
if (lineFallback !== void 0 || columnFallback !== void 0) {
|
|
671
|
+
return { line: lineFallback, column: columnFallback };
|
|
672
|
+
}
|
|
673
|
+
return void 0;
|
|
674
|
+
}
|
|
675
|
+
function normalizeBiomeSeverity(severity) {
|
|
676
|
+
if (!severity) {
|
|
677
|
+
return void 0;
|
|
678
|
+
}
|
|
679
|
+
const normalized = severity.toLowerCase();
|
|
680
|
+
if (normalized === "error") {
|
|
681
|
+
return 2;
|
|
682
|
+
}
|
|
683
|
+
if (normalized === "warning" || normalized === "warn") {
|
|
684
|
+
return 1;
|
|
685
|
+
}
|
|
686
|
+
return void 0;
|
|
687
|
+
}
|
|
688
|
+
function buildLintTasks(findings, linter) {
|
|
689
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
690
|
+
for (const finding of findings) {
|
|
691
|
+
const existing = grouped.get(finding.filePath);
|
|
692
|
+
if (existing) {
|
|
693
|
+
existing.push(finding);
|
|
694
|
+
} else {
|
|
695
|
+
grouped.set(finding.filePath, [finding]);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
const discoveredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
699
|
+
const tasks = [];
|
|
700
|
+
for (const [filePath, fileFindings] of grouped.entries()) {
|
|
701
|
+
const uniqueRules = Array.from(new Set(fileFindings.map((finding) => finding.ruleId)));
|
|
702
|
+
const fixableCount = fileFindings.filter((finding) => finding.fixable).length;
|
|
703
|
+
const complexity = fileFindings.length === 1 && fixableCount === 1 ? "trivial" : "simple";
|
|
704
|
+
const headlineRules = uniqueRules.slice(0, 5).join(", ") || "unknown";
|
|
705
|
+
const title = `Fix lint findings in ${filePath}`;
|
|
706
|
+
const description = [
|
|
707
|
+
`Resolve ${fileFindings.length} lint finding(s) reported by ${linter} in \`${filePath}\`.`,
|
|
708
|
+
`Primary rules: ${headlineRules}.`,
|
|
709
|
+
fixableCount > 0 ? `${fixableCount} finding(s) appear auto-fixable.` : "No auto-fixable findings were detected."
|
|
710
|
+
].join("\n\n");
|
|
711
|
+
const task = {
|
|
712
|
+
id: createTaskId2("lint", [filePath], title, `${linter}:${headlineRules}`),
|
|
713
|
+
source: "lint",
|
|
714
|
+
title,
|
|
715
|
+
description,
|
|
716
|
+
targetFiles: [filePath],
|
|
717
|
+
priority: 0,
|
|
718
|
+
complexity,
|
|
719
|
+
executionMode: "new-pr",
|
|
720
|
+
metadata: {
|
|
721
|
+
scannerId: "lint",
|
|
722
|
+
linter,
|
|
723
|
+
filePath,
|
|
724
|
+
issueCount: fileFindings.length,
|
|
725
|
+
ruleIds: uniqueRules,
|
|
726
|
+
fixableCount,
|
|
727
|
+
findings: fileFindings.map((finding) => ({
|
|
728
|
+
line: finding.line ?? null,
|
|
729
|
+
column: finding.column ?? null,
|
|
730
|
+
ruleId: finding.ruleId,
|
|
731
|
+
message: finding.message,
|
|
732
|
+
fixable: finding.fixable,
|
|
733
|
+
severity: finding.severity ?? null
|
|
734
|
+
}))
|
|
735
|
+
},
|
|
736
|
+
discoveredAt
|
|
737
|
+
};
|
|
738
|
+
tasks.push(task);
|
|
739
|
+
}
|
|
740
|
+
return tasks;
|
|
741
|
+
}
|
|
742
|
+
function createTaskId2(source, targetFiles, title, suffix) {
|
|
743
|
+
const content = [source, [...targetFiles].sort().join(","), title, suffix].join("::");
|
|
744
|
+
return createHash2("sha256").update(content).digest("hex").slice(0, 16);
|
|
745
|
+
}
|
|
746
|
+
async function readPackageJson(repoPath) {
|
|
747
|
+
const packageJsonPath = resolve2(repoPath, "package.json");
|
|
748
|
+
try {
|
|
749
|
+
const raw = await readFile2(packageJsonPath, "utf8");
|
|
750
|
+
const parsed = JSON.parse(raw);
|
|
751
|
+
return toRecord2(parsed);
|
|
752
|
+
} catch {
|
|
753
|
+
return {};
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
function collectDependencyNames(packageJson) {
|
|
757
|
+
const sections = [
|
|
758
|
+
toRecord2(packageJson.dependencies),
|
|
759
|
+
toRecord2(packageJson.devDependencies),
|
|
760
|
+
toRecord2(packageJson.peerDependencies),
|
|
761
|
+
toRecord2(packageJson.optionalDependencies)
|
|
762
|
+
];
|
|
763
|
+
const names = /* @__PURE__ */ new Set();
|
|
764
|
+
for (const section of sections) {
|
|
765
|
+
for (const key of Object.keys(section)) {
|
|
766
|
+
names.add(key);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return names;
|
|
770
|
+
}
|
|
771
|
+
async function hasAnyFile(repoPath, candidates) {
|
|
772
|
+
for (const candidate of candidates) {
|
|
773
|
+
if (await fileExists(resolve2(repoPath, candidate))) {
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return false;
|
|
778
|
+
}
|
|
779
|
+
async function fileExists(filePath) {
|
|
780
|
+
try {
|
|
781
|
+
await access(filePath);
|
|
782
|
+
return true;
|
|
783
|
+
} catch {
|
|
784
|
+
return false;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
function normalizeFilePath(filePath, repoPath) {
|
|
788
|
+
if (!filePath) {
|
|
789
|
+
return void 0;
|
|
790
|
+
}
|
|
791
|
+
if (filePath.startsWith("<")) {
|
|
792
|
+
return void 0;
|
|
793
|
+
}
|
|
794
|
+
const absoluteCandidate = resolve2(repoPath, filePath);
|
|
795
|
+
const rel = relative2(repoPath, absoluteCandidate);
|
|
796
|
+
if (!rel.startsWith("..")) {
|
|
797
|
+
return rel.split(sep2).join("/");
|
|
798
|
+
}
|
|
799
|
+
const direct = filePath.split(sep2).join("/");
|
|
800
|
+
return direct;
|
|
801
|
+
}
|
|
802
|
+
function normalizeJsonText(text) {
|
|
803
|
+
return text.trim();
|
|
804
|
+
}
|
|
805
|
+
function parseJson(text) {
|
|
806
|
+
if (text.trim().length === 0) {
|
|
807
|
+
return void 0;
|
|
808
|
+
}
|
|
809
|
+
try {
|
|
810
|
+
return JSON.parse(text);
|
|
811
|
+
} catch {
|
|
812
|
+
const jsonStart = text.indexOf("[");
|
|
813
|
+
const objectStart = text.indexOf("{");
|
|
814
|
+
const start = jsonStart === -1 ? objectStart : objectStart === -1 ? jsonStart : Math.min(jsonStart, objectStart);
|
|
815
|
+
if (start < 0) {
|
|
816
|
+
return void 0;
|
|
817
|
+
}
|
|
818
|
+
const trimmed = text.slice(start).trim();
|
|
819
|
+
try {
|
|
820
|
+
return JSON.parse(trimmed);
|
|
821
|
+
} catch {
|
|
822
|
+
return void 0;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
function toRecord2(value) {
|
|
827
|
+
if (value && typeof value === "object") {
|
|
828
|
+
return value;
|
|
829
|
+
}
|
|
830
|
+
return {};
|
|
831
|
+
}
|
|
832
|
+
function asString2(value) {
|
|
833
|
+
return typeof value === "string" ? value : void 0;
|
|
834
|
+
}
|
|
835
|
+
function asNumber2(value) {
|
|
836
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
837
|
+
}
|
|
838
|
+
function asArray2(value) {
|
|
839
|
+
return Array.isArray(value) ? value : [];
|
|
840
|
+
}
|
|
841
|
+
function runCommand2(command, args, options) {
|
|
842
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
843
|
+
const child = spawn2(command, args, {
|
|
844
|
+
cwd: options.cwd,
|
|
845
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
846
|
+
signal: options.signal
|
|
847
|
+
});
|
|
848
|
+
let stdout = "";
|
|
849
|
+
let stderr = "";
|
|
850
|
+
let timedOut = false;
|
|
851
|
+
let killHandle;
|
|
852
|
+
child.stdout.on("data", (chunk) => {
|
|
853
|
+
stdout += chunk.toString();
|
|
854
|
+
});
|
|
855
|
+
child.stderr.on("data", (chunk) => {
|
|
856
|
+
stderr += chunk.toString();
|
|
857
|
+
});
|
|
858
|
+
const timeoutHandle = setTimeout(() => {
|
|
859
|
+
timedOut = true;
|
|
860
|
+
child.kill("SIGTERM");
|
|
861
|
+
killHandle = setTimeout(() => child.kill("SIGKILL"), 2e3);
|
|
862
|
+
}, options.timeoutMs);
|
|
863
|
+
child.on("error", (error) => {
|
|
864
|
+
clearTimeout(timeoutHandle);
|
|
865
|
+
if (killHandle) {
|
|
866
|
+
clearTimeout(killHandle);
|
|
867
|
+
}
|
|
868
|
+
rejectPromise(error);
|
|
869
|
+
});
|
|
870
|
+
child.on("close", (exitCode, signal) => {
|
|
871
|
+
clearTimeout(timeoutHandle);
|
|
872
|
+
if (killHandle) {
|
|
873
|
+
clearTimeout(killHandle);
|
|
874
|
+
}
|
|
875
|
+
resolvePromise({
|
|
876
|
+
stdout,
|
|
877
|
+
stderr,
|
|
878
|
+
exitCode,
|
|
879
|
+
signal,
|
|
880
|
+
timedOut
|
|
881
|
+
});
|
|
882
|
+
});
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// src/discovery/scanners/test-gap-scanner.ts
|
|
887
|
+
import { createHash as createHash3 } from "crypto";
|
|
888
|
+
import { readFile as readFile3, readdir as readdir2, stat } from "fs/promises";
|
|
889
|
+
import { basename, resolve as resolve3, sep as sep3 } from "path";
|
|
890
|
+
var DEFAULT_EXCLUDES2 = [".git", "node_modules", "dist", "build", "coverage"];
|
|
891
|
+
var TestGapScanner = class {
|
|
892
|
+
id = "test-gap";
|
|
893
|
+
name = "Test Gap Scanner";
|
|
894
|
+
async scan(repoPath, options = {}) {
|
|
895
|
+
const maxTasks = options.maxTasks;
|
|
896
|
+
if (typeof maxTasks === "number" && maxTasks === 0) {
|
|
897
|
+
return [];
|
|
898
|
+
}
|
|
899
|
+
const excludes = mergeExcludes2(options.exclude);
|
|
900
|
+
const { sourceFiles, testFiles } = await collectCandidateFiles(repoPath, excludes);
|
|
901
|
+
if (sourceFiles.length === 0) {
|
|
902
|
+
return [];
|
|
903
|
+
}
|
|
904
|
+
const coveredSourceKeys = buildCoveredSourceKeySet(testFiles);
|
|
905
|
+
const untestedSourceFiles = sourceFiles.filter(
|
|
906
|
+
(sourceFilePath) => !coveredSourceKeys.has(toSourceKey(sourceFilePath))
|
|
907
|
+
);
|
|
908
|
+
if (untestedSourceFiles.length === 0) {
|
|
909
|
+
return [];
|
|
910
|
+
}
|
|
911
|
+
const cappedSourceFiles = typeof maxTasks === "number" && maxTasks > 0 ? untestedSourceFiles.slice(0, maxTasks) : untestedSourceFiles;
|
|
912
|
+
const discoveredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
913
|
+
const tasks = [];
|
|
914
|
+
for (const sourceFilePath of cappedSourceFiles) {
|
|
915
|
+
const absolutePath = resolve3(repoPath, sourceFilePath);
|
|
916
|
+
let fileContent = "";
|
|
917
|
+
let fileSizeBytes = 0;
|
|
918
|
+
try {
|
|
919
|
+
const [content, fileStats] = await Promise.all([
|
|
920
|
+
readFile3(absolutePath, "utf8"),
|
|
921
|
+
stat(absolutePath)
|
|
922
|
+
]);
|
|
923
|
+
fileContent = content;
|
|
924
|
+
fileSizeBytes = fileStats.size;
|
|
925
|
+
} catch {
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
const lineCount = countLines(fileContent);
|
|
929
|
+
const complexityBucket = toComplexityBucket(lineCount);
|
|
930
|
+
const complexity = toTaskComplexity(complexityBucket);
|
|
931
|
+
const estimatedTokens = estimateTokens(complexityBucket);
|
|
932
|
+
const symbols = extractSymbols(fileContent);
|
|
933
|
+
const task = {
|
|
934
|
+
id: createTaskId3(sourceFilePath),
|
|
935
|
+
source: "test-gap",
|
|
936
|
+
title: `Add tests for ${basename(sourceFilePath)}`,
|
|
937
|
+
description: buildDescription(sourceFilePath, symbols),
|
|
938
|
+
targetFiles: [sourceFilePath],
|
|
939
|
+
priority: 0,
|
|
940
|
+
complexity,
|
|
941
|
+
executionMode: "new-pr",
|
|
942
|
+
metadata: {
|
|
943
|
+
scannerId: "test-gap",
|
|
944
|
+
filePath: sourceFilePath,
|
|
945
|
+
lineCount,
|
|
946
|
+
fileSizeBytes,
|
|
947
|
+
complexityBucket,
|
|
948
|
+
estimatedTokens,
|
|
949
|
+
symbols
|
|
950
|
+
},
|
|
951
|
+
discoveredAt
|
|
952
|
+
};
|
|
953
|
+
tasks.push(task);
|
|
954
|
+
}
|
|
955
|
+
return tasks;
|
|
956
|
+
}
|
|
957
|
+
};
|
|
958
|
+
async function collectCandidateFiles(repoPath, excludePatterns) {
|
|
959
|
+
const sourceFiles = [];
|
|
960
|
+
const testFiles = [];
|
|
961
|
+
const excludeMatchers = excludePatterns.map(compileGlobMatcher2);
|
|
962
|
+
async function walk(relativeDir) {
|
|
963
|
+
const absoluteDir = resolve3(repoPath, relativeDir);
|
|
964
|
+
let entries;
|
|
965
|
+
try {
|
|
966
|
+
entries = await readdir2(absoluteDir, { withFileTypes: true, encoding: "utf8" });
|
|
967
|
+
} catch {
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
for (const entry of entries) {
|
|
971
|
+
const entryName = String(entry.name);
|
|
972
|
+
const relativePath = normalizeRelativePath2(
|
|
973
|
+
relativeDir ? `${relativeDir}/${entryName}` : entryName
|
|
974
|
+
);
|
|
975
|
+
if (excludeMatchers.some((matches) => matches(relativePath))) {
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
if (entry.isDirectory()) {
|
|
979
|
+
await walk(relativePath);
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
if (!entry.isFile()) {
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
if (isSourceFile(relativePath)) {
|
|
986
|
+
sourceFiles.push(relativePath);
|
|
987
|
+
}
|
|
988
|
+
if (isTestFile(relativePath)) {
|
|
989
|
+
testFiles.push(relativePath);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
await walk("");
|
|
994
|
+
sourceFiles.sort((left, right) => left.localeCompare(right));
|
|
995
|
+
testFiles.sort((left, right) => left.localeCompare(right));
|
|
996
|
+
return { sourceFiles, testFiles };
|
|
997
|
+
}
|
|
998
|
+
function isSourceFile(filePath) {
|
|
999
|
+
if (!filePath.endsWith(".ts")) {
|
|
1000
|
+
return false;
|
|
1001
|
+
}
|
|
1002
|
+
if (filePath.endsWith(".d.ts") || filePath.endsWith(".test.ts")) {
|
|
1003
|
+
return false;
|
|
1004
|
+
}
|
|
1005
|
+
if (basename(filePath) === "index.ts") {
|
|
1006
|
+
return false;
|
|
1007
|
+
}
|
|
1008
|
+
const parts = filePath.split("/");
|
|
1009
|
+
return parts.includes("src");
|
|
1010
|
+
}
|
|
1011
|
+
function isTestFile(filePath) {
|
|
1012
|
+
if (!filePath.endsWith(".test.ts")) {
|
|
1013
|
+
return false;
|
|
1014
|
+
}
|
|
1015
|
+
const parts = filePath.split("/");
|
|
1016
|
+
return parts.includes("tests") || parts.includes("__tests__");
|
|
1017
|
+
}
|
|
1018
|
+
function buildCoveredSourceKeySet(testFiles) {
|
|
1019
|
+
const covered = /* @__PURE__ */ new Set();
|
|
1020
|
+
for (const testFilePath of testFiles) {
|
|
1021
|
+
const candidateSourceKeys = deriveCandidateSourceKeysFromTest(testFilePath);
|
|
1022
|
+
for (const sourceKey of candidateSourceKeys) {
|
|
1023
|
+
covered.add(sourceKey);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
return covered;
|
|
1027
|
+
}
|
|
1028
|
+
function deriveCandidateSourceKeysFromTest(testFilePath) {
|
|
1029
|
+
const normalized = normalizeRelativePath2(testFilePath);
|
|
1030
|
+
if (!normalized.endsWith(".test.ts")) {
|
|
1031
|
+
return [];
|
|
1032
|
+
}
|
|
1033
|
+
const withoutSuffix = normalized.slice(0, -".test.ts".length);
|
|
1034
|
+
const parts = withoutSuffix.split("/");
|
|
1035
|
+
const markerIndex = parts.findIndex((part) => part === "tests" || part === "__tests__");
|
|
1036
|
+
if (markerIndex < 0) {
|
|
1037
|
+
return [];
|
|
1038
|
+
}
|
|
1039
|
+
const prefix = parts.slice(0, markerIndex);
|
|
1040
|
+
const suffix = parts.slice(markerIndex + 1);
|
|
1041
|
+
const candidates = /* @__PURE__ */ new Set();
|
|
1042
|
+
candidates.add(normalizeRelativePath2([...prefix, "src", ...suffix].join("/")));
|
|
1043
|
+
if (prefix[prefix.length - 1] === "src") {
|
|
1044
|
+
candidates.add(normalizeRelativePath2([...prefix, ...suffix].join("/")));
|
|
1045
|
+
}
|
|
1046
|
+
return [...candidates];
|
|
1047
|
+
}
|
|
1048
|
+
function toSourceKey(sourceFilePath) {
|
|
1049
|
+
const normalized = normalizeRelativePath2(sourceFilePath);
|
|
1050
|
+
return normalized.endsWith(".ts") ? normalized.slice(0, -".ts".length) : normalized;
|
|
1051
|
+
}
|
|
1052
|
+
function buildDescription(sourceFilePath, symbols) {
|
|
1053
|
+
if (symbols.length === 0) {
|
|
1054
|
+
return `Add initial test coverage for \`${sourceFilePath}\`. No named functions or classes were detected.`;
|
|
1055
|
+
}
|
|
1056
|
+
const symbolList = symbols.map((symbol) => `\`${symbol}\``).join(", ");
|
|
1057
|
+
return `Add test coverage for \`${sourceFilePath}\`, including: ${symbolList}.`;
|
|
1058
|
+
}
|
|
1059
|
+
function extractSymbols(fileContent) {
|
|
1060
|
+
const symbols = /* @__PURE__ */ new Set();
|
|
1061
|
+
const patterns = [
|
|
1062
|
+
/\b(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/g,
|
|
1063
|
+
/\b(?:export\s+)?class\s+([A-Za-z_$][\w$]*)\b/g,
|
|
1064
|
+
/\b(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/g
|
|
1065
|
+
];
|
|
1066
|
+
for (const pattern of patterns) {
|
|
1067
|
+
let match;
|
|
1068
|
+
match = pattern.exec(fileContent);
|
|
1069
|
+
while (match) {
|
|
1070
|
+
const symbolName = match[1];
|
|
1071
|
+
if (symbolName) {
|
|
1072
|
+
symbols.add(symbolName);
|
|
1073
|
+
}
|
|
1074
|
+
match = pattern.exec(fileContent);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
return [...symbols].sort((left, right) => left.localeCompare(right));
|
|
1078
|
+
}
|
|
1079
|
+
function countLines(fileContent) {
|
|
1080
|
+
if (fileContent.length === 0) {
|
|
1081
|
+
return 0;
|
|
1082
|
+
}
|
|
1083
|
+
return fileContent.split(/\r?\n/).length;
|
|
1084
|
+
}
|
|
1085
|
+
function toComplexityBucket(lineCount) {
|
|
1086
|
+
if (lineCount < 50) {
|
|
1087
|
+
return "small";
|
|
1088
|
+
}
|
|
1089
|
+
if (lineCount < 200) {
|
|
1090
|
+
return "medium";
|
|
1091
|
+
}
|
|
1092
|
+
return "large";
|
|
1093
|
+
}
|
|
1094
|
+
function toTaskComplexity(bucket) {
|
|
1095
|
+
if (bucket === "small") {
|
|
1096
|
+
return "simple";
|
|
1097
|
+
}
|
|
1098
|
+
if (bucket === "medium") {
|
|
1099
|
+
return "moderate";
|
|
1100
|
+
}
|
|
1101
|
+
return "complex";
|
|
1102
|
+
}
|
|
1103
|
+
function estimateTokens(bucket) {
|
|
1104
|
+
if (bucket === "small") {
|
|
1105
|
+
return 1500;
|
|
1106
|
+
}
|
|
1107
|
+
if (bucket === "medium") {
|
|
1108
|
+
return 4e3;
|
|
1109
|
+
}
|
|
1110
|
+
return 8e3;
|
|
1111
|
+
}
|
|
1112
|
+
function createTaskId3(sourceFilePath) {
|
|
1113
|
+
return createHash3("sha256").update(sourceFilePath).digest("hex").slice(0, 16);
|
|
1114
|
+
}
|
|
1115
|
+
function mergeExcludes2(exclude) {
|
|
1116
|
+
return Array.from(new Set([...DEFAULT_EXCLUDES2, ...exclude ?? []].filter(Boolean)));
|
|
1117
|
+
}
|
|
1118
|
+
function compileGlobMatcher2(pattern) {
|
|
1119
|
+
const normalized = normalizeRelativePath2(pattern.replace(/^!+/, "").trim());
|
|
1120
|
+
if (!normalized) {
|
|
1121
|
+
return () => false;
|
|
1122
|
+
}
|
|
1123
|
+
if (!normalized.includes("*")) {
|
|
1124
|
+
const prefix = normalized.endsWith("/") ? normalized : `${normalized}/`;
|
|
1125
|
+
return (filePath) => filePath === normalized || filePath.startsWith(prefix) || filePath.endsWith(`/${normalized}`);
|
|
1126
|
+
}
|
|
1127
|
+
const escaped = normalized.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "__DOUBLE_STAR__").replace(/\*/g, "[^/]*").replace(/__DOUBLE_STAR__/g, ".*");
|
|
1128
|
+
const regex = new RegExp(`^${escaped}$`);
|
|
1129
|
+
return (filePath) => regex.test(filePath);
|
|
1130
|
+
}
|
|
1131
|
+
function normalizeRelativePath2(filePath) {
|
|
1132
|
+
return filePath.split(sep3).join("/");
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// src/discovery/scanners/github-issues-scanner.ts
|
|
1136
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
1137
|
+
import { resolve as resolve4 } from "path";
|
|
1138
|
+
var GITHUB_API_BASE_URL = "https://api.github.com";
|
|
1139
|
+
var ISSUES_PER_PAGE = 30;
|
|
1140
|
+
var TITLE_LIMIT = 120;
|
|
1141
|
+
var DESCRIPTION_LIMIT = 500;
|
|
1142
|
+
var ESTIMATED_TOKENS_BY_COMPLEXITY = {
|
|
1143
|
+
trivial: 1500,
|
|
1144
|
+
simple: 4e3,
|
|
1145
|
+
moderate: 9e3,
|
|
1146
|
+
complex: 18e3
|
|
1147
|
+
};
|
|
1148
|
+
var GitHubIssuesScanner = class {
|
|
1149
|
+
constructor(token) {
|
|
1150
|
+
this.token = token;
|
|
1151
|
+
}
|
|
1152
|
+
id = "github-issue";
|
|
1153
|
+
name = "GitHub Issues Scanner";
|
|
1154
|
+
async scan(repoPath, options = {}) {
|
|
1155
|
+
const token = this.token ?? process.env.GITHUB_TOKEN;
|
|
1156
|
+
if (!token) {
|
|
1157
|
+
return [];
|
|
1158
|
+
}
|
|
1159
|
+
const repo = await resolveRepoCoordinates(repoPath, options);
|
|
1160
|
+
if (!repo) {
|
|
1161
|
+
return [];
|
|
1162
|
+
}
|
|
1163
|
+
const issues = await fetchOpenIssues(repo, token);
|
|
1164
|
+
if (issues.length === 0) {
|
|
1165
|
+
return [];
|
|
1166
|
+
}
|
|
1167
|
+
const discoveredAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1168
|
+
const tasks = issues.filter((issue) => issue.pull_request === void 0).map((issue) => mapIssueToTask(issue, discoveredAt)).filter((task) => task !== void 0);
|
|
1169
|
+
if (typeof options.maxTasks === "number" && options.maxTasks >= 0) {
|
|
1170
|
+
return tasks.slice(0, options.maxTasks);
|
|
1171
|
+
}
|
|
1172
|
+
return tasks;
|
|
1173
|
+
}
|
|
1174
|
+
};
|
|
1175
|
+
async function resolveRepoCoordinates(repoPath, options) {
|
|
1176
|
+
if (options.repo?.owner && options.repo.name) {
|
|
1177
|
+
return {
|
|
1178
|
+
owner: options.repo.owner,
|
|
1179
|
+
name: options.repo.name
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
return parseRepoFromGitConfig(repoPath);
|
|
1183
|
+
}
|
|
1184
|
+
async function fetchOpenIssues(repo, token) {
|
|
1185
|
+
const url = `${GITHUB_API_BASE_URL}/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}/issues?state=open&per_page=${ISSUES_PER_PAGE}&sort=updated`;
|
|
1186
|
+
try {
|
|
1187
|
+
const response = await fetch(url, {
|
|
1188
|
+
method: "GET",
|
|
1189
|
+
headers: {
|
|
1190
|
+
Authorization: `Bearer ${token}`,
|
|
1191
|
+
Accept: "application/vnd.github.v3+json"
|
|
1192
|
+
},
|
|
1193
|
+
signal: AbortSignal.timeout(3e4)
|
|
1194
|
+
});
|
|
1195
|
+
if (!response.ok) {
|
|
1196
|
+
return [];
|
|
1197
|
+
}
|
|
1198
|
+
const payload = await response.json();
|
|
1199
|
+
if (!Array.isArray(payload)) {
|
|
1200
|
+
return [];
|
|
1201
|
+
}
|
|
1202
|
+
return payload.map((item) => toIssueResponse(item));
|
|
1203
|
+
} catch {
|
|
1204
|
+
return [];
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
async function parseRepoFromGitConfig(repoPath) {
|
|
1208
|
+
const config = await readGitConfig(repoPath);
|
|
1209
|
+
if (!config) {
|
|
1210
|
+
return void 0;
|
|
1211
|
+
}
|
|
1212
|
+
const remoteUrl = extractRemoteUrl(config);
|
|
1213
|
+
if (!remoteUrl) {
|
|
1214
|
+
return void 0;
|
|
1215
|
+
}
|
|
1216
|
+
return parseGitHubRemoteUrl(remoteUrl);
|
|
1217
|
+
}
|
|
1218
|
+
async function readGitConfig(repoPath) {
|
|
1219
|
+
try {
|
|
1220
|
+
return await readFile4(resolve4(repoPath, ".git", "config"), "utf8");
|
|
1221
|
+
} catch {
|
|
1222
|
+
}
|
|
1223
|
+
try {
|
|
1224
|
+
const gitFile = await readFile4(resolve4(repoPath, ".git"), "utf8");
|
|
1225
|
+
const gitDir = parseGitDirPointer(gitFile);
|
|
1226
|
+
if (!gitDir) {
|
|
1227
|
+
return void 0;
|
|
1228
|
+
}
|
|
1229
|
+
return await readFile4(resolve4(repoPath, gitDir, "config"), "utf8");
|
|
1230
|
+
} catch {
|
|
1231
|
+
return void 0;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
function parseGitDirPointer(content) {
|
|
1235
|
+
const match = content.match(/^\s*gitdir:\s*(.+)\s*$/im);
|
|
1236
|
+
if (!match?.[1]) {
|
|
1237
|
+
return void 0;
|
|
1238
|
+
}
|
|
1239
|
+
return match[1].trim();
|
|
1240
|
+
}
|
|
1241
|
+
function extractRemoteUrl(configText) {
|
|
1242
|
+
const lines = configText.split(/\r?\n/);
|
|
1243
|
+
let activeRemote;
|
|
1244
|
+
let firstRemoteUrl;
|
|
1245
|
+
for (const rawLine of lines) {
|
|
1246
|
+
const line = rawLine.trim();
|
|
1247
|
+
if (!line || line.startsWith("#") || line.startsWith(";")) {
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
const sectionMatch = line.match(/^\[\s*remote\s+"([^"]+)"\s*\]$/i);
|
|
1251
|
+
if (sectionMatch?.[1]) {
|
|
1252
|
+
activeRemote = sectionMatch[1];
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
if (!activeRemote) {
|
|
1256
|
+
continue;
|
|
1257
|
+
}
|
|
1258
|
+
const urlMatch = line.match(/^url\s*=\s*(.+)$/i);
|
|
1259
|
+
if (!urlMatch?.[1]) {
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
const url = urlMatch[1].trim();
|
|
1263
|
+
if (activeRemote === "origin") {
|
|
1264
|
+
return url;
|
|
1265
|
+
}
|
|
1266
|
+
if (!firstRemoteUrl) {
|
|
1267
|
+
firstRemoteUrl = url;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
return firstRemoteUrl;
|
|
1271
|
+
}
|
|
1272
|
+
var GITHUB_SSH_PATTERN = /^git@github\.com:(?<owner>[A-Za-z0-9_.-]+)\/(?<repo>[A-Za-z0-9_.-]+?)(?:\.git)?$/i;
|
|
1273
|
+
function parseGitHubRemoteUrl(remoteUrl) {
|
|
1274
|
+
const normalized = remoteUrl.trim();
|
|
1275
|
+
if (!normalized) {
|
|
1276
|
+
return void 0;
|
|
1277
|
+
}
|
|
1278
|
+
const sshMatch = normalized.match(GITHUB_SSH_PATTERN);
|
|
1279
|
+
if (sshMatch?.groups?.owner && sshMatch.groups.repo) {
|
|
1280
|
+
return {
|
|
1281
|
+
owner: sshMatch.groups.owner,
|
|
1282
|
+
name: stripGitSuffix(sshMatch.groups.repo)
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
const normalizedUrlInput = normalized.startsWith("github.com/") ? `https://${normalized}` : normalized;
|
|
1286
|
+
try {
|
|
1287
|
+
const url = new URL(normalizedUrlInput);
|
|
1288
|
+
if (!isGitHubHost(url.hostname)) {
|
|
1289
|
+
return void 0;
|
|
1290
|
+
}
|
|
1291
|
+
const pathParts = url.pathname.split("/").filter(Boolean);
|
|
1292
|
+
if (pathParts.length < 2) {
|
|
1293
|
+
return void 0;
|
|
1294
|
+
}
|
|
1295
|
+
const owner = pathParts[0];
|
|
1296
|
+
const name = stripGitSuffix(pathParts[1]);
|
|
1297
|
+
if (!owner || !name) {
|
|
1298
|
+
return void 0;
|
|
1299
|
+
}
|
|
1300
|
+
return { owner, name };
|
|
1301
|
+
} catch {
|
|
1302
|
+
return void 0;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
function isGitHubHost(hostname) {
|
|
1306
|
+
const normalized = hostname.toLowerCase();
|
|
1307
|
+
return normalized === "github.com" || normalized === "www.github.com";
|
|
1308
|
+
}
|
|
1309
|
+
function stripGitSuffix(value) {
|
|
1310
|
+
return value.replace(/\.git$/i, "");
|
|
1311
|
+
}
|
|
1312
|
+
function mapIssueToTask(issue, discoveredAt) {
|
|
1313
|
+
const issueNumber = asNumber3(issue.number);
|
|
1314
|
+
const rawTitle = asString3(issue.title)?.trim();
|
|
1315
|
+
if (issueNumber === void 0 || !rawTitle) {
|
|
1316
|
+
return void 0;
|
|
1317
|
+
}
|
|
1318
|
+
const labels = normalizeLabels(issue.labels);
|
|
1319
|
+
const complexity = mapComplexityFromLabels(labels);
|
|
1320
|
+
const estimatedTokens = ESTIMATED_TOKENS_BY_COMPLEXITY[complexity];
|
|
1321
|
+
const bodyText = asString3(issue.body)?.trim() || "No description provided.";
|
|
1322
|
+
const labelSummary = labels.length > 0 ? `Labels: ${labels.join(", ")}` : "Labels: none";
|
|
1323
|
+
const title = truncate(rawTitle, TITLE_LIMIT);
|
|
1324
|
+
const description = truncate(`${bodyText}
|
|
1325
|
+
|
|
1326
|
+
${labelSummary}`, DESCRIPTION_LIMIT);
|
|
1327
|
+
const url = asString3(issue.html_url) ?? "";
|
|
1328
|
+
const author = readAuthor(issue.user);
|
|
1329
|
+
const createdAt = asString3(issue.created_at) ?? discoveredAt;
|
|
1330
|
+
return {
|
|
1331
|
+
id: `github-issue-${issueNumber}`,
|
|
1332
|
+
source: "github-issue",
|
|
1333
|
+
title,
|
|
1334
|
+
description,
|
|
1335
|
+
targetFiles: [],
|
|
1336
|
+
priority: 0,
|
|
1337
|
+
complexity,
|
|
1338
|
+
executionMode: "new-pr",
|
|
1339
|
+
linkedIssue: {
|
|
1340
|
+
number: issueNumber,
|
|
1341
|
+
url,
|
|
1342
|
+
labels
|
|
1343
|
+
},
|
|
1344
|
+
metadata: {
|
|
1345
|
+
issueNumber,
|
|
1346
|
+
labels,
|
|
1347
|
+
url,
|
|
1348
|
+
author,
|
|
1349
|
+
createdAt,
|
|
1350
|
+
estimatedTokens
|
|
1351
|
+
},
|
|
1352
|
+
discoveredAt
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
function mapComplexityFromLabels(labels) {
|
|
1356
|
+
const normalized = labels.map((label) => label.toLowerCase());
|
|
1357
|
+
if (normalized.some(
|
|
1358
|
+
(label) => label.includes("good first issue") || label.includes("good-first-issue")
|
|
1359
|
+
)) {
|
|
1360
|
+
return "simple";
|
|
1361
|
+
}
|
|
1362
|
+
if (normalized.some((label) => label.includes("feature"))) {
|
|
1363
|
+
return "complex";
|
|
1364
|
+
}
|
|
1365
|
+
if (normalized.some((label) => label.includes("enhancement"))) {
|
|
1366
|
+
return "moderate";
|
|
1367
|
+
}
|
|
1368
|
+
if (normalized.some((label) => label.includes("bug"))) {
|
|
1369
|
+
return "simple";
|
|
1370
|
+
}
|
|
1371
|
+
return "moderate";
|
|
1372
|
+
}
|
|
1373
|
+
function normalizeLabels(rawLabels) {
|
|
1374
|
+
if (!Array.isArray(rawLabels)) {
|
|
1375
|
+
return [];
|
|
1376
|
+
}
|
|
1377
|
+
const labels = [];
|
|
1378
|
+
for (const rawLabel of rawLabels) {
|
|
1379
|
+
if (typeof rawLabel === "string") {
|
|
1380
|
+
const trimmed = rawLabel.trim();
|
|
1381
|
+
if (trimmed.length > 0) {
|
|
1382
|
+
labels.push(trimmed);
|
|
1383
|
+
}
|
|
1384
|
+
continue;
|
|
1385
|
+
}
|
|
1386
|
+
if (rawLabel && typeof rawLabel === "object") {
|
|
1387
|
+
const name = asString3(rawLabel.name)?.trim();
|
|
1388
|
+
if (name) {
|
|
1389
|
+
labels.push(name);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
return Array.from(new Set(labels));
|
|
1394
|
+
}
|
|
1395
|
+
function readAuthor(user) {
|
|
1396
|
+
if (!user || typeof user !== "object") {
|
|
1397
|
+
return "unknown";
|
|
1398
|
+
}
|
|
1399
|
+
const login = asString3(user.login);
|
|
1400
|
+
if (!login) {
|
|
1401
|
+
return "unknown";
|
|
1402
|
+
}
|
|
1403
|
+
return login;
|
|
1404
|
+
}
|
|
1405
|
+
function toIssueResponse(value) {
|
|
1406
|
+
if (value && typeof value === "object") {
|
|
1407
|
+
return value;
|
|
1408
|
+
}
|
|
1409
|
+
return {};
|
|
1410
|
+
}
|
|
1411
|
+
function asString3(value) {
|
|
1412
|
+
return typeof value === "string" ? value : void 0;
|
|
1413
|
+
}
|
|
1414
|
+
function asNumber3(value) {
|
|
1415
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// src/discovery/scanner.ts
|
|
1419
|
+
import { createHash as createHash4 } from "crypto";
|
|
1420
|
+
var CompositeScanner = class {
|
|
1421
|
+
id = "composite";
|
|
1422
|
+
name = "Composite Scanner";
|
|
1423
|
+
scanners;
|
|
1424
|
+
constructor(scanners = [new LintScanner(), new TodoScanner()]) {
|
|
1425
|
+
this.scanners = scanners;
|
|
1426
|
+
}
|
|
1427
|
+
async scan(repoPath, options = {}) {
|
|
1428
|
+
const settled = await Promise.allSettled(
|
|
1429
|
+
this.scanners.map(async (scanner) => ({
|
|
1430
|
+
scannerId: scanner.id,
|
|
1431
|
+
tasks: await scanner.scan(repoPath, options)
|
|
1432
|
+
}))
|
|
1433
|
+
);
|
|
1434
|
+
const collected = [];
|
|
1435
|
+
for (const result of settled) {
|
|
1436
|
+
if (result.status !== "fulfilled") {
|
|
1437
|
+
continue;
|
|
1438
|
+
}
|
|
1439
|
+
const scannerId = result.value.scannerId;
|
|
1440
|
+
for (const task of result.value.tasks) {
|
|
1441
|
+
collected.push({ scannerId, task });
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
const deduplicated = deduplicateTasks(collected);
|
|
1445
|
+
if (typeof options.maxTasks === "number" && options.maxTasks >= 0) {
|
|
1446
|
+
return deduplicated.slice(0, options.maxTasks);
|
|
1447
|
+
}
|
|
1448
|
+
return deduplicated;
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
function createDefaultCompositeScanner() {
|
|
1452
|
+
return new CompositeScanner([new LintScanner(), new TodoScanner()]);
|
|
1453
|
+
}
|
|
1454
|
+
function deduplicateTasks(candidates) {
|
|
1455
|
+
const deduplicatedByHash = /* @__PURE__ */ new Map();
|
|
1456
|
+
for (const candidate of candidates) {
|
|
1457
|
+
const hash = taskContentHash(candidate.task);
|
|
1458
|
+
const existing = deduplicatedByHash.get(hash);
|
|
1459
|
+
if (!existing) {
|
|
1460
|
+
deduplicatedByHash.set(hash, {
|
|
1461
|
+
task: candidate.task,
|
|
1462
|
+
mergedSources: [candidate.scannerId],
|
|
1463
|
+
duplicateTaskIds: [candidate.task.id]
|
|
1464
|
+
});
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1467
|
+
const preferIncoming = candidate.task.priority > existing.task.priority;
|
|
1468
|
+
const winner = preferIncoming ? candidate.task : existing.task;
|
|
1469
|
+
const loser = preferIncoming ? existing.task : candidate.task;
|
|
1470
|
+
const mergedSources = unique([
|
|
1471
|
+
...existing.mergedSources,
|
|
1472
|
+
candidate.scannerId,
|
|
1473
|
+
String(loser.source)
|
|
1474
|
+
]);
|
|
1475
|
+
const duplicateTaskIds = unique([...existing.duplicateTaskIds, loser.id, winner.id]);
|
|
1476
|
+
const winnerMetadata = toRecord3(winner.metadata);
|
|
1477
|
+
const loserMetadata = toRecord3(loser.metadata);
|
|
1478
|
+
deduplicatedByHash.set(hash, {
|
|
1479
|
+
task: {
|
|
1480
|
+
...winner,
|
|
1481
|
+
metadata: {
|
|
1482
|
+
...loserMetadata,
|
|
1483
|
+
...winnerMetadata,
|
|
1484
|
+
mergedSources,
|
|
1485
|
+
duplicateTaskIds,
|
|
1486
|
+
dedupeHash: hash
|
|
1487
|
+
}
|
|
1488
|
+
},
|
|
1489
|
+
mergedSources,
|
|
1490
|
+
duplicateTaskIds
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
const deduplicated = [...deduplicatedByHash.values()].map((entry) => entry.task);
|
|
1494
|
+
deduplicated.sort((left, right) => {
|
|
1495
|
+
const byPriority = right.priority - left.priority;
|
|
1496
|
+
if (byPriority !== 0) {
|
|
1497
|
+
return byPriority;
|
|
1498
|
+
}
|
|
1499
|
+
return left.title.localeCompare(right.title);
|
|
1500
|
+
});
|
|
1501
|
+
return deduplicated;
|
|
1502
|
+
}
|
|
1503
|
+
function taskContentHash(task) {
|
|
1504
|
+
const content = [task.source, [...task.targetFiles].sort().join(","), task.title].join("::");
|
|
1505
|
+
return createHash4("sha256").update(content).digest("hex").slice(0, 16);
|
|
1506
|
+
}
|
|
1507
|
+
function toRecord3(value) {
|
|
1508
|
+
if (value && typeof value === "object") {
|
|
1509
|
+
return value;
|
|
1510
|
+
}
|
|
1511
|
+
return {};
|
|
1512
|
+
}
|
|
1513
|
+
function unique(values) {
|
|
1514
|
+
return Array.from(new Set(values.filter((value) => value.trim().length > 0)));
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// src/discovery/scanner-factory.ts
|
|
1518
|
+
function buildScanners(config, hasGitHubAuth) {
|
|
1519
|
+
const names = [];
|
|
1520
|
+
if (config?.discovery.scanners.lint !== false) {
|
|
1521
|
+
names.push("lint");
|
|
1522
|
+
}
|
|
1523
|
+
if (config?.discovery.scanners.todo !== false) {
|
|
1524
|
+
names.push("todo");
|
|
1525
|
+
}
|
|
1526
|
+
if (config?.discovery.scanners.testGap !== false) {
|
|
1527
|
+
names.push("test-gap");
|
|
1528
|
+
}
|
|
1529
|
+
if (hasGitHubAuth) {
|
|
1530
|
+
names.push("github-issues");
|
|
1531
|
+
}
|
|
1532
|
+
if (names.length === 0) {
|
|
1533
|
+
names.push("lint", "todo", "test-gap");
|
|
1534
|
+
}
|
|
1535
|
+
const unique2 = [...new Set(names)];
|
|
1536
|
+
const instances = unique2.map(instantiateScanner);
|
|
1537
|
+
return { names: unique2, instances, composite: new CompositeScanner(instances) };
|
|
1538
|
+
}
|
|
1539
|
+
function instantiateScanner(name) {
|
|
1540
|
+
switch (name) {
|
|
1541
|
+
case "lint":
|
|
1542
|
+
return new LintScanner();
|
|
1543
|
+
case "todo":
|
|
1544
|
+
return new TodoScanner();
|
|
1545
|
+
case "test-gap":
|
|
1546
|
+
return new TestGapScanner();
|
|
1547
|
+
case "github-issues":
|
|
1548
|
+
return new GitHubIssuesScanner();
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// src/discovery/ranker.ts
|
|
1553
|
+
var IMPACT_BY_SOURCE = {
|
|
1554
|
+
lint: 22,
|
|
1555
|
+
todo: 10,
|
|
1556
|
+
"test-gap": 24,
|
|
1557
|
+
"dead-code": 14,
|
|
1558
|
+
"github-issue": 20,
|
|
1559
|
+
custom: 12
|
|
1560
|
+
};
|
|
1561
|
+
var FEASIBILITY_BY_COMPLEXITY = {
|
|
1562
|
+
trivial: 25,
|
|
1563
|
+
simple: 20,
|
|
1564
|
+
moderate: 12,
|
|
1565
|
+
complex: 6
|
|
1566
|
+
};
|
|
1567
|
+
var TOKEN_EFFICIENCY_BY_COMPLEXITY = {
|
|
1568
|
+
trivial: 18,
|
|
1569
|
+
simple: 14,
|
|
1570
|
+
moderate: 8,
|
|
1571
|
+
complex: 4
|
|
1572
|
+
};
|
|
1573
|
+
function rankTasks(tasks) {
|
|
1574
|
+
const ranked = tasks.map((task) => {
|
|
1575
|
+
const scores = scoreTask(task);
|
|
1576
|
+
const priority = clamp(
|
|
1577
|
+
Math.round(
|
|
1578
|
+
scores.impactScore + scores.feasibilityScore + scores.freshnessScore + scores.issueSignals + scores.tokenEfficiency
|
|
1579
|
+
),
|
|
1580
|
+
0,
|
|
1581
|
+
100
|
|
1582
|
+
);
|
|
1583
|
+
const metadata = toRecord4(task.metadata);
|
|
1584
|
+
return {
|
|
1585
|
+
...task,
|
|
1586
|
+
priority,
|
|
1587
|
+
metadata: {
|
|
1588
|
+
...metadata,
|
|
1589
|
+
priorityBreakdown: scores
|
|
1590
|
+
}
|
|
1591
|
+
};
|
|
1592
|
+
});
|
|
1593
|
+
ranked.sort((left, right) => {
|
|
1594
|
+
const byPriority = right.priority - left.priority;
|
|
1595
|
+
if (byPriority !== 0) {
|
|
1596
|
+
return byPriority;
|
|
1597
|
+
}
|
|
1598
|
+
return left.title.localeCompare(right.title);
|
|
1599
|
+
});
|
|
1600
|
+
return ranked;
|
|
1601
|
+
}
|
|
1602
|
+
function scoreTask(task) {
|
|
1603
|
+
const metadata = toRecord4(task.metadata);
|
|
1604
|
+
const impactScore = scoreImpact(task, metadata);
|
|
1605
|
+
const feasibilityScore = scoreFeasibility(task, metadata);
|
|
1606
|
+
const freshnessScore = scoreFreshness(task, metadata);
|
|
1607
|
+
const issueSignals = scoreIssueSignals(task, metadata);
|
|
1608
|
+
const tokenEfficiency = scoreTokenEfficiency(task, metadata, impactScore);
|
|
1609
|
+
return {
|
|
1610
|
+
impactScore,
|
|
1611
|
+
feasibilityScore,
|
|
1612
|
+
freshnessScore,
|
|
1613
|
+
issueSignals,
|
|
1614
|
+
tokenEfficiency
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
function scoreImpact(task, metadata) {
|
|
1618
|
+
let score = IMPACT_BY_SOURCE[task.source] ?? 12;
|
|
1619
|
+
const matchCount = readNumber(metadata, "matchCount");
|
|
1620
|
+
if (task.source === "todo" && matchCount !== void 0) {
|
|
1621
|
+
if (matchCount >= 4) {
|
|
1622
|
+
score += 4;
|
|
1623
|
+
} else if (matchCount >= 2) {
|
|
1624
|
+
score += 2;
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
const issueCount = readNumber(metadata, "issueCount");
|
|
1628
|
+
if (task.source === "lint" && issueCount !== void 0 && issueCount >= 5) {
|
|
1629
|
+
score += 2;
|
|
1630
|
+
}
|
|
1631
|
+
if (task.linkedIssue) {
|
|
1632
|
+
score += 2;
|
|
1633
|
+
}
|
|
1634
|
+
return clamp(score, 0, 25);
|
|
1635
|
+
}
|
|
1636
|
+
function scoreFeasibility(task, metadata) {
|
|
1637
|
+
let score = FEASIBILITY_BY_COMPLEXITY[task.complexity];
|
|
1638
|
+
const fileCount = Math.max(task.targetFiles.length, readNumber(metadata, "targetFileCount") ?? 0);
|
|
1639
|
+
if (fileCount >= 6) {
|
|
1640
|
+
score -= 8;
|
|
1641
|
+
} else if (fileCount >= 3) {
|
|
1642
|
+
score -= 4;
|
|
1643
|
+
}
|
|
1644
|
+
if (task.executionMode === "direct-commit") {
|
|
1645
|
+
score -= 2;
|
|
1646
|
+
}
|
|
1647
|
+
return clamp(score, 0, 25);
|
|
1648
|
+
}
|
|
1649
|
+
function scoreFreshness(task, metadata) {
|
|
1650
|
+
const daysSinceChange = readNumber(metadata, "daysSinceLastChange") ?? readNumber(metadata, "freshnessDays") ?? getAgeInDays(readString(metadata, "lastModifiedAt"));
|
|
1651
|
+
if (daysSinceChange === void 0) {
|
|
1652
|
+
const discoveredAge = getAgeInDays(task.discoveredAt);
|
|
1653
|
+
if (discoveredAge === void 0) {
|
|
1654
|
+
return 7;
|
|
1655
|
+
}
|
|
1656
|
+
return clamp(15 - Math.floor(discoveredAge / 3), 4, 15);
|
|
1657
|
+
}
|
|
1658
|
+
if (daysSinceChange <= 3) {
|
|
1659
|
+
return 15;
|
|
1660
|
+
}
|
|
1661
|
+
if (daysSinceChange <= 14) {
|
|
1662
|
+
return 12;
|
|
1663
|
+
}
|
|
1664
|
+
if (daysSinceChange <= 30) {
|
|
1665
|
+
return 9;
|
|
1666
|
+
}
|
|
1667
|
+
if (daysSinceChange <= 90) {
|
|
1668
|
+
return 6;
|
|
1669
|
+
}
|
|
1670
|
+
if (daysSinceChange <= 180) {
|
|
1671
|
+
return 4;
|
|
1672
|
+
}
|
|
1673
|
+
return 2;
|
|
1674
|
+
}
|
|
1675
|
+
function scoreIssueSignals(task, metadata) {
|
|
1676
|
+
let score = 0;
|
|
1677
|
+
if (task.linkedIssue) {
|
|
1678
|
+
score += 5;
|
|
1679
|
+
score += Math.min(task.linkedIssue.labels.length, 4);
|
|
1680
|
+
}
|
|
1681
|
+
const labels = task.linkedIssue?.labels.map((label) => label.toLowerCase()) ?? [];
|
|
1682
|
+
if (labels.includes("good-first-issue")) {
|
|
1683
|
+
score += 2;
|
|
1684
|
+
}
|
|
1685
|
+
if (labels.includes("help-wanted")) {
|
|
1686
|
+
score += 1;
|
|
1687
|
+
}
|
|
1688
|
+
const upvotes = readNumber(metadata, "upvotes") ?? readNumber(metadata, "thumbsUp") ?? 0;
|
|
1689
|
+
const reactions = readNumber(metadata, "reactions") ?? 0;
|
|
1690
|
+
const maintainerComments = readNumber(metadata, "maintainerComments") ?? (readBoolean(metadata, "hasMaintainerComment") ? 1 : 0);
|
|
1691
|
+
score += Math.min(4, Math.floor(upvotes / 2) + Math.floor(reactions / 3));
|
|
1692
|
+
score += Math.min(3, maintainerComments);
|
|
1693
|
+
return clamp(score, 0, 15);
|
|
1694
|
+
}
|
|
1695
|
+
function scoreTokenEfficiency(task, metadata, impactScore) {
|
|
1696
|
+
const estimatedTokens = readNumber(metadata, "estimatedTokens") ?? readNumber(metadata, "totalEstimatedTokens") ?? readNestedNumber(metadata, "tokenEstimate", "totalEstimatedTokens");
|
|
1697
|
+
let score = TOKEN_EFFICIENCY_BY_COMPLEXITY[task.complexity];
|
|
1698
|
+
if (estimatedTokens !== void 0) {
|
|
1699
|
+
if (estimatedTokens <= 1500) {
|
|
1700
|
+
score = 20;
|
|
1701
|
+
} else if (estimatedTokens <= 5e3) {
|
|
1702
|
+
score = 16;
|
|
1703
|
+
} else if (estimatedTokens <= 12e3) {
|
|
1704
|
+
score = 12;
|
|
1705
|
+
} else if (estimatedTokens <= 25e3) {
|
|
1706
|
+
score = 8;
|
|
1707
|
+
} else {
|
|
1708
|
+
score = 4;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
if (task.targetFiles.length >= 4) {
|
|
1712
|
+
score -= 2;
|
|
1713
|
+
}
|
|
1714
|
+
if (impactScore >= 20) {
|
|
1715
|
+
score += 1;
|
|
1716
|
+
}
|
|
1717
|
+
return clamp(score, 0, 20);
|
|
1718
|
+
}
|
|
1719
|
+
function getAgeInDays(value) {
|
|
1720
|
+
if (!value) {
|
|
1721
|
+
return void 0;
|
|
1722
|
+
}
|
|
1723
|
+
const time = Date.parse(value);
|
|
1724
|
+
if (Number.isNaN(time)) {
|
|
1725
|
+
return void 0;
|
|
1726
|
+
}
|
|
1727
|
+
const now = Date.now();
|
|
1728
|
+
const diffMs = Math.max(now - time, 0);
|
|
1729
|
+
return Math.floor(diffMs / (24 * 60 * 60 * 1e3));
|
|
1730
|
+
}
|
|
1731
|
+
function readNestedNumber(metadata, parentKey, childKey) {
|
|
1732
|
+
const parent = toRecord4(metadata[parentKey]);
|
|
1733
|
+
return readNumber(parent, childKey);
|
|
1734
|
+
}
|
|
1735
|
+
function readNumber(metadata, key) {
|
|
1736
|
+
const value = metadata[key];
|
|
1737
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
1738
|
+
}
|
|
1739
|
+
function readString(metadata, key) {
|
|
1740
|
+
const value = metadata[key];
|
|
1741
|
+
return typeof value === "string" ? value : void 0;
|
|
1742
|
+
}
|
|
1743
|
+
function readBoolean(metadata, key) {
|
|
1744
|
+
return metadata[key] === true;
|
|
1745
|
+
}
|
|
1746
|
+
function toRecord4(value) {
|
|
1747
|
+
if (value && typeof value === "object") {
|
|
1748
|
+
return value;
|
|
1749
|
+
}
|
|
1750
|
+
return {};
|
|
1751
|
+
}
|
|
1752
|
+
function clamp(value, min, max) {
|
|
1753
|
+
return Math.min(max, Math.max(min, value));
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// src/discovery/analyzer.ts
|
|
1757
|
+
import { createReadStream } from "fs";
|
|
1758
|
+
import { mkdir, readFile as readFile5, readdir as readdir3, rename, stat as stat2, unlink, writeFile } from "fs/promises";
|
|
1759
|
+
import { createInterface } from "readline";
|
|
1760
|
+
import { dirname, extname, join, relative as relative3, resolve as resolve5 } from "path";
|
|
1761
|
+
import PQueue from "p-queue";
|
|
1762
|
+
var DEFAULT_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
1763
|
+
var DEFAULT_EXCLUDE_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", ".git", "coverage"]);
|
|
1764
|
+
var DEFAULT_EXCLUDE_SUFFIXES = [
|
|
1765
|
+
".d.ts",
|
|
1766
|
+
".test.ts",
|
|
1767
|
+
".spec.ts",
|
|
1768
|
+
".test.tsx",
|
|
1769
|
+
".spec.tsx",
|
|
1770
|
+
".test.js",
|
|
1771
|
+
".spec.js",
|
|
1772
|
+
".test.jsx",
|
|
1773
|
+
".spec.jsx"
|
|
1774
|
+
];
|
|
1775
|
+
var DEFAULT_MAX_AGE_MS = 864e5;
|
|
1776
|
+
var LARGE_FILE_THRESHOLD_BYTES = 1048576;
|
|
1777
|
+
var CONTEXT_DIR_NAME = ".oac/context";
|
|
1778
|
+
var CODEBASE_MAP_FILE = "codebase-map.json";
|
|
1779
|
+
var QUALITY_REPORT_FILE = "quality-report.json";
|
|
1780
|
+
async function analyzeCodebase(repoPath, options) {
|
|
1781
|
+
const resolvedRepoPath = resolve5(repoPath);
|
|
1782
|
+
const sourceDir = options?.sourceDir ?? "src";
|
|
1783
|
+
const srcRoot = join(resolvedRepoPath, sourceDir);
|
|
1784
|
+
const userExclude = options?.exclude ?? [];
|
|
1785
|
+
const repoFullName = options?.repoFullName ?? "";
|
|
1786
|
+
const headSha = options?.headSha ?? "";
|
|
1787
|
+
const allFiles = await walkSourceFiles(srcRoot, userExclude);
|
|
1788
|
+
const MAX_CONCURRENCY = 50;
|
|
1789
|
+
const MIN_CONCURRENCY = 4;
|
|
1790
|
+
const analysisQueue = new PQueue({ concurrency: MAX_CONCURRENCY });
|
|
1791
|
+
const memoryMonitor = createMemoryMonitor({
|
|
1792
|
+
intervalMs: 3e3,
|
|
1793
|
+
pressureRatio: 0.85,
|
|
1794
|
+
onPressure: () => {
|
|
1795
|
+
const current = analysisQueue.concurrency;
|
|
1796
|
+
const reduced = Math.max(MIN_CONCURRENCY, Math.floor(current / 2));
|
|
1797
|
+
if (reduced < current) analysisQueue.concurrency = reduced;
|
|
1798
|
+
},
|
|
1799
|
+
onRelief: () => {
|
|
1800
|
+
const current = analysisQueue.concurrency;
|
|
1801
|
+
const restored = Math.min(MAX_CONCURRENCY, current * 2);
|
|
1802
|
+
if (restored > current) analysisQueue.concurrency = restored;
|
|
1803
|
+
}
|
|
1804
|
+
});
|
|
1805
|
+
const fileInfos = await Promise.all(
|
|
1806
|
+
allFiles.map(
|
|
1807
|
+
(absPath) => analysisQueue.add(async () => {
|
|
1808
|
+
const relPath = relative3(resolvedRepoPath, absPath);
|
|
1809
|
+
const info = await analyzeFile(absPath, relPath);
|
|
1810
|
+
return { ...info, _absolutePath: absPath };
|
|
1811
|
+
})
|
|
1812
|
+
)
|
|
1813
|
+
);
|
|
1814
|
+
memoryMonitor.stop();
|
|
1815
|
+
const moduleMap = buildModuleMap(fileInfos, sourceDir);
|
|
1816
|
+
const moduleNames = new Set(Object.keys(moduleMap));
|
|
1817
|
+
const modules = [];
|
|
1818
|
+
for (const [moduleName, files] of Object.entries(moduleMap)) {
|
|
1819
|
+
const moduleFiles = files.map(({ _absolutePath: _, ...fi }) => fi);
|
|
1820
|
+
const totalLoc2 = moduleFiles.reduce((sum, f) => sum + f.loc, 0);
|
|
1821
|
+
const allExports = moduleFiles.flatMap((f) => f.exports);
|
|
1822
|
+
const dependencies = resolveModuleDependencies(files, moduleName, sourceDir, moduleNames);
|
|
1823
|
+
modules.push({
|
|
1824
|
+
name: moduleName,
|
|
1825
|
+
path: moduleName === "root" ? sourceDir : `${sourceDir}/${moduleName}`,
|
|
1826
|
+
files: moduleFiles,
|
|
1827
|
+
totalLoc: totalLoc2,
|
|
1828
|
+
exports: allExports,
|
|
1829
|
+
dependencies
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
const totalFiles = fileInfos.length;
|
|
1833
|
+
const totalLoc = fileInfos.reduce((sum, f) => sum + f.loc, 0);
|
|
1834
|
+
const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1835
|
+
const codebaseMap = {
|
|
1836
|
+
version: 1,
|
|
1837
|
+
generatedAt,
|
|
1838
|
+
repoFullName,
|
|
1839
|
+
headSha,
|
|
1840
|
+
modules,
|
|
1841
|
+
totalFiles,
|
|
1842
|
+
totalLoc
|
|
1843
|
+
};
|
|
1844
|
+
const findings = await runScanners(resolvedRepoPath, sourceDir, options);
|
|
1845
|
+
const qualityReport = buildQualityReport(findings, repoFullName, generatedAt);
|
|
1846
|
+
return { codebaseMap, qualityReport };
|
|
1847
|
+
}
|
|
1848
|
+
async function persistContext(repoPath, codebaseMap, qualityReport, contextDir) {
|
|
1849
|
+
const resolvedDir = contextDir ? resolve5(contextDir) : join(resolve5(repoPath), CONTEXT_DIR_NAME);
|
|
1850
|
+
await mkdir(resolvedDir, { recursive: true });
|
|
1851
|
+
await atomicWriteJson(join(resolvedDir, CODEBASE_MAP_FILE), codebaseMap);
|
|
1852
|
+
await atomicWriteJson(join(resolvedDir, QUALITY_REPORT_FILE), qualityReport);
|
|
1853
|
+
return resolvedDir;
|
|
1854
|
+
}
|
|
1855
|
+
async function loadContext(repoPath, contextDir) {
|
|
1856
|
+
const resolvedDir = contextDir ? resolve5(contextDir) : join(resolve5(repoPath), CONTEXT_DIR_NAME);
|
|
1857
|
+
try {
|
|
1858
|
+
const [mapRaw, reportRaw] = await Promise.all([
|
|
1859
|
+
readFile5(join(resolvedDir, CODEBASE_MAP_FILE), "utf-8"),
|
|
1860
|
+
readFile5(join(resolvedDir, QUALITY_REPORT_FILE), "utf-8")
|
|
1861
|
+
]);
|
|
1862
|
+
const codebaseMap = JSON.parse(mapRaw);
|
|
1863
|
+
const qualityReport = JSON.parse(reportRaw);
|
|
1864
|
+
return { codebaseMap, qualityReport };
|
|
1865
|
+
} catch {
|
|
1866
|
+
return null;
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
function isContextStale(codebaseMap, maxAgeMs = DEFAULT_MAX_AGE_MS) {
|
|
1870
|
+
const generatedAt = new Date(codebaseMap.generatedAt).getTime();
|
|
1871
|
+
if (Number.isNaN(generatedAt)) {
|
|
1872
|
+
return true;
|
|
1873
|
+
}
|
|
1874
|
+
return Date.now() - generatedAt > maxAgeMs;
|
|
1875
|
+
}
|
|
1876
|
+
function deriveModuleFromPath(filePath, sourceDir = "src") {
|
|
1877
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
1878
|
+
const prefix = `${sourceDir.replace(/\\/g, "/")}/`;
|
|
1879
|
+
if (!normalized.startsWith(prefix)) {
|
|
1880
|
+
return "root";
|
|
1881
|
+
}
|
|
1882
|
+
const afterSrc = normalized.slice(prefix.length);
|
|
1883
|
+
const firstSlash = afterSrc.indexOf("/");
|
|
1884
|
+
if (firstSlash === -1) {
|
|
1885
|
+
return "root";
|
|
1886
|
+
}
|
|
1887
|
+
return afterSrc.slice(0, firstSlash);
|
|
1888
|
+
}
|
|
1889
|
+
async function walkSourceFiles(dirPath, userExclude) {
|
|
1890
|
+
const results = [];
|
|
1891
|
+
const userExcludeDirs = /* @__PURE__ */ new Set();
|
|
1892
|
+
const userExcludeSuffixes = [];
|
|
1893
|
+
for (const pattern of userExclude) {
|
|
1894
|
+
const cleaned = pattern.replace(/\/\*{1,2}$/, "");
|
|
1895
|
+
if (cleaned.startsWith("*.")) {
|
|
1896
|
+
userExcludeSuffixes.push(cleaned.slice(1));
|
|
1897
|
+
} else {
|
|
1898
|
+
userExcludeDirs.add(cleaned);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
function isExcludedFile(name) {
|
|
1902
|
+
if (!DEFAULT_EXTENSIONS.has(extname(name))) return true;
|
|
1903
|
+
if (DEFAULT_EXCLUDE_SUFFIXES.some((suffix) => name.endsWith(suffix))) return true;
|
|
1904
|
+
if (userExcludeSuffixes.some((suffix) => name.endsWith(suffix))) return true;
|
|
1905
|
+
return false;
|
|
1906
|
+
}
|
|
1907
|
+
function isExcludedDir(name) {
|
|
1908
|
+
return DEFAULT_EXCLUDE_DIRS.has(name) || userExcludeDirs.has(name);
|
|
1909
|
+
}
|
|
1910
|
+
async function walk(dir) {
|
|
1911
|
+
let entries;
|
|
1912
|
+
try {
|
|
1913
|
+
entries = await readdir3(dir, { withFileTypes: true });
|
|
1914
|
+
} catch {
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
const subdirs = [];
|
|
1918
|
+
for (const entry of entries) {
|
|
1919
|
+
if (entry.isDirectory() && !isExcludedDir(entry.name)) {
|
|
1920
|
+
subdirs.push(walk(join(dir, entry.name)));
|
|
1921
|
+
} else if (entry.isFile() && !isExcludedFile(entry.name)) {
|
|
1922
|
+
results.push(join(dir, entry.name));
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
await Promise.all(subdirs);
|
|
1926
|
+
}
|
|
1927
|
+
await walk(dirPath);
|
|
1928
|
+
results.sort();
|
|
1929
|
+
return results;
|
|
1930
|
+
}
|
|
1931
|
+
async function analyzeFile(absolutePath, relativePath) {
|
|
1932
|
+
const fileStat = await stat2(absolutePath);
|
|
1933
|
+
if (fileStat.size > LARGE_FILE_THRESHOLD_BYTES) {
|
|
1934
|
+
const loc2 = await countLinesStreaming(absolutePath);
|
|
1935
|
+
return { path: relativePath, loc: loc2, sizeBytes: fileStat.size, exports: [], imports: [] };
|
|
1936
|
+
}
|
|
1937
|
+
const content = await readFile5(absolutePath, "utf-8");
|
|
1938
|
+
const lines = content.split("\n");
|
|
1939
|
+
const loc = lines.filter((line) => line.trim().length > 0).length;
|
|
1940
|
+
const exports = extractExports(content);
|
|
1941
|
+
const imports = extractImports(content);
|
|
1942
|
+
return {
|
|
1943
|
+
path: relativePath,
|
|
1944
|
+
loc,
|
|
1945
|
+
sizeBytes: fileStat.size,
|
|
1946
|
+
exports,
|
|
1947
|
+
imports
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
async function countLinesStreaming(absolutePath) {
|
|
1951
|
+
return new Promise((resolve6, reject) => {
|
|
1952
|
+
let loc = 0;
|
|
1953
|
+
const rl = createInterface({
|
|
1954
|
+
input: createReadStream(absolutePath, { encoding: "utf-8" }),
|
|
1955
|
+
crlfDelay: Number.POSITIVE_INFINITY
|
|
1956
|
+
});
|
|
1957
|
+
rl.on("line", (line) => {
|
|
1958
|
+
if (line.trim().length > 0) loc += 1;
|
|
1959
|
+
});
|
|
1960
|
+
rl.on("close", () => resolve6(loc));
|
|
1961
|
+
rl.on("error", reject);
|
|
1962
|
+
});
|
|
1963
|
+
}
|
|
1964
|
+
function extractExports(content) {
|
|
1965
|
+
const exports = [];
|
|
1966
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1967
|
+
const add = (name) => {
|
|
1968
|
+
const trimmed = name.trim();
|
|
1969
|
+
if (trimmed && !seen.has(trimmed)) {
|
|
1970
|
+
seen.add(trimmed);
|
|
1971
|
+
exports.push(trimmed);
|
|
1972
|
+
}
|
|
1973
|
+
};
|
|
1974
|
+
const namedDeclRe = /\bexport\s+(?:async\s+)?(?:function\*?|class|const|let|var|type|interface|enum)\s+([A-Za-z_$][A-Za-z0-9_$]*)/g;
|
|
1975
|
+
for (const m of content.matchAll(namedDeclRe)) {
|
|
1976
|
+
add(m[1]);
|
|
1977
|
+
}
|
|
1978
|
+
const bracedRe = /\bexport\s*\{([^}]+)\}/g;
|
|
1979
|
+
for (const m of content.matchAll(bracedRe)) {
|
|
1980
|
+
const inner = m[1];
|
|
1981
|
+
for (const item of inner.split(",")) {
|
|
1982
|
+
const parts = item.trim().split(/\s+as\s+/);
|
|
1983
|
+
const exportedName = parts.length > 1 ? parts[1].trim() : parts[0].trim();
|
|
1984
|
+
if (exportedName) {
|
|
1985
|
+
add(exportedName);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
const defaultRe = /\bexport\s+default\b/;
|
|
1990
|
+
if (defaultRe.test(content)) {
|
|
1991
|
+
add("default");
|
|
1992
|
+
}
|
|
1993
|
+
return exports;
|
|
1994
|
+
}
|
|
1995
|
+
function extractImports(content) {
|
|
1996
|
+
const imports = [];
|
|
1997
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1998
|
+
const add = (path) => {
|
|
1999
|
+
const trimmed = path.trim();
|
|
2000
|
+
if (trimmed && !seen.has(trimmed)) {
|
|
2001
|
+
seen.add(trimmed);
|
|
2002
|
+
imports.push(trimmed);
|
|
2003
|
+
}
|
|
2004
|
+
};
|
|
2005
|
+
const staticImportRe = /\bimport\s+(?:[\s\S]*?\s+from\s+)?["']([^"']+)["']/g;
|
|
2006
|
+
for (const m of content.matchAll(staticImportRe)) {
|
|
2007
|
+
add(m[1]);
|
|
2008
|
+
}
|
|
2009
|
+
const dynamicImportRe = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
|
|
2010
|
+
for (const m of content.matchAll(dynamicImportRe)) {
|
|
2011
|
+
add(m[1]);
|
|
2012
|
+
}
|
|
2013
|
+
return imports;
|
|
2014
|
+
}
|
|
2015
|
+
function buildModuleMap(fileInfos, sourceDir) {
|
|
2016
|
+
const moduleMap = {};
|
|
2017
|
+
for (const fi of fileInfos) {
|
|
2018
|
+
const moduleName = deriveModuleFromPath(fi.path, sourceDir);
|
|
2019
|
+
if (!moduleMap[moduleName]) {
|
|
2020
|
+
moduleMap[moduleName] = [];
|
|
2021
|
+
}
|
|
2022
|
+
moduleMap[moduleName].push(fi);
|
|
2023
|
+
}
|
|
2024
|
+
return moduleMap;
|
|
2025
|
+
}
|
|
2026
|
+
function resolveModuleDependencies(files, currentModule, sourceDir, allModuleNames) {
|
|
2027
|
+
const deps = /* @__PURE__ */ new Set();
|
|
2028
|
+
for (const file of files) {
|
|
2029
|
+
for (const importPath of file.imports) {
|
|
2030
|
+
if (!importPath.startsWith(".")) continue;
|
|
2031
|
+
const fileDir = dirname(file.path);
|
|
2032
|
+
const resolvedImport = join(fileDir, importPath).replace(/\\/g, "/");
|
|
2033
|
+
const importModule = deriveModuleFromPath(resolvedImport, sourceDir);
|
|
2034
|
+
if (importModule !== currentModule && allModuleNames.has(importModule)) {
|
|
2035
|
+
deps.add(importModule);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
return [...deps].sort();
|
|
2040
|
+
}
|
|
2041
|
+
async function runScanners(repoPath, sourceDir, options) {
|
|
2042
|
+
const scanners = options?.scanners;
|
|
2043
|
+
const composite = scanners ? new CompositeScanner(scanners) : new CompositeScanner();
|
|
2044
|
+
const scanOptions = {
|
|
2045
|
+
exclude: options?.exclude
|
|
2046
|
+
};
|
|
2047
|
+
let tasks;
|
|
2048
|
+
try {
|
|
2049
|
+
tasks = await composite.scan(repoPath, scanOptions);
|
|
2050
|
+
} catch {
|
|
2051
|
+
tasks = [];
|
|
2052
|
+
}
|
|
2053
|
+
return tasks.map((task) => taskToRawFinding(task, sourceDir));
|
|
2054
|
+
}
|
|
2055
|
+
function taskToRawFinding(task, sourceDir) {
|
|
2056
|
+
const filePath = task.targetFiles[0] ?? "";
|
|
2057
|
+
const scannerId = typeof task.metadata?.scannerId === "string" ? task.metadata.scannerId : task.source;
|
|
2058
|
+
return {
|
|
2059
|
+
scannerId,
|
|
2060
|
+
source: task.source,
|
|
2061
|
+
filePath,
|
|
2062
|
+
module: deriveModuleFromPath(filePath, sourceDir),
|
|
2063
|
+
title: task.title,
|
|
2064
|
+
description: task.description,
|
|
2065
|
+
severity: deriveSeverity(task.source),
|
|
2066
|
+
complexity: task.complexity,
|
|
2067
|
+
line: typeof task.metadata?.startLine === "number" ? task.metadata.startLine : void 0,
|
|
2068
|
+
metadata: task.metadata,
|
|
2069
|
+
discoveredAt: task.discoveredAt
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
function deriveSeverity(source) {
|
|
2073
|
+
switch (source) {
|
|
2074
|
+
case "lint":
|
|
2075
|
+
return "warning";
|
|
2076
|
+
case "todo":
|
|
2077
|
+
return "info";
|
|
2078
|
+
case "test-gap":
|
|
2079
|
+
return "info";
|
|
2080
|
+
case "github-issue":
|
|
2081
|
+
return "warning";
|
|
2082
|
+
case "dead-code":
|
|
2083
|
+
return "warning";
|
|
2084
|
+
case "custom":
|
|
2085
|
+
return "info";
|
|
2086
|
+
default:
|
|
2087
|
+
return "info";
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
function buildQualityReport(findings, repoFullName, generatedAt) {
|
|
2091
|
+
const bySource = {};
|
|
2092
|
+
const byModule = {};
|
|
2093
|
+
const bySeverity = {};
|
|
2094
|
+
for (const finding of findings) {
|
|
2095
|
+
bySource[finding.source] = (bySource[finding.source] ?? 0) + 1;
|
|
2096
|
+
const mod = finding.module ?? "root";
|
|
2097
|
+
byModule[mod] = (byModule[mod] ?? 0) + 1;
|
|
2098
|
+
bySeverity[finding.severity] = (bySeverity[finding.severity] ?? 0) + 1;
|
|
2099
|
+
}
|
|
2100
|
+
return {
|
|
2101
|
+
version: 1,
|
|
2102
|
+
generatedAt,
|
|
2103
|
+
repoFullName,
|
|
2104
|
+
findings,
|
|
2105
|
+
summary: {
|
|
2106
|
+
totalFindings: findings.length,
|
|
2107
|
+
bySource,
|
|
2108
|
+
byModule,
|
|
2109
|
+
bySeverity
|
|
2110
|
+
}
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
2113
|
+
async function atomicWriteJson(filePath, data) {
|
|
2114
|
+
const tmpPath = `${filePath}.tmp.${Date.now()}`;
|
|
2115
|
+
const content = `${JSON.stringify(data, null, 2)}
|
|
2116
|
+
`;
|
|
2117
|
+
try {
|
|
2118
|
+
await writeFile(tmpPath, content, "utf-8");
|
|
2119
|
+
await rename(tmpPath, filePath);
|
|
2120
|
+
} catch (error) {
|
|
2121
|
+
try {
|
|
2122
|
+
await unlink(tmpPath);
|
|
2123
|
+
} catch {
|
|
2124
|
+
}
|
|
2125
|
+
throw error;
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
// src/discovery/epic-grouper.ts
|
|
2130
|
+
import { randomUUID } from "crypto";
|
|
2131
|
+
var DEFAULT_MAX_SUBTASKS = 10;
|
|
2132
|
+
var DEFAULT_MIN_FINDINGS = 2;
|
|
2133
|
+
var MAX_CONTEXT_FILES = 20;
|
|
2134
|
+
function groupFindingsIntoEpics(findings, options) {
|
|
2135
|
+
const maxSubtasks = options?.maxSubtasksPerEpic ?? DEFAULT_MAX_SUBTASKS;
|
|
2136
|
+
const _minFindings = options?.minFindingsForEpic ?? DEFAULT_MIN_FINDINGS;
|
|
2137
|
+
const codebaseMap = options?.codebaseMap;
|
|
2138
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2139
|
+
for (const finding of findings) {
|
|
2140
|
+
const module = finding.module ?? deriveModuleFromPath(finding.filePath);
|
|
2141
|
+
const key = `${module}:${finding.source}`;
|
|
2142
|
+
let group = groups.get(key);
|
|
2143
|
+
if (!group) {
|
|
2144
|
+
group = [];
|
|
2145
|
+
groups.set(key, group);
|
|
2146
|
+
}
|
|
2147
|
+
group.push(finding);
|
|
2148
|
+
}
|
|
2149
|
+
const epics = [];
|
|
2150
|
+
for (const [key, groupFindings] of groups) {
|
|
2151
|
+
const [module, source] = key.split(":");
|
|
2152
|
+
if (groupFindings.length <= maxSubtasks) {
|
|
2153
|
+
epics.push(buildEpic(source, module, groupFindings, codebaseMap));
|
|
2154
|
+
} else {
|
|
2155
|
+
const chunks = chunkArray(groupFindings, maxSubtasks);
|
|
2156
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
2157
|
+
epics.push(
|
|
2158
|
+
buildEpic(source, module, chunks[i], codebaseMap, {
|
|
2159
|
+
partIndex: i + 1,
|
|
2160
|
+
totalParts: chunks.length
|
|
2161
|
+
})
|
|
2162
|
+
);
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
return epics;
|
|
2167
|
+
}
|
|
2168
|
+
function buildEpic(source, module, findings, codebaseMap, partInfo) {
|
|
2169
|
+
const epicId = randomUUID().slice(0, 16);
|
|
2170
|
+
const subtasks = findings.map((f) => findingToTask(f, epicId));
|
|
2171
|
+
let title = buildEpicTitle(source, module, subtasks);
|
|
2172
|
+
if (partInfo) {
|
|
2173
|
+
title = `${title} (${partInfo.partIndex}/${partInfo.totalParts})`;
|
|
2174
|
+
}
|
|
2175
|
+
return {
|
|
2176
|
+
id: epicId,
|
|
2177
|
+
title,
|
|
2178
|
+
description: buildEpicDescription(source, module, subtasks),
|
|
2179
|
+
scope: module,
|
|
2180
|
+
subtasks,
|
|
2181
|
+
contextFiles: resolveContextFiles(module, codebaseMap),
|
|
2182
|
+
status: "pending",
|
|
2183
|
+
priority: computeEpicPriority(subtasks),
|
|
2184
|
+
estimatedTokens: 0,
|
|
2185
|
+
// filled later by estimator
|
|
2186
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2187
|
+
metadata: {
|
|
2188
|
+
groupingStrategy: "by-module",
|
|
2189
|
+
source,
|
|
2190
|
+
findingCount: findings.length
|
|
2191
|
+
}
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
function buildEpicTitle(source, module, _subtasks) {
|
|
2195
|
+
const moduleLabel = module === "root" ? "" : ` for ${module} module`;
|
|
2196
|
+
switch (source) {
|
|
2197
|
+
case "test-gap":
|
|
2198
|
+
return `Improve test coverage${moduleLabel}`;
|
|
2199
|
+
case "todo":
|
|
2200
|
+
return `Address TODO comments${module === "root" ? "" : ` in ${module} module`}`;
|
|
2201
|
+
case "lint":
|
|
2202
|
+
return `Fix lint issues${module === "root" ? "" : ` in ${module} module`}`;
|
|
2203
|
+
case "dead-code":
|
|
2204
|
+
return `Remove dead code${module === "root" ? "" : ` in ${module} module`}`;
|
|
2205
|
+
case "github-issue":
|
|
2206
|
+
return `Resolve GitHub issues${module === "root" ? "" : ` in ${module} module`}`;
|
|
2207
|
+
case "custom":
|
|
2208
|
+
return `Address findings${module === "root" ? "" : ` in ${module} module`}`;
|
|
2209
|
+
default:
|
|
2210
|
+
return `Address ${source} findings${module === "root" ? "" : ` in ${module} module`}`;
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
function buildEpicDescription(source, module, subtasks) {
|
|
2214
|
+
const lines = [
|
|
2215
|
+
`Grouped ${subtasks.length} ${source} findings in the ${module} module:`,
|
|
2216
|
+
""
|
|
2217
|
+
];
|
|
2218
|
+
for (const task of subtasks) {
|
|
2219
|
+
const file = task.targetFiles.length > 0 ? `[${task.targetFiles[0]}] ` : "";
|
|
2220
|
+
lines.push(`- ${file}${task.title}`);
|
|
2221
|
+
}
|
|
2222
|
+
return lines.join("\n");
|
|
2223
|
+
}
|
|
2224
|
+
function findingToTask(finding, epicId) {
|
|
2225
|
+
return {
|
|
2226
|
+
id: `${finding.source}-${randomUUID().slice(0, 8)}`,
|
|
2227
|
+
source: finding.source,
|
|
2228
|
+
title: finding.title,
|
|
2229
|
+
description: finding.description,
|
|
2230
|
+
targetFiles: [finding.filePath].filter(Boolean),
|
|
2231
|
+
priority: derivePriorityFromSeverity(finding.severity),
|
|
2232
|
+
complexity: finding.complexity,
|
|
2233
|
+
executionMode: "new-pr",
|
|
2234
|
+
metadata: finding.metadata,
|
|
2235
|
+
discoveredAt: finding.discoveredAt,
|
|
2236
|
+
parentEpicId: epicId
|
|
2237
|
+
};
|
|
2238
|
+
}
|
|
2239
|
+
function derivePriorityFromSeverity(severity) {
|
|
2240
|
+
switch (severity) {
|
|
2241
|
+
case "error":
|
|
2242
|
+
return 80;
|
|
2243
|
+
case "warning":
|
|
2244
|
+
return 50;
|
|
2245
|
+
case "info":
|
|
2246
|
+
return 30;
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
function resolveContextFiles(module, codebaseMap) {
|
|
2250
|
+
if (!codebaseMap) return [];
|
|
2251
|
+
const moduleInfo = codebaseMap.modules.find((m) => m.name === module);
|
|
2252
|
+
if (!moduleInfo) return [];
|
|
2253
|
+
return moduleInfo.files.map((f) => f.path).slice(0, MAX_CONTEXT_FILES);
|
|
2254
|
+
}
|
|
2255
|
+
function computeEpicPriority(subtasks) {
|
|
2256
|
+
if (subtasks.length === 0) return 0;
|
|
2257
|
+
const avg = subtasks.reduce((sum, t) => sum + t.priority, 0) / subtasks.length;
|
|
2258
|
+
const sizeBoost = Math.min(10, subtasks.length * 2);
|
|
2259
|
+
return Math.min(100, Math.round(avg + sizeBoost));
|
|
2260
|
+
}
|
|
2261
|
+
function computeEpicComplexity(subtasks) {
|
|
2262
|
+
if (subtasks.length === 1) return subtasks[0].complexity;
|
|
2263
|
+
if (subtasks.length <= 3) return "simple";
|
|
2264
|
+
if (subtasks.length <= 6) return "moderate";
|
|
2265
|
+
return "complex";
|
|
2266
|
+
}
|
|
2267
|
+
function chunkArray(array, size) {
|
|
2268
|
+
const chunks = [];
|
|
2269
|
+
for (let i = 0; i < array.length; i += size) {
|
|
2270
|
+
chunks.push(array.slice(i, i + size));
|
|
2271
|
+
}
|
|
2272
|
+
return chunks;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
// src/discovery/backlog.ts
|
|
2276
|
+
import { mkdir as mkdir2, readFile as readFile6, rename as rename2, writeFile as writeFile2 } from "fs/promises";
|
|
2277
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
2278
|
+
var DEFAULT_CONTEXT_DIR = ".oac/context";
|
|
2279
|
+
async function persistBacklog(repoPath, backlog, contextDir) {
|
|
2280
|
+
const path = join2(repoPath, contextDir ?? DEFAULT_CONTEXT_DIR, "backlog.json");
|
|
2281
|
+
await mkdir2(dirname2(path), { recursive: true });
|
|
2282
|
+
const tempPath = `${path}.tmp`;
|
|
2283
|
+
await writeFile2(tempPath, JSON.stringify(backlog, null, 2), "utf8");
|
|
2284
|
+
await rename2(tempPath, path);
|
|
2285
|
+
return path;
|
|
2286
|
+
}
|
|
2287
|
+
async function loadBacklog(repoPath, contextDir) {
|
|
2288
|
+
const path = join2(repoPath, contextDir ?? DEFAULT_CONTEXT_DIR, "backlog.json");
|
|
2289
|
+
try {
|
|
2290
|
+
const content = await readFile6(path, "utf8");
|
|
2291
|
+
return JSON.parse(content);
|
|
2292
|
+
} catch (err) {
|
|
2293
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
2294
|
+
return null;
|
|
2295
|
+
}
|
|
2296
|
+
if (err instanceof SyntaxError) {
|
|
2297
|
+
return null;
|
|
2298
|
+
}
|
|
2299
|
+
return null;
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
function createBacklog(repoFullName, headSha, epics) {
|
|
2303
|
+
return {
|
|
2304
|
+
version: 1,
|
|
2305
|
+
lastUpdatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2306
|
+
repoFullName,
|
|
2307
|
+
headSha,
|
|
2308
|
+
epics
|
|
2309
|
+
};
|
|
2310
|
+
}
|
|
2311
|
+
function updateBacklog(existing, newEpics, completedEpicIds, headSha) {
|
|
2312
|
+
const epicMap = /* @__PURE__ */ new Map();
|
|
2313
|
+
for (const epic of existing.epics) {
|
|
2314
|
+
epicMap.set(epic.id, { ...epic });
|
|
2315
|
+
}
|
|
2316
|
+
for (const id of completedEpicIds) {
|
|
2317
|
+
const epic = epicMap.get(id);
|
|
2318
|
+
if (epic) {
|
|
2319
|
+
epic.status = "completed";
|
|
2320
|
+
epic.completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
for (const epic of newEpics) {
|
|
2324
|
+
const existing2 = epicMap.get(epic.id);
|
|
2325
|
+
if (!existing2) {
|
|
2326
|
+
epicMap.set(epic.id, { ...epic });
|
|
2327
|
+
} else if (existing2.status === "pending") {
|
|
2328
|
+
epicMap.set(epic.id, { ...epic });
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
const epics = Array.from(epicMap.values());
|
|
2332
|
+
return {
|
|
2333
|
+
version: 1,
|
|
2334
|
+
lastUpdatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2335
|
+
repoFullName: existing.repoFullName,
|
|
2336
|
+
headSha: headSha ?? existing.headSha,
|
|
2337
|
+
epics
|
|
2338
|
+
};
|
|
2339
|
+
}
|
|
2340
|
+
function getPendingEpics(backlog) {
|
|
2341
|
+
return backlog.epics.filter((e) => e.status === "pending").sort((a, b) => b.priority - a.priority);
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
export {
|
|
2345
|
+
TodoScanner,
|
|
2346
|
+
LintScanner,
|
|
2347
|
+
TestGapScanner,
|
|
2348
|
+
GitHubIssuesScanner,
|
|
2349
|
+
CompositeScanner,
|
|
2350
|
+
createDefaultCompositeScanner,
|
|
2351
|
+
buildScanners,
|
|
2352
|
+
rankTasks,
|
|
2353
|
+
analyzeCodebase,
|
|
2354
|
+
persistContext,
|
|
2355
|
+
loadContext,
|
|
2356
|
+
isContextStale,
|
|
2357
|
+
deriveModuleFromPath,
|
|
2358
|
+
groupFindingsIntoEpics,
|
|
2359
|
+
findingToTask,
|
|
2360
|
+
computeEpicPriority,
|
|
2361
|
+
computeEpicComplexity,
|
|
2362
|
+
persistBacklog,
|
|
2363
|
+
loadBacklog,
|
|
2364
|
+
createBacklog,
|
|
2365
|
+
updateBacklog,
|
|
2366
|
+
getPendingEpics
|
|
2367
|
+
};
|
|
2368
|
+
//# sourceMappingURL=chunk-OTPXGXO7.js.map
|