|
| 1 | +import { fetchRepoData } from '../src/fetcher.js'; |
| 2 | +import { buildPrompt } from '../src/prompt.js'; |
| 3 | +import { parseClaudeResponse } from '../src/parser.js'; |
| 4 | +import { deriveFit } from '../src/verdict.js'; |
| 5 | +import { parseRepoInput } from './repo-input.js'; |
| 6 | +import { callModel } from './model.js'; |
| 7 | +import { ghOpts } from './github-auth.js'; |
| 8 | +import { attachHtmlReport } from './report.js'; |
| 9 | + |
| 10 | +const MAX_REPOS = 5; |
| 11 | + |
| 12 | +export const COMPARE_TOOL = { |
| 13 | + name: 'compare_repos', |
| 14 | + description: |
| 15 | + 'Compare 2-5 GitHub/GitLab/npm/PyPI repos or packages for a concrete use case. ' + |
| 16 | + 'Returns a winner, ranking, tradeoff matrix, choose-if guidance, and risk comparison. ' + |
| 17 | + 'Use this before an agent installs or recommends a dependency.', |
| 18 | + inputSchema: { |
| 19 | + type: 'object', |
| 20 | + properties: { |
| 21 | + repos: { |
| 22 | + type: 'array', |
| 23 | + minItems: 2, |
| 24 | + maxItems: MAX_REPOS, |
| 25 | + items: { type: 'string' }, |
| 26 | + description: 'Repos/packages to compare: owner/name, platform:name, or GitHub/GitLab/npm/PyPI URLs.', |
| 27 | + }, |
| 28 | + useCase: { |
| 29 | + type: 'string', |
| 30 | + description: 'The specific job/constraints to compare against, e.g. edge API on Cloudflare Workers.', |
| 31 | + }, |
| 32 | + report: { type: 'boolean', description: 'Write a local HTML comparison report. Default: true.' }, |
| 33 | + openReport: { |
| 34 | + type: 'boolean', |
| 35 | + description: 'Open the local HTML comparison report in the browser. Default: true.', |
| 36 | + }, |
| 37 | + }, |
| 38 | + required: ['repos'], |
| 39 | + additionalProperties: false, |
| 40 | + }, |
| 41 | + outputSchema: { |
| 42 | + type: 'object', |
| 43 | + properties: { |
| 44 | + useCase: { type: 'string' }, |
| 45 | + bottom_line: { type: 'string' }, |
| 46 | + winner: { |
| 47 | + type: 'object', |
| 48 | + properties: { repoId: { type: 'string' }, rationale: { type: 'string' } }, |
| 49 | + }, |
| 50 | + ranking: { type: 'array' }, |
| 51 | + matrix: { type: 'array' }, |
| 52 | + choose_if: { type: 'array' }, |
| 53 | + risks: { type: 'array' }, |
| 54 | + repos: { type: 'array' }, |
| 55 | + report: { |
| 56 | + type: 'object', |
| 57 | + description: 'Local HTML report path/url opened for the user.', |
| 58 | + properties: { path: { type: 'string' }, url: { type: 'string' }, opened: { type: 'boolean' } }, |
| 59 | + }, |
| 60 | + }, |
| 61 | + required: ['winner', 'ranking', 'repos'], |
| 62 | + }, |
| 63 | +}; |
| 64 | + |
| 65 | +const strings = (xs, max = 6) => |
| 66 | + Array.isArray(xs) |
| 67 | + ? xs |
| 68 | + .map(String) |
| 69 | + .filter((s) => s.trim()) |
| 70 | + .slice(0, max) |
| 71 | + : []; |
| 72 | + |
| 73 | +function displayRepo(scan) { |
| 74 | + return `${scan.platform}:${scan.repoId}`; |
| 75 | +} |
| 76 | + |
| 77 | +export function buildComparePrompt(scans, useCase = '') { |
| 78 | + const summaries = scans.map((s, i) => ({ |
| 79 | + index: i + 1, |
| 80 | + repoId: displayRepo(s), |
| 81 | + description: s.description || '', |
| 82 | + language: s.language || 'Unknown', |
| 83 | + license: s.license || 'Unknown', |
| 84 | + stars: s.stars || 0, |
| 85 | + fit: s.fit, |
| 86 | + bottom_line: s.bottom_line, |
| 87 | + recommendation: s.recommendation, |
| 88 | + confidence: s.confidence, |
| 89 | + health: s.health, |
| 90 | + pros: strings(s.pros, 6), |
| 91 | + cons: strings(s.cons, 6), |
| 92 | + risks: Array.isArray(s.risk_register) ? s.risk_register.slice(0, 5) : [], |
| 93 | + red_flags: Array.isArray(s.red_flags) ? s.red_flags.slice(0, 5) : [], |
| 94 | + compare_hooks: s.compare_hooks || '', |
| 95 | + capabilities: strings(s.capabilities, 8), |
| 96 | + category: s.category || '', |
| 97 | + tech_stack: s.tech_stack || {}, |
| 98 | + })); |
| 99 | + |
| 100 | + return `You are a senior staff engineer choosing between dependencies for another experienced developer. |
| 101 | +
|
| 102 | +Use case / decision context: ${useCase || 'No specific use case provided; compare for general production adoption.'} |
| 103 | +
|
| 104 | +You are given RepoLens scan summaries. Treat them as evidence, not marketing. Pick a winner only when the evidence supports it. If the best answer is situational, still name the default choice and explain where another repo wins. |
| 105 | +
|
| 106 | +RepoLens scan summaries: |
| 107 | +${JSON.stringify(summaries, null, 2)} |
| 108 | +
|
| 109 | +Return ONLY a valid JSON object. No markdown fences. |
| 110 | +
|
| 111 | +{ |
| 112 | + "bottom_line": "Two decisive sentences naming the default pick and the main tradeoff.", |
| 113 | + "winner": { "repoId": "platform:owner/name or platform:package", "rationale": "Why this is the best default for the use case." }, |
| 114 | + "ranking": [{ "repoId": "platform:owner/name", "rank": 1, "fit": "best | strong | situational | risky", "score": 90, "why": "Specific reason for this rank." }], |
| 115 | + "matrix": [{ "criterion": "Use-case fit | API ergonomics | ecosystem | maintenance | operational risk | migration cost", "winner": "repoId or tie", "notes": "What matters for this criterion.", "scores": [{ "repoId": "platform:owner/name", "score": 1, "note": "Short evidence-backed note." }] }], |
| 116 | + "choose_if": [{ "repoId": "platform:owner/name", "reasons": ["Choose this when...", "Avoid it when..."] }], |
| 117 | + "risks": [{ "repoId": "platform:owner/name", "risk": "Concrete risk", "mitigation": "How to de-risk before adoption." }], |
| 118 | + "trial_plan": { "goal": "What the user should know after a short bake-off.", "steps": ["Concrete step 1", "Concrete step 2", "Concrete step 3"], "decision_rule": "Rule for choosing after the trial." } |
| 119 | +}`; |
| 120 | +} |
| 121 | + |
| 122 | +function extractJson(rawText) { |
| 123 | + const text = String(rawText || '') |
| 124 | + .trim() |
| 125 | + .replace(/^```(?:json)?\s*/i, '') |
| 126 | + .replace(/\s*```$/, ''); |
| 127 | + const start = text.indexOf('{'); |
| 128 | + const end = text.lastIndexOf('}'); |
| 129 | + if (start === -1 || end === -1) throw new Error('No JSON object found in comparison response'); |
| 130 | + return JSON.parse(text.slice(start, end + 1)); |
| 131 | +} |
| 132 | + |
| 133 | +function normalizeRanking(raw, scans) { |
| 134 | + const ids = new Set(scans.map(displayRepo)); |
| 135 | + const rows = Array.isArray(raw) ? raw : []; |
| 136 | + const normalized = rows |
| 137 | + .filter((r) => r && ids.has(String(r.repoId || ''))) |
| 138 | + .map((r, i) => ({ |
| 139 | + repoId: String(r.repoId), |
| 140 | + rank: Number(r.rank) || i + 1, |
| 141 | + fit: ['best', 'strong', 'situational', 'risky'].includes(r.fit) ? r.fit : 'situational', |
| 142 | + score: Math.max(0, Math.min(100, Number(r.score) || 0)), |
| 143 | + why: String(r.why || ''), |
| 144 | + })) |
| 145 | + .sort((a, b) => a.rank - b.rank); |
| 146 | + if (normalized.length) return normalized; |
| 147 | + return scans |
| 148 | + .map((s, i) => ({ |
| 149 | + repoId: displayRepo(s), |
| 150 | + rank: i + 1, |
| 151 | + fit: i === 0 ? 'best' : 'situational', |
| 152 | + score: Number(s.health?.score) || 0, |
| 153 | + why: s.bottom_line || s.description || 'Ranked by available RepoLens health/fit evidence.', |
| 154 | + })) |
| 155 | + .sort((a, b) => b.score - a.score) |
| 156 | + .map((r, i) => ({ ...r, rank: i + 1, fit: i === 0 ? 'best' : r.fit })); |
| 157 | +} |
| 158 | + |
| 159 | +export function parseComparisonResponse(rawText, scans, useCase = '') { |
| 160 | + const data = extractJson(rawText); |
| 161 | + const ranking = normalizeRanking(data.ranking, scans); |
| 162 | + const winnerId = String(data.winner?.repoId || ranking[0]?.repoId || displayRepo(scans[0])); |
| 163 | + return { |
| 164 | + useCase: useCase || 'General production adoption', |
| 165 | + bottom_line: String(data.bottom_line || ranking[0]?.why || ''), |
| 166 | + winner: { |
| 167 | + repoId: winnerId, |
| 168 | + rationale: String(data.winner?.rationale || ranking[0]?.why || ''), |
| 169 | + }, |
| 170 | + ranking, |
| 171 | + matrix: Array.isArray(data.matrix) ? data.matrix.slice(0, 8) : [], |
| 172 | + choose_if: Array.isArray(data.choose_if) |
| 173 | + ? data.choose_if |
| 174 | + .filter((x) => x && x.repoId) |
| 175 | + .slice(0, scans.length) |
| 176 | + .map((x) => ({ repoId: String(x.repoId), reasons: strings(x.reasons, 5) })) |
| 177 | + : [], |
| 178 | + risks: Array.isArray(data.risks) |
| 179 | + ? data.risks |
| 180 | + .filter((x) => x && x.repoId) |
| 181 | + .slice(0, 10) |
| 182 | + .map((x) => ({ |
| 183 | + repoId: String(x.repoId), |
| 184 | + risk: String(x.risk || ''), |
| 185 | + mitigation: String(x.mitigation || ''), |
| 186 | + })) |
| 187 | + : [], |
| 188 | + trial_plan: { |
| 189 | + goal: String(data.trial_plan?.goal || ''), |
| 190 | + steps: strings(data.trial_plan?.steps, 6), |
| 191 | + decision_rule: String(data.trial_plan?.decision_rule || ''), |
| 192 | + }, |
| 193 | + }; |
| 194 | +} |
| 195 | + |
| 196 | +export function validateCompareArgs(args = {}) { |
| 197 | + if (!Array.isArray(args.repos)) throw new Error('compare_repos requires repos: ["owner/name", ...]'); |
| 198 | + const repos = args.repos.map((r) => String(r || '').trim()).filter(Boolean); |
| 199 | + if (repos.length < 2) throw new Error('compare_repos needs at least 2 repos'); |
| 200 | + if (repos.length > MAX_REPOS) throw new Error(`compare_repos supports at most ${MAX_REPOS} repos`); |
| 201 | + return { repos, useCase: String(args.useCase || '').trim() }; |
| 202 | +} |
| 203 | + |
| 204 | +export async function scanForComparison(repo) { |
| 205 | + const { platform, repoId } = parseRepoInput(repo); |
| 206 | + const repoData = await fetchRepoData(platform, repoId, ghOpts()); |
| 207 | + const analysis = parseClaudeResponse(await callModel(buildPrompt(repoData))); |
| 208 | + return { |
| 209 | + repoId: repoData.repoId, |
| 210 | + platform, |
| 211 | + language: repoData.language, |
| 212 | + license: repoData.license, |
| 213 | + stars: repoData.stars, |
| 214 | + description: repoData.description, |
| 215 | + ...analysis, |
| 216 | + fit: deriveFit(analysis), |
| 217 | + }; |
| 218 | +} |
| 219 | + |
| 220 | +export async function runCompareRepos(args) { |
| 221 | + const { repos, useCase } = validateCompareArgs(args); |
| 222 | + const scans = await Promise.all(repos.map(scanForComparison)); |
| 223 | + const comparison = parseComparisonResponse( |
| 224 | + await callModel(buildComparePrompt(scans, useCase)), |
| 225 | + scans, |
| 226 | + useCase |
| 227 | + ); |
| 228 | + const result = { ...comparison, repos: scans }; |
| 229 | + const reportId = comparison.ranking.map((r) => r.repoId).join('-vs-'); |
| 230 | + return attachHtmlReport('compare_repos', reportId, result, args); |
| 231 | +} |
0 commit comments