import { z } from "zod";
import * as crypto from "crypto";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import yaml from "js-yaml";
import { simpleGit } from "simple-git";
import { logger } from "../../utils/logger.js";
import { parseWorkspaceAuthType, getDefaultAuthHeader, WorkspaceAuthType, readWorkspaceConfigRaw } from "../../utils/workspaceAuth.js";
import { AnalyticsService } from "../../services/AnalyticsService.js";
import { StateManager, registerSession, storeSessionData, setTestsRepoDir, } from "../../utils/AnalysisStateManager.js";
import { buildRecommendationPrompt } from "../../prompts/test-recommendation/test-recommendation-prompt.js";
import { isFrontendFile, isTestFile } from "../../prompts/test-recommendation/scopeAssessment.js";
import { enumerateCandidateUiPages } from "../../utils/uiPageEnumerator.js";
import { MAX_RECOMMENDATIONS, MAX_TESTS_TO_GENERATE } from "../../prompts/test-recommendation/recommendationSections.js";
import { TestDiscoveryService } from "../../services/TestDiscoveryService.js";
import { ScenarioSource, AnalysisScope } from "../../types/RepositoryAnalysis.js";
import { computeBranchDiff } from "../../utils/branchDiff.js";
import { parseFileEndpoints, extractResourceFromPath, classifyEndpointsByChangedFiles, } from "../../utils/routeParsers.js";
import { scanAllRepoEndpoints, scanRelatedEndpoints, grepRouterMountingContext, findCandidateRouteFiles, } from "../../utils/repoScanner.js";
import { detectProjectMetadata } from "../../utils/projectMetadata.js";
import { draftScenariosFromEndpoints } from "../../utils/scenarioDrafting.js";
import { buildAnalysisOutputText } from "../../prompts/test-recommendation/analysisOutputPrompt.js";
import { parseTraceFile, discoverTraceFiles, discoverPlaywrightZips, } from "../../utils/trace-parser.js";
import { TestSource } from "../../types/TestAnalysis.js";
import { parsePRComments } from "../../utils/pr-comment-parser.js";
/** Exported for testing: maps a parsed trace result to a TraceFile. */
export function buildTraceFileEntry(tracePath, result) {
    return {
        path: tracePath,
        format: result.format,
        analyzed: true,
        userFlows: result.userFlows.map((f) => f.flowId),
    };
}
const TOOL_NAME = "skyramp_analyze_changes";
const SECURITY_RELEVANT_DIFF_PATTERN = /\b(?:auth|authorization|permission|permissions|admin[-_\s]?key|x-admin-key|role|roles|rbac|owner|ownership|guard|auth[-_\s]?middleware|permission[-_\s]?middleware|require[-_\s]?(?:auth|admin|role)|authorize|authorized|authenticated|require_admin_key|destructive)\b/i;
export function isSecurityRelevantDiff(diffContent) {
    return SECURITY_RELEVANT_DIFF_PATTERN.test(diffContent);
}
// Must match testbot/src/constants.ts BOT_EMAIL
const BOT_EMAIL = "test-bot@skyramp.dev";
/**
 * Get files added by the last bot commit that still exist on disk.
 * Fallback for when PR comment file names don't match actual files
 * (e.g. after a force push that rewrote history).
 */
async function getBotCommittedFiles(repoPath) {
    try {
        const git = simpleGit(repoPath);
        const botShaRaw = await git.raw([
            "log", "--author=" + BOT_EMAIL, "-1", "--format=%H",
        ]);
        const lastBotSha = botShaRaw.trim();
        if (!lastBotSha)
            return [];
        const diffOutput = await git.raw([
            "diff-tree", "--no-commit-id", "--name-only", "-r", "--diff-filter=A", lastBotSha,
        ]);
        return diffOutput
            .trim()
            .split("\n")
            .filter(Boolean)
            .filter(f => {
            const abs = path.join(repoPath, f);
            return fs.existsSync(abs);
        });
    }
    catch {
        return [];
    }
}
/**
 * Get files changed by user commits (excluding bot commits) since the last bot commit.
 * Focuses on user intent — what did the developer push after the bot ran?
 * Returns null if no bot commit exists (first run) or on error.
 *
 * Uses git.raw() throughout because simple-git's .log() parser cannot
 * reliably handle custom --format flags. Filters out bot-authored commits
 * client-side because git's --not flag applies to revision specs, not --author.
 */
async function getUserChangedFiles(repoPath) {
    try {
        const git = simpleGit(repoPath);
        // Find the last bot commit — use git.raw() because simple-git's .log()
        // parser cannot handle a custom --format flag reliably.
        const botShaRaw = await git.raw([
            "log", "--author=" + BOT_EMAIL, "-1", "--format=%H",
        ]);
        const lastBotSha = botShaRaw.trim();
        if (!lastBotSha)
            return null;
        // Get all commits in the range, then filter out bot-authored ones client-side.
        // git's --not flag applies to revision specs, not --author.
        const rangeLog = await git.raw([
            "log", "--format=%H %ae", `${lastBotSha}..HEAD`,
        ]);
        const userShas = rangeLog
            .trim()
            .split("\n")
            .filter(Boolean)
            .filter(line => !line.endsWith(BOT_EMAIL))
            .map(line => line.split(" ")[0]);
        if (userShas.length === 0)
            return [];
        // Union all files touched by user commits
        const fileSet = new Set();
        for (const sha of userShas) {
            const filesRaw = await git.raw([
                "diff-tree", "--no-commit-id", "--name-only", "-r", sha,
            ]);
            filesRaw.trim().split("\n").filter(Boolean).forEach(f => fileSet.add(f));
        }
        return Array.from(fileSet);
    }
    catch {
        return null;
    }
}
const NON_APP_PATTERNS = [
    // CI/CD
    /^\.github\//,
    /^\.circleci\//,
    /^\.gitlab-ci/,
    /^\.travis\.yml$/,
    /^Jenkinsfile/,
    /^\.buildkite\//,
    /^\.drone\.yml$/,
    // Docs
    /\.md$/i,
    /\.mdx$/i,
    /\.rst$/i,
    /\.txt$/i,
    /^docs\//i,
    /^documentation\//i,
    /^CHANGELOG/i,
    /^CONTRIBUTING/i,
    /^README/i,
    /^LICENSE/i,
    // Lock files & dependency manifests (not the manifest itself, just locks)
    /package-lock\.json$/,
    /yarn\.lock$/,
    /pnpm-lock\.yaml$/,
    /poetry\.lock$/,
    /Pipfile\.lock$/,
    /Gemfile\.lock$/,
    /composer\.lock$/,
    /go\.sum$/,
    /Cargo\.lock$/,
    // Config / infra (non-code)
    /^\.env/,
    /\.eslintrc/,
    /\.prettierrc/,
    /\.editorconfig$/,
    /^\.vscode\//,
    /^\.idea\//,
    /docker-compose.*\.ya?ml$/i,
    /^Dockerfile/i,
    /^\.dockerignore$/,
    /^\.gitignore$/,
    /^\.gitattributes$/,
    /tsconfig.*\.json$/,
    /jest\.config\./,
    /babel\.config\./,
    /^renovate\.json$/,
    /^\.pre-commit-config/,
];
// ── filterEndpointsBySpec ──────────────────────────────────────────────────
// Pure helper extracted so unit tests can exercise the filtering + merge logic
// without spinning up the full analyzeChanges handler.
export function filterEndpointsBySpec(scannedEndpoints, specPaths, specPathItems, diffChangedPaths) {
    if (!specPaths || specPaths.size === 0)
        return scannedEndpoints;
    const filtered = scannedEndpoints.filter(ep => {
        const normalized = ep.path.replace(/:[a-zA-Z_][a-zA-Z0-9_]*/g, m => `{${m.slice(1)}}`);
        if (specPaths.has(ep.path) || specPaths.has(normalized))
            return true;
        if (diffChangedPaths.has(ep.path))
            return true;
        return false;
    });
    const scannedPathSet = new Set(filtered.map(ep => ep.path));
    // Also track normalized (:param → {param}) forms so Express-style scanned paths
    // (e.g. /api/v1/users/:id) don't produce a duplicate when the spec uses /api/v1/users/{id}.
    const scannedNormalizedSet = new Set(filtered.map(ep => ep.path.replace(/:[a-zA-Z_][a-zA-Z0-9_]*/g, m => `{${m.slice(1)}}`)));
    const HTTP_VERBS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
    for (const specPath of specPaths) {
        if (!scannedPathSet.has(specPath) && !scannedNormalizedSet.has(specPath)) {
            const pathObj = specPathItems[specPath];
            const specMethods = Object.keys(pathObj ?? {})
                .filter(k => HTTP_VERBS.has(k))
                .map(m => m.toUpperCase());
            if (specMethods.length > 0) {
                filtered.push({ path: specPath, methods: specMethods, sourceFile: "" });
            }
        }
    }
    return filtered;
}
const GRAPHQL_EXT = /\.(graphql|gql)$/i;
const GRAPHQL_SCHEMA_CONTENT_PATTERN = /^\s*(type\s+(Query|Mutation|Subscription)\s*\{|schema\s*\{|extend\s+type|directive\s+@)/m;
const GRAPHQL_IMPLEMENTATION_CONTENT_PATTERN = /(?:@Resolver\b|from\s+["'](?:@nestjs\/graphql|apollo-server|graphql-yoga|type-graphql)["']|\b(?:ApolloServer|GraphQLObjectType|GraphQLSchema|makeExecutableSchema|buildSchema)\b|(?:\btypeDefs\b[\s\S]{0,200}\bresolvers\b|\bresolvers\b[\s\S]{0,200}\btypeDefs\b))/m;
/** Path-based detection for non-code GraphQL artifacts.
 *  Matches "graphql" as a directory segment for files without a code extension
 *  (e.g. services/graphql/schema), but NOT filenames that merely contain
 *  "graphql" (e.g. graphql-test-route.ts). */
const GRAPHQL_DIR_PATTERN = /(?:^|[/\\])graphql(?:[/\\]|$)/i;
/** Code file extensions — these should use content detection, not directory heuristic,
 *  because .ts/.js files in a graphql/ directory are often resolvers or route-adjacent
 *  code and should not trigger the GraphQL-only early return by path alone. */
const CODE_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|py|rb|go|java|kt|rs|cs)$/i;
export async function isGraphQLFile(filePath, repositoryPath) {
    if (GRAPHQL_EXT.test(filePath))
        return true;
    // Only use directory heuristic for non-code files (e.g. extensionless files).
    // Code files in a graphql/ dir may be REST-reachable resolver glue; use content detection instead.
    if (GRAPHQL_DIR_PATTERN.test(filePath) && !CODE_EXT.test(filePath))
        return true;
    try {
        const absPath = path.join(repositoryPath, filePath);
        const fileContent = await fs.promises.readFile(absPath, "utf-8");
        return GRAPHQL_SCHEMA_CONTENT_PATTERN.test(fileContent) ||
            GRAPHQL_IMPLEMENTATION_CONTENT_PATTERN.test(fileContent);
    }
    catch {
        return false;
    }
}
function isGraphQLEndpointPath(endpointPath) {
    return /(?:^|\/)graphql(?:\/|$)/i.test(endpointPath);
}
async function isUnsupportedGraphQLEndpoint(endpoint, repositoryPath, checkGraphQLFile = (filePath) => isGraphQLFile(filePath, repositoryPath)) {
    return isGraphQLEndpointPath(endpoint.path) ||
        await checkGraphQLFile(endpoint.sourceFile);
}
export async function filterUnsupportedGraphQLEndpoints(endpoints, repositoryPath) {
    const graphqlFileCache = new Map();
    const checkGraphQLFile = (filePath) => {
        if (!filePath)
            return Promise.resolve(false);
        const cached = graphqlFileCache.get(filePath);
        if (cached)
            return cached;
        const result = isGraphQLFile(filePath, repositoryPath);
        graphqlFileCache.set(filePath, result);
        return result;
    };
    const filtered = [];
    for (const endpoint of endpoints) {
        if (!(await isUnsupportedGraphQLEndpoint(endpoint, repositoryPath, checkGraphQLFile))) {
            filtered.push(endpoint);
        }
    }
    return filtered;
}
function isNonApplicationFile(filePath) {
    return NON_APP_PATTERNS.some((p) => p.test(filePath));
}
const ROUTE_FILE_PATTERN = /route|controller|endpoint|handler|view|urls|api|router|service|gateway|resolver|\bserver\b/i;
const ROUTE_FILE_BASENAME_PATTERN = /\bapp\b|\bmain\b/i;
const SOURCE_EXTS = /\.(ts|tsx|js|jsx|py|java|kt|go|rb|php|rs|cs|ex|exs)$/;
/**
 * Recover endpoints from files deleted in this branch by reading their
 * content from the base branch via `git show`. Only checks files whose
 * name matches route-file heuristics to keep I/O bounded.
 */
async function recoverDeletedFileEndpoints(repositoryPath, baseBranch, deletedFiles) {
    const candidates = deletedFiles.filter((f) => SOURCE_EXTS.test(f) && (ROUTE_FILE_PATTERN.test(f) || ROUTE_FILE_BASENAME_PATTERN.test(path.basename(f))));
    if (candidates.length === 0)
        return [];
    const git = simpleGit(repositoryPath);
    const results = [];
    const endpointMap = new Map();
    for (const file of candidates) {
        try {
            const content = await git.show([`${baseBranch}:${file}`]);
            for (const ep of parseFileEndpoints(content, file)) {
                const normalizedPath = ep.path.startsWith("/") ? ep.path : `/${ep.path}`;
                const key = `${file}::${normalizedPath}`;
                const existing = endpointMap.get(key);
                if (existing) {
                    existing.methods.add(ep.method);
                }
                else {
                    endpointMap.set(key, {
                        path: normalizedPath,
                        methods: new Set([ep.method]),
                        sourceFile: file,
                    });
                }
            }
        }
        catch (err) {
            logger.debug("Failed to recover endpoints from deleted file", {
                file,
                baseBranch,
                error: err instanceof Error ? err.message : String(err),
            });
        }
    }
    for (const data of endpointMap.values()) {
        results.push({
            path: data.path,
            methods: Array.from(data.methods),
            sourceFile: data.sourceFile,
        });
    }
    return results;
}
export const analyzeChangesInputSchema = {
    repositoryPath: z
        .string()
        .describe("Absolute path to the repository root"),
    scope: z
        .enum(["full_repo", "branch_diff"])
        .default("branch_diff")
        .optional()
        .describe("Analysis scope. 'full_repo': scans all API endpoints in the repository. 'branch_diff': scans only API endpoints affected by current branch changes — faster for CI use. Default: 'branch_diff'."),
    baseBranch: z
        .string()
        .optional()
        .describe("Base branch for diff (auto-detected if omitted)"),
    testDirectory: z
        .string()
        .optional()
        .describe("Directory containing existing tests (auto-detected if omitted)"),
    topN: z
        .number()
        .default(MAX_RECOMMENDATIONS)
        .optional()
        .describe(`Number of ranked test recommendations to generate. Defaults to ${MAX_RECOMMENDATIONS}.`),
    maxGenerate: z
        .number()
        .int()
        .min(0)
        .optional()
        .describe(`Number of tests to generate and execute. Defaults to ${MAX_TESTS_TO_GENERATE} (diff mode) or all recommendations (full repo).`),
    prNumber: z
        .number()
        .optional()
        .describe("GitHub PR number. When provided, fetches previous TestBot comments on this PR and skips re-recommending tests already suggested in earlier commits — reduces duplicate recommendations across multiple pushes to the same PR."),
    stateOutputFile: z
        .string()
        .refine((v) => path.isAbsolute(v), { message: "stateOutputFile must be an absolute path" })
        .optional()
        .describe("Absolute path where the state file should be written. When provided, overrides the default auto-generated temp path so the caller can locate it without log parsing."),
    testsRepoDir: z
        .string()
        .refine((v) => path.isAbsolute(v), { message: "testsRepoDir must be an absolute path" })
        .optional()
        .describe("Absolute path to a separate test repository clone. When set, existing test discovery scans this directory instead of repositoryPath. Used in cross-repo test delivery mode where tests live in a separate repo."),
};
export function registerAnalyzeChangesTool(server) {
    server.registerTool(TOOL_NAME, {
        annotations: {
            readOnlyHint: false, // writes a state file to disk
            destructiveHint: false,
            idempotentHint: false,
            openWorldHint: true, // may fetch PR comments from GitHub
        },
        description: `Scan repository API endpoints and discover existing tests — first step of the unified Test Health Analysis Flow.

Combines API endpoint scanning, branch diff computation, and test discovery into a single state file consumed by \`skyramp_analyze_test_health\` and \`skyramp_actions\`.

**Output:** stateFile path + ranked test recommendations + enrichment instructions for calling \`skyramp_recommend_tests\`.`,
        // TODO: Define outputSchema here instead of embedding structured output format in the
        // description string — per Archit's review comment. outputSchema reduces token usage
        // by letting the MCP client understand the response shape structurally rather than
        // through natural language in the description.
        inputSchema: analyzeChangesInputSchema,
    }, async (params, extra) => {
        let errorResult;
        const sendProgress = async (progress, total, message) => {
            const progressToken = extra._meta?.progressToken;
            if (progressToken !== undefined) {
                const notification = {
                    method: "notifications/progress",
                    params: { progressToken, progress, total, message },
                };
                await extra.sendNotification(notification);
            }
        };
        try {
            const analysisScope = params.scope === "branch_diff" || !params.scope
                ? AnalysisScope.CurrentBranchDiff
                : AnalysisScope.FullRepo;
            logger.info("Analyze changes tool invoked", {
                repositoryPath: params.repositoryPath,
                analysisScope,
            });
            await sendProgress(0, 100, "Starting analysis...");
            // ── Step 1: Branch diff ──
            let diffData;
            if (analysisScope === AnalysisScope.CurrentBranchDiff) {
                await sendProgress(10, 100, "Computing branch diff...");
                try {
                    diffData = await computeBranchDiff(params.repositoryPath, params.baseBranch);
                    logger.info("Branch diff computed", {
                        currentBranch: diffData.currentBranch,
                        baseBranch: diffData.baseBranch,
                        changedFiles: diffData.changedFiles.length,
                    });
                }
                catch (error) {
                    const msg = error instanceof Error ? error.message : String(error);
                    logger.warning("Failed to obtain branch diff, continuing without diff", { error: msg });
                }
            }
            // ── Early return: all changed files are non-application ──
            // Use user-only commits since last bot run (if a bot commit exists) so that
            // bot-committed test files don't count as application changes.
            if (analysisScope === AnalysisScope.CurrentBranchDiff && diffData && diffData.changedFiles.length > 0) {
                const userFiles = await getUserChangedFiles(params.repositoryPath);
                const filesToCheck = userFiles ?? diffData.changedFiles;
                const appFiles = filesToCheck.filter(f => !isNonApplicationFile(f));
                if (filesToCheck.length > 0 && appFiles.length === 0) {
                    logger.info("All user-changed files are non-application — skipping analysis", {
                        changedFiles: filesToCheck,
                    });
                    return {
                        content: [{
                                type: "text",
                                text: `All ${filesToCheck.length} changed file(s) are non-application (CI/CD, docs, lock files, config). No test analysis needed for this diff.\n\nChanged files: ${filesToCheck.join(", ")}`,
                            }],
                    };
                }
            }
            // ── Step 2: Scan endpoints ──
            let scannedEndpoints = [];
            let rawRelatedEndpointCount;
            if (analysisScope !== AnalysisScope.CurrentBranchDiff) {
                await sendProgress(25, 100, "Scanning all repository endpoints...");
                try {
                    scannedEndpoints = scanAllRepoEndpoints(params.repositoryPath);
                    logger.info("Pre-scanned repo endpoints", {
                        count: scannedEndpoints.length,
                    });
                }
                catch (err) {
                    logger.warning("Endpoint pre-scan failed", {
                        error: err instanceof Error ? err.message : String(err),
                    });
                }
            }
            else if (diffData) {
                await sendProgress(25, 100, "Scanning related endpoints from diff...");
                try {
                    scannedEndpoints = scanRelatedEndpoints(params.repositoryPath, diffData.changedFiles);
                    rawRelatedEndpointCount = scannedEndpoints.length;
                    logger.info("Scanned related endpoints", {
                        count: scannedEndpoints.length,
                    });
                }
                catch (err) {
                    rawRelatedEndpointCount = 0;
                    logger.warning("Related endpoint scan failed", {
                        error: err instanceof Error ? err.message : String(err),
                    });
                }
                const beforeGraphQLFilter = scannedEndpoints.length;
                scannedEndpoints = await filterUnsupportedGraphQLEndpoints(scannedEndpoints, params.repositoryPath);
                if (scannedEndpoints.length !== beforeGraphQLFilter) {
                    logger.info("Filtered unsupported GraphQL endpoints from related scan", {
                        before: beforeGraphQLFilter,
                        after: scannedEndpoints.length,
                    });
                }
                // No fallback to scanAllRepoEndpoints in PR mode.
                // If the scanner found 0 related endpoints, the PR likely touches
                // non-route code (services, models, schemas, client SDK). Flooding
                // all repo endpoints into the prompt causes the LLM to test
                // irrelevant neighboring endpoints instead of focusing on the diff.
            }
            await sendProgress(40, 100, "Classifying changed endpoints...");
            // ── Step 2.5: Classify endpoints by changed files ──
            // Cross-reference changedFiles against scannedEndpoints[].sourceFile to
            // identify new/modified/removed endpoints — replaces fragile regex parsing
            // of diff hunks. Scanned endpoints always have full paths and concrete
            // HTTP methods, eliminating the need for path resolution and MULTI sentinels.
            let classifiedEndpoints;
            if (diffData) {
                // Recover endpoints from deleted files by reading base-branch content
                const deletedFileEndpoints = diffData.deletedFiles.length > 0
                    ? await recoverDeletedFileEndpoints(params.repositoryPath, diffData.baseBranch, diffData.deletedFiles)
                    : [];
                classifiedEndpoints = classifyEndpointsByChangedFiles(diffData, scannedEndpoints, deletedFileEndpoints);
                classifiedEndpoints = {
                    ...classifiedEndpoints,
                    // changed/new endpoints come from scannedEndpoints, which is already
                    // GraphQL-filtered in branch-diff mode. Removed endpoints are recovered
                    // from the base branch separately and still need unsupported-protocol filtering.
                    removedEndpoints: await filterUnsupportedGraphQLEndpoints(classifiedEndpoints.removedEndpoints, params.repositoryPath),
                };
                logger.info("Classified endpoints from changed files", {
                    changed: classifiedEndpoints.changedEndpoints.length,
                    new: classifiedEndpoints.newEndpoints.length,
                    removed: classifiedEndpoints.removedEndpoints.length,
                    unmatched: classifiedEndpoints.unmatchedFiles.length,
                });
            }
            // ── Early return: GraphQL-only diff — REST testing not supported ──
            // Uses (userFiles ?? diffData.changedFiles) to match the existing non-app
            // early-return pattern — bot-committed .graphql files must not trigger this.
            if (analysisScope === AnalysisScope.CurrentBranchDiff &&
                classifiedEndpoints &&
                classifiedEndpoints.changedEndpoints.length === 0 &&
                classifiedEndpoints.newEndpoints.length === 0 &&
                classifiedEndpoints.removedEndpoints.length === 0 &&
                diffData) {
                const userFiles = await getUserChangedFiles(params.repositoryPath);
                const filesToCheck = userFiles ?? diffData.changedFiles;
                // Exclude non-application files (docs, CI, configs) before the graphql check.
                // Non-app files are neutral — a README-only diff must NOT fire this early return.
                const appFilesToCheck = filesToCheck.filter(f => !isNonApplicationFile(f));
                const allGraphQL = appFilesToCheck.length > 0 &&
                    (await Promise.all(appFilesToCheck.map(f => isGraphQLFile(f, params.repositoryPath)))).every(Boolean);
                if (allGraphQL) {
                    logger.info("GraphQL-only diff detected — REST testing not supported", {
                        changedFiles: diffData.changedFiles,
                    });
                    return {
                        content: [{
                                type: "text",
                                text: [
                                    "**GraphQL-only diff detected.**",
                                    "",
                                    "The changed files appear to be GraphQL schema, artifact, or endpoint implementation files.",
                                    "Skyramp currently supports REST API testing only — GraphQL introspection,",
                                    "query validation, and type-name grounding are not yet supported.",
                                    "",
                                    "No test recommendations can be generated for this diff.",
                                    "",
                                    `Changed files: ${diffData.changedFiles.join(", ")}`,
                                ].join("\n"),
                            }],
                    };
                }
            }
            await sendProgress(50, 100, "Discovering existing tests...");
            // ── Step 3: Discover existing tests ──
            // Compute changedResources from classified endpoints for test discovery filtering.
            // undefined    → full-repo mode (no diff context)
            // []           → PR mode, no endpoints found → skip external tests
            // [...names]   → PR mode with resolved resource names → filter external by relevance
            let changedResources;
            if (!classifiedEndpoints) {
                changedResources = undefined;
            }
            else {
                const allClassified = [
                    ...classifiedEndpoints.changedEndpoints,
                    ...classifiedEndpoints.newEndpoints,
                    ...classifiedEndpoints.removedEndpoints,
                ];
                if (allClassified.length > 0) {
                    // Scanned endpoints always have full paths — extractResourceFromPath
                    // never returns "unknown" for properly resolved paths.
                    const resolved = allClassified
                        .map((ep) => extractResourceFromPath(ep.path))
                        .filter((r, i, arr) => r !== "unknown" && arr.indexOf(r) === i);
                    changedResources = resolved.length > 0 ? resolved : ["unknown"];
                }
                else if (classifiedEndpoints.unmatchedFiles.length > 0) {
                    // Changed files don't map to any endpoint (e.g. schema, model, or
                    // migration changes near route files). Use ["unknown"] so external
                    // tests get name-only entries — enough for the LLM to infer coverage
                    // from filenames without flooding context with full extraction of
                    // hundreds of irrelevant test files.
                    changedResources = ["unknown"];
                }
                else {
                    changedResources = [];
                }
            }
            let existingTests = [];
            let discoveredRelevantExternalPaths = [];
            try {
                const testDiscoveryService = new TestDiscoveryService();
                setTestsRepoDir(params.testsRepoDir);
                const testScanPath = params.testsRepoDir ?? params.repositoryPath;
                const discoveryResult = await testDiscoveryService.discoverTests(testScanPath, { changedResources });
                existingTests = discoveryResult.tests.map((test) => ({
                    testFile: test.testFile,
                    testType: test.testType,
                    language: test.language,
                    framework: test.framework,
                    apiSchema: test.apiSchema,
                    apiEndpoint: test.apiEndpoint,
                    source: test.source,
                    generatedAt: test.generatedAt,
                }));
                // Use the authoritative list from discovery — includes relevant files even
                // when regex endpoint extraction failed (e.g. helper-abstracted HTTP calls).
                discoveredRelevantExternalPaths = discoveryResult.relevantExternalTestPaths;
                logger.info("Test discovery completed", {
                    totalTests: existingTests.length,
                    relevantExternal: discoveredRelevantExternalPaths.length,
                });
            }
            catch (err) {
                logger.warning("Test discovery failed, continuing with empty list", {
                    error: err instanceof Error ? err.message : String(err),
                });
            }
            await sendProgress(65, 100, "Reading workspace config...");
            // ── Step 4: Read workspace config ──
            let wsBaseUrl = "";
            let wsAuthHeader;
            let wsSchemaPath = "";
            let wsAuthMethod = "none";
            let wsAuthType;
            let wsAuthScheme;
            const rawConfig = await readWorkspaceConfigRaw(params.repositoryPath);
            if (rawConfig) {
                const svc = rawConfig.services?.[0];
                if (svc?.api?.baseUrl)
                    wsBaseUrl = svc.api.baseUrl;
                if (svc?.api?.authHeader !== undefined)
                    wsAuthHeader = svc.api.authHeader;
                if (svc?.api?.schemaPath)
                    wsSchemaPath = svc.api.schemaPath;
                wsAuthType = parseWorkspaceAuthType(svc?.api?.authType);
                wsAuthScheme = svc?.api?.authScheme;
                // If authHeader not explicitly set, infer from authType using the canonical mapping.
                if (!wsAuthHeader) {
                    const inferred = getDefaultAuthHeader(wsAuthType);
                    if (inferred) {
                        wsAuthHeader = inferred;
                    }
                    else if (wsAuthScheme !== undefined) {
                        // authScheme is set but authType is unrecognized (e.g. a custom value not in the
                        // enum). authScheme only applies to Authorization headers, so infer it.
                        // Use !== undefined so an intentionally empty authScheme ("") still triggers this.
                        wsAuthHeader = "Authorization";
                    }
                }
                // apiKey requires an explicit authHeader — getDefaultAuthHeader returns "" for it.
                // Warn rather than silently producing requests with no auth header.
                if (wsAuthType === WorkspaceAuthType.ApiKey && !wsAuthHeader) {
                    logger.warning("workspace.yml has authType: apiKey but no authHeader is set — " +
                        "tests will be sent without an auth header and will likely receive 401/403. " +
                        "Add authHeader: <X-Your-Key-Header> to .skyramp/workspace.yml.");
                }
                if (wsAuthHeader) {
                    // Use the parsed authType directly instead of pattern-matching on the header name.
                    // "custom" sentinel preserves the auth signal when authType is unrecognized.
                    wsAuthMethod = wsAuthType ?? "custom";
                }
            }
            // ── Step 4b: Fetch OpenAPI spec and extract valid paths ──
            // spec and specPaths are hoisted so Change 5 (spec merge) can access spec.paths[specPath]
            // outside this try block without a scope error.
            let specPaths;
            let spec;
            let specFetchSucceeded = false;
            if (wsSchemaPath) {
                try {
                    const isUrl = wsSchemaPath.startsWith("http://") || wsSchemaPath.startsWith("https://");
                    const SPEC_FETCH_TIMEOUT_MS = 10_000;
                    let specText;
                    if (isUrl) {
                        const specRes = await fetch(wsSchemaPath, { signal: AbortSignal.timeout(SPEC_FETCH_TIMEOUT_MS) });
                        if (!specRes.ok) {
                            throw new Error(`HTTP ${specRes.status} ${specRes.statusText} fetching spec at ${wsSchemaPath}`);
                        }
                        specText = await specRes.text();
                    }
                    else {
                        specText = fs.readFileSync(path.resolve(params.repositoryPath, wsSchemaPath), "utf-8");
                    }
                    // js-yaml handles both JSON and YAML specs (dep: js-yaml ^4.1.1)
                    spec = yaml.load(specText);
                    specPaths = new Set(Object.keys((spec && typeof spec === "object" ? spec.paths : null) ?? {}));
                    // Only treat spec as authoritative when it actually has usable path entries
                    specFetchSucceeded = spec && typeof spec === "object" &&
                        spec.paths !== null && typeof spec.paths === "object" &&
                        specPaths.size > 0;
                    logger.info("Loaded OpenAPI spec paths", { count: specPaths.size, source: wsSchemaPath });
                }
                catch (err) {
                    logger.warning("Could not load OpenAPI spec — continuing without path validation", {
                        schemaPath: wsSchemaPath,
                        error: err instanceof Error ? err.message : String(err),
                    });
                }
            }
            // ── Step 4c: Filter scanned endpoints against spec ──
            // Sequencing: scannedEndpoints is populated at Step 2 (~line 400),
            // but wsSchemaPath and specPaths aren't known until Step 4b (above).
            // All of 1b, 5, 10a, and 8 must run here — after specPaths is assigned
            // and before Step 7 (~line 604) builds skeletonEndpoints.
            if (specPaths && specPaths.size > 0) {
                // Build a set of paths that the current diff explicitly changed — these
                // are preserved even if missing from spec (spec may lag the code on new PRs).
                // removedEndpoints intentionally excluded: deleted paths won't appear in
                // scannedEndpoints (they no longer exist in code), so there is nothing to
                // preserve — including them would only produce spurious spec-lag warnings.
                const diffChangedPaths = new Set([
                    ...(classifiedEndpoints?.changedEndpoints ?? []).map(ep => ep.path),
                    ...(classifiedEndpoints?.newEndpoints ?? []).map(ep => ep.path),
                ]);
                const beforeCount = scannedEndpoints.length;
                // ── Steps 4c + 4c-merge: filter against spec, merge spec-only paths ──
                scannedEndpoints = filterEndpointsBySpec(scannedEndpoints, specPaths, spec.paths ?? {}, diffChangedPaths);
                logger.info("Filtered scanned endpoints against OpenAPI spec", {
                    before: beforeCount, after: scannedEndpoints.length,
                    delta: scannedEndpoints.length - beforeCount, // positive = net added (spec merge), negative = net removed
                });
                // Warn when diff-changed endpoints were missing from spec — indicates spec lag
                const specLagPaths = [...diffChangedPaths].filter(p => !specPaths.has(p) &&
                    !specPaths.has(p.replace(/:[a-zA-Z_][a-zA-Z0-9_]*/g, m => `{${m.slice(1)}}`)));
                if (specLagPaths.length > 0) {
                    logger.warning("Spec may be lagging code — diff-changed paths missing from spec (kept in catalog)", {
                        paths: specLagPaths, schemaPath: wsSchemaPath,
                    });
                }
            }
            // ── Step 4d: Filter unsupported protocol endpoints (GraphQL) ──
            // Must run AFTER spec-merge above — specs may include /graphql and the
            // merge step would re-add it if this ran earlier.
            {
                const beforeUnsupported = scannedEndpoints.length;
                scannedEndpoints = await filterUnsupportedGraphQLEndpoints(scannedEndpoints, params.repositoryPath);
                if (scannedEndpoints.length < beforeUnsupported) {
                    logger.info("Filtered unsupported protocol endpoints (GraphQL)", {
                        removed: beforeUnsupported - scannedEndpoints.length,
                    });
                }
            }
            // ── Step 5: Detect project metadata ──
            const projectMeta = detectProjectMetadata(params.repositoryPath);
            // ── Step 6: Trace files ──
            let traceResult;
            const traceFiles = discoverTraceFiles(params.repositoryPath);
            if (traceFiles.length > 0) {
                try {
                    traceResult = await parseTraceFile(traceFiles[0]);
                    logger.info("Parsed trace file", {
                        file: traceFiles[0],
                        entries: traceResult.entries.length,
                    });
                }
                catch (err) {
                    logger.warning("Trace parsing failed", {
                        error: err instanceof Error ? err.message : String(err),
                    });
                }
            }
            // ── Step 7: Build skeleton endpoints ──
            const skeletonResponse = (method) => method === "POST"
                ? { statusCode: 201, description: "Created" }
                : method === "DELETE"
                    ? { statusCode: 204, description: "No Content" }
                    : { statusCode: 200, description: "OK" };
            const skeletonEndpoints = scannedEndpoints.map((ep) => ({
                path: ep.path,
                resourceGroup: ep.path.split("/").filter(Boolean).pop() || "unknown",
                pathParams: (ep.path.match(/\{(\w+)\}/g) || []).map((p) => ({
                    name: p.slice(1, -1),
                    type: "string",
                    required: true,
                })),
                methods: ep.methods.map((m) => ({
                    method: m,
                    description: "",
                    queryParams: [],
                    authRequired: true,
                    sourceFile: ep.sourceFile,
                    interactions: [
                        {
                            description: `${m} ${ep.path}`,
                            type: "success",
                            request: {},
                            response: skeletonResponse(m),
                        },
                    ],
                })),
            }));
            // ── Step 8: Merge trace interactions ──
            if (traceResult && traceResult.entries.length > 0) {
                for (const entry of traceResult.entries) {
                    let rawPath = entry.path;
                    try {
                        rawPath = new URL(rawPath).pathname;
                    }
                    catch {
                        /* already a path */
                    }
                    const normalizedPath = rawPath
                        .replace(/\/[0-9a-f-]{20,}/gi, "/{id}")
                        .replace(/\/\d+/g, "/{id}");
                    const existing = skeletonEndpoints.find((ep) => {
                        const epNorm = ep.path.replace(/\{\w+\}/g, "/{id}");
                        return epNorm === normalizedPath;
                    });
                    if (existing) {
                        const methodObj = existing.methods.find((m) => m.method === entry.method);
                        if (methodObj) {
                            const alreadyHasStatus = methodObj.interactions.some((i) => i.response.statusCode === entry.statusCode);
                            if (!alreadyHasStatus) {
                                const traceRequest = {};
                                if (entry.requestBody)
                                    traceRequest.body = entry.requestBody;
                                if (entry.queryParams)
                                    traceRequest.queryParams = entry.queryParams;
                                methodObj.interactions.push({
                                    description: `${entry.method} ${entry.path} \u2192 ${entry.statusCode} (from trace)`,
                                    type: "success",
                                    request: Object.keys(traceRequest).length > 0 ? traceRequest : {},
                                    response: {
                                        statusCode: entry.statusCode,
                                        description: `Observed in trace (${traceResult.format})`,
                                        ...(entry.responseBody
                                            ? { body: entry.responseBody }
                                            : {}),
                                    },
                                });
                            }
                        }
                    }
                }
            }
            // ── Step 9: Draft scenarios ──
            // Only new endpoints are passed as "diff-direct" — draftDiffDirectScenarios
            // generates success-oriented scenarios (200/201/204) which are wrong for
            // removed endpoints. Removal coverage (verify-404) is handled by the LLM
            // from the diffContext.removedEndpoints signal in the recommendation prompt.
            // Classified endpoints have full paths and concrete methods (no MULTI sentinels).
            const newEndpointsForDrafting = classifiedEndpoints?.newEndpoints.flatMap((ep) => ep.methods.map((m) => ({
                method: m,
                path: ep.path,
                sourceFile: ep.sourceFile,
            }))) ?? [];
            const changedEndpointsForSecurityExpansion = classifiedEndpoints?.changedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
                method: m,
                path: ep.path,
                sourceFile: ep.sourceFile,
            }))) ?? [];
            const securityRelevantDiff = Boolean(diffData?.diffContent && isSecurityRelevantDiff(diffData.diffContent));
            // Full-repo mode: no diff context, so seed scenario drafting from the entire
            // skeletonEndpoints catalog. We gate on analysisScope (not just array length)
            // to avoid drafting catalog-wide scenarios for PR-mode diffs that happened to
            // add zero new endpoints (only changed or removed existing ones).
            const fullRepoMode = analysisScope !== AnalysisScope.CurrentBranchDiff;
            const scenarioDraftSeed = newEndpointsForDrafting.length > 0
                ? newEndpointsForDrafting
                : fullRepoMode
                    ? skeletonEndpoints.flatMap(ep => ep.methods.map(m => ({
                        method: typeof m === "string" ? m : m.method,
                        path: ep.path,
                        sourceFile: m.sourceFile ?? "",
                    })))
                    : [];
            const codeInferredScenarios = draftScenariosFromEndpoints(skeletonEndpoints, scenarioDraftSeed, wsAuthType, {
                changedEndpoints: changedEndpointsForSecurityExpansion,
                securityRelevantDiff,
            });
            let allDraftedScenarios = codeInferredScenarios;
            if (traceResult && traceResult.userFlows.length > 0) {
                const traceScenarios = traceResult.userFlows
                    .slice(0, 5)
                    .map((flow, idx) => ({
                    scenarioName: `trace-flow-${idx + 1}`,
                    description: `User flow from trace: ${flow.entries.map((e) => `${e.method} ${e.path}`).join(" \u2192 ")}`,
                    category: "workflow",
                    priority: "high",
                    steps: flow.entries.map((e, stepIdx) => ({
                        order: stepIdx + 1,
                        method: e.method,
                        path: e.path,
                        description: `${e.method} ${e.path} \u2192 ${e.statusCode}`,
                        interactionType: e.statusCode < 400
                            ? "success"
                            : "error",
                        requestBody: e.requestBody,
                        responseBody: e.responseBody,
                        expectedStatusCode: e.statusCode,
                    })),
                    chainingKeys: [],
                    requiresAuth: true,
                    estimatedComplexity: flow.entries.length > 3
                        ? "complex"
                        : "moderate",
                    source: ScenarioSource.Trace,
                }));
                allDraftedScenarios = [...traceScenarios, ...codeInferredScenarios];
            }
            await sendProgress(80, 100, "Building unified state...");
            // ── Step 10: Build full RepositoryAnalysis for ranked recommendations ──
            const sessionId = crypto.randomUUID();
            // Build existing test locations map (type → file list) for deduplication in recommendations.
            // Order per type: relevant external (with covers) → Skyramp → low-relevance external (name-only).
            // This ensures the LLM and programmatic dedup encounter external coverage signals first.
            // testFile is kept absolute for filesystem use; displayPath is relativized for prompt display.
            const relevantExternalSet = new Set(discoveredRelevantExternalPaths);
            const displayPath = (absPath) => path.relative(params.repositoryPath, absPath);
            const testLocationsByType = {};
            const addEntry = (type, entry) => {
                testLocationsByType[type] = testLocationsByType[type]
                    ? `${testLocationsByType[type]}, ${entry}`
                    : entry;
            };
            // Pass 1: relevant external tests with extracted endpoints
            for (const t of existingTests) {
                if (t.source !== TestSource.External)
                    continue;
                if (!relevantExternalSet.has(t.testFile))
                    continue;
                const dp = displayPath(t.testFile);
                const entry = t.apiEndpoint
                    ? `${dp} [external] (covers: ${t.apiEndpoint})`
                    : `${dp} [external]`;
                addEntry(t.testType || "unknown", entry);
            }
            // Pass 2: Skyramp tests — tagged [skyramp] so the LLM can positively identify the source
            for (const t of existingTests) {
                if (t.source === TestSource.External)
                    continue;
                const dp = displayPath(t.testFile);
                const entry = t.apiEndpoint
                    ? `${dp} [skyramp] (covers: ${t.apiEndpoint})`
                    : `${dp} [skyramp]`;
                addEntry(t.testType || "unknown", entry);
            }
            // Pass 3: low-relevance external tests (name-only) — capped to avoid context overflow
            const LOW_RELEVANCE_CAP = 20;
            let lowRelevanceCount = 0;
            let lowRelevanceSuppressed = 0;
            for (const t of existingTests) {
                if (t.source !== TestSource.External)
                    continue;
                if (relevantExternalSet.has(t.testFile))
                    continue;
                if (lowRelevanceCount < LOW_RELEVANCE_CAP) {
                    addEntry(t.testType || "unknown", `${displayPath(t.testFile)} [external]`);
                    lowRelevanceCount++;
                }
                else {
                    lowRelevanceSuppressed++;
                }
            }
            if (lowRelevanceSuppressed > 0) {
                // Append a summary so the LLM knows more files exist
                const summaryType = "unknown";
                addEntry(summaryType, `[${lowRelevanceSuppressed} more external test file(s) not shown — low relevance to this PR]`);
            }
            // Use the authoritative relevant paths from discovery — includes files where
            // regex extraction failed but path/name scoring identified as PR-relevant.
            // Relativize against repositoryPath to avoid leaking absolute machine paths
            // into the prompt, state file, and PR reports.
            const relevantExternalTestPaths = discoveredRelevantExternalPaths.map(p => path.relative(params.repositoryPath, p));
            // Build the full RepositoryAnalysis object — same structure as analyzeRepositoryTool
            // so buildRecommendationPrompt can reason over enriched endpoint + scenario data
            // Build diffContext from classifiedEndpoints — ScannedEndpoint already
            // has full paths and concrete methods, so no grouping/MULTI handling needed.
            const diffContext = classifiedEndpoints ? {
                currentBranch: classifiedEndpoints.currentBranch,
                baseBranch: classifiedEndpoints.baseBranch,
                changedFiles: classifiedEndpoints.changedFiles,
                newEndpoints: classifiedEndpoints.newEndpoints.map((ep) => ({
                    path: ep.path,
                    methods: ep.methods.map((m) => ({
                        method: m,
                        sourceFile: ep.sourceFile,
                        interactionCount: 0,
                    })),
                })),
                modifiedEndpoints: classifiedEndpoints.changedEndpoints.map((ep) => ({
                    path: ep.path,
                    methods: ep.methods.map((m) => ({
                        method: m,
                        sourceFile: ep.sourceFile,
                        changeType: "modified",
                    })),
                })),
                removedEndpoints: classifiedEndpoints.removedEndpoints.map((ep) => ({
                    path: ep.path,
                    methods: ep.methods.map((m) => ({
                        method: m,
                        sourceFile: ep.sourceFile,
                        changeType: "removed",
                    })),
                })),
                affectedServices: classifiedEndpoints.affectedServices,
                summary: "",
            } : undefined;
            const fullAnalysis = {
                metadata: {
                    repositoryName: path.basename(params.repositoryPath),
                    analysisDate: new Date().toISOString(),
                    scanDepth: "full",
                    analysisScope,
                },
                projectClassification: {
                    projectType: projectMeta.projectType,
                    primaryLanguage: projectMeta.primaryLanguage,
                    primaryFramework: projectMeta.primaryFramework,
                    deploymentPattern: projectMeta.deploymentPattern,
                },
                technologyStack: {
                    languages: projectMeta.languages,
                    frameworks: projectMeta.frameworks,
                    runtime: projectMeta.runtime,
                    keyDependencies: [],
                },
                businessContext: {
                    mainPurpose: "",
                    userFlows: [],
                    dataFlows: [],
                    integrationPatterns: [],
                    draftedScenarios: allDraftedScenarios,
                },
                artifacts: {
                    openApiSpecs: wsSchemaPath ? [{ path: wsSchemaPath, version: "from-workspace-config", endpointCount: 0, baseUrl: wsBaseUrl, authType: wsAuthMethod }] : [],
                    playwrightRecordings: discoverPlaywrightZips(params.repositoryPath).map(p => ({ path: p, description: "" })),
                    traceFiles: traceResult ? [buildTraceFileEntry(traceFiles[0], traceResult)] : [],
                    notFound: [],
                },
                apiEndpoints: {
                    totalCount: skeletonEndpoints.reduce((acc, ep) => acc + ep.methods.length, 0),
                    baseUrl: wsBaseUrl,
                    endpoints: skeletonEndpoints,
                },
                authentication: {
                    method: wsAuthMethod,
                    configLocation: wsAuthHeader ? ".skyramp/workspace.yml" : "",
                    envVarsRequired: [],
                    setupExample: wsAuthHeader ? `${wsAuthHeader}: <token>` : "",
                },
                infrastructure: {
                    isContainerized: projectMeta.isContainerized,
                    hasDockerCompose: projectMeta.hasDockerCompose,
                    hasKubernetes: false,
                    hasCiCd: projectMeta.hasCiCd,
                },
                existingTests: {
                    frameworks: [...new Set(existingTests.map((t) => t.framework).filter(Boolean))],
                    coverage: { unit: 0, integration: 0, e2e: 0, ui: 0, load: 0, contract: 0, smoke: 0 },
                    testLocations: testLocationsByType,
                    hasCoverageReports: false,
                    relevantExternalTestPaths,
                },
                ...(diffContext ? { branchDiffContext: diffContext } : {}),
            };
            // Store RecommendationState in memory so it's compatible with skyramp_recommend_tests if needed
            const recommendationState = {
                repositoryPath: params.repositoryPath,
                analysisScope,
                analysis: fullAnalysis,
            };
            storeSessionData(sessionId, recommendationState);
            // ── Step 11: Build UnifiedAnalysisState and save ──
            // fullAnalysis lives only in inMemorySessionStore (for MCP resources
            // and registerRecommendTestsPrompt). The disk state carries only the
            // slim fields that downstream tools (health, execute, actions) need.
            // routerMountContext and candidateRouteFiles are computed here so they
            // can be persisted to the state file for downstream tools (health, drift).
            // Without them, analyzeTestHealth would work only off the static catalog
            // which has wrong paths for nested resources and unsupported frameworks.
            const routerMountContext = grepRouterMountingContext(params.repositoryPath);
            const routeLikeUnmatchedFiles = [];
            for (const file of classifiedEndpoints?.unmatchedFiles ?? []) {
                const routeLike = SOURCE_EXTS.test(file) &&
                    (ROUTE_FILE_PATTERN.test(file) || ROUTE_FILE_BASENAME_PATTERN.test(path.basename(file)));
                if (routeLike && !(await isGraphQLFile(file, params.repositoryPath))) {
                    routeLikeUnmatchedFiles.push(file);
                }
            }
            const shouldIncludeCandidateRouteFiles = analysisScope !== AnalysisScope.CurrentBranchDiff ||
                rawRelatedEndpointCount === 0 ||
                scannedEndpoints.length === 0 ||
                routeLikeUnmatchedFiles.length > 0;
            let candidateRouteFiles;
            if (shouldIncludeCandidateRouteFiles) {
                candidateRouteFiles = [];
                for (const file of findCandidateRouteFiles(params.repositoryPath)) {
                    if (!(await isGraphQLFile(file, params.repositoryPath))) {
                        candidateRouteFiles.push(file);
                    }
                }
            }
            // Write the full diff to a temp file before building state so the path
            // can be persisted and read by analyzeTestHealthTool for per-line detection.
            let diffFilePath;
            if (diffData?.diffContent) {
                diffFilePath = path.join(os.tmpdir(), `skyramp-diff-${sessionId}.diff`);
                await fs.promises.writeFile(diffFilePath, diffData.diffContent, { encoding: "utf-8", mode: 0o600 });
            }
            // Read router mount files server-side (size-capped) so the LLM has them
            // inline and doesn't need an extra read step when no spec is available.
            const ROUTER_INLINE_LIMIT = 4096; // bytes — skip files larger than ~4 KB
            const ROUTER_INLINE_MAX_FILES = 3;
            const routerFileContents = routerMountContext
                .slice(0, ROUTER_INLINE_MAX_FILES)
                .flatMap((f) => {
                try {
                    const absPath = path.isAbsolute(f) ? f : path.join(params.repositoryPath, f);
                    const stat = fs.statSync(absPath);
                    if (stat.size > ROUTER_INLINE_LIMIT)
                        return [];
                    const content = fs.readFileSync(absPath, "utf-8").trimEnd();
                    return [{ file: f, content }];
                }
                catch {
                    return [];
                }
            });
            // Compute UI context from the diff's changed files using the shared
            // `isFrontendFile` classifier. Persisting this in the stateFile lets
            // skyramp_analyze_test_health and the recommendation prompt consume the
            // same classification without re-deriving it. Absent on backend-only PRs.
            //
            // candidateUiPages is enumerated programmatically via the strategy
            // ladder in uiPageEnumerator (framework route grep, source-grounded
            // routes, root fallback). The same enumeration powers the
            // skyramp_ui_analyze_changes pre-flight tool that the testbot prompt
            // calls before this tool, so both code paths see the same candidates.
            const uiContext = await (async () => {
                const changedFiles = classifiedEndpoints?.changedFiles ?? [];
                if (changedFiles.length === 0)
                    return undefined;
                // Filter to frontend source files only — exclude test files, which
                // pass isFrontendFile (any .ts under a frontend directory matches
                // the tier-3 rule) but aren't UI source we'd want to ground page
                // enumeration in.
                const frontendFiles = changedFiles.filter((f) => isFrontendFile(f) && !isTestFile(f));
                if (frontendFiles.length === 0)
                    return undefined;
                const candidateUiPages = await enumerateCandidateUiPages(params.repositoryPath, frontendFiles);
                return {
                    changedFrontendFiles: frontendFiles,
                    candidateUiPages,
                };
            })();
            const unifiedState = {
                existingTests,
                newEndpoints: newEndpointsForDrafting,
                analysisScope,
                ...(uiContext ? { uiContext } : {}),
                repositoryAnalysis: {
                    skeletonEndpoints,
                    projectMeta,
                    wsBaseUrl,
                    wsAuthHeader,
                    wsAuthType,
                    wsAuthScheme,
                    wsSchemaPath,
                    wsAuthMethod,
                    specFetchSucceeded,
                    scenarios: allDraftedScenarios,
                    diffFilePath,
                    testLocations: testLocationsByType,
                    diff: classifiedEndpoints
                        ? {
                            currentBranch: classifiedEndpoints.currentBranch,
                            baseBranch: classifiedEndpoints.baseBranch,
                            changedFiles: classifiedEndpoints.changedFiles,
                            newEndpoints: classifiedEndpoints.newEndpoints.map((ep) => ({
                                path: ep.path,
                                methods: ep.methods.map((m) => ({
                                    method: m,
                                    sourceFile: ep.sourceFile,
                                })),
                            })),
                            modifiedEndpoints: classifiedEndpoints.changedEndpoints.map((ep) => ({
                                path: ep.path,
                                methods: ep.methods.map((m) => ({
                                    method: m,
                                    sourceFile: ep.sourceFile,
                                })),
                            })),
                            removedEndpoints: classifiedEndpoints.removedEndpoints.map((ep) => ({
                                path: ep.path,
                                methods: ep.methods.map((m) => ({
                                    method: m,
                                    sourceFile: ep.sourceFile,
                                })),
                            })),
                            affectedServices: classifiedEndpoints.affectedServices,
                        }
                        : undefined,
                    sessionId,
                    routerMountContext,
                    candidateRouteFiles,
                },
            };
            // Clean up old state files (>24 hours) before creating new one
            const stateDir = params.stateOutputFile
                ? path.dirname(path.resolve(params.stateOutputFile))
                : undefined;
            try {
                await StateManager.cleanupOldFiles(24, stateDir);
            }
            catch (error) {
                logger.warning(`Failed to cleanup old state files: ${error.message}`);
            }
            const stateManager = new StateManager("analysis", sessionId, undefined, params.stateOutputFile);
            await stateManager.writeData(unifiedState, {
                repositoryPath: params.repositoryPath,
                step: "analyze_changes",
            });
            const stateFile = stateManager.getStatePath();
            registerSession(sessionId, stateFile);
            try {
                await server.server.sendResourceListChanged();
            }
            catch {
                // Client may not support resource list notifications
            }
            await sendProgress(90, 100, "Generating ranked recommendations...");
            // ── Step 12: Generate ranked recommendations inline ──
            const topN = params.topN ?? MAX_RECOMMENDATIONS;
            // ── Step 13: Fetch PR comment history for deduplication ──
            let prContext;
            if (params.prNumber) {
                try {
                    // Derive repo owner/name from git remote
                    const { execFileSync } = await import("child_process");
                    const remoteUrl = execFileSync("git", ["-C", params.repositoryPath, "remote", "get-url", "origin"], { encoding: "utf-8", timeout: 5_000 }).trim();
                    // Parse owner/repo from https or ssh remote URLs
                    const match = remoteUrl.match(/[:/]([^/]+)\/([^/.]+?)(\.git)?$/);
                    if (match) {
                        prContext = await parsePRComments(match[1], match[2], params.prNumber);
                        logger.info("Fetched PR comment history", {
                            prNumber: params.prNumber,
                            previousRecommendations: prContext.previousRecommendations.length,
                            implementedFiles: prContext.implementedTestFiles.length,
                        });
                    }
                }
                catch (err) {
                    logger.warning("Failed to fetch PR comment history — continuing without it", {
                        error: err instanceof Error ? err.message : String(err),
                    });
                }
                // Fallback: if comment-based implementedTestFiles is empty (names didn't
                // match disk, e.g. after a force push), recover from the actual bot commit.
                if (prContext && prContext.implementedTestFiles.length === 0 && analysisScope === AnalysisScope.CurrentBranchDiff) {
                    const botFiles = await getBotCommittedFiles(params.repositoryPath);
                    if (botFiles.length > 0) {
                        logger.info("Recovered implementedTestFiles from bot commit (comment names did not match disk)", {
                            files: botFiles,
                        });
                        prContext.implementedTestFiles = botFiles;
                    }
                }
            }
            if (uiContext && uiContext.changedFrontendFiles.length > 0) {
                logger.info("Frontend changes detected — UI rec grounding relies on the agent's own browser_blueprint history", { candidateUiPages: uiContext.candidateUiPages.map((p) => p.url) });
            }
            const recommendationPrompt = buildRecommendationPrompt(fullAnalysis, analysisScope, topN, prContext, wsAuthHeader, wsAuthType, wsAuthScheme, params.maxGenerate, sessionId);
            await sendProgress(100, 100, "Analysis complete.");
            const stateSize = await stateManager.getSizeFormatted();
            const structuredSummary = JSON.stringify({
                sessionId,
                stateFile,
                summary: {
                    repositoryName: path.basename(params.repositoryPath),
                    projectType: projectMeta.projectType,
                    primaryFramework: projectMeta.primaryFramework,
                    existingTestCount: existingTests.length,
                    newEndpointCount: classifiedEndpoints?.newEndpoints.length ?? 0,
                    endpointCount: skeletonEndpoints.reduce((acc, ep) => acc + ep.methods.length, 0),
                },
                // Surface uiContext inline so the testbot prompt can iterate
                // candidateUiPages and inspect changedFrontendFiles without
                // re-reading the stateFile. Absent on backend-only PRs.
                ...(uiContext ? { uiContext } : {}),
                stateFileSize: stateSize,
                nextStep: "Call skyramp_analyze_test_health with stateFile to run drift analysis and health scoring",
            }, null, 2);
            // Build a DiffSummary for buildAnalysisOutputText from classified endpoints.
            const parsedDiffShim = classifiedEndpoints
                ? {
                    currentBranch: classifiedEndpoints.currentBranch,
                    baseBranch: classifiedEndpoints.baseBranch,
                    changedFiles: classifiedEndpoints.changedFiles,
                    diffStat: diffData?.diffStat ?? "",
                    newEndpoints: classifiedEndpoints.newEndpoints.flatMap((ep) => ep.methods.map((m) => ({
                        method: m,
                        path: ep.path,
                        sourceFile: ep.sourceFile,
                    }))),
                    modifiedEndpoints: classifiedEndpoints.changedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
                        method: m,
                        path: ep.path,
                        sourceFile: ep.sourceFile,
                    }))),
                    removedEndpoints: classifiedEndpoints.removedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
                        method: m,
                        path: ep.path,
                        sourceFile: ep.sourceFile,
                    }))),
                    affectedServices: classifiedEndpoints.affectedServices,
                }
                : undefined;
            const outputText = buildAnalysisOutputText({
                sessionId,
                stateFile,
                repositoryPath: params.repositoryPath,
                analysisScope,
                parsedDiff: parsedDiffShim,
                diffFilePath,
                candidateRouteFiles,
                scannedEndpoints,
                wsBaseUrl,
                wsAuthHeader: wsAuthHeader ?? "",
                wsAuthType: wsAuthType ?? "",
                wsSchemaPath,
                specFetchSucceeded,
                routerMountContext,
                routerFileContents,
                unmatchedFiles: classifiedEndpoints?.unmatchedFiles,
                nextTool: "skyramp_analyze_test_health",
            });
            return {
                content: [
                    {
                        type: "text",
                        text: `\`\`\`json\n${structuredSummary}\n\`\`\`\n\n${outputText}\n\n---\n\n## Pre-built Test Catalog — Fill in placeholders from source code, then display verbatim\n⚠️ Do NOT reformat, rename sections, or generate a new catalog. Replace \`<…from source>\` values, then show this output exactly as-is, grouped by test type.\n\n${recommendationPrompt}`,
                    },
                ],
                isError: false,
            };
        }
        catch (error) {
            const errorMessage = error instanceof Error ? error.message : String(error);
            logger.error("Analyze changes tool failed", { error: errorMessage });
            errorResult = {
                content: [
                    {
                        type: "text",
                        text: `Error analyzing changes: ${errorMessage}`,
                    },
                ],
                isError: true,
            };
            return errorResult;
        }
        finally {
            AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, {
                repositoryPath: params.repositoryPath,
            });
        }
    });
}
