@skyramp/mcp 0.3.8 → 0.4.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/commands/commandLibrary.d.ts +1 -1
- package/build/commands/commandLibrary.js +3 -3
- package/build/commands/recommendTestsAndExecuteCommand.d.ts +1 -1
- package/build/commands/recommendTestsAndExecuteCommand.js +35 -20
- package/build/commands/testThisEndpointCommand.js +35 -19
- package/build/index.js +9 -3
- package/build/playwright/blueprintDigest.d.ts +15 -0
- package/build/playwright/blueprintDigest.js +152 -0
- package/build/playwright/blueprintDigestStore.d.ts +31 -0
- package/build/playwright/blueprintDigestStore.js +117 -0
- package/build/playwright/registerPlaywrightTools.js +60 -12
- package/build/playwright/traceRecordingPrompt.js +8 -7
- package/build/prompts/enhance-assertions/sharedAssertionRules.js +9 -8
- package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +24 -2
- package/build/prompts/promptAssets.d.ts +20 -0
- package/build/prompts/promptAssets.js +55 -0
- package/build/prompts/sut-setup/modes/dockerComposePrompt.js +19 -5
- package/build/prompts/test-maintenance/actionsInstructions.d.ts +4 -0
- package/build/prompts/test-maintenance/actionsInstructions.js +14 -2
- package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +0 -10
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +2 -11
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +8 -4
- package/build/prompts/test-recommendation/diffExecutionPlan.d.ts +5 -22
- package/build/prompts/test-recommendation/diffExecutionPlan.js +37 -465
- package/build/prompts/test-recommendation/recommendationSections.d.ts +7 -17
- package/build/prompts/test-recommendation/recommendationSections.js +67 -309
- package/build/prompts/test-recommendation/recommendationShared.d.ts +19 -47
- package/build/prompts/test-recommendation/recommendationShared.js +49 -155
- package/build/prompts/test-recommendation/registerRecommendTestsPrompt.d.ts +0 -5
- package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +10 -153
- package/build/prompts/test-recommendation/test-recommendation-prompt.d.ts +2 -29
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +32 -457
- package/build/prompts/testbot/planDeclarations.d.ts +6 -0
- package/build/prompts/testbot/planDeclarations.js +9 -0
- package/build/prompts/testbot/testbot-prompts.d.ts +8 -0
- package/build/prompts/testbot/testbot-prompts.js +256 -381
- package/build/recommendation/answers.d.ts +35 -0
- package/build/recommendation/answers.js +96 -0
- package/build/recommendation/registerPlan.d.ts +49 -0
- package/build/recommendation/registerPlan.js +117 -0
- package/build/recommendation/runVerifiers.d.ts +10 -0
- package/build/recommendation/runVerifiers.js +49 -0
- package/build/recommendation/subjectStep.d.ts +42 -0
- package/build/recommendation/subjectStep.js +86 -0
- package/build/recommendation/types.d.ts +163 -0
- package/build/recommendation/types.js +20 -0
- package/build/recommendation/verifierContracts.d.ts +382 -0
- package/build/recommendation/verifierContracts.js +263 -0
- package/build/recommendation/verifiers/changedFile.d.ts +2 -0
- package/build/recommendation/verifiers/changedFile.js +82 -0
- package/build/recommendation/verifiers/citedPath.d.ts +12 -0
- package/build/recommendation/verifiers/citedPath.js +35 -0
- package/build/recommendation/verifiers/coverage.d.ts +7 -0
- package/build/recommendation/verifiers/coverage.js +617 -0
- package/build/recommendation/verifiers/deliveredMatchesPlan.d.ts +11 -0
- package/build/recommendation/verifiers/deliveredMatchesPlan.js +33 -0
- package/build/recommendation/verifiers/endpointGrounded.d.ts +17 -0
- package/build/recommendation/verifiers/endpointGrounded.js +128 -0
- package/build/recommendation/verifiers/existingCoverage.d.ts +6 -0
- package/build/recommendation/verifiers/existingCoverage.js +51 -0
- package/build/recommendation/verifiers/expectedOutcome.d.ts +31 -0
- package/build/recommendation/verifiers/expectedOutcome.js +105 -0
- package/build/recommendation/verifiers/removedElementGuarded.d.ts +2 -0
- package/build/recommendation/verifiers/removedElementGuarded.js +57 -0
- package/build/recommendation/verifiers/reportedCategory.d.ts +26 -0
- package/build/recommendation/verifiers/reportedCategory.js +84 -0
- package/build/recommendation/verifiers/screenRoute.d.ts +10 -0
- package/build/recommendation/verifiers/screenRoute.js +118 -0
- package/build/recommendation/verifiers/statedDifference.d.ts +6 -0
- package/build/recommendation/verifiers/statedDifference.js +140 -0
- package/build/recommendation/verifiers/uiElementGrounded.d.ts +7 -0
- package/build/recommendation/verifiers/uiElementGrounded.js +318 -0
- package/build/resources/analysisResources.js +1 -114
- package/build/resources/testbotResource.js +23 -13
- package/build/services/ModularizationService.js +2 -1
- package/build/services/TestDiscoveryService.d.ts +3 -72
- package/build/services/TestDiscoveryService.js +10 -303
- package/build/services/containerEnv.d.ts +1 -1
- package/build/services/containerEnv.js +12 -0
- package/build/skills/fixTestImportErrorsSkill.d.ts +13 -0
- package/build/skills/fixTestImportErrorsSkill.js +20 -0
- package/build/toolNames.d.ts +1 -0
- package/build/toolNames.js +1 -0
- package/build/tools/code-refactor/enhanceAssertionsTool.js +3 -3
- package/build/tools/code-refactor/modularizationTool.js +2 -1
- package/build/tools/executeSkyrampTestTool.d.ts +80 -0
- package/build/tools/executeSkyrampTestTool.js +246 -19
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +6 -0
- package/build/tools/generate-tests/generateContractRestTool.js +3 -3
- package/build/tools/generate-tests/planGuard.d.ts +2 -2
- package/build/tools/generate-tests/planGuard.js +78 -18
- package/build/tools/one-click/oneClickTool.d.ts +0 -1
- package/build/tools/one-click/oneClickTool.js +0 -5
- package/build/tools/submitReportTool.d.ts +48 -42
- package/build/tools/submitReportTool.js +576 -193
- package/build/tools/test-management/actionsTool.js +72 -4
- package/build/tools/test-management/analyzeChangesTool.d.ts +144 -48
- package/build/tools/test-management/analyzeChangesTool.js +212 -1219
- package/build/tools/test-management/analyzeTestHealthTool.js +13 -24
- package/build/tools/test-management/index.d.ts +1 -0
- package/build/tools/test-management/index.js +1 -0
- package/build/tools/test-management/registerTestPlanTool.d.ts +795 -172
- package/build/tools/test-management/registerTestPlanTool.js +609 -542
- package/build/tools/test-management/resolveScreenTool.d.ts +75 -0
- package/build/tools/test-management/resolveScreenTool.js +289 -0
- package/build/types/BlueprintDigest.d.ts +34 -0
- package/build/types/BlueprintDigest.js +1 -0
- package/build/types/RepositoryAnalysis.d.ts +20 -1559
- package/build/types/RepositoryAnalysis.js +2 -58
- package/build/types/StepMethod.d.ts +40 -0
- package/build/types/StepMethod.js +77 -0
- package/build/types/TestAnalysis.d.ts +12 -0
- package/build/types/TestExecution.d.ts +4 -0
- package/build/types/TestRecommendation.d.ts +24 -24
- package/build/types/TestRecommendation.js +91 -89
- package/build/types/TestbotPromptOptions.d.ts +0 -4
- package/build/types/TestbotReport.d.ts +64 -2
- package/build/utils/AnalysisStateManager.d.ts +79 -113
- package/build/utils/AnalysisStateManager.js +147 -57
- package/build/utils/assertion-verify/api-shared-lints.js +1 -1
- package/build/utils/assertion-verify/metrics.js +85 -36
- package/build/utils/assertion-verify/ui-lints.d.ts +0 -5
- package/build/utils/assertion-verify/ui-lints.js +32 -0
- package/build/utils/branchDiff.d.ts +63 -31
- package/build/utils/branchDiff.js +242 -94
- package/build/utils/containedPath.d.ts +18 -0
- package/build/utils/containedPath.js +73 -0
- package/build/utils/dartRouteExtractor.d.ts +18 -34
- package/build/utils/dartRouteExtractor.js +101 -173
- package/build/utils/featureFlags.d.ts +12 -0
- package/build/utils/featureFlags.js +14 -0
- package/build/utils/frontendSelectors.d.ts +48 -27
- package/build/utils/frontendSelectors.js +241 -80
- package/build/utils/pathMatching.d.ts +2 -4
- package/build/utils/pathMatching.js +2 -4
- package/build/utils/planMatchKeys.d.ts +38 -47
- package/build/utils/planMatchKeys.js +143 -81
- package/build/utils/rebaselineSnapshots.d.ts +24 -0
- package/build/utils/rebaselineSnapshots.js +65 -0
- package/build/utils/removedUiElements.d.ts +22 -0
- package/build/utils/removedUiElements.js +106 -0
- package/build/utils/reportVerification.d.ts +2 -6
- package/build/utils/reportVerification.js +61 -2
- package/build/utils/screenRoutes.d.ts +66 -0
- package/build/utils/screenRoutes.js +727 -0
- package/build/utils/sourceRouteExtractor.js +320 -112
- package/build/utils/testFileClassification.d.ts +11 -2
- package/build/utils/testFileClassification.js +44 -2
- package/build/utils/testFixtures.d.ts +5 -0
- package/build/utils/testFixtures.js +13 -0
- package/build/utils/utils.d.ts +0 -1
- package/build/utils/utils.js +0 -11
- package/build/utils/versions.d.ts +3 -3
- package/build/utils/versions.js +1 -1
- package/build/workspace/workspace.d.ts +12 -12
- package/node_modules/playwright/lib/mcp/skyramp/assertHiddenTool.js +56 -0
- package/node_modules/playwright/lib/mcp/skyramp/assertTool.js +2 -1
- package/node_modules/playwright/lib/mcp/skyramp/loadTraceTool.js +10 -0
- package/node_modules/playwright/lib/mcp/skyramp/skyRampImport.js +4 -1
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +160 -1
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +4 -2
- package/node_modules/playwright/node_modules/playwright-core/lib/server/codegen/skyramp/jsonlReader.js +1 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder/recorderSignalProcessor.js +2 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/server/recorder.js +5 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.-Id052Lr.js → index.B7KbSQcC.js} +1 -1
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/index.html +1 -1
- package/node_modules/playwright/node_modules/playwright-core/package.json +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/server/codegen/skyramp/jsonlReader.ts +1 -1
- package/node_modules/playwright/node_modules/playwright-core/src/server/recorder/recorderSignalProcessor.ts +7 -0
- package/node_modules/playwright/node_modules/playwright-core/src/server/recorder.ts +6 -1
- package/node_modules/playwright/package.json +1 -1
- package/package.json +4 -3
- package/plugin/.claude-plugin/plugin.json +8 -0
- package/plugin/plugin.json +6 -0
- package/plugin/prompts/declaring-a-plan.md +20 -0
- package/plugin/prompts/generate-tests/context-fetching.md +4 -0
- package/plugin/prompts/generate-tests/execution-plan.md +63 -0
- package/plugin/prompts/generate-tests/generation.md +108 -0
- package/plugin/prompts/generate-tests/path-parameters.md +1 -0
- package/plugin/prompts/generate-tests/reasoning-protocol.md +17 -0
- package/plugin/prompts/generate-tests/tool-workflow-variants.md +61 -0
- package/plugin/prompts/generate-tests/tool-workflows.md +65 -0
- package/plugin/prompts/plan-tests.md +42 -0
- package/plugin/prompts/testbot-task1.md +82 -0
- package/plugin/skills/fix-test-import-errors/SKILL.md +98 -0
- package/build/prompts/test-recommendation/analysisOutputPrompt.d.ts +0 -84
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +0 -369
- package/build/prompts/test-recommendation/fullRepoCatalog.d.ts +0 -7
- package/build/prompts/test-recommendation/fullRepoCatalog.js +0 -283
- package/build/prompts/test-recommendation/scopeAssessment.d.ts +0 -81
- package/build/prompts/test-recommendation/scopeAssessment.js +0 -359
- package/build/recommendation/budgeters/diversityBalancedBudgeter.d.ts +0 -7
- package/build/recommendation/budgeters/diversityBalancedBudgeter.js +0 -105
- package/build/recommendation/budgeters/fixedNBudgeter.d.ts +0 -7
- package/build/recommendation/budgeters/fixedNBudgeter.js +0 -11
- package/build/recommendation/budgeters/shared.d.ts +0 -32
- package/build/recommendation/budgeters/shared.js +0 -246
- package/build/recommendation/discriminators.d.ts +0 -37
- package/build/recommendation/discriminators.js +0 -379
- package/build/recommendation/diversity.d.ts +0 -47
- package/build/recommendation/diversity.js +0 -101
- package/build/recommendation/planRanker.d.ts +0 -65
- package/build/recommendation/planRanker.js +0 -83
- package/build/recommendation/testFixtures.d.ts +0 -25
- package/build/recommendation/testFixtures.js +0 -45
- package/build/types/FrontendIntegration.d.ts +0 -28
- package/build/types/FrontendIntegration.js +0 -22
- package/build/types/Recommendation.d.ts +0 -146
- package/build/types/Recommendation.js +0 -74
- package/build/utils/changedRoutes.d.ts +0 -29
- package/build/utils/changedRoutes.js +0 -87
- package/build/utils/frontendIntegration.d.ts +0 -9
- package/build/utils/frontendIntegration.js +0 -243
- package/build/utils/importerHop.d.ts +0 -135
- package/build/utils/importerHop.js +0 -489
- package/build/utils/pathAffinityClassification.d.ts +0 -49
- package/build/utils/pathAffinityClassification.js +0 -180
- package/build/utils/pythonMountPrefixes.d.ts +0 -25
- package/build/utils/pythonMountPrefixes.js +0 -347
- package/build/utils/repoScanner.d.ts +0 -34
- package/build/utils/repoScanner.js +0 -300
- package/build/utils/routeParsers.d.ts +0 -95
- package/build/utils/routeParsers.js +0 -951
- package/build/utils/scenarioDrafting.d.ts +0 -92
- package/build/utils/scenarioDrafting.js +0 -951
- package/build/utils/subjectEndpoints.d.ts +0 -19
- package/build/utils/subjectEndpoints.js +0 -98
- package/build/utils/uiPageEnumerator.d.ts +0 -172
- package/build/utils/uiPageEnumerator.js +0 -474
|
@@ -1,42 +1,23 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import * as crypto from "crypto";
|
|
3
3
|
import * as fs from "fs";
|
|
4
|
-
import * as os from "os";
|
|
5
4
|
import * as path from "path";
|
|
6
5
|
import yaml from "js-yaml";
|
|
7
|
-
import { simpleGit } from "simple-git";
|
|
8
6
|
import { logger } from "../../utils/logger.js";
|
|
9
|
-
import { dualChannelResult } from "../../utils/utils.js";
|
|
10
7
|
import { parseWorkspaceAuthType, getDefaultAuthHeader, WorkspaceAuthType, readWorkspaceConfigRaw } from "../../utils/workspaceAuth.js";
|
|
11
8
|
import { AnalyticsService } from "../../services/AnalyticsService.js";
|
|
12
9
|
import { makeProgressReporter } from "../../utils/progress.js";
|
|
13
10
|
import { StateManager, registerSession, storeSessionData, rememberTestsRepoDir, getActiveRunStatePath, setActiveRunStatePath, } from "../../utils/AnalysisStateManager.js";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { buildExternalCoverageSet } from "../../prompts/test-recommendation/recommendationShared.js";
|
|
17
|
-
import { resolveSubjectEndpoints } from "../../utils/subjectEndpoints.js";
|
|
18
|
-
import { collectChangedRouteLines } from "../../utils/changedRoutes.js";
|
|
19
|
-
import { CandidateSource, computeCandidateId } from "../../types/Recommendation.js";
|
|
20
|
-
import { selectPlan } from "../../recommendation/planRanker.js";
|
|
21
|
-
import { buildApprovedPlanItem } from "../../utils/planMatchKeys.js";
|
|
22
|
-
import { enumerateCandidateUiPages, MAX_CANDIDATE_PAGES } from "../../utils/uiPageEnumerator.js";
|
|
23
|
-
import { checkFrontendFileIntegration } from "../../utils/frontendIntegration.js";
|
|
24
|
-
import { MAX_RECOMMENDATIONS, MAX_TESTS_TO_GENERATE } from "../../prompts/test-recommendation/recommendationSections.js";
|
|
11
|
+
import { isTestFile } from "../../utils/testFileClassification.js";
|
|
12
|
+
import { isPlanOnlyMode } from "../../utils/planOnlyMode.js";
|
|
25
13
|
import { TestDiscoveryService } from "../../services/TestDiscoveryService.js";
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import { computeBranchDiff } from "../../utils/branchDiff.js";
|
|
29
|
-
import { classifyEndpointsByChangedFiles, selectRemovalCandidateFiles, recoverRemovedEndpointsFromBase, } from "../../utils/routeParsers.js";
|
|
30
|
-
import { classifyUnmatchedFilesByPathAffinity } from "../../utils/pathAffinityClassification.js";
|
|
31
|
-
import { scanAllRepoEndpoints, scanRelatedEndpoints, grepRouterMountingContext, findCandidateRouteFiles, } from "../../utils/repoScanner.js";
|
|
14
|
+
import { AnalysisScope } from "../../types/RepositoryAnalysis.js";
|
|
15
|
+
import { computeBranchDiff, changedFileNames, } from "../../utils/branchDiff.js";
|
|
32
16
|
import { detectProjectMetadata } from "../../utils/projectMetadata.js";
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import { extractChangedSelectors, MAX_CHANGED_SELECTORS } from "../../utils/frontendSelectors.js";
|
|
36
|
-
import { buildAnalysisOutputText } from "../../prompts/test-recommendation/analysisOutputPrompt.js";
|
|
17
|
+
import { diffBlocksByPath } from "../../utils/branchDiff.js";
|
|
18
|
+
import { collectRemovedUiElements } from "../../utils/removedUiElements.js";
|
|
37
19
|
import { parseTraceFile, discoverTraceFiles, discoverPlaywrightZips, } from "../../utils/trace-parser.js";
|
|
38
20
|
import { TestSource } from "../../types/TestAnalysis.js";
|
|
39
|
-
import { parsePRComments } from "../../utils/pr-comment-parser.js";
|
|
40
21
|
/** Exported for testing: maps a parsed trace result to a TraceFile. */
|
|
41
22
|
export function buildTraceFileEntry(tracePath, result) {
|
|
42
23
|
return {
|
|
@@ -47,169 +28,6 @@ export function buildTraceFileEntry(tracePath, result) {
|
|
|
47
28
|
};
|
|
48
29
|
}
|
|
49
30
|
const TOOL_NAME = "skyramp_analyze_changes";
|
|
50
|
-
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;
|
|
51
|
-
export function isSecurityRelevantDiff(diffContent) {
|
|
52
|
-
return SECURITY_RELEVANT_DIFF_PATTERN.test(diffContent);
|
|
53
|
-
}
|
|
54
|
-
// Must match testbot/src/constants.ts BOT_EMAIL
|
|
55
|
-
const BOT_EMAIL = "test-bot@skyramp.dev";
|
|
56
|
-
/**
|
|
57
|
-
* Get files added by the last bot commit that still exist on disk.
|
|
58
|
-
* Fallback for when PR comment file names don't match actual files
|
|
59
|
-
* (e.g. after a force push that rewrote history).
|
|
60
|
-
*/
|
|
61
|
-
async function getBotCommittedFiles(repoPath) {
|
|
62
|
-
try {
|
|
63
|
-
const git = simpleGit(repoPath);
|
|
64
|
-
const botShaRaw = await git.raw([
|
|
65
|
-
"log", "--author=" + BOT_EMAIL, "-1", "--format=%H",
|
|
66
|
-
]);
|
|
67
|
-
const lastBotSha = botShaRaw.trim();
|
|
68
|
-
if (!lastBotSha)
|
|
69
|
-
return [];
|
|
70
|
-
const diffOutput = await git.raw([
|
|
71
|
-
"diff-tree", "--no-commit-id", "--name-only", "-r", "--diff-filter=A", lastBotSha,
|
|
72
|
-
]);
|
|
73
|
-
return diffOutput
|
|
74
|
-
.trim()
|
|
75
|
-
.split("\n")
|
|
76
|
-
.filter(Boolean)
|
|
77
|
-
.filter(f => {
|
|
78
|
-
const abs = path.join(repoPath, f);
|
|
79
|
-
return fs.existsSync(abs);
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
catch {
|
|
83
|
-
return [];
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
/**
|
|
87
|
-
* Get files changed by user commits (excluding bot commits) since the last bot commit.
|
|
88
|
-
* Focuses on user intent — what did the developer push after the bot ran?
|
|
89
|
-
* Returns null if no bot commit exists (first run) or on error.
|
|
90
|
-
*
|
|
91
|
-
* Uses git.raw() throughout because simple-git's .log() parser cannot
|
|
92
|
-
* reliably handle custom --format flags. Filters out bot-authored commits
|
|
93
|
-
* client-side because git's --not flag applies to revision specs, not --author.
|
|
94
|
-
*/
|
|
95
|
-
async function getUserChangedFiles(repoPath) {
|
|
96
|
-
try {
|
|
97
|
-
const git = simpleGit(repoPath);
|
|
98
|
-
// Find the last bot commit — use git.raw() because simple-git's .log()
|
|
99
|
-
// parser cannot handle a custom --format flag reliably.
|
|
100
|
-
const botShaRaw = await git.raw([
|
|
101
|
-
"log", "--author=" + BOT_EMAIL, "-1", "--format=%H",
|
|
102
|
-
]);
|
|
103
|
-
const lastBotSha = botShaRaw.trim();
|
|
104
|
-
if (!lastBotSha)
|
|
105
|
-
return null;
|
|
106
|
-
// Get all commits in the range, then filter out bot-authored ones client-side.
|
|
107
|
-
// git's --not flag applies to revision specs, not --author.
|
|
108
|
-
const rangeLog = await git.raw([
|
|
109
|
-
"log", "--format=%H %ae", `${lastBotSha}..HEAD`,
|
|
110
|
-
]);
|
|
111
|
-
const userShas = rangeLog
|
|
112
|
-
.trim()
|
|
113
|
-
.split("\n")
|
|
114
|
-
.filter(Boolean)
|
|
115
|
-
.filter(line => !line.endsWith(BOT_EMAIL))
|
|
116
|
-
.map(line => line.split(" ")[0]);
|
|
117
|
-
if (userShas.length === 0)
|
|
118
|
-
return [];
|
|
119
|
-
// Union all files touched by user commits
|
|
120
|
-
const fileSet = new Set();
|
|
121
|
-
for (const sha of userShas) {
|
|
122
|
-
const filesRaw = await git.raw([
|
|
123
|
-
"diff-tree", "--no-commit-id", "--name-only", "-r", sha,
|
|
124
|
-
]);
|
|
125
|
-
filesRaw.trim().split("\n").filter(Boolean).forEach(f => fileSet.add(f));
|
|
126
|
-
}
|
|
127
|
-
return Array.from(fileSet);
|
|
128
|
-
}
|
|
129
|
-
catch {
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
const NON_APP_PATTERNS = [
|
|
134
|
-
// CI/CD
|
|
135
|
-
/^\.github\//,
|
|
136
|
-
/^\.circleci\//,
|
|
137
|
-
/^\.gitlab-ci/,
|
|
138
|
-
/^\.travis\.yml$/,
|
|
139
|
-
/^Jenkinsfile/,
|
|
140
|
-
/^\.buildkite\//,
|
|
141
|
-
/^\.drone\.yml$/,
|
|
142
|
-
// Docs
|
|
143
|
-
/\.md$/i,
|
|
144
|
-
/\.mdx$/i,
|
|
145
|
-
/\.rst$/i,
|
|
146
|
-
/\.txt$/i,
|
|
147
|
-
/^docs\//i,
|
|
148
|
-
/^documentation\//i,
|
|
149
|
-
/^CHANGELOG/i,
|
|
150
|
-
/^CONTRIBUTING/i,
|
|
151
|
-
/^README/i,
|
|
152
|
-
/^LICENSE/i,
|
|
153
|
-
// Lock files & dependency manifests (not the manifest itself, just locks)
|
|
154
|
-
/package-lock\.json$/,
|
|
155
|
-
/yarn\.lock$/,
|
|
156
|
-
/pnpm-lock\.yaml$/,
|
|
157
|
-
/poetry\.lock$/,
|
|
158
|
-
/Pipfile\.lock$/,
|
|
159
|
-
/Gemfile\.lock$/,
|
|
160
|
-
/composer\.lock$/,
|
|
161
|
-
/go\.sum$/,
|
|
162
|
-
/Cargo\.lock$/,
|
|
163
|
-
// Config / infra (non-code)
|
|
164
|
-
/^\.env/,
|
|
165
|
-
/\.eslintrc/,
|
|
166
|
-
/\.prettierrc/,
|
|
167
|
-
/\.editorconfig$/,
|
|
168
|
-
/^\.vscode\//,
|
|
169
|
-
/^\.idea\//,
|
|
170
|
-
/docker-compose.*\.ya?ml$/i,
|
|
171
|
-
/^Dockerfile/i,
|
|
172
|
-
/^\.dockerignore$/,
|
|
173
|
-
/^\.gitignore$/,
|
|
174
|
-
/^\.gitattributes$/,
|
|
175
|
-
/tsconfig.*\.json$/,
|
|
176
|
-
/jest\.config\./,
|
|
177
|
-
/babel\.config\./,
|
|
178
|
-
/^renovate\.json$/,
|
|
179
|
-
/^\.pre-commit-config/,
|
|
180
|
-
];
|
|
181
|
-
// ── filterEndpointsBySpec ──────────────────────────────────────────────────
|
|
182
|
-
// Pure helper extracted so unit tests can exercise the filtering + merge logic
|
|
183
|
-
// without spinning up the full analyzeChanges handler.
|
|
184
|
-
export function filterEndpointsBySpec(scannedEndpoints, specPaths, specPathItems, diffChangedPaths) {
|
|
185
|
-
if (!specPaths || specPaths.size === 0)
|
|
186
|
-
return scannedEndpoints;
|
|
187
|
-
const filtered = scannedEndpoints.filter(ep => {
|
|
188
|
-
const normalized = ep.path.replace(/:[a-zA-Z_][a-zA-Z0-9_]*/g, m => `{${m.slice(1)}}`);
|
|
189
|
-
if (specPaths.has(ep.path) || specPaths.has(normalized))
|
|
190
|
-
return true;
|
|
191
|
-
if (diffChangedPaths.has(ep.path))
|
|
192
|
-
return true;
|
|
193
|
-
return false;
|
|
194
|
-
});
|
|
195
|
-
const scannedPathSet = new Set(filtered.map(ep => ep.path));
|
|
196
|
-
// Also track normalized (:param → {param}) forms so Express-style scanned paths
|
|
197
|
-
// (e.g. /api/v1/users/:id) don't produce a duplicate when the spec uses /api/v1/users/{id}.
|
|
198
|
-
const scannedNormalizedSet = new Set(filtered.map(ep => ep.path.replace(/:[a-zA-Z_][a-zA-Z0-9_]*/g, m => `{${m.slice(1)}}`)));
|
|
199
|
-
const HTTP_VERBS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
|
|
200
|
-
for (const specPath of specPaths) {
|
|
201
|
-
if (!scannedPathSet.has(specPath) && !scannedNormalizedSet.has(specPath)) {
|
|
202
|
-
const pathObj = specPathItems[specPath];
|
|
203
|
-
const specMethods = Object.keys(pathObj ?? {})
|
|
204
|
-
.filter(k => HTTP_VERBS.has(k))
|
|
205
|
-
.map(m => m.toUpperCase());
|
|
206
|
-
if (specMethods.length > 0) {
|
|
207
|
-
filtered.push({ path: specPath, methods: specMethods, sourceFile: "" });
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
return filtered;
|
|
212
|
-
}
|
|
213
31
|
const GRAPHQL_EXT = /\.(graphql|gql)$/i;
|
|
214
32
|
const GRAPHQL_SCHEMA_CONTENT_PATTERN = /^\s*(type\s+(Query|Mutation|Subscription)\s*\{|schema\s*\{|extend\s+type|directive\s+@)/m;
|
|
215
33
|
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;
|
|
@@ -239,39 +57,7 @@ export async function isGraphQLFile(filePath, repositoryPath) {
|
|
|
239
57
|
return false;
|
|
240
58
|
}
|
|
241
59
|
}
|
|
242
|
-
|
|
243
|
-
return /(?:^|\/)graphql(?:\/|$)/i.test(endpointPath);
|
|
244
|
-
}
|
|
245
|
-
async function isUnsupportedGraphQLEndpoint(endpoint, repositoryPath, checkGraphQLFile = (filePath) => isGraphQLFile(filePath, repositoryPath)) {
|
|
246
|
-
return isGraphQLEndpointPath(endpoint.path) ||
|
|
247
|
-
await checkGraphQLFile(endpoint.sourceFile);
|
|
248
|
-
}
|
|
249
|
-
export async function filterUnsupportedGraphQLEndpoints(endpoints, repositoryPath) {
|
|
250
|
-
const graphqlFileCache = new Map();
|
|
251
|
-
const checkGraphQLFile = (filePath) => {
|
|
252
|
-
if (!filePath)
|
|
253
|
-
return Promise.resolve(false);
|
|
254
|
-
const cached = graphqlFileCache.get(filePath);
|
|
255
|
-
if (cached)
|
|
256
|
-
return cached;
|
|
257
|
-
const result = isGraphQLFile(filePath, repositoryPath);
|
|
258
|
-
graphqlFileCache.set(filePath, result);
|
|
259
|
-
return result;
|
|
260
|
-
};
|
|
261
|
-
const filtered = [];
|
|
262
|
-
for (const endpoint of endpoints) {
|
|
263
|
-
if (!(await isUnsupportedGraphQLEndpoint(endpoint, repositoryPath, checkGraphQLFile))) {
|
|
264
|
-
filtered.push(endpoint);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
return filtered;
|
|
268
|
-
}
|
|
269
|
-
function isNonApplicationFile(filePath) {
|
|
270
|
-
return NON_APP_PATTERNS.some((p) => p.test(filePath));
|
|
271
|
-
}
|
|
272
|
-
const ROUTE_FILE_PATTERN = /route|controller|endpoint|handler|view|urls|api|router|service|gateway|resolver|\bserver\b/i;
|
|
273
|
-
const ROUTE_FILE_BASENAME_PATTERN = /\bapp\b|\bmain\b/i;
|
|
274
|
-
const SOURCE_EXTS = /\.(ts|tsx|js|jsx|py|java|kt|go|rb|php|rs|cs|ex|exs)$/;
|
|
60
|
+
// ── Seed and fixture data ───────────────────────────────────────────────────
|
|
275
61
|
export const analyzeChangesInputSchema = {
|
|
276
62
|
repositoryPath: z
|
|
277
63
|
.string()
|
|
@@ -289,25 +75,21 @@ export const analyzeChangesInputSchema = {
|
|
|
289
75
|
.string()
|
|
290
76
|
.optional()
|
|
291
77
|
.describe("Directory containing existing tests (auto-detected if omitted)"),
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
.default(MAX_RECOMMENDATIONS)
|
|
296
|
-
.describe(`Number of ranked test recommendations to generate. Defaults to ${MAX_RECOMMENDATIONS}.`),
|
|
297
|
-
maxGenerate: z
|
|
298
|
-
.number()
|
|
299
|
-
.int()
|
|
300
|
-
.min(0)
|
|
301
|
-
.optional()
|
|
302
|
-
.describe(`Number of tests to generate and execute. Defaults to ${MAX_TESTS_TO_GENERATE} (diff mode) or all recommendations (full repo).`),
|
|
78
|
+
// The legacy count arguments are declared nowhere on purpose. zod strips an
|
|
79
|
+
// undeclared key, so a caller that still sends one keeps a working call while
|
|
80
|
+
// the name never appears in the schema the agent lists.
|
|
303
81
|
prNumber: z
|
|
304
82
|
.number()
|
|
305
83
|
.optional()
|
|
306
|
-
.describe("GitHub PR number.
|
|
84
|
+
.describe("GitHub PR number. Accepted and applied nowhere: nothing reads previous Testbot comments on the pull request any more. Pass it if the caller supplies it."),
|
|
307
85
|
repository: z
|
|
308
86
|
.string()
|
|
309
87
|
.optional()
|
|
310
88
|
.describe("The owner/repo this call analyzes (e.g. 'letsramp/api-insight'). In a multi-repo run, ALWAYS set it — for the primary AND every related repo. All calls in one Testbot run share ONE state file automatically (you do NOT pass a file path); each repo's analysis is stored as its own section within it. The primary's analysis is kept at the root and tagged with its owner/repo; related repos are stored as keyed sections. Omit only for a single-repo run."),
|
|
89
|
+
primaryRepository: z
|
|
90
|
+
.string()
|
|
91
|
+
.optional()
|
|
92
|
+
.describe("The owner/repo of the run's PRIMARY repository. In a multi-repo run set it on EVERY skyramp_analyze_changes call, for the primary and each related repo, so the state file's root is the primary whichever call comes first. Omit in a single-repo run."),
|
|
311
93
|
testsRepoDir: z
|
|
312
94
|
.string()
|
|
313
95
|
.refine((v) => path.isAbsolute(v), { message: "testsRepoDir must be an absolute path" })
|
|
@@ -319,103 +101,59 @@ export const analyzeChangesInputSchema = {
|
|
|
319
101
|
.default(false)
|
|
320
102
|
.describe("When true, diffs the base ref against the working tree (captures uncommitted and unstaged changes). Use for local-dev workflows. Defaults to false (CI mode — committed changes only)."),
|
|
321
103
|
};
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
const baseUrlStep = pathOnly
|
|
343
|
-
? `
|
|
344
|
-
**The entries above are URL paths, not full URLs** — the workspace declares no frontend service with \`api.baseUrl\`. Determine the frontend base URL once before capturing — the testbot workflow's \`targetReadyCheckCommand\` or the dev-server port in \`package.json\` scripts usually names it — then prefix every path with it. Log an \`issuesFound\` info entry recommending that \`api.baseUrl\` be set on the frontend service in \`.skyramp/workspace.yml\`.
|
|
345
|
-
`
|
|
346
|
-
: "";
|
|
347
|
-
return `Frontend changes detected. **Before writing any UI recommendation \`reasoning\`, capture blueprints on the candidate UI pages below.** Those captures stay in your tool-result history and serve as element vocabulary — the recommendation catalog further down gives you the authoring rules; you bring the observed elements.
|
|
348
|
-
|
|
349
|
-
**Candidate ${pathOnly ? "URL paths" : "URLs"}:**
|
|
350
|
-
${pagesYaml}${capNote}
|
|
351
|
-
${baseUrlStep}
|
|
352
|
-
**For each candidate URL:**
|
|
353
|
-
- \`browser_navigate\` to the URL
|
|
354
|
-
- \`browser_blueprint\` to capture the page
|
|
355
|
-
|
|
356
|
-
You don't need to thread the blueprints back into a tool call — they're in your context once captured.
|
|
357
|
-
|
|
358
|
-
If a candidate URL 404s or redirects unexpectedly, navigate from the frontend base URL and explore (admin apps mount routes under base prefixes the source extraction can't see). If the page rendered but lacks the changed feature (gated UI: modal, dropdown, accordion), do NOT iterate further during this step — UI recs will fall back to source-grounded prose for those, and the agent's later trace recording (Task 2) will navigate into the gate via capture-act-capture.
|
|
359
|
-
|
|
360
|
-
If \`browser_blueprint\` fails on every candidate URL (app unreachable, all 404s), proceed and log an \`issuesFound\` info entry. Recommendations will be source-grounded; non-UI work is unaffected.`;
|
|
361
|
-
}
|
|
362
|
-
/**
|
|
363
|
-
* Instruction block for changed frontend files the server determined have no
|
|
364
|
-
* production importer (SKYR-3855) — dead/orphaned components. Used both to
|
|
365
|
-
* replace the capture instructions entirely (every changed frontend file is
|
|
366
|
-
* unintegrated) and to append a scoped note when only some files are
|
|
367
|
-
* (`buildCaptureInstructions` still runs for the rest in that case).
|
|
368
|
-
*/
|
|
369
|
-
export function buildUnintegratedInstructions(results) {
|
|
370
|
-
const listing = results
|
|
371
|
-
.filter((r) => !r.integrated)
|
|
372
|
-
.map((r) => {
|
|
373
|
-
const token = path.basename(r.file).replace(/\.[^.]+$/, "");
|
|
374
|
-
return ` - \`${r.file}\` (checked for a production import of \`${token}\` — none found)`;
|
|
104
|
+
/** SKYR-4182: structuredContent needs a declared outputSchema, and the schema
|
|
105
|
+
* has to accept the early exit as well as a full analysis — hence the optional
|
|
106
|
+
* fields. */
|
|
107
|
+
export const analyzeChangesOutputSchema = {
|
|
108
|
+
sessionId: z.string().optional(),
|
|
109
|
+
stateFile: z.string().optional(),
|
|
110
|
+
repositoryPath: z.string(),
|
|
111
|
+
repositoryName: z.string(),
|
|
112
|
+
repository: z.string().optional(),
|
|
113
|
+
relatedRepositories: z.array(z.string()).optional(),
|
|
114
|
+
analysisScope: z.string(),
|
|
115
|
+
planOnly: z.boolean(),
|
|
116
|
+
currentBranch: z.string().optional(),
|
|
117
|
+
baseBranch: z.string().optional(),
|
|
118
|
+
changedFiles: z.array(z.object({ path: z.string(), deleted: z.boolean() })),
|
|
119
|
+
existingTestFiles: z
|
|
120
|
+
.object({
|
|
121
|
+
skyramp: z.array(z.string()),
|
|
122
|
+
external: z.array(z.string()),
|
|
123
|
+
relevantExternal: z.array(z.string()),
|
|
375
124
|
})
|
|
376
|
-
.
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
125
|
+
.optional(),
|
|
126
|
+
workspace: z
|
|
127
|
+
.object({
|
|
128
|
+
baseUrl: z.string(),
|
|
129
|
+
authMethod: z.string(),
|
|
130
|
+
authHeader: z.string().optional(),
|
|
131
|
+
openApiSpecPath: z.string().optional(),
|
|
132
|
+
openApiSpecLoaded: z.boolean(),
|
|
133
|
+
})
|
|
134
|
+
.optional(),
|
|
135
|
+
uiContext: z
|
|
136
|
+
.object({
|
|
137
|
+
removedElements: z
|
|
138
|
+
.array(z.object({ attribute: z.string(), value: z.string(), file: z.string() }))
|
|
139
|
+
.optional(),
|
|
140
|
+
})
|
|
141
|
+
.optional(),
|
|
142
|
+
nextStep: z.string().optional(),
|
|
143
|
+
};
|
|
394
144
|
/**
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
* absent in full-repo scope — where fullRepoCatalog deliberately drops externally covered
|
|
401
|
-
* scenarios, and promoting them back would contradict the prompt the agent reads — and
|
|
402
|
-
* after a branch-diff failure, where nothing is known about the changed files at all.
|
|
403
|
-
*
|
|
404
|
-
* Classification comes from discovery, not from `isTestFile`: the flag has to agree with
|
|
405
|
-
* whatever built the coverage keys it guards.
|
|
406
|
-
*
|
|
407
|
-
* `userChangedFiles` is the bot-filtered list. The bot commits its generated tests onto
|
|
408
|
-
* the branch it analyzed, and those filenames are exactly what the discovery patterns
|
|
409
|
-
* exist to match — so reading the raw base..HEAD list shut the reserve on every re-run
|
|
410
|
-
* of a branch: run 1 generates the tests, run 2 sees them. When the filter cannot answer
|
|
411
|
-
* (no bot commit yet, or a git failure) the raw list stands. That is safe in the same
|
|
412
|
-
* direction as the `!diffData` case: the raw list can only hold MORE files, so the
|
|
413
|
-
* fallback shuts the reserve rather than opening it.
|
|
145
|
+
* Assemble the tool result. The payload is carried on BOTH channels because
|
|
146
|
+
* each agent CLI reads a different one and none falls back: Claude Code takes
|
|
147
|
+
* `structuredContent`, Cursor takes `content[]`. `content[]` must be the
|
|
148
|
+
* literal JSON serialization of `structuredContent` — that exact form is what
|
|
149
|
+
* GitHub Copilot CLI de-duplicates on.
|
|
414
150
|
*/
|
|
415
|
-
export function
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
151
|
+
export function buildAnalyzeChangesResult(data) {
|
|
152
|
+
const structuredContent = JSON.parse(JSON.stringify(data));
|
|
153
|
+
return {
|
|
154
|
+
structuredContent,
|
|
155
|
+
content: [{ type: "text", text: JSON.stringify(structuredContent) }],
|
|
156
|
+
};
|
|
419
157
|
}
|
|
420
158
|
export function registerAnalyzeChangesTool(server) {
|
|
421
159
|
server.registerTool(TOOL_NAME, {
|
|
@@ -423,17 +161,12 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
423
161
|
readOnlyHint: false, // writes a state file to disk
|
|
424
162
|
destructiveHint: false,
|
|
425
163
|
idempotentHint: false,
|
|
426
|
-
openWorldHint: true, //
|
|
164
|
+
openWorldHint: true, // reads an OpenAPI spec by URL when one is configured
|
|
427
165
|
},
|
|
428
|
-
description: `
|
|
166
|
+
description: `Read the repository and the current change, and discover the existing tests — first step of the unified Test Health Analysis Flow. Returns DATA only, no instructions: a stateFile path, the branch and its base, the changed files with their status, the existing test files, the workspace base URL and auth, the OpenAPI spec path, the repository's seed and fixture data files, and the identifying attributes the change removed from surviving files. It proposes no tests and it tells you nothing about the diff — read the diff and decide what the change warrants. Pass stateFile to skyramp_register_test_plan (required before you write any test), skyramp_analyze_test_health and skyramp_actions.`,
|
|
429
167
|
// SKYR-4182: the payload is returned via structuredContent, which requires a
|
|
430
|
-
// declared outputSchema.
|
|
431
|
-
|
|
432
|
-
outputSchema: {
|
|
433
|
-
executionPlan: z
|
|
434
|
-
.string()
|
|
435
|
-
.describe("Analysis summary, UI blueprint capture instructions, and the ranked Execution Plan. Read and follow this field."),
|
|
436
|
-
},
|
|
168
|
+
// declared outputSchema.
|
|
169
|
+
outputSchema: analyzeChangesOutputSchema,
|
|
437
170
|
inputSchema: analyzeChangesInputSchema,
|
|
438
171
|
}, async (params, extra) => {
|
|
439
172
|
let errorResult;
|
|
@@ -464,177 +197,24 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
464
197
|
logger.warning("Failed to obtain branch diff, continuing without diff", { error: msg });
|
|
465
198
|
}
|
|
466
199
|
}
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
const filesToCheck = userFiles ?? diffData.changedFiles;
|
|
473
|
-
const appFiles = filesToCheck.filter(f => !isNonApplicationFile(f));
|
|
474
|
-
if (filesToCheck.length > 0 && appFiles.length === 0) {
|
|
475
|
-
logger.info("All user-changed files are non-application — skipping analysis", {
|
|
476
|
-
changedFiles: filesToCheck,
|
|
477
|
-
});
|
|
478
|
-
return dualChannelResult({
|
|
479
|
-
executionPlan: `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(", ")}`,
|
|
480
|
-
});
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
// ── Step 2: Scan endpoints ──
|
|
484
|
-
let scannedEndpoints = [];
|
|
485
|
-
let rawRelatedEndpointCount;
|
|
486
|
-
if (analysisScope !== AnalysisScope.CurrentBranchDiff) {
|
|
487
|
-
await sendProgress(25, 100, "Scanning all repository endpoints...");
|
|
488
|
-
try {
|
|
489
|
-
scannedEndpoints = scanAllRepoEndpoints(params.repositoryPath);
|
|
490
|
-
logger.info("Pre-scanned repo endpoints", {
|
|
491
|
-
count: scannedEndpoints.length,
|
|
492
|
-
});
|
|
493
|
-
}
|
|
494
|
-
catch (err) {
|
|
495
|
-
logger.warning("Endpoint pre-scan failed", {
|
|
496
|
-
error: err instanceof Error ? err.message : String(err),
|
|
497
|
-
});
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
else if (diffData) {
|
|
501
|
-
await sendProgress(25, 100, "Scanning related endpoints from diff...");
|
|
502
|
-
try {
|
|
503
|
-
scannedEndpoints = scanRelatedEndpoints(params.repositoryPath, diffData.changedFiles);
|
|
504
|
-
rawRelatedEndpointCount = scannedEndpoints.length;
|
|
505
|
-
logger.info("Scanned related endpoints", {
|
|
506
|
-
count: scannedEndpoints.length,
|
|
507
|
-
});
|
|
508
|
-
}
|
|
509
|
-
catch (err) {
|
|
510
|
-
rawRelatedEndpointCount = 0;
|
|
511
|
-
logger.warning("Related endpoint scan failed", {
|
|
512
|
-
error: err instanceof Error ? err.message : String(err),
|
|
513
|
-
});
|
|
514
|
-
}
|
|
515
|
-
const beforeGraphQLFilter = scannedEndpoints.length;
|
|
516
|
-
scannedEndpoints = await filterUnsupportedGraphQLEndpoints(scannedEndpoints, params.repositoryPath);
|
|
517
|
-
if (scannedEndpoints.length !== beforeGraphQLFilter) {
|
|
518
|
-
logger.info("Filtered unsupported GraphQL endpoints from related scan", {
|
|
519
|
-
before: beforeGraphQLFilter,
|
|
520
|
-
after: scannedEndpoints.length,
|
|
521
|
-
});
|
|
522
|
-
}
|
|
523
|
-
// No fallback to scanAllRepoEndpoints in PR mode.
|
|
524
|
-
// If the scanner found 0 related endpoints, the PR likely touches
|
|
525
|
-
// non-route code (services, models, schemas, client SDK). Flooding
|
|
526
|
-
// all repo endpoints into the prompt causes the LLM to test
|
|
527
|
-
// irrelevant neighboring endpoints instead of focusing on the diff.
|
|
528
|
-
}
|
|
529
|
-
await sendProgress(40, 100, "Classifying changed endpoints...");
|
|
530
|
-
// ── Step 2.5: Classify endpoints by changed files ──
|
|
531
|
-
// Cross-reference changedFiles against scannedEndpoints[].sourceFile to
|
|
532
|
-
// identify new/modified/removed endpoints — replaces fragile regex parsing
|
|
533
|
-
// of diff hunks. Scanned endpoints always have full paths and concrete
|
|
534
|
-
// HTTP methods, eliminating the need for path resolution and MULTI sentinels.
|
|
535
|
-
let classifiedEndpoints;
|
|
536
|
-
// Importer-hop mapping (SKYR-3855): unmatched non-route backend files
|
|
537
|
-
// (DTOs, services, models) hopped one import edge to the endpoints that
|
|
538
|
-
// reference them. Kept separate from classifiedEndpoints.changedEndpoints
|
|
539
|
-
// (which it also feeds into, below) so Step 9 can draft response-shape
|
|
540
|
-
// scenarios specifically for these endpoints.
|
|
541
|
-
let hopResult = { endpoints: [], byFile: {} };
|
|
200
|
+
// The changed-file listing the result carries. Read from
|
|
201
|
+
// `--name-status`, which is the same listing skyramp_register_test_plan
|
|
202
|
+
// verifies a candidate's `changedFile` against — one source, so the two
|
|
203
|
+
// cannot disagree about what the change touched.
|
|
204
|
+
let changedNames = [];
|
|
542
205
|
if (diffData) {
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
// line-edit). classifyEndpointsByChangedFiles filters out any that still
|
|
546
|
-
// exist in the current catalog, leaving only genuine removals.
|
|
547
|
-
const removalCandidateFiles = selectRemovalCandidateFiles(diffData);
|
|
548
|
-
const git = simpleGit(params.repositoryPath);
|
|
549
|
-
const recoveredBaseEndpoints = removalCandidateFiles.length > 0
|
|
550
|
-
? await recoverRemovedEndpointsFromBase(removalCandidateFiles, (file) => git.show([`${diffData.baseBranch}:${file}`]), params.repositoryPath, diffData.baseBranch)
|
|
551
|
-
: [];
|
|
552
|
-
classifiedEndpoints = classifyEndpointsByChangedFiles(diffData, scannedEndpoints, recoveredBaseEndpoints);
|
|
553
|
-
classifiedEndpoints = {
|
|
554
|
-
...classifiedEndpoints,
|
|
555
|
-
// changed/new endpoints come from scannedEndpoints, which is already
|
|
556
|
-
// GraphQL-filtered in branch-diff mode. Removed endpoints are recovered
|
|
557
|
-
// from the base branch separately and still need unsupported-protocol filtering.
|
|
558
|
-
removedEndpoints: await filterUnsupportedGraphQLEndpoints(classifiedEndpoints.removedEndpoints, params.repositoryPath),
|
|
559
|
-
};
|
|
560
|
-
logger.info("Classified endpoints from changed files", {
|
|
561
|
-
changed: classifiedEndpoints.changedEndpoints.length,
|
|
562
|
-
new: classifiedEndpoints.newEndpoints.length,
|
|
563
|
-
removed: classifiedEndpoints.removedEndpoints.length,
|
|
564
|
-
unmatched: classifiedEndpoints.unmatchedFiles.length,
|
|
565
|
-
});
|
|
566
|
-
// ── Importer-hop classification (SKYR-3855) ──
|
|
567
|
-
// A changed DTO/service/model file declares no route of its own, so it
|
|
568
|
-
// never matches classifyEndpointsByChangedFiles' exact sourceFile check
|
|
569
|
-
// and lands in unmatchedFiles. Hop one import edge: find already-scanned
|
|
570
|
-
// endpoints whose source imports the unmatched file AND shares its REST
|
|
571
|
-
// resource, then fold them into changedEndpoints so diffContext.modifiedEndpoints,
|
|
572
|
-
// novelty classification, and drift analysis all light up. Never fatal —
|
|
573
|
-
// any failure just leaves classification exactly as it was.
|
|
574
|
-
if (classifiedEndpoints.unmatchedFiles.length > 0) {
|
|
575
|
-
try {
|
|
576
|
-
hopResult = mapUnmatchedFilesToImporterEndpoints(params.repositoryPath, classifiedEndpoints.unmatchedFiles, scannedEndpoints, [...classifiedEndpoints.changedEndpoints, ...classifiedEndpoints.newEndpoints], diffData.diffContent);
|
|
577
|
-
// `byFile` can be non-empty even when `endpoints` is empty — the hop
|
|
578
|
-
// still found a valid mapping, but every candidate was already present
|
|
579
|
-
// in changedEndpoints/newEndpoints (e.g. the controller and its DTO both
|
|
580
|
-
// changed in the same PR) and got deduped out of `endpoints`. The
|
|
581
|
-
// response-shape drafting step (below) re-derives its endpoint list
|
|
582
|
-
// from `scannedEndpoints` via `buildHopResponseShapeInputs`, keyed off
|
|
583
|
-
// `byFile`, so it isn't affected by that dedup — only the
|
|
584
|
-
// `changedEndpoints` merge here needs the (deduped) `endpoints` array.
|
|
585
|
-
if (Object.keys(hopResult.byFile).length > 0) {
|
|
586
|
-
logger.info("Importer-hop mapped unmatched files to endpoints", {
|
|
587
|
-
byFile: hopResult.byFile,
|
|
588
|
-
});
|
|
589
|
-
if (hopResult.endpoints.length > 0) {
|
|
590
|
-
classifiedEndpoints = {
|
|
591
|
-
...classifiedEndpoints,
|
|
592
|
-
changedEndpoints: [...classifiedEndpoints.changedEndpoints, ...hopResult.endpoints],
|
|
593
|
-
};
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
catch (err) {
|
|
598
|
-
logger.warning("Importer-hop classification failed — continuing without it", {
|
|
599
|
-
error: err instanceof Error ? err.message : String(err),
|
|
600
|
-
});
|
|
601
|
-
hopResult = { endpoints: [], byFile: {} };
|
|
602
|
-
}
|
|
206
|
+
try {
|
|
207
|
+
changedNames = changedFileNames(await computeBranchDiff(params.repositoryPath, params.baseBranch, false, true));
|
|
603
208
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
// early-return pattern — bot-committed .graphql files must not trigger this.
|
|
608
|
-
if (analysisScope === AnalysisScope.CurrentBranchDiff &&
|
|
609
|
-
classifiedEndpoints &&
|
|
610
|
-
classifiedEndpoints.changedEndpoints.length === 0 &&
|
|
611
|
-
classifiedEndpoints.newEndpoints.length === 0 &&
|
|
612
|
-
classifiedEndpoints.removedEndpoints.length === 0 &&
|
|
613
|
-
diffData) {
|
|
614
|
-
const userFiles = await getUserChangedFiles(params.repositoryPath);
|
|
615
|
-
const filesToCheck = userFiles ?? diffData.changedFiles;
|
|
616
|
-
// Exclude non-application files (docs, CI, configs) before the graphql check.
|
|
617
|
-
// Non-app files are neutral — a README-only diff must NOT fire this early return.
|
|
618
|
-
const appFilesToCheck = filesToCheck.filter(f => !isNonApplicationFile(f));
|
|
619
|
-
const allGraphQL = appFilesToCheck.length > 0 &&
|
|
620
|
-
(await Promise.all(appFilesToCheck.map(f => isGraphQLFile(f, params.repositoryPath)))).every(Boolean);
|
|
621
|
-
if (allGraphQL) {
|
|
622
|
-
logger.info("GraphQL-only diff detected — REST testing not supported", {
|
|
623
|
-
changedFiles: diffData.changedFiles,
|
|
209
|
+
catch (error) {
|
|
210
|
+
logger.warning("Failed to list changed file names, continuing without them", {
|
|
211
|
+
error: error instanceof Error ? error.message : String(error),
|
|
624
212
|
});
|
|
625
|
-
return dualChannelResult({ executionPlan: [
|
|
626
|
-
"**GraphQL-only diff detected.**",
|
|
627
|
-
"",
|
|
628
|
-
"The changed files appear to be GraphQL schema, artifact, or endpoint implementation files.",
|
|
629
|
-
"Skyramp currently supports REST API testing only — GraphQL introspection,",
|
|
630
|
-
"query validation, and type-name grounding are not yet supported.",
|
|
631
|
-
"",
|
|
632
|
-
"No test recommendations can be generated for this diff.",
|
|
633
|
-
"",
|
|
634
|
-
`Changed files: ${diffData.changedFiles.join(", ")}`,
|
|
635
|
-
].join("\n") });
|
|
636
213
|
}
|
|
637
214
|
}
|
|
215
|
+
// No endpoint scan, in either mode. A full-repo run used to hand the
|
|
216
|
+
// agent a catalog built by framework route parsers; it reads the
|
|
217
|
+
// repository and enumerates what it serves itself.
|
|
638
218
|
await sendProgress(50, 100, "Discovering existing tests...");
|
|
639
219
|
// ── Step 3: Discover existing tests ──
|
|
640
220
|
// Always scan from repositoryPath so tests in any subdirectory (e.g.
|
|
@@ -645,142 +225,31 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
645
225
|
// testsRepoDir is a cross-repo path override — honour it when set.
|
|
646
226
|
// Otherwise always scan the full repo root.
|
|
647
227
|
const testDir = params.testsRepoDir ?? undefined;
|
|
648
|
-
|
|
649
|
-
//
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
if (fs.statSync(abs).size > ROUTER_INLINE_LIMIT * 10)
|
|
655
|
-
return ""; // skip very large files
|
|
656
|
-
return fs.readFileSync(abs, "utf-8");
|
|
657
|
-
}
|
|
658
|
-
catch {
|
|
659
|
-
return "";
|
|
660
|
-
}
|
|
661
|
-
});
|
|
662
|
-
// Size-capped subset for LLM prompt injection.
|
|
663
|
-
const ROUTER_INLINE_MAX_FILES = 3;
|
|
664
|
-
const routerFileContents = routerMountContext
|
|
665
|
-
.slice(0, ROUTER_INLINE_MAX_FILES)
|
|
666
|
-
.flatMap((f, i) => {
|
|
667
|
-
const content = routerMountContextContents[i] ?? "";
|
|
668
|
-
if (!content || content.length > ROUTER_INLINE_LIMIT)
|
|
669
|
-
return [];
|
|
670
|
-
return [{ file: f, content: content.trimEnd() }];
|
|
671
|
-
});
|
|
672
|
-
// PR mode → [] excludes external tests from pre-loading (agent discovers via grep).
|
|
673
|
-
// No diff context → undefined triggers full-repo mode in TestDiscoveryService.
|
|
674
|
-
// SKYR-3924: a diff that changed NO endpoints but did change schema/model/DTO files
|
|
675
|
-
// yields no endpoint signal, so external tests would be skipped entirely and the
|
|
676
|
-
// breaking existing tests never enter the maintenance scope. Derive resource tokens
|
|
677
|
-
// from the changed files via the shared importer-hop derivation and use them, so
|
|
678
|
-
// discoverTests partitions and PRE-LOADS the relevant existing tests.
|
|
679
|
-
// Per-file diff slices — lazily computed once and shared by the changedSymbols
|
|
680
|
-
// (SKYR-3924) and changedSelectors (UI promotion pass 3) derivations below, so a diff
|
|
681
|
-
// that triggers both isn't sliced twice.
|
|
228
|
+
// No route hints. The server used to grep for router entry points, list
|
|
229
|
+
// candidate route files and inline the first few router files' contents.
|
|
230
|
+
// All three guessed at a surface the agent reads for itself.
|
|
231
|
+
// Per-file diff slices — lazily computed once and shared by the
|
|
232
|
+
// changedSelectors derivation and the removed-element collection below,
|
|
233
|
+
// so a diff that reaches both is not sliced twice.
|
|
682
234
|
let _diffByFile = null;
|
|
683
|
-
const getDiffByFile = () => (_diffByFile ??= diffData?.diffContent ?
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
// files, configs, workflows, and docs (they'd contribute filename noise like
|
|
693
|
-
// "workspace" or "skyramp-testbot").
|
|
694
|
-
const SOURCE_EXT = /\.(ts|tsx|js|jsx|py|java|kt|go|rb|php|rs|cs|ex|exs)$/;
|
|
695
|
-
const files = (classifiedEndpoints.changedFiles ?? diffData.changedFiles ?? [])
|
|
696
|
-
.filter((f) => SOURCE_EXT.test(f) && !isTestFile(f));
|
|
697
|
-
// A schema/DTO/model file is named for its module, not the REST resource — its
|
|
698
|
-
// class names are the real signal (DeploymentCreate → "deployment", not the
|
|
699
|
-
// "actions" filename). For those files prefer the symbol-derived tokens and drop
|
|
700
|
-
// the generic filename token; otherwise use all tokens.
|
|
701
|
-
const SCHEMA_FILE = /\/(schemas?|models?|dtos?|entities|serializers?|types)\/|\.(dto|schema|model|entity|serializer)\.[^/]+$/i;
|
|
702
|
-
// Per-file diff sections (with @@ headers) so each file's tokens derive
|
|
703
|
-
// only from its own hunk — passing the full multi-file diff would mix
|
|
704
|
-
// symbols across files. Falls back to the full diff if a path doesn't
|
|
705
|
-
// match (no worse than the prior behavior). SKYR-3924.
|
|
706
|
-
const diffByFile = getDiffByFile();
|
|
707
|
-
const derived = new Set();
|
|
708
|
-
const symbols = new Set();
|
|
709
|
-
for (const f of files) {
|
|
710
|
-
const fileDiff = diffByFile.get(f) ?? diffData.diffContent;
|
|
711
|
-
const all = deriveResourceTokensForFile(f, fileDiff);
|
|
712
|
-
const filenameToken = deriveResourceToken(f);
|
|
713
|
-
const symbolTokens = all.filter((t) => t !== filenameToken);
|
|
714
|
-
// deriveResourceTokensForFile already drops generic tokens (via importerHop's
|
|
715
|
-
// GENERIC_RESOURCE_TOKENS / GENERIC_SYMBOL_TOKENS), so no extra filter is needed here.
|
|
716
|
-
const chosen = SCHEMA_FILE.test(f) && symbolTokens.length > 0 ? symbolTokens : all;
|
|
717
|
-
for (const t of chosen)
|
|
718
|
-
derived.add(t);
|
|
719
|
-
// Raw changed symbol names, derived from THIS file's diff slice only (not the full
|
|
720
|
-
// multi-file diff) so an unrelated changed file — a test or doc — can't inject its
|
|
721
|
-
// symbols. Uses the per-file slice from diffByFile.get(f).
|
|
722
|
-
for (const s of extractChangedSymbols(diffByFile.get(f) ?? ""))
|
|
723
|
-
symbols.add(s);
|
|
724
|
-
}
|
|
725
|
-
if (derived.size > 0) {
|
|
726
|
-
changedResources = [...derived];
|
|
727
|
-
// Raw changed symbol names (e.g. "DeploymentCreate") for a content grep against
|
|
728
|
-
// test bodies — catches tests that reference the changed type by name but whose
|
|
729
|
-
// path/URL doesn't token-match the resource (e.g. a schema unit test).
|
|
730
|
-
changedSymbols = [...symbols];
|
|
731
|
-
logger.info("Schema-only diff: derived changedResources from changed files (SKYR-3924)", {
|
|
732
|
-
changedResources,
|
|
733
|
-
changedSymbols,
|
|
734
|
-
});
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
// Compute changed frontend files once — reused for both discoverTests (component
|
|
738
|
-
// test promotion) and uiContext below (page enumeration). Uses classifiedEndpoints
|
|
739
|
-
// changedFiles so it reflects the same diff scope as the rest of the analysis.
|
|
740
|
-
const flutterSdkDep = hasFlutterSdkDep(params.repositoryPath);
|
|
741
|
-
const changedFrontendFiles = (classifiedEndpoints?.changedFiles ?? diffData?.changedFiles ?? [])
|
|
742
|
-
.filter(f => isFrontendFile(f, { hasFlutterSdkDep: flutterSdkDep }) && !isTestFile(f));
|
|
743
|
-
// Selector literals (data-testid values, CSS class tokens) added/removed in the
|
|
744
|
-
// frontend diff — drives discovery pass 3, which promotes page objects/specs that
|
|
745
|
-
// couple to a changed component by selector rather than by filename or import
|
|
746
|
-
// (see TestDiscoveryService Step 2). Derived from each changed frontend file's own
|
|
747
|
-
// diff slice so an unrelated file can't inject selectors.
|
|
748
|
-
let changedSelectors;
|
|
749
|
-
if (changedFrontendFiles.length > 0 && diffData?.diffContent) {
|
|
750
|
-
const diffByFile = getDiffByFile();
|
|
751
|
-
// Total cap across all changed files (each file is separately capped inside
|
|
752
|
-
// extractChangedSelectors). Bounds pass 3's O(externalFiles × selectors) scan so a
|
|
753
|
-
// large frontend PR can't produce a pathologically long selector set.
|
|
754
|
-
const MAX_TOTAL_SELECTORS = 200;
|
|
755
|
-
const selectors = new Set();
|
|
756
|
-
outer: for (const f of changedFrontendFiles) {
|
|
757
|
-
const fileSelectors = extractChangedSelectors(diffByFile.get(f) ?? "");
|
|
758
|
-
if (fileSelectors.length >= MAX_CHANGED_SELECTORS) {
|
|
759
|
-
logger.warning(`changedSelectors: file '${f}' hit the per-file cap (${MAX_CHANGED_SELECTORS}); its selector extraction may be truncated`);
|
|
760
|
-
}
|
|
761
|
-
for (const s of fileSelectors) {
|
|
762
|
-
selectors.add(s);
|
|
763
|
-
if (selectors.size >= MAX_TOTAL_SELECTORS) {
|
|
764
|
-
logger.warning(`changedSelectors: hit the total cap (${MAX_TOTAL_SELECTORS}) across ${changedFrontendFiles.length} changed frontend file(s); remaining selectors ignored for UI promotion pass 3`);
|
|
765
|
-
break outer;
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
if (selectors.size > 0) {
|
|
770
|
-
changedSelectors = [...selectors];
|
|
771
|
-
logger.info("Derived changedSelectors from frontend diff (UI promotion pass 3)", {
|
|
772
|
-
count: changedSelectors.length,
|
|
773
|
-
});
|
|
774
|
-
}
|
|
775
|
-
}
|
|
235
|
+
const getDiffByFile = () => (_diffByFile ??= diffData?.diffContent ? diffBlocksByPath(diffData.diffContent) : new Map());
|
|
236
|
+
// No resource tokens are derived from the changed files. Deriving them
|
|
237
|
+
// guessed which existing tests were relevant from filenames and symbol
|
|
238
|
+
// names; `TestDiscoveryService` now takes the external tests uncut, up to
|
|
239
|
+
// its full-repo cap, and the agent reads them and decides.
|
|
240
|
+
// No frontend/backend classification of the changed files. It read a
|
|
241
|
+
// path's extension and directory to decide what kind of file it was, and
|
|
242
|
+
// that verdict gated whether the agent did any UI work at all. The agent
|
|
243
|
+
// reads the diff.
|
|
776
244
|
let existingTests = [];
|
|
777
245
|
let discoveredRelevantExternalPaths = [];
|
|
246
|
+
let testDiscoveryFailed = false;
|
|
778
247
|
try {
|
|
779
248
|
const testDiscoveryService = new TestDiscoveryService();
|
|
780
249
|
// Run-scoped: only the primary call carries testsRepoDir, so never
|
|
781
250
|
// let a related repo's call clear it (SKYR-4204).
|
|
782
251
|
rememberTestsRepoDir(params.testsRepoDir);
|
|
783
|
-
const discoveryResult = await testDiscoveryService.discoverTests(testDir ?? params.repositoryPath
|
|
252
|
+
const discoveryResult = await testDiscoveryService.discoverTests(testDir ?? params.repositoryPath);
|
|
784
253
|
existingTests = discoveryResult.tests.map((test) => ({
|
|
785
254
|
testFile: test.testFile,
|
|
786
255
|
testType: test.testType,
|
|
@@ -799,6 +268,7 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
799
268
|
});
|
|
800
269
|
}
|
|
801
270
|
catch (err) {
|
|
271
|
+
testDiscoveryFailed = true;
|
|
802
272
|
logger.warning("Test discovery failed, continuing with empty list", {
|
|
803
273
|
error: err instanceof Error ? err.message : String(err),
|
|
804
274
|
});
|
|
@@ -885,81 +355,6 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
885
355
|
});
|
|
886
356
|
}
|
|
887
357
|
}
|
|
888
|
-
// ── Step 4c: Filter scanned endpoints against spec ──
|
|
889
|
-
// Sequencing: scannedEndpoints is populated at Step 2 (~line 400),
|
|
890
|
-
// but wsSchemaPath and specPaths aren't known until Step 4b (above).
|
|
891
|
-
// All of 1b, 5, 10a, and 8 must run here — after specPaths is assigned
|
|
892
|
-
// and before Step 7 (~line 604) builds skeletonEndpoints.
|
|
893
|
-
if (specPaths && specPaths.size > 0) {
|
|
894
|
-
// Build a set of paths that the current diff explicitly changed — these
|
|
895
|
-
// are preserved even if missing from spec (spec may lag the code on new PRs).
|
|
896
|
-
// removedEndpoints intentionally excluded: deleted paths won't appear in
|
|
897
|
-
// scannedEndpoints (they no longer exist in code), so there is nothing to
|
|
898
|
-
// preserve — including them would only produce spurious spec-lag warnings.
|
|
899
|
-
const diffChangedPaths = new Set([
|
|
900
|
-
...(classifiedEndpoints?.changedEndpoints ?? []).map(ep => ep.path),
|
|
901
|
-
...(classifiedEndpoints?.newEndpoints ?? []).map(ep => ep.path),
|
|
902
|
-
]);
|
|
903
|
-
const beforeCount = scannedEndpoints.length;
|
|
904
|
-
// ── Steps 4c + 4c-merge: filter against spec, merge spec-only paths ──
|
|
905
|
-
scannedEndpoints = filterEndpointsBySpec(scannedEndpoints, specPaths, spec.paths ?? {}, diffChangedPaths);
|
|
906
|
-
logger.info("Filtered scanned endpoints against OpenAPI spec", {
|
|
907
|
-
before: beforeCount, after: scannedEndpoints.length,
|
|
908
|
-
delta: scannedEndpoints.length - beforeCount, // positive = net added (spec merge), negative = net removed
|
|
909
|
-
});
|
|
910
|
-
// Warn when diff-changed endpoints were missing from spec — indicates spec lag
|
|
911
|
-
const specLagPaths = [...diffChangedPaths].filter(p => !specPaths.has(p) &&
|
|
912
|
-
!specPaths.has(p.replace(/:[a-zA-Z_][a-zA-Z0-9_]*/g, m => `{${m.slice(1)}}`)));
|
|
913
|
-
if (specLagPaths.length > 0) {
|
|
914
|
-
logger.warning("Spec may be lagging code — diff-changed paths missing from spec (kept in catalog)", {
|
|
915
|
-
paths: specLagPaths, schemaPath: wsSchemaPath,
|
|
916
|
-
});
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
// ── Step 4d: Filter unsupported protocol endpoints (GraphQL) ──
|
|
920
|
-
// Must run AFTER spec-merge above — specs may include /graphql and the
|
|
921
|
-
// merge step would re-add it if this ran earlier.
|
|
922
|
-
{
|
|
923
|
-
const beforeUnsupported = scannedEndpoints.length;
|
|
924
|
-
scannedEndpoints = await filterUnsupportedGraphQLEndpoints(scannedEndpoints, params.repositoryPath);
|
|
925
|
-
if (scannedEndpoints.length < beforeUnsupported) {
|
|
926
|
-
logger.info("Filtered unsupported protocol endpoints (GraphQL)", {
|
|
927
|
-
removed: beforeUnsupported - scannedEndpoints.length,
|
|
928
|
-
});
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
// ── Step 4e: Path-affinity classification fallback (SKYR-3857) ──
|
|
932
|
-
// classifyEndpointsByChangedFiles (Step 2.5, above) matched changed files
|
|
933
|
-
// to endpoints by exact sourceFile equality against the catalog as it stood
|
|
934
|
-
// BEFORE the spec merge (Step 4c) — deliberately: this fallback needs the
|
|
935
|
-
// final, spec-merged, GraphQL-filtered scannedEndpoints to have anything to
|
|
936
|
-
// match against for a repo whose catalog is entirely spec-derived (every
|
|
937
|
-
// entry sourceFile: ""), which is exactly the case it exists to cover. It
|
|
938
|
-
// still runs well before diffContext/hasApiChanges (built much later, from
|
|
939
|
-
// classifiedEndpoints) are derived, so the merged classification below is
|
|
940
|
-
// visible everywhere that matters.
|
|
941
|
-
if (classifiedEndpoints?.unmatchedFiles?.length) {
|
|
942
|
-
const fallback = classifyUnmatchedFilesByPathAffinity(classifiedEndpoints.unmatchedFiles, scannedEndpoints,
|
|
943
|
-
// changed+new are the dedup list (sourceFile::path keys — prevents
|
|
944
|
-
// double-adding). removedEndpoints is passed separately: its entries
|
|
945
|
-
// are keyed by the DELETED file's path so dedup can never match them;
|
|
946
|
-
// the fallback excludes them from its candidates by path+method so a
|
|
947
|
-
// stale spec-merged entry can't resurrect a removed endpoint as
|
|
948
|
-
// "modified".
|
|
949
|
-
[...classifiedEndpoints.changedEndpoints, ...classifiedEndpoints.newEndpoints], undefined, diffData?.diffContent, classifiedEndpoints.removedEndpoints);
|
|
950
|
-
if (fallback.endpoints.length > 0) {
|
|
951
|
-
const mappedFiles = new Set(fallback.mappedFiles);
|
|
952
|
-
classifiedEndpoints = {
|
|
953
|
-
...classifiedEndpoints,
|
|
954
|
-
changedEndpoints: [...classifiedEndpoints.changedEndpoints, ...fallback.endpoints],
|
|
955
|
-
unmatchedFiles: classifiedEndpoints.unmatchedFiles.filter((f) => !mappedFiles.has(f)),
|
|
956
|
-
};
|
|
957
|
-
logger.info("Path-affinity fallback recovered endpoint classification", {
|
|
958
|
-
files: mappedFiles.size,
|
|
959
|
-
endpoints: fallback.endpoints.length,
|
|
960
|
-
});
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
358
|
// ── Step 5: Detect project metadata ──
|
|
964
359
|
const projectMeta = detectProjectMetadata(params.repositoryPath);
|
|
965
360
|
// ── Step 6: Trace files ──
|
|
@@ -979,164 +374,9 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
979
374
|
});
|
|
980
375
|
}
|
|
981
376
|
}
|
|
982
|
-
//
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
: method === "DELETE"
|
|
986
|
-
? { statusCode: 204, description: "No Content" }
|
|
987
|
-
: { statusCode: 200, description: "OK" };
|
|
988
|
-
const skeletonEndpoints = scannedEndpoints.map((ep) => ({
|
|
989
|
-
path: ep.path,
|
|
990
|
-
resourceGroup: ep.path.split("/").filter(Boolean).pop() || "unknown",
|
|
991
|
-
pathParams: (ep.path.match(/\{(\w+)\}/g) || []).map((p) => ({
|
|
992
|
-
name: p.slice(1, -1),
|
|
993
|
-
type: "string",
|
|
994
|
-
required: true,
|
|
995
|
-
})),
|
|
996
|
-
methods: ep.methods.map((m) => ({
|
|
997
|
-
method: m,
|
|
998
|
-
description: "",
|
|
999
|
-
queryParams: [],
|
|
1000
|
-
authRequired: true,
|
|
1001
|
-
sourceFile: ep.sourceFile,
|
|
1002
|
-
interactions: [
|
|
1003
|
-
{
|
|
1004
|
-
description: `${m} ${ep.path}`,
|
|
1005
|
-
type: "success",
|
|
1006
|
-
request: {},
|
|
1007
|
-
response: skeletonResponse(m),
|
|
1008
|
-
},
|
|
1009
|
-
],
|
|
1010
|
-
})),
|
|
1011
|
-
}));
|
|
1012
|
-
// ── Step 8: Merge trace interactions ──
|
|
1013
|
-
if (traceResult && traceResult.entries.length > 0) {
|
|
1014
|
-
for (const entry of traceResult.entries) {
|
|
1015
|
-
let rawPath = entry.path;
|
|
1016
|
-
try {
|
|
1017
|
-
rawPath = new URL(rawPath).pathname;
|
|
1018
|
-
}
|
|
1019
|
-
catch {
|
|
1020
|
-
/* already a path */
|
|
1021
|
-
}
|
|
1022
|
-
const normalizedPath = rawPath
|
|
1023
|
-
.replace(/\/[0-9a-f-]{20,}/gi, "/{id}")
|
|
1024
|
-
.replace(/\/\d+/g, "/{id}");
|
|
1025
|
-
const existing = skeletonEndpoints.find((ep) => {
|
|
1026
|
-
const epNorm = ep.path.replace(/\{\w+\}/g, "/{id}");
|
|
1027
|
-
return epNorm === normalizedPath;
|
|
1028
|
-
});
|
|
1029
|
-
if (existing) {
|
|
1030
|
-
const methodObj = existing.methods.find((m) => m.method === entry.method);
|
|
1031
|
-
if (methodObj) {
|
|
1032
|
-
const alreadyHasStatus = methodObj.interactions.some((i) => i.response.statusCode === entry.statusCode);
|
|
1033
|
-
if (!alreadyHasStatus) {
|
|
1034
|
-
const traceRequest = {};
|
|
1035
|
-
if (entry.requestBody)
|
|
1036
|
-
traceRequest.body = entry.requestBody;
|
|
1037
|
-
if (entry.queryParams)
|
|
1038
|
-
traceRequest.queryParams = entry.queryParams;
|
|
1039
|
-
methodObj.interactions.push({
|
|
1040
|
-
description: `${entry.method} ${entry.path} \u2192 ${entry.statusCode} (from trace)`,
|
|
1041
|
-
type: "success",
|
|
1042
|
-
request: Object.keys(traceRequest).length > 0 ? traceRequest : {},
|
|
1043
|
-
response: {
|
|
1044
|
-
statusCode: entry.statusCode,
|
|
1045
|
-
description: `Observed in trace (${traceResult.format})`,
|
|
1046
|
-
...(entry.responseBody
|
|
1047
|
-
? { body: entry.responseBody }
|
|
1048
|
-
: {}),
|
|
1049
|
-
},
|
|
1050
|
-
});
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
// ── Step 9: Draft scenarios ──
|
|
1057
|
-
// Only new endpoints are passed as "diff-direct" — draftDiffDirectScenarios
|
|
1058
|
-
// generates success-oriented scenarios (200/201/204) which are wrong for
|
|
1059
|
-
// removed endpoints. Removal coverage (verify-404) is handled by the LLM
|
|
1060
|
-
// from the diffContext.removedEndpoints signal in the recommendation prompt.
|
|
1061
|
-
// Classified endpoints have full paths and concrete methods (no MULTI sentinels).
|
|
1062
|
-
const newEndpointsForDrafting = classifiedEndpoints?.newEndpoints.flatMap((ep) => ep.methods.map((m) => ({
|
|
1063
|
-
method: m,
|
|
1064
|
-
path: ep.path,
|
|
1065
|
-
sourceFile: ep.sourceFile,
|
|
1066
|
-
}))) ?? [];
|
|
1067
|
-
const changedEndpointsForSecurityExpansion = classifiedEndpoints?.changedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
|
|
1068
|
-
method: m,
|
|
1069
|
-
path: ep.path,
|
|
1070
|
-
sourceFile: ep.sourceFile,
|
|
1071
|
-
}))) ?? [];
|
|
1072
|
-
const securityRelevantDiff = Boolean(diffData?.diffContent && isSecurityRelevantDiff(diffData.diffContent));
|
|
1073
|
-
// Full-repo mode: no diff context, so seed scenario drafting from the entire
|
|
1074
|
-
// skeletonEndpoints catalog. We gate on analysisScope (not just array length)
|
|
1075
|
-
// to avoid drafting catalog-wide scenarios for PR-mode diffs that happened to
|
|
1076
|
-
// add zero new endpoints (only changed or removed existing ones).
|
|
1077
|
-
const fullRepoMode = analysisScope !== AnalysisScope.CurrentBranchDiff;
|
|
1078
|
-
const scenarioDraftSeed = newEndpointsForDrafting.length > 0
|
|
1079
|
-
? newEndpointsForDrafting
|
|
1080
|
-
: fullRepoMode
|
|
1081
|
-
? skeletonEndpoints.flatMap(ep => ep.methods.map(m => ({
|
|
1082
|
-
method: typeof m === "string" ? m : m.method,
|
|
1083
|
-
path: ep.path,
|
|
1084
|
-
sourceFile: m.sourceFile ?? "",
|
|
1085
|
-
})))
|
|
1086
|
-
: [];
|
|
1087
|
-
const removedEndpointsForDrafting = classifiedEndpoints?.removedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
|
|
1088
|
-
method: m,
|
|
1089
|
-
path: ep.path,
|
|
1090
|
-
sourceFile: ep.sourceFile,
|
|
1091
|
-
}))) ?? [];
|
|
1092
|
-
const codeInferredScenarios = draftScenariosFromEndpoints(skeletonEndpoints, scenarioDraftSeed, wsAuthType, {
|
|
1093
|
-
changedEndpoints: changedEndpointsForSecurityExpansion,
|
|
1094
|
-
securityRelevantDiff,
|
|
1095
|
-
}, removedEndpointsForDrafting);
|
|
1096
|
-
// Response-shape scenarios for importer-hop-mapped endpoints (SKYR-3855):
|
|
1097
|
-
// deterministic coverage for backend changes (DTOs/services) that touch no
|
|
1098
|
-
// route directly, so the ranked plan mentions the actual change instead of
|
|
1099
|
-
// falling back to generic structural tests on unrelated resources.
|
|
1100
|
-
// See `buildHopResponseShapeInputs` for why this is keyed off `byFile`
|
|
1101
|
-
// rather than `hopResult.endpoints`.
|
|
1102
|
-
const hopResponseShapeInputs = buildHopResponseShapeInputs(hopResult, scannedEndpoints, diffData?.diffContent);
|
|
1103
|
-
const responseShapeScenarios = hopResponseShapeInputs.endpoints.length > 0
|
|
1104
|
-
? draftResponseShapeScenarios(hopResponseShapeInputs.endpoints, hopResult.byFile, hopResponseShapeInputs.addedFieldsByFile)
|
|
1105
|
-
: [];
|
|
1106
|
-
// `codeInferredScenarios` is already capped internally by
|
|
1107
|
-
// draftScenariosFromEndpoints, but `responseShapeScenarios` is appended
|
|
1108
|
-
// afterward — re-cap the combined list so the global MAX_TOTAL_SCENARIOS
|
|
1109
|
-
// bound (prompt size/cost, ranking input) still holds.
|
|
1110
|
-
let allDraftedScenarios = capScenarios([...codeInferredScenarios, ...responseShapeScenarios]);
|
|
1111
|
-
if (traceResult && traceResult.userFlows.length > 0) {
|
|
1112
|
-
const traceScenarios = traceResult.userFlows
|
|
1113
|
-
.slice(0, 5)
|
|
1114
|
-
.map((flow, idx) => ({
|
|
1115
|
-
scenarioName: `trace-flow-${idx + 1}`,
|
|
1116
|
-
description: `User flow from trace: ${flow.entries.map((e) => `${e.method} ${e.path}`).join(" \u2192 ")}`,
|
|
1117
|
-
category: "workflow",
|
|
1118
|
-
priority: "high",
|
|
1119
|
-
steps: flow.entries.map((e, stepIdx) => ({
|
|
1120
|
-
order: stepIdx + 1,
|
|
1121
|
-
method: e.method,
|
|
1122
|
-
path: e.path,
|
|
1123
|
-
description: `${e.method} ${e.path} \u2192 ${e.statusCode}`,
|
|
1124
|
-
interactionType: e.statusCode < 400
|
|
1125
|
-
? "success"
|
|
1126
|
-
: "error",
|
|
1127
|
-
requestBody: e.requestBody,
|
|
1128
|
-
responseBody: e.responseBody,
|
|
1129
|
-
expectedStatusCode: e.statusCode,
|
|
1130
|
-
})),
|
|
1131
|
-
chainingKeys: [],
|
|
1132
|
-
requiresAuth: true,
|
|
1133
|
-
estimatedComplexity: flow.entries.length > 3
|
|
1134
|
-
? "complex"
|
|
1135
|
-
: "moderate",
|
|
1136
|
-
source: ScenarioSource.Trace,
|
|
1137
|
-
}));
|
|
1138
|
-
allDraftedScenarios = capScenarios([...traceScenarios, ...codeInferredScenarios, ...responseShapeScenarios]);
|
|
1139
|
-
}
|
|
377
|
+
// Nothing is drafted here. The server proposed scenarios from the
|
|
378
|
+
// catalog and from trace flows; the agent drafts every candidate and
|
|
379
|
+
// registers it through the plan tool.
|
|
1140
380
|
await sendProgress(80, 100, "Building unified state...");
|
|
1141
381
|
// ── Step 10: Build full RepositoryAnalysis for ranked recommendations ──
|
|
1142
382
|
const sessionId = crypto.randomUUID();
|
|
@@ -1201,91 +441,23 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
1201
441
|
// Relativize against repositoryPath to avoid leaking absolute machine paths
|
|
1202
442
|
// into the prompt, state file, and PR reports.
|
|
1203
443
|
const relevantExternalTestPaths = discoveredRelevantExternalPaths.map(p => path.relative(params.repositoryPath, p));
|
|
1204
|
-
// Build the full RepositoryAnalysis object — same structure as
|
|
1205
|
-
//
|
|
1206
|
-
//
|
|
1207
|
-
//
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
interactionCount: 0,
|
|
1218
|
-
})),
|
|
1219
|
-
})),
|
|
1220
|
-
modifiedEndpoints: classifiedEndpoints.changedEndpoints.map((ep) => ({
|
|
1221
|
-
path: ep.path,
|
|
1222
|
-
methods: ep.methods.map((m) => ({
|
|
1223
|
-
method: m,
|
|
1224
|
-
sourceFile: ep.sourceFile,
|
|
1225
|
-
changeType: "modified",
|
|
1226
|
-
})),
|
|
1227
|
-
})),
|
|
1228
|
-
removedEndpoints: classifiedEndpoints.removedEndpoints.map((ep) => ({
|
|
1229
|
-
path: ep.path,
|
|
1230
|
-
methods: ep.methods.map((m) => ({
|
|
1231
|
-
method: m,
|
|
1232
|
-
sourceFile: ep.sourceFile,
|
|
1233
|
-
changeType: "removed",
|
|
1234
|
-
})),
|
|
1235
|
-
})),
|
|
1236
|
-
affectedServices: classifiedEndpoints.affectedServices,
|
|
1237
|
-
summary: "",
|
|
444
|
+
// Build the full RepositoryAnalysis object — same structure as
|
|
445
|
+
// analyzeRepositoryTool. It is stored in the in-process session store for
|
|
446
|
+
// the MCP resources and registerRecommendTestsPrompt; the tool result
|
|
447
|
+
// does not carry it.
|
|
448
|
+
// The branch diff itself — which branch, against which base, which files.
|
|
449
|
+
// No endpoint lists: the scan that produced them ran a route parser over
|
|
450
|
+
// the diff, and the agent reads the diff directly instead.
|
|
451
|
+
// No `summary`: the server used to write a sentence about the diff here
|
|
452
|
+
// and it has written an empty string since. Nothing reads the field.
|
|
453
|
+
const diffContext = diffData ? {
|
|
454
|
+
currentBranch: diffData.currentBranch,
|
|
455
|
+
baseBranch: diffData.baseBranch,
|
|
456
|
+
changedFiles: diffData.changedFiles,
|
|
1238
457
|
} : undefined;
|
|
1239
|
-
//
|
|
1240
|
-
//
|
|
1241
|
-
//
|
|
1242
|
-
// slim fields that downstream tools (health, execute, actions) need.
|
|
1243
|
-
// routerMountContext and candidateRouteFiles are computed here so they
|
|
1244
|
-
// can be persisted to the state file for downstream tools (health, drift).
|
|
1245
|
-
// Without them, analyzeTestHealth would work only off the static catalog
|
|
1246
|
-
// which has wrong paths for nested resources and unsupported frameworks.
|
|
1247
|
-
const routeLikeUnmatchedFiles = [];
|
|
1248
|
-
for (const file of classifiedEndpoints?.unmatchedFiles ?? []) {
|
|
1249
|
-
const routeLike = SOURCE_EXTS.test(file) &&
|
|
1250
|
-
(ROUTE_FILE_PATTERN.test(file) || ROUTE_FILE_BASENAME_PATTERN.test(path.basename(file)));
|
|
1251
|
-
if (routeLike && !(await isGraphQLFile(file, params.repositoryPath))) {
|
|
1252
|
-
routeLikeUnmatchedFiles.push(file);
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
const shouldIncludeCandidateRouteFiles = analysisScope !== AnalysisScope.CurrentBranchDiff ||
|
|
1256
|
-
rawRelatedEndpointCount === 0 ||
|
|
1257
|
-
scannedEndpoints.length === 0 ||
|
|
1258
|
-
routeLikeUnmatchedFiles.length > 0;
|
|
1259
|
-
let candidateRouteFiles;
|
|
1260
|
-
if (shouldIncludeCandidateRouteFiles) {
|
|
1261
|
-
candidateRouteFiles = [];
|
|
1262
|
-
for (const file of findCandidateRouteFiles(params.repositoryPath)) {
|
|
1263
|
-
if (!(await isGraphQLFile(file, params.repositoryPath))) {
|
|
1264
|
-
candidateRouteFiles.push(file);
|
|
1265
|
-
}
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
// Write the full diff to a temp file before building state so the path
|
|
1269
|
-
// can be persisted and read by analyzeTestHealthTool for per-line detection.
|
|
1270
|
-
let diffFilePath;
|
|
1271
|
-
if (diffData?.diffContent) {
|
|
1272
|
-
diffFilePath = path.join(os.tmpdir(), `skyramp-diff-${sessionId}.diff`);
|
|
1273
|
-
await fs.promises.writeFile(diffFilePath, diffData.diffContent, { encoding: "utf-8", mode: 0o600 });
|
|
1274
|
-
}
|
|
1275
|
-
const routeDiscovery = {
|
|
1276
|
-
candidateFiles: [
|
|
1277
|
-
...(diffData?.changedFiles ?? []),
|
|
1278
|
-
...(candidateRouteFiles ?? []),
|
|
1279
|
-
].filter((file, index, files) => files.indexOf(file) === index),
|
|
1280
|
-
staticHints: scannedEndpoints.map((ep) => ({
|
|
1281
|
-
path: ep.path,
|
|
1282
|
-
methods: ep.methods,
|
|
1283
|
-
sourceFile: ep.sourceFile,
|
|
1284
|
-
})),
|
|
1285
|
-
openApiPaths: specPaths ? [...specPaths] : [],
|
|
1286
|
-
routerMountContext,
|
|
1287
|
-
...(diffFilePath ? { diffFilePath } : {}),
|
|
1288
|
-
};
|
|
458
|
+
// No `routeDiscovery`. Its three parts — a candidate route-file list, a
|
|
459
|
+
// router entry-point grep and the OpenAPI path list — were the server's
|
|
460
|
+
// guess at the repository's surface. Nothing downstream read them.
|
|
1289
461
|
const fullAnalysis = {
|
|
1290
462
|
metadata: {
|
|
1291
463
|
repositoryName: path.basename(params.repositoryPath),
|
|
@@ -1310,7 +482,6 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
1310
482
|
userFlows: [],
|
|
1311
483
|
dataFlows: [],
|
|
1312
484
|
integrationPatterns: [],
|
|
1313
|
-
draftedScenarios: allDraftedScenarios,
|
|
1314
485
|
},
|
|
1315
486
|
artifacts: {
|
|
1316
487
|
openApiSpecs: wsSchemaPath ? [{ path: wsSchemaPath, version: "from-workspace-config", endpointCount: 0, baseUrl: wsBaseUrl, authType: wsAuthMethod }] : [],
|
|
@@ -1318,11 +489,7 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
1318
489
|
traceFiles: traceResult ? [buildTraceFileEntry(traceFiles[0], traceResult)] : [],
|
|
1319
490
|
notFound: [],
|
|
1320
491
|
},
|
|
1321
|
-
|
|
1322
|
-
totalCount: skeletonEndpoints.reduce((acc, ep) => acc + ep.methods.length, 0),
|
|
1323
|
-
baseUrl: wsBaseUrl,
|
|
1324
|
-
endpoints: skeletonEndpoints,
|
|
1325
|
-
},
|
|
492
|
+
workspace: { baseUrl: wsBaseUrl },
|
|
1326
493
|
authentication: {
|
|
1327
494
|
method: wsAuthMethod,
|
|
1328
495
|
configLocation: wsAuthHeader ? ".skyramp/workspace.yml" : "",
|
|
@@ -1342,7 +509,6 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
1342
509
|
hasCoverageReports: false,
|
|
1343
510
|
relevantExternalTestPaths,
|
|
1344
511
|
},
|
|
1345
|
-
routeDiscovery,
|
|
1346
512
|
...(diffContext ? { branchDiffContext: diffContext } : {}),
|
|
1347
513
|
};
|
|
1348
514
|
// Store RecommendationState in memory so it's compatible with skyramp_recommend_tests if needed
|
|
@@ -1352,175 +518,73 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
1352
518
|
analysis: fullAnalysis,
|
|
1353
519
|
};
|
|
1354
520
|
storeSessionData(sessionId, recommendationState);
|
|
1355
|
-
//
|
|
1356
|
-
//
|
|
1357
|
-
//
|
|
1358
|
-
// an extra file read of its own.
|
|
1359
|
-
const DIFF_TEXT_CAP = 200_000;
|
|
1360
|
-
const diffText = diffData?.diffContent ? diffData.diffContent.slice(0, DIFF_TEXT_CAP) : undefined;
|
|
1361
|
-
// Compute UI context from the diff's changed files using the shared
|
|
1362
|
-
// `isFrontendFile` classifier. Persisting this in the stateFile lets
|
|
1363
|
-
// skyramp_analyze_test_health and the recommendation prompt consume the
|
|
1364
|
-
// same classification without re-deriving it. Absent on backend-only PRs.
|
|
1365
|
-
//
|
|
1366
|
-
// candidateUiPages is enumerated programmatically via the strategy
|
|
1367
|
-
// ladder in uiPageEnumerator (framework route grep + import graph,
|
|
1368
|
-
// source-grounded routes, root fallback). The agent uses these to capture
|
|
1369
|
-
// browser_blueprints — see uiInstructions below, which this tool returns
|
|
1370
|
-
// so the agent captures element vocabulary for UI rec reasoning.
|
|
521
|
+
// UI context. Nothing here classifies a file as frontend any more, so it
|
|
522
|
+
// is built for every run that has a diff rather than only for one the
|
|
523
|
+
// server judged to have touched the frontend.
|
|
1371
524
|
const uiContext = await (async () => {
|
|
1372
|
-
|
|
1373
|
-
if (changedFrontendFiles.length === 0)
|
|
525
|
+
if (!diffData)
|
|
1374
526
|
return undefined;
|
|
1375
|
-
// SKYR-
|
|
1376
|
-
//
|
|
1377
|
-
//
|
|
1378
|
-
//
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
527
|
+
// SKYR-4305: identifying `data-*` attributes the diff removed from a
|
|
528
|
+
// file that survives it — grounding for the removal test the removal
|
|
529
|
+
// check requires, since a removed element appears in no blueprint.
|
|
530
|
+
// Purely additive: a failure here must never cost the analyze call.
|
|
531
|
+
let removedElements = [];
|
|
532
|
+
if (diffData?.diffContent) {
|
|
533
|
+
try {
|
|
534
|
+
removedElements = collectRemovedUiElements({
|
|
535
|
+
// Every changed file that is not a test. A removed `data-testid`
|
|
536
|
+
// is found by reading the diff, not by the file's extension.
|
|
537
|
+
changedFrontendFiles: (diffData.changedFiles ?? []).filter((f) => !isTestFile(f)),
|
|
538
|
+
deletedFiles: diffData.deletedFiles ?? [],
|
|
539
|
+
diffByFile: getDiffByFile(),
|
|
540
|
+
existingTestFiles: [
|
|
541
|
+
...existingTests.map((t) => t.testFile),
|
|
542
|
+
...discoveredRelevantExternalPaths,
|
|
543
|
+
],
|
|
544
|
+
discoveryFailed: testDiscoveryFailed,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
catch (err) {
|
|
548
|
+
logger.warning("removedUiElements: collection failed, continuing without removal-guard grounding", {
|
|
549
|
+
error: err instanceof Error ? err.message : String(err),
|
|
550
|
+
});
|
|
551
|
+
removedElements = [];
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (removedElements.length > 0) {
|
|
555
|
+
logger.info("Removed UI elements detected (removal-guard grounding)", {
|
|
556
|
+
count: removedElements.length,
|
|
557
|
+
removed: removedElements.map((e) => `${e.attribute}="${e.value}"`),
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
return removedElements.length > 0 ? { removedElements } : {};
|
|
1387
561
|
})();
|
|
1388
|
-
//
|
|
1389
|
-
//
|
|
1390
|
-
//
|
|
1391
|
-
// instead of two. The captures stay in the agent's tool-result history and
|
|
1392
|
-
// serve as element vocabulary when it writes UI recommendation reasoning.
|
|
1393
|
-
//
|
|
1394
|
-
// SKYR-3855: when the integration check finds no production importer for
|
|
1395
|
-
// some/all changed frontend files, that overrides/augments the normal
|
|
1396
|
-
// capture flow — see buildUnintegratedInstructions.
|
|
1397
|
-
const unintegratedFrontendFiles = (uiContext?.frontendFileIntegration ?? []).filter((r) => !r.integrated);
|
|
1398
|
-
const allFrontendFilesUnintegrated = !!uiContext &&
|
|
1399
|
-
uiContext.changedFrontendFiles.length > 0 &&
|
|
1400
|
-
unintegratedFrontendFiles.length === uiContext.changedFrontendFiles.length;
|
|
1401
|
-
const uiInstructions = !uiContext || uiContext.changedFrontendFiles.length === 0
|
|
1402
|
-
? NO_UI_INSTRUCTIONS
|
|
1403
|
-
: allFrontendFilesUnintegrated
|
|
1404
|
-
? buildUnintegratedInstructions(unintegratedFrontendFiles)
|
|
1405
|
-
: (uiContext.candidateUiPages.length === 0
|
|
1406
|
-
? NO_RESOLVABLE_URLS_INSTRUCTIONS
|
|
1407
|
-
: buildCaptureInstructions(uiContext.candidateUiPages)) +
|
|
1408
|
-
(unintegratedFrontendFiles.length > 0
|
|
1409
|
-
? `\n\n${buildUnintegratedInstructions(unintegratedFrontendFiles)}`
|
|
1410
|
-
: "");
|
|
1411
|
-
// ── SKYR-3879 Path B: budget context + pre-seeded approved plan ──
|
|
1412
|
-
// Computed BEFORE the state write (not after, alongside the
|
|
1413
|
-
// recommendation prompt) so both this pre-seed and buildRecommendationPrompt
|
|
1414
|
-
// derive their GENERATE/ADDITIONAL split from the exact same numbers —
|
|
1415
|
-
// computeScoredCandidates is the single source of truth for both.
|
|
1416
|
-
const topN = params.topN ?? MAX_RECOMMENDATIONS;
|
|
1417
|
-
const scoredResult = computeScoredCandidates(fullAnalysis, analysisScope, topN, params.maxGenerate);
|
|
1418
|
-
// Resolve each scenario's subject endpoints once, here — this is the only
|
|
1419
|
-
// point that has the scenarios, the diff, and runs before BOTH consumers
|
|
1420
|
-
// (selectPlan below, and the two prompt renderers). `scored.scenario` is
|
|
1421
|
-
// the SAME object as the entry in `allDraftedScenarios` (assigned by
|
|
1422
|
-
// reference into businessContext.draftedScenarios, then into
|
|
1423
|
-
// repositoryAnalysis.scenarios below), so this mutation also fills what
|
|
1424
|
-
// gets persisted. Filling later would leave one consumer keying off a
|
|
1425
|
-
// different subject (SKYR-4214).
|
|
1426
|
-
const changedRoutesForSubjects = collectChangedRouteLines(diffText ?? "");
|
|
1427
|
-
for (const scored of scoredResult.scored) {
|
|
1428
|
-
scored.scenario.subjectEndpoints = resolveSubjectEndpoints(scored.scenario, {
|
|
1429
|
-
changedRoutes: changedRoutesForSubjects,
|
|
1430
|
-
});
|
|
1431
|
-
}
|
|
1432
|
-
const externalCoverage = buildExternalCoverageSet(testLocationsByType);
|
|
1433
|
-
const diffChangesTestFiles = computeDiffChangesTestFiles(diffData, await getUserChangedFiles(params.repositoryPath));
|
|
1434
|
-
const planBudgetContext = {
|
|
1435
|
-
maxGenerate: scoredResult.maxGen,
|
|
1436
|
-
maxTotal: topN,
|
|
1437
|
-
isUIOnlyPR: scoredResult.isUIOnlyPR,
|
|
1438
|
-
hasFrontendChanges: scoredResult.hasFrontendChanges,
|
|
1439
|
-
externalCoverageKeys: [...externalCoverage],
|
|
1440
|
-
diffChangesTestFiles,
|
|
1441
|
-
};
|
|
1442
|
-
let approvedPlan;
|
|
1443
|
-
if (scoredResult.scored.length > 0) {
|
|
1444
|
-
const budgetCtx = {
|
|
1445
|
-
maxGenerate: planBudgetContext.maxGenerate,
|
|
1446
|
-
maxTotal: planBudgetContext.maxTotal,
|
|
1447
|
-
isUIOnlyPR: planBudgetContext.isUIOnlyPR,
|
|
1448
|
-
hasFrontendChanges: planBudgetContext.hasFrontendChanges,
|
|
1449
|
-
externalCoverage,
|
|
1450
|
-
diffChangesTestFiles,
|
|
1451
|
-
};
|
|
1452
|
-
const serverCandidates = scoredResult.scored.map(({ scenario, priority, novelty }) => ({
|
|
1453
|
-
scenario,
|
|
1454
|
-
priority,
|
|
1455
|
-
novelty,
|
|
1456
|
-
source: CandidateSource.SERVER,
|
|
1457
|
-
candidateId: computeCandidateId(scenario),
|
|
1458
|
-
}));
|
|
1459
|
-
const plan = selectPlan(serverCandidates, { ...budgetCtx, diffText });
|
|
1460
|
-
approvedPlan = {
|
|
1461
|
-
planId: crypto.randomUUID(),
|
|
1462
|
-
createdAt: new Date().toISOString(),
|
|
1463
|
-
generate: plan.generate.map(buildApprovedPlanItem),
|
|
1464
|
-
additional: plan.additional.map(buildApprovedPlanItem),
|
|
1465
|
-
demotions: [],
|
|
1466
|
-
dropped: plan.dropped,
|
|
1467
|
-
};
|
|
1468
|
-
}
|
|
562
|
+
// The server proposes no tests. It returns the analysis; the agent
|
|
563
|
+
// drafts its own plan from it and registers that through
|
|
564
|
+
// skyramp_register_test_plan.
|
|
1469
565
|
const unifiedState = {
|
|
1470
566
|
existingTests,
|
|
1471
|
-
newEndpoints: newEndpointsForDrafting,
|
|
1472
567
|
analysisScope,
|
|
1473
568
|
...(uiContext ? { uiContext } : {}),
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
569
|
+
// Only what another tool or prompt reads back. The workspace base URL,
|
|
570
|
+
// the spec path, the auth method, the project metadata, the relevant
|
|
571
|
+
// external test paths and the changed-file names were all written here
|
|
572
|
+
// for v1 stages that no longer exist; the tool result carries the ones
|
|
573
|
+
// the agent still needs.
|
|
1477
574
|
repositoryAnalysis: {
|
|
1478
|
-
|
|
1479
|
-
projectMeta,
|
|
1480
|
-
wsBaseUrl,
|
|
575
|
+
// The IDE recommendation prompt reads these four.
|
|
1481
576
|
wsAuthHeader,
|
|
1482
577
|
wsAuthType,
|
|
1483
578
|
wsAuthScheme,
|
|
1484
|
-
wsSchemaPath,
|
|
1485
|
-
wsAuthMethod,
|
|
1486
|
-
specFetchSucceeded,
|
|
1487
|
-
scenarios: allDraftedScenarios,
|
|
1488
|
-
diffFilePath,
|
|
1489
579
|
testLocations: testLocationsByType,
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
path: ep.path,
|
|
1497
|
-
methods: ep.methods.map((m) => ({
|
|
1498
|
-
method: m,
|
|
1499
|
-
sourceFile: ep.sourceFile,
|
|
1500
|
-
})),
|
|
1501
|
-
})),
|
|
1502
|
-
modifiedEndpoints: classifiedEndpoints.changedEndpoints.map((ep) => ({
|
|
1503
|
-
path: ep.path,
|
|
1504
|
-
methods: ep.methods.map((m) => ({
|
|
1505
|
-
method: m,
|
|
1506
|
-
sourceFile: ep.sourceFile,
|
|
1507
|
-
})),
|
|
1508
|
-
})),
|
|
1509
|
-
removedEndpoints: classifiedEndpoints.removedEndpoints.map((ep) => ({
|
|
1510
|
-
path: ep.path,
|
|
1511
|
-
methods: ep.methods.map((m) => ({
|
|
1512
|
-
method: m,
|
|
1513
|
-
sourceFile: ep.sourceFile,
|
|
1514
|
-
})),
|
|
1515
|
-
})),
|
|
1516
|
-
affectedServices: classifiedEndpoints.affectedServices,
|
|
1517
|
-
}
|
|
580
|
+
// NAMES only, never content: the state file's readers ask which files
|
|
581
|
+
// the change touched, and the agent reads the diff itself.
|
|
582
|
+
// skyramp_register_test_plan still recomputes the listing at verify time
|
|
583
|
+
// from `baseBranch`, so the two cannot drift apart.
|
|
584
|
+
diff: diffData
|
|
585
|
+
? { baseBranch: diffData.baseBranch, changedFiles: changedNames.map((file) => file.path) }
|
|
1518
586
|
: undefined,
|
|
1519
587
|
sessionId,
|
|
1520
|
-
routerMountContext,
|
|
1521
|
-
candidateRouteFiles,
|
|
1522
|
-
routeDiscovery,
|
|
1523
|
-
relevantExternalTestPaths,
|
|
1524
588
|
},
|
|
1525
589
|
};
|
|
1526
590
|
// Clean up old state files (>24 hours) before creating new one.
|
|
@@ -1547,6 +611,7 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
1547
611
|
// same run-scoped stateFile.
|
|
1548
612
|
await stateManager.writeRepoData(unifiedState, {
|
|
1549
613
|
repo: params.repository,
|
|
614
|
+
primaryRepo: params.primaryRepository,
|
|
1550
615
|
repositoryPath: params.repositoryPath,
|
|
1551
616
|
step: "analyze_changes",
|
|
1552
617
|
});
|
|
@@ -1561,121 +626,49 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
1561
626
|
catch {
|
|
1562
627
|
// Client may not support resource list notifications
|
|
1563
628
|
}
|
|
1564
|
-
|
|
1565
|
-
//
|
|
1566
|
-
//
|
|
1567
|
-
// ── Step 13: Fetch PR comment history for deduplication ──
|
|
1568
|
-
let prContext;
|
|
1569
|
-
if (params.prNumber) {
|
|
1570
|
-
try {
|
|
1571
|
-
// Derive repo owner/name from git remote
|
|
1572
|
-
const { execFileSync } = await import("child_process");
|
|
1573
|
-
const remoteUrl = execFileSync("git", ["-C", params.repositoryPath, "remote", "get-url", "origin"], { encoding: "utf-8", timeout: 5_000 }).trim();
|
|
1574
|
-
// Parse owner/repo from https or ssh remote URLs
|
|
1575
|
-
const match = remoteUrl.match(/[:/]([^/]+)\/([^/.]+?)(\.git)?$/);
|
|
1576
|
-
if (match) {
|
|
1577
|
-
prContext = await parsePRComments(match[1], match[2], params.prNumber);
|
|
1578
|
-
logger.info("Fetched PR comment history", {
|
|
1579
|
-
prNumber: params.prNumber,
|
|
1580
|
-
previousRecommendations: prContext.previousRecommendations.length,
|
|
1581
|
-
implementedFiles: prContext.implementedTestFiles.length,
|
|
1582
|
-
});
|
|
1583
|
-
}
|
|
1584
|
-
}
|
|
1585
|
-
catch (err) {
|
|
1586
|
-
logger.warning("Failed to fetch PR comment history — continuing without it", {
|
|
1587
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1588
|
-
});
|
|
1589
|
-
}
|
|
1590
|
-
// Fallback: if comment-based implementedTestFiles is empty (names didn't
|
|
1591
|
-
// match disk, e.g. after a force push), recover from the actual bot commit.
|
|
1592
|
-
if (prContext && prContext.implementedTestFiles.length === 0 && analysisScope === AnalysisScope.CurrentBranchDiff) {
|
|
1593
|
-
const botFiles = await getBotCommittedFiles(params.repositoryPath);
|
|
1594
|
-
if (botFiles.length > 0) {
|
|
1595
|
-
logger.info("Recovered implementedTestFiles from bot commit (comment names did not match disk)", {
|
|
1596
|
-
files: botFiles,
|
|
1597
|
-
});
|
|
1598
|
-
prContext.implementedTestFiles = botFiles;
|
|
1599
|
-
}
|
|
1600
|
-
}
|
|
1601
|
-
}
|
|
1602
|
-
if (uiContext && uiContext.changedFrontendFiles.length > 0) {
|
|
1603
|
-
logger.info("Frontend changes detected — UI rec grounding relies on the agent's own browser_blueprint history", { candidateUiPages: uiContext.candidateUiPages.map((p) => p.url) });
|
|
1604
|
-
}
|
|
1605
|
-
const recommendationPrompt = buildRecommendationPrompt(fullAnalysis, analysisScope, topN, prContext, wsAuthHeader, wsAuthType, wsAuthScheme, params.maxGenerate, sessionId);
|
|
629
|
+
// No pull-request comment history. `prNumber` is still accepted, and
|
|
630
|
+
// nothing reads it: skipping a test because an earlier comment mentioned
|
|
631
|
+
// it dropped tests the current diff still needed.
|
|
1606
632
|
await sendProgress(100, 100, "Analysis complete.");
|
|
1607
|
-
const
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
modifiedEndpointCount: classifiedEndpoints?.changedEndpoints.length ?? 0,
|
|
1618
|
-
removedEndpointCount: classifiedEndpoints?.removedEndpoints.length ?? 0,
|
|
1619
|
-
endpointCount: skeletonEndpoints.reduce((acc, ep) => acc + ep.methods.length, 0),
|
|
1620
|
-
newEndpoints: (classifiedEndpoints?.newEndpoints ?? []).flatMap((ep) => ep.methods.map((m) => `${m} ${ep.path}`)),
|
|
1621
|
-
modifiedEndpoints: (classifiedEndpoints?.changedEndpoints ?? []).flatMap((ep) => ep.methods.map((m) => `${m} ${ep.path}`)),
|
|
1622
|
-
},
|
|
1623
|
-
// Surface uiContext inline so the testbot prompt can iterate
|
|
1624
|
-
// candidateUiPages and inspect changedFrontendFiles without
|
|
1625
|
-
// re-reading the stateFile. Absent on backend-only PRs.
|
|
1626
|
-
...(uiContext ? { uiContext } : {}),
|
|
1627
|
-
stateFileSize: stateSize,
|
|
1628
|
-
nextStep: "Call skyramp_analyze_test_health with stateFile to run drift analysis and health scoring",
|
|
1629
|
-
}, null, 2);
|
|
1630
|
-
// Build a DiffSummary for buildAnalysisOutputText from classified endpoints.
|
|
1631
|
-
const parsedDiffShim = classifiedEndpoints
|
|
1632
|
-
? {
|
|
1633
|
-
currentBranch: classifiedEndpoints.currentBranch,
|
|
1634
|
-
baseBranch: classifiedEndpoints.baseBranch,
|
|
1635
|
-
changedFiles: classifiedEndpoints.changedFiles,
|
|
1636
|
-
diffStat: diffData?.diffStat ?? "",
|
|
1637
|
-
newEndpoints: classifiedEndpoints.newEndpoints.flatMap((ep) => ep.methods.map((m) => ({
|
|
1638
|
-
method: m,
|
|
1639
|
-
path: ep.path,
|
|
1640
|
-
sourceFile: ep.sourceFile,
|
|
1641
|
-
}))),
|
|
1642
|
-
modifiedEndpoints: classifiedEndpoints.changedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
|
|
1643
|
-
method: m,
|
|
1644
|
-
path: ep.path,
|
|
1645
|
-
sourceFile: ep.sourceFile,
|
|
1646
|
-
}))),
|
|
1647
|
-
removedEndpoints: classifiedEndpoints.removedEndpoints.flatMap((ep) => ep.methods.map((m) => ({
|
|
1648
|
-
method: m,
|
|
1649
|
-
path: ep.path,
|
|
1650
|
-
sourceFile: ep.sourceFile,
|
|
1651
|
-
}))),
|
|
1652
|
-
affectedServices: classifiedEndpoints.affectedServices,
|
|
1653
|
-
}
|
|
1654
|
-
: undefined;
|
|
1655
|
-
const outputText = buildAnalysisOutputText({
|
|
633
|
+
const relatedRepositories = await stateManager.listRelatedRepos();
|
|
634
|
+
// Existing test files, split by who owns them and relativized so no
|
|
635
|
+
// absolute machine path reaches the agent.
|
|
636
|
+
const skyrampTestFiles = [];
|
|
637
|
+
const externalTestFiles = [];
|
|
638
|
+
for (const test of existingTests) {
|
|
639
|
+
const target = test.source === TestSource.External ? externalTestFiles : skyrampTestFiles;
|
|
640
|
+
target.push(displayPath(test.testFile));
|
|
641
|
+
}
|
|
642
|
+
return buildAnalyzeChangesResult({
|
|
1656
643
|
sessionId,
|
|
1657
644
|
stateFile,
|
|
1658
645
|
repositoryPath: params.repositoryPath,
|
|
646
|
+
repositoryName: path.basename(params.repositoryPath),
|
|
647
|
+
...(params.repository ? { repository: params.repository } : {}),
|
|
648
|
+
...(relatedRepositories.length ? { relatedRepositories } : {}),
|
|
1659
649
|
analysisScope,
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
650
|
+
planOnly: isPlanOnlyMode(),
|
|
651
|
+
...(diffData
|
|
652
|
+
? {
|
|
653
|
+
currentBranch: diffData.currentBranch,
|
|
654
|
+
baseBranch: diffData.baseBranch,
|
|
655
|
+
}
|
|
656
|
+
: {}),
|
|
657
|
+
changedFiles: changedNames,
|
|
658
|
+
existingTestFiles: {
|
|
659
|
+
skyramp: skyrampTestFiles,
|
|
660
|
+
external: externalTestFiles,
|
|
661
|
+
relevantExternal: relevantExternalTestPaths,
|
|
662
|
+
},
|
|
663
|
+
workspace: {
|
|
664
|
+
baseUrl: wsBaseUrl,
|
|
665
|
+
authMethod: wsAuthMethod,
|
|
666
|
+
...(wsAuthHeader ? { authHeader: wsAuthHeader } : {}),
|
|
667
|
+
...(wsSchemaPath ? { openApiSpecPath: wsSchemaPath } : {}),
|
|
668
|
+
openApiSpecLoaded: specFetchSucceeded,
|
|
669
|
+
},
|
|
670
|
+
...(uiContext?.removedElements?.length ? { uiContext } : {}),
|
|
671
|
+
nextStep: "skyramp_analyze_test_health, with this stateFile",
|
|
1679
672
|
});
|
|
1680
673
|
}
|
|
1681
674
|
catch (error) {
|