From 5e8a2dab39f0fc727e68a0853670e8330e88427c Mon Sep 17 00:00:00 2001 From: Mir Sameer Date: Fri, 19 Jun 2026 09:20:16 -0700 Subject: [PATCH] Add PR-ready change review reports --- README.md | 5 +- docs/agent-guide.md | 3 + docs/research-notes.md | 4 +- llms.txt | 3 +- scripts/test-skip-gate.mjs | 2 +- src/cli.ts | 26 ++++++ src/core/api.ts | 5 ++ src/core/review.ts | 173 +++++++++++++++++++++++++++++++++++++ src/core/store.ts | 2 +- src/core/types.ts | 13 +++ src/mcp/server.ts | 29 +++++++ tests/indexer.test.ts | 40 ++++++++- tests/mcp-server.test.ts | 11 ++- 13 files changed, 307 insertions(+), 9 deletions(-) create mode 100644 src/core/review.ts diff --git a/README.md b/README.md index 3a250ce..fa817bb 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ RepoLens MCP is an original TypeScript implementation built around fast local ve ## Why It Stands Out -- **MCP-native**: exposes 38 tools for indexing, version/update status, repeatable benchmarking, persistent config, project inventory/status, fleet summaries, cross-repo graphing, multi-agent setup, optional startup auto-indexing and git-aware auto-sync, BM25 code search, redacted secret scanning, symbol search, reference lookup, semantic search, vector search, context packs, source snippets, graph schema with relationship patterns and label properties, structural graph search, graph community detection, read-only Cypher-like graph queries, route-call links, runtime trace ingestion, channel/event edges, typed inheritance/implementation/use edges, receiver-aware method call edges, conservative data-flow edges, import-resolved file graphs, multi-ecosystem package manifests, lockfile resolved-dependency graphs, Docker/Kubernetes infrastructure nodes, dependency-cycle detection, architecture reports, architecture summaries, git-history hotspots, tracing, git-change impact, dead-code candidates, maintainable ADR memory, graph snapshots, and graph package exchange. +- **MCP-native**: exposes 39 tools for indexing, version/update status, repeatable benchmarking, persistent config, project inventory/status, fleet summaries, cross-repo graphing, multi-agent setup, optional startup auto-indexing and git-aware auto-sync, BM25 code search, redacted secret scanning, symbol search, reference lookup, semantic search, vector search, context packs, source snippets, graph schema with relationship patterns and label properties, structural graph search, graph community detection, read-only Cypher-like graph queries, route-call links, runtime trace ingestion, channel/event edges, typed inheritance/implementation/use edges, receiver-aware method call edges, conservative data-flow edges, import-resolved file graphs, multi-ecosystem package manifests, lockfile resolved-dependency graphs, Docker/Kubernetes infrastructure nodes, dependency-cycle detection, architecture reports, PR-ready change review reports, architecture summaries, git-history hotspots, tracing, git-change impact, dead-code candidates, maintainable ADR memory, graph snapshots, and graph package exchange. - **Agent-ready setup**: `doctor` inspects the local Codex MCP configuration, `install-codex` can add a managed MCP block with dry-run and force safeguards, `uninstall-codex` removes only managed RepoLens config, and `agent-setup`/`install-agents` generate reviewable guidance plus opt-in hook/reminder files for Codex, Claude, Gemini, Zed, OpenCode, Antigravity, Aider, KiloCode, VS Code, OpenClaw, and Kiro. - **Local-first SQLite memory**: all indexed data stays in `.repolens/memory.db`. - **Project catalog and cross-repo graphing**: `list-projects`, `project-status`, `fleet-summary`, `fleet-graph`, and `delete-project` track indexed repositories, aggregate languages/routes/HTTP calls/dependencies, and produce a catalog-wide graph with shared dependencies, route overlaps, and inferred consumer/provider service links. @@ -116,6 +116,7 @@ repolens-mcp dead-code [--db path] repolens-mcp cycles [--db path] [--limit n] repolens-mcp ingest-traces traces.json [--db path] repolens-mcp changes [repo] [--db path] +repolens-mcp review-report [repo] [--db path] [--limit n] [--format markdown|json] [--out report.md] repolens-mcp report [--db path] [--format markdown|html] [--graph-limit n] [--out report.html] repolens-mcp export-graph --out graph.html [--db path] repolens-mcp pack-graph --out graph.rlgz [--db path] [--label name] @@ -168,6 +169,7 @@ repolens-mcp mcp | `find_dependency_cycles` | Find import-resolved dependency cycles between architecture clusters. | | `ingest_traces` | Add observed runtime HTTP, event, or symbol edges as `OBSERVED_*` relationships. | | `detect_changes` | Map uncommitted git changes to indexed graph impact with per-file blast radius, relationship counts, and risk reasons. | +| `change_review_report` | Generate a PR-ready Markdown or JSON report with change risk, impacted graph items, security notes, and review checklist items. | | `architecture_report` | Generate a markdown or HTML architecture report with graph, hotspot, history, risk, and recommendation sections. | | `remember_decision` | Persist an ADR-style architecture decision. | | `list_decisions` | Retrieve saved decisions. | @@ -246,6 +248,7 @@ node --experimental-sqlite dist/src/cli.js context-pack "order checkout flow" -- node --experimental-sqlite dist/src/cli.js cycles --db /tmp/memory.db node --experimental-sqlite dist/src/cli.js query-graph "MATCH (f:Function) RETURN f.name,f.filePath LIMIT 5" --db /tmp/memory.db node --experimental-sqlite dist/src/cli.js ingest-traces traces.json --db /tmp/memory.db +node --experimental-sqlite dist/src/cli.js review-report /path/to/big/repo --db /tmp/memory.db --out change-review.md node --experimental-sqlite dist/src/cli.js report --db /tmp/memory.db --format html --out report.html node --experimental-sqlite dist/src/cli.js export-graph --db /tmp/memory.db --out graph.html --limit 1000 node --experimental-sqlite dist/src/cli.js pack-graph --db /tmp/memory.db --out graph.rlgz --label validation diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 6edb52d..b2758c4 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -37,6 +37,7 @@ On Windows PowerShell, the local installer mirrors the shell installer: 2. Use `architecture`, `schema`, `communities`, or `fleet-summary` to understand the project shape. `schema` includes relationship patterns and label property hints for safer graph queries. 3. Use `search`, `symbols`, `references`, `trace`, `cycles`, and `context-pack` for focused code context. 4. Use `changes` after edits to map uncommitted files back to graph impact, including per-file blast radius, relationship counts, and risk reasons. +5. Use `review-report --out change-review.md` when you need a PR-ready summary of changed-file risk, impacted graph items, security review notes, and checklist items. ## Useful MCP Tools @@ -50,6 +51,7 @@ On Windows PowerShell, the local installer mirrors the shell installer: - `get_graph_schema`: inspect labels, edge types, relationship patterns, and label properties before writing graph queries. - `scan_secrets`: return redacted high-signal secret findings from indexed lines. - `architecture_report`: generate Markdown or HTML reports. +- `change_review_report`: generate PR-ready Markdown or JSON from git change impact and graph context. - `export_graph_package` / `import_graph_package`: share or bootstrap local graph snapshots. ## Big-Repo Validation Pattern @@ -87,3 +89,4 @@ For behavior changes, include: - Documentation updates for CLI, MCP, graph schema, dashboard, or security behavior. - Package boundary validation when package contents changed. - Report or graph artifact paths when output behavior changed. +- `review-report` output for changes where graph impact, release risk, or security-sensitive paths need review evidence. diff --git a/docs/research-notes.md b/docs/research-notes.md index 57206a8..cc1e480 100644 --- a/docs/research-notes.md +++ b/docs/research-notes.md @@ -25,7 +25,7 @@ RepoLens MCP is not a fork or a drop-in static C replacement. It is an original - Node 24 plus native SQLite for a dependency-light local graph store. - Stable MCP SDK v1 package rather than the pre-alpha v2 branch. -- Clear CLI commands and MCP tools for Codex setup checks, persistent config, 11-agent setup guidance, graph package bootstrap, optional MCP startup auto-indexing, git-aware MCP auto-sync, indexing, repeatable full/incremental benchmarks, project inventory/status, fleet summaries with inferred service links, cross-repo fleet graphs, graph package exchange, BM25 code search, reference lookup, typed inheritance/implementation/use/data-flow edges, redacted secret scanning, semantic search, local vector search, context packs, source snippets, graph schema with relationship patterns and label properties, graph community detection, structural graph search, read-only Cypher-like graph queries with `DISTINCT`, `count`, `ORDER BY`, and `SKIP`, runtime trace ingestion, import-resolved file graph edges, multi-ecosystem manifest and lockfile extraction, Docker/Kubernetes/Kustomize infrastructure graph extraction, channel/event graph extraction, GraphQL/gRPC/tRPC/OpenAPI protocol extraction, route-call linking, relative and workspace-package import cycle detection, architecture reports, architecture summaries, git-history hotspots, tracing, impact analysis, dead-code candidates, git-change impact, and architecture decisions. +- Clear CLI commands and MCP tools for Codex setup checks, persistent config, 11-agent setup guidance, graph package bootstrap, optional MCP startup auto-indexing, git-aware MCP auto-sync, indexing, repeatable full/incremental benchmarks, project inventory/status, fleet summaries with inferred service links, cross-repo fleet graphs, graph package exchange, BM25 code search, reference lookup, typed inheritance/implementation/use/data-flow edges, redacted secret scanning, semantic search, local vector search, context packs, source snippets, graph schema with relationship patterns and label properties, graph community detection, structural graph search, read-only Cypher-like graph queries with `DISTINCT`, `count`, `ORDER BY`, and `SKIP`, runtime trace ingestion, import-resolved file graph edges, multi-ecosystem manifest and lockfile extraction, Docker/Kubernetes/Kustomize infrastructure graph extraction, channel/event graph extraction, GraphQL/gRPC/tRPC/OpenAPI protocol extraction, route-call linking, relative and workspace-package import cycle detection, architecture reports, PR-ready change review reports, architecture summaries, git-history hotspots, tracing, impact analysis, dead-code candidates, git-change impact, and architecture decisions. - Incremental indexing skips unchanged files, prunes removed files, and avoids call-edge rebuilds when there is no repository delta. - Watch mode keeps the graph fresh with polling-based incremental refreshes while preserving deterministic CLI behavior for tests and automation; git-aware auto-sync skips unchanged HEAD/status polls during long-running MCP sessions. - Browser dashboard without a bundler so the project is easy to build and inspect. @@ -44,7 +44,7 @@ RepoLens MCP is not a fork or a drop-in static C replacement. It is an original - Built-in ADR memory, not just structural graph search. - Dashboard API and HTML are included in the same binary entrypoint, avoiding a separate frontend build while still exposing graph exploration, fleet service links, schema counts, relationship patterns, label property hints, review signals, dead-code samples, and report links. - Swift extraction and big-repo validation now cover a mixed mobile/web monorepo, not only TypeScript services. -- Structural graph search, BM25 source search with code-aware token expansion, reference lookup, typed inheritance/implementation/use edges, conservative data-flow edges, persistent startup config, redacted secret scanning, context packs for agents, multi-agent setup guidance, graph package bootstrap, optional startup auto-indexing, git-aware auto-sync, repeatable benchmark output, import-resolved local file edges, multi-ecosystem package/dependency nodes, resolved lockfile dependency nodes, project inventory/status, fleet summaries with cross-project service links, cross-repo graphing, runtime trace ingestion, Docker/Kubernetes infrastructure nodes, channel/event edges, first-class HTTP call nodes, GraphQL/gRPC/tRPC/OpenAPI protocol nodes, route-call edges, deterministic graph communities, dependency-free semantic search, persisted local vector search, read-only Cypher-like graph queries, graph schema relationship/property summaries, dependency-cycle detection, dead-code candidates, git-history hotspots, git-diff impact mapping with per-file blast radius, watch indexing, and portable graph/package exports are first-class workflows. +- Structural graph search, BM25 source search with code-aware token expansion, reference lookup, typed inheritance/implementation/use edges, conservative data-flow edges, persistent startup config, redacted secret scanning, context packs for agents, multi-agent setup guidance, graph package bootstrap, optional startup auto-indexing, git-aware auto-sync, repeatable benchmark output, import-resolved local file edges, multi-ecosystem package/dependency nodes, resolved lockfile dependency nodes, project inventory/status, fleet summaries with cross-project service links, cross-repo graphing, runtime trace ingestion, Docker/Kubernetes infrastructure nodes, channel/event edges, first-class HTTP call nodes, GraphQL/gRPC/tRPC/OpenAPI protocol nodes, route-call edges, deterministic graph communities, dependency-free semantic search, persisted local vector search, read-only Cypher-like graph queries, graph schema relationship/property summaries, dependency-cycle detection, dead-code candidates, git-history hotspots, git-diff impact mapping with per-file blast radius, PR-ready change review reports, watch indexing, and portable graph/package exports are first-class workflows. - Indexing now writes local `SIMILAR_TO` and `SEMANTICALLY_RELATED` edges plus deterministic symbol vectors without external embeddings or network calls. - Architecture reports combine metrics, language mix, schema counts, structural hotspots, git-history churn, boundaries, import-resolved cycle checks, recommendations, dead-code samples, review signals, and a graph preview into one shareable artifact; the live schema API additionally exposes relationship patterns and label property hints for query authors. - A repeatable benchmark run on the large validation repo completed a full index in 16,484 ms and a no-op incremental index in 233 ms while preserving a 5,812-symbol, 38,645-edge graph with 153 Next.js route nodes, 653 resolved import edges, 5,957 type-use edges, 976 data-flow edges, and 387 locked dependencies. diff --git a/llms.txt b/llms.txt index 1f9bd66..c0ea0ea 100644 --- a/llms.txt +++ b/llms.txt @@ -6,6 +6,7 @@ RepoLens MCP is a local-first repository intelligence server for AI coding agent - You need architecture context before changing a codebase. - You need symbol, reference, route, dependency, or impact lookup without manually reading every file. +- You need a PR-ready change review report from git impact, graph risk, security notes, and checklist items. - You want a repeatable full/incremental benchmark for a repository graph. - You need a portable `.rlgz` graph package for local sharing or bootstrap. - You want redacted secret-scan results, package/dependency graph data, runtime trace edges, or architecture reports. @@ -29,7 +30,7 @@ Run the MCP server with: node --experimental-sqlite dist/src/cli.js mcp ``` -The server exposes tools for indexing, benchmarking, config management, project cataloging, fleet summaries, cross-repo graphing, code search, secret scanning, symbol/reference lookup, graph schema with relationship patterns and label properties, graph search, semantic/vector search, context packs, read-only graph queries, trace ingestion, impact analysis, architecture reports, ADR memory, and graph package import/export. +The server exposes tools for indexing, benchmarking, config management, project cataloging, fleet summaries, cross-repo graphing, code search, secret scanning, symbol/reference lookup, graph schema with relationship patterns and label properties, graph search, semantic/vector search, context packs, read-only graph queries, trace ingestion, impact analysis, PR-ready change review reports, architecture reports, ADR memory, and graph package import/export. ## Local Data Rules diff --git a/scripts/test-skip-gate.mjs b/scripts/test-skip-gate.mjs index 3241511..094de67 100644 --- a/scripts/test-skip-gate.mjs +++ b/scripts/test-skip-gate.mjs @@ -17,7 +17,7 @@ const allowedSkips = [ file: "tests/indexer.test.ts", reason: "git is not available", guard: "git.status !== 0", - expectedCount: 3 + expectedCount: 4 } ]; diff --git a/src/cli.ts b/src/cli.ts index 51c192d..523c2c3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { architectureReport, benchmarkRepository, + changeReviewReport, configGet, configList, configReset, @@ -246,6 +247,22 @@ async function main(): Promise { case "changes": print(detectChanges(args.positional[0] ? path.resolve(args.positional[0]) : undefined, numberFlag(args, "limit"), stringFlag(args, "db"))); break; + case "review-report": + case "pr-report": { + const out = stringFlag(args, "out"); + const format = reviewReportFormatFlag(args, out); + const report = changeReviewReport(args.positional[0] ? path.resolve(args.positional[0]) : undefined, numberFlag(args, "limit"), stringFlag(args, "db")); + const body = format === "json" ? `${jsonBlock(report)}\n` : report.markdown; + if (out) { + const outPath = path.resolve(out); + await fs.mkdir(path.dirname(outPath), { recursive: true }); + await fs.writeFile(outPath, body); + print({ out: outPath, format, risk: report.risk, changedFiles: report.summary.changedFileCount, impactedItems: report.summary.impactedItemCount }); + } else { + process.stdout.write(body); + } + break; + } case "decision": { const title = required(stringFlag(args, "title") ?? args.positional[0], "title"); const body = required(stringFlag(args, "body") ?? args.positional.slice(1).join(" "), "body"); @@ -491,6 +508,14 @@ function traceModeFlag(args: ParsedArgs): TraceMode | undefined { throw new Error("Invalid --mode. Use one of: all, calls, data_flow, cross_service."); } +function reviewReportFormatFlag(args: ParsedArgs, out?: string): "markdown" | "json" { + const value = stringFlag(args, "format") ?? (out?.endsWith(".json") ? "json" : "markdown"); + if (value === "markdown" || value === "json") { + return value; + } + throw new Error("Invalid --format. Use one of: markdown, json."); +} + function decisionStatusFlag(args: ParsedArgs): "proposed" | "accepted" | "superseded" | undefined { const value = stringFlag(args, "status"); if (!value) { @@ -651,6 +676,7 @@ Usage: repolens-mcp cycles [--db path] [--limit n] repolens-mcp ingest-traces traces.json [--db path] repolens-mcp changes [repo] [--db path] [--limit n] + repolens-mcp review-report [repo] [--db path] [--limit n] [--format markdown|json] [--out report.md] repolens-mcp decision --title "ADR title" --body "Decision body" [--tags a,b] repolens-mcp decision-update [--title "New title"] [--status proposed|accepted|superseded] [--body "Updated body"] [--tags a,b] [--db path] repolens-mcp decision-delete [--db path] diff --git a/src/core/api.ts b/src/core/api.ts index 0025c0a..35741d7 100644 --- a/src/core/api.ts +++ b/src/core/api.ts @@ -11,6 +11,7 @@ import { getRepoLensConfigValue, readRepoLensConfig, resetRepoLensConfigValue, s import { buildFleetGraph, type FleetGraphOptions } from "./fleet-graph.js"; import { indexRepository } from "./indexer.js"; import { buildArchitectureReport } from "./report.js"; +import { buildChangeReviewReport } from "./review.js"; import { defaultDbPath, MemoryStore } from "./store.js"; import { getVersionStatus as readVersionStatus, type VersionStatusOptions } from "./version.js"; import { watchRepository } from "./watcher.js"; @@ -232,6 +233,10 @@ export function detectChanges(root?: string, limit?: number, dbPath?: string) { return withStore(dbPath, (store) => store.detectChanges(root, limit)); } +export function changeReviewReport(root?: string, limit?: number, dbPath?: string) { + return buildChangeReviewReport(detectChanges(root, limit, dbPath)); +} + export function rememberDecision(decision: DecisionRecord, dbPath?: string) { return withStore(dbPath, (store) => store.addDecision(decision)); } diff --git a/src/core/review.ts b/src/core/review.ts new file mode 100644 index 0000000..45bde73 --- /dev/null +++ b/src/core/review.ts @@ -0,0 +1,173 @@ +import type { ChangeImpactResult, ChangeReviewReport } from "./types.js"; + +type ChangeReviewDraft = Omit; + +const securityPathPattern = + /(^|\/)(\.github|security|auth|oauth|session|token|secret|credential|permission|policy|install|release|deploy|infra|scripts?|Dockerfile|k8s|kubernetes)(\/|\.|$)/i; + +export function buildChangeReviewReport(impact: ChangeImpactResult, generatedAt = new Date().toISOString()): ChangeReviewReport { + const securityNotes = securityReviewNotes(impact); + const checklist = reviewChecklist(impact, securityNotes); + const draft: ChangeReviewDraft = { + generatedAt, + root: impact.root, + risk: impact.risk, + summary: impact.summary, + changedFiles: impact.changedFileDetails, + impacted: impact.impacted, + signals: impact.signals, + securityNotes, + checklist + }; + return { ...draft, markdown: renderChangeReviewMarkdown(draft) }; +} + +export function renderChangeReviewMarkdown(report: ChangeReviewDraft): string { + const changedRows = + report.changedFiles.length > 0 + ? report.changedFiles + .slice(0, 25) + .map( + (file) => + `| ${file.risk} | ${escapeTable(file.status)} | \`${escapeBackticks(file.path)}\` | ${file.symbols} | ${file.directEdges} | ${escapeTable(file.reasons.join("; ") || "none")} |` + ) + .join("\n") + : "| none | none | none | 0 | 0 | no uncommitted changes detected |"; + + const impactedRows = + report.impacted.length > 0 + ? report.impacted + .slice(0, 15) + .map((item) => `| ${item.score.toFixed(2)} | \`${escapeBackticks(item.item)}\` | ${escapeTable(item.reason)} |`) + .join("\n") + : "| 0.00 | none | no impacted indexed symbols found |"; + + return `# RepoLens Change Review + +Generated: ${report.generatedAt} +Root: ${report.root} +Risk: ${report.risk} + +## Summary + +- Changed files: ${report.summary.changedFileCount} +- Indexed changed files: ${report.summary.indexedChangedFileCount} +- Impacted indexed items: ${report.summary.impactedItemCount} +- Direct graph relationships: ${report.summary.directEdgeCount} +- Top edge types: ${inlineCounts(report.summary.topEdgeTypes, "type")} +- Top symbol kinds: ${inlineCounts(report.summary.topSymbolKinds, "kind")} + +## Changed Files + +| Risk | Status | File | Symbols | Direct edges | Notes | +| --- | --- | --- | ---: | ---: | --- | +${changedRows} + +## Top Impacted Items + +| Score | Item | Reason | +| ---: | --- | --- | +${impactedRows} + +## Security Review Notes + +${markdownList(report.securityNotes)} + +## Suggested PR Checklist + +${markdownChecklist(report.checklist)} + +## Signals + +${markdownList(report.signals.length ? report.signals : ["change impact completed without extra signals"])} +`; +} + +function securityReviewNotes(impact: ChangeImpactResult): string[] { + const notes: string[] = []; + const securityFiles = impact.changedFileDetails.filter((file) => securityPathPattern.test(file.path)); + const unindexedFiles = impact.changedFileDetails.filter((file) => !file.indexed); + const highRiskFiles = impact.changedFileDetails.filter((file) => file.risk === "high"); + const mediumRiskFiles = impact.changedFileDetails.filter((file) => file.risk === "medium"); + + if (impact.changedFileDetails.length === 0) { + notes.push("No uncommitted changes were detected; generate the report after edits or against the branch checkout to review PR impact."); + } + if (securityFiles.length > 0) { + notes.push(`Review security-sensitive paths: ${securityFiles.slice(0, 8).map((file) => file.path).join(", ")}.`); + } + if (highRiskFiles.length > 0) { + notes.push(`High-risk graph impact found in ${highRiskFiles.length} changed file(s); inspect inbound callers and release/security boundaries before merge.`); + } else if (mediumRiskFiles.length > 0) { + notes.push(`Medium-risk graph impact found in ${mediumRiskFiles.length} changed file(s); verify callers, tests, and generated artifacts.`); + } + if (unindexedFiles.length > 0) { + notes.push(`${unindexedFiles.length} changed file(s) are not in the current graph; re-index before treating impact coverage as complete.`); + } + if (impact.summary.directEdgeCount > 80) { + notes.push("The changed files touch many graph relationships; include focused regression evidence for the highest-degree files."); + } + if (notes.length === 0) { + notes.push("No security-sensitive path or high-impact graph signal was detected from the indexed change set."); + } + return notes; +} + +function reviewChecklist(impact: ChangeImpactResult, securityNotes: string[]): string[] { + const checklist = [ + "Reviewed changed-file risk, direct graph relationships, and top impacted indexed items.", + "Ran the relevant focused tests for the changed behavior.", + "Confirmed generated artifacts, local databases, graph packages, and private reports are not part of the commit." + ]; + if (impact.changedFileDetails.some((file) => !file.indexed)) { + checklist.unshift("Re-indexed the repository so new or previously skipped files are represented in the graph."); + } + if (securityNotes.some((note) => /security-sensitive|High-risk|Medium-risk/i.test(note))) { + checklist.push("Reviewed security, release, install, auth, credential, and workflow boundaries touched by this change."); + } + return checklist; +} + +function inlineCounts>(items: T[], key: keyof T): string { + if (items.length === 0) { + return "none"; + } + return items + .slice(0, 5) + .map((item) => `${String(item[key])} (${String(item.count ?? 0)})`) + .join(", "); +} + +function markdownList(items: string[]): string { + return items.map((item) => `- ${escapeMarkdownText(item)}`).join("\n"); +} + +function markdownChecklist(items: string[]): string { + return items.map((item) => `- [ ] ${escapeMarkdownText(item)}`).join("\n"); +} + +function escapeTable(value: string): string { + return escapeMarkdown(value, new Set(["`", "|"])); +} + +function escapeBackticks(value: string): string { + return escapeMarkdown(value, new Set(["`"])); +} + +function escapeMarkdownText(value: string): string { + return escapeMarkdown(value, new Set(["`"])); +} + +function escapeMarkdown(value: string, extraEscapedCharacters: Set): string { + let escaped = ""; + for (const char of value) { + if (char === "\n") { + escaped += " "; + } else if (char === "\\" || extraEscapedCharacters.has(char)) { + escaped += `\\${char}`; + } else { + escaped += char; + } + } + return escaped; +} diff --git a/src/core/store.ts b/src/core/store.ts index 989c038..524d39a 100644 --- a/src/core/store.ts +++ b/src/core/store.ts @@ -2317,7 +2317,7 @@ function getCount(db: DatabaseSync, sql: string): number { } function gitChangedFiles(root: string, signals: string[]): GitChangedFile[] { - const result = spawnSync("git", ["-C", root, "status", "--porcelain=v1", "-z"], { encoding: "utf8" }); + const result = spawnSync("git", ["-C", root, "status", "--porcelain=v1", "-z", "--untracked-files=all"], { encoding: "utf8" }); if (result.status !== 0) { const message = String(result.stderr || result.stdout || "git status failed").trim(); signals.push(`git status --porcelain failed: ${message}`); diff --git a/src/core/types.ts b/src/core/types.ts index 0e94566..288b1f1 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -376,6 +376,19 @@ export interface ChangeImpactResult { signals: string[]; } +export interface ChangeReviewReport { + generatedAt: string; + root: string; + risk: "none" | "low" | "medium" | "high"; + summary: ChangeImpactResult["summary"]; + changedFiles: ChangeImpactResult["changedFileDetails"]; + impacted: ChangeImpactResult["impacted"]; + signals: string[]; + securityNotes: string[]; + checklist: string[]; + markdown: string; +} + export interface DecisionRecord { id?: number; title: string; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 8cf91e1..2c2dc3d 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { architectureReport, benchmarkRepository, + changeReviewReport, configGet, configList, configReset, @@ -546,6 +547,23 @@ export async function startMcpServer(): Promise { async ({ root, limit, dbPath }) => text(detectChanges(root, limit, dbPath)) ); + server.registerTool( + "change_review_report", + { + description: "Generate a PR-ready Markdown or JSON change-impact report from uncommitted git changes and the indexed graph.", + inputSchema: { + root: z.string().optional(), + limit: z.number().int().positive().max(500).optional(), + format: z.enum(["markdown", "json"]).default("markdown"), + dbPath: z.string().optional() + } + }, + async ({ root, limit, format, dbPath }) => { + const report = changeReviewReport(root, limit, dbPath); + return format === "json" ? text(report) : plainText(report.markdown); + } + ); + server.registerTool( "remember_decision", { @@ -768,6 +786,17 @@ function text(value: unknown) { }; } +function plainText(value: string) { + return { + content: [ + { + type: "text" as const, + text: value + } + ] + }; +} + function currentCliPath(): string { return process.argv[1] ?? "repolens-mcp"; } diff --git a/tests/indexer.test.ts b/tests/indexer.test.ts index 1bd9075..dd5bb66 100644 --- a/tests/indexer.test.ts +++ b/tests/indexer.test.ts @@ -5,7 +5,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { architectureReport, benchmarkRepository, contextPack, packGraph, unpackGraph } from "../src/core/api.js"; +import { architectureReport, benchmarkRepository, changeReviewReport, contextPack, packGraph, unpackGraph } from "../src/core/api.js"; import { addCallEdges, addDataFlowEdges, addTypeRelationEdges, extractFromFile } from "../src/core/extractor.js"; import { indexRepository } from "../src/core/indexer.js"; import { MemoryStore } from "../src/core/store.js"; @@ -1244,6 +1244,44 @@ test("detects git change blast radius with per-file details", async (t) => { } }); +test("renders a PR-ready change review report", async (t) => { + const git = spawnSync("git", ["--version"], { encoding: "utf8" }); + if (git.status !== 0) { + t.skip("git is not available"); + return; + } + + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "memory-change-review-")); + const repo = path.join(tmp, "repo"); + const dbPath = path.join(tmp, "memory.db"); + await fs.cp(fixture, repo, { recursive: true }); + const runGit = (...args: string[]) => { + const result = spawnSync("git", ["-C", repo, ...args], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr || result.stdout); + }; + + spawnSync("git", ["init", repo], { encoding: "utf8" }); + runGit("config", "user.email", "repolens@example.test"); + runGit("config", "user.name", "RepoLens Test"); + runGit("add", "."); + runGit("commit", "-m", "initial graph"); + await indexRepository({ root: repo, dbPath }); + + await fs.appendFile(path.join(repo, "src", "orders.ts"), "\nexport function cancelOrder() { return orders.pop(); }\n"); + await fs.mkdir(path.join(repo, ".github", "workflows"), { recursive: true }); + await fs.writeFile(path.join(repo, ".github", "workflows", "release.yml"), "name: release\n"); + + const report = changeReviewReport(repo, 20, dbPath); + + assert.equal(report.summary.changedFileCount, 2); + assert.match(report.markdown, /^# RepoLens Change Review/); + assert.match(report.markdown, /## Security Review Notes/); + assert.match(report.markdown, /\.github\/workflows\/release\.yml/); + assert.ok(report.securityNotes.some((note) => note.includes("security-sensitive paths"))); + assert.ok(report.checklist.some((item) => item.includes("security"))); + assert.ok(report.markdown.includes("Suggested PR Checklist")); +}); + test("incremental indexing skips unchanged files and prunes removed files", async () => { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "memory-incremental-")); const repo = path.join(tmp, "repo"); diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index e716f56..a880e6d 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -226,12 +226,13 @@ test("MCP stdio JSON-RPC initializes and lists registered tools", async () => { const tools = await client.request("tools/list", {}); assert.equal(tools.error, undefined, stderrFor(child)); const list = (tools.result as { tools?: Array<{ name: string }> }).tools ?? []; - assert.equal(list.length, 38); + assert.equal(list.length, 39); assert.ok(list.some((tool) => tool.name === "index_repository")); assert.ok(list.some((tool) => tool.name === "benchmark_repository")); assert.ok(list.some((tool) => tool.name === "version_status")); assert.ok(list.some((tool) => tool.name === "trace_path")); assert.ok(list.some((tool) => tool.name === "scan_secrets")); + assert.ok(list.some((tool) => tool.name === "change_review_report")); } finally { await close(); } @@ -286,6 +287,12 @@ test("MCP stdio JSON-RPC rejects fuzzed invalid tool calls without exiting", asy arguments: fc.record({ agents: fc.array(fc.constant("unknown-agent"), { minLength: 1, maxLength: 2 }) }) + }), + fc.record({ + name: fc.constant("change_review_report"), + arguments: fc.record({ + format: fc.string({ maxLength: 12 }).filter((value) => value !== "markdown" && value !== "json") + }) }) ), { numRuns: 24, seed: 20260618 } @@ -297,7 +304,7 @@ test("MCP stdio JSON-RPC rejects fuzzed invalid tool calls without exiting", asy const stillAlive = await client.request("tools/list", {}); assert.equal(stillAlive.error, undefined, stderrFor(child)); - assert.equal(((stillAlive.result as { tools?: unknown[] }).tools ?? []).length, 38); + assert.equal(((stillAlive.result as { tools?: unknown[] }).tools ?? []).length, 39); } finally { await close(); }