From 6a210b550e73f4a0b3d3cc9b2a9ded042735cc1f Mon Sep 17 00:00:00 2001 From: Laith Al-Saadoon <9553966+theagenticguy@users.noreply.github.com> Date: Fri, 29 May 2026 17:47:35 -0500 Subject: [PATCH] feat(cli): expose 9 read-only graph tools as CLI subcommands; fix cochanges test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight read-only graph capabilities were MCP-only with no `codehub` CLI subcommand, so CLI/CI-only users couldn't reach them (field-report Issue 3). Adds 9 subcommands sharing the SAME logic as their MCP siblings, following the `verdict` CLI-MCP shared-function pattern. THIN (reuse an existing @opencodehub/analysis fn or storage reader): findings, dead-code, license-audit, project-profile, risk-trends, dependencies. EXTRACT (logic was inlined in the MCP handler with no analysis home — lifted VERBATIM into @opencodehub/analysis, then the MCP tool refactored to import it so both surfaces share one impl): - owners -> analysis/owners.ts listOwners (slice-before-join preserved) - route-map -> analysis/route-map.ts listRouteMap (two-stage method, 500 cap) - api-impact -> analysis/api-impact.ts listApiImpact (+ classifyShape moved to analysis/shape.ts); reuses the existing RiskLevel union, no second name added to the barrel. Each CLI command opens a read-only store via openStoreForCommand, exposes a storeFactory test seam (risk-trends uses a snapshotsFn seam since it reads .codehub/history, not the graph), branches on --json, and emits a plain table/JSON — NOT the MCP next_steps/staleness envelope. Registered with hyphenated names and non-variadic --repo, before parseAsync. Also fixes a pre-existing mcp test that failed on clean main: the "impact surfaces cochanges" fixture used the node id `F:foo` (one colon), which `looksLikeNodeId` rejects (real ids carry >=2 colons), so the target never resolved and the cochanges side-section came back empty. Fixture now uses a realistic `Function:src/foo.ts:foo` id. Verified end-to-end against a live index: all 9 commands render correctly; the MCP tools still delegate (no duplicated logic). analysis 128/128, mcp 219/219, cli 286/286 (+20 new), tsc + biome clean. Field-report Issue 3. --- packages/analysis/src/api-impact.ts | 181 ++++++++++++++++++ packages/analysis/src/index.ts | 8 + packages/analysis/src/owners.ts | 53 +++++ packages/analysis/src/route-map.ts | 104 ++++++++++ packages/analysis/src/shape.ts | 26 +++ packages/cli/src/commands/api-impact.test.ts | 129 +++++++++++++ packages/cli/src/commands/api-impact.ts | 61 ++++++ packages/cli/src/commands/dead-code.test.ts | 141 ++++++++++++++ packages/cli/src/commands/dead-code.ts | 84 ++++++++ .../cli/src/commands/dependencies.test.ts | 114 +++++++++++ packages/cli/src/commands/dependencies.ts | 85 ++++++++ packages/cli/src/commands/findings.test.ts | 132 +++++++++++++ packages/cli/src/commands/findings.ts | 111 +++++++++++ .../cli/src/commands/license-audit.test.ts | 94 +++++++++ packages/cli/src/commands/license-audit.ts | 91 +++++++++ packages/cli/src/commands/owners.test.ts | 137 +++++++++++++ packages/cli/src/commands/owners.ts | 53 +++++ .../cli/src/commands/project-profile.test.ts | 103 ++++++++++ packages/cli/src/commands/project-profile.ts | 90 +++++++++ packages/cli/src/commands/risk-trends.test.ts | 76 ++++++++ packages/cli/src/commands/risk-trends.ts | 86 +++++++++ packages/cli/src/commands/route-map.test.ts | 132 +++++++++++++ packages/cli/src/commands/route-map.ts | 58 ++++++ packages/cli/src/index.ts | 168 ++++++++++++++++ packages/mcp/src/tool-handlers.test.ts | 10 +- packages/mcp/src/tools/api-impact.ts | 161 +--------------- packages/mcp/src/tools/owners.ts | 34 +--- packages/mcp/src/tools/route-map.ts | 74 +------ packages/mcp/src/tools/shape-check.ts | 24 +-- 29 files changed, 2349 insertions(+), 271 deletions(-) create mode 100644 packages/analysis/src/api-impact.ts create mode 100644 packages/analysis/src/owners.ts create mode 100644 packages/analysis/src/route-map.ts create mode 100644 packages/analysis/src/shape.ts create mode 100644 packages/cli/src/commands/api-impact.test.ts create mode 100644 packages/cli/src/commands/api-impact.ts create mode 100644 packages/cli/src/commands/dead-code.test.ts create mode 100644 packages/cli/src/commands/dead-code.ts create mode 100644 packages/cli/src/commands/dependencies.test.ts create mode 100644 packages/cli/src/commands/dependencies.ts create mode 100644 packages/cli/src/commands/findings.test.ts create mode 100644 packages/cli/src/commands/findings.ts create mode 100644 packages/cli/src/commands/license-audit.test.ts create mode 100644 packages/cli/src/commands/license-audit.ts create mode 100644 packages/cli/src/commands/owners.test.ts create mode 100644 packages/cli/src/commands/owners.ts create mode 100644 packages/cli/src/commands/project-profile.test.ts create mode 100644 packages/cli/src/commands/project-profile.ts create mode 100644 packages/cli/src/commands/risk-trends.test.ts create mode 100644 packages/cli/src/commands/risk-trends.ts create mode 100644 packages/cli/src/commands/route-map.test.ts create mode 100644 packages/cli/src/commands/route-map.ts diff --git a/packages/analysis/src/api-impact.ts b/packages/analysis/src/api-impact.ts new file mode 100644 index 00000000..21fe319e --- /dev/null +++ b/packages/analysis/src/api-impact.ts @@ -0,0 +1,181 @@ +/** + * `listApiImpact` — score the blast radius of changing a Route's contract. + * + * For every Route matching the filter (`route` substring, or `file` + * substring against Route.filePath) we compute: + * - consumers = files with outgoing FETCHES → this Route. + * - middleware = handlers reached via HANDLES_ROUTE (typically + * File ids; Operation ids when the OpenAPI + * phase linked a spec). + * - mismatches = consumer files whose accessed keys are not a + * subset of Route.responseKeys (delegated to + * `classifyShape`). + * - affectedProcesses = Process nodes whose PROCESS_STEP edges walk + * through any of the consumer symbols. + * + * Risk banding (deterministic): + * LOW — 0 consumers and 0 mismatches. + * MEDIUM — 1-4 consumers, 0 mismatches. + * HIGH — 5-19 consumers OR any mismatch. + * CRITICAL — ≥ 20 consumers. + * + * Lifted verbatim from the MCP `api_impact` tool so the MCP surface and the + * `codehub api-impact` CLI command share one impl. Reuses the + * already-exported {@link RiskLevel} union rather than introducing a second + * `Risk` name. + */ + +import type { GraphNode, RouteNode } from "@opencodehub/core-types"; +import type { IGraphStore } from "@opencodehub/storage"; +import { classifyShape } from "./shape.js"; +import type { RiskLevel } from "./types.js"; + +export interface ApiImpactRow { + readonly route: { + readonly id: string; + readonly url: string; + readonly method: string; + readonly filePath: string; + }; + readonly risk: RiskLevel; + readonly consumers: readonly string[]; + readonly middleware: readonly string[]; + readonly mismatches: readonly string[]; + readonly affectedProcesses: readonly string[]; +} + +export interface ApiImpactFilter { + readonly route?: string | undefined; + readonly file?: string | undefined; +} + +export async function listApiImpact( + graph: IGraphStore, + filter: ApiImpactFilter = {}, +): Promise { + const opts: { pathLike?: string; limit?: number } = { limit: 500 }; + if (filter.route !== undefined && filter.route.length > 0) opts.pathLike = filter.route; + let routes: readonly RouteNode[] = await graph.listRoutes(opts); + if (filter.file !== undefined && filter.file.length > 0) { + const sub = filter.file; + routes = routes.filter((r) => r.filePath.includes(sub)); + } + const sorted = [...routes].sort((a, b) => { + if (a.url !== b.url) return a.url < b.url ? -1 : 1; + const am = a.method ?? ""; + const bm = b.method ?? ""; + return am < bm ? -1 : am > bm ? 1 : 0; + }); + + const out: ApiImpactRow[] = []; + for (const r of sorted) { + const responseKeys = r.responseKeys ?? []; + + const [consumerSymbolIds, handlers] = await Promise.all([ + fetchFromIds(graph, r.id, "FETCHES"), + fetchFromIds(graph, r.id, "HANDLES_ROUTE"), + ]); + + const consumerFiles = await resolveFiles(graph, consumerSymbolIds); + + const mismatches: string[] = []; + for (const file of consumerFiles) { + const accessedKeys = await collectAccessedKeys(graph, file); + const { status } = classifyShape(accessedKeys, responseKeys); + if (status === "MISMATCH") mismatches.push(file); + } + + const affectedProcesses = await fetchAffectedProcesses(graph, consumerSymbolIds); + + const risk = scoreRisk(consumerFiles.length, mismatches.length); + out.push({ + route: { id: r.id, url: r.url, method: r.method ?? "", filePath: r.filePath }, + risk, + consumers: consumerFiles, + middleware: handlers, + mismatches, + affectedProcesses, + }); + } + return out; +} + +export function scoreRisk(consumers: number, mismatches: number): RiskLevel { + if (consumers >= 20) return "CRITICAL"; + if (consumers >= 5 || mismatches > 0) return "HIGH"; + if (consumers >= 1) return "MEDIUM"; + return "LOW"; +} + +export function worseRisk(a: RiskLevel, b: RiskLevel): RiskLevel { + const order: Record = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3 }; + return order[a] >= order[b] ? a : b; +} + +async function fetchFromIds( + graph: IGraphStore, + targetId: string, + type: "FETCHES" | "HANDLES_ROUTE", +): Promise { + const edges = await graph.listEdgesByType(type, { toIds: [targetId] }); + return edges + .map((e) => e.from) + .filter((s) => s.length > 0) + .sort(); +} + +async function resolveFiles( + graph: IGraphStore, + nodeIds: readonly string[], +): Promise { + if (nodeIds.length === 0) return []; + const partners = await graph.listNodes({ ids: [...nodeIds] }); + const set = new Set(); + for (const n of partners) { + if (n.filePath && n.filePath.length > 0) set.add(n.filePath); + } + return Array.from(set).sort(); +} + +async function collectAccessedKeys(graph: IGraphStore, file: string): Promise { + const edges = await graph.listEdgesByType("ACCESSES"); + if (edges.length === 0) return []; + const allIds = new Set(); + for (const e of edges) { + allIds.add(e.from); + allIds.add(e.to); + } + const allNodes = await graph.listNodes({ ids: [...allIds] }); + const byId = new Map(); + for (const n of allNodes) byId.set(n.id, n); + const names = new Set(); + for (const e of edges) { + const src = byId.get(e.from); + if (!src || src.filePath !== file) continue; + const target = byId.get(e.to); + if (!target || target.kind !== "Property") continue; + if (target.name && target.name.length > 0) names.add(target.name); + } + return Array.from(names).sort(); +} + +async function fetchAffectedProcesses( + graph: IGraphStore, + consumerSymbolIds: readonly string[], +): Promise { + if (consumerSymbolIds.length === 0) return []; + const targetSet = new Set(consumerSymbolIds); + const edges = await graph.listEdgesByType("PROCESS_STEP"); + const procIds = new Set(); + for (const e of edges) { + if (!targetSet.has(e.to)) continue; + procIds.add(e.from); + } + if (procIds.size === 0) return []; + const partners = await graph.listNodes({ ids: [...procIds] }); + const out: string[] = []; + for (const n of partners) { + if (n.kind === "Process") out.push(n.id); + } + return out.sort(); +} diff --git a/packages/analysis/src/index.ts b/packages/analysis/src/index.ts index 1af2f373..5dee00d9 100644 --- a/packages/analysis/src/index.ts +++ b/packages/analysis/src/index.ts @@ -15,6 +15,8 @@ export type { } from "@opencodehub/wiki"; /** @deprecated Use `@opencodehub/wiki`. */ export { generateWiki } from "@opencodehub/wiki"; +export type { ApiImpactFilter, ApiImpactRow } from "./api-impact.js"; +export { listApiImpact, scoreRisk, worseRisk } from "./api-impact.js"; export type { DeadCodeResult, Deadness, @@ -83,6 +85,8 @@ export type { LicenseTier, } from "./license-classify.js"; export { classifyDependencies } from "./license-classify.js"; +export type { OwnerRow } from "./owners.js"; +export { listOwners } from "./owners.js"; export type { Adjacency, EdgeLike } from "./page-rank.js"; export { buildAdjacency, pageRank } from "./page-rank.js"; export { runRename } from "./rename.js"; @@ -112,6 +116,10 @@ export { SNAPSHOT_RETENTION, snapshotFilename, } from "./risk-snapshot.js"; +export type { RouteMapFilter, RouteMapRow } from "./route-map.js"; +export { listRouteMap } from "./route-map.js"; +export type { ShapeStatus } from "./shape.js"; +export { classifyShape } from "./shape.js"; export { computeStaleness } from "./staleness.js"; export type { AffectedModule, diff --git a/packages/analysis/src/owners.ts b/packages/analysis/src/owners.ts new file mode 100644 index 00000000..9978b19c --- /dev/null +++ b/packages/analysis/src/owners.ts @@ -0,0 +1,53 @@ +/** + * `listOwners` — ranked `OWNED_BY` contributors for a graph node. + * + * Walks the outgoing `OWNED_BY` edges from a File / Symbol / Community + * node in confidence-descending order (with a `.to` ASC tiebreak), slices + * to `limit` BEFORE the Contributor join, then joins each surviving edge + * to its Contributor node for display metadata (name + email/hash). + * + * Lifted verbatim from the MCP `owners` tool so the MCP surface and the + * `codehub owners` CLI command share one impl. The slice-before-join order + * is load-bearing — it is preserved exactly. + */ + +import type { IGraphStore } from "@opencodehub/storage"; + +export interface OwnerRow { + readonly email: string; + readonly emailHash: string; + readonly name: string; + readonly weight: number; +} + +export async function listOwners( + graph: IGraphStore, + target: string, + limit: number, +): Promise { + const ownedBy = await graph.listEdgesByType("OWNED_BY", { fromIds: [target] }); + const sorted = [...ownedBy].sort((a, b) => { + const ac = a.confidence ?? 0; + const bc = b.confidence ?? 0; + if (ac !== bc) return bc - ac; + return a.to < b.to ? -1 : a.to > b.to ? 1 : 0; + }); + const sliced = sorted.slice(0, limit); + const contributors = await graph.listNodesByKind("Contributor"); + const contribById = new Map(); + for (const c of contributors) contribById.set(c.id, c); + + const owners: OwnerRow[] = []; + for (const edge of sliced) { + const c = contribById.get(edge.to); + if (c === undefined) continue; + const plain = typeof c.emailPlain === "string" ? c.emailPlain : ""; + owners.push({ + email: plain, + emailHash: c.emailHash, + name: c.name, + weight: edge.confidence ?? 0, + }); + } + return owners; +} diff --git a/packages/analysis/src/route-map.ts b/packages/analysis/src/route-map.ts new file mode 100644 index 00000000..65d3ce36 --- /dev/null +++ b/packages/analysis/src/route-map.ts @@ -0,0 +1,104 @@ +/** + * `listRouteMap` — enumerate HTTP `Route` nodes with handlers + consumers. + * + * One row per Route, filtered by optional route URL substring / method. + * For every row we also pull: + * - handlers = ids of nodes pointing at the route via HANDLES_ROUTE + * (typically Files for framework routes, Operations for + * OpenAPI specs). + * - consumers = ids of nodes pointing at the route via FETCHES (the + * `from_id` side — the symbol doing the outbound call). + * - responseKeys = the TEXT[] response-shape keys populated by the + * `routes` phase when static detection identified the + * response body. + * + * Lifted verbatim from the MCP `route_map` tool so the MCP surface and the + * `codehub route-map` CLI command share one impl. The two-stage method + * handling (push a typed method into `listRoutes` when it is one of the five + * known verbs, else a TS post-filter) and the `listRoutes` limit:500 cap are + * preserved exactly. + */ + +import type { IGraphStore } from "@opencodehub/storage"; + +export interface RouteMapRow { + readonly id: string; + readonly url: string; + readonly method: string; + readonly filePath: string; + readonly responseKeys: readonly string[]; + readonly handlers: readonly string[]; + readonly consumers: readonly string[]; +} + +export interface RouteMapFilter { + readonly route?: string | undefined; + readonly method?: string | undefined; +} + +export async function listRouteMap( + graph: IGraphStore, + filter: RouteMapFilter = {}, +): Promise { + const opts: { + pathLike?: string; + methods?: readonly ("GET" | "POST" | "PUT" | "DELETE" | "PATCH")[]; + limit?: number; + } = { limit: 500 }; + if (filter.route !== undefined && filter.route.length > 0) opts.pathLike = filter.route; + if ( + filter.method !== undefined && + ["GET", "POST", "PUT", "DELETE", "PATCH"].includes(filter.method) + ) { + opts.methods = [filter.method as "GET" | "POST" | "PUT" | "DELETE" | "PATCH"]; + } + let listed = await graph.listRoutes(opts); + if ( + filter.method !== undefined && + !["GET", "POST", "PUT", "DELETE", "PATCH"].includes(filter.method) + ) { + listed = listed.filter((r) => r.method === filter.method); + } + const sortedRoutes = [...listed].sort((a, b) => { + if (a.url !== b.url) return a.url < b.url ? -1 : 1; + const am = a.method ?? ""; + const bm = b.method ?? ""; + return am < bm ? -1 : am > bm ? 1 : 0; + }); + + const routes: RouteMapRow[] = []; + for (const r of sortedRoutes) { + const [handlers, consumers] = await Promise.all([ + fetchRelationFromIds(graph, r.id, "HANDLES_ROUTE"), + fetchRelationFromIds(graph, r.id, "FETCHES"), + ]); + routes.push({ + id: r.id, + url: stringOr(r.url, ""), + method: stringOr(r.method, ""), + filePath: stringOr(r.filePath, ""), + responseKeys: r.responseKeys ?? [], + handlers, + consumers, + }); + } + return routes; +} + +async function fetchRelationFromIds( + graph: IGraphStore, + routeId: string, + type: "HANDLES_ROUTE" | "FETCHES", +): Promise { + const edges = await graph.listEdgesByType(type, { toIds: [routeId] }); + return edges + .map((e) => e.from) + .filter((s) => s.length > 0) + .sort(); +} + +function stringOr(v: unknown, fallback: string): string { + if (typeof v === "string") return v; + if (typeof v === "number" || typeof v === "boolean") return String(v); + return fallback; +} diff --git a/packages/analysis/src/shape.ts b/packages/analysis/src/shape.ts new file mode 100644 index 00000000..f9bcc22f --- /dev/null +++ b/packages/analysis/src/shape.ts @@ -0,0 +1,26 @@ +/** + * Route response-shape classification. + * + * `classifyShape` compares the property names a consumer file actually + * reads off a response against the Route's statically-detected + * `responseKeys`. Lifted verbatim from the MCP `shape_check` tool so both + * the MCP surface and the `api-impact` analysis fn share one impl. + * + * - MATCH — every accessed key is in responseKeys. + * - MISMATCH — at least one accessed key is NOT in responseKeys. + * - PARTIAL — no accessed keys found (can't check). + */ + +export type ShapeStatus = "MATCH" | "MISMATCH" | "PARTIAL"; + +/** Classify a set of accessed keys against responseKeys. */ +export function classifyShape( + accessedKeys: readonly string[], + responseKeys: readonly string[], +): { status: ShapeStatus; missing: readonly string[] } { + if (accessedKeys.length === 0) return { status: "PARTIAL", missing: [] }; + const known = new Set(responseKeys); + const missing = accessedKeys.filter((k) => !known.has(k)); + if (missing.length === 0) return { status: "MATCH", missing: [] }; + return { status: "MISMATCH", missing }; +} diff --git a/packages/cli/src/commands/api-impact.test.ts b/packages/cli/src/commands/api-impact.test.ts new file mode 100644 index 00000000..65eb875b --- /dev/null +++ b/packages/cli/src/commands/api-impact.test.ts @@ -0,0 +1,129 @@ +/** + * Tests for `codehub api-impact` CLI command. + * + * The command calls the shared `listApiImpact` fn from + * `@opencodehub/analysis` (the same impl the MCP `api_impact` tool uses). The + * fake graph supplies a Route, a FETCHES consumer symbol, and that symbol's + * file so the consumer count + risk band are exercised end-to-end. + * + * Covers: + * - A single consumer with no shape mismatch → risk=MEDIUM in JSON. + * - The `highestRisk` aggregate reflects the worst route. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { + CodeRelation, + GraphNode, + NodeId, + RelationType, + RouteNode, +} from "@opencodehub/core-types"; +import type { IGraphStore, ITemporalStore, ListNodesOptions, Store } from "@opencodehub/storage"; +import { runApiImpact } from "./api-impact.js"; + +function route( + over: Omit, "id" | "url"> & { id: string; url: string }, +): RouteNode { + return { + kind: "Route", + method: "GET", + filePath: "src/routes.ts", + responseKeys: [], + ...over, + } as unknown as RouteNode; +} + +function edge(from: string, to: string, type: RelationType): CodeRelation { + return { from: from as NodeId, to: to as NodeId, type, confidence: 1 } as CodeRelation; +} + +function node(id: string, kind: string, filePath: string, name = ""): GraphNode { + return { id: id as NodeId, kind, name, filePath } as unknown as GraphNode; +} + +function makeFakeStore( + routes: readonly RouteNode[], + edges: readonly CodeRelation[], + nodes: readonly GraphNode[], +): { store: Store; closed: () => boolean } { + let closed = false; + const graph: Partial = { + listRoutes: async (opts) => { + let out = [...routes]; + if (opts?.pathLike !== undefined) + out = out.filter((r) => r.url.includes(opts.pathLike as string)); + return out; + }, + listEdgesByType: async (type, opts) => { + const to = opts?.toIds?.[0]; + return edges.filter((e) => e.type === type && (to === undefined || e.to === to)); + }, + listNodes: async (opts: ListNodesOptions = {}) => { + if (opts.ids === undefined) return nodes; + const ids = new Set(opts.ids.map(String)); + return nodes.filter((n) => ids.has(n.id)); + }, + }; + const store = { + graph: graph as unknown as IGraphStore, + temporal: {} as unknown as ITemporalStore, + graphFile: "/tmp/fake.lbug", + temporalFile: "/tmp/fake.duckdb", + close: async () => { + closed = true; + }, + } as Store; + return { store, closed: () => closed }; +} + +async function captureStdout(fn: () => Promise): Promise { + const orig = console.log; + const chunks: string[] = []; + console.log = (...args: unknown[]) => { + chunks.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")); + }; + try { + await fn(); + } finally { + console.log = orig; + } + return chunks.join("\n"); +} + +test("api-impact --json scores a single-consumer route as MEDIUM", async () => { + const { store, closed } = makeFakeStore( + [route({ id: "Route:GET:/users", url: "/users" })], + [edge("Function:caller", "Route:GET:/users", "FETCHES")], + [node("Function:caller", "Function", "src/caller.ts", "caller")], + ); + const out = await captureStdout(async () => { + await runApiImpact({ + json: true, + storeFactory: async () => ({ store, repoPath: "/tmp/r" }), + }); + }); + const parsed = JSON.parse(out) as { + routes: Array<{ risk: string; consumers: string[] }>; + highestRisk: string; + }; + assert.equal(parsed.routes.length, 1); + assert.equal(parsed.routes[0]?.risk, "MEDIUM"); + assert.deepEqual(parsed.routes[0]?.consumers, ["src/caller.ts"]); + assert.equal(parsed.highestRisk, "MEDIUM"); + assert.ok(closed(), "store must be closed"); +}); + +test("api-impact --json reports LOW for a route with no consumers", async () => { + const { store } = makeFakeStore([route({ id: "Route:GET:/health", url: "/health" })], [], []); + const out = await captureStdout(async () => { + await runApiImpact({ + json: true, + storeFactory: async () => ({ store, repoPath: "/tmp/r" }), + }); + }); + const parsed = JSON.parse(out) as { routes: Array<{ risk: string }>; highestRisk: string }; + assert.equal(parsed.routes[0]?.risk, "LOW"); + assert.equal(parsed.highestRisk, "LOW"); +}); diff --git a/packages/cli/src/commands/api-impact.ts b/packages/cli/src/commands/api-impact.ts new file mode 100644 index 00000000..c4235cce --- /dev/null +++ b/packages/cli/src/commands/api-impact.ts @@ -0,0 +1,61 @@ +/** + * `codehub api-impact` — score the blast radius of changing a Route's + * contract. + * + * CLI sibling of the MCP `api_impact` tool. Both surfaces call the shared + * `listApiImpact` fn from `@opencodehub/analysis`, which scores each matching + * Route (LOW / MEDIUM / HIGH / CRITICAL) by consumer count + shape + * mismatches and surfaces the affected Process flows. + * + * Mirrors `packages/mcp/src/tools/api-impact.ts`. Does NOT emit the MCP + * next_steps / staleness envelope. + */ + +import { listApiImpact, type RiskLevel, worseRisk } from "@opencodehub/analysis"; +import type { Store } from "@opencodehub/storage"; +import { openStoreForCommand } from "./open-store.js"; + +export interface ApiImpactOptions { + readonly repo?: string; + readonly home?: string; + readonly json?: boolean; + readonly route?: string; + readonly file?: string; + /** Test seam — inject a fake store. Production leaves this unset. */ + readonly storeFactory?: () => Promise<{ store: Store; repoPath: string }>; +} + +export async function runApiImpact(opts: ApiImpactOptions = {}): Promise { + const factory = opts.storeFactory ?? (() => openStoreForCommand({ ...opts, readOnly: true })); + const { store } = await factory(); + try { + const rows = await listApiImpact(store.graph, { + ...(opts.route !== undefined ? { route: opts.route } : {}), + ...(opts.file !== undefined ? { file: opts.file } : {}), + }); + + const highest = rows.reduce((acc, r) => worseRisk(acc, r.risk), "LOW"); + + if (opts.json) { + console.log(JSON.stringify({ routes: rows, highestRisk: highest }, null, 2)); + return; + } + + console.warn( + `api-impact: ${rows.length} route(s)${opts.route ? ` · url~${opts.route}` : ""}${ + opts.file ? ` · filePath~${opts.file}` : "" + } · highest=${highest}:`, + ); + if (rows.length === 0) { + console.log("(no routes matched — check the filter or re-index with `codehub analyze`)"); + return; + } + for (const r of rows) { + console.log( + `[${r.risk}] ${r.route.method} ${r.route.url} consumers=${r.consumers.length} mismatches=${r.mismatches.length} processes=${r.affectedProcesses.length}`, + ); + } + } finally { + await store.close(); + } +} diff --git a/packages/cli/src/commands/dead-code.test.ts b/packages/cli/src/commands/dead-code.test.ts new file mode 100644 index 00000000..76c4a42b --- /dev/null +++ b/packages/cli/src/commands/dead-code.test.ts @@ -0,0 +1,141 @@ +/** + * Tests for `codehub dead-code` CLI command. + * + * The command reuses `classifyDeadness` from `@opencodehub/analysis`. The + * fake graph implements just the readers `classifyDeadness` calls + * (`listNodes`, `listEdges`, `listEdgesByType`) over an in-memory fixture, so + * the test drives the real classifier rather than stubbing it. + * + * Covers: + * - A non-exported symbol with no referrers classifies dead and renders. + * - `--file-path-pattern` filters the dead set. + * - `--include-unreachable-exports` toggles the unreachable-export set in. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { GraphNode, NodeId, NodeKind } from "@opencodehub/core-types"; +import type { IGraphStore, ITemporalStore, Store } from "@opencodehub/storage"; +import { runDeadCode } from "./dead-code.js"; + +interface FakeSym { + readonly id: string; + readonly name: string; + readonly filePath: string; + readonly isExported?: boolean; + readonly startLine?: number; +} + +function makeFakeStore(syms: readonly FakeSym[]): { store: Store; closed: () => boolean } { + let closed = false; + const toNode = (s: FakeSym): GraphNode => + ({ + id: s.id as NodeId, + kind: "Function" as NodeKind, + name: s.name, + filePath: s.filePath, + isExported: s.isExported === true, + startLine: s.startLine ?? 1, + }) as unknown as GraphNode; + + const graph: Partial = { + listNodes: async (opts) => { + if (opts?.ids !== undefined) { + const ids = new Set(opts.ids.map(String)); + return syms.filter((s) => ids.has(s.id)).map(toNode); + } + // kinds-filtered fetch (Function is in SYMBOL_KINDS). + return syms.map(toNode); + }, + listEdges: async () => [], + listEdgesByType: async () => [], + }; + const store = { + graph: graph as unknown as IGraphStore, + temporal: {} as unknown as ITemporalStore, + graphFile: "/tmp/fake.lbug", + temporalFile: "/tmp/fake.duckdb", + close: async () => { + closed = true; + }, + } as Store; + return { store, closed: () => closed }; +} + +async function captureStdout(fn: () => Promise): Promise { + const orig = console.log; + const chunks: string[] = []; + console.log = (...args: unknown[]) => { + chunks.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")); + }; + try { + await fn(); + } finally { + console.log = orig; + } + return chunks.join("\n"); +} + +test("dead-code --json reports a dead non-exported symbol", async () => { + const { store, closed } = makeFakeStore([ + { id: "Function:src/a.ts:dead", name: "dead", filePath: "src/a.ts", isExported: false }, + ]); + const out = await captureStdout(async () => { + await runDeadCode({ json: true, storeFactory: async () => ({ store, repoPath: "/tmp/r" }) }); + }); + const parsed = JSON.parse(out) as { + summary: { dead: number }; + symbols: Array<{ name: string; deadness: string }>; + }; + assert.equal(parsed.summary.dead, 1); + assert.equal(parsed.symbols[0]?.name, "dead"); + assert.equal(parsed.symbols[0]?.deadness, "dead"); + assert.ok(closed(), "store must be closed"); +}); + +test("dead-code --file-path-pattern narrows the dead set", async () => { + const { store } = makeFakeStore([ + { id: "Function:src/keep.ts:a", name: "a", filePath: "src/keep.ts" }, + { id: "Function:src/drop.ts:b", name: "b", filePath: "src/drop.ts" }, + ]); + const out = await captureStdout(async () => { + await runDeadCode({ + json: true, + filePathPattern: "keep", + storeFactory: async () => ({ store, repoPath: "/tmp/r" }), + }); + }); + const parsed = JSON.parse(out) as { symbols: Array<{ filePath: string }> }; + assert.equal(parsed.symbols.length, 1); + assert.equal(parsed.symbols[0]?.filePath, "src/keep.ts"); +}); + +test("dead-code --include-unreachable-exports folds the export set in", async () => { + const { store } = makeFakeStore([ + // exported, no cross-module referrer → unreachable-export. + { id: "Function:src/x.ts:exp", name: "exp", filePath: "src/x.ts", isExported: true }, + ]); + const withoutFlag = await captureStdout(async () => { + await runDeadCode({ json: true, storeFactory: async () => ({ store, repoPath: "/tmp/r" }) }); + }); + const a = JSON.parse(withoutFlag) as { + symbols: unknown[]; + summary: { unreachableExports: number }; + }; + assert.equal(a.symbols.length, 0, "default excludes unreachable exports from symbols"); + assert.equal(a.summary.unreachableExports, 1); + + const { store: store2 } = makeFakeStore([ + { id: "Function:src/x.ts:exp", name: "exp", filePath: "src/x.ts", isExported: true }, + ]); + const withFlag = await captureStdout(async () => { + await runDeadCode({ + json: true, + includeUnreachableExports: true, + storeFactory: async () => ({ store: store2, repoPath: "/tmp/r" }), + }); + }); + const b = JSON.parse(withFlag) as { symbols: Array<{ deadness: string }> }; + assert.equal(b.symbols.length, 1); + assert.equal(b.symbols[0]?.deadness, "unreachable-export"); +}); diff --git a/packages/cli/src/commands/dead-code.ts b/packages/cli/src/commands/dead-code.ts new file mode 100644 index 00000000..aee12541 --- /dev/null +++ b/packages/cli/src/commands/dead-code.ts @@ -0,0 +1,84 @@ +/** + * `codehub dead-code` — enumerate dead and unreachable-export symbols. + * + * CLI sibling of the MCP `list_dead_code` tool. Reuses `classifyDeadness` + * from `@opencodehub/analysis`, then applies the same file-path-substring + * filter, `includeUnreachableExports` toggle, and `limit` slice. + * + * Mirrors `packages/mcp/src/tools/list-dead-code.ts`. Does NOT emit the MCP + * next_steps / staleness envelope. + */ + +import { classifyDeadness, type DeadSymbol } from "@opencodehub/analysis"; +import type { Store } from "@opencodehub/storage"; +import { openStoreForCommand } from "./open-store.js"; + +export interface DeadCodeOptions { + readonly repo?: string; + readonly home?: string; + readonly json?: boolean; + readonly filePathPattern?: string; + readonly includeUnreachableExports?: boolean; + readonly limit?: number; + /** Test seam — inject a fake store. Production leaves this unset. */ + readonly storeFactory?: () => Promise<{ store: Store; repoPath: string }>; +} + +export async function runDeadCode(opts: DeadCodeOptions = {}): Promise { + const limit = opts.limit ?? 100; + const includeUnreachable = opts.includeUnreachableExports ?? false; + const pattern = opts.filePathPattern; + const factory = opts.storeFactory ?? (() => openStoreForCommand({ ...opts, readOnly: true })); + const { store } = await factory(); + try { + const result = await classifyDeadness(store.graph); + + const filterByPath = (s: DeadSymbol): boolean => + pattern === undefined || s.filePath.includes(pattern); + + const dead = result.dead.filter(filterByPath); + const unreachable = result.unreachableExports.filter(filterByPath); + + const combined: DeadSymbol[] = includeUnreachable ? [...dead, ...unreachable] : [...dead]; + const truncated = combined.slice(0, limit); + + const summary = { + dead: result.dead.length, + unreachableExports: result.unreachableExports.length, + ghostCommunities: result.ghostCommunities.length, + }; + + if (opts.json) { + console.log( + JSON.stringify( + { summary, symbols: truncated, ghostCommunities: [...result.ghostCommunities] }, + null, + 2, + ), + ); + return; + } + + console.warn( + `dead-code: ${summary.dead} dead · ${summary.unreachableExports} unreachable exports · ${summary.ghostCommunities} ghost communities.`, + ); + if (truncated.length === 0) { + console.log("(no non-live symbols match the filter)"); + } else { + console.log( + `Showing ${truncated.length} of ${combined.length}${pattern ? ` · filePath~${pattern}` : ""}:`, + ); + for (const s of truncated) { + console.log(` • [${s.deadness}] ${s.name} [${s.kind}] — ${s.filePath}:${s.startLine}`); + } + } + if (result.ghostCommunities.length > 0) { + console.log(`Ghost communities (${result.ghostCommunities.length}):`); + for (const c of result.ghostCommunities.slice(0, 20)) { + console.log(` ⊿ ${c}`); + } + } + } finally { + await store.close(); + } +} diff --git a/packages/cli/src/commands/dependencies.test.ts b/packages/cli/src/commands/dependencies.test.ts new file mode 100644 index 00000000..d7829f9d --- /dev/null +++ b/packages/cli/src/commands/dependencies.test.ts @@ -0,0 +1,114 @@ +/** + * Tests for `codehub dependencies` CLI command. + * + * The command reads `store.graph.listDependencies(opts)` and applies a TS + * `filePath` substring post-filter, mirroring the MCP `dependencies` tool. + * + * Covers: + * - JSON mode emits a `{ dependencies, total }` payload. + * - The ecosystem flag is forwarded to the storage reader. + * - The `filePath` substring narrows the result post-finder. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { DependencyNode } from "@opencodehub/core-types"; +import type { + IGraphStore, + ITemporalStore, + ListDependenciesOptions, + Store, +} from "@opencodehub/storage"; +import { runDependencies } from "./dependencies.js"; + +function dep( + over: Omit, "id" | "name"> & { id: string; name: string }, +): DependencyNode { + return { + kind: "Dependency", + version: "1.0.0", + ecosystem: "npm", + lockfileSource: "package-lock.json", + license: "MIT", + filePath: "package-lock.json", + ...over, + } as unknown as DependencyNode; +} + +interface FakeHandle { + closed: boolean; + lastOpts?: ListDependenciesOptions; + store: Store; +} + +function makeFakeStore(deps: readonly DependencyNode[]): FakeHandle { + const handle: FakeHandle = { closed: false, store: {} as Store }; + const graph: Partial = { + listDependencies: async (opts: ListDependenciesOptions = {}) => { + handle.lastOpts = opts; + let out = [...deps]; + if (opts.ecosystem !== undefined) out = out.filter((d) => d.ecosystem === opts.ecosystem); + return out; + }, + }; + handle.store = { + graph: graph as unknown as IGraphStore, + temporal: {} as unknown as ITemporalStore, + graphFile: "/tmp/fake.lbug", + temporalFile: "/tmp/fake.duckdb", + close: async () => { + handle.closed = true; + }, + } as Store; + return handle; +} + +async function captureStdout(fn: () => Promise): Promise { + const orig = console.log; + const chunks: string[] = []; + console.log = (...args: unknown[]) => { + chunks.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")); + }; + try { + await fn(); + } finally { + console.log = orig; + } + return chunks.join("\n"); +} + +test("dependencies --json emits a dependencies payload and forwards ecosystem", async () => { + const handle = makeFakeStore([ + dep({ id: "d1", name: "a", ecosystem: "npm" }), + dep({ id: "d2", name: "b", ecosystem: "pypi" }), + ]); + const out = await captureStdout(async () => { + await runDependencies({ + json: true, + ecosystem: "npm", + storeFactory: async () => ({ store: handle.store, repoPath: "/tmp/r" }), + }); + }); + assert.equal(handle.lastOpts?.ecosystem, "npm"); + const parsed = JSON.parse(out) as { dependencies: Array<{ name: string }>; total: number }; + assert.equal(parsed.total, 1); + assert.equal(parsed.dependencies[0]?.name, "a"); + assert.ok(handle.closed, "store must be closed"); +}); + +test("dependencies --file-path narrows the result post-finder", async () => { + const handle = makeFakeStore([ + dep({ id: "d1", name: "a", lockfileSource: "apps/web/package-lock.json" }), + dep({ id: "d2", name: "b", lockfileSource: "apps/api/package-lock.json" }), + ]); + const out = await captureStdout(async () => { + await runDependencies({ + json: true, + filePath: "apps/web", + storeFactory: async () => ({ store: handle.store, repoPath: "/tmp/r" }), + }); + }); + const parsed = JSON.parse(out) as { dependencies: Array<{ name: string }>; total: number }; + assert.equal(parsed.total, 1); + assert.equal(parsed.dependencies[0]?.name, "a"); +}); diff --git a/packages/cli/src/commands/dependencies.ts b/packages/cli/src/commands/dependencies.ts new file mode 100644 index 00000000..a109938f --- /dev/null +++ b/packages/cli/src/commands/dependencies.ts @@ -0,0 +1,85 @@ +/** + * `codehub dependencies` — enumerate Dependency nodes for an indexed repo. + * + * CLI sibling of the MCP `dependencies` tool and of `license-audit`. Reads + * `store.graph.listDependencies()` (optionally narrowed by ecosystem) and + * applies the same `filePath` substring post-filter, then renders a table. + * + * Mirrors `packages/mcp/src/tools/dependencies.ts`. Does NOT emit the MCP + * next_steps / staleness envelope. + */ + +import type { Store } from "@opencodehub/storage"; +import { openStoreForCommand } from "./open-store.js"; + +export interface DependenciesOptions { + readonly repo?: string; + readonly home?: string; + readonly json?: boolean; + readonly filePath?: string; + readonly ecosystem?: "npm" | "pypi" | "go" | "cargo" | "maven" | "nuget"; + readonly limit?: number; + /** Test seam — inject a fake store. Production leaves this unset. */ + readonly storeFactory?: () => Promise<{ store: Store; repoPath: string }>; +} + +interface DependencyRow { + readonly id: string; + readonly name: string; + readonly version: string; + readonly ecosystem: string; + readonly license: string; + readonly lockfileSource: string; +} + +export async function runDependencies(opts: DependenciesOptions = {}): Promise { + const limit = opts.limit ?? 500; + const factory = opts.storeFactory ?? (() => openStoreForCommand({ ...opts, readOnly: true })); + const { store } = await factory(); + try { + const listOpts: { ecosystem?: string; limit?: number } = { limit }; + if (opts.ecosystem !== undefined) listOpts.ecosystem = opts.ecosystem; + const all = await store.graph.listDependencies(listOpts); + const filtered = + opts.filePath === undefined + ? all + : all.filter((d) => { + const lf = d.lockfileSource ?? d.filePath; + return lf.includes(opts.filePath as string); + }); + + const rows: DependencyRow[] = filtered.map((d) => ({ + id: d.id, + name: d.name, + version: stringOr(d.version, "UNKNOWN"), + ecosystem: stringOr(d.ecosystem, "unknown"), + license: stringOr(d.license, "UNKNOWN"), + lockfileSource: stringOr(d.lockfileSource, d.filePath), + })); + + if (opts.json) { + console.log(JSON.stringify({ dependencies: rows, total: rows.length }, null, 2)); + return; + } + + if (rows.length === 0) { + console.warn( + "dependencies: no dependencies found — index the repo with `codehub analyze` and verify the `dependencies` phase ran", + ); + return; + } + for (const d of rows) { + console.log( + `[${d.ecosystem}] ${d.name}@${d.version} (${d.lockfileSource}, license=${d.license})`, + ); + } + } finally { + await store.close(); + } +} + +function stringOr(v: unknown, fallback: string): string { + if (typeof v === "string") return v; + if (typeof v === "number" || typeof v === "boolean") return String(v); + return fallback; +} diff --git a/packages/cli/src/commands/findings.test.ts b/packages/cli/src/commands/findings.test.ts new file mode 100644 index 00000000..e95324d9 --- /dev/null +++ b/packages/cli/src/commands/findings.test.ts @@ -0,0 +1,132 @@ +/** + * Tests for `codehub findings` CLI command. + * + * The command reuses the same storage reader and TS post-finder as the MCP + * `list_findings` tool. The fake store implements `listFindings` over an + * in-memory fixture so the tests stay tied to the production interface. + * + * Covers: + * - JSON mode emits a `{ findings, total }` payload. + * - `severity="none"` is filtered entirely in the TS post-finder (never + * pushed to listFindings) and drops non-`none` rows. + * - `scanner` / `filePath` substring narrowing is applied post-finder. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { FindingNode } from "@opencodehub/core-types"; +import type { IGraphStore, ITemporalStore, ListFindingsOptions, Store } from "@opencodehub/storage"; +import { runFindings } from "./findings.js"; + +function finding(over: Omit, "id"> & { id: string }): FindingNode { + return { + kind: "Finding", + ruleId: "R1", + severity: "warning", + scannerId: "semgrep", + message: "m", + filePath: "src/a.ts", + propertiesBag: {}, + startLine: 1, + ...over, + } as unknown as FindingNode; +} + +interface FakeHandle { + closed: boolean; + lastFindingsOpts?: ListFindingsOptions; + store: Store; +} + +function makeFakeStore(rows: readonly FindingNode[]): FakeHandle { + const handle: FakeHandle = { closed: false, store: {} as Store }; + const graph: Partial = { + listFindings: async (opts: ListFindingsOptions = {}) => { + handle.lastFindingsOpts = opts; + // Mirror the storage tier: narrow by severity / ruleId only. + let out = [...rows]; + if (opts.severity !== undefined) { + const set = new Set(opts.severity); + out = out.filter((f) => set.has(f.severity as "note" | "warning" | "error")); + } + if (opts.ruleId !== undefined) out = out.filter((f) => f.ruleId === opts.ruleId); + return out; + }, + }; + handle.store = { + graph: graph as unknown as IGraphStore, + temporal: {} as unknown as ITemporalStore, + graphFile: "/tmp/fake.lbug", + temporalFile: "/tmp/fake.duckdb", + close: async () => { + handle.closed = true; + }, + } as Store; + return handle; +} + +async function captureStdout(fn: () => Promise): Promise { + const orig = console.log; + const chunks: string[] = []; + console.log = (...args: unknown[]) => { + chunks.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")); + }; + try { + await fn(); + } finally { + console.log = orig; + } + return chunks.join("\n"); +} + +test("findings --json emits a findings payload", async () => { + const handle = makeFakeStore([finding({ id: "x", severity: "error" })]); + const out = await captureStdout(async () => { + await runFindings({ + json: true, + storeFactory: async () => ({ store: handle.store, repoPath: "/tmp/r" }), + }); + }); + const parsed = JSON.parse(out) as { findings: unknown[]; total: number }; + assert.equal(parsed.total, 1); + assert.equal((parsed.findings[0] as { severity: string }).severity, "error"); + assert.ok(handle.closed, "store must be closed"); +}); + +test("findings severity=none filters in TS post-finder, never passed to listFindings", async () => { + const handle = makeFakeStore([ + finding({ id: "a", severity: "none" }), + finding({ id: "b", severity: "warning" }), + ]); + const out = await captureStdout(async () => { + await runFindings({ + severity: "none", + json: true, + storeFactory: async () => ({ store: handle.store, repoPath: "/tmp/r" }), + }); + }); + // `none` must NOT be forwarded to listFindings (which only accepts the trio). + assert.equal(handle.lastFindingsOpts?.severity, undefined); + const parsed = JSON.parse(out) as { findings: Array<{ id: string }>; total: number }; + assert.equal(parsed.total, 1); + assert.equal(parsed.findings[0]?.id, "a"); +}); + +test("findings scanner + filePath narrowing is applied post-finder", async () => { + const handle = makeFakeStore([ + finding({ id: "a", scannerId: "semgrep", filePath: "src/keep.ts" }), + finding({ id: "b", scannerId: "osv-scanner", filePath: "src/keep.ts" }), + finding({ id: "c", scannerId: "semgrep", filePath: "src/drop.ts" }), + ]); + const out = await captureStdout(async () => { + await runFindings({ + scanner: "semgrep", + filePath: "keep", + json: true, + storeFactory: async () => ({ store: handle.store, repoPath: "/tmp/r" }), + }); + }); + const parsed = JSON.parse(out) as { findings: Array<{ id: string }>; total: number }; + assert.equal(parsed.total, 1); + assert.equal(parsed.findings[0]?.id, "a"); +}); diff --git a/packages/cli/src/commands/findings.ts b/packages/cli/src/commands/findings.ts new file mode 100644 index 00000000..4bc19749 --- /dev/null +++ b/packages/cli/src/commands/findings.ts @@ -0,0 +1,111 @@ +/** + * `codehub findings` — enumerate SARIF Finding nodes for an indexed repo. + * + * CLI sibling of the MCP `list_findings` tool. Reuses the same storage + * reader (`store.graph.listFindings`) plus the identical TS post-finder for + * `scanner` / `filePath` substring narrowing and the `severity==="none"` + * filter. Only `note|warning|error` are pushed into `listFindings`; the + * `none` severity is handled entirely in the TS post-finder (both halves — + * we never pass it to the storage tier and we drop rows whose severity is + * not `none` when the caller asked for `none`). + * + * Mirrors `packages/mcp/src/tools/list-findings.ts:runListFindings`. Does NOT + * emit the MCP next_steps / staleness envelope — that is MCP-only. + */ + +import type { Store } from "@opencodehub/storage"; +import { openStoreForCommand } from "./open-store.js"; + +export interface FindingsOptions { + readonly repo?: string; + readonly home?: string; + readonly json?: boolean; + readonly severity?: "error" | "warning" | "note" | "none"; + readonly scanner?: string; + readonly ruleId?: string; + readonly filePath?: string; + readonly limit?: number; + /** Test seam — inject a fake store. Production leaves this unset. */ + readonly storeFactory?: () => Promise<{ store: Store; repoPath: string }>; +} + +interface FindingRow { + readonly id: string; + readonly scanner: string; + readonly ruleId: string; + readonly severity: string; + readonly message: string; + readonly filePath: string; + readonly startLine?: number; + readonly endLine?: number; + readonly properties: Record; +} + +export async function runFindings(opts: FindingsOptions = {}): Promise { + const limit = opts.limit ?? 500; + const factory = opts.storeFactory ?? (() => openStoreForCommand({ ...opts, readOnly: true })); + const { store } = await factory(); + try { + const findingsOpts: { + severity?: readonly ("note" | "warning" | "error")[]; + ruleId?: string; + limit?: number; + } = { limit }; + if ( + opts.severity !== undefined && + (opts.severity === "note" || opts.severity === "warning" || opts.severity === "error") + ) { + findingsOpts.severity = [opts.severity]; + } + if (opts.ruleId !== undefined) findingsOpts.ruleId = opts.ruleId; + const all = await store.graph.listFindings(findingsOpts); + + const filtered = all.filter((f) => { + if (opts.severity === "none" && f.severity !== "none") return false; + if (opts.scanner !== undefined && f.scannerId !== opts.scanner) return false; + if (opts.filePath !== undefined && !f.filePath.includes(opts.filePath)) return false; + return true; + }); + + const rows: FindingRow[] = filtered.map((f) => ({ + id: f.id, + scanner: stringOr(f.scannerId, "unknown"), + ruleId: stringOr(f.ruleId, ""), + severity: stringOr(f.severity, "note"), + message: stringOr(f.message, ""), + filePath: stringOr(f.filePath, ""), + properties: f.propertiesBag, + ...(typeof f.startLine === "number" && Number.isFinite(f.startLine) + ? { startLine: f.startLine } + : {}), + ...(typeof f.endLine === "number" && Number.isFinite(f.endLine) + ? { endLine: f.endLine } + : {}), + })); + + if (opts.json) { + console.log(JSON.stringify({ findings: rows, total: rows.length }, null, 2)); + return; + } + + if (rows.length === 0) { + console.warn( + "findings: no findings matched — run `codehub scan` or `codehub ingest-sarif ` to populate Finding nodes", + ); + return; + } + for (const f of rows) { + const loc = f.startLine !== undefined ? `:${f.startLine}` : ""; + const msg = f.message ? ` — ${f.message}` : ""; + console.log(`[${f.severity}] ${f.scanner}:${f.ruleId} at ${f.filePath}${loc}${msg}`); + } + } finally { + await store.close(); + } +} + +function stringOr(v: unknown, fallback: string): string { + if (typeof v === "string") return v; + if (typeof v === "number" || typeof v === "boolean") return String(v); + return fallback; +} diff --git a/packages/cli/src/commands/license-audit.test.ts b/packages/cli/src/commands/license-audit.test.ts new file mode 100644 index 00000000..16fa2f6a --- /dev/null +++ b/packages/cli/src/commands/license-audit.test.ts @@ -0,0 +1,94 @@ +/** + * Tests for `codehub license-audit` CLI command. + * + * The command reads `store.graph.listDependencies()`, maps to DependencyRef, + * and runs `classifyDependencies` from `@opencodehub/analysis`. The fake + * store returns a fixed Dependency list. + * + * Covers: + * - A copyleft (GPL) dep drives tier=BLOCK. + * - An all-permissive set drives tier=OK with cleared output. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { DependencyNode } from "@opencodehub/core-types"; +import type { IGraphStore, ITemporalStore, Store } from "@opencodehub/storage"; +import { runLicenseAudit } from "./license-audit.js"; + +function dep( + over: Omit, "id" | "name"> & { id: string; name: string }, +): DependencyNode { + return { + kind: "Dependency", + version: "1.0.0", + ecosystem: "npm", + lockfileSource: "package-lock.json", + license: "MIT", + filePath: "package-lock.json", + ...over, + } as unknown as DependencyNode; +} + +function makeFakeStore(deps: readonly DependencyNode[]): { store: Store; closed: () => boolean } { + let closed = false; + const graph: Partial = { + listDependencies: async () => deps, + }; + const store = { + graph: graph as unknown as IGraphStore, + temporal: {} as unknown as ITemporalStore, + graphFile: "/tmp/fake.lbug", + temporalFile: "/tmp/fake.duckdb", + close: async () => { + closed = true; + }, + } as Store; + return { store, closed: () => closed }; +} + +async function captureStdout(fn: () => Promise): Promise { + const orig = console.log; + const chunks: string[] = []; + console.log = (...args: unknown[]) => { + chunks.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")); + }; + try { + await fn(); + } finally { + console.log = orig; + } + return chunks.join("\n"); +} + +test("license-audit --json flags a GPL dep as tier=BLOCK", async () => { + const { store, closed } = makeFakeStore([ + dep({ id: "d1", name: "copyleft-lib", license: "GPL-3.0" }), + dep({ id: "d2", name: "ok-lib", license: "MIT" }), + ]); + const out = await captureStdout(async () => { + await runLicenseAudit({ + json: true, + storeFactory: async () => ({ store, repoPath: "/tmp/r" }), + }); + }); + const parsed = JSON.parse(out) as { + tier: string; + flagged: { copyleft: Array<{ name: string }> }; + }; + assert.equal(parsed.tier, "BLOCK"); + assert.equal(parsed.flagged.copyleft[0]?.name, "copyleft-lib"); + assert.ok(closed(), "store must be closed"); +}); + +test("license-audit --json clears an all-permissive set to tier=OK", async () => { + const { store } = makeFakeStore([dep({ id: "d1", name: "ok-lib", license: "Apache-2.0" })]); + const out = await captureStdout(async () => { + await runLicenseAudit({ + json: true, + storeFactory: async () => ({ store, repoPath: "/tmp/r" }), + }); + }); + const parsed = JSON.parse(out) as { tier: string }; + assert.equal(parsed.tier, "OK"); +}); diff --git a/packages/cli/src/commands/license-audit.ts b/packages/cli/src/commands/license-audit.ts new file mode 100644 index 00000000..2fe4790c --- /dev/null +++ b/packages/cli/src/commands/license-audit.ts @@ -0,0 +1,91 @@ +/** + * `codehub license-audit` — classify Dependency nodes by license risk tier. + * + * CLI sibling of the MCP `license_audit` tool. Reads every Dependency node + * (`store.graph.listDependencies()`), maps each row to a `DependencyRef`, + * and runs `classifyDependencies` from `@opencodehub/analysis`. + * + * Mirrors `packages/mcp/src/tools/license-audit.ts`. Does NOT emit the MCP + * next_steps / staleness envelope. + */ + +import { classifyDependencies, type DependencyRef } from "@opencodehub/analysis"; +import type { Store } from "@opencodehub/storage"; +import { openStoreForCommand } from "./open-store.js"; + +export interface LicenseAuditOptions { + readonly repo?: string; + readonly home?: string; + readonly json?: boolean; + /** Test seam — inject a fake store. Production leaves this unset. */ + readonly storeFactory?: () => Promise<{ store: Store; repoPath: string }>; +} + +export async function runLicenseAudit(opts: LicenseAuditOptions = {}): Promise { + const factory = opts.storeFactory ?? (() => openStoreForCommand({ ...opts, readOnly: true })); + const { store } = await factory(); + try { + const all = await store.graph.listDependencies(); + const deps: DependencyRef[] = all.map((d) => ({ + id: d.id, + name: d.name, + version: stringOr(d.version, "UNKNOWN"), + ecosystem: stringOr(d.ecosystem, "unknown"), + license: stringOr(d.license, "UNKNOWN"), + lockfileSource: stringOr(d.lockfileSource, d.filePath), + })); + + const result = classifyDependencies(deps); + + if (opts.json) { + console.log( + JSON.stringify( + { tier: result.tier, flagged: result.flagged, summary: result.summary }, + null, + 2, + ), + ); + return; + } + + console.warn( + `license-audit: tier=${result.tier} (${result.summary.okCount}/${result.summary.total} ok, ${result.summary.flaggedCount} flagged)`, + ); + if (result.flagged.copyleft.length > 0) { + console.log(`Copyleft (${result.flagged.copyleft.length}):`); + for (const d of result.flagged.copyleft) { + console.log(` - [${d.ecosystem}] ${d.name}@${d.version} — ${d.license}`); + } + } + if (result.flagged.proprietary.length > 0) { + console.log(`Proprietary (${result.flagged.proprietary.length}):`); + for (const d of result.flagged.proprietary) { + console.log(` - [${d.ecosystem}] ${d.name}@${d.version} — ${d.license}`); + } + } + if (result.flagged.unknown.length > 0) { + console.log(`Unknown/missing (${result.flagged.unknown.length}):`); + for (const d of result.flagged.unknown.slice(0, 25)) { + console.log(` - [${d.ecosystem}] ${d.name}@${d.version}`); + } + if (result.flagged.unknown.length > 25) { + console.log(` ... ${result.flagged.unknown.length - 25} more`); + } + } + if ( + result.flagged.copyleft.length === 0 && + result.flagged.proprietary.length === 0 && + result.flagged.unknown.length === 0 + ) { + console.log("All licenses cleared."); + } + } finally { + await store.close(); + } +} + +function stringOr(v: unknown, fallback: string): string { + if (typeof v === "string") return v; + if (typeof v === "number" || typeof v === "boolean") return String(v); + return fallback; +} diff --git a/packages/cli/src/commands/owners.test.ts b/packages/cli/src/commands/owners.test.ts new file mode 100644 index 00000000..e1387d6d --- /dev/null +++ b/packages/cli/src/commands/owners.test.ts @@ -0,0 +1,137 @@ +/** + * Tests for `codehub owners ` CLI command. + * + * The command calls the shared `listOwners` fn from `@opencodehub/analysis` + * (the same impl the MCP `owners` tool uses). The fake graph supplies + * OWNED_BY edges + Contributor nodes so the confidence-desc sort, + * slice-before-join, and `.to` ASC tiebreak are exercised end-to-end. + * + * Covers: + * - Owners are ranked confidence-desc and rendered. + * - `--limit` slices BEFORE the Contributor join (a low-confidence owner + * past the limit is dropped). + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { CodeRelation, GraphNode, NodeId, RelationType } from "@opencodehub/core-types"; +import type { IGraphStore, ITemporalStore, Store } from "@opencodehub/storage"; +import { runOwners } from "./owners.js"; + +interface FakeEdge { + readonly from: string; + readonly to: string; + readonly confidence: number; +} + +interface FakeContrib { + readonly id: string; + readonly name: string; + readonly emailHash: string; + readonly emailPlain?: string; +} + +function makeFakeStore( + edges: readonly FakeEdge[], + contribs: readonly FakeContrib[], +): { store: Store; closed: () => boolean } { + let closed = false; + const toRel = (e: FakeEdge): CodeRelation => + ({ + from: e.from as NodeId, + to: e.to as NodeId, + type: "OWNED_BY" as RelationType, + confidence: e.confidence, + }) as CodeRelation; + const toNode = (c: FakeContrib): GraphNode => + ({ + id: c.id as NodeId, + kind: "Contributor", + name: c.name, + filePath: "", + emailHash: c.emailHash, + ...(c.emailPlain !== undefined ? { emailPlain: c.emailPlain } : {}), + }) as unknown as GraphNode; + + const graph: Partial = { + listEdgesByType: async (_type, opts) => { + const from = opts?.fromIds?.[0]; + return edges.filter((e) => from === undefined || e.from === from).map(toRel); + }, + listNodesByKind: (async () => contribs.map(toNode)) as IGraphStore["listNodesByKind"], + }; + const store = { + graph: graph as unknown as IGraphStore, + temporal: {} as unknown as ITemporalStore, + graphFile: "/tmp/fake.lbug", + temporalFile: "/tmp/fake.duckdb", + close: async () => { + closed = true; + }, + } as Store; + return { store, closed: () => closed }; +} + +async function captureStdout(fn: () => Promise): Promise { + const orig = console.log; + const chunks: string[] = []; + console.log = (...args: unknown[]) => { + chunks.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")); + }; + try { + await fn(); + } finally { + console.log = orig; + } + return chunks.join("\n"); +} + +test("owners --json ranks contributors confidence-descending", async () => { + const { store, closed } = makeFakeStore( + [ + { from: "File:src/a.ts", to: "Contributor:bob", confidence: 0.3 }, + { from: "File:src/a.ts", to: "Contributor:alice", confidence: 0.7 }, + ], + [ + { id: "Contributor:alice", name: "Alice", emailHash: "aaa", emailPlain: "alice@x.com" }, + { id: "Contributor:bob", name: "Bob", emailHash: "bbb" }, + ], + ); + const out = await captureStdout(async () => { + await runOwners("File:src/a.ts", { + json: true, + storeFactory: async () => ({ store, repoPath: "/tmp/r" }), + }); + }); + const parsed = JSON.parse(out) as { + owners: Array<{ name: string; weight: number }>; + total: number; + }; + assert.equal(parsed.total, 2); + assert.equal(parsed.owners[0]?.name, "Alice"); + assert.equal(parsed.owners[1]?.name, "Bob"); + assert.ok(closed(), "store must be closed"); +}); + +test("owners --limit slices BEFORE the Contributor join", async () => { + const { store } = makeFakeStore( + [ + { from: "File:src/a.ts", to: "Contributor:alice", confidence: 0.9 }, + { from: "File:src/a.ts", to: "Contributor:bob", confidence: 0.1 }, + ], + [ + { id: "Contributor:alice", name: "Alice", emailHash: "aaa" }, + { id: "Contributor:bob", name: "Bob", emailHash: "bbb" }, + ], + ); + const out = await captureStdout(async () => { + await runOwners("File:src/a.ts", { + json: true, + limit: 1, + storeFactory: async () => ({ store, repoPath: "/tmp/r" }), + }); + }); + const parsed = JSON.parse(out) as { owners: Array<{ name: string }>; total: number }; + assert.equal(parsed.total, 1); + assert.equal(parsed.owners[0]?.name, "Alice"); +}); diff --git a/packages/cli/src/commands/owners.ts b/packages/cli/src/commands/owners.ts new file mode 100644 index 00000000..6acc053c --- /dev/null +++ b/packages/cli/src/commands/owners.ts @@ -0,0 +1,53 @@ +/** + * `codehub owners ` — ranked OWNED_BY contributors for a node. + * + * CLI sibling of the MCP `owners` tool. Both surfaces call the shared + * `listOwners` fn from `@opencodehub/analysis`, which walks the OWNED_BY + * edges in confidence-descending order (with a `.to` ASC tiebreak), slices + * to `limit` BEFORE the Contributor join, then joins for display metadata. + * + * Mirrors `packages/mcp/src/tools/owners.ts`. Does NOT emit the MCP + * next_steps / staleness envelope. + */ + +import { listOwners } from "@opencodehub/analysis"; +import type { Store } from "@opencodehub/storage"; +import { openStoreForCommand } from "./open-store.js"; + +export interface OwnersOptions { + readonly repo?: string; + readonly home?: string; + readonly json?: boolean; + readonly limit?: number; + /** Test seam — inject a fake store. Production leaves this unset. */ + readonly storeFactory?: () => Promise<{ store: Store; repoPath: string }>; +} + +export async function runOwners(target: string, opts: OwnersOptions = {}): Promise { + const limit = opts.limit ?? 20; + const factory = opts.storeFactory ?? (() => openStoreForCommand({ ...opts, readOnly: true })); + const { store } = await factory(); + try { + const owners = await listOwners(store.graph, target, limit); + + if (opts.json) { + console.log(JSON.stringify({ owners, total: owners.length }, null, 2)); + return; + } + + console.warn(`owners: ${target} (${owners.length}):`); + if (owners.length === 0) { + console.log( + "(no OWNED_BY edges for this target — either the target id is unknown or the ownership phase has not run. Re-index with `codehub analyze --force`.)", + ); + return; + } + for (const o of owners) { + const id = o.email.length > 0 ? o.email : `sha256:${o.emailHash.slice(0, 10)}…`; + const name = o.name.length > 0 ? o.name : "unknown"; + console.log(`- ${name} <${id}> weight=${o.weight.toFixed(3)}`); + } + } finally { + await store.close(); + } +} diff --git a/packages/cli/src/commands/project-profile.test.ts b/packages/cli/src/commands/project-profile.test.ts new file mode 100644 index 00000000..f994c6b2 --- /dev/null +++ b/packages/cli/src/commands/project-profile.test.ts @@ -0,0 +1,103 @@ +/** + * Tests for `codehub project-profile` CLI command. + * + * The command reads the singleton ProjectProfile node via + * `listNodesByKind("ProjectProfile", { limit: 1 })` and decodes its fields, + * mirroring the MCP `project_profile` tool. + * + * Covers: + * - A populated profile renders languages/frameworks in JSON. + * - The structured framework view (`name:variant`) wins over flat names. + * - An absent profile yields empty arrays in JSON (no throw). + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { ProjectProfileNode } from "@opencodehub/core-types"; +import type { IGraphStore, ITemporalStore, Store } from "@opencodehub/storage"; +import { runProjectProfile } from "./project-profile.js"; + +function makeFakeStore(profile: ProjectProfileNode | undefined): { + store: Store; + closed: () => boolean; +} { + let closed = false; + const graph: Partial = { + listNodesByKind: (async (_kind: string) => + profile ? [profile] : []) as IGraphStore["listNodesByKind"], + }; + const store = { + graph: graph as unknown as IGraphStore, + temporal: {} as unknown as ITemporalStore, + graphFile: "/tmp/fake.lbug", + temporalFile: "/tmp/fake.duckdb", + close: async () => { + closed = true; + }, + } as Store; + return { store, closed: () => closed }; +} + +async function captureStdout(fn: () => Promise): Promise { + const orig = console.log; + const chunks: string[] = []; + console.log = (...args: unknown[]) => { + chunks.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")); + }; + try { + await fn(); + } finally { + console.log = orig; + } + return chunks.join("\n"); +} + +test("project-profile --json renders a populated profile", async () => { + const profile = { + kind: "ProjectProfile", + id: "ProjectProfile::profile", + name: "profile", + filePath: "", + languages: ["typescript", "python"], + frameworks: ["nextjs"], + frameworksDetected: [ + { + name: "nextjs", + category: "meta", + variant: "app-router", + confidence: "deterministic", + evidence: [], + }, + ], + iacTypes: ["terraform"], + apiContracts: ["openapi"], + manifests: ["package.json"], + srcDirs: ["src"], + } as unknown as ProjectProfileNode; + const { store, closed } = makeFakeStore(profile); + const out = await captureStdout(async () => { + await runProjectProfile({ + json: true, + storeFactory: async () => ({ store, repoPath: "/tmp/r" }), + }); + }); + const parsed = JSON.parse(out) as { + profile: { languages: string[]; frameworksDetected: unknown[] }; + }; + assert.deepEqual(parsed.profile.languages, ["typescript", "python"]); + assert.equal(parsed.profile.frameworksDetected.length, 1); + assert.ok(closed(), "store must be closed"); +}); + +test("project-profile --json returns empty arrays when the node is absent", async () => { + const { store } = makeFakeStore(undefined); + const out = await captureStdout(async () => { + await runProjectProfile({ + json: true, + storeFactory: async () => ({ store, repoPath: "/tmp/r" }), + }); + }); + const parsed = JSON.parse(out) as { profile: { languages: string[]; manifests: string[] } }; + assert.deepEqual(parsed.profile.languages, []); + assert.deepEqual(parsed.profile.manifests, []); +}); diff --git a/packages/cli/src/commands/project-profile.ts b/packages/cli/src/commands/project-profile.ts new file mode 100644 index 00000000..91adf7b9 --- /dev/null +++ b/packages/cli/src/commands/project-profile.ts @@ -0,0 +1,90 @@ +/** + * `codehub project-profile` — return the ProjectProfile node for a repo. + * + * CLI sibling of the MCP `project_profile` tool. Reads the singleton + * ProjectProfile node (`listNodesByKind("ProjectProfile", { limit: 1 })`) + * and decodes each array field, preferring the structured framework view + * (`name:variant`) when present. + * + * Mirrors `packages/mcp/src/tools/project-profile.ts`. Does NOT emit the MCP + * next_steps / staleness envelope. + */ + +import type { FrameworkDetection } from "@opencodehub/core-types"; +import type { Store } from "@opencodehub/storage"; +import { openStoreForCommand } from "./open-store.js"; + +export interface ProjectProfileOptions { + readonly repo?: string; + readonly home?: string; + readonly json?: boolean; + /** Test seam — inject a fake store. Production leaves this unset. */ + readonly storeFactory?: () => Promise<{ store: Store; repoPath: string }>; +} + +interface ProjectProfilePayload { + readonly languages: readonly string[]; + readonly frameworks: readonly string[]; + readonly frameworksDetected: readonly FrameworkDetection[]; + readonly iacTypes: readonly string[]; + readonly apiContracts: readonly string[]; + readonly manifests: readonly string[]; + readonly srcDirs: readonly string[]; +} + +export async function runProjectProfile(opts: ProjectProfileOptions = {}): Promise { + const factory = opts.storeFactory ?? (() => openStoreForCommand({ ...opts, readOnly: true })); + const { store } = await factory(); + try { + const nodes = await store.graph.listNodesByKind("ProjectProfile", { limit: 1 }); + const profile = nodes[0]; + const payload: ProjectProfilePayload = { + languages: profile?.languages ? [...profile.languages] : [], + frameworks: profile?.frameworks ? [...profile.frameworks] : [], + frameworksDetected: profile?.frameworksDetected ? [...profile.frameworksDetected] : [], + iacTypes: profile?.iacTypes ? [...profile.iacTypes] : [], + apiContracts: profile?.apiContracts ? [...profile.apiContracts] : [], + manifests: profile?.manifests ? [...profile.manifests] : [], + srcDirs: profile?.srcDirs ? [...profile.srcDirs] : [], + }; + + if (opts.json) { + console.log(JSON.stringify({ profile: payload }, null, 2)); + return; + } + + if (profile === undefined) { + console.warn( + "project-profile: no ProjectProfile node found. Re-index with `codehub analyze --force` to populate.", + ); + return; + } + + if (payload.languages.length > 0) { + console.log(`languages (${payload.languages.length}): ${payload.languages.join(", ")}`); + } + if (payload.frameworks.length > 0) { + const display = + payload.frameworksDetected.length > 0 + ? payload.frameworksDetected.map((d) => (d.variant ? `${d.name}:${d.variant}` : d.name)) + : payload.frameworks; + console.log(`frameworks (${display.length}): ${display.join(", ")}`); + } + if (payload.iacTypes.length > 0) { + console.log(`iacTypes (${payload.iacTypes.length}): ${payload.iacTypes.join(", ")}`); + } + if (payload.apiContracts.length > 0) { + console.log( + `apiContracts (${payload.apiContracts.length}): ${payload.apiContracts.join(", ")}`, + ); + } + if (payload.manifests.length > 0) { + console.log(`manifests (${payload.manifests.length}): ${payload.manifests.join(", ")}`); + } + if (payload.srcDirs.length > 0) { + console.log(`srcDirs (${payload.srcDirs.length}): ${payload.srcDirs.join(", ")}`); + } + } finally { + await store.close(); + } +} diff --git a/packages/cli/src/commands/risk-trends.test.ts b/packages/cli/src/commands/risk-trends.test.ts new file mode 100644 index 00000000..a0a64078 --- /dev/null +++ b/packages/cli/src/commands/risk-trends.test.ts @@ -0,0 +1,76 @@ +/** + * Tests for `codehub risk-trends` CLI command. + * + * The command reads snapshot history and runs `computeRiskTrends`. The + * mandatory `snapshotsFn` test seam supplies the snapshots directly so the + * test never seeds a real `.codehub/history` directory on disk. + * + * Covers: + * - Empty history → overall=stable, snapshot_count=0. + * - A rising two-snapshot history → a non-stable community trend in JSON. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { RiskSnapshot } from "@opencodehub/analysis"; +import { runRiskTrends } from "./risk-trends.js"; + +function snapshot(ts: string, c1Risk: number): RiskSnapshot { + return { + timestamp: ts, + commit: ts, + perCommunityRisk: { c1: { risk: c1Risk, nodeCount: 5 } }, + totalNodeCount: 5, + totalEdgeCount: 4, + findingsSeverityHistogram: { error: 0, warning: 0, note: 0 }, + }; +} + +async function captureStdout(fn: () => Promise): Promise { + const orig = console.log; + const chunks: string[] = []; + console.log = (...args: unknown[]) => { + chunks.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")); + }; + try { + await fn(); + } finally { + console.log = orig; + } + return chunks.join("\n"); +} + +test("risk-trends --json on empty history → stable, count 0", async () => { + const out = await captureStdout(async () => { + await runRiskTrends({ + repo: "/tmp/fake-repo-no-history", + json: true, + snapshotsFn: async () => [], + }); + }); + const parsed = JSON.parse(out) as { overall_trend: string; snapshot_count: number }; + assert.equal(parsed.overall_trend, "stable"); + assert.equal(parsed.snapshot_count, 0); +}); + +test("risk-trends --json surfaces per-community trend from injected snapshots", async () => { + const snaps: readonly RiskSnapshot[] = [ + snapshot("2026-01-01T00:00:00.000Z", 0.1), + snapshot("2026-02-01T00:00:00.000Z", 0.5), + snapshot("2026-03-01T00:00:00.000Z", 0.9), + ]; + const out = await captureStdout(async () => { + await runRiskTrends({ + repo: "/tmp/fake-repo", + json: true, + snapshotsFn: async () => snaps, + }); + }); + const parsed = JSON.parse(out) as { + snapshot_count: number; + communities: Record; + }; + assert.equal(parsed.snapshot_count, 3); + assert.ok(parsed.communities["c1"], "c1 community trend present"); + assert.ok(typeof parsed.communities["c1"]?.trend === "string"); +}); diff --git a/packages/cli/src/commands/risk-trends.ts b/packages/cli/src/commands/risk-trends.ts new file mode 100644 index 00000000..9878cd44 --- /dev/null +++ b/packages/cli/src/commands/risk-trends.ts @@ -0,0 +1,86 @@ +/** + * `codehub risk-trends` — per-community risk trajectory + 30-day projection. + * + * CLI sibling of the MCP `risk_trends` tool. Unlike the other read-only + * commands this one does NOT touch the graph — it reads the snapshot history + * written by the risk-snapshot phase (`.codehub/history/risk_*.json`) and + * runs `computeRiskTrends(await loadSnapshots(repoPath))`, both exported from + * `@opencodehub/analysis` (the same pair `wiki.ts` consumes). + * + * Mirrors `packages/mcp/src/tools/risk-trends.ts`. Does NOT emit the MCP + * next_steps / staleness envelope. + * + * A mandatory `snapshotsFn?` test seam (default `loadSnapshots`) lets tests + * supply the snapshot array directly so they never have to seed a real + * `.codehub/history` directory. + */ + +import { resolve } from "node:path"; +import { computeRiskTrends, loadSnapshots, type RiskSnapshot } from "@opencodehub/analysis"; +import { readRegistry } from "../registry.js"; + +export interface RiskTrendsOptions { + readonly repo?: string; + readonly home?: string; + readonly json?: boolean; + /** + * Test seam — load snapshots for a repo path. Defaults to the real + * `loadSnapshots` reader. Tests inject a fake so they never seed a real + * `.codehub/history` directory on disk. + */ + readonly snapshotsFn?: (repoPath: string) => Promise; +} + +export async function runRiskTrends(opts: RiskTrendsOptions = {}): Promise { + const repoPath = await resolveRepoPath(opts); + const load = opts.snapshotsFn ?? loadSnapshots; + const snapshots = await load(repoPath); + const trends = computeRiskTrends(snapshots); + + if (opts.json) { + console.log( + JSON.stringify( + { + overall_trend: trends.overallTrend, + snapshot_count: trends.snapshotCount, + communities: trends.communities, + }, + null, + 2, + ), + ); + return; + } + + console.warn(`risk-trends: overall=${trends.overallTrend} (${trends.snapshotCount} snapshots).`); + const ids = Object.keys(trends.communities).sort(); + if (ids.length === 0) { + console.log("(no community trends yet — run `codehub analyze` a few times to build history)"); + return; + } + for (const id of ids.slice(0, 30)) { + const entry = trends.communities[id]; + if (entry === undefined) continue; + console.log( + `- ${id}: ${entry.trend} (current=${entry.currentRisk.toFixed(3)}, 30d=${entry.projectedRisk30d.toFixed(3)})`, + ); + } + if (ids.length > 30) console.log(`… ${ids.length - 30} more communities`); +} + +/** + * Resolve the repo path from `--repo ` (registry lookup, falling back + * to a raw path) or the current working directory. Mirrors the resolution in + * `open-store.ts` but without opening the graph, since risk-trends is a + * filesystem read. + */ +async function resolveRepoPath(opts: RiskTrendsOptions): Promise { + if (opts.repo !== undefined) { + const registryOpts = opts.home !== undefined ? { home: opts.home } : {}; + const registry = await readRegistry(registryOpts); + const hit = registry[opts.repo]; + if (hit) return resolve(hit.path); + return resolve(opts.repo); + } + return resolve(process.cwd()); +} diff --git a/packages/cli/src/commands/route-map.test.ts b/packages/cli/src/commands/route-map.test.ts new file mode 100644 index 00000000..5d14710a --- /dev/null +++ b/packages/cli/src/commands/route-map.test.ts @@ -0,0 +1,132 @@ +/** + * Tests for `codehub route-map` CLI command. + * + * The command calls the shared `listRouteMap` fn from `@opencodehub/analysis` + * (the same impl the MCP `route_map` tool uses). The fake graph supplies + * Route nodes plus HANDLES_ROUTE / FETCHES edges. + * + * Covers: + * - JSON mode emits a `{ routes, total }` payload with handlers/consumers. + * - A non-standard method (not in the five-verb set) is handled by the + * two-stage TS post-filter. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { CodeRelation, NodeId, RelationType, RouteNode } from "@opencodehub/core-types"; +import type { IGraphStore, ITemporalStore, ListRoutesOptions, Store } from "@opencodehub/storage"; +import { runRouteMap } from "./route-map.js"; + +function route( + over: Omit, "id" | "url"> & { id: string; url: string }, +): RouteNode { + return { + kind: "Route", + method: "GET", + filePath: "src/routes.ts", + responseKeys: [], + ...over, + } as unknown as RouteNode; +} + +function edge(from: string, to: string, type: RelationType): CodeRelation { + return { from: from as NodeId, to: to as NodeId, type, confidence: 1 } as CodeRelation; +} + +interface FakeHandle { + closed: boolean; + lastRoutesOpts?: ListRoutesOptions; + store: Store; +} + +function makeFakeStore(routes: readonly RouteNode[], edges: readonly CodeRelation[]): FakeHandle { + const handle: FakeHandle = { closed: false, store: {} as Store }; + const graph: Partial = { + listRoutes: async (opts: ListRoutesOptions = {}) => { + handle.lastRoutesOpts = opts; + let out = [...routes]; + if (opts.methods !== undefined) { + const set = new Set(opts.methods); + out = out.filter((r) => r.method !== undefined && set.has(r.method as "GET")); + } + if (opts.pathLike !== undefined) + out = out.filter((r) => r.url.includes(opts.pathLike as string)); + return out; + }, + listEdgesByType: async (type, opts) => { + const to = opts?.toIds?.[0]; + return edges.filter((e) => e.type === type && (to === undefined || e.to === to)); + }, + }; + handle.store = { + graph: graph as unknown as IGraphStore, + temporal: {} as unknown as ITemporalStore, + graphFile: "/tmp/fake.lbug", + temporalFile: "/tmp/fake.duckdb", + close: async () => { + handle.closed = true; + }, + } as Store; + return handle; +} + +async function captureStdout(fn: () => Promise): Promise { + const orig = console.log; + const chunks: string[] = []; + console.log = (...args: unknown[]) => { + chunks.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")); + }; + try { + await fn(); + } finally { + console.log = orig; + } + return chunks.join("\n"); +} + +test("route-map --json emits routes with handlers and consumers", async () => { + const handle = makeFakeStore( + [route({ id: "Route:GET:/users", url: "/users", method: "GET" })], + [ + edge("File:handler.ts", "Route:GET:/users", "HANDLES_ROUTE"), + edge("Function:caller", "Route:GET:/users", "FETCHES"), + ], + ); + const out = await captureStdout(async () => { + await runRouteMap({ + json: true, + storeFactory: async () => ({ store: handle.store, repoPath: "/tmp/r" }), + }); + }); + const parsed = JSON.parse(out) as { + routes: Array<{ url: string; handlers: string[]; consumers: string[] }>; + total: number; + }; + assert.equal(parsed.total, 1); + assert.equal(parsed.routes[0]?.url, "/users"); + assert.deepEqual(parsed.routes[0]?.handlers, ["File:handler.ts"]); + assert.deepEqual(parsed.routes[0]?.consumers, ["Function:caller"]); + assert.ok(handle.closed, "store must be closed"); +}); + +test("route-map non-standard method uses the two-stage TS post-filter", async () => { + const handle = makeFakeStore( + [ + route({ id: "Route:HEAD:/ping", url: "/ping", method: "HEAD" }), + route({ id: "Route:GET:/ping", url: "/ping", method: "GET" }), + ], + [], + ); + const out = await captureStdout(async () => { + await runRouteMap({ + json: true, + method: "HEAD", + storeFactory: async () => ({ store: handle.store, repoPath: "/tmp/r" }), + }); + }); + // HEAD is not one of the five verbs → must NOT be pushed to listRoutes.methods. + assert.equal(handle.lastRoutesOpts?.methods, undefined); + const parsed = JSON.parse(out) as { routes: Array<{ method: string }>; total: number }; + assert.equal(parsed.total, 1); + assert.equal(parsed.routes[0]?.method, "HEAD"); +}); diff --git a/packages/cli/src/commands/route-map.ts b/packages/cli/src/commands/route-map.ts new file mode 100644 index 00000000..5e59c1c0 --- /dev/null +++ b/packages/cli/src/commands/route-map.ts @@ -0,0 +1,58 @@ +/** + * `codehub route-map` — map HTTP Route nodes to handlers + consumers. + * + * CLI sibling of the MCP `route_map` tool. Both surfaces call the shared + * `listRouteMap` fn from `@opencodehub/analysis`, which lists Route nodes + * (limit:500 cap, two-stage method handling) and pulls each route's + * HANDLES_ROUTE handlers and FETCHES consumers. + * + * Mirrors `packages/mcp/src/tools/route-map.ts`. Does NOT emit the MCP + * next_steps / staleness envelope. + */ + +import { listRouteMap } from "@opencodehub/analysis"; +import type { Store } from "@opencodehub/storage"; +import { openStoreForCommand } from "./open-store.js"; + +export interface RouteMapOptions { + readonly repo?: string; + readonly home?: string; + readonly json?: boolean; + readonly route?: string; + readonly method?: string; + /** Test seam — inject a fake store. Production leaves this unset. */ + readonly storeFactory?: () => Promise<{ store: Store; repoPath: string }>; +} + +export async function runRouteMap(opts: RouteMapOptions = {}): Promise { + const factory = opts.storeFactory ?? (() => openStoreForCommand({ ...opts, readOnly: true })); + const { store } = await factory(); + try { + const routes = await listRouteMap(store.graph, { + ...(opts.route !== undefined ? { route: opts.route } : {}), + ...(opts.method !== undefined ? { method: opts.method } : {}), + }); + + if (opts.json) { + console.log(JSON.stringify({ routes, total: routes.length }, null, 2)); + return; + } + + console.warn( + `route-map: ${routes.length} route(s)${opts.route ? ` · url~${opts.route}` : ""}${ + opts.method ? ` · method=${opts.method}` : "" + }:`, + ); + if (routes.length === 0) { + console.log("(no routes matched — verify the `routes` phase ran on a supported framework)"); + return; + } + for (const r of routes) { + console.log( + `${r.method} ${r.url} handlers=${r.handlers.length} consumers=${r.consumers.length} keys=${r.responseKeys.length}`, + ); + } + } finally { + await store.close(); + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3f996d3e..a714d789 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -786,6 +786,174 @@ program }); }); +// --- read-only graph capabilities (CLI siblings of the MCP tools) ---------- +// Each reuses the same underlying logic as its MCP tool (a shared +// `@opencodehub/analysis` fn or an IGraphStore/ITemporalStore reader), +// following the `verdict` CLI↔MCP shared-function pattern. + +program + .command("findings") + .description("List SARIF Finding nodes (sibling of the MCP list_findings tool)") + .option("--repo ", "Registered repo name (default: current directory)") + .option("--severity ", "Restrict to one SARIF severity: error | warning | note | none") + .option("--scanner ", "Restrict to a single scanner id (e.g. 'semgrep')") + .option("--rule-id ", "Restrict to a single rule id") + .option("--file-path ", "Substring filter on the finding's file path") + .option("--limit ", "Maximum findings to return", (v) => Number.parseInt(v, 10), 500) + .option("--json", "Emit JSON on stdout") + .action(async (opts: Record) => { + const mod = await import("./commands/findings.js"); + const sev = opts["severity"]; + await mod.runFindings({ + ...(typeof opts["repo"] === "string" ? { repo: opts["repo"] } : {}), + ...(sev === "error" || sev === "warning" || sev === "note" || sev === "none" + ? { severity: sev } + : {}), + ...(typeof opts["scanner"] === "string" ? { scanner: opts["scanner"] } : {}), + ...(typeof opts["ruleId"] === "string" ? { ruleId: opts["ruleId"] } : {}), + ...(typeof opts["filePath"] === "string" ? { filePath: opts["filePath"] } : {}), + ...(typeof opts["limit"] === "number" ? { limit: opts["limit"] } : {}), + json: opts["json"] === true, + }); + }); + +program + .command("dead-code") + .description("List dead and unreachable-export symbols (sibling of MCP list_dead_code)") + .option("--repo ", "Registered repo name (default: current directory)") + .option("--file-path-pattern ", "Substring filter on each symbol's file path") + .option("--include-unreachable-exports", "Also include exported-but-unreferenced symbols") + .option("--limit ", "Maximum symbols to return", (v) => Number.parseInt(v, 10), 100) + .option("--json", "Emit JSON on stdout") + .action(async (opts: Record) => { + const mod = await import("./commands/dead-code.js"); + await mod.runDeadCode({ + ...(typeof opts["repo"] === "string" ? { repo: opts["repo"] } : {}), + ...(typeof opts["filePathPattern"] === "string" + ? { filePathPattern: opts["filePathPattern"] } + : {}), + includeUnreachableExports: opts["includeUnreachableExports"] === true, + ...(typeof opts["limit"] === "number" ? { limit: opts["limit"] } : {}), + json: opts["json"] === true, + }); + }); + +program + .command("license-audit") + .description("Classify Dependency nodes by license risk tier (sibling of MCP license_audit)") + .option("--repo ", "Registered repo name (default: current directory)") + .option("--json", "Emit JSON on stdout") + .action(async (opts: Record) => { + const mod = await import("./commands/license-audit.js"); + await mod.runLicenseAudit({ + ...(typeof opts["repo"] === "string" ? { repo: opts["repo"] } : {}), + json: opts["json"] === true, + }); + }); + +program + .command("project-profile") + .description("Show the detected project profile (sibling of MCP project_profile)") + .option("--repo ", "Registered repo name (default: current directory)") + .option("--json", "Emit JSON on stdout") + .action(async (opts: Record) => { + const mod = await import("./commands/project-profile.js"); + await mod.runProjectProfile({ + ...(typeof opts["repo"] === "string" ? { repo: opts["repo"] } : {}), + json: opts["json"] === true, + }); + }); + +program + .command("risk-trends") + .description("Per-community risk trend + 30-day projection (sibling of MCP risk_trends)") + .option("--repo ", "Registered repo name (default: current directory)") + .option("--json", "Emit JSON on stdout") + .action(async (opts: Record) => { + const mod = await import("./commands/risk-trends.js"); + await mod.runRiskTrends({ + ...(typeof opts["repo"] === "string" ? { repo: opts["repo"] } : {}), + ...(typeof opts["home"] === "string" ? { home: opts["home"] } : {}), + json: opts["json"] === true, + }); + }); + +program + .command("owners ") + .description("List ranked OWNED_BY contributors for a node (sibling of MCP owners)") + .option("--repo ", "Registered repo name (default: current directory)") + .option("--limit ", "Maximum contributors to return", (v) => Number.parseInt(v, 10), 20) + .option("--json", "Emit JSON on stdout") + .action(async (target: string, opts: Record) => { + const mod = await import("./commands/owners.js"); + await mod.runOwners(target, { + ...(typeof opts["repo"] === "string" ? { repo: opts["repo"] } : {}), + ...(typeof opts["limit"] === "number" ? { limit: opts["limit"] } : {}), + json: opts["json"] === true, + }); + }); + +program + .command("route-map") + .description("Map HTTP routes to handlers and consumers (sibling of MCP route_map)") + .option("--repo ", "Registered repo name (default: current directory)") + .option("--route ", "Substring match against Route.url (e.g. '/api/users')") + .option("--method ", "Exact match against Route.method (e.g. 'GET')") + .option("--json", "Emit JSON on stdout") + .action(async (opts: Record) => { + const mod = await import("./commands/route-map.js"); + await mod.runRouteMap({ + ...(typeof opts["repo"] === "string" ? { repo: opts["repo"] } : {}), + ...(typeof opts["route"] === "string" ? { route: opts["route"] } : {}), + ...(typeof opts["method"] === "string" ? { method: opts["method"] } : {}), + json: opts["json"] === true, + }); + }); + +program + .command("api-impact") + .description("Score the blast radius of changing a Route's contract (sibling of MCP api_impact)") + .option("--repo ", "Registered repo name (default: current directory)") + .option("--route ", "Substring match against Route.url") + .option("--file ", "Substring match against Route.filePath") + .option("--json", "Emit JSON on stdout") + .action(async (opts: Record) => { + const mod = await import("./commands/api-impact.js"); + await mod.runApiImpact({ + ...(typeof opts["repo"] === "string" ? { repo: opts["repo"] } : {}), + ...(typeof opts["route"] === "string" ? { route: opts["route"] } : {}), + ...(typeof opts["file"] === "string" ? { file: opts["file"] } : {}), + json: opts["json"] === true, + }); + }); + +program + .command("dependencies") + .description("List external dependencies (sibling of MCP dependencies)") + .option("--repo ", "Registered repo name (default: current directory)") + .option("--ecosystem ", "Restrict to one ecosystem: npm | pypi | go | cargo | maven | nuget") + .option("--file-path ", "Substring filter on the manifest/lockfile path") + .option("--limit ", "Maximum dependencies to return", (v) => Number.parseInt(v, 10), 500) + .option("--json", "Emit JSON on stdout") + .action(async (opts: Record) => { + const mod = await import("./commands/dependencies.js"); + const eco = opts["ecosystem"]; + await mod.runDependencies({ + ...(typeof opts["repo"] === "string" ? { repo: opts["repo"] } : {}), + ...(eco === "npm" || + eco === "pypi" || + eco === "go" || + eco === "cargo" || + eco === "maven" || + eco === "nuget" + ? { ecosystem: eco } + : {}), + ...(typeof opts["filePath"] === "string" ? { filePath: opts["filePath"] } : {}), + ...(typeof opts["limit"] === "number" ? { limit: opts["limit"] } : {}), + json: opts["json"] === true, + }); + }); + function splitList(raw: string): readonly string[] { return raw .split(",") diff --git a/packages/mcp/src/tool-handlers.test.ts b/packages/mcp/src/tool-handlers.test.ts index 7b661717..d3e12cd2 100644 --- a/packages/mcp/src/tool-handlers.test.ts +++ b/packages/mcp/src/tool-handlers.test.ts @@ -631,7 +631,13 @@ test("impact drives the analysis package and groups by depth", async () => { test("impact surfaces cochanges for the target's file as a side section", async () => { await withTestHarness( { - nodes: [{ id: "F:foo", name: "foo", kind: "Function", file_path: "src/foo.ts" }], + // Realistic node id: OCH ids carry ≥2 colons (`::`), + // which is what `looksLikeNodeId` requires to route a target as an id. + // The old `F:foo` (one colon) was treated as a NAME, never resolved, + // and the cochanges side-section came back empty. + nodes: [ + { id: "Function:src/foo.ts:foo", name: "foo", kind: "Function", file_path: "src/foo.ts" }, + ], relations: [], cochanges: [ { @@ -648,7 +654,7 @@ test("impact surfaces cochanges for the target's file as a side section", async async (ctx, server) => { registerImpactTool(server, ctx); const handler = getHandler(server, "impact"); - const result = await handler({ target: "F:foo", repo: "fakerepo" }, {}); + const result = await handler({ target: "Function:src/foo.ts:foo", repo: "fakerepo" }, {}); const sc = result.structuredContent as { cochanges: Array<{ file: string; lift: number }>; byDepth: Record>; diff --git a/packages/mcp/src/tools/api-impact.ts b/packages/mcp/src/tools/api-impact.ts index 8cba954b..0b31167e 100644 --- a/packages/mcp/src/tools/api-impact.ts +++ b/packages/mcp/src/tools/api-impact.ts @@ -22,13 +22,11 @@ // biome-ignore-all lint/complexity/useLiteralKeys: dot-access disallowed on Record index signatures import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { GraphNode, RouteNode } from "@opencodehub/core-types"; -import type { IGraphStore } from "@opencodehub/storage"; +import { type ApiImpactRow, listApiImpact, type RiskLevel, worseRisk } from "@opencodehub/analysis"; import { z } from "zod"; import { toolErrorFromUnknown } from "../error-envelope.js"; import { withNextSteps } from "../next-step-hints.js"; import { stalenessFromMeta } from "../staleness.js"; -import { classifyShape } from "./shape-check.js"; import { fromToolResult, repoArgShape, @@ -44,21 +42,11 @@ const ApiImpactInput = { file: z.string().optional().describe("Substring match against Route.filePath."), }; -export type Risk = "LOW" | "MEDIUM" | "HIGH" | "CRITICAL"; - -export interface ApiImpactRow { - readonly route: { - readonly id: string; - readonly url: string; - readonly method: string; - readonly filePath: string; - }; - readonly risk: Risk; - readonly consumers: readonly string[]; - readonly middleware: readonly string[]; - readonly mismatches: readonly string[]; - readonly affectedProcesses: readonly string[]; -} +// `Risk` was the original MCP-side name for the risk union; it now aliases the +// shared `RiskLevel` exported by @opencodehub/analysis. Re-exported for +// backward compatibility along with the row type. +export type Risk = RiskLevel; +export type { ApiImpactRow }; interface ApiImpactArgs { readonly repo?: string | undefined; @@ -70,7 +58,10 @@ interface ApiImpactArgs { export async function runApiImpact(ctx: ToolContext, args: ApiImpactArgs): Promise { const call = await withStore(ctx, args, async (store, resolved) => { try { - const rows = await analyzeApiImpact(store.graph, args.route, args.file); + const rows = await listApiImpact(store.graph, { + ...(args.route !== undefined ? { route: args.route } : {}), + ...(args.file !== undefined ? { file: args.file } : {}), + }); const header = `api_impact — ${rows.length} route(s) for ${resolved.name}${ args.route ? ` · url~${args.route}` : "" @@ -130,135 +121,3 @@ export function registerApiImpactTool(server: McpServer, ctx: ToolContext): void async (args) => fromToolResult(await runApiImpact(ctx, args)), ); } - -async function analyzeApiImpact( - graph: IGraphStore, - routeFilter: string | undefined, - fileFilter: string | undefined, -): Promise { - const opts: { pathLike?: string; limit?: number } = { limit: 500 }; - if (routeFilter !== undefined && routeFilter.length > 0) opts.pathLike = routeFilter; - let routes: readonly RouteNode[] = await graph.listRoutes(opts); - if (fileFilter !== undefined && fileFilter.length > 0) { - const sub = fileFilter; - routes = routes.filter((r) => r.filePath.includes(sub)); - } - const sorted = [...routes].sort((a, b) => { - if (a.url !== b.url) return a.url < b.url ? -1 : 1; - const am = a.method ?? ""; - const bm = b.method ?? ""; - return am < bm ? -1 : am > bm ? 1 : 0; - }); - - const out: ApiImpactRow[] = []; - for (const r of sorted) { - const responseKeys = r.responseKeys ?? []; - - const [consumerSymbolIds, handlers] = await Promise.all([ - fetchFromIds(graph, r.id, "FETCHES"), - fetchFromIds(graph, r.id, "HANDLES_ROUTE"), - ]); - - const consumerFiles = await resolveFiles(graph, consumerSymbolIds); - - const mismatches: string[] = []; - for (const file of consumerFiles) { - const accessedKeys = await collectAccessedKeys(graph, file); - const { status } = classifyShape(accessedKeys, responseKeys); - if (status === "MISMATCH") mismatches.push(file); - } - - const affectedProcesses = await fetchAffectedProcesses(graph, consumerSymbolIds); - - const risk = scoreRisk(consumerFiles.length, mismatches.length); - out.push({ - route: { id: r.id, url: r.url, method: r.method ?? "", filePath: r.filePath }, - risk, - consumers: consumerFiles, - middleware: handlers, - mismatches, - affectedProcesses, - }); - } - return out; -} - -function scoreRisk(consumers: number, mismatches: number): Risk { - if (consumers >= 20) return "CRITICAL"; - if (consumers >= 5 || mismatches > 0) return "HIGH"; - if (consumers >= 1) return "MEDIUM"; - return "LOW"; -} - -function worseRisk(a: Risk, b: Risk): Risk { - const order: Record = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3 }; - return order[a] >= order[b] ? a : b; -} - -async function fetchFromIds( - graph: IGraphStore, - targetId: string, - type: "FETCHES" | "HANDLES_ROUTE", -): Promise { - const edges = await graph.listEdgesByType(type, { toIds: [targetId] }); - return edges - .map((e) => e.from) - .filter((s) => s.length > 0) - .sort(); -} - -async function resolveFiles( - graph: IGraphStore, - nodeIds: readonly string[], -): Promise { - if (nodeIds.length === 0) return []; - const partners = await graph.listNodes({ ids: [...nodeIds] }); - const set = new Set(); - for (const n of partners) { - if (n.filePath && n.filePath.length > 0) set.add(n.filePath); - } - return Array.from(set).sort(); -} - -async function collectAccessedKeys(graph: IGraphStore, file: string): Promise { - const edges = await graph.listEdgesByType("ACCESSES"); - if (edges.length === 0) return []; - const allIds = new Set(); - for (const e of edges) { - allIds.add(e.from); - allIds.add(e.to); - } - const allNodes = await graph.listNodes({ ids: [...allIds] }); - const byId = new Map(); - for (const n of allNodes) byId.set(n.id, n); - const names = new Set(); - for (const e of edges) { - const src = byId.get(e.from); - if (!src || src.filePath !== file) continue; - const target = byId.get(e.to); - if (!target || target.kind !== "Property") continue; - if (target.name && target.name.length > 0) names.add(target.name); - } - return Array.from(names).sort(); -} - -async function fetchAffectedProcesses( - graph: IGraphStore, - consumerSymbolIds: readonly string[], -): Promise { - if (consumerSymbolIds.length === 0) return []; - const targetSet = new Set(consumerSymbolIds); - const edges = await graph.listEdgesByType("PROCESS_STEP"); - const procIds = new Set(); - for (const e of edges) { - if (!targetSet.has(e.to)) continue; - procIds.add(e.from); - } - if (procIds.size === 0) return []; - const partners = await graph.listNodes({ ids: [...procIds] }); - const out: string[] = []; - for (const n of partners) { - if (n.kind === "Process") out.push(n.id); - } - return out.sort(); -} diff --git a/packages/mcp/src/tools/owners.ts b/packages/mcp/src/tools/owners.ts index b44ef620..e3a8200c 100644 --- a/packages/mcp/src/tools/owners.ts +++ b/packages/mcp/src/tools/owners.ts @@ -12,6 +12,7 @@ // biome-ignore-all lint/complexity/useLiteralKeys: dot-access disallowed on Record index signatures import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { listOwners } from "@opencodehub/analysis"; import { z } from "zod"; import { toolErrorFromUnknown } from "../error-envelope.js"; import { withNextSteps } from "../next-step-hints.js"; @@ -42,13 +43,6 @@ const OwnersInput = { .describe("Maximum number of contributors to return (default 20, max 100)."), }; -interface OwnerRow { - readonly email: string; - readonly emailHash: string; - readonly name: string; - readonly weight: number; -} - interface OwnersArgs { readonly target: string; readonly repo?: string | undefined; @@ -60,31 +54,7 @@ export async function runOwners(ctx: ToolContext, args: OwnersArgs): Promise { try { - const graph = store.graph; - const ownedBy = await graph.listEdgesByType("OWNED_BY", { fromIds: [args.target] }); - const sorted = [...ownedBy].sort((a, b) => { - const ac = a.confidence ?? 0; - const bc = b.confidence ?? 0; - if (ac !== bc) return bc - ac; - return a.to < b.to ? -1 : a.to > b.to ? 1 : 0; - }); - const sliced = sorted.slice(0, limit); - const contributors = await graph.listNodesByKind("Contributor"); - const contribById = new Map(); - for (const c of contributors) contribById.set(c.id, c); - - const owners: OwnerRow[] = []; - for (const edge of sliced) { - const c = contribById.get(edge.to); - if (c === undefined) continue; - const plain = typeof c.emailPlain === "string" ? c.emailPlain : ""; - owners.push({ - email: plain, - emailHash: c.emailHash, - name: c.name, - weight: edge.confidence ?? 0, - }); - } + const owners = await listOwners(store.graph, args.target, limit); const header = `Owners for ${args.target} in ${resolved.name} (${owners.length}):`; const body = diff --git a/packages/mcp/src/tools/route-map.ts b/packages/mcp/src/tools/route-map.ts index 73551bd1..2e821ca9 100644 --- a/packages/mcp/src/tools/route-map.ts +++ b/packages/mcp/src/tools/route-map.ts @@ -15,6 +15,7 @@ // biome-ignore-all lint/complexity/useLiteralKeys: dot-access disallowed on Record index signatures import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { listRouteMap } from "@opencodehub/analysis"; import { z } from "zod"; import { toolErrorFromUnknown } from "../error-envelope.js"; import { withNextSteps } from "../next-step-hints.js"; @@ -38,16 +39,6 @@ const RouteMapInput = { .describe("Reserved for a future framework filter; currently ignored."), }; -interface RouteRow { - readonly id: string; - readonly url: string; - readonly method: string; - readonly filePath: string; - readonly responseKeys: readonly string[]; - readonly handlers: readonly string[]; - readonly consumers: readonly string[]; -} - interface RouteMapArgs { readonly repo?: string | undefined; readonly repo_uri?: string | undefined; @@ -59,50 +50,11 @@ interface RouteMapArgs { export async function runRouteMap(ctx: ToolContext, args: RouteMapArgs): Promise { const call = await withStore(ctx, args, async (store, resolved) => { try { - const graph = store.graph; - const opts: { - pathLike?: string; - methods?: readonly ("GET" | "POST" | "PUT" | "DELETE" | "PATCH")[]; - limit?: number; - } = { limit: 500 }; - if (args.route !== undefined && args.route.length > 0) opts.pathLike = args.route; - if ( - args.method !== undefined && - ["GET", "POST", "PUT", "DELETE", "PATCH"].includes(args.method) - ) { - opts.methods = [args.method as "GET" | "POST" | "PUT" | "DELETE" | "PATCH"]; - } - let listed = await graph.listRoutes(opts); - if ( - args.method !== undefined && - !["GET", "POST", "PUT", "DELETE", "PATCH"].includes(args.method) - ) { - listed = listed.filter((r) => r.method === args.method); - } - const sortedRoutes = [...listed].sort((a, b) => { - if (a.url !== b.url) return a.url < b.url ? -1 : 1; - const am = a.method ?? ""; - const bm = b.method ?? ""; - return am < bm ? -1 : am > bm ? 1 : 0; + const routes = await listRouteMap(store.graph, { + ...(args.route !== undefined ? { route: args.route } : {}), + ...(args.method !== undefined ? { method: args.method } : {}), }); - const routes: RouteRow[] = []; - for (const r of sortedRoutes) { - const [handlers, consumers] = await Promise.all([ - fetchRelationFromIds(graph, r.id, "HANDLES_ROUTE"), - fetchRelationFromIds(graph, r.id, "FETCHES"), - ]); - routes.push({ - id: r.id, - url: stringOr(r.url, ""), - method: stringOr(r.method, ""), - filePath: stringOr(r.filePath, ""), - responseKeys: r.responseKeys ?? [], - handlers, - consumers, - }); - } - const header = `Routes (${routes.length}) for ${resolved.name}${ args.route ? ` · url~${args.route}` : "" }${args.method ? ` · method=${args.method}` : ""}:`; @@ -158,21 +110,3 @@ export function registerRouteMapTool(server: McpServer, ctx: ToolContext): void async (args) => fromToolResult(await runRouteMap(ctx, args)), ); } - -async function fetchRelationFromIds( - graph: import("@opencodehub/storage").IGraphStore, - routeId: string, - type: "HANDLES_ROUTE" | "FETCHES", -): Promise { - const edges = await graph.listEdgesByType(type, { toIds: [routeId] }); - return edges - .map((e) => e.from) - .filter((s) => s.length > 0) - .sort(); -} - -function stringOr(v: unknown, fallback: string): string { - if (typeof v === "string") return v; - if (typeof v === "number" || typeof v === "boolean") return String(v); - return fallback; -} diff --git a/packages/mcp/src/tools/shape-check.ts b/packages/mcp/src/tools/shape-check.ts index ee4eb798..57e52d08 100644 --- a/packages/mcp/src/tools/shape-check.ts +++ b/packages/mcp/src/tools/shape-check.ts @@ -16,12 +16,13 @@ * - MISMATCH — at least one accessed key is NOT in responseKeys. * - PARTIAL — no accessed keys found (can't check). * - * `classifyShape` is exported so `api_impact` can reuse it for its - * `mismatches` count without re-walking the graph. + * `classifyShape` now lives in `@opencodehub/analysis` (so `api_impact` and + * the CLI surface can reuse it) and is re-exported here for backward compat. */ // biome-ignore-all lint/complexity/useLiteralKeys: dot-access disallowed on Record index signatures import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { classifyShape, type ShapeStatus } from "@opencodehub/analysis"; import type { CodeRelation, GraphNode } from "@opencodehub/core-types"; import type { IGraphStore } from "@opencodehub/storage"; import { z } from "zod"; @@ -37,13 +38,16 @@ import { withStore, } from "./shared.js"; +export type { ShapeStatus }; +// Re-export so callers that imported `classifyShape` / `ShapeStatus` from +// this module keep working after the lift into @opencodehub/analysis. +export { classifyShape }; + const ShapeCheckInput = { ...repoArgShape, route: z.string().optional().describe("Substring match against Route.url."), }; -export type ShapeStatus = "MATCH" | "MISMATCH" | "PARTIAL"; - export interface ConsumerShape { readonly file: string; readonly accessedKeys: readonly string[]; @@ -148,18 +152,6 @@ export async function loadRouteShapes( return routes; } -/** Classify a set of accessed keys against responseKeys. */ -export function classifyShape( - accessedKeys: readonly string[], - responseKeys: readonly string[], -): { status: ShapeStatus; missing: readonly string[] } { - if (accessedKeys.length === 0) return { status: "PARTIAL", missing: [] }; - const known = new Set(responseKeys); - const missing = accessedKeys.filter((k) => !known.has(k)); - if (missing.length === 0) return { status: "MATCH", missing: [] }; - return { status: "MISMATCH", missing }; -} - async function collectConsumerShapes( graph: IGraphStore, accessesEdges: readonly CodeRelation[],