@open330/oac-discovery 2026.2.17

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/dist/index.js ADDED
@@ -0,0 +1,1723 @@
1
+ // src/scanners/todo-scanner.ts
2
+ import { spawn } from "child_process";
3
+ import { createHash } from "crypto";
4
+ import { readFile, readdir } from "fs/promises";
5
+ import { resolve, sep } from "path";
6
+ var DEFAULT_TIMEOUT_MS = 6e4;
7
+ var TODO_GROUPING_WINDOW = 10;
8
+ var TODO_RG_PATTERN = "\\b(TODO|FIXME|HACK|XXX)\\b";
9
+ var TODO_KEYWORD_PATTERN = /\b(TODO|FIXME|HACK|XXX)\b/i;
10
+ var TODO_TEXT_PATTERN = /\b(TODO|FIXME|HACK|XXX)\b[:\s-]?(.*)$/i;
11
+ var COMMENT_CONTINUATION_PATTERN = /^\s*(?:\/\/|\/\*+|\*|#|--)/;
12
+ var MAX_FUNCTION_LOOKBACK_LINES = 80;
13
+ var DEFAULT_EXCLUDES = [".git", "node_modules", "dist", "build", "coverage"];
14
+ var FUNCTION_PATTERNS = [
15
+ /^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/,
16
+ /^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/,
17
+ /^\s*(?:public|private|protected|static|readonly|\s)*(?:async\s+)?([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*[:{]/,
18
+ /^\s*def\s+([A-Za-z_]\w*)\s*\(/
19
+ ];
20
+ var TodoScanner = class {
21
+ id = "todo";
22
+ name = "TODO Scanner";
23
+ async scan(repoPath, options = {}) {
24
+ const matches = await this.findTodoMatches(repoPath, options);
25
+ if (matches.length === 0) {
26
+ return [];
27
+ }
28
+ const grouped = groupTodoMatches(matches, TODO_GROUPING_WINDOW);
29
+ const fileCache = /* @__PURE__ */ new Map();
30
+ const now = (/* @__PURE__ */ new Date()).toISOString();
31
+ const tasks = [];
32
+ for (const cluster of grouped) {
33
+ const fileLines = await getFileLines(repoPath, cluster.filePath, fileCache);
34
+ const task = buildTodoTask(cluster, fileLines, now);
35
+ tasks.push(task);
36
+ }
37
+ if (typeof options.maxTasks === "number" && options.maxTasks >= 0) {
38
+ return tasks.slice(0, options.maxTasks);
39
+ }
40
+ return tasks;
41
+ }
42
+ async findTodoMatches(repoPath, options) {
43
+ try {
44
+ return await findTodoMatchesWithRipgrep(repoPath, options);
45
+ } catch (error) {
46
+ if (isCommandNotFound(error)) {
47
+ return findTodoMatchesWithFsFallback(repoPath, options);
48
+ }
49
+ throw error;
50
+ }
51
+ }
52
+ };
53
+ async function findTodoMatchesWithRipgrep(repoPath, options) {
54
+ const args = ["--json", "--line-number", "--column"];
55
+ if (options.includeHidden) {
56
+ args.push("--hidden");
57
+ }
58
+ const excludes = mergeExcludes(options.exclude);
59
+ for (const pattern of excludes) {
60
+ args.push("--glob", toRgExclude(pattern));
61
+ }
62
+ args.push("-e", TODO_RG_PATTERN, ".");
63
+ const result = await runCommand("rg", args, {
64
+ cwd: repoPath,
65
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
66
+ signal: options.signal
67
+ });
68
+ if (result.timedOut) {
69
+ throw new Error(`TODO scanner timed out after ${options.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms`);
70
+ }
71
+ if (result.exitCode === 1 && result.stdout.trim().length === 0) {
72
+ return [];
73
+ }
74
+ if (result.exitCode !== 0 && result.exitCode !== 1) {
75
+ throw new Error(`ripgrep failed: ${result.stderr || result.stdout}`);
76
+ }
77
+ return parseRipgrepJson(result.stdout);
78
+ }
79
+ function parseRipgrepJson(output) {
80
+ const matches = [];
81
+ for (const line of output.split(/\r?\n/)) {
82
+ if (line.trim().length === 0) {
83
+ continue;
84
+ }
85
+ let parsed;
86
+ try {
87
+ parsed = JSON.parse(line);
88
+ } catch {
89
+ continue;
90
+ }
91
+ const record = toRecord(parsed);
92
+ if (record.type !== "match") {
93
+ continue;
94
+ }
95
+ const data = toRecord(record.data);
96
+ const pathRecord = toRecord(data.path);
97
+ const linesRecord = toRecord(data.lines);
98
+ const rawFilePath = asString(pathRecord.text);
99
+ const rawText = asString(linesRecord.text);
100
+ const filePath = rawFilePath ? normalizeRelativePath(rawFilePath) : void 0;
101
+ const lineNumber = asNumber(data.line_number);
102
+ const text = rawText ? sanitizeLine(rawText) : void 0;
103
+ const submatches = asArray(data.submatches);
104
+ const firstSubmatch = toRecord(submatches.at(0));
105
+ const column = (asNumber(firstSubmatch.start) ?? 0) + 1;
106
+ if (!filePath || !lineNumber || !text) {
107
+ continue;
108
+ }
109
+ const keyword = extractTodoKeyword(text) ?? "TODO";
110
+ matches.push({
111
+ filePath,
112
+ line: lineNumber,
113
+ column,
114
+ keyword,
115
+ text
116
+ });
117
+ }
118
+ return matches;
119
+ }
120
+ async function findTodoMatchesWithFsFallback(repoPath, options) {
121
+ const excludes = mergeExcludes(options.exclude);
122
+ const files = await collectFiles(repoPath, excludes);
123
+ const matches = [];
124
+ for (const filePath of files) {
125
+ const absolutePath = resolve(repoPath, filePath);
126
+ let content = "";
127
+ try {
128
+ content = await readFile(absolutePath, "utf8");
129
+ } catch {
130
+ continue;
131
+ }
132
+ const lines = content.split(/\r?\n/);
133
+ for (let index = 0; index < lines.length; index += 1) {
134
+ const lineText = lines[index] ?? "";
135
+ if (!TODO_KEYWORD_PATTERN.test(lineText)) {
136
+ continue;
137
+ }
138
+ const keyword = extractTodoKeyword(lineText) ?? "TODO";
139
+ const columnIndex = lineText.search(TODO_KEYWORD_PATTERN);
140
+ matches.push({
141
+ filePath,
142
+ line: index + 1,
143
+ column: columnIndex >= 0 ? columnIndex + 1 : 1,
144
+ keyword,
145
+ text: sanitizeLine(lineText)
146
+ });
147
+ }
148
+ }
149
+ return matches;
150
+ }
151
+ function groupTodoMatches(matches, lineWindow) {
152
+ const sorted = [...matches].sort((left, right) => {
153
+ const byFile = left.filePath.localeCompare(right.filePath);
154
+ if (byFile !== 0) {
155
+ return byFile;
156
+ }
157
+ return left.line - right.line;
158
+ });
159
+ const groups = [];
160
+ let active;
161
+ for (const match of sorted) {
162
+ if (!active) {
163
+ active = { filePath: match.filePath, matches: [match] };
164
+ groups.push(active);
165
+ continue;
166
+ }
167
+ const last = active.matches[active.matches.length - 1];
168
+ const sameFile = active.filePath === match.filePath;
169
+ if (sameFile && last && match.line - last.line <= lineWindow) {
170
+ active.matches.push(match);
171
+ continue;
172
+ }
173
+ active = { filePath: match.filePath, matches: [match] };
174
+ groups.push(active);
175
+ }
176
+ return groups;
177
+ }
178
+ async function getFileLines(repoPath, filePath, cache) {
179
+ const cached = cache.get(filePath);
180
+ if (cached) {
181
+ return cached;
182
+ }
183
+ try {
184
+ const text = await readFile(resolve(repoPath, filePath), "utf8");
185
+ const lines = text.split(/\r?\n/);
186
+ cache.set(filePath, lines);
187
+ return lines;
188
+ } catch {
189
+ const empty = [];
190
+ cache.set(filePath, empty);
191
+ return empty;
192
+ }
193
+ }
194
+ function buildTodoTask(cluster, fileLines, discoveredAt) {
195
+ const first = cluster.matches[0];
196
+ const last = cluster.matches[cluster.matches.length - 1];
197
+ const functionName = first ? findNearestFunctionName(fileLines, first.line) : void 0;
198
+ const isMultiLine = cluster.matches.some((match) => isMultiLineTodo(match, fileLines));
199
+ const complexity = cluster.matches.length > 1 || isMultiLine ? "simple" : "trivial";
200
+ const title = first ? `Address TODO comments in ${cluster.filePath}:${first.line}` : `Address TODO comments in ${cluster.filePath}`;
201
+ const todoSummary = cluster.matches.map((match) => `- ${match.keyword} at line ${match.line}: ${truncate(match.text, 140)}`).join("\n");
202
+ const descriptionParts = [
203
+ `Resolve TODO-style markers in \`${cluster.filePath}\`.`,
204
+ functionName ? `Nearest function context: \`${functionName}\`.` : void 0,
205
+ "Markers discovered:",
206
+ todoSummary
207
+ ].filter((part) => Boolean(part));
208
+ const description = descriptionParts.join("\n\n");
209
+ const uniqueKeywords = Array.from(
210
+ new Set(cluster.matches.map((match) => match.keyword.toUpperCase()))
211
+ );
212
+ const stableHashInput = [
213
+ cluster.filePath,
214
+ String(first?.line ?? 0),
215
+ String(last?.line ?? 0),
216
+ uniqueKeywords.join(","),
217
+ cluster.matches.map((match) => match.text).join("\n")
218
+ ].join("::");
219
+ const task = {
220
+ id: createTaskId("todo", [cluster.filePath], title, stableHashInput),
221
+ source: "todo",
222
+ title,
223
+ description,
224
+ targetFiles: [cluster.filePath],
225
+ priority: 0,
226
+ complexity,
227
+ executionMode: "new-pr",
228
+ metadata: {
229
+ scannerId: "todo",
230
+ filePath: cluster.filePath,
231
+ startLine: first?.line ?? null,
232
+ endLine: last?.line ?? null,
233
+ functionName: functionName ?? null,
234
+ keywordSet: uniqueKeywords,
235
+ matchCount: cluster.matches.length,
236
+ matches: cluster.matches.map((match) => ({
237
+ line: match.line,
238
+ column: match.column,
239
+ keyword: match.keyword,
240
+ text: match.text
241
+ }))
242
+ },
243
+ discoveredAt
244
+ };
245
+ return task;
246
+ }
247
+ function findNearestFunctionName(fileLines, lineNumber) {
248
+ if (lineNumber <= 0 || fileLines.length === 0) {
249
+ return void 0;
250
+ }
251
+ const startIndex = Math.max(0, lineNumber - 1 - MAX_FUNCTION_LOOKBACK_LINES);
252
+ for (let index = lineNumber - 1; index >= startIndex; index -= 1) {
253
+ const candidate = fileLines[index]?.trim();
254
+ if (!candidate || candidate.startsWith("//")) {
255
+ continue;
256
+ }
257
+ for (const pattern of FUNCTION_PATTERNS) {
258
+ const match = candidate.match(pattern);
259
+ if (match?.[1]) {
260
+ return match[1];
261
+ }
262
+ }
263
+ }
264
+ return void 0;
265
+ }
266
+ function isMultiLineTodo(match, fileLines) {
267
+ const baseIndex = match.line - 1;
268
+ const line = fileLines[baseIndex + 1];
269
+ if (line === void 0) {
270
+ return false;
271
+ }
272
+ const trimmed = line.trim();
273
+ if (trimmed.length === 0 || TODO_KEYWORD_PATTERN.test(trimmed)) {
274
+ return false;
275
+ }
276
+ if (COMMENT_CONTINUATION_PATTERN.test(trimmed)) {
277
+ return true;
278
+ }
279
+ return false;
280
+ }
281
+ function extractTodoKeyword(lineText) {
282
+ const match = lineText.match(TODO_TEXT_PATTERN);
283
+ if (!match?.[1]) {
284
+ return void 0;
285
+ }
286
+ return match[1].toUpperCase();
287
+ }
288
+ function truncate(value, maxLength) {
289
+ if (value.length <= maxLength) {
290
+ return value;
291
+ }
292
+ return `${value.slice(0, maxLength - 1)}\u2026`;
293
+ }
294
+ function createTaskId(source, targetFiles, title, suffix) {
295
+ const base = [source, [...targetFiles].sort().join(","), title, suffix].join("::");
296
+ return createHash("sha256").update(base).digest("hex").slice(0, 16);
297
+ }
298
+ function mergeExcludes(exclude) {
299
+ return Array.from(new Set([...DEFAULT_EXCLUDES, ...exclude ?? []].filter(Boolean)));
300
+ }
301
+ function toRgExclude(pattern) {
302
+ const trimmed = pattern.trim();
303
+ if (trimmed.startsWith("!")) {
304
+ return trimmed;
305
+ }
306
+ return `!${trimmed}`;
307
+ }
308
+ async function collectFiles(rootDir, excludes) {
309
+ const files = [];
310
+ const compiledExcludes = excludes.map(compileGlobMatcher);
311
+ async function walk(relativeDir) {
312
+ const absoluteDir = resolve(rootDir, relativeDir);
313
+ let entries;
314
+ try {
315
+ entries = await readdir(absoluteDir, { withFileTypes: true, encoding: "utf8" });
316
+ } catch {
317
+ return;
318
+ }
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
+ await walk(relPath);
329
+ continue;
330
+ }
331
+ if (entry.isFile()) {
332
+ files.push(relPath);
333
+ }
334
+ }
335
+ }
336
+ await walk("");
337
+ return files;
338
+ }
339
+ function compileGlobMatcher(pattern) {
340
+ const normalized = normalizeRelativePath(pattern.replace(/^!+/, "").trim());
341
+ if (!normalized) {
342
+ return () => false;
343
+ }
344
+ if (!normalized.includes("*")) {
345
+ const prefix = normalized.endsWith("/") ? normalized : `${normalized}/`;
346
+ return (filePath) => filePath === normalized || filePath.startsWith(prefix) || filePath.endsWith(`/${normalized}`);
347
+ }
348
+ const escaped = normalized.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "__DOUBLE_STAR__").replace(/\*/g, "[^/]*").replace(/__DOUBLE_STAR__/g, ".*");
349
+ const regex = new RegExp(`^${escaped}$`);
350
+ return (filePath) => regex.test(filePath);
351
+ }
352
+ function normalizeRelativePath(filePath) {
353
+ return filePath.split(sep).join("/");
354
+ }
355
+ function sanitizeLine(line) {
356
+ return line.replace(/\r?\n/g, "").trim();
357
+ }
358
+ function toRecord(value) {
359
+ if (value && typeof value === "object") {
360
+ return value;
361
+ }
362
+ return {};
363
+ }
364
+ function asString(value) {
365
+ return typeof value === "string" ? value : void 0;
366
+ }
367
+ function asNumber(value) {
368
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
369
+ }
370
+ function asArray(value) {
371
+ return Array.isArray(value) ? value : [];
372
+ }
373
+ function isCommandNotFound(error) {
374
+ if (!(error instanceof Error)) {
375
+ return false;
376
+ }
377
+ const maybeNodeError = error;
378
+ return maybeNodeError.code === "ENOENT";
379
+ }
380
+ function runCommand(command, args, options) {
381
+ return new Promise((resolvePromise, rejectPromise) => {
382
+ const child = spawn(command, args, {
383
+ cwd: options.cwd,
384
+ stdio: ["ignore", "pipe", "pipe"],
385
+ signal: options.signal
386
+ });
387
+ let stdout = "";
388
+ let stderr = "";
389
+ let timedOut = false;
390
+ let killHandle;
391
+ child.stdout.on("data", (chunk) => {
392
+ stdout += chunk.toString();
393
+ });
394
+ child.stderr.on("data", (chunk) => {
395
+ stderr += chunk.toString();
396
+ });
397
+ const timeoutHandle = setTimeout(() => {
398
+ timedOut = true;
399
+ child.kill("SIGTERM");
400
+ killHandle = setTimeout(() => child.kill("SIGKILL"), 2e3);
401
+ }, options.timeoutMs);
402
+ child.on("error", (error) => {
403
+ clearTimeout(timeoutHandle);
404
+ if (killHandle) {
405
+ clearTimeout(killHandle);
406
+ }
407
+ rejectPromise(error);
408
+ });
409
+ child.on("close", (exitCode, signal) => {
410
+ clearTimeout(timeoutHandle);
411
+ if (killHandle) {
412
+ clearTimeout(killHandle);
413
+ }
414
+ resolvePromise({
415
+ stdout,
416
+ stderr,
417
+ exitCode,
418
+ signal,
419
+ timedOut
420
+ });
421
+ });
422
+ });
423
+ }
424
+
425
+ // src/scanners/lint-scanner.ts
426
+ import { spawn as spawn2 } from "child_process";
427
+ import { createHash as createHash2 } from "crypto";
428
+ import { access, readFile as readFile2 } from "fs/promises";
429
+ import { relative as relative2, resolve as resolve2, sep as sep2 } from "path";
430
+ var DEFAULT_TIMEOUT_MS2 = 6e4;
431
+ var LintScanner = class {
432
+ id = "lint";
433
+ name = "Lint Scanner";
434
+ async scan(repoPath, options = {}) {
435
+ const detection = await detectLinter(repoPath);
436
+ if (detection.kind === "none") {
437
+ return [];
438
+ }
439
+ const result = await runLinter(repoPath, detection, options);
440
+ const findings = detection.kind === "eslint" ? parseEslintFindings(result.stdout, repoPath) : parseBiomeFindings(result.stdout, repoPath);
441
+ if (findings.length === 0) {
442
+ return [];
443
+ }
444
+ const tasks = buildLintTasks(findings, detection.kind);
445
+ if (typeof options.maxTasks === "number" && options.maxTasks >= 0) {
446
+ return tasks.slice(0, options.maxTasks);
447
+ }
448
+ return tasks;
449
+ }
450
+ };
451
+ async function detectLinter(repoPath) {
452
+ const packageManager = await detectPackageManager(repoPath);
453
+ const packageJson = await readPackageJson(repoPath);
454
+ const scriptLint = asString2(toRecord2(packageJson.scripts).lint)?.toLowerCase() ?? "";
455
+ const dependencies = collectDependencyNames(packageJson);
456
+ if (scriptLint.includes("biome")) {
457
+ return { kind: "biome", packageManager };
458
+ }
459
+ if (scriptLint.includes("eslint")) {
460
+ return { kind: "eslint", packageManager };
461
+ }
462
+ if (dependencies.has("eslint") || await hasAnyFile(repoPath, ESLINT_CONFIG_FILES)) {
463
+ return { kind: "eslint", packageManager };
464
+ }
465
+ if (dependencies.has("@biomejs/biome") || await hasAnyFile(repoPath, BIOME_CONFIG_FILES)) {
466
+ return { kind: "biome", packageManager };
467
+ }
468
+ return { kind: "none", packageManager };
469
+ }
470
+ var ESLINT_CONFIG_FILES = [
471
+ ".eslintrc",
472
+ ".eslintrc.json",
473
+ ".eslintrc.cjs",
474
+ ".eslintrc.js",
475
+ "eslint.config.js",
476
+ "eslint.config.mjs",
477
+ "eslint.config.cjs"
478
+ ];
479
+ var BIOME_CONFIG_FILES = ["biome.json", "biome.jsonc"];
480
+ async function detectPackageManager(repoPath) {
481
+ const checks = [
482
+ { file: "pnpm-lock.yaml", manager: "pnpm" },
483
+ { file: "bun.lockb", manager: "bun" },
484
+ { file: "bun.lock", manager: "bun" },
485
+ { file: "yarn.lock", manager: "yarn" },
486
+ { file: "package-lock.json", manager: "npm" }
487
+ ];
488
+ for (const check of checks) {
489
+ if (await fileExists(resolve2(repoPath, check.file))) {
490
+ return check.manager;
491
+ }
492
+ }
493
+ return "npm";
494
+ }
495
+ async function runLinter(repoPath, detection, options) {
496
+ const command = buildLintCommand(detection, options);
497
+ const result = await runCommand2(command.command, command.args, {
498
+ cwd: repoPath,
499
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS2,
500
+ signal: options.signal
501
+ });
502
+ if (result.timedOut) {
503
+ throw new Error(`Lint scanner timed out after ${options.timeoutMs ?? DEFAULT_TIMEOUT_MS2}ms`);
504
+ }
505
+ const output = result.stdout.trim();
506
+ if (result.exitCode !== 0 && output.length === 0) {
507
+ return {
508
+ ...result,
509
+ stdout: normalizeJsonText(result.stderr)
510
+ };
511
+ }
512
+ return {
513
+ ...result,
514
+ stdout: normalizeJsonText(result.stdout)
515
+ };
516
+ }
517
+ function buildLintCommand(detection, options) {
518
+ const excludes = options.exclude ?? [];
519
+ if (detection.kind === "eslint") {
520
+ const eslintArgs = ["eslint", ".", "--format", "json", "--no-error-on-unmatched-pattern"];
521
+ for (const pattern of excludes) {
522
+ eslintArgs.push("--ignore-pattern", pattern);
523
+ }
524
+ return withPackageManagerRunner(detection.packageManager, eslintArgs);
525
+ }
526
+ const biomeArgs = ["biome", "check", ".", "--reporter=json"];
527
+ return withPackageManagerRunner(detection.packageManager, biomeArgs);
528
+ }
529
+ function withPackageManagerRunner(packageManager, commandArgs) {
530
+ if (packageManager === "pnpm") {
531
+ return { command: "pnpm", args: ["exec", ...commandArgs] };
532
+ }
533
+ if (packageManager === "yarn") {
534
+ return { command: "yarn", args: commandArgs };
535
+ }
536
+ if (packageManager === "bun") {
537
+ return { command: "bunx", args: commandArgs };
538
+ }
539
+ return { command: "npx", args: ["--no-install", ...commandArgs] };
540
+ }
541
+ function parseEslintFindings(output, repoPath) {
542
+ const parsed = parseJson(output);
543
+ if (!Array.isArray(parsed)) {
544
+ return [];
545
+ }
546
+ const findings = [];
547
+ for (const result of parsed) {
548
+ const resultRecord = toRecord2(result);
549
+ const filePathValue = asString2(resultRecord.filePath);
550
+ const filePath = normalizeFilePath(filePathValue, repoPath);
551
+ if (!filePath) {
552
+ continue;
553
+ }
554
+ const messages = asArray2(resultRecord.messages);
555
+ for (const message of messages) {
556
+ const messageRecord = toRecord2(message);
557
+ const ruleId = asString2(messageRecord.ruleId) ?? "unknown";
558
+ const text = asString2(messageRecord.message);
559
+ if (!text) {
560
+ continue;
561
+ }
562
+ findings.push({
563
+ filePath,
564
+ line: asNumber2(messageRecord.line),
565
+ column: asNumber2(messageRecord.column),
566
+ ruleId,
567
+ message: text,
568
+ fixable: messageRecord.fix !== void 0,
569
+ severity: asNumber2(messageRecord.severity)
570
+ });
571
+ }
572
+ }
573
+ return findings;
574
+ }
575
+ function parseBiomeFindings(output, repoPath) {
576
+ const parsed = parseJson(output);
577
+ if (parsed === void 0) {
578
+ return [];
579
+ }
580
+ const diagnostics = collectBiomeDiagnostics(parsed);
581
+ const findings = [];
582
+ for (const diagnostic of diagnostics) {
583
+ const path = extractBiomePath(diagnostic);
584
+ const filePath = normalizeFilePath(path, repoPath);
585
+ if (!filePath) {
586
+ continue;
587
+ }
588
+ const message = asString2(diagnostic.description) ?? asString2(diagnostic.message) ?? asString2(diagnostic.reason);
589
+ if (!message) {
590
+ continue;
591
+ }
592
+ const category = asString2(diagnostic.category) ?? "unknown";
593
+ const position = extractBiomePosition(diagnostic);
594
+ const tags = asArray2(diagnostic.tags).map((value) => String(value).toLowerCase());
595
+ findings.push({
596
+ filePath,
597
+ line: position?.line,
598
+ column: position?.column,
599
+ ruleId: category,
600
+ message,
601
+ fixable: tags.includes("fixable") || tags.includes("quickfix") || diagnostic.suggestedFixes !== void 0,
602
+ severity: normalizeBiomeSeverity(asString2(diagnostic.severity))
603
+ });
604
+ }
605
+ return findings;
606
+ }
607
+ function collectBiomeDiagnostics(value) {
608
+ const diagnostics = [];
609
+ const queue = [value];
610
+ while (queue.length > 0) {
611
+ const current = queue.shift();
612
+ if (current === void 0 || current === null) {
613
+ continue;
614
+ }
615
+ if (Array.isArray(current)) {
616
+ for (const item of current) {
617
+ queue.push(item);
618
+ }
619
+ continue;
620
+ }
621
+ if (typeof current !== "object") {
622
+ continue;
623
+ }
624
+ const record = current;
625
+ if (looksLikeBiomeDiagnostic(record)) {
626
+ diagnostics.push(record);
627
+ }
628
+ for (const value2 of Object.values(record)) {
629
+ if (Array.isArray(value2) || value2 && typeof value2 === "object") {
630
+ queue.push(value2);
631
+ }
632
+ }
633
+ }
634
+ return diagnostics;
635
+ }
636
+ function looksLikeBiomeDiagnostic(record) {
637
+ if (record.location !== void 0 && record.category !== void 0) {
638
+ return true;
639
+ }
640
+ if (record.path !== void 0 && (record.description !== void 0 || record.message !== void 0)) {
641
+ return true;
642
+ }
643
+ return false;
644
+ }
645
+ function extractBiomePath(diagnostic) {
646
+ const location = toRecord2(diagnostic.location);
647
+ const pathValue = location.path;
648
+ if (typeof pathValue === "string") {
649
+ return pathValue;
650
+ }
651
+ const pathRecord = toRecord2(pathValue);
652
+ const file = asString2(pathRecord.file);
653
+ if (file) {
654
+ return file;
655
+ }
656
+ return asString2(diagnostic.filePath);
657
+ }
658
+ function extractBiomePosition(diagnostic) {
659
+ const location = toRecord2(diagnostic.location);
660
+ const span = toRecord2(location.span);
661
+ const start = toRecord2(span.start);
662
+ const line = asNumber2(start.line);
663
+ const column = asNumber2(start.column);
664
+ if (line !== void 0 || column !== void 0) {
665
+ return { line, column };
666
+ }
667
+ const lineFallback = asNumber2(location.line) ?? asNumber2(diagnostic.line);
668
+ const columnFallback = asNumber2(location.column) ?? asNumber2(diagnostic.column);
669
+ if (lineFallback !== void 0 || columnFallback !== void 0) {
670
+ return { line: lineFallback, column: columnFallback };
671
+ }
672
+ return void 0;
673
+ }
674
+ function normalizeBiomeSeverity(severity) {
675
+ if (!severity) {
676
+ return void 0;
677
+ }
678
+ const normalized = severity.toLowerCase();
679
+ if (normalized === "error") {
680
+ return 2;
681
+ }
682
+ if (normalized === "warning" || normalized === "warn") {
683
+ return 1;
684
+ }
685
+ return void 0;
686
+ }
687
+ function buildLintTasks(findings, linter) {
688
+ const grouped = /* @__PURE__ */ new Map();
689
+ for (const finding of findings) {
690
+ const existing = grouped.get(finding.filePath);
691
+ if (existing) {
692
+ existing.push(finding);
693
+ } else {
694
+ grouped.set(finding.filePath, [finding]);
695
+ }
696
+ }
697
+ const discoveredAt = (/* @__PURE__ */ new Date()).toISOString();
698
+ const tasks = [];
699
+ for (const [filePath, fileFindings] of grouped.entries()) {
700
+ const uniqueRules = Array.from(new Set(fileFindings.map((finding) => finding.ruleId)));
701
+ const fixableCount = fileFindings.filter((finding) => finding.fixable).length;
702
+ const complexity = fileFindings.length === 1 && fixableCount === 1 ? "trivial" : "simple";
703
+ const headlineRules = uniqueRules.slice(0, 5).join(", ") || "unknown";
704
+ const title = `Fix lint findings in ${filePath}`;
705
+ const description = [
706
+ `Resolve ${fileFindings.length} lint finding(s) reported by ${linter} in \`${filePath}\`.`,
707
+ `Primary rules: ${headlineRules}.`,
708
+ fixableCount > 0 ? `${fixableCount} finding(s) appear auto-fixable.` : "No auto-fixable findings were detected."
709
+ ].join("\n\n");
710
+ const task = {
711
+ id: createTaskId2("lint", [filePath], title, `${linter}:${headlineRules}`),
712
+ source: "lint",
713
+ title,
714
+ description,
715
+ targetFiles: [filePath],
716
+ priority: 0,
717
+ complexity,
718
+ executionMode: "new-pr",
719
+ metadata: {
720
+ scannerId: "lint",
721
+ linter,
722
+ filePath,
723
+ issueCount: fileFindings.length,
724
+ ruleIds: uniqueRules,
725
+ fixableCount,
726
+ findings: fileFindings.map((finding) => ({
727
+ line: finding.line ?? null,
728
+ column: finding.column ?? null,
729
+ ruleId: finding.ruleId,
730
+ message: finding.message,
731
+ fixable: finding.fixable,
732
+ severity: finding.severity ?? null
733
+ }))
734
+ },
735
+ discoveredAt
736
+ };
737
+ tasks.push(task);
738
+ }
739
+ return tasks;
740
+ }
741
+ function createTaskId2(source, targetFiles, title, suffix) {
742
+ const content = [source, [...targetFiles].sort().join(","), title, suffix].join("::");
743
+ return createHash2("sha256").update(content).digest("hex").slice(0, 16);
744
+ }
745
+ async function readPackageJson(repoPath) {
746
+ const packageJsonPath = resolve2(repoPath, "package.json");
747
+ try {
748
+ const raw = await readFile2(packageJsonPath, "utf8");
749
+ const parsed = JSON.parse(raw);
750
+ return toRecord2(parsed);
751
+ } catch {
752
+ return {};
753
+ }
754
+ }
755
+ function collectDependencyNames(packageJson) {
756
+ const sections = [
757
+ toRecord2(packageJson.dependencies),
758
+ toRecord2(packageJson.devDependencies),
759
+ toRecord2(packageJson.peerDependencies),
760
+ toRecord2(packageJson.optionalDependencies)
761
+ ];
762
+ const names = /* @__PURE__ */ new Set();
763
+ for (const section of sections) {
764
+ for (const key of Object.keys(section)) {
765
+ names.add(key);
766
+ }
767
+ }
768
+ return names;
769
+ }
770
+ async function hasAnyFile(repoPath, candidates) {
771
+ for (const candidate of candidates) {
772
+ if (await fileExists(resolve2(repoPath, candidate))) {
773
+ return true;
774
+ }
775
+ }
776
+ return false;
777
+ }
778
+ async function fileExists(filePath) {
779
+ try {
780
+ await access(filePath);
781
+ return true;
782
+ } catch {
783
+ return false;
784
+ }
785
+ }
786
+ function normalizeFilePath(filePath, repoPath) {
787
+ if (!filePath) {
788
+ return void 0;
789
+ }
790
+ if (filePath.startsWith("<")) {
791
+ return void 0;
792
+ }
793
+ const absoluteCandidate = resolve2(repoPath, filePath);
794
+ const rel = relative2(repoPath, absoluteCandidate);
795
+ if (!rel.startsWith("..")) {
796
+ return rel.split(sep2).join("/");
797
+ }
798
+ const direct = filePath.split(sep2).join("/");
799
+ return direct;
800
+ }
801
+ function normalizeJsonText(text) {
802
+ return text.trim();
803
+ }
804
+ function parseJson(text) {
805
+ if (text.trim().length === 0) {
806
+ return void 0;
807
+ }
808
+ try {
809
+ return JSON.parse(text);
810
+ } catch {
811
+ const jsonStart = text.indexOf("[");
812
+ const objectStart = text.indexOf("{");
813
+ const start = jsonStart === -1 ? objectStart : objectStart === -1 ? jsonStart : Math.min(jsonStart, objectStart);
814
+ if (start < 0) {
815
+ return void 0;
816
+ }
817
+ const trimmed = text.slice(start).trim();
818
+ try {
819
+ return JSON.parse(trimmed);
820
+ } catch {
821
+ return void 0;
822
+ }
823
+ }
824
+ }
825
+ function toRecord2(value) {
826
+ if (value && typeof value === "object") {
827
+ return value;
828
+ }
829
+ return {};
830
+ }
831
+ function asString2(value) {
832
+ return typeof value === "string" ? value : void 0;
833
+ }
834
+ function asNumber2(value) {
835
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
836
+ }
837
+ function asArray2(value) {
838
+ return Array.isArray(value) ? value : [];
839
+ }
840
+ function runCommand2(command, args, options) {
841
+ return new Promise((resolvePromise, rejectPromise) => {
842
+ const child = spawn2(command, args, {
843
+ cwd: options.cwd,
844
+ stdio: ["ignore", "pipe", "pipe"],
845
+ signal: options.signal
846
+ });
847
+ let stdout = "";
848
+ let stderr = "";
849
+ let timedOut = false;
850
+ let killHandle;
851
+ child.stdout.on("data", (chunk) => {
852
+ stdout += chunk.toString();
853
+ });
854
+ child.stderr.on("data", (chunk) => {
855
+ stderr += chunk.toString();
856
+ });
857
+ const timeoutHandle = setTimeout(() => {
858
+ timedOut = true;
859
+ child.kill("SIGTERM");
860
+ killHandle = setTimeout(() => child.kill("SIGKILL"), 2e3);
861
+ }, options.timeoutMs);
862
+ child.on("error", (error) => {
863
+ clearTimeout(timeoutHandle);
864
+ if (killHandle) {
865
+ clearTimeout(killHandle);
866
+ }
867
+ rejectPromise(error);
868
+ });
869
+ child.on("close", (exitCode, signal) => {
870
+ clearTimeout(timeoutHandle);
871
+ if (killHandle) {
872
+ clearTimeout(killHandle);
873
+ }
874
+ resolvePromise({
875
+ stdout,
876
+ stderr,
877
+ exitCode,
878
+ signal,
879
+ timedOut
880
+ });
881
+ });
882
+ });
883
+ }
884
+
885
+ // src/scanners/test-gap-scanner.ts
886
+ import { createHash as createHash3 } from "crypto";
887
+ import { readFile as readFile3, readdir as readdir2, stat } from "fs/promises";
888
+ import { basename, resolve as resolve3, sep as sep3 } from "path";
889
+ var DEFAULT_EXCLUDES2 = [".git", "node_modules", "dist", "build", "coverage"];
890
+ var TestGapScanner = class {
891
+ id = "test-gap";
892
+ name = "Test Gap Scanner";
893
+ async scan(repoPath, options = {}) {
894
+ const maxTasks = options.maxTasks;
895
+ if (typeof maxTasks === "number" && maxTasks === 0) {
896
+ return [];
897
+ }
898
+ const excludes = mergeExcludes2(options.exclude);
899
+ const { sourceFiles, testFiles } = await collectCandidateFiles(repoPath, excludes);
900
+ if (sourceFiles.length === 0) {
901
+ return [];
902
+ }
903
+ const coveredSourceKeys = buildCoveredSourceKeySet(testFiles);
904
+ const untestedSourceFiles = sourceFiles.filter(
905
+ (sourceFilePath) => !coveredSourceKeys.has(toSourceKey(sourceFilePath))
906
+ );
907
+ if (untestedSourceFiles.length === 0) {
908
+ return [];
909
+ }
910
+ const cappedSourceFiles = typeof maxTasks === "number" && maxTasks > 0 ? untestedSourceFiles.slice(0, maxTasks) : untestedSourceFiles;
911
+ const discoveredAt = (/* @__PURE__ */ new Date()).toISOString();
912
+ const tasks = [];
913
+ for (const sourceFilePath of cappedSourceFiles) {
914
+ const absolutePath = resolve3(repoPath, sourceFilePath);
915
+ let fileContent = "";
916
+ let fileSizeBytes = 0;
917
+ try {
918
+ const [content, fileStats] = await Promise.all([
919
+ readFile3(absolutePath, "utf8"),
920
+ stat(absolutePath)
921
+ ]);
922
+ fileContent = content;
923
+ fileSizeBytes = fileStats.size;
924
+ } catch {
925
+ continue;
926
+ }
927
+ const lineCount = countLines(fileContent);
928
+ const complexityBucket = toComplexityBucket(lineCount);
929
+ const complexity = toTaskComplexity(complexityBucket);
930
+ const estimatedTokens = estimateTokens(complexityBucket);
931
+ const symbols = extractSymbols(fileContent);
932
+ const task = {
933
+ id: createTaskId3(sourceFilePath),
934
+ source: "test-gap",
935
+ title: `Add tests for ${basename(sourceFilePath)}`,
936
+ description: buildDescription(sourceFilePath, symbols),
937
+ targetFiles: [sourceFilePath],
938
+ priority: 0,
939
+ complexity,
940
+ executionMode: "new-pr",
941
+ metadata: {
942
+ scannerId: "test-gap",
943
+ filePath: sourceFilePath,
944
+ lineCount,
945
+ fileSizeBytes,
946
+ complexityBucket,
947
+ estimatedTokens,
948
+ symbols
949
+ },
950
+ discoveredAt
951
+ };
952
+ tasks.push(task);
953
+ }
954
+ return tasks;
955
+ }
956
+ };
957
+ async function collectCandidateFiles(repoPath, excludePatterns) {
958
+ const sourceFiles = [];
959
+ const testFiles = [];
960
+ const excludeMatchers = excludePatterns.map(compileGlobMatcher2);
961
+ async function walk(relativeDir) {
962
+ const absoluteDir = resolve3(repoPath, relativeDir);
963
+ let entries;
964
+ try {
965
+ entries = await readdir2(absoluteDir, { withFileTypes: true, encoding: "utf8" });
966
+ } catch {
967
+ return;
968
+ }
969
+ for (const entry of entries) {
970
+ const entryName = String(entry.name);
971
+ const relativePath = normalizeRelativePath2(
972
+ relativeDir ? `${relativeDir}/${entryName}` : entryName
973
+ );
974
+ if (excludeMatchers.some((matches) => matches(relativePath))) {
975
+ continue;
976
+ }
977
+ if (entry.isDirectory()) {
978
+ await walk(relativePath);
979
+ continue;
980
+ }
981
+ if (!entry.isFile()) {
982
+ continue;
983
+ }
984
+ if (isSourceFile(relativePath)) {
985
+ sourceFiles.push(relativePath);
986
+ }
987
+ if (isTestFile(relativePath)) {
988
+ testFiles.push(relativePath);
989
+ }
990
+ }
991
+ }
992
+ await walk("");
993
+ sourceFiles.sort((left, right) => left.localeCompare(right));
994
+ testFiles.sort((left, right) => left.localeCompare(right));
995
+ return { sourceFiles, testFiles };
996
+ }
997
+ function isSourceFile(filePath) {
998
+ if (!filePath.endsWith(".ts")) {
999
+ return false;
1000
+ }
1001
+ if (filePath.endsWith(".d.ts") || filePath.endsWith(".test.ts")) {
1002
+ return false;
1003
+ }
1004
+ if (basename(filePath) === "index.ts") {
1005
+ return false;
1006
+ }
1007
+ const parts = filePath.split("/");
1008
+ return parts.includes("src");
1009
+ }
1010
+ function isTestFile(filePath) {
1011
+ if (!filePath.endsWith(".test.ts")) {
1012
+ return false;
1013
+ }
1014
+ const parts = filePath.split("/");
1015
+ return parts.includes("tests") || parts.includes("__tests__");
1016
+ }
1017
+ function buildCoveredSourceKeySet(testFiles) {
1018
+ const covered = /* @__PURE__ */ new Set();
1019
+ for (const testFilePath of testFiles) {
1020
+ const candidateSourceKeys = deriveCandidateSourceKeysFromTest(testFilePath);
1021
+ for (const sourceKey of candidateSourceKeys) {
1022
+ covered.add(sourceKey);
1023
+ }
1024
+ }
1025
+ return covered;
1026
+ }
1027
+ function deriveCandidateSourceKeysFromTest(testFilePath) {
1028
+ const normalized = normalizeRelativePath2(testFilePath);
1029
+ if (!normalized.endsWith(".test.ts")) {
1030
+ return [];
1031
+ }
1032
+ const withoutSuffix = normalized.slice(0, -".test.ts".length);
1033
+ const parts = withoutSuffix.split("/");
1034
+ const markerIndex = parts.findIndex((part) => part === "tests" || part === "__tests__");
1035
+ if (markerIndex < 0) {
1036
+ return [];
1037
+ }
1038
+ const prefix = parts.slice(0, markerIndex);
1039
+ const suffix = parts.slice(markerIndex + 1);
1040
+ const candidates = /* @__PURE__ */ new Set();
1041
+ candidates.add(normalizeRelativePath2([...prefix, "src", ...suffix].join("/")));
1042
+ if (prefix[prefix.length - 1] === "src") {
1043
+ candidates.add(normalizeRelativePath2([...prefix, ...suffix].join("/")));
1044
+ }
1045
+ return [...candidates];
1046
+ }
1047
+ function toSourceKey(sourceFilePath) {
1048
+ const normalized = normalizeRelativePath2(sourceFilePath);
1049
+ return normalized.endsWith(".ts") ? normalized.slice(0, -".ts".length) : normalized;
1050
+ }
1051
+ function buildDescription(sourceFilePath, symbols) {
1052
+ if (symbols.length === 0) {
1053
+ return `Add initial test coverage for \`${sourceFilePath}\`. No named functions or classes were detected.`;
1054
+ }
1055
+ const symbolList = symbols.map((symbol) => `\`${symbol}\``).join(", ");
1056
+ return `Add test coverage for \`${sourceFilePath}\`, including: ${symbolList}.`;
1057
+ }
1058
+ function extractSymbols(fileContent) {
1059
+ const symbols = /* @__PURE__ */ new Set();
1060
+ const patterns = [
1061
+ /\b(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/g,
1062
+ /\b(?:export\s+)?class\s+([A-Za-z_$][\w$]*)\b/g,
1063
+ /\b(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/g
1064
+ ];
1065
+ for (const pattern of patterns) {
1066
+ let match;
1067
+ match = pattern.exec(fileContent);
1068
+ while (match) {
1069
+ const symbolName = match[1];
1070
+ if (symbolName) {
1071
+ symbols.add(symbolName);
1072
+ }
1073
+ match = pattern.exec(fileContent);
1074
+ }
1075
+ }
1076
+ return [...symbols].sort((left, right) => left.localeCompare(right));
1077
+ }
1078
+ function countLines(fileContent) {
1079
+ if (fileContent.length === 0) {
1080
+ return 0;
1081
+ }
1082
+ return fileContent.split(/\r?\n/).length;
1083
+ }
1084
+ function toComplexityBucket(lineCount) {
1085
+ if (lineCount < 50) {
1086
+ return "small";
1087
+ }
1088
+ if (lineCount < 200) {
1089
+ return "medium";
1090
+ }
1091
+ return "large";
1092
+ }
1093
+ function toTaskComplexity(bucket) {
1094
+ if (bucket === "small") {
1095
+ return "simple";
1096
+ }
1097
+ if (bucket === "medium") {
1098
+ return "moderate";
1099
+ }
1100
+ return "complex";
1101
+ }
1102
+ function estimateTokens(bucket) {
1103
+ if (bucket === "small") {
1104
+ return 1500;
1105
+ }
1106
+ if (bucket === "medium") {
1107
+ return 4e3;
1108
+ }
1109
+ return 8e3;
1110
+ }
1111
+ function createTaskId3(sourceFilePath) {
1112
+ return createHash3("sha256").update(sourceFilePath).digest("hex").slice(0, 16);
1113
+ }
1114
+ function mergeExcludes2(exclude) {
1115
+ return Array.from(new Set([...DEFAULT_EXCLUDES2, ...exclude ?? []].filter(Boolean)));
1116
+ }
1117
+ function compileGlobMatcher2(pattern) {
1118
+ const normalized = normalizeRelativePath2(pattern.replace(/^!+/, "").trim());
1119
+ if (!normalized) {
1120
+ return () => false;
1121
+ }
1122
+ if (!normalized.includes("*")) {
1123
+ const prefix = normalized.endsWith("/") ? normalized : `${normalized}/`;
1124
+ return (filePath) => filePath === normalized || filePath.startsWith(prefix) || filePath.endsWith(`/${normalized}`);
1125
+ }
1126
+ const escaped = normalized.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "__DOUBLE_STAR__").replace(/\*/g, "[^/]*").replace(/__DOUBLE_STAR__/g, ".*");
1127
+ const regex = new RegExp(`^${escaped}$`);
1128
+ return (filePath) => regex.test(filePath);
1129
+ }
1130
+ function normalizeRelativePath2(filePath) {
1131
+ return filePath.split(sep3).join("/");
1132
+ }
1133
+
1134
+ // src/scanners/github-issues-scanner.ts
1135
+ import { readFile as readFile4 } from "fs/promises";
1136
+ import { resolve as resolve4 } from "path";
1137
+ var GITHUB_API_BASE_URL = "https://api.github.com";
1138
+ var ISSUES_PER_PAGE = 30;
1139
+ var TITLE_LIMIT = 120;
1140
+ var DESCRIPTION_LIMIT = 500;
1141
+ var ESTIMATED_TOKENS_BY_COMPLEXITY = {
1142
+ trivial: 1500,
1143
+ simple: 4e3,
1144
+ moderate: 9e3,
1145
+ complex: 18e3
1146
+ };
1147
+ var GitHubIssuesScanner = class {
1148
+ constructor(token) {
1149
+ this.token = token;
1150
+ }
1151
+ id = "github-issue";
1152
+ name = "GitHub Issues Scanner";
1153
+ async scan(repoPath, options = {}) {
1154
+ const token = this.token ?? process.env.GITHUB_TOKEN;
1155
+ if (!token) {
1156
+ return [];
1157
+ }
1158
+ const repo = await resolveRepoCoordinates(repoPath, options);
1159
+ if (!repo) {
1160
+ return [];
1161
+ }
1162
+ const issues = await fetchOpenIssues(repo, token);
1163
+ if (issues.length === 0) {
1164
+ return [];
1165
+ }
1166
+ const discoveredAt = (/* @__PURE__ */ new Date()).toISOString();
1167
+ const tasks = issues.filter((issue) => issue.pull_request === void 0).map((issue) => mapIssueToTask(issue, discoveredAt)).filter((task) => task !== void 0);
1168
+ if (typeof options.maxTasks === "number" && options.maxTasks >= 0) {
1169
+ return tasks.slice(0, options.maxTasks);
1170
+ }
1171
+ return tasks;
1172
+ }
1173
+ };
1174
+ async function resolveRepoCoordinates(repoPath, options) {
1175
+ if (options.repo?.owner && options.repo.name) {
1176
+ return {
1177
+ owner: options.repo.owner,
1178
+ name: options.repo.name
1179
+ };
1180
+ }
1181
+ return parseRepoFromGitConfig(repoPath);
1182
+ }
1183
+ async function fetchOpenIssues(repo, token) {
1184
+ const url = `${GITHUB_API_BASE_URL}/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}/issues?state=open&per_page=${ISSUES_PER_PAGE}&sort=updated`;
1185
+ try {
1186
+ const response = await fetch(url, {
1187
+ method: "GET",
1188
+ headers: {
1189
+ Authorization: `Bearer ${token}`,
1190
+ Accept: "application/vnd.github.v3+json"
1191
+ }
1192
+ });
1193
+ if (!response.ok) {
1194
+ return [];
1195
+ }
1196
+ const payload = await response.json();
1197
+ if (!Array.isArray(payload)) {
1198
+ return [];
1199
+ }
1200
+ return payload.map((item) => toIssueResponse(item));
1201
+ } catch {
1202
+ return [];
1203
+ }
1204
+ }
1205
+ async function parseRepoFromGitConfig(repoPath) {
1206
+ const config = await readGitConfig(repoPath);
1207
+ if (!config) {
1208
+ return void 0;
1209
+ }
1210
+ const remoteUrl = extractRemoteUrl(config);
1211
+ if (!remoteUrl) {
1212
+ return void 0;
1213
+ }
1214
+ return parseGitHubRemoteUrl(remoteUrl);
1215
+ }
1216
+ async function readGitConfig(repoPath) {
1217
+ try {
1218
+ return await readFile4(resolve4(repoPath, ".git", "config"), "utf8");
1219
+ } catch {
1220
+ }
1221
+ try {
1222
+ const gitFile = await readFile4(resolve4(repoPath, ".git"), "utf8");
1223
+ const gitDir = parseGitDirPointer(gitFile);
1224
+ if (!gitDir) {
1225
+ return void 0;
1226
+ }
1227
+ return await readFile4(resolve4(repoPath, gitDir, "config"), "utf8");
1228
+ } catch {
1229
+ return void 0;
1230
+ }
1231
+ }
1232
+ function parseGitDirPointer(content) {
1233
+ const match = content.match(/^\s*gitdir:\s*(.+)\s*$/im);
1234
+ if (!match?.[1]) {
1235
+ return void 0;
1236
+ }
1237
+ return match[1].trim();
1238
+ }
1239
+ function extractRemoteUrl(configText) {
1240
+ const lines = configText.split(/\r?\n/);
1241
+ let activeRemote;
1242
+ let firstRemoteUrl;
1243
+ for (const rawLine of lines) {
1244
+ const line = rawLine.trim();
1245
+ if (!line || line.startsWith("#") || line.startsWith(";")) {
1246
+ continue;
1247
+ }
1248
+ const sectionMatch = line.match(/^\[\s*remote\s+"([^"]+)"\s*\]$/i);
1249
+ if (sectionMatch?.[1]) {
1250
+ activeRemote = sectionMatch[1];
1251
+ continue;
1252
+ }
1253
+ if (!activeRemote) {
1254
+ continue;
1255
+ }
1256
+ const urlMatch = line.match(/^url\s*=\s*(.+)$/i);
1257
+ if (!urlMatch?.[1]) {
1258
+ continue;
1259
+ }
1260
+ const url = urlMatch[1].trim();
1261
+ if (activeRemote === "origin") {
1262
+ return url;
1263
+ }
1264
+ if (!firstRemoteUrl) {
1265
+ firstRemoteUrl = url;
1266
+ }
1267
+ }
1268
+ return firstRemoteUrl;
1269
+ }
1270
+ var GITHUB_SSH_PATTERN = /^git@github\.com:(?<owner>[A-Za-z0-9_.-]+)\/(?<repo>[A-Za-z0-9_.-]+?)(?:\.git)?$/i;
1271
+ function parseGitHubRemoteUrl(remoteUrl) {
1272
+ const normalized = remoteUrl.trim();
1273
+ if (!normalized) {
1274
+ return void 0;
1275
+ }
1276
+ const sshMatch = normalized.match(GITHUB_SSH_PATTERN);
1277
+ if (sshMatch?.groups?.owner && sshMatch.groups.repo) {
1278
+ return {
1279
+ owner: sshMatch.groups.owner,
1280
+ name: stripGitSuffix(sshMatch.groups.repo)
1281
+ };
1282
+ }
1283
+ const normalizedUrlInput = normalized.startsWith("github.com/") ? `https://${normalized}` : normalized;
1284
+ try {
1285
+ const url = new URL(normalizedUrlInput);
1286
+ if (!isGitHubHost(url.hostname)) {
1287
+ return void 0;
1288
+ }
1289
+ const pathParts = url.pathname.split("/").filter(Boolean);
1290
+ if (pathParts.length < 2) {
1291
+ return void 0;
1292
+ }
1293
+ const owner = pathParts[0];
1294
+ const name = stripGitSuffix(pathParts[1]);
1295
+ if (!owner || !name) {
1296
+ return void 0;
1297
+ }
1298
+ return { owner, name };
1299
+ } catch {
1300
+ return void 0;
1301
+ }
1302
+ }
1303
+ function isGitHubHost(hostname) {
1304
+ const normalized = hostname.toLowerCase();
1305
+ return normalized === "github.com" || normalized === "www.github.com";
1306
+ }
1307
+ function stripGitSuffix(value) {
1308
+ return value.replace(/\.git$/i, "");
1309
+ }
1310
+ function mapIssueToTask(issue, discoveredAt) {
1311
+ const issueNumber = asNumber3(issue.number);
1312
+ const rawTitle = asString3(issue.title)?.trim();
1313
+ if (issueNumber === void 0 || !rawTitle) {
1314
+ return void 0;
1315
+ }
1316
+ const labels = normalizeLabels(issue.labels);
1317
+ const complexity = mapComplexityFromLabels(labels);
1318
+ const estimatedTokens = ESTIMATED_TOKENS_BY_COMPLEXITY[complexity];
1319
+ const bodyText = asString3(issue.body)?.trim() || "No description provided.";
1320
+ const labelSummary = labels.length > 0 ? `Labels: ${labels.join(", ")}` : "Labels: none";
1321
+ const title = truncate2(rawTitle, TITLE_LIMIT);
1322
+ const description = truncate2(`${bodyText}
1323
+
1324
+ ${labelSummary}`, DESCRIPTION_LIMIT);
1325
+ const url = asString3(issue.html_url) ?? "";
1326
+ const author = readAuthor(issue.user);
1327
+ const createdAt = asString3(issue.created_at) ?? discoveredAt;
1328
+ return {
1329
+ id: `github-issue-${issueNumber}`,
1330
+ source: "github-issue",
1331
+ title,
1332
+ description,
1333
+ targetFiles: [],
1334
+ priority: 0,
1335
+ complexity,
1336
+ executionMode: "new-pr",
1337
+ metadata: {
1338
+ issueNumber,
1339
+ labels,
1340
+ url,
1341
+ author,
1342
+ createdAt,
1343
+ estimatedTokens
1344
+ },
1345
+ discoveredAt
1346
+ };
1347
+ }
1348
+ function mapComplexityFromLabels(labels) {
1349
+ const normalized = labels.map((label) => label.toLowerCase());
1350
+ if (normalized.some((label) => label.includes("feature"))) {
1351
+ return "complex";
1352
+ }
1353
+ if (normalized.some((label) => label.includes("enhancement"))) {
1354
+ return "moderate";
1355
+ }
1356
+ if (normalized.some((label) => label.includes("bug"))) {
1357
+ return "simple";
1358
+ }
1359
+ return "moderate";
1360
+ }
1361
+ function normalizeLabels(rawLabels) {
1362
+ if (!Array.isArray(rawLabels)) {
1363
+ return [];
1364
+ }
1365
+ const labels = [];
1366
+ for (const rawLabel of rawLabels) {
1367
+ if (typeof rawLabel === "string") {
1368
+ const trimmed = rawLabel.trim();
1369
+ if (trimmed.length > 0) {
1370
+ labels.push(trimmed);
1371
+ }
1372
+ continue;
1373
+ }
1374
+ if (rawLabel && typeof rawLabel === "object") {
1375
+ const name = asString3(rawLabel.name)?.trim();
1376
+ if (name) {
1377
+ labels.push(name);
1378
+ }
1379
+ }
1380
+ }
1381
+ return Array.from(new Set(labels));
1382
+ }
1383
+ function readAuthor(user) {
1384
+ if (!user || typeof user !== "object") {
1385
+ return "unknown";
1386
+ }
1387
+ const login = asString3(user.login);
1388
+ if (!login) {
1389
+ return "unknown";
1390
+ }
1391
+ return login;
1392
+ }
1393
+ function truncate2(value, maxLength) {
1394
+ if (value.length <= maxLength) {
1395
+ return value;
1396
+ }
1397
+ return `${value.slice(0, maxLength - 1)}\u2026`;
1398
+ }
1399
+ function toIssueResponse(value) {
1400
+ if (value && typeof value === "object") {
1401
+ return value;
1402
+ }
1403
+ return {};
1404
+ }
1405
+ function asString3(value) {
1406
+ return typeof value === "string" ? value : void 0;
1407
+ }
1408
+ function asNumber3(value) {
1409
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
1410
+ }
1411
+
1412
+ // src/scanner.ts
1413
+ import { createHash as createHash4 } from "crypto";
1414
+ var CompositeScanner = class {
1415
+ id = "composite";
1416
+ name = "Composite Scanner";
1417
+ scanners;
1418
+ constructor(scanners = [new LintScanner(), new TodoScanner()]) {
1419
+ this.scanners = scanners;
1420
+ }
1421
+ async scan(repoPath, options = {}) {
1422
+ const settled = await Promise.allSettled(
1423
+ this.scanners.map(async (scanner) => ({
1424
+ scannerId: scanner.id,
1425
+ tasks: await scanner.scan(repoPath, options)
1426
+ }))
1427
+ );
1428
+ const collected = [];
1429
+ for (const result of settled) {
1430
+ if (result.status !== "fulfilled") {
1431
+ continue;
1432
+ }
1433
+ const scannerId = result.value.scannerId;
1434
+ for (const task of result.value.tasks) {
1435
+ collected.push({ scannerId, task });
1436
+ }
1437
+ }
1438
+ const deduplicated = deduplicateTasks(collected);
1439
+ if (typeof options.maxTasks === "number" && options.maxTasks >= 0) {
1440
+ return deduplicated.slice(0, options.maxTasks);
1441
+ }
1442
+ return deduplicated;
1443
+ }
1444
+ };
1445
+ function createDefaultCompositeScanner() {
1446
+ return new CompositeScanner([new LintScanner(), new TodoScanner()]);
1447
+ }
1448
+ function deduplicateTasks(candidates) {
1449
+ const deduplicatedByHash = /* @__PURE__ */ new Map();
1450
+ for (const candidate of candidates) {
1451
+ const hash = taskContentHash(candidate.task);
1452
+ const existing = deduplicatedByHash.get(hash);
1453
+ if (!existing) {
1454
+ deduplicatedByHash.set(hash, {
1455
+ task: candidate.task,
1456
+ mergedSources: [candidate.scannerId],
1457
+ duplicateTaskIds: [candidate.task.id]
1458
+ });
1459
+ continue;
1460
+ }
1461
+ const preferIncoming = candidate.task.priority > existing.task.priority;
1462
+ const winner = preferIncoming ? candidate.task : existing.task;
1463
+ const loser = preferIncoming ? existing.task : candidate.task;
1464
+ const mergedSources = unique([
1465
+ ...existing.mergedSources,
1466
+ candidate.scannerId,
1467
+ String(loser.source)
1468
+ ]);
1469
+ const duplicateTaskIds = unique([...existing.duplicateTaskIds, loser.id, winner.id]);
1470
+ const winnerMetadata = toRecord3(winner.metadata);
1471
+ const loserMetadata = toRecord3(loser.metadata);
1472
+ deduplicatedByHash.set(hash, {
1473
+ task: {
1474
+ ...winner,
1475
+ metadata: {
1476
+ ...loserMetadata,
1477
+ ...winnerMetadata,
1478
+ mergedSources,
1479
+ duplicateTaskIds,
1480
+ dedupeHash: hash
1481
+ }
1482
+ },
1483
+ mergedSources,
1484
+ duplicateTaskIds
1485
+ });
1486
+ }
1487
+ const deduplicated = [...deduplicatedByHash.values()].map((entry) => entry.task);
1488
+ deduplicated.sort((left, right) => {
1489
+ const byPriority = right.priority - left.priority;
1490
+ if (byPriority !== 0) {
1491
+ return byPriority;
1492
+ }
1493
+ return left.title.localeCompare(right.title);
1494
+ });
1495
+ return deduplicated;
1496
+ }
1497
+ function taskContentHash(task) {
1498
+ const content = [task.source, [...task.targetFiles].sort().join(","), task.title].join("::");
1499
+ return createHash4("sha256").update(content).digest("hex").slice(0, 16);
1500
+ }
1501
+ function toRecord3(value) {
1502
+ if (value && typeof value === "object") {
1503
+ return value;
1504
+ }
1505
+ return {};
1506
+ }
1507
+ function unique(values) {
1508
+ return Array.from(new Set(values.filter((value) => value.trim().length > 0)));
1509
+ }
1510
+
1511
+ // src/ranker.ts
1512
+ var IMPACT_BY_SOURCE = {
1513
+ lint: 22,
1514
+ todo: 10,
1515
+ "test-gap": 24,
1516
+ "dead-code": 14,
1517
+ "github-issue": 20,
1518
+ custom: 12
1519
+ };
1520
+ var FEASIBILITY_BY_COMPLEXITY = {
1521
+ trivial: 25,
1522
+ simple: 20,
1523
+ moderate: 12,
1524
+ complex: 6
1525
+ };
1526
+ var TOKEN_EFFICIENCY_BY_COMPLEXITY = {
1527
+ trivial: 18,
1528
+ simple: 14,
1529
+ moderate: 8,
1530
+ complex: 4
1531
+ };
1532
+ function rankTasks(tasks) {
1533
+ const ranked = tasks.map((task) => {
1534
+ const scores = scoreTask(task);
1535
+ const priority = clamp(
1536
+ Math.round(
1537
+ scores.impactScore + scores.feasibilityScore + scores.freshnessScore + scores.issueSignals + scores.tokenEfficiency
1538
+ ),
1539
+ 0,
1540
+ 100
1541
+ );
1542
+ const metadata = toRecord4(task.metadata);
1543
+ return {
1544
+ ...task,
1545
+ priority,
1546
+ metadata: {
1547
+ ...metadata,
1548
+ priorityBreakdown: scores
1549
+ }
1550
+ };
1551
+ });
1552
+ ranked.sort((left, right) => {
1553
+ const byPriority = right.priority - left.priority;
1554
+ if (byPriority !== 0) {
1555
+ return byPriority;
1556
+ }
1557
+ return left.title.localeCompare(right.title);
1558
+ });
1559
+ return ranked;
1560
+ }
1561
+ function scoreTask(task) {
1562
+ const metadata = toRecord4(task.metadata);
1563
+ const impactScore = scoreImpact(task, metadata);
1564
+ const feasibilityScore = scoreFeasibility(task, metadata);
1565
+ const freshnessScore = scoreFreshness(task, metadata);
1566
+ const issueSignals = scoreIssueSignals(task, metadata);
1567
+ const tokenEfficiency = scoreTokenEfficiency(task, metadata, impactScore);
1568
+ return {
1569
+ impactScore,
1570
+ feasibilityScore,
1571
+ freshnessScore,
1572
+ issueSignals,
1573
+ tokenEfficiency
1574
+ };
1575
+ }
1576
+ function scoreImpact(task, metadata) {
1577
+ let score = IMPACT_BY_SOURCE[task.source] ?? 12;
1578
+ const matchCount = readNumber(metadata, "matchCount");
1579
+ if (task.source === "todo" && matchCount !== void 0) {
1580
+ if (matchCount >= 4) {
1581
+ score += 4;
1582
+ } else if (matchCount >= 2) {
1583
+ score += 2;
1584
+ }
1585
+ }
1586
+ const issueCount = readNumber(metadata, "issueCount");
1587
+ if (task.source === "lint" && issueCount !== void 0 && issueCount >= 5) {
1588
+ score += 2;
1589
+ }
1590
+ if (task.linkedIssue) {
1591
+ score += 2;
1592
+ }
1593
+ return clamp(score, 0, 25);
1594
+ }
1595
+ function scoreFeasibility(task, metadata) {
1596
+ let score = FEASIBILITY_BY_COMPLEXITY[task.complexity];
1597
+ const fileCount = Math.max(task.targetFiles.length, readNumber(metadata, "targetFileCount") ?? 0);
1598
+ if (fileCount >= 6) {
1599
+ score -= 8;
1600
+ } else if (fileCount >= 3) {
1601
+ score -= 4;
1602
+ }
1603
+ if (task.executionMode === "direct-commit") {
1604
+ score -= 2;
1605
+ }
1606
+ return clamp(score, 0, 25);
1607
+ }
1608
+ function scoreFreshness(task, metadata) {
1609
+ const daysSinceChange = readNumber(metadata, "daysSinceLastChange") ?? readNumber(metadata, "freshnessDays") ?? getAgeInDays(readString(metadata, "lastModifiedAt"));
1610
+ if (daysSinceChange === void 0) {
1611
+ const discoveredAge = getAgeInDays(task.discoveredAt);
1612
+ if (discoveredAge === void 0) {
1613
+ return 7;
1614
+ }
1615
+ return clamp(15 - Math.floor(discoveredAge / 3), 4, 15);
1616
+ }
1617
+ if (daysSinceChange <= 3) {
1618
+ return 15;
1619
+ }
1620
+ if (daysSinceChange <= 14) {
1621
+ return 12;
1622
+ }
1623
+ if (daysSinceChange <= 30) {
1624
+ return 9;
1625
+ }
1626
+ if (daysSinceChange <= 90) {
1627
+ return 6;
1628
+ }
1629
+ if (daysSinceChange <= 180) {
1630
+ return 4;
1631
+ }
1632
+ return 2;
1633
+ }
1634
+ function scoreIssueSignals(task, metadata) {
1635
+ let score = 0;
1636
+ if (task.linkedIssue) {
1637
+ score += 5;
1638
+ score += Math.min(task.linkedIssue.labels.length, 4);
1639
+ }
1640
+ const labels = task.linkedIssue?.labels.map((label) => label.toLowerCase()) ?? [];
1641
+ if (labels.includes("good-first-issue")) {
1642
+ score += 2;
1643
+ }
1644
+ if (labels.includes("help-wanted")) {
1645
+ score += 1;
1646
+ }
1647
+ const upvotes = readNumber(metadata, "upvotes") ?? readNumber(metadata, "thumbsUp") ?? 0;
1648
+ const reactions = readNumber(metadata, "reactions") ?? 0;
1649
+ const maintainerComments = readNumber(metadata, "maintainerComments") ?? (readBoolean(metadata, "hasMaintainerComment") ? 1 : 0);
1650
+ score += Math.min(4, Math.floor(upvotes / 2) + Math.floor(reactions / 3));
1651
+ score += Math.min(3, maintainerComments);
1652
+ return clamp(score, 0, 15);
1653
+ }
1654
+ function scoreTokenEfficiency(task, metadata, impactScore) {
1655
+ const estimatedTokens = readNumber(metadata, "estimatedTokens") ?? readNumber(metadata, "totalEstimatedTokens") ?? readNestedNumber(metadata, "tokenEstimate", "totalEstimatedTokens");
1656
+ let score = TOKEN_EFFICIENCY_BY_COMPLEXITY[task.complexity];
1657
+ if (estimatedTokens !== void 0) {
1658
+ if (estimatedTokens <= 1500) {
1659
+ score = 20;
1660
+ } else if (estimatedTokens <= 5e3) {
1661
+ score = 16;
1662
+ } else if (estimatedTokens <= 12e3) {
1663
+ score = 12;
1664
+ } else if (estimatedTokens <= 25e3) {
1665
+ score = 8;
1666
+ } else {
1667
+ score = 4;
1668
+ }
1669
+ }
1670
+ if (task.targetFiles.length >= 4) {
1671
+ score -= 2;
1672
+ }
1673
+ if (impactScore >= 20) {
1674
+ score += 1;
1675
+ }
1676
+ return clamp(score, 0, 20);
1677
+ }
1678
+ function getAgeInDays(value) {
1679
+ if (!value) {
1680
+ return void 0;
1681
+ }
1682
+ const time = Date.parse(value);
1683
+ if (Number.isNaN(time)) {
1684
+ return void 0;
1685
+ }
1686
+ const now = Date.now();
1687
+ const diffMs = Math.max(now - time, 0);
1688
+ return Math.floor(diffMs / (24 * 60 * 60 * 1e3));
1689
+ }
1690
+ function readNestedNumber(metadata, parentKey, childKey) {
1691
+ const parent = toRecord4(metadata[parentKey]);
1692
+ return readNumber(parent, childKey);
1693
+ }
1694
+ function readNumber(metadata, key) {
1695
+ const value = metadata[key];
1696
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
1697
+ }
1698
+ function readString(metadata, key) {
1699
+ const value = metadata[key];
1700
+ return typeof value === "string" ? value : void 0;
1701
+ }
1702
+ function readBoolean(metadata, key) {
1703
+ return metadata[key] === true;
1704
+ }
1705
+ function toRecord4(value) {
1706
+ if (value && typeof value === "object") {
1707
+ return value;
1708
+ }
1709
+ return {};
1710
+ }
1711
+ function clamp(value, min, max) {
1712
+ return Math.min(max, Math.max(min, value));
1713
+ }
1714
+ export {
1715
+ CompositeScanner,
1716
+ GitHubIssuesScanner,
1717
+ LintScanner,
1718
+ TestGapScanner,
1719
+ TodoScanner,
1720
+ createDefaultCompositeScanner,
1721
+ rankTasks
1722
+ };
1723
+ //# sourceMappingURL=index.js.map