Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions packages/analysis/src/api-impact.ts
Original file line number Diff line number Diff line change
@@ -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<readonly ApiImpactRow[]> {
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<RiskLevel, number> = { 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<readonly string[]> {
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<readonly string[]> {
if (nodeIds.length === 0) return [];
const partners = await graph.listNodes({ ids: [...nodeIds] });
const set = new Set<string>();
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<readonly string[]> {
const edges = await graph.listEdgesByType("ACCESSES");
if (edges.length === 0) return [];
const allIds = new Set<string>();
for (const e of edges) {
allIds.add(e.from);
allIds.add(e.to);
}
const allNodes = await graph.listNodes({ ids: [...allIds] });
const byId = new Map<string, GraphNode>();
for (const n of allNodes) byId.set(n.id, n);
const names = new Set<string>();
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<readonly string[]> {
if (consumerSymbolIds.length === 0) return [];
const targetSet = new Set(consumerSymbolIds);
const edges = await graph.listEdgesByType("PROCESS_STEP");
const procIds = new Set<string>();
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();
}
8 changes: 8 additions & 0 deletions packages/analysis/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
53 changes: 53 additions & 0 deletions packages/analysis/src/owners.ts
Original file line number Diff line number Diff line change
@@ -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<readonly OwnerRow[]> {
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<string, (typeof contributors)[number]>();
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;
}
104 changes: 104 additions & 0 deletions packages/analysis/src/route-map.ts
Original file line number Diff line number Diff line change
@@ -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<readonly RouteMapRow[]> {
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<readonly string[]> {
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;
}
26 changes: 26 additions & 0 deletions packages/analysis/src/shape.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading
Loading