export function nextjsFileToApiPath(filePath) {
    const pagesMatch = filePath.match(/(?:^|\/)pages\/(api\/.+)\.[jt]sx?$/);
    if (pagesMatch) {
        let route = "/" + pagesMatch[1];
        route = route.replace(/\/index$/, "");
        route = route
            .replace(/\[\.\.\.(\w+)\]/g, "{$1}")
            .replace(/\[(\w+)\]/g, "{$1}");
        return route;
    }
    const appMatch = filePath.match(/(?:^|\/)app\/(api\/.+)\/route\.[jt]sx?$/);
    if (appMatch) {
        let route = "/" + appMatch[1];
        route = route
            .replace(/\[\.\.\.(\w+)\]/g, "{$1}")
            .replace(/\[(\w+)\]/g, "{$1}");
        return route;
    }
    return null;
}
// UI component file extensions are unambiguously frontend — never route definitions.
const UI_COMPONENT_EXT = /\.(jsx|tsx|vue|svelte)$/i;
// Frontend-file extensions for UI-change detection (broader than UI_COMPONENT_EXT —
// includes template/markup files that backend-only PRs won't touch). Used by
// testbot-prompts.ts to gate the UI pre-scan and capture-act-capture sections,
// and anywhere else that needs to ask "does this PR touch the frontend?".
// Single source of truth for that question.
export const UI_FILE_EXTENSIONS = ['tsx', 'jsx', 'vue', 'svelte', 'html', 'xml'];
// Git pathspec form of the UI-file filter — usable directly with `git diff`'s
// trailing pathspec args, no external tool needed. Avoids the "agent shell
// has grep aliased to rg" failure mode where `grep -E` becomes `rg -E`
// (which means `--encoding`, not extended regex). Each entry is `*.ext`;
// pass them as `-- '*.tsx' '*.jsx' ...` to git diff.
export const UI_FILE_GIT_PATHSPEC = UI_FILE_EXTENSIONS.map(ext => `'*.${ext}'`).join(' ');
// Pipe-joined form of the UI extensions for use inside an extended regex
// (e.g. `grep -E '\\.(tsx|jsx|vue|svelte|html|xml) '`). Mirrors
// UI_FILE_GIT_PATHSPEC but for grep over a pre-computed diff file.
export const UI_FILE_EXTENSIONS_PIPED = UI_FILE_EXTENSIONS.join('|');
export function parseRouteLine(line, sourceFile) {
    const stripped = line.replace(/^[+-]\s*/, "").trim();
    const isComponent = UI_COMPONENT_EXT.test(sourceFile);
    const decoratorMatch = stripped.match(/@(?:\w+)\.(get|post|put|patch|delete|head|options)\s*\(\s*["']([^"'?#]*)/i);
    if (decoratorMatch) {
        return {
            method: decoratorMatch[1].toUpperCase(),
            path: decoratorMatch[2],
            sourceFile,
        };
    }
    const flaskMatch = stripped.match(/@\w+\.route\s*\(\s*["']([^"'?#]+)["'][^)]*methods\s*=\s*\[["'](\w+)["']/i);
    if (flaskMatch) {
        return {
            method: flaskMatch[2].toUpperCase(),
            path: flaskMatch[1],
            sourceFile,
        };
    }
    const expressMatch = stripped.match(/(?:router|app)\.(get|post|put|patch|delete)\s*\(\s*["']([^"'?#]+)/i);
    if (expressMatch && !isComponent) {
        return {
            method: expressMatch[1].toUpperCase(),
            path: expressMatch[2],
            sourceFile,
        };
    }
    const springMatch = stripped.match(/@(Get|Post|Put|Patch|Delete)Mapping\s*(?:\(\s*(?:(?:value|path)\s*=\s*)?["']([^"'?#]+)["'])?/i);
    if (springMatch && springMatch[2]) {
        return {
            method: springMatch[1].toUpperCase(),
            path: springMatch[2],
            sourceFile,
        };
    }
    // @RequestMapping at method level — without explicit method = RequestMethod.X it matches
    // all HTTP verbs, so emit "MULTI" as the sentinel rather than guessing GET.
    const requestMappingMatch = stripped.match(/@RequestMapping\s*\(\s*(?:(?:value|path)\s*=\s*)?["']([^"'?#]+)["']/i);
    if (requestMappingMatch) {
        const methodMatches = [...stripped.matchAll(/RequestMethod\.([A-Z]+)/gi)];
        const method = methodMatches.length === 1 ? methodMatches[0][1].toUpperCase() : "MULTI";
        return { method, path: requestMappingMatch[1], sourceFile };
    }
    const nestjsMatch = stripped.match(/@(Get|Post|Put|Patch|Delete)\s*\(\s*["']([^"'?#]+)["']/i);
    if (nestjsMatch && !isComponent) {
        return {
            method: nestjsMatch[1].toUpperCase(),
            path: nestjsMatch[2],
            sourceFile,
        };
    }
    const nextjsMethodMatch = stripped.match(/req\.method\s*===?\s*["'](GET|POST|PUT|PATCH|DELETE)["']/i);
    if (nextjsMethodMatch) {
        const apiPath = nextjsFileToApiPath(sourceFile);
        if (apiPath) {
            return {
                method: nextjsMethodMatch[1].toUpperCase(),
                path: apiPath,
                sourceFile,
            };
        }
    }
    // Next.js guard clause: `if (req.method !== "POST")` means the handler IS for POST
    const nextjsGuardMatch = stripped.match(/req\.method\s*!==?\s*["'](GET|POST|PUT|PATCH|DELETE)["']/i);
    if (nextjsGuardMatch) {
        const apiPath = nextjsFileToApiPath(sourceFile);
        if (apiPath) {
            return {
                method: nextjsGuardMatch[1].toUpperCase(),
                path: apiPath,
                sourceFile,
            };
        }
    }
    const appRouterExportMatch = stripped.match(/export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\s*\(/i);
    if (appRouterExportMatch) {
        const apiPath = nextjsFileToApiPath(sourceFile);
        if (apiPath) {
            return {
                method: appRouterExportMatch[1].toUpperCase(),
                path: apiPath,
                sourceFile,
            };
        }
    }
    // Go (Gin, Echo, Chi): <ident>.GET/POST/...("/path", handler)
    const ginMatch = stripped.match(/(?:\w+)\.(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*\(\s*["']([^"'?#]+)/i);
    if (ginMatch && sourceFile.endsWith(".go")) {
        return { method: ginMatch[1].toUpperCase(), path: ginMatch[2], sourceFile };
    }
    // Go stdlib: http.HandleFunc/Handle
    const goHandleMatch = stripped.match(/(?:HandleFunc|Handle)\s*\(\s*["']([^"'?#]+)/);
    if (goHandleMatch && sourceFile.endsWith(".go")) {
        return { method: "MULTI", path: goHandleMatch[1], sourceFile };
    }
    // Ruby (Rails) and Elixir (Phoenix): verb "/path", ...
    // Rails: get "/users", to: "users#index"
    // Phoenix: get "/users", UserController, :index
    const railsMatch = stripped.match(/^(get|post|put|patch|delete)\s+["']([^"'?#]+)/i);
    if (railsMatch && (sourceFile.endsWith(".rb") ||
        sourceFile.endsWith(".ex") ||
        sourceFile.endsWith(".exs") ||
        /routes/i.test(sourceFile))) {
        return { method: railsMatch[1].toUpperCase(), path: railsMatch[2], sourceFile };
    }
    // PHP — Laravel
    const laravelMatch = stripped.match(/Route::(get|post|put|patch|delete|options)\s*\(\s*["']([^"'?#]+)/i);
    if (laravelMatch) {
        return { method: laravelMatch[1].toUpperCase(), path: laravelMatch[2], sourceFile };
    }
    // Rust — Actix
    const actixAttrMatch = stripped.match(/#\[(get|post|put|patch|delete)\s*\(\s*["']([^"'?#]+)/i);
    if (actixAttrMatch) {
        return { method: actixAttrMatch[1].toUpperCase(), path: actixAttrMatch[2], sourceFile };
    }
    // ASP.NET
    const aspnetMatch = stripped.match(/\[Http(Get|Post|Put|Patch|Delete)\s*(?:\(\s*["']([^"'?#]+)["'])?/i);
    if (aspnetMatch) {
        return {
            method: aspnetMatch[1].toUpperCase(),
            path: aspnetMatch[2] || "/",
            sourceFile,
        };
    }
    // Hapi.js
    const hapiMatch = stripped.match(/method:\s*["'](GET|POST|PUT|PATCH|DELETE)["'][^}]*path:\s*["']([^"'?#]+)/i);
    if (hapiMatch && !isComponent) {
        return { method: hapiMatch[1].toUpperCase(), path: hapiMatch[2], sourceFile };
    }
    return null;
}
/**
 * Scan all route definitions in a single source file, combining prefix declarations
 * with method-level annotations. Two orthogonal state machines run in parallel:
 *
 * 1. Class-prefix (Spring, NestJS, ASP.NET, Kotlin):
 *    @Controller/@RestController/@RequestMapping (class level) + class declaration →
 *    activates classPrefix, applied to all @Get/@Post/@GetMapping etc. below.
 *
 * 2. Router-variable prefix (FastAPI, Flask Blueprint):
 *    varName = APIRouter(prefix=...) or Blueprint(url_prefix=...) — handles both
 *    single-line and multi-line declarations — maps the variable name to its prefix.
 *    Applied when @varName.method(...) is seen.
 *
 * Falls back to pure parseRouteLine for all other frameworks (Express inline, Gin,
 * Rails, Laravel, Actix, Hapi, Go stdlib, etc.), which remain unaffected.
 */
export function parseFileEndpoints(content, sourceFile) {
    const results = [];
    let classPrefix = ""; // active prefix for the current class body
    let pendingPrefix = ""; // set by class-level annotation; activated on class declaration
    // FastAPI APIRouter / Flask Blueprint: variable name → prefix
    const routerPrefixes = new Map();
    let pendingRouterVar = ""; // variable name being accumulated in a multi-line declaration
    let pendingPythonDecorator = null;
    let pendingExpressMethod = ""; // HTTP method from a multi-line Express route (e.g. "POST" from `router.post(\n`)
    for (const line of content.split("\n")) {
        const trimmed = line.trim();
        if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("*"))
            continue;
        // ── Class-prefix state machine (Java/Kotlin/TS/C#) ───────────────────────
        // Class declaration → activate any pending prefix (or clear if none was set)
        if (/^(?:(?:public|private|protected|abstract|final|sealed|static|override)\s+)*(?:export\s+)?(?:abstract\s+)?class\s+\w+/.test(trimmed)) {
            classPrefix = pendingPrefix;
            pendingPrefix = "";
            continue;
        }
        // @Controller / @RestController → marks a class-level context (Spring, NestJS)
        if (/@(?:Controller|RestController)\b/.test(trimmed)) {
            const m = trimmed.match(/@(?:Controller|RestController)\s*\(\s*["']([^"'?#]+)["']/i);
            if (m)
                pendingPrefix = m[1]; // explicit path → update pending
            // No path arg → leave pendingPrefix unchanged (may have been set by @RequestMapping)
            continue;
        }
        // @RequestMapping without method= → class-level prefix declaration (Spring/Kotlin)
        if (/@RequestMapping\b/.test(trimmed) && !/method\s*=/.test(trimmed)) {
            const m = trimmed.match(/@RequestMapping\s*\(\s*(?:(?:value|path)\s*=\s*)?["']([^"'?#]+)["']/i);
            pendingPrefix = m ? m[1] : "";
            continue;
        }
        // ASP.NET [Route("prefix")] at start of line → class-level prefix
        if (/^\[Route\s*\(/.test(trimmed)) {
            const m = trimmed.match(/\[Route\s*\(\s*["']([^"'?#]+)["']/i);
            pendingPrefix = m ? m[1] : "";
            continue;
        }
        // ── Router-variable prefix state machine (Python FastAPI / Flask) ─────────
        if (!trimmed.startsWith("@")) {
            // Inside a multi-line APIRouter()/Blueprint() call — collect prefix= / url_prefix=
            if (pendingRouterVar) {
                const prefM = trimmed.match(/\burl_prefix\s*=\s*["']([^"'?#]+)["']/)
                    || trimmed.match(/(?<![a-zA-Z_])prefix\s*=\s*["']([^"'?#]+)["']/);
                if (prefM) {
                    routerPrefixes.set(pendingRouterVar, prefM[1]);
                    pendingRouterVar = "";
                }
                else if (trimmed.startsWith(")")) {
                    pendingRouterVar = ""; // closed without a prefix arg
                }
                continue;
            }
            // Single-line: varName = APIRouter(prefix="/path") or APIRouter(tags=..., prefix="/path")
            const singleM = trimmed.match(/^(\w+)\s*=\s*APIRouter\s*\([^)]*prefix\s*=\s*["']([^"'?#]+)["']/) ||
                trimmed.match(/^(\w+)\s*=\s*Blueprint\s*\([^)]*url_prefix\s*=\s*["']([^"'?#]+)["']/);
            if (singleM) {
                routerPrefixes.set(singleM[1], singleM[2]);
                continue;
            }
            // Multi-line open: varName = APIRouter( or Blueprint( without closing ) on this line
            if (/^(\w+)\s*=\s*(?:APIRouter|Blueprint)\s*\(/.test(trimmed) && !trimmed.includes(")")) {
                const openM = trimmed.match(/^(\w+)/);
                if (openM)
                    pendingRouterVar = openM[1];
                continue;
            }
        }
        if (pendingPythonDecorator) {
            // FastAPI decorators commonly split the route path onto the next line:
            //   @router.delete(
            //       "/{id}",
            //       ...
            //   )
            const pathMatch = trimmed.match(/^(?:path\s*=\s*)?["']([^"'?#]*)/) ||
                trimmed.match(/^value\s*=\s*["']([^"'?#]*)/);
            if (pathMatch) {
                const routerPrefix = routerPrefixes.get(pendingPythonDecorator.routerVar);
                const prefix = routerPrefix?.replace(/\/$/, "");
                const suffix = pathMatch[1].replace(/^\//, "");
                const path = prefix
                    ? (suffix ? `${prefix}/${suffix}` : prefix)
                    : pathMatch[1];
                results.push({
                    method: pendingPythonDecorator.method,
                    path,
                    sourceFile,
                });
                pendingPythonDecorator = null;
                continue;
            }
            if (/^[\]\)\}]+[;,)]*\s*(?:#.*)?$/.test(trimmed) ||
                trimmed.startsWith("@") ||
                /^async\s+def\b|^def\b/.test(trimmed)) {
                pendingPythonDecorator = null;
            }
            else {
                continue;
            }
        }
        // ── Multi-line Express route state machine ────────────────────────────────
        // Handles patterns like:
        //   router.post(
        //       '/:collection',
        //       middleware,
        //       handler
        //   );
        if (pendingExpressMethod) {
            // Look for the first path-like string literal on this line
            const pathMatch = trimmed.match(/["'](\/[^"'?#]*|:[^"'?#]+)["']/);
            if (pathMatch) {
                const isComponent = UI_COMPONENT_EXT.test(sourceFile);
                if (!isComponent) {
                    results.push({
                        method: pendingExpressMethod,
                        path: pathMatch[1],
                        sourceFile,
                    });
                }
                pendingExpressMethod = "";
                continue;
            }
            // If we hit a closing delimiter-only line or another route call, abandon the pending state
            if (/^[\]\)\}]+[;,)]*\s*(?:\/\/.*)?$/.test(trimmed)) {
                pendingExpressMethod = "";
                continue;
            }
            if (/(?:router|app)\.(get|post|put|patch|delete)\s*\(/i.test(trimmed)) {
                // New route call — reset and fall through to parse this line normally
                pendingExpressMethod = "";
            }
            else {
                continue; // Skip middleware/handler lines
            }
        }
        // ── Non-annotation line → clear any dangling pendingPrefix ───────────────
        // Prevents method-level @RequestMapping (no method=) from being misidentified
        if (pendingPrefix && !trimmed.startsWith("@") && !trimmed.startsWith("[")) {
            pendingPrefix = "";
        }
        // ── Parse method-level annotation ─────────────────────────────────────────
        let ep = parseRouteLine(trimmed, sourceFile);
        // NestJS decorator with no path arg → applies to the controller's root path
        if (!ep) {
            const nestjsNoPath = trimmed.match(/@(Get|Post|Put|Patch|Delete)\s*\(\s*\)/i);
            if (nestjsNoPath)
                ep = { method: nestjsNoPath[1].toUpperCase(), path: "", sourceFile };
        }
        if (!ep) {
            // Detect multi-line FastAPI/Flask-style decorators with the path on a later line.
            const pythonDecoratorMultiLine = trimmed.match(/@(\w+)\.(get|post|put|patch|delete|head|options)\s*\(\s*$/i);
            if (pythonDecoratorMultiLine) {
                pendingPythonDecorator = {
                    routerVar: pythonDecoratorMultiLine[1],
                    method: pythonDecoratorMultiLine[2].toUpperCase(),
                };
                continue;
            }
            // Detect multi-line Express route: method call with no path string on same line
            // e.g. "router.post(" or "app.get(\n"
            const expressMultiLine = trimmed.match(/(?:router|app)\.(get|post|put|patch|delete)\s*\(\s*$/i);
            if (expressMultiLine && !UI_COMPONENT_EXT.test(sourceFile)) {
                pendingExpressMethod = expressMultiLine[1].toUpperCase();
            }
            continue;
        }
        // ── Apply prefix ──────────────────────────────────────────────────────────
        if (classPrefix) {
            // Class-based prefix (Spring, NestJS, ASP.NET)
            const prefix = classPrefix.replace(/\/$/, "");
            const suffix = ep.path.replace(/^\//, "");
            if (!suffix) {
                ep.path = prefix || "/";
            }
            else if (!ep.path.startsWith(prefix)) {
                ep.path = `${prefix}/${suffix}`;
            }
        }
        else if (routerPrefixes.size > 0) {
            // Router-variable prefix (FastAPI, Flask Blueprint)
            const varM = trimmed.match(/@(\w+)\.(get|post|put|patch|delete|head|options)\s*\(/i)
                || trimmed.match(/@(\w+)\.route\s*\(/i);
            if (varM) {
                const routerPrefix = routerPrefixes.get(varM[1]);
                if (routerPrefix) {
                    const prefix = routerPrefix.replace(/\/$/, "");
                    const suffix = ep.path.replace(/^\//, "");
                    ep.path = suffix ? `${prefix}/${suffix}` : prefix;
                }
            }
        }
        results.push(ep);
    }
    return results;
}
export const SKIP_PATH_SEGMENTS = new Set(["api", "v1", "v2", "v3", "public"]);
/**
 * Extract the primary resource key from an endpoint path for use as a dedup key.
 *
 * For top-level resources (e.g. "/api/v1/orders/{id}") returns the resource name ("orders").
 * For sub-resources (e.g. "/orders/{id}/items") returns "orders_items" — including the
 * parent prevents "/orders/{id}/items" and "/products/{id}/items" from colliding on "items".
 */
export function extractResourceFromPath(endpointPath) {
    const segments = endpointPath.split("/").filter(Boolean);
    const meaningful = segments.filter((s) => !s.startsWith("{") && !SKIP_PATH_SEGMENTS.has(s));
    if (meaningful.length === 0)
        return "unknown";
    // Sub-resource pattern: /…/<parent>/{param}/<child>
    // Detected when the last raw segment is non-param and the second-to-last is a param.
    const last = segments[segments.length - 1];
    const secondLast = segments.length >= 2 ? segments[segments.length - 2] : "";
    if (!last.startsWith("{") && secondLast.startsWith("{") && meaningful.length >= 2) {
        return `${meaningful[meaningful.length - 2]}_${meaningful[meaningful.length - 1]}`;
    }
    return meaningful[meaningful.length - 1];
}
const GENERIC_ROUTE_BASENAMES = new Set(["api", "app", "handler", "handlers", "index", "route", "routes", "router", "server"]);
function normalizeSourcePath(filePath) {
    return filePath.replace(/\\/g, "/");
}
/**
 * Classify endpoints by cross-referencing `changedFiles` against `scannedEndpoints[].sourceFile`.
 *
 * This replaces `parseEndpointsFromDiff` — instead of regex-parsing diff hunks for route
 * annotations (fragile for 15+ frameworks), it uses the already-scanned endpoint catalog
 * which has full, resolved paths and concrete HTTP methods.
 *
 * For deleted files, the caller must supply endpoints recovered from the base branch
 * (via `parseFileEndpoints` on `git show base:<file>`) in `deletedFileEndpoints`.
 */
export function classifyEndpointsByChangedFiles(diffData, scannedEndpoints, deletedFileEndpoints) {
    const newFileSet = new Set(diffData.newFiles.map(normalizeSourcePath));
    const deletedFileSet = new Set(diffData.deletedFiles.map(normalizeSourcePath));
    // Build sourceFile -> ScannedEndpoint[] map from the post-change catalog.
    // Git diffs use POSIX separators; scanner output can reflect OS-native paths.
    const bySourceFile = new Map();
    for (const ep of scannedEndpoints) {
        const sourceKey = normalizeSourcePath(ep.sourceFile);
        const existing = bySourceFile.get(sourceKey);
        if (existing)
            existing.push(ep);
        else
            bySourceFile.set(sourceKey, [ep]);
    }
    const changedEndpoints = [];
    const newEndpoints = [];
    const unmatchedFiles = [];
    for (const file of diffData.changedFiles) {
        const fileKey = normalizeSourcePath(file);
        // Deleted files won't appear in scanned catalog — handled separately below
        if (deletedFileSet.has(fileKey))
            continue;
        const eps = bySourceFile.get(fileKey);
        if (!eps || eps.length === 0) {
            unmatchedFiles.push(file);
            continue;
        }
        if (newFileSet.has(fileKey)) {
            newEndpoints.push(...eps);
        }
        else {
            changedEndpoints.push(...eps);
        }
    }
    // Removed endpoints: from deleted files, recovered from the base branch.
    // Filter out endpoints that still exist in the current catalog as moved/refactored.
    // Same router-relative paths can exist in sibling files, so only treat them as moved
    // when the source resource also matches.
    function endpointSourceResource(ep) {
        const sourceSegments = ep.sourceFile.split(/[\\/]/).filter(Boolean);
        const base = sourceSegments.at(-1)?.replace(/\.[^.]+$/, "") ?? "";
        const normalizedBase = base.replace(/^(?:old|new)[-_]/i, "").toLowerCase();
        const isDynamicBase = /^\[.*\]$/.test(normalizedBase) || /^\{.*\}$/.test(normalizedBase);
        if (normalizedBase && !isDynamicBase && !GENERIC_ROUTE_BASENAMES.has(normalizedBase)) {
            return normalizedBase;
        }
        if (normalizedBase && GENERIC_ROUTE_BASENAMES.has(normalizedBase)) {
            const contextSegment = [...sourceSegments]
                .slice(0, -1)
                .reverse()
                .map((segment) => segment.replace(/\.[^.]+$/, "").replace(/^(?:old|new)[-_]/i, "").toLowerCase())
                .find((segment) => segment && !GENERIC_ROUTE_BASENAMES.has(segment) && !/^\[.*\]$/.test(segment) && !/^\{.*\}$/.test(segment));
            if (contextSegment)
                return `${contextSegment}/${normalizedBase}`;
        }
        const staticSegments = ep.path.split("/").filter(Boolean).filter((segment) => !/^[:{[]/.test(segment));
        return staticSegments[staticSegments.length - 1]?.toLowerCase() ?? normalizedBase;
    }
    const currentMethodKeys = new Set();
    const currentPathResourceKeys = new Set();
    const currentMultiKeys = new Set();
    const movedKey = (path, resource, method) => method ? `${path}::${resource}::${method}` : `${path}::${resource}`;
    for (const current of scannedEndpoints) {
        const currentResource = endpointSourceResource(current);
        const pathResourceKey = movedKey(current.path, currentResource);
        currentPathResourceKeys.add(pathResourceKey);
        for (const method of current.methods) {
            if (method === "MULTI")
                currentMultiKeys.add(pathResourceKey);
            currentMethodKeys.add(movedKey(current.path, currentResource, method));
        }
    }
    function isMovedEndpoint(deleted, method) {
        const deletedResource = endpointSourceResource(deleted);
        const pathResourceKey = movedKey(deleted.path, deletedResource);
        if (method === "MULTI")
            return currentPathResourceKeys.has(pathResourceKey);
        return currentMultiKeys.has(pathResourceKey) ||
            currentMethodKeys.has(movedKey(deleted.path, deletedResource, method));
    }
    const removedEndpoints = (deletedFileEndpoints ?? [])
        .map((ep) => ({
        ...ep,
        methods: ep.methods.filter((m) => !isMovedEndpoint(ep, m)),
    }))
        .filter((ep) => ep.methods.length > 0);
    // Affected services: same heuristic as before
    const servicePattern = /(?:services?|modules?|apps?)\/([a-z0-9_-]+)/i;
    const affectedServices = [
        ...new Set(diffData.changedFiles
            .map((f) => f.match(servicePattern)?.[1])
            .filter((s) => !!s)),
    ];
    return {
        changedEndpoints,
        newEndpoints,
        removedEndpoints,
        unmatchedFiles,
        changedFiles: diffData.changedFiles,
        currentBranch: diffData.currentBranch,
        baseBranch: diffData.baseBranch,
        affectedServices,
    };
}
