diff --git a/deepdive.js b/deepdive.js index 69d9c32..75eb389 100644 --- a/deepdive.js +++ b/deepdive.js @@ -28,8 +28,12 @@ export function extractJsonObject(rawText) { // ─── Stage 0: fetch source (GitHub-first; others degrade to README only) ────── -async function ghJson(url) { - const r = await fetch(url, { headers: { Accept: 'application/vnd.github+json' } }); +async function ghJson(url, opts = {}) { + const headers = { Accept: 'application/vnd.github+json' }; + // Optional auth (MCP only; the extension passes no token). Lifts the 60/hr + // anon GitHub limit that blueprint/deep-dive would otherwise trip mid-scan. + if (opts.githubToken) headers.Authorization = `Bearer ${opts.githubToken}`; + const r = await fetch(url, { headers }); if (!r.ok) throw new Error(`GitHub ${r.status} for ${url}`); return r.json(); } @@ -58,13 +62,13 @@ export function selectKeyFiles(paths) { * Returns { tree: string[], files: [{path, content}], degraded: boolean }. * Only GitHub fetches real source; other platforms return a degraded result. */ -export async function fetchSource(platform, repoId) { +export async function fetchSource(platform, repoId, opts = {}) { if (platform !== 'github') return { tree: [], files: [], degraded: true }; - const meta = await ghJson(`https://api.github.com/repos/${repoId}`); + const meta = await ghJson(`https://api.github.com/repos/${repoId}`, opts); const branch = meta.default_branch || 'main'; const treeRes = await ghJson( - `https://api.github.com/repos/${repoId}/git/trees/${branch}?recursive=1` + `https://api.github.com/repos/${repoId}/git/trees/${branch}?recursive=1`, opts ); const allPaths = (treeRes.tree || []).filter(e => e.type === 'blob').map(e => e.path); const tree = allPaths.slice(0, MAX_TREE_PATHS); @@ -73,7 +77,7 @@ export async function fetchSource(platform, repoId) { const files = []; for (const path of keyPaths) { try { - const data = await ghJson(`https://api.github.com/repos/${repoId}/contents/${encodeURIComponent(path).replace(/%2F/g, '/')}`); + const data = await ghJson(`https://api.github.com/repos/${repoId}/contents/${encodeURIComponent(path).replace(/%2F/g, '/')}`, opts); if (data.encoding === 'base64' && data.content) { const content = atob(data.content.replace(/\n/g, '')).slice(0, MAX_FILE_CHARS); files.push({ path, content }); diff --git a/fetcher.js b/fetcher.js index d3f9e85..78be1d5 100644 --- a/fetcher.js +++ b/fetcher.js @@ -1,11 +1,17 @@ -async function fetchJson(url) { - const r = await fetch(url); +async function fetchJson(url, headers) { + const r = await fetch(url, headers ? { headers } : undefined); if (!r.ok) throw new Error(`HTTP ${r.status} for ${url}`); return r.json(); } -export async function fetchRepoData(platform, repoId) { - if (platform === 'github') return fetchGitHub(repoId); +// Authorization header for GitHub when an MCP caller supplies a token; null +// otherwise, so the anonymous path stays byte-for-byte identical (extension use). +function ghHeaders(opts) { + return opts && opts.githubToken ? { Authorization: `Bearer ${opts.githubToken}` } : null; +} + +export async function fetchRepoData(platform, repoId, opts = {}) { + if (platform === 'github') return fetchGitHub(repoId, opts); if (platform === 'gitlab') return fetchGitLab(repoId); if (platform === 'npm') return fetchNpm(repoId); if (platform === 'pypi') return fetchPyPI(repoId); @@ -22,11 +28,13 @@ function bytesToComposition(langs) { .map(([name, bytes]) => ({ name, pct: Math.round((bytes / total) * 100) })); } -async function fetchGitHub(repoId) { +async function fetchGitHub(repoId, opts = {}) { + const headers = ghHeaders(opts); + const init = headers ? { headers } : undefined; const [meta, readmeRes, langRes] = await Promise.all([ - fetchJson(`https://api.github.com/repos/${repoId}`), - fetch(`https://api.github.com/repos/${repoId}/readme`).catch(() => ({ ok: false })), - fetch(`https://api.github.com/repos/${repoId}/languages`).catch(() => ({ ok: false })), + fetchJson(`https://api.github.com/repos/${repoId}`, headers), + fetch(`https://api.github.com/repos/${repoId}/readme`, init).catch(() => ({ ok: false })), + fetch(`https://api.github.com/repos/${repoId}/languages`, init).catch(() => ({ ok: false })), ]); let readme = ''; if (readmeRes.ok) { diff --git a/mcp/README.md b/mcp/README.md index 1239027..f8b0e70 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -1,12 +1,13 @@ # RepoLens MCP server A local [MCP](https://modelcontextprotocol.io) server that exposes RepoLens's repo -analysis as a tool. An LLM client (Claude Desktop, Cursor, etc.) calls `scan_repo` -and gets RepoLens's verdict-first JSON back — ready to render as components. +analysis as tools. An LLM client (Claude Desktop, Cursor, etc.) calls a tool and +gets RepoLens's JSON back — ready to render as components. -This is a **thin proof**: one tool, GitHub-only, Anthropic-only. It reuses the -extension's own pipeline (`fetcher.js` → `prompt.js` → `parser.js`); only the -provider call is MCP-specific. +GitHub-only, Anthropic-only, **three tools** (`scan_repo`, `blueprint_scene`, +`deep_dive`). Each reuses the extension's own pipeline modules verbatim +(`fetcher.js`, `prompt.js`, `parser.js`, `deepdive.js`, `blueprint-adapter.js`); +only the provider call (`anthropic.js`) is MCP-specific. ## What stays true @@ -28,7 +29,8 @@ provider call is MCP-specific. "license": "MIT", "stars": 21000, "description": "Small, fast web framework for the edges.", - "fit": "strong", + "fit": { "level": "strong", "label": "Strong fit", "why": "Health 92 · 0 flags · 4 pros / 1 cons" }, + "bottom_line": "A lean, fast framework worth adopting for edge runtimes.", "health": { "score": 92 }, "pros": ["..."], "cons": ["..."], @@ -37,32 +39,65 @@ provider call is MCP-specific. } ``` +`fit` is derived deterministically from the health score, red-flag count, and +pros/cons balance — `level` is one of `strong | solid | care | risky`. + ### `blueprint_scene({ repo })` -Maps how the repo is built and returns a laid-out graph — `nodes` (key parts) and +Maps how the repo is built and returns a laid-out scene — `nodes` (key parts) and `edges` (how they relate), with positions — ready for a ``-style component. Heavier than `scan_repo`: it reads source and makes two model calls -(atoms, then lineage). +(atoms, then lineage). Edges are engine-shaped (`{ id, from, to, rel }`), not +`{ source, target }`. ```json { "id": "repo:...", "scope": "blueprint", "repoId": "honojs/hono", - "nodes": [{ "id": "app", "label": "Hono app", "kind": "entrypoint", "x": 120, "y": 40 }], - "edges": [{ "source": "app", "target": "router", "label": "depends-on" }], + "nodes": [{ "id": "app", "label": "Hono app", "kind": "entrypoint", "x": 120, "y": 40, + "layer": "entrypoint", "ref": { "root": true, "purpose": "...", "files": ["src/hono.ts"] } }], + "edges": [{ "id": "e123", "from": "app", "to": "router", "rel": "depends-on" }], "camera": { "x": 0, "y": 0, "zoom": 1 } } ``` +### `deep_dive({ repo })` + +Explains how the repo actually works in plain language, with the weak spots named. +Returns a from-scratch `explanation`, the `gaps` and `assumptions` behind it, +self-test `questions`, per-claim `confidence`, plus the underlying `atoms` and +`lineage`. **Heaviest tool** — reads source and makes three model calls +(atoms → lineage → Feynman). + +```json +{ + "repoId": "honojs/hono", + "degraded": false, + "explanation": "Hono is a small web framework that ...", + "gaps": ["..."], + "assumptions": ["..."], + "questions": [{ "q": "What runs a request?", "a": "..." }], + "confidence": [{ "claim": "...", "level": "high", "note": "..." }], + "atoms": [{ "id": "router", "name": "Router", "kind": "subsystem", "purpose": "..." }], + "lineage": { "links": [{ "from": "app", "to": "router", "relation": "depends-on" }], "roots": ["app"], "leaves": [] } +} +``` + ## Setup ```bash cd mcp npm install -export ANTHROPIC_API_KEY=sk-ant-... # required -export ANTHROPIC_MODEL=claude-sonnet-4-6 # optional override -node server.js # speaks MCP over stdio +export ANTHROPIC_API_KEY=sk-ant-... # required +export ANTHROPIC_MODEL=claude-sonnet-4-6 # optional override +export ANTHROPIC_TIMEOUT_MS=60000 # optional; hard per-call timeout (default 60s) +export GITHUB_TOKEN=ghp_... # optional; lifts GitHub 60/hr → 5000/hr +node server.js # speaks MCP over stdio ``` +A `GITHUB_TOKEN` is **strongly recommended** for `blueprint_scene` and `deep_dive`: +each makes 10+ GitHub calls per run and will hit the 60 req/hr anonymous limit +(surfacing as a mid-scan `GitHub 403`) without one. + ### Add to Claude Desktop In `claude_desktop_config.json`: @@ -73,7 +108,7 @@ In `claude_desktop_config.json`: "repolens": { "command": "node", "args": ["/absolute/path/to/repolens/mcp/server.js"], - "env": { "ANTHROPIC_API_KEY": "sk-ant-..." } + "env": { "ANTHROPIC_API_KEY": "sk-ant-...", "GITHUB_TOKEN": "ghp_..." } } } } @@ -81,7 +116,9 @@ In `claude_desktop_config.json`: ## Notes -- Unauthenticated GitHub requests are rate-limited. For heavier use, a `GITHUB_TOKEN` - pass-through is a follow-up. -- Next (follow-ups): `deep_dive` (the plain-English layer), multi-provider, and - npm / PyPI / GitLab support; a `GITHUB_TOKEN` pass-through for higher rate limits. +- `deep_dive` and `blueprint_scene` are GitHub-deep but README-shallow elsewhere: + only GitHub exposes a file tree, so on other platforms they degrade (`deep_dive` + sets `degraded: true`). +- Next (follow-ups): multi-provider (reuse the extension's `providers.js` registry), + npm / PyPI / GitLab inputs for `scan_repo` (the fetcher already supports them — + only the input parser is GitHub-only), and a `tools/list` structural smoke test. diff --git a/mcp/anthropic.js b/mcp/anthropic.js index 608bbb7..478152e 100644 --- a/mcp/anthropic.js +++ b/mcp/anthropic.js @@ -5,6 +5,7 @@ const ENDPOINT = 'https://api.anthropic.com/v1/messages'; const DEFAULT_MODEL = 'claude-sonnet-4-6'; +const DEFAULT_TIMEOUT_MS = 60_000; /** * @param {string} prompt - the fully-assembled analysis prompt. @@ -14,20 +15,36 @@ export async function callAnthropic(prompt) { const key = process.env.ANTHROPIC_API_KEY; if (!key) throw new Error('ANTHROPIC_API_KEY is not set in the environment'); const model = process.env.ANTHROPIC_MODEL || DEFAULT_MODEL; + const timeoutMs = Number(process.env.ANTHROPIC_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS; - const res = await fetch(ENDPOINT, { - method: 'POST', - headers: { - 'content-type': 'application/json', - 'anthropic-version': '2023-06-01', - 'x-api-key': key, - }, - body: JSON.stringify({ - model, - max_tokens: 4096, - messages: [{ role: 'user', content: prompt }], - }), - }); + // Hard timeout so a stalled connection can't hang the tool call forever + // (mirrors the extension's per-provider timeout in background.js). + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + let res; + try { + res = await fetch(ENDPOINT, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'anthropic-version': '2023-06-01', + 'x-api-key': key, + }, + body: JSON.stringify({ + model, + max_tokens: 4096, + messages: [{ role: 'user', content: prompt }], + }), + signal: controller.signal, + }); + } catch (err) { + if (err && err.name === 'AbortError') { + throw new Error(`Anthropic request timed out after ${timeoutMs}ms`); + } + throw err; + } finally { + clearTimeout(timer); + } if (!res.ok) { const detail = await res.text().catch(() => ''); diff --git a/mcp/blueprint-scene.js b/mcp/blueprint-scene.js index 04e0faa..50e06f6 100644 --- a/mcp/blueprint-scene.js +++ b/mcp/blueprint-scene.js @@ -12,6 +12,7 @@ import { fetchSource, buildAtomsPrompt, parseAtoms, buildLineagePrompt, parseLin import { buildBlueprintScene } from '../blueprint-adapter.js'; import { parseRepoInput } from './repo-input.js'; import { callAnthropic } from './anthropic.js'; +import { ghOpts } from './github-auth.js'; export const BLUEPRINT_TOOL = { name: 'blueprint_scene', @@ -25,6 +26,8 @@ export const BLUEPRINT_TOOL = { required: ['repo'], additionalProperties: false, }, + // Mirrors the real scene object returned by buildBlueprintScene (scene.js + + // repair-graph.js): engine-shaped nodes/edges, not a {source,target} graph. outputSchema: { type: 'object', properties: { @@ -39,10 +42,22 @@ export const BLUEPRINT_TOOL = { properties: { id: { type: 'string' }, label: { type: 'string' }, - kind: { type: 'string' }, + kind: { type: 'string', description: 'subsystem|module|concept|entrypoint|data' }, + layer: { type: ['string', 'null'] }, x: { type: 'number' }, y: { type: 'number' }, + pinned: { type: 'boolean' }, + ref: { + type: 'object', + description: 'root = lineage root (load-bearing); plus purpose + files', + properties: { + root: { type: 'boolean' }, + purpose: { type: ['string', 'null'] }, + files: { type: 'array', items: { type: 'string' } }, + }, + }, }, + required: ['id', 'label', 'kind', 'x', 'y'], }, }, edges: { @@ -50,22 +65,32 @@ export const BLUEPRINT_TOOL = { items: { type: 'object', properties: { - source: { type: 'string' }, - target: { type: 'string' }, - label: { type: 'string' }, + id: { type: 'string' }, + from: { type: 'string' }, + to: { type: 'string' }, + rel: { type: 'string', description: 'depends-on|enables|triggers|derives-from' }, + note: { type: ['string', 'null'] }, + userDrawn: { type: 'boolean' }, }, + required: ['id', 'from', 'to', 'rel'], }, }, - camera: { type: 'object' }, + annotations: { type: 'array' }, + camera: { + type: 'object', + properties: { x: { type: 'number' }, y: { type: 'number' }, zoom: { type: 'number' } }, + }, + source: { type: 'object', description: 'lens + timestamps' }, }, - required: ['nodes', 'edges'], + required: ['id', 'nodes', 'edges'], }, }; export async function runBlueprintScene(args) { const { platform, repoId } = parseRepoInput(args?.repo); - const repoData = await fetchRepoData(platform, repoId); - const source = await fetchSource(platform, repoId); + const opts = ghOpts(); + const repoData = await fetchRepoData(platform, repoId, opts); + const source = await fetchSource(platform, repoId, opts); const { atoms } = parseAtoms(await callAnthropic(buildAtomsPrompt(repoData, source, null))); const lineage = parseLineage(await callAnthropic(buildLineagePrompt(atoms))); return buildBlueprintScene({ deepDive: { atoms, lineage }, repoId, title: repoId }); diff --git a/mcp/deep-dive.js b/mcp/deep-dive.js new file mode 100644 index 0000000..d88712d --- /dev/null +++ b/mcp/deep-dive.js @@ -0,0 +1,92 @@ +// deep_dive tool: RepoLens's plain-English Deep Dive — explain how a repo really +// works, with the gaps and confidence made explicit. +// +// Pipeline (extension modules, verbatim): fetchRepoData + fetchSource -> +// atoms prompt/parse -> lineage prompt/parse -> Feynman prompt/parse. THREE +// model calls (atoms, lineage, Feynman), so this is the heaviest tool. `facts` +// is null — the extension's local "runner" (measured metrics) isn't part of a +// headless MCP, and factsBlock('') degrades cleanly. + +import { fetchRepoData } from '../fetcher.js'; +import { + fetchSource, + buildAtomsPrompt, parseAtoms, + buildLineagePrompt, parseLineage, + buildFeynmanPrompt, parseFeynman, +} from '../deepdive.js'; +import { parseRepoInput } from './repo-input.js'; +import { callAnthropic } from './anthropic.js'; +import { ghOpts } from './github-auth.js'; + +export const DEEP_DIVE_TOOL = { + name: 'deep_dive', + description: + "Explain how a GitHub repo actually works, in plain language, with its weak spots named. " + + 'Returns a from-scratch explanation, the gaps/assumptions behind it, self-test questions, ' + + 'per-claim confidence, and the underlying atoms + causal lineage. Use this when the user ' + + 'wants to *understand* a codebase, not just judge it. Heaviest tool (reads source, three model calls).', + inputSchema: { + type: 'object', + properties: { repo: { type: 'string', description: 'A repo as owner/name or a GitHub URL' } }, + required: ['repo'], + additionalProperties: false, + }, + outputSchema: { + type: 'object', + properties: { + repoId: { type: 'string' }, + degraded: { type: 'boolean', description: 'true when no source tree was available (README-only read)' }, + explanation: { type: 'string', description: 'Plain-language explanation from scratch.' }, + gaps: { type: 'array', items: { type: 'string' }, description: 'Where the explanation is weakest.' }, + assumptions: { type: 'array', items: { type: 'string' }, description: 'Inferred, not directly verified.' }, + questions: { + type: 'array', + items: { + type: 'object', + properties: { q: { type: 'string' }, a: { type: 'string' } }, + }, + description: 'Self-test questions with answers.', + }, + confidence: { + type: 'array', + items: { + type: 'object', + properties: { + claim: { type: 'string' }, + level: { type: 'string', enum: ['high', 'medium', 'low'] }, + note: { type: 'string' }, + }, + }, + }, + atoms: { type: 'array', description: 'The atomic units the explanation is built from.' }, + lineage: { type: 'object', description: 'Causal links + roots/leaves between atoms.' }, + }, + required: ['repoId', 'explanation'], + }, +}; + +/** Pure assembly of the tool result. Network/model-free so it is unit-testable. */ +export function buildDeepDiveResult(repoId, atoms, lineage, feynman, source) { + return { + repoId, + degraded: !!(source && source.degraded), + explanation: feynman.explanation, + gaps: feynman.gaps, + assumptions: feynman.assumptions, + questions: feynman.questions, + confidence: feynman.confidence, + atoms, + lineage, + }; +} + +export async function runDeepDive(args) { + const { platform, repoId } = parseRepoInput(args?.repo); + const opts = ghOpts(); + const repoData = await fetchRepoData(platform, repoId, opts); + const source = await fetchSource(platform, repoId, opts); + const { atoms } = parseAtoms(await callAnthropic(buildAtomsPrompt(repoData, source, null))); + const lineage = parseLineage(await callAnthropic(buildLineagePrompt(atoms))); + const feynman = parseFeynman(await callAnthropic(buildFeynmanPrompt(repoData, atoms, lineage))); + return buildDeepDiveResult(repoId, atoms, lineage, feynman, source); +} diff --git a/mcp/github-auth.js b/mcp/github-auth.js new file mode 100644 index 0000000..5b37edb --- /dev/null +++ b/mcp/github-auth.js @@ -0,0 +1,16 @@ +// Optional GitHub auth for MCP fetches. +// +// A GITHUB_TOKEN in the environment lifts GitHub's 60 req/hr unauthenticated +// limit to 5000/hr — important for blueprint_scene and deep_dive, which make +// many GitHub calls per run (1 tree + up to 8 contents, plus repo meta). With no +// token set, this returns {} and every fetch stays anonymous — identical to the +// extension, which never sends an Authorization header. +// +// The shape ({ githubToken }) is what fetcher.js and deepdive.js expect as their +// optional trailing `opts` argument. + +/** @returns {{ githubToken?: string }} auth opts for the fetch helpers. */ +export function ghOpts() { + const token = process.env.GITHUB_TOKEN; + return token ? { githubToken: token } : {}; +} diff --git a/mcp/scan-repo.js b/mcp/scan-repo.js index 15f2d3f..881dee1 100644 --- a/mcp/scan-repo.js +++ b/mcp/scan-repo.js @@ -4,8 +4,10 @@ import { fetchRepoData } from '../fetcher.js'; import { buildPrompt } from '../prompt.js'; import { parseClaudeResponse } from '../parser.js'; +import { deriveFit } from '../verdict.js'; import { parseRepoInput } from './repo-input.js'; import { callAnthropic } from './anthropic.js'; +import { ghOpts } from './github-auth.js'; export const SCAN_TOOL = { name: 'scan_repo', @@ -28,7 +30,17 @@ export const SCAN_TOOL = { license: { type: 'string' }, stars: { type: 'number' }, description: { type: 'string' }, - fit: { type: 'string', description: 'overall fit verdict' }, + fit: { + type: 'object', + description: 'Overall fit verdict, derived from health score, red flags, and pros/cons.', + properties: { + level: { type: 'string', enum: ['strong', 'solid', 'care', 'risky'] }, + label: { type: 'string' }, + why: { type: 'string' }, + }, + required: ['level', 'label'], + }, + bottom_line: { type: 'string', description: 'One-line takeaway.' }, health: { type: 'object', description: 'health score + signals' }, pros: { type: 'array', items: { type: 'string' } }, cons: { type: 'array', items: { type: 'string' } }, @@ -39,10 +51,13 @@ export const SCAN_TOOL = { }, }; -export async function runScanRepo(args) { - const { platform, repoId } = parseRepoInput(args?.repo); - const repoData = await fetchRepoData(platform, repoId); - const analysis = parseClaudeResponse(await callAnthropic(buildPrompt(repoData))); +/** + * Assemble the tool result from fetched repo data + parsed analysis. Pure (no + * network/model) so the fit derivation is unit-testable. `fit` is derived here + * because parseClaudeResponse does not produce it — the extension computes it + * separately via deriveFit at render time. + */ +export function buildScanResult(platform, repoData, analysis) { return { repoId: repoData.repoId, platform, @@ -51,5 +66,13 @@ export async function runScanRepo(args) { stars: repoData.stars, description: repoData.description, ...analysis, + fit: deriveFit(analysis), }; } + +export async function runScanRepo(args) { + const { platform, repoId } = parseRepoInput(args?.repo); + const repoData = await fetchRepoData(platform, repoId, ghOpts()); + const analysis = parseClaudeResponse(await callAnthropic(buildPrompt(repoData))); + return buildScanResult(platform, repoData, analysis); +} diff --git a/mcp/server.js b/mcp/server.js index a48a1ac..dbb182c 100644 --- a/mcp/server.js +++ b/mcp/server.js @@ -6,6 +6,7 @@ // Tools: // scan_repo — verdict-first analysis (fit/health/pros/cons/flags) // blueprint_scene — laid-out nodes/edges graph of how the repo is built +// deep_dive — plain-English explanation + gaps + confidence (heaviest) import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; @@ -13,10 +14,12 @@ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprot import { SCAN_TOOL, runScanRepo } from './scan-repo.js'; import { BLUEPRINT_TOOL, runBlueprintScene } from './blueprint-scene.js'; +import { DEEP_DIVE_TOOL, runDeepDive } from './deep-dive.js'; const TOOLS = { [SCAN_TOOL.name]: { def: SCAN_TOOL, run: runScanRepo }, [BLUEPRINT_TOOL.name]: { def: BLUEPRINT_TOOL, run: runBlueprintScene }, + [DEEP_DIVE_TOOL.name]: { def: DEEP_DIVE_TOOL, run: runDeepDive }, }; const server = new Server( diff --git a/tests/mcp-deep-dive.test.js b/tests/mcp-deep-dive.test.js new file mode 100644 index 0000000..153123c --- /dev/null +++ b/tests/mcp-deep-dive.test.js @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { buildDeepDiveResult, runDeepDive } from '../mcp/deep-dive.js'; + +describe('buildDeepDiveResult', () => { + it('assembles the Feynman layer + atoms/lineage and flags degraded', () => { + const feynman = { + explanation: 'It works.', + gaps: ['g'], + assumptions: ['a'], + questions: [{ q: '?', a: '!' }], + confidence: [{ claim: 'c', level: 'high', note: 'n' }], + }; + const out = buildDeepDiveResult('a/b', [{ id: 'x' }], { links: [], roots: [], leaves: [] }, feynman, { degraded: true }); + expect(out.repoId).toBe('a/b'); + expect(out.explanation).toBe('It works.'); + expect(out.degraded).toBe(true); + expect(out.atoms).toEqual([{ id: 'x' }]); + expect(out.questions).toEqual([{ q: '?', a: '!' }]); + }); +}); + +describe('runDeepDive (offline, mocked GitHub + Anthropic)', () => { + beforeEach(() => { + vi.restoreAllMocks(); + process.env.ANTHROPIC_API_KEY = 'sk-test'; + }); + afterEach(() => { + delete process.env.ANTHROPIC_API_KEY; + }); + + it('makes three sequential model calls (atoms → lineage → Feynman)', async () => { + const atomsJson = JSON.stringify({ atoms: [{ id: 'core', name: 'Core', kind: 'subsystem', purpose: 'p', files: ['package.json'] }] }); + const lineageJson = JSON.stringify({ links: [], roots: ['core'], leaves: [] }); + const feynmanJson = JSON.stringify({ explanation: 'Plain.', gaps: [], assumptions: [], questions: [], confidence: [] }); + const anthropicQueue = [atomsJson, lineageJson, feynmanJson]; + + global.fetch = vi.fn((url) => { + const u = String(url); + if (u.includes('api.anthropic.com')) { + return Promise.resolve({ ok: true, json: async () => ({ content: [{ text: anthropicQueue.shift() }] }) }); + } + if (u.includes('/git/trees/')) return Promise.resolve({ ok: true, json: async () => ({ tree: [{ type: 'blob', path: 'package.json' }] }) }); + if (u.includes('/contents/')) return Promise.resolve({ ok: true, json: async () => ({ encoding: 'base64', content: btoa('{"name":"x"}') }) }); + if (u.includes('/readme') || u.includes('/languages')) return Promise.resolve({ ok: false }); + if (/\/repos\/[^/]+\/[^/]+$/.test(u)) { + return Promise.resolve({ ok: true, json: async () => ({ description: 'x', stargazers_count: 0, language: 'JavaScript', license: { spdx_id: 'MIT' }, default_branch: 'main' }) }); + } + return Promise.resolve({ ok: false }); + }); + + const out = await runDeepDive({ repo: 'honojs/hono' }); + expect(out.repoId).toBe('honojs/hono'); + expect(out.explanation).toBe('Plain.'); + expect(out.atoms).toHaveLength(1); + expect(out.lineage.roots).toEqual(['core']); + expect(out.degraded).toBe(false); + + const anthropicCalls = global.fetch.mock.calls.filter((c) => String(c[0]).includes('api.anthropic.com')); + expect(anthropicCalls).toHaveLength(3); + }); +}); diff --git a/tests/mcp-github-auth.test.js b/tests/mcp-github-auth.test.js new file mode 100644 index 0000000..353ee8b --- /dev/null +++ b/tests/mcp-github-auth.test.js @@ -0,0 +1,20 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { ghOpts } from '../mcp/github-auth.js'; + +describe('ghOpts', () => { + const original = process.env.GITHUB_TOKEN; + afterEach(() => { + if (original === undefined) delete process.env.GITHUB_TOKEN; + else process.env.GITHUB_TOKEN = original; + }); + + it('returns empty opts when GITHUB_TOKEN is unset (anonymous, like the extension)', () => { + delete process.env.GITHUB_TOKEN; + expect(ghOpts()).toEqual({}); + }); + + it('returns the token when GITHUB_TOKEN is set', () => { + process.env.GITHUB_TOKEN = 'ghp_test123'; + expect(ghOpts()).toEqual({ githubToken: 'ghp_test123' }); + }); +}); diff --git a/tests/mcp-scan-repo.test.js b/tests/mcp-scan-repo.test.js new file mode 100644 index 0000000..a595d57 --- /dev/null +++ b/tests/mcp-scan-repo.test.js @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { buildScanResult, runScanRepo } from '../mcp/scan-repo.js'; + +describe('buildScanResult', () => { + const repoData = { repoId: 'a/b', language: 'JS', license: 'MIT', stars: 5, description: 'x' }; + + it('derives fit — the field parseClaudeResponse never produces', () => { + const analysis = { health: { score: 90 }, red_flags: [], pros: ['p1', 'p2'], cons: [], bottom_line: 'Good.' }; + const out = buildScanResult('github', repoData, analysis); + expect(out.repoId).toBe('a/b'); + expect(out.platform).toBe('github'); + expect(out.fit.level).toBe('strong'); + expect(out.fit.label).toBe('Strong fit'); + expect(out.fit.why).toContain('Health 90'); + expect(out.bottom_line).toBe('Good.'); // carried through from the spread analysis + }); + + it('reflects a weak repo as a risky fit', () => { + const analysis = { health: { score: 40 }, red_flags: [{ severity: 'risk' }], pros: [], cons: ['c'] }; + expect(buildScanResult('github', repoData, analysis).fit.level).toBe('risky'); + }); +}); + +describe('runScanRepo (offline, mocked GitHub + Anthropic)', () => { + const originalToken = process.env.GITHUB_TOKEN; + beforeEach(() => { + vi.restoreAllMocks(); + process.env.ANTHROPIC_API_KEY = 'sk-test'; + }); + afterEach(() => { + delete process.env.ANTHROPIC_API_KEY; + if (originalToken === undefined) delete process.env.GITHUB_TOKEN; + else process.env.GITHUB_TOKEN = originalToken; + }); + + it('runs end-to-end and threads GITHUB_TOKEN into the GitHub call', async () => { + process.env.GITHUB_TOKEN = 'ghp_abc'; + const scanJson = JSON.stringify({ health: { score: 80 }, red_flags: [], pros: ['a'], cons: [], bottom_line: 'Solid.' }); + global.fetch = vi.fn((url) => { + const u = String(url); + if (u.includes('api.anthropic.com')) { + return Promise.resolve({ ok: true, json: async () => ({ content: [{ text: scanJson }] }) }); + } + if (u.includes('/readme') || u.includes('/languages')) return Promise.resolve({ ok: false }); + if (u.endsWith('/repos/facebook/react')) { + return Promise.resolve({ ok: true, json: async () => ({ description: 'UI', stargazers_count: 1, language: 'JavaScript', license: { spdx_id: 'MIT' } }) }); + } + return Promise.resolve({ ok: false }); + }); + + const out = await runScanRepo({ repo: 'facebook/react' }); + expect(out.repoId).toBe('facebook/react'); + expect(out.fit.level).toBe('solid'); + + const ghCall = global.fetch.mock.calls.find((c) => String(c[0]).endsWith('/repos/facebook/react')); + expect(ghCall[1]?.headers?.Authorization).toBe('Bearer ghp_abc'); + const anthCall = global.fetch.mock.calls.find((c) => String(c[0]).includes('api.anthropic.com')); + expect(anthCall[1].headers['x-api-key']).toBe('sk-test'); + }); + + it('sends no Authorization header when GITHUB_TOKEN is unset', async () => { + delete process.env.GITHUB_TOKEN; + const scanJson = JSON.stringify({ health: { score: 60 }, red_flags: [], pros: [], cons: [] }); + global.fetch = vi.fn((url) => { + const u = String(url); + if (u.includes('api.anthropic.com')) return Promise.resolve({ ok: true, json: async () => ({ content: [{ text: scanJson }] }) }); + if (u.includes('/readme') || u.includes('/languages')) return Promise.resolve({ ok: false }); + return Promise.resolve({ ok: true, json: async () => ({ description: '', stargazers_count: 0, language: 'JavaScript', license: { spdx_id: 'MIT' } }) }); + }); + await runScanRepo({ repo: 'x/y' }); + const ghCall = global.fetch.mock.calls.find((c) => String(c[0]).endsWith('/repos/x/y')); + // No init / no headers => anonymous, identical to extension behavior. + expect(ghCall[1]?.headers?.Authorization).toBeUndefined(); + }); +});