/**
* Comparison tools - diff between versions/refs
*/
import { ToolHandler, safeJson, getStore, getEnabledExtractedRepos, sanitize } from "./shared.js";
/**
* Validate git ref format (branch/tag name)
*/
function isValidGitRef(ref: unknown): ref is string {
if (!ref || typeof ref !== "string") return false;
if (ref.length > 250) return false;
if (/\.\./.test(ref)) return false; // No double dots
if (/^\/|\/\/|\/$/g.test(ref)) return false; // No leading/trailing/double slashes
if (/[\x00-\x1F\x7F~^:?*\[\]\\]/.test(ref)) return false; // No special chars
return true;
}
/**
* Validate repository name format
*/
function isValidRepoName(repo: unknown): repo is string {
if (!repo || typeof repo !== "string") return false;
return /^[a-zA-Z0-9._-]{1,100}$/.test(repo);
}
/**
* Validate extractor name
*/
function isValidExtractorName(extractor: unknown): extractor is string {
if (!extractor || typeof extractor !== "string") return false;
const validExtractors = ["nip_usage", "user_flows", "data_flow", "kubernetes", "terraform", "monorepo", "journey_impact"];
return validExtractors.includes(extractor);
}
/**
* Diff two sets and return added/removed items
*/
function diffSets<T>(from: T[], to: T[]): { added: T[]; removed: T[] } {
const fromSet = new Set(from);
const toSet = new Set(to);
return {
added: to.filter((x) => !fromSet.has(x)),
removed: from.filter((x) => !toSet.has(x)),
};
}
/**
* Generate a Mermaid diagram showing diff summary
*/
function generateDiffDiagram(diff: {
nips?: { added: string[]; removed: string[] };
screens?: { added: string[]; removed: string[] };
services?: { added: string[]; removed: string[] };
}): string {
const lines: string[] = ["flowchart LR"];
lines.push(' subgraph Changes["π Changes"]');
if (diff.nips) {
if (diff.nips.added.length > 0) {
lines.push(` nips_added["β
NIPs Added: ${diff.nips.added.join(", ")}"]`);
}
if (diff.nips.removed.length > 0) {
lines.push(` nips_removed["β NIPs Removed: ${diff.nips.removed.join(", ")}"]`);
}
}
if (diff.screens) {
if (diff.screens.added.length > 0) {
const preview = diff.screens.added.slice(0, 3).join(", ");
const more = diff.screens.added.length > 3 ? ` (+${diff.screens.added.length - 3})` : "";
lines.push(` screens_added["β
Screens: ${preview}${more}"]`);
}
if (diff.screens.removed.length > 0) {
lines.push(` screens_removed["β Screens: -${diff.screens.removed.length}"]`);
}
}
if (diff.services) {
if (diff.services.added.length > 0) {
lines.push(` svc_added["β
Services: +${diff.services.added.length}"]`);
}
if (diff.services.removed.length > 0) {
lines.push(` svc_removed["β Services: -${diff.services.removed.length}"]`);
}
}
lines.push(" end");
return lines.join("\n");
}
export const comparisonTools: ToolHandler[] = [
{
name: "compare_versions",
description:
"Show available repos/refs for comparison. Use extract_ref to pull new refs first, then diff_versions to compare.",
schema: { type: "object", properties: {} },
handler: async () => {
const s = await getStore();
const repos = await getEnabledExtractedRepos();
const repoVersions: Record<string, Array<{ refType: string; ref: string }>> = {};
for (const repo of repos) {
const versions = await s.listVersions(repo);
repoVersions[repo] = versions.map((v) => ({ refType: v.refType, ref: v.ref }));
}
return safeJson({
message: "Use diff_versions tool with from_ref and to_ref to compare. Optionally filter by repo or extractor.",
availableVersions: repoVersions,
example: {
from_ref: "v1.0.0",
to_ref: "main",
repo: "(optional) filter to one repo",
extractor: "(optional) nip_usage | user_flows | data_flow | kubernetes | terraform",
},
});
},
},
{
name: "pr_impact_preview",
description: `PR Impact Preview - Analyze what breaks if a PR changes types.
Shows which repos use types that changed between two refs (e.g., main vs PR branch).
Perfect for code review: "This PR changes UserProfile type - these 5 repos use it"
Returns:
- Changed types (added/removed/modified)
- Repos that use each changed type
- Breaking change risk assessment`,
schema: {
type: "object",
properties: {
repo: {
type: "string",
description: "Repository where the PR is (required)",
},
from_ref: {
type: "string",
description: "Base ref (e.g., 'main', 'master')",
},
to_ref: {
type: "string",
description: "PR branch ref (e.g., 'feature/new-auth')",
},
},
required: ["repo", "from_ref", "to_ref"],
},
handler: async (args) => {
const repo = args.repo as string;
const fromRef = args.from_ref as string;
const toRef = args.to_ref as string;
if (!isValidRepoName(repo)) {
return safeJson({ error: "Invalid repository name" });
}
if (!isValidGitRef(fromRef) || !isValidGitRef(toRef)) {
return safeJson({ error: "Invalid git ref" });
}
const s = await getStore();
const { loadFromAllRepos } = await import("./shared.js");
// Helper to normalize type names (same logic as types.ts)
const normalizeTypeName = (name: string): string => {
return name.replace(/[_-]/g, "").toLowerCase();
};
// Load type definitions from both refs
const fromVersions = await s.listVersions(repo);
const fromVersion = fromVersions.find((v) => v.ref === fromRef);
const toVersion = fromVersions.find((v) => v.ref === toRef);
if (!fromVersion || !toVersion) {
return safeJson({
error: "Missing data",
message: `Need both refs extracted. Use extract_ref('${repo}', '${fromRef}') and extract_ref('${repo}', '${toRef}') first.`,
});
}
type TypeData = { types?: Array<{ name: string; fields?: Array<{ name: string }>; kind: string }> } | null;
const fromData = (await s.loadExtractor(
repo,
fromVersion.refType as "branch" | "tag",
fromRef,
"type_definitions"
)) as TypeData;
const toData = (await s.loadExtractor(
repo,
toVersion.refType as "branch" | "tag",
toRef,
"type_definitions"
)) as TypeData;
if (!fromData || !toData) {
return safeJson({
error: "No type data",
message: "Type definitions not found. Ensure type_definitions extractor ran.",
});
}
const fromTypes = fromData.types || [];
const toTypes = toData.types || [];
type TypeDef = { name: string; fields?: Array<{ name: string }>; kind: string };
// Build type maps by normalized name
const fromTypeMap = new Map<string, TypeDef>();
const toTypeMap = new Map<string, TypeDef>();
for (const type of fromTypes) {
fromTypeMap.set(normalizeTypeName(type.name), type);
}
for (const type of toTypes) {
toTypeMap.set(normalizeTypeName(type.name), type);
}
// Find changed types
const changedTypes: Array<{
name: string;
change: "added" | "removed" | "modified";
risk: "high" | "medium" | "low";
fieldChanges?: { added: string[]; removed: string[] };
}> = [];
const allNormalizedNames = new Set([...fromTypeMap.keys(), ...toTypeMap.keys()]);
for (const normalizedName of allNormalizedNames) {
const fromType = fromTypeMap.get(normalizedName);
const toType = toTypeMap.get(normalizedName);
if (!fromType && toType) {
// Added
changedTypes.push({ name: toType.name, change: "added", risk: "low" });
} else if (fromType && !toType) {
// Removed - HIGH RISK
changedTypes.push({ name: fromType.name, change: "removed", risk: "high" });
} else if (fromType && toType) {
// Modified - check field changes
const fromFields = new Set((fromType.fields || []).map((f) => f.name));
const toFields = new Set((toType.fields || []).map((f) => f.name));
const addedFields = [...toFields].filter((f) => !fromFields.has(f));
const removedFields = [...fromFields].filter((f) => !toFields.has(f));
if (addedFields.length > 0 || removedFields.length > 0) {
const risk = removedFields.length > 0 ? "high" : addedFields.length > 0 ? "medium" : "low";
changedTypes.push({
name: fromType.name,
change: "modified",
risk,
fieldChanges: { added: addedFields, removed: removedFields },
});
}
}
}
// Find which repos use these types
const allTypeData = (await loadFromAllRepos("type_definitions")) as Record<
string,
{ types?: Array<{ name: string }> }
>;
const impact: Array<{
type: string;
change: string;
risk: string;
affectedRepos: string[];
fieldChanges?: { added: string[]; removed: string[] };
}> = [];
for (const changed of changedTypes) {
const affectedRepos: string[] = [];
const normalizedChanged = normalizeTypeName(changed.name);
for (const [otherRepo, otherData] of Object.entries(allTypeData)) {
if (otherRepo === repo) continue; // Skip the PR repo itself
const otherTypes = otherData.types || [];
for (const otherType of otherTypes) {
if (normalizeTypeName(otherType.name) === normalizedChanged) {
if (!affectedRepos.includes(otherRepo)) {
affectedRepos.push(otherRepo);
}
}
}
}
impact.push({
type: changed.name,
change: changed.change,
risk: changed.risk,
affectedRepos,
fieldChanges: changed.fieldChanges,
});
}
// Sort by risk and impact
impact.sort((a, b) => {
const riskOrder = { high: 3, medium: 2, low: 1 };
if (riskOrder[a.risk as keyof typeof riskOrder] !== riskOrder[b.risk as keyof typeof riskOrder]) {
return riskOrder[b.risk as keyof typeof riskOrder] - riskOrder[a.risk as keyof typeof riskOrder];
}
return b.affectedRepos.length - a.affectedRepos.length;
});
const summary = {
totalChangedTypes: changedTypes.length,
highRiskChanges: impact.filter((i) => i.risk === "high").length,
affectedRepos: [...new Set(impact.flatMap((i) => i.affectedRepos))].length,
totalImpact: impact.reduce((sum, i) => sum + i.affectedRepos.length, 0),
};
return safeJson({
repo,
from_ref: fromRef,
to_ref: toRef,
summary,
impact,
message:
impact.length === 0
? "β
No breaking changes detected - no other repos use the changed types"
: `β οΈ ${summary.highRiskChanges} high-risk changes affecting ${summary.affectedRepos} repos`,
});
},
},
{
name: "diff_versions",
description: "Compare ecosystem or repo between two refs (branches/tags). Shows added/removed/changed items.",
schema: {
type: "object",
properties: {
from_ref: {
type: "string",
description: "Source ref (branch or tag name) to compare from",
},
to_ref: {
type: "string",
description: "Target ref (branch or tag name) to compare to",
},
repo: {
type: "string",
description: "Optional: limit diff to a specific repo",
},
extractor: {
type: "string",
description:
"Optional: limit diff to specific extractor (nip_usage, user_flows, data_flow, kubernetes, terraform)",
},
},
required: ["from_ref", "to_ref"],
},
handler: async (args) => {
const fromRef = args.from_ref;
const toRef = args.to_ref;
const repoFilter = args.repo;
const extractorFilter = args.extractor;
// Validate from_ref
if (!isValidGitRef(fromRef)) {
return safeJson({
error: "Invalid from_ref",
message: "Please provide a valid branch or tag name for from_ref.",
});
}
// Validate to_ref
if (!isValidGitRef(toRef)) {
return safeJson({
error: "Invalid to_ref",
message: "Please provide a valid branch or tag name for to_ref.",
});
}
// Validate repo filter if provided
if (repoFilter !== undefined && !isValidRepoName(repoFilter)) {
return safeJson({
error: "Invalid repository name",
message: "Repository names must be 1-100 characters containing only alphanumeric characters, hyphens, underscores, and dots.",
});
}
// Validate extractor filter if provided
if (extractorFilter !== undefined && !isValidExtractorName(extractorFilter)) {
return safeJson({
error: "Invalid extractor name",
message: "Valid extractors are: nip_usage, user_flows, data_flow, kubernetes, terraform, monorepo, journey_impact",
});
}
const s = await getStore();
const repos = await getEnabledExtractedRepos();
const targetRepos = repoFilter ? repos.filter((r) => r === repoFilter) : repos;
if (targetRepos.length === 0) {
return safeJson({ error: `No matching repos found${repoFilter ? ` for "${repoFilter}"` : ""}` });
}
const diff: {
summary: {
reposCompared: number;
reposWithChanges: number;
fromRef: string;
toRef: string;
};
byRepo: Record<
string,
{
fromFound: boolean;
toFound: boolean;
extractorDiffs: Record<string, unknown>;
}
>;
aggregated: {
nips?: { added: string[]; removed: string[] };
eventKinds?: { added: number[]; removed: number[] };
screens?: { added: string[]; removed: string[] };
services?: { added: string[]; removed: string[] };
k8sResources?: { added: string[]; removed: string[] };
tfResources?: { added: number; removed: number };
};
} = {
summary: {
reposCompared: targetRepos.length,
reposWithChanges: 0,
fromRef,
toRef,
},
byRepo: {},
aggregated: {},
};
// Collect all changes for aggregation
const allNipsFrom: string[] = [];
const allNipsTo: string[] = [];
const allScreensFrom: string[] = [];
const allScreensTo: string[] = [];
const allServicesFrom: string[] = [];
const allServicesTo: string[] = [];
for (const repo of targetRepos) {
const versions = await s.listVersions(repo);
const fromVersion = versions.find((v) => v.ref === fromRef);
const toVersion = versions.find((v) => v.ref === toRef);
diff.byRepo[repo] = {
fromFound: !!fromVersion,
toFound: !!toVersion,
extractorDiffs: {},
};
if (!fromVersion || !toVersion) continue;
const fromData = await s.load(repo, fromVersion.refType as "branch" | "tag", fromRef);
const toData = await s.load(repo, toVersion.refType as "branch" | "tag", toRef);
if (!fromData || !toData) continue;
let hasChanges = false;
// Compare NIPs
if (!extractorFilter || extractorFilter === "nip_usage") {
const fromNips = fromData.data?.nip_usage as { nips?: Record<string, unknown> } | undefined;
const toNips = toData.data?.nip_usage as { nips?: Record<string, unknown> } | undefined;
const fromKeys = Object.keys(fromNips?.nips || {});
const toKeys = Object.keys(toNips?.nips || {});
allNipsFrom.push(...fromKeys);
allNipsTo.push(...toKeys);
const nipDiff = diffSets(fromKeys, toKeys);
if (nipDiff.added.length > 0 || nipDiff.removed.length > 0) {
diff.byRepo[repo].extractorDiffs.nip_usage = nipDiff;
hasChanges = true;
}
}
// Compare screens
if (!extractorFilter || extractorFilter === "user_flows") {
const fromFlows = fromData.data?.user_flows as { screens?: Array<{ name: string }> } | undefined;
const toFlows = toData.data?.user_flows as { screens?: Array<{ name: string }> } | undefined;
const fromScreens = (fromFlows?.screens || []).map((s) => s.name);
const toScreens = (toFlows?.screens || []).map((s) => s.name);
allScreensFrom.push(...fromScreens);
allScreensTo.push(...toScreens);
const screenDiff = diffSets(fromScreens, toScreens);
if (screenDiff.added.length > 0 || screenDiff.removed.length > 0) {
diff.byRepo[repo].extractorDiffs.user_flows = screenDiff;
hasChanges = true;
}
}
// Compare services
if (!extractorFilter || extractorFilter === "data_flow") {
const fromFlow = fromData.data?.data_flow as { services?: Array<{ name: string }> } | undefined;
const toFlow = toData.data?.data_flow as { services?: Array<{ name: string }> } | undefined;
const fromServices = (fromFlow?.services || []).map((s) => s.name);
const toServices = (toFlow?.services || []).map((s) => s.name);
allServicesFrom.push(...fromServices);
allServicesTo.push(...toServices);
const serviceDiff = diffSets(fromServices, toServices);
if (serviceDiff.added.length > 0 || serviceDiff.removed.length > 0) {
diff.byRepo[repo].extractorDiffs.data_flow = serviceDiff;
hasChanges = true;
}
}
if (hasChanges) {
diff.summary.reposWithChanges++;
}
}
// Aggregate changes
diff.aggregated.nips = diffSets([...new Set(allNipsFrom)], [...new Set(allNipsTo)]);
diff.aggregated.screens = diffSets([...new Set(allScreensFrom)], [...new Set(allScreensTo)]);
diff.aggregated.services = diffSets([...new Set(allServicesFrom)], [...new Set(allServicesTo)]);
// Generate visual diff
const mermaid = generateDiffDiagram(diff.aggregated);
return safeJson({
...diff,
mermaid,
hint: "Display 'mermaid' in a ```mermaid block for visual diff",
});
},
},
];