@skyramp/mcp 0.2.9 → 0.2.10-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/index.js +1 -1
- package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +28 -1
- package/build/prompts/initialize-workspace/initializeWorkspacePrompt.js +1 -0
- package/build/prompts/personas.d.ts +2 -2
- package/build/prompts/personas.js +2 -2
- package/build/prompts/sut-setup/modes/dockerComposePrompt.js +3 -0
- package/build/prompts/sut-setup/shared.js +1 -3
- package/build/prompts/test-maintenance/drift-analysis-prompt.d.ts +1 -1
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +10 -5
- package/build/prompts/test-maintenance/driftAnalysisSections.js +1 -4
- package/build/prompts/test-maintenance/driftAnalysisShared.d.ts +7 -1
- package/build/prompts/test-maintenance/driftAnalysisShared.js +19 -1
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.d.ts +0 -6
- package/build/prompts/test-maintenance/uiDriftAnalysisSections.js +0 -16
- package/build/prompts/test-recommendation/recommendationShared.js +1 -1
- package/build/prompts/testbot/testbot-prompts.js +8 -8
- package/build/resources/testbotResource.js +1 -1
- package/build/services/TestDiscoveryService.d.ts +0 -1
- package/build/services/TestDiscoveryService.js +13 -33
- package/build/services/TestDiscoveryService.test.js +136 -1
- package/build/services/TestExecutionService.d.ts +1 -1
- package/build/tools/executeSkyrampTestTool.js +40 -71
- package/build/tools/submitReportTool.d.ts +20 -20
- package/build/tools/submitReportTool.js +113 -31
- package/build/tools/submitReportTool.test.js +411 -94
- package/build/tools/test-management/actionsTool.js +135 -53
- package/build/tools/test-management/actionsTool.test.d.ts +1 -0
- package/build/tools/test-management/actionsTool.test.js +297 -0
- package/build/tools/test-management/analyzeChangesTool.d.ts +9 -0
- package/build/tools/test-management/analyzeChangesTool.js +63 -152
- package/build/tools/test-management/analyzeChangesTool.test.js +34 -3
- package/build/tools/test-management/analyzeTestHealthTool.js +24 -157
- package/build/tools/test-management/analyzeTestHealthTool.test.js +15 -215
- package/build/tools/workspace/initializeWorkspaceTool.js +35 -15
- package/build/types/FrontendIntegration.d.ts +25 -0
- package/build/types/FrontendIntegration.js +19 -0
- package/build/types/RepositoryAnalysis.d.ts +8 -8
- package/build/types/TestAnalysis.d.ts +16 -50
- package/build/types/TestAnalysis.js +0 -28
- package/build/types/TestTypes.d.ts +3 -1
- package/build/types/TestTypes.js +3 -1
- package/build/utils/AnalysisStateManager.d.ts +13 -1
- package/build/utils/docker.test.js +1 -1
- package/build/utils/featureFlags.d.ts +1 -1
- package/build/utils/featureFlags.js +1 -1
- package/build/utils/frontendIntegration.d.ts +9 -0
- package/build/utils/frontendIntegration.js +237 -0
- package/build/utils/frontendIntegration.test.d.ts +1 -0
- package/build/utils/frontendIntegration.test.js +229 -0
- package/build/utils/pr-comment-parser.d.ts +3 -3
- package/build/utils/pr-comment-parser.js +6 -6
- package/build/utils/pr-comment-parser.test.js +3 -3
- package/build/utils/repoScanner.d.ts +7 -0
- package/build/utils/repoScanner.js +14 -6
- package/build/utils/repoScanner.test.js +9 -9
- package/build/utils/routeParsers.d.ts +32 -0
- package/build/utils/routeParsers.js +205 -2
- package/build/utils/routeParsers.test.js +279 -29
- package/build/utils/sourceRouteExtractor.js +174 -5
- package/build/utils/sourceRouteExtractor.test.js +173 -0
- package/build/utils/utils.d.ts +11 -0
- package/build/utils/utils.js +19 -0
- package/build/utils/utils.test.js +23 -1
- package/build/utils/versions.d.ts +3 -3
- package/build/utils/versions.js +1 -1
- package/node_modules/playwright/lib/mcp/browser/tools/files.js +8 -3
- package/node_modules/playwright/lib/mcp/browser/tools/wait.js +1 -1
- package/node_modules/playwright/lib/mcp/skyramp/exportTool.js +10 -1
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +33 -9
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +7 -1
- package/node_modules/playwright/lib/mcp/test/skyRampExport.test.js +44 -0
- package/node_modules/playwright/node_modules/playwright-core/lib/vite/traceViewer/{index.B_7ywgmr.js → index.d4gkVSou.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/package.json +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// @ts-nocheck - Jest ESM type inference issues
|
|
2
|
+
import { jest } from "@jest/globals";
|
|
3
|
+
import * as fs from "fs/promises";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import * as os from "os";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
jest.unstable_mockModule("../../utils/logger.js", () => ({
|
|
8
|
+
logger: { info: jest.fn(), error: jest.fn(), warning: jest.fn(), debug: jest.fn() },
|
|
9
|
+
}));
|
|
10
|
+
jest.unstable_mockModule("../../services/AnalyticsService.js", () => ({
|
|
11
|
+
AnalyticsService: { pushMCPToolEvent: jest.fn().mockResolvedValue(undefined) },
|
|
12
|
+
}));
|
|
13
|
+
const { registerActionsTool } = await import("./actionsTool.js");
|
|
14
|
+
const { DriftAction, TestSource } = await import("../../types/TestAnalysis.js");
|
|
15
|
+
const { TestType } = await import("../../types/TestTypes.js");
|
|
16
|
+
function captureToolHandler() {
|
|
17
|
+
let handler;
|
|
18
|
+
let inputSchema;
|
|
19
|
+
const fakeServer = {
|
|
20
|
+
registerTool: (_name, opts, fn) => {
|
|
21
|
+
handler = fn;
|
|
22
|
+
inputSchema = opts.inputSchema;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
registerActionsTool(fakeServer);
|
|
26
|
+
// Mirrors the real MCP SDK's registerTool: validates args against inputSchema via Zod
|
|
27
|
+
// before invoking the handler, so tests exercise the real tool contract (refinements
|
|
28
|
+
// included) instead of bypassing validation entirely.
|
|
29
|
+
return async (params) => {
|
|
30
|
+
const parsed = z.object(inputSchema).safeParse(params);
|
|
31
|
+
if (!parsed.success) {
|
|
32
|
+
return { isError: true, content: [{ type: "text", text: parsed.error.message }] };
|
|
33
|
+
}
|
|
34
|
+
return handler(parsed.data);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
async function writeStateFile(dir, existingTests, repositoryAnalysis) {
|
|
38
|
+
const stateFile = path.join(dir, "analyze-changes-state.json");
|
|
39
|
+
await fs.writeFile(stateFile, JSON.stringify({
|
|
40
|
+
existingTests,
|
|
41
|
+
repositoryAnalysis,
|
|
42
|
+
analysisScope: {},
|
|
43
|
+
newEndpoints: [],
|
|
44
|
+
metadata: {
|
|
45
|
+
sessionId: "test-session",
|
|
46
|
+
stateType: "unified",
|
|
47
|
+
repositoryPath: dir,
|
|
48
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
49
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
50
|
+
},
|
|
51
|
+
}), "utf-8");
|
|
52
|
+
return stateFile;
|
|
53
|
+
}
|
|
54
|
+
describe("registerActionsTool — completeness check (SKYR-3906)", () => {
|
|
55
|
+
let handler;
|
|
56
|
+
let tmpDirs = [];
|
|
57
|
+
beforeAll(() => {
|
|
58
|
+
handler = captureToolHandler();
|
|
59
|
+
});
|
|
60
|
+
afterEach(async () => {
|
|
61
|
+
for (const dir of tmpDirs) {
|
|
62
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
tmpDirs = [];
|
|
65
|
+
});
|
|
66
|
+
it("succeeds when every existing test is covered by recommendations", async () => {
|
|
67
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
|
|
68
|
+
tmpDirs.push(tmpDir);
|
|
69
|
+
const testFile = path.join(tmpDir, "orders_contract_test.py");
|
|
70
|
+
await fs.writeFile(testFile, "# test");
|
|
71
|
+
const stateFile = await writeStateFile(tmpDir, [{ testFile, source: TestSource.Skyramp }]);
|
|
72
|
+
const result = await handler({
|
|
73
|
+
stateFile,
|
|
74
|
+
recommendations: [
|
|
75
|
+
{
|
|
76
|
+
testFilePath: testFile,
|
|
77
|
+
action: DriftAction.Update,
|
|
78
|
+
rationale: "New field added",
|
|
79
|
+
testType: TestType.CONTRACT,
|
|
80
|
+
endpoint: "GET /api/v1/orders",
|
|
81
|
+
updateInstructions: "Assert new field",
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
expect(result.content).toBeDefined();
|
|
86
|
+
expect(result.isError).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
it("succeeds when every existing test is covered by ignoredTestFiles", async () => {
|
|
89
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
|
|
90
|
+
tmpDirs.push(tmpDir);
|
|
91
|
+
const testFile = path.join(tmpDir, "unrelated_test.py");
|
|
92
|
+
await fs.writeFile(testFile, "# test");
|
|
93
|
+
const stateFile = await writeStateFile(tmpDir, [{ testFile, source: TestSource.Skyramp }]);
|
|
94
|
+
const result = await handler({
|
|
95
|
+
stateFile,
|
|
96
|
+
ignoredTestFiles: [testFile],
|
|
97
|
+
});
|
|
98
|
+
expect(result.isError).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
it("rejects a bare basename in ignoredTestFiles at the schema level — ambiguous across files sharing that name", async () => {
|
|
101
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
|
|
102
|
+
tmpDirs.push(tmpDir);
|
|
103
|
+
const testFile = path.join(tmpDir, "unrelated_test.py");
|
|
104
|
+
await fs.writeFile(testFile, "# test");
|
|
105
|
+
const stateFile = await writeStateFile(tmpDir, [{ testFile, source: TestSource.Skyramp }]);
|
|
106
|
+
const result = await handler({
|
|
107
|
+
stateFile,
|
|
108
|
+
ignoredTestFiles: ["unrelated_test.py"],
|
|
109
|
+
});
|
|
110
|
+
expect(result.isError).toBe(true);
|
|
111
|
+
expect(result.content[0].text).toContain("must be absolute paths");
|
|
112
|
+
});
|
|
113
|
+
it("rejects a recommendation with action: IGNORE at the schema level — must use ignoredTestFiles", async () => {
|
|
114
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
|
|
115
|
+
tmpDirs.push(tmpDir);
|
|
116
|
+
const testFile = path.join(tmpDir, "unrelated_test.py");
|
|
117
|
+
await fs.writeFile(testFile, "# test");
|
|
118
|
+
const stateFile = await writeStateFile(tmpDir, [{ testFile, source: TestSource.Skyramp }]);
|
|
119
|
+
const result = await handler({
|
|
120
|
+
stateFile,
|
|
121
|
+
recommendations: [{ testFilePath: testFile, action: DriftAction.Ignore, rationale: "No change needed", testType: TestType.CONTRACT, endpoint: "GET /api/v1/orders" }],
|
|
122
|
+
// ignoredTestFiles intentionally omitted — IGNORE must not silently persist via recommendations.
|
|
123
|
+
});
|
|
124
|
+
expect(result.isError).toBe(true);
|
|
125
|
+
expect(result.content[0].text).toContain("Use ignoredTestFiles for IGNORE tests");
|
|
126
|
+
});
|
|
127
|
+
it("fails with a descriptive error naming the file missing from both recommendations and ignoredTestFiles", async () => {
|
|
128
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
|
|
129
|
+
tmpDirs.push(tmpDir);
|
|
130
|
+
const coveredFile = path.join(tmpDir, "covered_test.py");
|
|
131
|
+
const droppedFile = path.join(tmpDir, "secret_test.py");
|
|
132
|
+
await fs.writeFile(coveredFile, "# test");
|
|
133
|
+
await fs.writeFile(droppedFile, "# test");
|
|
134
|
+
const stateFile = await writeStateFile(tmpDir, [
|
|
135
|
+
{ testFile: coveredFile, source: TestSource.Skyramp },
|
|
136
|
+
{ testFile: droppedFile, source: TestSource.Skyramp },
|
|
137
|
+
]);
|
|
138
|
+
const result = await handler({
|
|
139
|
+
stateFile,
|
|
140
|
+
recommendations: [{ testFilePath: coveredFile, action: DriftAction.Update, rationale: "Field added", testType: TestType.CONTRACT, endpoint: "GET /api/v1/orders" }],
|
|
141
|
+
// droppedFile intentionally absent from both recommendations and ignoredTestFiles
|
|
142
|
+
});
|
|
143
|
+
expect(result.isError).toBe(true);
|
|
144
|
+
expect(result.content[0].text).toContain("secret_test.py");
|
|
145
|
+
expect(result.content[0].text).toContain("not accounted for");
|
|
146
|
+
});
|
|
147
|
+
it("persists a reconciled maintenanceVerdicts + ignoredTestFiles record to the state file", async () => {
|
|
148
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
|
|
149
|
+
tmpDirs.push(tmpDir);
|
|
150
|
+
const updatedFile = path.join(tmpDir, "orders_contract_test.py");
|
|
151
|
+
const ignoredFile = path.join(tmpDir, "unrelated_test.py");
|
|
152
|
+
await fs.writeFile(updatedFile, "# test");
|
|
153
|
+
await fs.writeFile(ignoredFile, "# test");
|
|
154
|
+
const stateFile = await writeStateFile(tmpDir, [
|
|
155
|
+
{ testFile: updatedFile, source: TestSource.Skyramp },
|
|
156
|
+
{ testFile: ignoredFile, source: TestSource.Skyramp },
|
|
157
|
+
]);
|
|
158
|
+
await handler({
|
|
159
|
+
stateFile,
|
|
160
|
+
recommendations: [{ testFilePath: updatedFile, action: DriftAction.Update, rationale: "Field added", testType: TestType.CONTRACT, endpoint: "GET /api/v1/orders" }],
|
|
161
|
+
ignoredTestFiles: [ignoredFile],
|
|
162
|
+
});
|
|
163
|
+
const written = JSON.parse(await fs.readFile(stateFile, "utf-8"));
|
|
164
|
+
expect(written.ignoredTestFiles).toEqual([ignoredFile]);
|
|
165
|
+
expect(written.maintenanceVerdicts).toEqual([
|
|
166
|
+
{ testFilePath: updatedFile, action: DriftAction.Update, rationale: "Field added", testType: TestType.CONTRACT, endpoint: "GET /api/v1/orders" },
|
|
167
|
+
]);
|
|
168
|
+
// Original existingTests must survive the write-back untouched.
|
|
169
|
+
expect(written.existingTests).toHaveLength(2);
|
|
170
|
+
});
|
|
171
|
+
it("fails loud when persisting maintenanceVerdicts to state fails, instead of silently applying edits anyway", async () => {
|
|
172
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
|
|
173
|
+
tmpDirs.push(tmpDir);
|
|
174
|
+
const updatedFile = path.join(tmpDir, "orders_contract_test.py");
|
|
175
|
+
await fs.writeFile(updatedFile, "# test");
|
|
176
|
+
const stateFile = await writeStateFile(tmpDir, [{ testFile: updatedFile, source: TestSource.Skyramp }]);
|
|
177
|
+
// Make the state file unwritable so writeRepoData's overwrite fails with EACCES.
|
|
178
|
+
await fs.chmod(stateFile, 0o444);
|
|
179
|
+
try {
|
|
180
|
+
const result = await handler({
|
|
181
|
+
stateFile,
|
|
182
|
+
recommendations: [{ testFilePath: updatedFile, action: DriftAction.Update, rationale: "Field added", testType: TestType.CONTRACT, endpoint: "GET /api/v1/orders" }],
|
|
183
|
+
});
|
|
184
|
+
expect(result.isError).toBe(true);
|
|
185
|
+
expect(result.content[0].text).toContain("Failed to persist maintenance reconciliation");
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
await fs.chmod(stateFile, 0o644);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
it("does not require accounting for a test when there are no existing tests at all", async () => {
|
|
192
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
|
|
193
|
+
tmpDirs.push(tmpDir);
|
|
194
|
+
const stateFile = await writeStateFile(tmpDir, []);
|
|
195
|
+
const result = await handler({ stateFile });
|
|
196
|
+
expect(result.isError).toBeUndefined();
|
|
197
|
+
});
|
|
198
|
+
// ── Multi-repo (`repository` param) ─────────────────────────────────────────
|
|
199
|
+
// A run-scoped state file holds the primary repo at the root and each related
|
|
200
|
+
// repo under `relatedRepos[<owner/repo>]`. `args.repository` must scope both
|
|
201
|
+
// the completeness check and the maintenanceVerdicts/ignoredTestFiles write-back
|
|
202
|
+
// to the matching section only.
|
|
203
|
+
async function writeMultiRepoStateFile(stateFile, primary, related) {
|
|
204
|
+
const now = "2026-01-01T00:00:00.000Z";
|
|
205
|
+
await fs.writeFile(stateFile, JSON.stringify({
|
|
206
|
+
existingTests: primary.existingTests,
|
|
207
|
+
analysisScope: {},
|
|
208
|
+
newEndpoints: [],
|
|
209
|
+
metadata: {
|
|
210
|
+
sessionId: "test-session",
|
|
211
|
+
stateType: "unified",
|
|
212
|
+
repositoryPath: primary.repositoryPath,
|
|
213
|
+
repository: "org/primary",
|
|
214
|
+
createdAt: now,
|
|
215
|
+
updatedAt: now,
|
|
216
|
+
},
|
|
217
|
+
relatedRepos: {
|
|
218
|
+
[related.repo]: {
|
|
219
|
+
repositoryPath: related.repositoryPath,
|
|
220
|
+
data: { existingTests: related.existingTests, analysisScope: {}, newEndpoints: [] },
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
}), "utf-8");
|
|
224
|
+
}
|
|
225
|
+
it("scopes the completeness check and write-back to a related (non-primary) repo section, leaving the primary section untouched", async () => {
|
|
226
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
|
|
227
|
+
tmpDirs.push(tmpDir);
|
|
228
|
+
const primaryDir = path.join(tmpDir, "primary");
|
|
229
|
+
const secondaryDir = path.join(tmpDir, "secondary");
|
|
230
|
+
await fs.mkdir(primaryDir, { recursive: true });
|
|
231
|
+
await fs.mkdir(secondaryDir, { recursive: true });
|
|
232
|
+
const secondaryTestFile = path.join(secondaryDir, "orders_contract_test.py");
|
|
233
|
+
await fs.writeFile(secondaryTestFile, "# test");
|
|
234
|
+
const stateFile = path.join(tmpDir, "analyze-changes-state.json");
|
|
235
|
+
await writeMultiRepoStateFile(stateFile, { repositoryPath: primaryDir, existingTests: [] }, { repo: "org/secondary", repositoryPath: secondaryDir, existingTests: [{ testFile: secondaryTestFile, source: TestSource.Skyramp }] });
|
|
236
|
+
const result = await handler({
|
|
237
|
+
stateFile,
|
|
238
|
+
repository: "org/secondary",
|
|
239
|
+
recommendations: [{ testFilePath: secondaryTestFile, action: DriftAction.Update, rationale: "Field added", testType: TestType.CONTRACT, endpoint: "GET /api/v1/orders" }],
|
|
240
|
+
});
|
|
241
|
+
expect(result.isError).toBeUndefined();
|
|
242
|
+
const written = JSON.parse(await fs.readFile(stateFile, "utf-8"));
|
|
243
|
+
// UPDATE converts to VERIFY — the agent has no write access to a related repo's
|
|
244
|
+
// checkout, so no edit is ever attempted there.
|
|
245
|
+
expect(written.relatedRepos["org/secondary"].data.maintenanceVerdicts).toEqual([
|
|
246
|
+
{ testFilePath: secondaryTestFile, action: DriftAction.Verify, rationale: "[related repo — no write access, needs manual review] Field added", testType: TestType.CONTRACT, endpoint: "GET /api/v1/orders" },
|
|
247
|
+
]);
|
|
248
|
+
// Primary section must be untouched by a write scoped to the related repo.
|
|
249
|
+
expect(written.maintenanceVerdicts).toBeUndefined();
|
|
250
|
+
expect(written.existingTests).toEqual([]);
|
|
251
|
+
});
|
|
252
|
+
it("also converts DELETE to VERIFY for a related repo — no action type gets write access there", async () => {
|
|
253
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
|
|
254
|
+
tmpDirs.push(tmpDir);
|
|
255
|
+
const primaryDir = path.join(tmpDir, "primary");
|
|
256
|
+
const secondaryDir = path.join(tmpDir, "secondary");
|
|
257
|
+
await fs.mkdir(primaryDir, { recursive: true });
|
|
258
|
+
await fs.mkdir(secondaryDir, { recursive: true });
|
|
259
|
+
const secondaryTestFile = path.join(secondaryDir, "reset_contract_test.py");
|
|
260
|
+
await fs.writeFile(secondaryTestFile, "# test");
|
|
261
|
+
const stateFile = path.join(tmpDir, "analyze-changes-state.json");
|
|
262
|
+
await writeMultiRepoStateFile(stateFile, { repositoryPath: primaryDir, existingTests: [] }, { repo: "org/secondary", repositoryPath: secondaryDir, existingTests: [{ testFile: secondaryTestFile, source: TestSource.Skyramp }] });
|
|
263
|
+
const result = await handler({
|
|
264
|
+
stateFile,
|
|
265
|
+
repository: "org/secondary",
|
|
266
|
+
recommendations: [{ testFilePath: secondaryTestFile, action: DriftAction.Delete, rationale: "Endpoint removed", testType: TestType.CONTRACT, endpoint: "DELETE /api/v1/reset" }],
|
|
267
|
+
});
|
|
268
|
+
expect(result.isError).toBeUndefined();
|
|
269
|
+
const written = JSON.parse(await fs.readFile(stateFile, "utf-8"));
|
|
270
|
+
expect(written.relatedRepos["org/secondary"].data.maintenanceVerdicts).toEqual([
|
|
271
|
+
{ testFilePath: secondaryTestFile, action: DriftAction.Verify, rationale: "[related repo — no write access, needs manual review] Endpoint removed", testType: TestType.CONTRACT, endpoint: "DELETE /api/v1/reset" },
|
|
272
|
+
]);
|
|
273
|
+
});
|
|
274
|
+
it("fails the completeness check for a related repo's missing test, unaffected by the primary repo's own state", async () => {
|
|
275
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "actions-test-"));
|
|
276
|
+
tmpDirs.push(tmpDir);
|
|
277
|
+
const primaryDir = path.join(tmpDir, "primary");
|
|
278
|
+
const secondaryDir = path.join(tmpDir, "secondary");
|
|
279
|
+
await fs.mkdir(primaryDir, { recursive: true });
|
|
280
|
+
await fs.mkdir(secondaryDir, { recursive: true });
|
|
281
|
+
const primaryTestFile = path.join(primaryDir, "covered_test.py");
|
|
282
|
+
const secondaryTestFile = path.join(secondaryDir, "secret_test.py");
|
|
283
|
+
await fs.writeFile(primaryTestFile, "# test");
|
|
284
|
+
await fs.writeFile(secondaryTestFile, "# test");
|
|
285
|
+
const stateFile = path.join(tmpDir, "analyze-changes-state.json");
|
|
286
|
+
await writeMultiRepoStateFile(stateFile, { repositoryPath: primaryDir, existingTests: [{ testFile: primaryTestFile, source: TestSource.Skyramp }] }, { repo: "org/secondary", repositoryPath: secondaryDir, existingTests: [{ testFile: secondaryTestFile, source: TestSource.Skyramp }] });
|
|
287
|
+
// Primary repo's own test is fully accounted for, but that must not satisfy
|
|
288
|
+
// the related repo's completeness check.
|
|
289
|
+
const result = await handler({
|
|
290
|
+
stateFile,
|
|
291
|
+
repository: "org/secondary",
|
|
292
|
+
recommendations: [],
|
|
293
|
+
});
|
|
294
|
+
expect(result.isError).toBe(true);
|
|
295
|
+
expect(result.content[0].text).toContain("secret_test.py");
|
|
296
|
+
});
|
|
297
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { CandidateUiPage } from "../../utils/uiPageEnumerator.js";
|
|
4
|
+
import type { FrontendFileIntegration } from "../../types/FrontendIntegration.js";
|
|
4
5
|
import { TraceFile } from "../../types/RepositoryAnalysis.js";
|
|
5
6
|
import { ScannedEndpoint } from "../../utils/repoScanner.js";
|
|
6
7
|
import { TraceParseResult } from "../../utils/trace-parser.js";
|
|
@@ -24,4 +25,12 @@ export declare const analyzeChangesInputSchema: {
|
|
|
24
25
|
export declare const NO_UI_INSTRUCTIONS = "No UI changes detected \u2014 no blueprint capture needed.";
|
|
25
26
|
export declare const NO_RESOLVABLE_URLS_INSTRUCTIONS = "Frontend changes detected but no candidate URLs could be resolved (workspace baseUrl missing or no router files matched). UI recommendations will be source-grounded only.";
|
|
26
27
|
export declare function buildCaptureInstructions(pages: CandidateUiPage[]): string;
|
|
28
|
+
/**
|
|
29
|
+
* Instruction block for changed frontend files the server determined have no
|
|
30
|
+
* production importer (SKYR-3855) — dead/orphaned components. Used both to
|
|
31
|
+
* replace the capture instructions entirely (every changed frontend file is
|
|
32
|
+
* unintegrated) and to append a scoped note when only some files are
|
|
33
|
+
* (`buildCaptureInstructions` still runs for the rest in that case).
|
|
34
|
+
*/
|
|
35
|
+
export declare function buildUnintegratedInstructions(results: FrontendFileIntegration[]): string;
|
|
27
36
|
export declare function registerAnalyzeChangesTool(server: McpServer): void;
|
|
@@ -12,11 +12,12 @@ import { StateManager, registerSession, storeSessionData, setTestsRepoDir, getAc
|
|
|
12
12
|
import { buildRecommendationPrompt } from "../../prompts/test-recommendation/test-recommendation-prompt.js";
|
|
13
13
|
import { hasFlutterSdkDep, isFrontendFile, isTestFile } from "../../prompts/test-recommendation/scopeAssessment.js";
|
|
14
14
|
import { enumerateCandidateUiPages } from "../../utils/uiPageEnumerator.js";
|
|
15
|
+
import { checkFrontendFileIntegration } from "../../utils/frontendIntegration.js";
|
|
15
16
|
import { MAX_RECOMMENDATIONS, MAX_TESTS_TO_GENERATE } from "../../prompts/test-recommendation/recommendationSections.js";
|
|
16
17
|
import { TestDiscoveryService } from "../../services/TestDiscoveryService.js";
|
|
17
18
|
import { ScenarioSource, AnalysisScope } from "../../types/RepositoryAnalysis.js";
|
|
18
19
|
import { computeBranchDiff } from "../../utils/branchDiff.js";
|
|
19
|
-
import {
|
|
20
|
+
import { classifyEndpointsByChangedFiles, selectRemovalCandidateFiles, recoverRemovedEndpointsFromBase, } from "../../utils/routeParsers.js";
|
|
20
21
|
import { scanAllRepoEndpoints, scanRelatedEndpoints, grepRouterMountingContext, findCandidateRouteFiles, } from "../../utils/repoScanner.js";
|
|
21
22
|
import { detectProjectMetadata } from "../../utils/projectMetadata.js";
|
|
22
23
|
import { draftScenariosFromEndpoints } from "../../utils/scenarioDrafting.js";
|
|
@@ -32,24 +33,6 @@ const GENERIC_MODULE_NAMES = new Set([
|
|
|
32
33
|
"config", "settings", "exceptions", "errors", "base", "common",
|
|
33
34
|
"app", "main", "index", "server", "init", "deps", "dependencies",
|
|
34
35
|
]);
|
|
35
|
-
function resolveMountPrefixTokens(mountFileContents, changedFiles) {
|
|
36
|
-
const tokens = changedFiles.flatMap((file) => {
|
|
37
|
-
const mod = path.basename(file).replace(/\.[^.]+$/, "").toLowerCase();
|
|
38
|
-
if (GENERIC_MODULE_NAMES.has(mod))
|
|
39
|
-
return [];
|
|
40
|
-
const modRe = new RegExp(`\\b${mod.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
|
41
|
-
return mountFileContents.flatMap((content) => {
|
|
42
|
-
if (!modRe.test(content.toLowerCase()))
|
|
43
|
-
return [];
|
|
44
|
-
const matches = [...content.matchAll(/prefix\s*=\s*["'](\/?[^"']+)/g)];
|
|
45
|
-
return matches.flatMap((m) => {
|
|
46
|
-
const token = extractResourceFromPath(m[1]);
|
|
47
|
-
return token !== "unknown" ? [token] : [];
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
return [...new Set(tokens)];
|
|
52
|
-
}
|
|
53
36
|
/** Exported for testing: maps a parsed trace result to a TraceFile. */
|
|
54
37
|
export function buildTraceFileEntry(tracePath, result) {
|
|
55
38
|
return {
|
|
@@ -285,54 +268,6 @@ function isNonApplicationFile(filePath) {
|
|
|
285
268
|
const ROUTE_FILE_PATTERN = /route|controller|endpoint|handler|view|urls|api|router|service|gateway|resolver|\bserver\b/i;
|
|
286
269
|
const ROUTE_FILE_BASENAME_PATTERN = /\bapp\b|\bmain\b/i;
|
|
287
270
|
const SOURCE_EXTS = /\.(ts|tsx|js|jsx|py|java|kt|go|rb|php|rs|cs|ex|exs)$/;
|
|
288
|
-
/**
|
|
289
|
-
* Recover endpoints from files deleted in this branch by reading their
|
|
290
|
-
* content from the base branch via `git show`. Only checks files whose
|
|
291
|
-
* name matches route-file heuristics to keep I/O bounded.
|
|
292
|
-
*/
|
|
293
|
-
async function recoverDeletedFileEndpoints(repositoryPath, baseBranch, deletedFiles) {
|
|
294
|
-
const candidates = deletedFiles.filter((f) => SOURCE_EXTS.test(f) && (ROUTE_FILE_PATTERN.test(f) || ROUTE_FILE_BASENAME_PATTERN.test(path.basename(f))));
|
|
295
|
-
if (candidates.length === 0)
|
|
296
|
-
return [];
|
|
297
|
-
const git = simpleGit(repositoryPath);
|
|
298
|
-
const results = [];
|
|
299
|
-
const endpointMap = new Map();
|
|
300
|
-
for (const file of candidates) {
|
|
301
|
-
try {
|
|
302
|
-
const content = await git.show([`${baseBranch}:${file}`]);
|
|
303
|
-
for (const ep of parseFileEndpoints(content, file)) {
|
|
304
|
-
const normalizedPath = ep.path.startsWith("/") ? ep.path : `/${ep.path}`;
|
|
305
|
-
const key = `${file}::${normalizedPath}`;
|
|
306
|
-
const existing = endpointMap.get(key);
|
|
307
|
-
if (existing) {
|
|
308
|
-
existing.methods.add(ep.method);
|
|
309
|
-
}
|
|
310
|
-
else {
|
|
311
|
-
endpointMap.set(key, {
|
|
312
|
-
path: normalizedPath,
|
|
313
|
-
methods: new Set([ep.method]),
|
|
314
|
-
sourceFile: file,
|
|
315
|
-
});
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
catch (err) {
|
|
320
|
-
logger.debug("Failed to recover endpoints from deleted file", {
|
|
321
|
-
file,
|
|
322
|
-
baseBranch,
|
|
323
|
-
error: err instanceof Error ? err.message : String(err),
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
for (const data of endpointMap.values()) {
|
|
328
|
-
results.push({
|
|
329
|
-
path: data.path,
|
|
330
|
-
methods: Array.from(data.methods),
|
|
331
|
-
sourceFile: data.sourceFile,
|
|
332
|
-
});
|
|
333
|
-
}
|
|
334
|
-
return results;
|
|
335
|
-
}
|
|
336
271
|
export const analyzeChangesInputSchema = {
|
|
337
272
|
repositoryPath: z
|
|
338
273
|
.string()
|
|
@@ -364,7 +299,7 @@ export const analyzeChangesInputSchema = {
|
|
|
364
299
|
prNumber: z
|
|
365
300
|
.number()
|
|
366
301
|
.optional()
|
|
367
|
-
.describe("GitHub PR number. When provided, fetches previous
|
|
302
|
+
.describe("GitHub PR number. When provided, fetches previous Testbot comments on this PR and skips re-recommending tests already suggested in earlier commits — reduces duplicate recommendations across multiple pushes to the same PR."),
|
|
368
303
|
repository: z
|
|
369
304
|
.string()
|
|
370
305
|
.optional()
|
|
@@ -403,6 +338,30 @@ If a candidate URL 404s or redirects unexpectedly, navigate from the workspace b
|
|
|
403
338
|
|
|
404
339
|
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.`;
|
|
405
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* Instruction block for changed frontend files the server determined have no
|
|
343
|
+
* production importer (SKYR-3855) — dead/orphaned components. Used both to
|
|
344
|
+
* replace the capture instructions entirely (every changed frontend file is
|
|
345
|
+
* unintegrated) and to append a scoped note when only some files are
|
|
346
|
+
* (`buildCaptureInstructions` still runs for the rest in that case).
|
|
347
|
+
*/
|
|
348
|
+
export function buildUnintegratedInstructions(results) {
|
|
349
|
+
const listing = results
|
|
350
|
+
.filter((r) => !r.integrated)
|
|
351
|
+
.map((r) => {
|
|
352
|
+
const token = path.basename(r.file).replace(/\.[^.]+$/, "");
|
|
353
|
+
return ` - \`${r.file}\` (checked for a production import of \`${token}\` — none found)`;
|
|
354
|
+
})
|
|
355
|
+
.join("\n");
|
|
356
|
+
return `**Server-side integration check found no production importer for the following changed component(s) — do not treat this as unproven:**
|
|
357
|
+
${listing}
|
|
358
|
+
|
|
359
|
+
These components have no DOM presence in the running app: no route, page, or other production file imports or renders them. Handle them as follows:
|
|
360
|
+
- **Never record traces or generate UI tests against a substitute surface** (a different page or component that happens to be visible). A test on an unrelated surface validates nothing about this PR.
|
|
361
|
+
- **DO write the change-targeting test directly from code analysis** — you know the component's structure, selectors, and expected behavior from the diff. Target the component's own elements/attributes. The test is EXPECTED TO FAIL because the component never mounts; that failing test documents the integration gap and is more valuable than prose. Mark its report entry accordingly (e.g. "expected failure: component has no production importer").
|
|
362
|
+
- Log an \`issuesFound\` entry (high severity) naming the unintegrated file(s) above and stating that the changed code is unreachable in the running app.
|
|
363
|
+
- Backend/other work in this PR is unaffected — proceed with it normally.`;
|
|
364
|
+
}
|
|
406
365
|
export function registerAnalyzeChangesTool(server) {
|
|
407
366
|
server.registerTool(TOOL_NAME, {
|
|
408
367
|
annotations: {
|
|
@@ -526,11 +485,16 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
526
485
|
// HTTP methods, eliminating the need for path resolution and MULTI sentinels.
|
|
527
486
|
let classifiedEndpoints;
|
|
528
487
|
if (diffData) {
|
|
529
|
-
// Recover endpoints
|
|
530
|
-
|
|
531
|
-
|
|
488
|
+
// Recover endpoints that existed in the base branch from every candidate
|
|
489
|
+
// file (deleted files AND modified files that may have dropped a route via
|
|
490
|
+
// line-edit). classifyEndpointsByChangedFiles filters out any that still
|
|
491
|
+
// exist in the current catalog, leaving only genuine removals.
|
|
492
|
+
const removalCandidateFiles = selectRemovalCandidateFiles(diffData);
|
|
493
|
+
const git = simpleGit(params.repositoryPath);
|
|
494
|
+
const recoveredBaseEndpoints = removalCandidateFiles.length > 0
|
|
495
|
+
? await recoverRemovedEndpointsFromBase(removalCandidateFiles, (file) => git.show([`${diffData.baseBranch}:${file}`]))
|
|
532
496
|
: [];
|
|
533
|
-
classifiedEndpoints = classifyEndpointsByChangedFiles(diffData, scannedEndpoints,
|
|
497
|
+
classifiedEndpoints = classifyEndpointsByChangedFiles(diffData, scannedEndpoints, recoveredBaseEndpoints);
|
|
534
498
|
classifiedEndpoints = {
|
|
535
499
|
...classifiedEndpoints,
|
|
536
500
|
// changed/new endpoints come from scannedEndpoints, which is already
|
|
@@ -617,79 +581,9 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
617
581
|
return [];
|
|
618
582
|
return [{ file: f, content: content.trimEnd() }];
|
|
619
583
|
});
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
|
|
623
|
-
// [...names] → PR mode with resolved resource names → filter external by relevance
|
|
624
|
-
let changedResources;
|
|
625
|
-
if (!classifiedEndpoints) {
|
|
626
|
-
changedResources = undefined;
|
|
627
|
-
}
|
|
628
|
-
else {
|
|
629
|
-
const allClassified = [
|
|
630
|
-
...classifiedEndpoints.changedEndpoints,
|
|
631
|
-
...classifiedEndpoints.newEndpoints,
|
|
632
|
-
...classifiedEndpoints.removedEndpoints,
|
|
633
|
-
];
|
|
634
|
-
const FRAMEWORK_SUFFIX_RE = /\.(service|controller|transformer|handler|middleware|resolver|repository|module|guard|interceptor|pipe|filter|decorator|input|output|dto|schema)$/i;
|
|
635
|
-
if (allClassified.length > 0) {
|
|
636
|
-
// Scanned endpoints always have full paths — extractResourceFromPath
|
|
637
|
-
// never returns "unknown" for properly resolved paths.
|
|
638
|
-
// Exception: NestJS versioned controllers register relative paths (e.g. "GET /")
|
|
639
|
-
// which resolve to "unknown". Fall through to file-path extraction in that case.
|
|
640
|
-
const resolved = allClassified
|
|
641
|
-
.map((ep) => extractResourceFromPath(ep.path))
|
|
642
|
-
.filter((r, i, arr) => r !== "unknown" && arr.indexOf(r) === i);
|
|
643
|
-
if (resolved.length > 0) {
|
|
644
|
-
changedResources = resolved;
|
|
645
|
-
}
|
|
646
|
-
else {
|
|
647
|
-
// All endpoints resolved to "unknown" (e.g. NestJS relative paths) —
|
|
648
|
-
// first try to extract resource names from router mount prefixes.
|
|
649
|
-
const changedSourceFiles = classifiedEndpoints.changedFiles ?? [];
|
|
650
|
-
const mountedResources = resolveMountPrefixTokens(routerMountContextContents, changedSourceFiles);
|
|
651
|
-
if (mountedResources.length > 0) {
|
|
652
|
-
changedResources = mountedResources;
|
|
653
|
-
}
|
|
654
|
-
else {
|
|
655
|
-
// Fall back to extracting resource names from changed file paths.
|
|
656
|
-
const candidateFiles = classifiedEndpoints.unmatchedFiles.length > 0
|
|
657
|
-
? classifiedEndpoints.unmatchedFiles
|
|
658
|
-
: classifiedEndpoints.changedFiles ?? [];
|
|
659
|
-
const fromFiles = candidateFiles
|
|
660
|
-
.map((f) => extractResourceFromPath(f.replace(/\.[^./]+$/, "").replace(FRAMEWORK_SUFFIX_RE, "")))
|
|
661
|
-
.filter((r, i, arr) => r !== "unknown" && arr.indexOf(r) === i);
|
|
662
|
-
changedResources = fromFiles.length > 0 ? fromFiles : ["unknown"];
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
else if (classifiedEndpoints.unmatchedFiles.length > 0) {
|
|
667
|
-
// Changed files don't map to any endpoint (e.g. DTO, service, output
|
|
668
|
-
// formatter, migration). Extract resource names from the file paths so
|
|
669
|
-
// external tests whose names overlap with these resources are surfaced
|
|
670
|
-
// for drift assessment.
|
|
671
|
-
// Strip the file extension and common framework suffixes (.service,
|
|
672
|
-
// .controller, .input, .output, etc.) before extracting so that
|
|
673
|
-
// "event-types.service.ts" yields "event-types" rather than
|
|
674
|
-
// "event-types.service", which would fail relevance scoring against
|
|
675
|
-
// test files that contain "event" and "types" but not "service".
|
|
676
|
-
const fromFiles = classifiedEndpoints.unmatchedFiles
|
|
677
|
-
.map((f) => extractResourceFromPath(f.replace(/\.[^./]+$/, "").replace(FRAMEWORK_SUFFIX_RE, "")))
|
|
678
|
-
.filter((r, i, arr) => r !== "unknown" && arr.indexOf(r) === i);
|
|
679
|
-
changedResources = fromFiles.length > 0 ? fromFiles : ["unknown"];
|
|
680
|
-
}
|
|
681
|
-
else {
|
|
682
|
-
// No endpoints and no unmatched files — the diff touches only non-route
|
|
683
|
-
// files (e.g. schema, model, DTO). Extract resource tokens from the
|
|
684
|
-
// changed file paths so tests that import the changed symbol are still
|
|
685
|
-
// surfaced for drift assessment. Fall back to [] only if extraction
|
|
686
|
-
// yields nothing (e.g. a file with a non-resource name like utils.py).
|
|
687
|
-
const fromChangedFiles = (classifiedEndpoints.changedFiles ?? [])
|
|
688
|
-
.map((f) => extractResourceFromPath(f.replace(/\.[^./]+$/, "").replace(FRAMEWORK_SUFFIX_RE, "")))
|
|
689
|
-
.filter((r, i, arr) => r !== "unknown" && arr.indexOf(r) === i);
|
|
690
|
-
changedResources = fromChangedFiles.length > 0 ? fromChangedFiles : [];
|
|
691
|
-
}
|
|
692
|
-
}
|
|
584
|
+
// PR mode → [] excludes external tests from pre-loading (agent discovers via grep).
|
|
585
|
+
// No diff context → undefined triggers full-repo mode in TestDiscoveryService.
|
|
586
|
+
const changedResources = classifiedEndpoints ? [] : undefined;
|
|
693
587
|
// Compute changed frontend files once — reused for both discoverTests (component
|
|
694
588
|
// test promotion) and uiContext below (page enumeration). Uses classifiedEndpoints
|
|
695
589
|
// changedFiles so it reflects the same diff scope as the rest of the analysis.
|
|
@@ -707,7 +601,6 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
707
601
|
testType: test.testType,
|
|
708
602
|
language: test.language,
|
|
709
603
|
framework: test.framework,
|
|
710
|
-
apiSchema: test.apiSchema,
|
|
711
604
|
apiEndpoint: test.apiEndpoint,
|
|
712
605
|
source: test.source,
|
|
713
606
|
generatedAt: test.generatedAt,
|
|
@@ -1038,7 +931,7 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
1038
931
|
const entry = t.apiEndpoint
|
|
1039
932
|
? `${dp} [external] (covers: ${t.apiEndpoint})`
|
|
1040
933
|
: `${dp} [external]`;
|
|
1041
|
-
addEntry(t.testType
|
|
934
|
+
addEntry(t.testType, entry);
|
|
1042
935
|
}
|
|
1043
936
|
// Pass 2: Skyramp tests — tagged [skyramp] so the LLM can positively identify the source
|
|
1044
937
|
for (const t of existingTests) {
|
|
@@ -1048,7 +941,7 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
1048
941
|
const entry = t.apiEndpoint
|
|
1049
942
|
? `${dp} [skyramp] (covers: ${t.apiEndpoint})`
|
|
1050
943
|
: `${dp} [skyramp]`;
|
|
1051
|
-
addEntry(t.testType
|
|
944
|
+
addEntry(t.testType, entry);
|
|
1052
945
|
}
|
|
1053
946
|
// Pass 3: low-relevance external tests (name-only) — capped to avoid context overflow
|
|
1054
947
|
const LOW_RELEVANCE_CAP = 20;
|
|
@@ -1060,7 +953,7 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
1060
953
|
if (relevantExternalSet.has(t.testFile))
|
|
1061
954
|
continue;
|
|
1062
955
|
if (lowRelevanceCount < LOW_RELEVANCE_CAP) {
|
|
1063
|
-
addEntry(t.testType
|
|
956
|
+
addEntry(t.testType, `${displayPath(t.testFile)} [external]`);
|
|
1064
957
|
lowRelevanceCount++;
|
|
1065
958
|
}
|
|
1066
959
|
else {
|
|
@@ -1228,9 +1121,14 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
1228
1121
|
if (changedFrontendFiles.length === 0)
|
|
1229
1122
|
return undefined;
|
|
1230
1123
|
const candidateUiPages = await enumerateCandidateUiPages(params.repositoryPath, changedFrontendFiles);
|
|
1124
|
+
// SKYR-3855: deterministic production-importer check, computed here so
|
|
1125
|
+
// downstream consumers (testbot prompt) can skip UI generation for
|
|
1126
|
+
// unintegrated components on a server fact instead of a mid-run grep.
|
|
1127
|
+
const frontendFileIntegration = checkFrontendFileIntegration(params.repositoryPath, changedFrontendFiles);
|
|
1231
1128
|
return {
|
|
1232
1129
|
changedFrontendFiles,
|
|
1233
1130
|
candidateUiPages,
|
|
1131
|
+
frontendFileIntegration,
|
|
1234
1132
|
};
|
|
1235
1133
|
})();
|
|
1236
1134
|
// Derive the UI blueprint-capture instructions the agent acts on after
|
|
@@ -1238,11 +1136,24 @@ export function registerAnalyzeChangesTool(server) {
|
|
|
1238
1136
|
// pre-flight tool, now folded in here so the agent makes one analysis call
|
|
1239
1137
|
// instead of two. The captures stay in the agent's tool-result history and
|
|
1240
1138
|
// serve as element vocabulary when it writes UI recommendation reasoning.
|
|
1139
|
+
//
|
|
1140
|
+
// SKYR-3855: when the integration check finds no production importer for
|
|
1141
|
+
// some/all changed frontend files, that overrides/augments the normal
|
|
1142
|
+
// capture flow — see buildUnintegratedInstructions.
|
|
1143
|
+
const unintegratedFrontendFiles = (uiContext?.frontendFileIntegration ?? []).filter((r) => !r.integrated);
|
|
1144
|
+
const allFrontendFilesUnintegrated = !!uiContext &&
|
|
1145
|
+
uiContext.changedFrontendFiles.length > 0 &&
|
|
1146
|
+
unintegratedFrontendFiles.length === uiContext.changedFrontendFiles.length;
|
|
1241
1147
|
const uiInstructions = !uiContext || uiContext.changedFrontendFiles.length === 0
|
|
1242
1148
|
? NO_UI_INSTRUCTIONS
|
|
1243
|
-
:
|
|
1244
|
-
?
|
|
1245
|
-
:
|
|
1149
|
+
: allFrontendFilesUnintegrated
|
|
1150
|
+
? buildUnintegratedInstructions(unintegratedFrontendFiles)
|
|
1151
|
+
: (uiContext.candidateUiPages.length === 0
|
|
1152
|
+
? NO_RESOLVABLE_URLS_INSTRUCTIONS
|
|
1153
|
+
: buildCaptureInstructions(uiContext.candidateUiPages)) +
|
|
1154
|
+
(unintegratedFrontendFiles.length > 0
|
|
1155
|
+
? `\n\n${buildUnintegratedInstructions(unintegratedFrontendFiles)}`
|
|
1156
|
+
: "");
|
|
1246
1157
|
const unifiedState = {
|
|
1247
1158
|
existingTests,
|
|
1248
1159
|
newEndpoints: newEndpointsForDrafting,
|