@quikit/quikpilot-runner 7.0.6 → 8.2.0
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/README.md +27 -27
- package/dist/index.js +601 -77
- package/dist/index.js.map +4 -4
- package/package.json +26 -26
package/dist/index.js
CHANGED
|
@@ -23,6 +23,54 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
23
23
|
mod
|
|
24
24
|
));
|
|
25
25
|
|
|
26
|
+
// src/env.ts
|
|
27
|
+
function required(name, value) {
|
|
28
|
+
if (!value) {
|
|
29
|
+
throw new Error(`Missing required environment variable: ${name}`);
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function readRunnerEnv(env2 = process.env) {
|
|
34
|
+
const githubRunId = required("GITHUB_RUN_ID", env2.GITHUB_RUN_ID);
|
|
35
|
+
const githubServerUrl = env2.GITHUB_SERVER_URL ?? "https://github.com";
|
|
36
|
+
const githubRepository = required("GITHUB_REPOSITORY", env2.GITHUB_REPOSITORY);
|
|
37
|
+
if (env2.MODE === "readiness_check") {
|
|
38
|
+
return {
|
|
39
|
+
mode: "readiness_check",
|
|
40
|
+
readinessToken: required("READINESS_TOKEN", env2.READINESS_TOKEN),
|
|
41
|
+
readinessCallbackUrl: required("READINESS_CALLBACK_URL", env2.READINESS_CALLBACK_URL),
|
|
42
|
+
setupCmd: env2.SETUP_CMD ?? "",
|
|
43
|
+
testCmd: required("TEST_CMD", env2.TEST_CMD),
|
|
44
|
+
githubRunId,
|
|
45
|
+
githubServerUrl,
|
|
46
|
+
githubRepository
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
mode: "run",
|
|
51
|
+
runId: required("RUN_ID", env2.RUN_ID),
|
|
52
|
+
callbackUrl: required("CALLBACK_URL", env2.CALLBACK_URL),
|
|
53
|
+
packetUrl: required("PACKET_URL", env2.PACKET_URL),
|
|
54
|
+
tokenUrl: required("TOKEN_URL", env2.TOKEN_URL),
|
|
55
|
+
installationTokenUrl: required("INSTALLATION_TOKEN_URL", env2.INSTALLATION_TOKEN_URL),
|
|
56
|
+
githubRunId,
|
|
57
|
+
githubServerUrl,
|
|
58
|
+
githubRepository
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/quiktrackError.ts
|
|
63
|
+
async function describeQuikTrackFailure(res) {
|
|
64
|
+
try {
|
|
65
|
+
const body = await res.json();
|
|
66
|
+
if (body && typeof body === "object" && typeof body.reason_code === "string") {
|
|
67
|
+
return `${res.status} ${res.statusText} (${body.reason_code})`;
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
}
|
|
71
|
+
return `${res.status} ${res.statusText}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
26
74
|
// src/callback.ts
|
|
27
75
|
async function reportBack(params) {
|
|
28
76
|
const { callbackUrl, runToken, status, branch, warnings } = params;
|
|
@@ -35,7 +83,7 @@ async function reportBack(params) {
|
|
|
35
83
|
body: JSON.stringify(body)
|
|
36
84
|
});
|
|
37
85
|
if (!res.ok) {
|
|
38
|
-
throw new Error(`Report back to QuikTrack failed: ${
|
|
86
|
+
throw new Error(`Report back to QuikTrack failed: ${await describeQuikTrackFailure(res)}`);
|
|
39
87
|
}
|
|
40
88
|
}
|
|
41
89
|
|
|
@@ -155,6 +203,14 @@ var import_node_fs = require("node:fs");
|
|
|
155
203
|
var import_node_path = require("node:path");
|
|
156
204
|
|
|
157
205
|
// src/envelope.ts
|
|
206
|
+
var HARD_FAILURE_CODES = /* @__PURE__ */ new Set(["SEMANTIC_MISMATCH", "CONTRACT_REJECTED"]);
|
|
207
|
+
function checkHardFailure(envelope, useCase) {
|
|
208
|
+
const code = envelope?.errorCode;
|
|
209
|
+
if (typeof code === "string" && HARD_FAILURE_CODES.has(code)) {
|
|
210
|
+
const detail = envelope.error ?? envelope.normalizedText ?? "no detail";
|
|
211
|
+
throw new Error(`${useCase} failed (${code}, not retried): ${detail}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
158
214
|
function unwrapExecute(envelope, useCase) {
|
|
159
215
|
if (envelope?.success === false) {
|
|
160
216
|
throw new Error(
|
|
@@ -167,11 +223,123 @@ function unwrapExecute(envelope, useCase) {
|
|
|
167
223
|
return envelope.structuredJson;
|
|
168
224
|
}
|
|
169
225
|
|
|
226
|
+
// src/retryFetch.ts
|
|
227
|
+
var RETRYABLE_STATUS_CODES = /* @__PURE__ */ new Set([502, 503, 504]);
|
|
228
|
+
var RETRYABLE_NETWORK_ERROR_CODES = /* @__PURE__ */ new Set(["ECONNRESET", "ETIMEDOUT"]);
|
|
229
|
+
function isRetryableNetworkError(err) {
|
|
230
|
+
const code = err?.cause?.code ?? err?.code;
|
|
231
|
+
return typeof code === "string" && RETRYABLE_NETWORK_ERROR_CODES.has(code);
|
|
232
|
+
}
|
|
233
|
+
async function fetchWithRetry(url, init, { retries = 2, backoffMs = 250 } = {}) {
|
|
234
|
+
for (let attempt = 0; ; attempt++) {
|
|
235
|
+
try {
|
|
236
|
+
const res = await fetch(url, init);
|
|
237
|
+
if (RETRYABLE_STATUS_CODES.has(res.status) && attempt < retries) {
|
|
238
|
+
await new Promise((r) => setTimeout(r, backoffMs));
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
return res;
|
|
242
|
+
} catch (err) {
|
|
243
|
+
if (isRetryableNetworkError(err) && attempt < retries) {
|
|
244
|
+
await new Promise((r) => setTimeout(r, backoffMs));
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
170
252
|
// src/codegen.ts
|
|
171
253
|
var FILE_TOO_LARGE_CODE = "CODEGEN_FILE_TOO_LARGE";
|
|
172
254
|
var CONTEXT_TOO_LARGE_CODE = "CODEGEN_CONTEXT_TOO_LARGE";
|
|
173
|
-
var
|
|
174
|
-
|
|
255
|
+
var OUTPUT_TOO_LARGE_CODE = "CODEGEN_OUTPUT_TOO_LARGE";
|
|
256
|
+
var NOT_RETRIED_CODES = /* @__PURE__ */ new Set([FILE_TOO_LARGE_CODE, CONTEXT_TOO_LARGE_CODE, OUTPUT_TOO_LARGE_CODE]);
|
|
257
|
+
var JUDGED_BY_TOO_LARGE_CODE = "CODEGEN_JUDGED_BY_TOO_LARGE";
|
|
258
|
+
var SUSPECT_PLACEHOLDER_RE = /rest(?: of the)? (?:file|code) unchanged|remainder unchanged|\/\/\s*\.\.\.\s*(?:rest|remaining)/i;
|
|
259
|
+
var SUSPECT_LENGTH_DROP_RATIO = 0.6;
|
|
260
|
+
var SUSPECT_REMOVED_LINE_RATIO = 0.3;
|
|
261
|
+
var MIN_LINES_FOR_REMOVED_LINE_CHECK = 10;
|
|
262
|
+
var JS_TS_EXTENSION_RE = /\.(?:ts|tsx|js|jsx|mjs|cjs)$/;
|
|
263
|
+
var EXPORTED_SYMBOL_RE = /^export\s+(?:default\s+)?(?:async\s+)?(?:function\*?|class|const|let|var|interface|type|enum)\s+([A-Za-z_$][\w$]*)/gm;
|
|
264
|
+
function countRemovedLines(currentContent, newContent) {
|
|
265
|
+
const remaining = /* @__PURE__ */ new Map();
|
|
266
|
+
for (const line of newContent.split("\n")) {
|
|
267
|
+
remaining.set(line, (remaining.get(line) ?? 0) + 1);
|
|
268
|
+
}
|
|
269
|
+
let removed = 0;
|
|
270
|
+
for (const line of currentContent.split("\n")) {
|
|
271
|
+
const count = remaining.get(line) ?? 0;
|
|
272
|
+
if (count > 0) {
|
|
273
|
+
remaining.set(line, count - 1);
|
|
274
|
+
} else {
|
|
275
|
+
removed++;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return removed;
|
|
279
|
+
}
|
|
280
|
+
function extractExportedSymbols(content) {
|
|
281
|
+
const symbols = /* @__PURE__ */ new Set();
|
|
282
|
+
for (const match of content.matchAll(EXPORTED_SYMBOL_RE)) {
|
|
283
|
+
symbols.add(match[1]);
|
|
284
|
+
}
|
|
285
|
+
return symbols;
|
|
286
|
+
}
|
|
287
|
+
function removedUnexplainedSymbols(targetPath, planText, currentContent, newContent) {
|
|
288
|
+
if (!JS_TS_EXTENSION_RE.test(targetPath)) return [];
|
|
289
|
+
const before = extractExportedSymbols(currentContent);
|
|
290
|
+
const after = extractExportedSymbols(newContent);
|
|
291
|
+
const lowerPlan = planText.toLowerCase();
|
|
292
|
+
return [...before].filter((symbol) => !after.has(symbol) && !lowerPlan.includes(symbol.toLowerCase()));
|
|
293
|
+
}
|
|
294
|
+
function looksLikeTruncatedRewrite(target, currentContent, newContent) {
|
|
295
|
+
if (SUSPECT_PLACEHOLDER_RE.test(newContent)) {
|
|
296
|
+
return "contains placeholder text suggesting an abbreviated rewrite";
|
|
297
|
+
}
|
|
298
|
+
if (currentContent.length > 0 && newContent.length < currentContent.length * SUSPECT_LENGTH_DROP_RATIO) {
|
|
299
|
+
return `sharp length drop (${currentContent.length} -> ${newContent.length} chars)`;
|
|
300
|
+
}
|
|
301
|
+
const currentLineCount = currentContent.length > 0 ? currentContent.split("\n").length : 0;
|
|
302
|
+
if (currentLineCount >= MIN_LINES_FOR_REMOVED_LINE_CHECK) {
|
|
303
|
+
const removedLines = countRemovedLines(currentContent, newContent);
|
|
304
|
+
if (removedLines > currentLineCount * SUSPECT_REMOVED_LINE_RATIO) {
|
|
305
|
+
const pct = (removedLines / currentLineCount * 100).toFixed(0);
|
|
306
|
+
return `removed ${removedLines} of ${currentLineCount} lines (${pct}%) without a matching drop in character count`;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const planText = target.planStepsImplemented.join(" ");
|
|
310
|
+
const unexplainedSymbols = removedUnexplainedSymbols(target.path, planText, currentContent, newContent);
|
|
311
|
+
if (unexplainedSymbols.length > 0) {
|
|
312
|
+
return `removed exported symbol(s) the plan steps don't mention: ${unexplainedSymbols.join(", ")}`;
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
var CONTRACT_VERSION = "test-targets-1";
|
|
317
|
+
function buildJudgedByPool(requiredTests, targets, skippedCases) {
|
|
318
|
+
const claimedIds = /* @__PURE__ */ new Set();
|
|
319
|
+
for (const t of targets) {
|
|
320
|
+
for (const id of t.automationIds ?? []) claimedIds.add(id);
|
|
321
|
+
}
|
|
322
|
+
const skippedIds = new Set(skippedCases.map((s) => s.automationId));
|
|
323
|
+
const pool = [];
|
|
324
|
+
for (const tc of requiredTests) {
|
|
325
|
+
if (!claimedIds.has(tc.automationId) || skippedIds.has(tc.automationId)) continue;
|
|
326
|
+
if (!tc.given || !tc.when || !tc.then) continue;
|
|
327
|
+
pool.push({ automationId: tc.automationId, title: tc.title, given: tc.given, when: tc.when, then: tc.then });
|
|
328
|
+
}
|
|
329
|
+
return pool;
|
|
330
|
+
}
|
|
331
|
+
function narrowJudgedByForTarget(pool, targets, codeTargetPath) {
|
|
332
|
+
const narrowedIds = /* @__PURE__ */ new Set();
|
|
333
|
+
for (const t of targets) {
|
|
334
|
+
if (!t.automationIds?.length || !t.contextPaths?.includes(codeTargetPath)) continue;
|
|
335
|
+
for (const id of t.automationIds) narrowedIds.add(id);
|
|
336
|
+
}
|
|
337
|
+
return pool.filter((c) => narrowedIds.has(c.automationId));
|
|
338
|
+
}
|
|
339
|
+
function toWireJudgedBy(pool) {
|
|
340
|
+
return pool.map(({ automationId: _automationId, ...rest }) => rest);
|
|
341
|
+
}
|
|
342
|
+
async function runCodegen(gateway, targets, workspaceListing, requiredTests, deniedPaths, judgedByPool, warnings) {
|
|
175
343
|
const results = [];
|
|
176
344
|
const listing = new Set(workspaceListing);
|
|
177
345
|
for (const target of targets) {
|
|
@@ -191,12 +359,23 @@ async function runCodegen(gateway, targets, workspaceListing) {
|
|
|
191
359
|
currentContent,
|
|
192
360
|
planSteps: target.planStepsImplemented,
|
|
193
361
|
kind: target.kind,
|
|
194
|
-
contextContents
|
|
362
|
+
contextContents,
|
|
363
|
+
// QUIKTR-1083 (§15.6) — required on every call on the versioned path.
|
|
364
|
+
contractVersion: CONTRACT_VERSION
|
|
195
365
|
};
|
|
196
366
|
if (isNewFile) {
|
|
197
367
|
contextData.workspaceListing = workspaceListing;
|
|
198
368
|
}
|
|
199
|
-
|
|
369
|
+
if (deniedPaths.length > 0) {
|
|
370
|
+
contextData.deniedPaths = deniedPaths;
|
|
371
|
+
}
|
|
372
|
+
if (target.automationIds && target.automationIds.length > 0) {
|
|
373
|
+
const wanted = new Set(target.automationIds);
|
|
374
|
+
contextData.requiredTests = requiredTests.filter((tc) => wanted.has(tc.automationId));
|
|
375
|
+
} else if (judgedByPool.length > 0) {
|
|
376
|
+
contextData.judgedBy = toWireJudgedBy(judgedByPool);
|
|
377
|
+
}
|
|
378
|
+
const callCodegen = () => fetchWithRetry(`${gateway.baseUrl}/ai/execute`, {
|
|
200
379
|
method: "POST",
|
|
201
380
|
headers: { "content-type": "application/json", "x-run-token": gateway.token },
|
|
202
381
|
body: JSON.stringify({
|
|
@@ -207,20 +386,60 @@ async function runCodegen(gateway, targets, workspaceListing) {
|
|
|
207
386
|
contextData
|
|
208
387
|
})
|
|
209
388
|
});
|
|
389
|
+
let res = await callCodegen();
|
|
210
390
|
if (!res.ok) {
|
|
211
391
|
throw new Error(`Codegen call failed for ${target.path}: ${res.status} ${res.statusText}`);
|
|
212
392
|
}
|
|
213
|
-
|
|
393
|
+
let envelope = await res.json();
|
|
394
|
+
checkHardFailure(envelope, "pilot.codegen");
|
|
395
|
+
if (envelope?.errorCode === JUDGED_BY_TOO_LARGE_CODE && contextData.judgedBy) {
|
|
396
|
+
const before = contextData.judgedBy.length;
|
|
397
|
+
const narrowed = narrowJudgedByForTarget(judgedByPool, targets, target.path);
|
|
398
|
+
if (narrowed.length > 0) {
|
|
399
|
+
contextData.judgedBy = toWireJudgedBy(narrowed);
|
|
400
|
+
} else {
|
|
401
|
+
delete contextData.judgedBy;
|
|
402
|
+
}
|
|
403
|
+
warnings.push(`judgedBy narrowed for ${target.path}: ${before} \u2192 ${narrowed.length} cases`);
|
|
404
|
+
res = await callCodegen();
|
|
405
|
+
if (!res.ok) {
|
|
406
|
+
throw new Error(`Codegen call failed for ${target.path}: ${res.status} ${res.statusText}`);
|
|
407
|
+
}
|
|
408
|
+
envelope = await res.json();
|
|
409
|
+
checkHardFailure(envelope, "pilot.codegen");
|
|
410
|
+
}
|
|
214
411
|
if (typeof envelope?.errorCode === "string" && NOT_RETRIED_CODES.has(envelope.errorCode)) {
|
|
215
412
|
const detail = envelope.error ? `: ${envelope.error}` : "";
|
|
216
413
|
throw new Error(`Codegen refused ${target.path}: ${envelope.errorCode} (not retried)${detail}`);
|
|
217
414
|
}
|
|
415
|
+
if (envelope?.errorCode === JUDGED_BY_TOO_LARGE_CODE) {
|
|
416
|
+
const detail = envelope.error ? `: ${envelope.error}` : "";
|
|
417
|
+
throw new Error(`Codegen refused ${target.path}: ${envelope.errorCode} (after narrowing)${detail}`);
|
|
418
|
+
}
|
|
218
419
|
const body = unwrapExecute(envelope, "pilot.codegen");
|
|
219
420
|
if (body.outcome !== "GENERATED" || typeof body.newContent !== "string") {
|
|
220
421
|
throw new Error(
|
|
221
422
|
`Codegen refused ${target.path}${body.refusalReason ? `: ${body.refusalReason}` : ""}`
|
|
222
423
|
);
|
|
223
424
|
}
|
|
425
|
+
if (!isNewFile) {
|
|
426
|
+
const suspectReason = looksLikeTruncatedRewrite(target, currentContent, body.newContent);
|
|
427
|
+
if (suspectReason) {
|
|
428
|
+
throw new Error(
|
|
429
|
+
`Codegen output for ${target.path} looks like a truncated/abbreviated rewrite (${suspectReason}) \u2014 refusing to apply it. Interim guard per \xA716.7; the AI Runtime's own output-cap check is not live yet.`
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
console.log(
|
|
434
|
+
JSON.stringify({
|
|
435
|
+
event: "codegen.metrics",
|
|
436
|
+
targetPath: target.path,
|
|
437
|
+
kind: target.kind,
|
|
438
|
+
inputLength: currentContent.length,
|
|
439
|
+
outputLength: body.newContent.length,
|
|
440
|
+
removedLines: isNewFile ? null : countRemovedLines(currentContent, body.newContent)
|
|
441
|
+
})
|
|
442
|
+
);
|
|
224
443
|
results.push({ targetPath: target.path, newContent: body.newContent });
|
|
225
444
|
}
|
|
226
445
|
return results;
|
|
@@ -414,26 +633,6 @@ function describeDependencyViolations(check) {
|
|
|
414
633
|
return check.violations.map((v) => `${v.file} imports ${v.packages.join(", ")}, not a dependency of this repository`).join("; ");
|
|
415
634
|
}
|
|
416
635
|
|
|
417
|
-
// src/env.ts
|
|
418
|
-
function required(name, value) {
|
|
419
|
-
if (!value) {
|
|
420
|
-
throw new Error(`Missing required environment variable: ${name}`);
|
|
421
|
-
}
|
|
422
|
-
return value;
|
|
423
|
-
}
|
|
424
|
-
function readRunnerEnv(env = process.env) {
|
|
425
|
-
return {
|
|
426
|
-
runId: required("RUN_ID", env.RUN_ID),
|
|
427
|
-
callbackUrl: required("CALLBACK_URL", env.CALLBACK_URL),
|
|
428
|
-
packetUrl: required("PACKET_URL", env.PACKET_URL),
|
|
429
|
-
tokenUrl: required("TOKEN_URL", env.TOKEN_URL),
|
|
430
|
-
installationTokenUrl: required("INSTALLATION_TOKEN_URL", env.INSTALLATION_TOKEN_URL),
|
|
431
|
-
githubRunId: required("GITHUB_RUN_ID", env.GITHUB_RUN_ID),
|
|
432
|
-
githubServerUrl: env.GITHUB_SERVER_URL ?? "https://github.com",
|
|
433
|
-
githubRepository: required("GITHUB_REPOSITORY", env.GITHUB_REPOSITORY)
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
|
|
437
636
|
// src/guardrail.ts
|
|
438
637
|
function checkGuardrail(deniedPaths) {
|
|
439
638
|
addAll();
|
|
@@ -451,7 +650,7 @@ async function exchangeForInstallationToken(installationTokenUrl, runToken) {
|
|
|
451
650
|
headers: { Authorization: `Bearer ${runToken}` }
|
|
452
651
|
});
|
|
453
652
|
if (!res.ok) {
|
|
454
|
-
throw new Error(`Exchange installation token failed: ${
|
|
653
|
+
throw new Error(`Exchange installation token failed: ${await describeQuikTrackFailure(res)}`);
|
|
455
654
|
}
|
|
456
655
|
const body = await res.json();
|
|
457
656
|
if (body.success !== true) {
|
|
@@ -462,14 +661,14 @@ async function exchangeForInstallationToken(installationTokenUrl, runToken) {
|
|
|
462
661
|
|
|
463
662
|
// src/oidc.ts
|
|
464
663
|
var QUIKPILOT_OIDC_AUDIENCE = "quiktrack-quikpilot";
|
|
465
|
-
async function fetchGithubOidcToken(
|
|
664
|
+
async function fetchGithubOidcToken(env2 = process.env) {
|
|
466
665
|
const requestUrl = required(
|
|
467
666
|
"ACTIONS_ID_TOKEN_REQUEST_URL",
|
|
468
|
-
|
|
667
|
+
env2.ACTIONS_ID_TOKEN_REQUEST_URL
|
|
469
668
|
);
|
|
470
669
|
const requestToken = required(
|
|
471
670
|
"ACTIONS_ID_TOKEN_REQUEST_TOKEN",
|
|
472
|
-
|
|
671
|
+
env2.ACTIONS_ID_TOKEN_REQUEST_TOKEN
|
|
473
672
|
);
|
|
474
673
|
const separator = requestUrl.includes("?") ? "&" : "?";
|
|
475
674
|
const url = `${requestUrl}${separator}audience=${encodeURIComponent(QUIKPILOT_OIDC_AUDIENCE)}`;
|
|
@@ -487,6 +686,10 @@ async function fetchGithubOidcToken(env = process.env) {
|
|
|
487
686
|
}
|
|
488
687
|
|
|
489
688
|
// src/overlapCheck.ts
|
|
689
|
+
var TEST_PATH_RE = /(\.(test|spec)\.[jt]sx?$)|(^|\/)(__tests__|tests?)\//i;
|
|
690
|
+
function isTestTarget(path) {
|
|
691
|
+
return TEST_PATH_RE.test(path);
|
|
692
|
+
}
|
|
490
693
|
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
491
694
|
"a",
|
|
492
695
|
"an",
|
|
@@ -548,6 +751,9 @@ function jaccard(a, b) {
|
|
|
548
751
|
const union = a.size + b.size - intersection;
|
|
549
752
|
return intersection / union;
|
|
550
753
|
}
|
|
754
|
+
function normalizedStep(step) {
|
|
755
|
+
return step.trim().toLowerCase().replace(/\s+/g, " ");
|
|
756
|
+
}
|
|
551
757
|
var OVERLAP_THRESHOLD = 0.5;
|
|
552
758
|
function findOverlappingSteps(targets) {
|
|
553
759
|
const items = targets.flatMap(
|
|
@@ -561,12 +767,14 @@ function findOverlappingSteps(targets) {
|
|
|
561
767
|
for (let i = 0; i < items.length; i++) {
|
|
562
768
|
for (let j = i + 1; j < items.length; j++) {
|
|
563
769
|
if (items[i].target === items[j].target) continue;
|
|
770
|
+
if (isTestTarget(items[i].target) || isTestTarget(items[j].target)) continue;
|
|
564
771
|
if (jaccard(items[i].words, items[j].words) >= OVERLAP_THRESHOLD) {
|
|
565
772
|
warnings.push({
|
|
566
773
|
targetA: items[i].target,
|
|
567
774
|
stepA: items[i].step,
|
|
568
775
|
targetB: items[j].target,
|
|
569
|
-
stepB: items[j].step
|
|
776
|
+
stepB: items[j].step,
|
|
777
|
+
severity: normalizedStep(items[i].step) === normalizedStep(items[j].step) ? "identical" : "similar"
|
|
570
778
|
});
|
|
571
779
|
}
|
|
572
780
|
}
|
|
@@ -586,7 +794,7 @@ async function fetchPacket(packetUrl, runToken, githubRunId) {
|
|
|
586
794
|
headers: { Authorization: `Bearer ${runToken}` }
|
|
587
795
|
});
|
|
588
796
|
if (!res.ok) {
|
|
589
|
-
throw new Error(`Fetch work packet failed: ${
|
|
797
|
+
throw new Error(`Fetch work packet failed: ${await describeQuikTrackFailure(res)}`);
|
|
590
798
|
}
|
|
591
799
|
const body = await res.json();
|
|
592
800
|
if (body.success !== true) {
|
|
@@ -648,23 +856,28 @@ function findJUnitReport(cwd = process.cwd()) {
|
|
|
648
856
|
function buildCiUrl(serverUrl, repository, githubRunId) {
|
|
649
857
|
return `${serverUrl}/${repository}/actions/runs/${githubRunId}`;
|
|
650
858
|
}
|
|
859
|
+
function describeUnmatchedAutomationIds(unmatched) {
|
|
860
|
+
if (unmatched.length === 0) return null;
|
|
861
|
+
const named = unmatched.map((u) => `${u.automationId} (\xD7${u.count})`).join(", ");
|
|
862
|
+
return `${unmatched.length} generated test result(s) did not match any test case: ${named} \u2014 check the automationId format.`;
|
|
863
|
+
}
|
|
651
864
|
async function reportToQuikTest(params) {
|
|
652
865
|
const { resultsUrl, runToken, githubServerUrl, githubRepository, githubRunId, cwd } = params;
|
|
653
866
|
if (!resultsUrl) {
|
|
654
867
|
console.log("packet.quikTest is null \u2014 this run has no QuikTest run to report into.");
|
|
655
|
-
return;
|
|
868
|
+
return null;
|
|
656
869
|
}
|
|
657
870
|
const report = findJUnitReport(cwd);
|
|
658
871
|
if (!report) {
|
|
659
872
|
console.warn("::warning::QuikPilot found no JUnit report \u2014 no results reported to QuikTest.");
|
|
660
|
-
return;
|
|
873
|
+
return null;
|
|
661
874
|
}
|
|
662
875
|
try {
|
|
663
876
|
console.log(`reporting ${report}`);
|
|
664
877
|
const ciUrl = buildCiUrl(githubServerUrl, githubRepository, githubRunId);
|
|
665
|
-
const { readFileSync:
|
|
666
|
-
const body =
|
|
667
|
-
const url = `${resultsUrl}?ciUrl=${encodeURIComponent(ciUrl)}`;
|
|
878
|
+
const { readFileSync: readFileSync5 } = await import("node:fs");
|
|
879
|
+
const body = readFileSync5(report);
|
|
880
|
+
const url = `${resultsUrl}?ciUrl=${encodeURIComponent(ciUrl)}&automationIdFormat=bracket`;
|
|
668
881
|
const res = await fetch(url, {
|
|
669
882
|
method: "POST",
|
|
670
883
|
headers: { Authorization: `Bearer ${runToken}`, "Content-Type": "text/xml" },
|
|
@@ -674,8 +887,16 @@ async function reportToQuikTest(params) {
|
|
|
674
887
|
throw new Error(`QuikTest report POST failed: ${res.status} ${res.statusText}`);
|
|
675
888
|
}
|
|
676
889
|
(0, import_node_fs4.rmSync)(report, { force: true });
|
|
890
|
+
let unmatched = [];
|
|
891
|
+
try {
|
|
892
|
+
const json = await res.json();
|
|
893
|
+
unmatched = json.data?.unmatched ?? [];
|
|
894
|
+
} catch {
|
|
895
|
+
}
|
|
896
|
+
return { unmatched };
|
|
677
897
|
} catch (err) {
|
|
678
898
|
console.warn(`::warning::QuikPilot failed to report results to QuikTest: ${err instanceof Error ? err.message : String(err)}`);
|
|
899
|
+
return null;
|
|
679
900
|
}
|
|
680
901
|
}
|
|
681
902
|
|
|
@@ -685,8 +906,38 @@ var TESTCASE_RE = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
|
|
|
685
906
|
var NAME_ATTR_RE = /\bname="([^"]*)"/;
|
|
686
907
|
var CLASSNAME_ATTR_RE = /\bclassname="([^"]*)"/;
|
|
687
908
|
var FAILURE_CHILD_RE = /<failure\b|<error\b/;
|
|
909
|
+
var TESTSUITE_OPEN_RE = /<testsuite\b([^>]*?)>/g;
|
|
910
|
+
var FILE_ATTR_RE = /\bfile="([^"]*)"/;
|
|
911
|
+
var QP_TOKEN_BRACKET_RE = /\[(QP-[A-Z][A-Z0-9]*-\d+-\d+)\]/;
|
|
912
|
+
function normalizeSlashes(path) {
|
|
913
|
+
return path.replace(/\\/g, "/");
|
|
914
|
+
}
|
|
915
|
+
var FAILURE_TAG_RE = /<(?:failure|error)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/(?:failure|error)>)/;
|
|
916
|
+
var FAILURE_MESSAGE_ATTR_RE = /\bmessage="([^"]*)"/;
|
|
917
|
+
var MAX_FAILURE_TEXT = 500;
|
|
918
|
+
function unescapeXmlEntities(s) {
|
|
919
|
+
return s.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&");
|
|
920
|
+
}
|
|
921
|
+
function extractFailureText(testcaseBody) {
|
|
922
|
+
const tag = FAILURE_TAG_RE.exec(testcaseBody);
|
|
923
|
+
if (!tag) return null;
|
|
924
|
+
const [, attrs = "", inner = ""] = tag;
|
|
925
|
+
const raw = (FAILURE_MESSAGE_ATTR_RE.exec(attrs)?.[1] ?? inner).trim();
|
|
926
|
+
if (!raw) return null;
|
|
927
|
+
const text = unescapeXmlEntities(raw);
|
|
928
|
+
return text.length > MAX_FAILURE_TEXT ? `${text.slice(0, MAX_FAILURE_TEXT)}\u2026` : text;
|
|
929
|
+
}
|
|
688
930
|
function parseJUnitOutcomes(xml) {
|
|
689
|
-
const
|
|
931
|
+
const outcomes = /* @__PURE__ */ new Map();
|
|
932
|
+
const failureText = /* @__PURE__ */ new Map();
|
|
933
|
+
const discoveredFiles = /* @__PURE__ */ new Set();
|
|
934
|
+
const discoveredAutomationIds = /* @__PURE__ */ new Set();
|
|
935
|
+
TESTSUITE_OPEN_RE.lastIndex = 0;
|
|
936
|
+
let suiteMatch;
|
|
937
|
+
while (suiteMatch = TESTSUITE_OPEN_RE.exec(xml)) {
|
|
938
|
+
const file = FILE_ATTR_RE.exec(suiteMatch[1])?.[1];
|
|
939
|
+
if (file) discoveredFiles.add(normalizeSlashes(file));
|
|
940
|
+
}
|
|
690
941
|
let match;
|
|
691
942
|
TESTCASE_RE.lastIndex = 0;
|
|
692
943
|
while (match = TESTCASE_RE.exec(xml)) {
|
|
@@ -695,9 +946,19 @@ function parseJUnitOutcomes(xml) {
|
|
|
695
946
|
if (!name) continue;
|
|
696
947
|
const classname = CLASSNAME_ATTR_RE.exec(attrs)?.[1];
|
|
697
948
|
const id = classname ? `${classname}::${name}` : name;
|
|
698
|
-
|
|
949
|
+
const failed = FAILURE_CHILD_RE.test(body);
|
|
950
|
+
outcomes.set(id, !failed);
|
|
951
|
+
if (failed) {
|
|
952
|
+
const text = extractFailureText(body);
|
|
953
|
+
if (text) failureText.set(id, text);
|
|
954
|
+
}
|
|
955
|
+
const file = FILE_ATTR_RE.exec(attrs)?.[1];
|
|
956
|
+
if (file) discoveredFiles.add(normalizeSlashes(file));
|
|
957
|
+
if (classname) discoveredFiles.add(normalizeSlashes(classname));
|
|
958
|
+
const bracket = QP_TOKEN_BRACKET_RE.exec(name)?.[1];
|
|
959
|
+
if (bracket) discoveredAutomationIds.add(bracket);
|
|
699
960
|
}
|
|
700
|
-
return
|
|
961
|
+
return { outcomes, failureText, discoveredFiles, discoveredAutomationIds };
|
|
701
962
|
}
|
|
702
963
|
function readJUnitOutcomes(cwd) {
|
|
703
964
|
const report = findJUnitReport(cwd);
|
|
@@ -710,35 +971,67 @@ function readJUnitOutcomes(cwd) {
|
|
|
710
971
|
}
|
|
711
972
|
function captureTestSnapshot(command, cwd = process.cwd()) {
|
|
712
973
|
const outcome = runTestCommand(command);
|
|
713
|
-
|
|
974
|
+
const parsed = readJUnitOutcomes(cwd);
|
|
975
|
+
return {
|
|
976
|
+
outcome,
|
|
977
|
+
results: parsed?.outcomes ?? null,
|
|
978
|
+
failureText: parsed?.failureText,
|
|
979
|
+
discoveredFiles: parsed?.discoveredFiles ?? null,
|
|
980
|
+
discoveredAutomationIds: parsed?.discoveredAutomationIds ?? null
|
|
981
|
+
};
|
|
714
982
|
}
|
|
715
983
|
async function captureBaselineSnapshot(command, cwd = process.cwd()) {
|
|
716
984
|
const outcome = await runTestCommandAsync(command);
|
|
717
|
-
|
|
985
|
+
const parsed = readJUnitOutcomes(cwd);
|
|
986
|
+
return {
|
|
987
|
+
outcome,
|
|
988
|
+
results: parsed?.outcomes ?? null,
|
|
989
|
+
failureText: parsed?.failureText,
|
|
990
|
+
discoveredFiles: parsed?.discoveredFiles ?? null,
|
|
991
|
+
discoveredAutomationIds: parsed?.discoveredAutomationIds ?? null
|
|
992
|
+
};
|
|
718
993
|
}
|
|
719
994
|
function checkForRegressions(baseline, after) {
|
|
720
995
|
if (!baseline.results || !after.results) {
|
|
721
|
-
return { blocked: false, regressions: [], newFailures: [], preExisting: [] };
|
|
996
|
+
return { blocked: false, regressions: [], newFailures: [], preExisting: [], failureText: /* @__PURE__ */ new Map() };
|
|
722
997
|
}
|
|
723
998
|
const regressions = [];
|
|
724
999
|
const newFailures = [];
|
|
725
1000
|
const preExisting = [];
|
|
1001
|
+
const failureText = /* @__PURE__ */ new Map();
|
|
1002
|
+
const afterFailureText = after.failureText;
|
|
726
1003
|
for (const [testId, passed] of after.results) {
|
|
727
1004
|
if (passed) continue;
|
|
728
1005
|
const before = baseline.results.get(testId);
|
|
729
|
-
if (before === true)
|
|
730
|
-
|
|
731
|
-
|
|
1006
|
+
if (before === true) {
|
|
1007
|
+
regressions.push(testId);
|
|
1008
|
+
const text = afterFailureText?.get(testId);
|
|
1009
|
+
if (text) failureText.set(testId, text);
|
|
1010
|
+
} else if (before === false) {
|
|
1011
|
+
preExisting.push(testId);
|
|
1012
|
+
} else {
|
|
1013
|
+
newFailures.push(testId);
|
|
1014
|
+
const text = afterFailureText?.get(testId);
|
|
1015
|
+
if (text) failureText.set(testId, text);
|
|
1016
|
+
}
|
|
732
1017
|
}
|
|
733
|
-
return { blocked: regressions.length > 0 || newFailures.length > 0, regressions, newFailures, preExisting };
|
|
1018
|
+
return { blocked: regressions.length > 0 || newFailures.length > 0, regressions, newFailures, preExisting, failureText };
|
|
734
1019
|
}
|
|
735
1020
|
function describeRegressions(check) {
|
|
1021
|
+
const withText = (id) => {
|
|
1022
|
+
const text = check.failureText.get(id);
|
|
1023
|
+
return text ? `${id} (${text})` : id;
|
|
1024
|
+
};
|
|
736
1025
|
const parts = [];
|
|
737
1026
|
if (check.regressions.length > 0) {
|
|
738
|
-
parts.push(
|
|
1027
|
+
parts.push(
|
|
1028
|
+
`${check.regressions.length} regression(s) (passed before, now fail): ${check.regressions.map(withText).join(", ")}`
|
|
1029
|
+
);
|
|
739
1030
|
}
|
|
740
1031
|
if (check.newFailures.length > 0) {
|
|
741
|
-
parts.push(
|
|
1032
|
+
parts.push(
|
|
1033
|
+
`${check.newFailures.length} new failure(s) (did not exist before): ${check.newFailures.map(withText).join(", ")}`
|
|
1034
|
+
);
|
|
742
1035
|
}
|
|
743
1036
|
if (check.preExisting.length > 0) {
|
|
744
1037
|
parts.push(`${check.preExisting.length} pre-existing failure(s), unrelated to this change, not blocking`);
|
|
@@ -746,12 +1039,99 @@ function describeRegressions(check) {
|
|
|
746
1039
|
return parts.join("; ");
|
|
747
1040
|
}
|
|
748
1041
|
|
|
1042
|
+
// src/scopeCheck.ts
|
|
1043
|
+
var import_node_fs6 = require("node:fs");
|
|
1044
|
+
var ROUTE_METHOD_RE = /\b(GET|POST|PUT|PATCH|DELETE)\b/i;
|
|
1045
|
+
var ROUTE_PATH_RE = /\/[A-Za-z0-9_\-{}[\]]+(?:\/[A-Za-z0-9_\-:{}[\]]+)+/;
|
|
1046
|
+
var ROUTE_EXPORT_RE = /\bexport\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\s*\(/;
|
|
1047
|
+
var NO_PATH = "(this file's route)";
|
|
1048
|
+
function detectMentions(text) {
|
|
1049
|
+
const found = [];
|
|
1050
|
+
for (const line of text.split("\n")) {
|
|
1051
|
+
const methodMatch = ROUTE_METHOD_RE.exec(line);
|
|
1052
|
+
const pathMatch = ROUTE_PATH_RE.exec(line);
|
|
1053
|
+
if (methodMatch && pathMatch) {
|
|
1054
|
+
found.push({ method: methodMatch[1].toUpperCase(), path: pathMatch[0] });
|
|
1055
|
+
}
|
|
1056
|
+
const exportMatch = ROUTE_EXPORT_RE.exec(line);
|
|
1057
|
+
if (exportMatch) {
|
|
1058
|
+
found.push({ method: exportMatch[1].toUpperCase(), path: NO_PATH });
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
return found;
|
|
1062
|
+
}
|
|
1063
|
+
function normalizePath(path) {
|
|
1064
|
+
return path.replace(/:[A-Za-z0-9_]+|\{[^}]+\}|\[[^\]]+\]/g, "*");
|
|
1065
|
+
}
|
|
1066
|
+
function surfaceCovers(surface, mention) {
|
|
1067
|
+
if (!new RegExp(`\\b${mention.method}\\b`, "i").test(surface)) return false;
|
|
1068
|
+
if (mention.path === NO_PATH) return true;
|
|
1069
|
+
const declaredPath = normalizePath(surface);
|
|
1070
|
+
const mentionPath = normalizePath(mention.path);
|
|
1071
|
+
return declaredPath.includes(mentionPath) || mentionPath.includes(declaredPath);
|
|
1072
|
+
}
|
|
1073
|
+
function addedLines(before, after) {
|
|
1074
|
+
const beforeLines = new Set(before.split("\n"));
|
|
1075
|
+
return after.split("\n").filter((line) => !beforeLines.has(line)).join("\n");
|
|
1076
|
+
}
|
|
1077
|
+
function checkApiScope(results, targets, apiChanges) {
|
|
1078
|
+
const targetsByPath = new Map(targets.map((t) => [t.path, t]));
|
|
1079
|
+
const violations = [];
|
|
1080
|
+
for (const { targetPath, newContent } of results) {
|
|
1081
|
+
const target = targetsByPath.get(targetPath);
|
|
1082
|
+
const isNewFile = target?.kind === "NEW_FILE";
|
|
1083
|
+
let scanned;
|
|
1084
|
+
if (isNewFile) {
|
|
1085
|
+
scanned = newContent;
|
|
1086
|
+
} else {
|
|
1087
|
+
let currentContent;
|
|
1088
|
+
try {
|
|
1089
|
+
currentContent = (0, import_node_fs6.readFileSync)(targetPath, "utf8");
|
|
1090
|
+
} catch {
|
|
1091
|
+
currentContent = "";
|
|
1092
|
+
}
|
|
1093
|
+
scanned = addedLines(currentContent, newContent);
|
|
1094
|
+
}
|
|
1095
|
+
for (const mention of detectMentions(scanned)) {
|
|
1096
|
+
if (!apiChanges.some((c) => surfaceCovers(c.surface, mention))) {
|
|
1097
|
+
violations.push({ file: targetPath, method: mention.method, path: mention.path });
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
return { blocked: violations.length > 0, violations };
|
|
1102
|
+
}
|
|
1103
|
+
function describeScopeViolations(check) {
|
|
1104
|
+
return check.violations.map((v) => `${v.file} adds ${v.method} ${v.path}, not declared in the design's API changes`).join("; ");
|
|
1105
|
+
}
|
|
1106
|
+
|
|
749
1107
|
// src/targeting.ts
|
|
750
1108
|
var import_node_path4 = require("node:path");
|
|
1109
|
+
|
|
1110
|
+
// src/deniedPaths.ts
|
|
1111
|
+
function globToRegExp(pattern) {
|
|
1112
|
+
const escaped = pattern.split("").map((c) => /[.+^${}()|[\]\\]/.test(c) ? `\\${c}` : c).join("");
|
|
1113
|
+
const withGlobs = escaped.replace(/\*\*\//g, " SLASH ").replace(/\*\*/g, " STAR ").replace(/\*/g, "[^/]*").replace(/ SLASH /g, "(?:.*/)?").replace(/ STAR /g, ".*");
|
|
1114
|
+
return new RegExp(`^${withGlobs}$`);
|
|
1115
|
+
}
|
|
1116
|
+
function matchesDeniedPath(path, patterns) {
|
|
1117
|
+
for (const pattern of patterns) {
|
|
1118
|
+
if (globToRegExp(pattern).test(path)) return pattern;
|
|
1119
|
+
}
|
|
1120
|
+
return null;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// src/targeting.ts
|
|
1124
|
+
var CONTRACT_VERSION2 = "test-targets-1";
|
|
751
1125
|
function listWorkspaceFiles(workspaceRoot) {
|
|
752
1126
|
return listFiles(workspaceRoot ?? ".");
|
|
753
1127
|
}
|
|
754
|
-
function validateTarget(target, workspaceRoot, workspaceListing) {
|
|
1128
|
+
function validateTarget(target, workspaceRoot, workspaceListing, deniedPaths) {
|
|
1129
|
+
const denied = matchesDeniedPath(target.path, deniedPaths);
|
|
1130
|
+
if (denied) {
|
|
1131
|
+
throw new Error(
|
|
1132
|
+
`pilot.targeting proposed a target matching a denied path (${denied}): ${target.path}`
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
755
1135
|
if (target.kind !== "EXISTING" && target.kind !== "NEW_FILE") {
|
|
756
1136
|
throw new Error(`pilot.targeting returned an unknown kind "${target.kind}" for ${target.path}`);
|
|
757
1137
|
}
|
|
@@ -771,8 +1151,23 @@ function validateTarget(target, workspaceRoot, workspaceListing) {
|
|
|
771
1151
|
);
|
|
772
1152
|
}
|
|
773
1153
|
}
|
|
774
|
-
async function runTargeting(gateway, design, workspaceRoot, workspaceListing, requiredTests) {
|
|
775
|
-
const
|
|
1154
|
+
async function runTargeting(gateway, design, workspaceRoot, workspaceListing, requiredTests, deniedPaths, testDiscovery) {
|
|
1155
|
+
const contextData = {
|
|
1156
|
+
// NESTED, NOT FLATTENED — a second vocabulary for the design shape is exactly what
|
|
1157
|
+
// QUIKTR-1034 cost.
|
|
1158
|
+
workspaceListing,
|
|
1159
|
+
design,
|
|
1160
|
+
// QUIKTR-1065/1083 — requiredTests carried through so targeting can propose a test-file
|
|
1161
|
+
// target naming which cases it covers (§15.1's Case shape, unchanged).
|
|
1162
|
+
requiredTests,
|
|
1163
|
+
// QUIKTR-1083 (§15.6) — required on the versioned path.
|
|
1164
|
+
contractVersion: CONTRACT_VERSION2
|
|
1165
|
+
};
|
|
1166
|
+
if (deniedPaths.length > 0) {
|
|
1167
|
+
contextData.deniedPaths = deniedPaths;
|
|
1168
|
+
}
|
|
1169
|
+
contextData.testDiscovery = testDiscovery;
|
|
1170
|
+
const res = await fetchWithRetry(`${gateway.baseUrl}/ai/execute`, {
|
|
776
1171
|
method: "POST",
|
|
777
1172
|
headers: { "content-type": "application/json", "x-run-token": gateway.token },
|
|
778
1173
|
body: JSON.stringify({
|
|
@@ -780,18 +1175,14 @@ async function runTargeting(gateway, design, workspaceRoot, workspaceListing, re
|
|
|
780
1175
|
useCase: "pilot.targeting",
|
|
781
1176
|
expectedOutputType: "json",
|
|
782
1177
|
userPrompt: "",
|
|
783
|
-
|
|
784
|
-
// QUIKTR-1034 cost.
|
|
785
|
-
// QUIKTR-1065 — requiredTests carried through so targeting CAN propose a test-file
|
|
786
|
-
// target naming which cases it covers. The AI Runtime prompt doesn't read this yet
|
|
787
|
-
// (that half is theirs, not this package's) — sending it now is what makes it possible.
|
|
788
|
-
contextData: { workspaceListing, design, requiredTests }
|
|
1178
|
+
contextData
|
|
789
1179
|
})
|
|
790
1180
|
});
|
|
791
1181
|
if (!res.ok) {
|
|
792
1182
|
throw new Error(`Targeting call failed: ${res.status} ${res.statusText}`);
|
|
793
1183
|
}
|
|
794
1184
|
const envelope = await res.json();
|
|
1185
|
+
checkHardFailure(envelope, "pilot.targeting");
|
|
795
1186
|
const body = unwrapExecute(envelope, "pilot.targeting");
|
|
796
1187
|
if (body.outcome === "NO_TARGET") {
|
|
797
1188
|
return { outcome: "NO_TARGET", reason: body.noTargetReason ?? null };
|
|
@@ -801,9 +1192,54 @@ async function runTargeting(gateway, design, workspaceRoot, workspaceListing, re
|
|
|
801
1192
|
}
|
|
802
1193
|
const listing = new Set(workspaceListing);
|
|
803
1194
|
for (const target of body.targets) {
|
|
804
|
-
validateTarget(target, workspaceRoot, listing);
|
|
1195
|
+
validateTarget(target, workspaceRoot, listing, deniedPaths);
|
|
805
1196
|
}
|
|
806
|
-
return {
|
|
1197
|
+
return {
|
|
1198
|
+
outcome: "TARGETED",
|
|
1199
|
+
targets: body.targets,
|
|
1200
|
+
skippedCases: body.skippedCases ?? []
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// src/testPlacementCheck.ts
|
|
1205
|
+
function isDiscovered(targetPath, discoveredFiles) {
|
|
1206
|
+
const normalized = targetPath.replace(/\\/g, "/");
|
|
1207
|
+
for (const candidate of discoveredFiles) {
|
|
1208
|
+
if (candidate === normalized) return true;
|
|
1209
|
+
if (candidate.endsWith(normalized) || normalized.endsWith(candidate)) return true;
|
|
1210
|
+
}
|
|
1211
|
+
return false;
|
|
1212
|
+
}
|
|
1213
|
+
function checkTestPlacement(targets, discoveredFiles) {
|
|
1214
|
+
if (!discoveredFiles || discoveredFiles.size === 0) return { blocked: false, violations: [] };
|
|
1215
|
+
const violations = [];
|
|
1216
|
+
for (const target of targets) {
|
|
1217
|
+
if (target.kind !== "EXISTING") continue;
|
|
1218
|
+
if (!target.automationIds || target.automationIds.length === 0) continue;
|
|
1219
|
+
if (!isDiscovered(target.path, discoveredFiles)) {
|
|
1220
|
+
violations.push({ file: target.path, automationIds: target.automationIds });
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
return { blocked: violations.length > 0, violations };
|
|
1224
|
+
}
|
|
1225
|
+
function describeTestPlacementViolations(check) {
|
|
1226
|
+
return check.violations.map(
|
|
1227
|
+
(v) => `${v.file} claims test case(s) ${v.automationIds.join(", ")} but the repository's own test runner never discovered this file`
|
|
1228
|
+
).join("; ");
|
|
1229
|
+
}
|
|
1230
|
+
function checkAutomationIdCoverage(targets, discoveredAutomationIds) {
|
|
1231
|
+
if (discoveredAutomationIds === null || discoveredAutomationIds === void 0) {
|
|
1232
|
+
return { blocked: false, missing: [] };
|
|
1233
|
+
}
|
|
1234
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
1235
|
+
for (const target of targets) {
|
|
1236
|
+
for (const id of target.automationIds ?? []) claimed.add(id);
|
|
1237
|
+
}
|
|
1238
|
+
const missing = [...claimed].filter((id) => !discoveredAutomationIds.has(id));
|
|
1239
|
+
return { blocked: missing.length > 0, missing };
|
|
1240
|
+
}
|
|
1241
|
+
function describeAutomationCoverageViolations(check) {
|
|
1242
|
+
return `test case(s) ${check.missing.join(", ")} have no matching test discovered in the test run \u2014 generated, but never executed`;
|
|
807
1243
|
}
|
|
808
1244
|
|
|
809
1245
|
// src/tokenExchange.ts
|
|
@@ -813,7 +1249,7 @@ async function exchangeForRunToken(tokenUrl, oidcToken) {
|
|
|
813
1249
|
headers: { Authorization: `Bearer ${oidcToken}` }
|
|
814
1250
|
});
|
|
815
1251
|
if (!res.ok) {
|
|
816
|
-
throw new Error(`Exchange run token failed: ${
|
|
1252
|
+
throw new Error(`Exchange run token failed: ${await describeQuikTrackFailure(res)}`);
|
|
817
1253
|
}
|
|
818
1254
|
const body = await res.json();
|
|
819
1255
|
if (body.success !== true) {
|
|
@@ -823,7 +1259,7 @@ async function exchangeForRunToken(tokenUrl, oidcToken) {
|
|
|
823
1259
|
}
|
|
824
1260
|
|
|
825
1261
|
// src/run.ts
|
|
826
|
-
async function run(
|
|
1262
|
+
async function run(env2) {
|
|
827
1263
|
let hardFailure = false;
|
|
828
1264
|
let packet;
|
|
829
1265
|
let branch;
|
|
@@ -831,8 +1267,8 @@ async function run(env = readRunnerEnv()) {
|
|
|
831
1267
|
let warnings = [];
|
|
832
1268
|
let targeted = false;
|
|
833
1269
|
try {
|
|
834
|
-
runToken = await exchangeForRunToken(
|
|
835
|
-
packet = await fetchPacket(
|
|
1270
|
+
runToken = await exchangeForRunToken(env2.tokenUrl, await fetchGithubOidcToken());
|
|
1271
|
+
packet = await fetchPacket(env2.packetUrl, runToken, env2.githubRunId);
|
|
836
1272
|
checkoutTargetBranch(packet.targetBranch);
|
|
837
1273
|
const workspaceListing = listWorkspaceFiles(packet.workspaceRoot);
|
|
838
1274
|
const targeting = await runTargeting(
|
|
@@ -840,26 +1276,55 @@ async function run(env = readRunnerEnv()) {
|
|
|
840
1276
|
packet.design,
|
|
841
1277
|
packet.workspaceRoot,
|
|
842
1278
|
workspaceListing,
|
|
843
|
-
packet.requiredTests
|
|
1279
|
+
packet.requiredTests,
|
|
1280
|
+
packet.guardrails.deniedPaths,
|
|
1281
|
+
packet.testDiscovery
|
|
844
1282
|
);
|
|
845
1283
|
if (targeting.outcome === "NO_TARGET") {
|
|
846
|
-
writePhase0Marker(
|
|
1284
|
+
writePhase0Marker(env2.runId);
|
|
847
1285
|
runCommand(packet.commands.setup, "Setup");
|
|
848
1286
|
runCommand(packet.commands.lint, "Lint");
|
|
849
1287
|
runTestCommand(packet.commands.test);
|
|
850
1288
|
} else {
|
|
851
1289
|
targeted = true;
|
|
1290
|
+
if (targeting.skippedCases?.length) {
|
|
1291
|
+
for (const skipped of targeting.skippedCases) {
|
|
1292
|
+
const w = `Test case ${skipped.automationId} has no given/when/then \u2014 skipped, no test was written for it.`;
|
|
1293
|
+
warnings.push(w);
|
|
1294
|
+
console.warn(`QuikPilot targeting warning (not blocking): ${w}`);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
852
1297
|
const overlaps = findOverlappingSteps(targeting.targets);
|
|
853
|
-
|
|
854
|
-
|
|
1298
|
+
const blockingOverlaps = overlaps.filter((o) => o.severity === "identical");
|
|
1299
|
+
const softOverlaps = overlaps.filter((o) => o.severity === "similar");
|
|
1300
|
+
if (softOverlaps.length > 0) {
|
|
1301
|
+
warnings = describeOverlapWarnings(softOverlaps);
|
|
855
1302
|
for (const w of warnings) {
|
|
856
1303
|
console.warn(`QuikPilot targeting warning (not blocking): ${w}`);
|
|
857
1304
|
}
|
|
858
1305
|
}
|
|
1306
|
+
if (blockingOverlaps.length > 0) {
|
|
1307
|
+
throw new Error(
|
|
1308
|
+
`QuikPilot's generated plan gives two files identical instructions \u2014 ${describeOverlapWarnings(blockingOverlaps).join("; ")}`
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
859
1311
|
runCommand(packet.commands.setup, "Setup");
|
|
1312
|
+
const judgedByPool = buildJudgedByPool(
|
|
1313
|
+
packet.requiredTests,
|
|
1314
|
+
targeting.targets,
|
|
1315
|
+
targeting.skippedCases ?? []
|
|
1316
|
+
);
|
|
860
1317
|
const [baseline, results] = await Promise.all([
|
|
861
1318
|
captureBaselineSnapshot(packet.commands.test),
|
|
862
|
-
runCodegen(
|
|
1319
|
+
runCodegen(
|
|
1320
|
+
packet.gateway,
|
|
1321
|
+
targeting.targets,
|
|
1322
|
+
workspaceListing,
|
|
1323
|
+
packet.requiredTests,
|
|
1324
|
+
packet.guardrails.deniedPaths,
|
|
1325
|
+
judgedByPool,
|
|
1326
|
+
warnings
|
|
1327
|
+
)
|
|
863
1328
|
]);
|
|
864
1329
|
const depCheck = checkImportDependencies(
|
|
865
1330
|
results,
|
|
@@ -871,6 +1336,18 @@ async function run(env = readRunnerEnv()) {
|
|
|
871
1336
|
`QuikPilot's generated code imports packages this repository doesn't have \u2014 ${describeDependencyViolations(depCheck)}`
|
|
872
1337
|
);
|
|
873
1338
|
}
|
|
1339
|
+
const scopeCheck = checkApiScope(results, targeting.targets, packet.design?.apiChanges ?? []);
|
|
1340
|
+
if (scopeCheck.blocked) {
|
|
1341
|
+
throw new Error(
|
|
1342
|
+
`QuikPilot's generated code exceeds the approved design \u2014 ${describeScopeViolations(scopeCheck)}`
|
|
1343
|
+
);
|
|
1344
|
+
}
|
|
1345
|
+
const placementCheck = checkTestPlacement(targeting.targets, baseline.discoveredFiles);
|
|
1346
|
+
if (placementCheck.blocked) {
|
|
1347
|
+
throw new Error(
|
|
1348
|
+
`QuikPilot's generated tests are not where the test runner will find them \u2014 ${describeTestPlacementViolations(placementCheck)}`
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
874
1351
|
flushCodegenResults(results);
|
|
875
1352
|
try {
|
|
876
1353
|
runCommand(packet.commands.format, "Format");
|
|
@@ -885,6 +1362,12 @@ async function run(env = readRunnerEnv()) {
|
|
|
885
1362
|
`QuikPilot's generated code introduced test failures \u2014 ${describeRegressions(check)}`
|
|
886
1363
|
);
|
|
887
1364
|
}
|
|
1365
|
+
const coverageCheck = checkAutomationIdCoverage(targeting.targets, after.discoveredAutomationIds);
|
|
1366
|
+
if (coverageCheck.blocked) {
|
|
1367
|
+
throw new Error(
|
|
1368
|
+
`QuikPilot's generated tests were not executed \u2014 ${describeAutomationCoverageViolations(coverageCheck)}`
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
888
1371
|
}
|
|
889
1372
|
} catch (err) {
|
|
890
1373
|
hardFailure = true;
|
|
@@ -894,23 +1377,28 @@ async function run(env = readRunnerEnv()) {
|
|
|
894
1377
|
console.error("No run token \u2014 the OIDC exchange failed before QuikTrack could be reached.");
|
|
895
1378
|
return { hardFailure: true };
|
|
896
1379
|
}
|
|
897
|
-
await reportToQuikTest({
|
|
1380
|
+
const quikTestReport = await reportToQuikTest({
|
|
898
1381
|
resultsUrl: packet?.quikTest?.resultsUrl,
|
|
899
1382
|
runToken,
|
|
900
|
-
githubServerUrl:
|
|
901
|
-
githubRepository:
|
|
902
|
-
githubRunId:
|
|
1383
|
+
githubServerUrl: env2.githubServerUrl,
|
|
1384
|
+
githubRepository: env2.githubRepository,
|
|
1385
|
+
githubRunId: env2.githubRunId
|
|
903
1386
|
});
|
|
1387
|
+
const unmatchedWarning = quikTestReport ? describeUnmatchedAutomationIds(quikTestReport.unmatched) : null;
|
|
1388
|
+
if (unmatchedWarning) {
|
|
1389
|
+
warnings.push(unmatchedWarning);
|
|
1390
|
+
console.warn(`QuikPilot ingest warning (not blocking): ${unmatchedWarning}`);
|
|
1391
|
+
}
|
|
904
1392
|
if (!hardFailure && packet) {
|
|
905
1393
|
try {
|
|
906
1394
|
checkGuardrail(packet.guardrails.deniedPaths);
|
|
907
1395
|
const installationToken = await exchangeForInstallationToken(
|
|
908
|
-
|
|
1396
|
+
env2.installationTokenUrl,
|
|
909
1397
|
runToken
|
|
910
1398
|
);
|
|
911
1399
|
const result = commitAndPush(
|
|
912
1400
|
packet.branch,
|
|
913
|
-
|
|
1401
|
+
env2.runId,
|
|
914
1402
|
installationToken,
|
|
915
1403
|
targeted,
|
|
916
1404
|
packet.issueKey,
|
|
@@ -923,7 +1411,7 @@ async function run(env = readRunnerEnv()) {
|
|
|
923
1411
|
}
|
|
924
1412
|
}
|
|
925
1413
|
await reportBack({
|
|
926
|
-
callbackUrl:
|
|
1414
|
+
callbackUrl: env2.callbackUrl,
|
|
927
1415
|
runToken,
|
|
928
1416
|
status: hardFailure ? "failed" : "completed",
|
|
929
1417
|
branch,
|
|
@@ -932,8 +1420,44 @@ async function run(env = readRunnerEnv()) {
|
|
|
932
1420
|
return { hardFailure };
|
|
933
1421
|
}
|
|
934
1422
|
|
|
1423
|
+
// src/readinessCheck.ts
|
|
1424
|
+
async function runReadinessCheck(env2) {
|
|
1425
|
+
let setupOk = true;
|
|
1426
|
+
try {
|
|
1427
|
+
runCommand(env2.setupCmd || null, "Setup");
|
|
1428
|
+
} catch (err) {
|
|
1429
|
+
setupOk = false;
|
|
1430
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1431
|
+
}
|
|
1432
|
+
const testOutcome = runTestCommand(env2.testCmd);
|
|
1433
|
+
const testOk = testOutcome === "success";
|
|
1434
|
+
const junitPath = findJUnitReport();
|
|
1435
|
+
const result = {
|
|
1436
|
+
setupOk,
|
|
1437
|
+
testOk,
|
|
1438
|
+
junitFound: junitPath !== null,
|
|
1439
|
+
junitPath
|
|
1440
|
+
};
|
|
1441
|
+
try {
|
|
1442
|
+
const res = await fetch(env2.readinessCallbackUrl, {
|
|
1443
|
+
method: "POST",
|
|
1444
|
+
headers: { Authorization: `Bearer ${env2.readinessToken}`, "Content-Type": "application/json" },
|
|
1445
|
+
body: JSON.stringify(result)
|
|
1446
|
+
});
|
|
1447
|
+
if (!res.ok) {
|
|
1448
|
+
throw new Error(`Readiness callback POST failed: ${await describeQuikTrackFailure(res)}`);
|
|
1449
|
+
}
|
|
1450
|
+
} catch (err) {
|
|
1451
|
+
console.warn(
|
|
1452
|
+
`::warning::QuikPilot readiness check failed to report its result: ${err instanceof Error ? err.message : String(err)}`
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
935
1457
|
// src/index.ts
|
|
936
|
-
|
|
1458
|
+
var env = readRunnerEnv();
|
|
1459
|
+
var task = env.mode === "readiness_check" ? runReadinessCheck(env).then(() => ({ hardFailure: false })) : run(env);
|
|
1460
|
+
task.then(({ hardFailure }) => {
|
|
937
1461
|
process.exitCode = hardFailure ? 1 : 0;
|
|
938
1462
|
}).catch((err) => {
|
|
939
1463
|
console.error(err instanceof Error ? err.message : String(err));
|