@quikit/quikpilot-runner 8.2.8 → 9.0.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 +7 -0
- package/dist/index.js +391 -43
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,3 +25,10 @@ pull request into that repository (QuikTrack's "re-bootstrap" flow), never silen
|
|
|
25
25
|
Releases follow [SemVer](https://semver.org/): a major version bump means the workflow file's own
|
|
26
26
|
shape changed (inputs, permissions) and requires a new pull request regardless of this package's
|
|
27
27
|
version; minor/patch releases are runner-logic fixes safe to pick up on the next re-bootstrap.
|
|
28
|
+
|
|
29
|
+
**9.0.0 — a failed run keeps its files.** When a run fails after code generation, the runner writes the files it
|
|
30
|
+
generated, a copy of the last test report and a short `manifest.json` (run, targets, files, the reason it stopped;
|
|
31
|
+
no credentials) to `quikpilot-failure/`, and the workflow's new `Keep the files of a failed run` step uploads that folder
|
|
32
|
+
as the Actions artifact `quikpilot-failed-run` for 7 days. Nothing is added to a branch or a pull request. That step is
|
|
33
|
+
part of the workflow file, so this is a major version: a repository on an older workflow file keeps working and simply
|
|
34
|
+
does not upload, until its "update the workflow" pull request is merged.
|
package/dist/index.js
CHANGED
|
@@ -73,13 +73,14 @@ async function describeQuikTrackFailure(res) {
|
|
|
73
73
|
|
|
74
74
|
// src/callback.ts
|
|
75
75
|
async function reportBack(params) {
|
|
76
|
-
const { callbackUrl, runToken, status, branch, warnings, targeting, baselineFindings, unresolved } = params;
|
|
76
|
+
const { callbackUrl, runToken, status, branch, warnings, targeting, baselineFindings, vanishedTests, unresolved } = params;
|
|
77
77
|
const body = { status };
|
|
78
78
|
if (branch) body.branch = branch;
|
|
79
79
|
if (warnings && warnings.length > 0) body.warnings = warnings;
|
|
80
80
|
if (unresolved && unresolved.length > 0) body.unresolved = unresolved;
|
|
81
81
|
if (targeting) body.targeting = targeting;
|
|
82
82
|
if (baselineFindings) body.baselineFindings = baselineFindings;
|
|
83
|
+
if (vanishedTests) body.vanishedTests = vanishedTests;
|
|
83
84
|
const res = await fetch(callbackUrl, {
|
|
84
85
|
method: "POST",
|
|
85
86
|
headers: { Authorization: `Bearer ${runToken}`, "Content-Type": "application/json" },
|
|
@@ -111,20 +112,20 @@ function runTestCommand(command) {
|
|
|
111
112
|
}
|
|
112
113
|
function runTestCommandAsync(command) {
|
|
113
114
|
if (!command) return Promise.resolve("success");
|
|
114
|
-
return new Promise((
|
|
115
|
+
return new Promise((resolve5) => {
|
|
115
116
|
const child = (0, import_node_child_process.spawn)(command, { shell: "/bin/bash", stdio: "inherit" });
|
|
116
|
-
child.on("exit", (code) =>
|
|
117
|
-
child.on("error", () =>
|
|
117
|
+
child.on("exit", (code) => resolve5(code === 0 ? "success" : "failure"));
|
|
118
|
+
child.on("error", () => resolve5("failure"));
|
|
118
119
|
});
|
|
119
120
|
}
|
|
120
121
|
function runCommandWithTimeout(command, timeoutMs) {
|
|
121
|
-
return new Promise((
|
|
122
|
+
return new Promise((resolve5) => {
|
|
122
123
|
let settled = false;
|
|
123
124
|
const finish = (outcome) => {
|
|
124
125
|
if (settled) return;
|
|
125
126
|
settled = true;
|
|
126
127
|
clearTimeout(timer);
|
|
127
|
-
|
|
128
|
+
resolve5(outcome);
|
|
128
129
|
};
|
|
129
130
|
const child = (0, import_node_child_process.spawn)(command, { shell: "/bin/bash", stdio: "inherit", detached: true });
|
|
130
131
|
const timer = setTimeout(() => {
|
|
@@ -251,6 +252,96 @@ function findDeniedContextPath(contextPaths, patterns) {
|
|
|
251
252
|
return null;
|
|
252
253
|
}
|
|
253
254
|
|
|
255
|
+
// src/editsApply.ts
|
|
256
|
+
function detectLineEnding(text) {
|
|
257
|
+
const crlf = text.split("\r\n").length - 1;
|
|
258
|
+
const bareLf = text.split("\n").length - 1 - crlf;
|
|
259
|
+
if (crlf > 0 && bareLf > 0) return "mixed";
|
|
260
|
+
return crlf > 0 ? "CRLF" : "LF";
|
|
261
|
+
}
|
|
262
|
+
function toLf(text) {
|
|
263
|
+
return text.replace(/\r\n/g, "\n");
|
|
264
|
+
}
|
|
265
|
+
function fromLf(text, ending) {
|
|
266
|
+
return ending === "CRLF" ? text.replace(/\n/g, "\r\n") : text;
|
|
267
|
+
}
|
|
268
|
+
function occurrences(haystack, needle) {
|
|
269
|
+
const found = [];
|
|
270
|
+
let index = haystack.indexOf(needle);
|
|
271
|
+
while (index !== -1) {
|
|
272
|
+
found.push(index);
|
|
273
|
+
index = haystack.indexOf(needle, index + 1);
|
|
274
|
+
}
|
|
275
|
+
return found;
|
|
276
|
+
}
|
|
277
|
+
function applyEdits(originalLf, edits) {
|
|
278
|
+
if (!Array.isArray(edits) || edits.length === 0) {
|
|
279
|
+
return {
|
|
280
|
+
ok: false,
|
|
281
|
+
editCount: 0,
|
|
282
|
+
refusals: [{ kind: "no_edits", edits: [], matchCount: null, message: "the response carried no edits" }]
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
const refusals = [];
|
|
286
|
+
const ranges = [];
|
|
287
|
+
edits.forEach((edit, index) => {
|
|
288
|
+
const number = index + 1;
|
|
289
|
+
const record = edit !== null && typeof edit === "object" ? edit : null;
|
|
290
|
+
const find = record?.find;
|
|
291
|
+
const replace = record?.replace;
|
|
292
|
+
if (typeof find !== "string" || typeof replace !== "string") {
|
|
293
|
+
refusals.push({
|
|
294
|
+
kind: "empty",
|
|
295
|
+
edits: [number],
|
|
296
|
+
matchCount: null,
|
|
297
|
+
message: `edit ${number} is missing a string find or replace`
|
|
298
|
+
});
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (find === "") {
|
|
302
|
+
refusals.push({ kind: "empty", edits: [number], matchCount: null, message: `edit ${number} has an empty anchor` });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const starts = occurrences(originalLf, find);
|
|
306
|
+
if (starts.length === 0) {
|
|
307
|
+
refusals.push({ kind: "no_match", edits: [number], matchCount: 0, message: `edit ${number}: the anchor matched nothing` });
|
|
308
|
+
} else if (starts.length > 1) {
|
|
309
|
+
refusals.push({
|
|
310
|
+
kind: "ambiguous",
|
|
311
|
+
edits: [number],
|
|
312
|
+
matchCount: starts.length,
|
|
313
|
+
message: `edit ${number}: the anchor matched ${starts.length} times`
|
|
314
|
+
});
|
|
315
|
+
} else {
|
|
316
|
+
ranges.push({ start: starts[0], end: starts[0] + find.length, number, replace });
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
for (let i = 0; i < ranges.length; i++) {
|
|
320
|
+
for (let j = i + 1; j < ranges.length; j++) {
|
|
321
|
+
const a = ranges[i];
|
|
322
|
+
const b = ranges[j];
|
|
323
|
+
if (a.start < b.end && b.start < a.end) {
|
|
324
|
+
const [first, second] = a.number < b.number ? [a.number, b.number] : [b.number, a.number];
|
|
325
|
+
refusals.push({
|
|
326
|
+
kind: "overlap",
|
|
327
|
+
edits: [first, second],
|
|
328
|
+
matchCount: null,
|
|
329
|
+
message: `edits ${first} and ${second} overlap`
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (refusals.length > 0) return { ok: false, refusals, editCount: edits.length };
|
|
335
|
+
const pieces = [];
|
|
336
|
+
let cursor = 0;
|
|
337
|
+
for (const range of [...ranges].sort((x, y) => x.start - y.start)) {
|
|
338
|
+
pieces.push(originalLf.slice(cursor, range.start), range.replace);
|
|
339
|
+
cursor = range.end;
|
|
340
|
+
}
|
|
341
|
+
pieces.push(originalLf.slice(cursor));
|
|
342
|
+
return { ok: true, content: pieces.join(""), editCount: edits.length };
|
|
343
|
+
}
|
|
344
|
+
|
|
254
345
|
// src/runtimeReplyError.ts
|
|
255
346
|
var RuntimeReplyError = class extends Error {
|
|
256
347
|
summary;
|
|
@@ -413,6 +504,58 @@ function looksLikeTruncatedRewrite(target, currentContent, newContent) {
|
|
|
413
504
|
return null;
|
|
414
505
|
}
|
|
415
506
|
var CONTRACT_VERSION = "test-targets-1";
|
|
507
|
+
var CONTRACT_VERSION_EDITS = "edits-1";
|
|
508
|
+
var ANCHOR_REFUSED_CODE = "CODEGEN_ANCHOR_REFUSED";
|
|
509
|
+
function summariseLocalRefusals(refusals) {
|
|
510
|
+
const parts = [];
|
|
511
|
+
const matchCounts = [];
|
|
512
|
+
for (const r of refusals) {
|
|
513
|
+
if (r.kind === "ambiguous") {
|
|
514
|
+
parts.push(`an anchor matched ${r.matchCount} times`);
|
|
515
|
+
if (r.matchCount !== null) matchCounts.push(r.matchCount);
|
|
516
|
+
} else if (r.kind === "no_match") {
|
|
517
|
+
parts.push("an anchor matched nothing");
|
|
518
|
+
} else if (r.kind === "overlap") {
|
|
519
|
+
parts.push(`edits ${r.edits.join(" and ")} overlap`);
|
|
520
|
+
} else if (r.kind === "no_edits") {
|
|
521
|
+
parts.push("the reply had no edits");
|
|
522
|
+
} else {
|
|
523
|
+
parts.push("an edit was missing its anchor or replacement");
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return { text: [...new Set(parts)].join("; "), matchCounts };
|
|
527
|
+
}
|
|
528
|
+
function summariseRuntimeRefusal(error) {
|
|
529
|
+
const text = typeof error === "string" ? error : "";
|
|
530
|
+
const matchCounts = [...text.matchAll(/anchor matched (\d+) times/g)].map((m) => Number(m[1]));
|
|
531
|
+
const parts = matchCounts.map((n) => `an anchor matched ${n} times`);
|
|
532
|
+
if (/no match/i.test(text)) parts.push("an anchor matched nothing");
|
|
533
|
+
if (/overlap/i.test(text)) parts.push("edits overlapped");
|
|
534
|
+
if (/empty anchor|missing a string/i.test(text)) parts.push("an edit was missing its anchor or replacement");
|
|
535
|
+
return { text: parts.length > 0 ? [...new Set(parts)].join("; ") : "the runtime could not place the edits", matchCounts };
|
|
536
|
+
}
|
|
537
|
+
function throwOnTerminalRefusal(envelope, targetPath) {
|
|
538
|
+
if (typeof envelope?.errorCode === "string" && NOT_RETRIED_CODES.has(envelope.errorCode)) {
|
|
539
|
+
const detail = envelope.error ? `: ${envelope.error}` : "";
|
|
540
|
+
throw new RuntimeReplyError(
|
|
541
|
+
`Codegen refused ${targetPath}: ${envelope.errorCode} (not retried)${detail}`,
|
|
542
|
+
`Codegen refused ${targetPath}: ${envelope.errorCode} (not retried)`
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
if (envelope?.errorCode === JUDGED_BY_TOO_LARGE_CODE) {
|
|
546
|
+
const detail = envelope.error ? `: ${envelope.error}` : "";
|
|
547
|
+
throw new RuntimeReplyError(
|
|
548
|
+
`Codegen refused ${targetPath}: ${envelope.errorCode} (after narrowing)${detail}`,
|
|
549
|
+
`Codegen refused ${targetPath}: ${envelope.errorCode} (after narrowing)`
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
function codegenRefusedError(targetPath, body) {
|
|
554
|
+
return new RuntimeReplyError(
|
|
555
|
+
`Codegen refused ${targetPath}${body.refusalReason ? `: ${body.refusalReason}` : ""}`,
|
|
556
|
+
`Codegen refused ${targetPath}`
|
|
557
|
+
);
|
|
558
|
+
}
|
|
416
559
|
function buildJudgedByPool(requiredTests, targets, skippedCases) {
|
|
417
560
|
const claimedIds = /* @__PURE__ */ new Set();
|
|
418
561
|
for (const t of targets) {
|
|
@@ -444,6 +587,8 @@ async function runCodegen(gateway, targets, workspaceListing, requiredTests, den
|
|
|
444
587
|
for (const target of targets) {
|
|
445
588
|
const isNewFile = target.kind === "NEW_FILE";
|
|
446
589
|
const currentContent = isNewFile ? "" : (0, import_node_fs.readFileSync)(target.path, "utf8");
|
|
590
|
+
const lineEnding = isNewFile ? "LF" : detectLineEnding(currentContent);
|
|
591
|
+
const wantsEdits = !isNewFile && lineEnding !== "mixed";
|
|
447
592
|
const contextPaths = target.contextPaths ?? [];
|
|
448
593
|
const deniedContext = findDeniedContextPath(contextPaths, deniedPaths);
|
|
449
594
|
if (deniedContext) {
|
|
@@ -458,7 +603,7 @@ async function runCodegen(gateway, targets, workspaceListing, requiredTests, den
|
|
|
458
603
|
);
|
|
459
604
|
}
|
|
460
605
|
}
|
|
461
|
-
const namedContents = contextPaths.map((
|
|
606
|
+
const namedContents = contextPaths.map((path2) => ({ path: path2, content: (0, import_node_fs.readFileSync)(path2, "utf8") }));
|
|
462
607
|
const added = collectRunContext(target, targets, new Set(contextPaths), deniedPaths);
|
|
463
608
|
const contextContents = [...namedContents, ...added.entries];
|
|
464
609
|
for (const s of added.skipped) {
|
|
@@ -474,12 +619,13 @@ async function runCodegen(gateway, targets, workspaceListing, requiredTests, den
|
|
|
474
619
|
contextLog?.push(contextRecord);
|
|
475
620
|
const contextData = {
|
|
476
621
|
targetPath: target.path,
|
|
477
|
-
|
|
622
|
+
// Every anchor and replacement is LF, always (Doc 62 §2), so the file is sent as LF.
|
|
623
|
+
currentContent: wantsEdits ? toLf(currentContent) : currentContent,
|
|
478
624
|
planSteps: target.planStepsImplemented,
|
|
479
625
|
kind: target.kind,
|
|
480
626
|
contextContents,
|
|
481
627
|
// QUIKTR-1083 (§15.6) — required on every call on the versioned path.
|
|
482
|
-
contractVersion: CONTRACT_VERSION
|
|
628
|
+
contractVersion: wantsEdits ? CONTRACT_VERSION_EDITS : CONTRACT_VERSION
|
|
483
629
|
};
|
|
484
630
|
if (isNewFile) {
|
|
485
631
|
contextData.workspaceListing = workspaceListing;
|
|
@@ -541,29 +687,91 @@ async function runCodegen(gateway, targets, workspaceListing, requiredTests, den
|
|
|
541
687
|
envelope = await res.json();
|
|
542
688
|
checkHardFailure(envelope, "pilot.codegen", target.path);
|
|
543
689
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
690
|
+
throwOnTerminalRefusal(envelope, target.path);
|
|
691
|
+
let newContent = null;
|
|
692
|
+
let failedEdits = null;
|
|
693
|
+
let editCount;
|
|
694
|
+
let path = "whole_file";
|
|
695
|
+
let reason = isNewFile ? "new_file" : wantsEdits ? void 0 : "mixed_line_endings";
|
|
696
|
+
if (wantsEdits && envelope?.errorCode === ANCHOR_REFUSED_CODE) {
|
|
697
|
+
failedEdits = { reason: "anchor_refused", summary: summariseRuntimeRefusal(envelope.error) };
|
|
698
|
+
} else {
|
|
699
|
+
const body = unwrapExecute(envelope, "pilot.codegen");
|
|
700
|
+
if (wantsEdits) {
|
|
701
|
+
if (body.outcome === "REFUSED") throw codegenRefusedError(target.path, body);
|
|
702
|
+
if (body.outcome !== "GENERATED" || typeof body.newContent === "string") {
|
|
703
|
+
throw new RuntimeReplyError(
|
|
704
|
+
`Codegen reply for ${target.path} was not in the edits-1 shape and was not applied`,
|
|
705
|
+
`Codegen reply for ${target.path} was not in the edits-1 shape`
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
const applied = applyEdits(toLf(currentContent), body.edits);
|
|
709
|
+
if (applied.ok) {
|
|
710
|
+
newContent = fromLf(applied.content, lineEnding);
|
|
711
|
+
path = "edits";
|
|
712
|
+
editCount = applied.editCount;
|
|
713
|
+
} else if (applied.refusals.some((r) => r.kind === "no_edits")) {
|
|
714
|
+
throw new RuntimeReplyError(
|
|
715
|
+
`Codegen reply for ${target.path} carried no edits (not retried)`,
|
|
716
|
+
`Codegen reply for ${target.path} carried no edits`
|
|
717
|
+
);
|
|
718
|
+
} else {
|
|
719
|
+
failedEdits = { reason: "local_refusal", summary: summariseLocalRefusals(applied.refusals) };
|
|
720
|
+
}
|
|
721
|
+
} else {
|
|
722
|
+
if (body.outcome !== "GENERATED" || typeof body.newContent !== "string") {
|
|
723
|
+
throw codegenRefusedError(target.path, body);
|
|
724
|
+
}
|
|
725
|
+
newContent = body.newContent;
|
|
726
|
+
}
|
|
550
727
|
}
|
|
551
|
-
if (
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
`Codegen refused ${target.path}: ${envelope.errorCode} (after narrowing)${detail}`,
|
|
555
|
-
`Codegen refused ${target.path}: ${envelope.errorCode} (after narrowing)`
|
|
728
|
+
if (failedEdits) {
|
|
729
|
+
warnings.push(
|
|
730
|
+
`edits for ${target.path} could not be placed (${failedEdits.summary.text}); asked again for the whole file`
|
|
556
731
|
);
|
|
732
|
+
contextData.currentContent = currentContent;
|
|
733
|
+
contextData.contractVersion = CONTRACT_VERSION;
|
|
734
|
+
try {
|
|
735
|
+
res = await callCodegen();
|
|
736
|
+
if (!res.ok) {
|
|
737
|
+
throw new Error(`Codegen call failed for ${target.path}: ${res.status} ${res.statusText}`);
|
|
738
|
+
}
|
|
739
|
+
envelope = await res.json();
|
|
740
|
+
checkHardFailure(envelope, "pilot.codegen", target.path);
|
|
741
|
+
throwOnTerminalRefusal(envelope, target.path);
|
|
742
|
+
const whole = unwrapExecute(envelope, "pilot.codegen");
|
|
743
|
+
if (whole.outcome !== "GENERATED" || typeof whole.newContent !== "string") {
|
|
744
|
+
throw codegenRefusedError(target.path, whole);
|
|
745
|
+
}
|
|
746
|
+
newContent = whole.newContent;
|
|
747
|
+
} catch (error) {
|
|
748
|
+
if (error instanceof RuntimeReplyError) {
|
|
749
|
+
throw new RuntimeReplyError(
|
|
750
|
+
`${error.message} (this was the whole-file request made after the edits for ${target.path} were refused: ${failedEdits.summary.text})`,
|
|
751
|
+
`${error.summary} (the whole-file request, made after the edits were refused)`
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
throw error;
|
|
755
|
+
}
|
|
756
|
+
path = "fallback";
|
|
757
|
+
reason = failedEdits.reason;
|
|
557
758
|
}
|
|
558
|
-
|
|
559
|
-
if (
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
759
|
+
if (newContent === null) throw new Error(`Codegen produced no content for ${target.path}`);
|
|
760
|
+
if (!isNewFile) {
|
|
761
|
+
console.log(
|
|
762
|
+
JSON.stringify({
|
|
763
|
+
event: "codegen.edits_path",
|
|
764
|
+
targetPath: target.path,
|
|
765
|
+
path,
|
|
766
|
+
...reason ? { reason } : {},
|
|
767
|
+
...editCount !== void 0 ? { editCount } : {},
|
|
768
|
+
...failedEdits && failedEdits.summary.matchCounts.length > 0 ? { matchCounts: failedEdits.summary.matchCounts } : {},
|
|
769
|
+
lineEnding
|
|
770
|
+
})
|
|
563
771
|
);
|
|
564
772
|
}
|
|
565
773
|
if (!isNewFile) {
|
|
566
|
-
const suspectReason = looksLikeTruncatedRewrite(target, currentContent,
|
|
774
|
+
const suspectReason = looksLikeTruncatedRewrite(target, currentContent, newContent);
|
|
567
775
|
if (suspectReason) {
|
|
568
776
|
throw new Error(
|
|
569
777
|
`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.`
|
|
@@ -576,11 +784,11 @@ async function runCodegen(gateway, targets, workspaceListing, requiredTests, den
|
|
|
576
784
|
targetPath: target.path,
|
|
577
785
|
kind: target.kind,
|
|
578
786
|
inputLength: currentContent.length,
|
|
579
|
-
outputLength:
|
|
580
|
-
removedLines: isNewFile ? null : countRemovedLines(currentContent,
|
|
787
|
+
outputLength: newContent.length,
|
|
788
|
+
removedLines: isNewFile ? null : countRemovedLines(currentContent, newContent)
|
|
581
789
|
})
|
|
582
790
|
);
|
|
583
|
-
results.push({ targetPath: target.path, newContent
|
|
791
|
+
results.push({ targetPath: target.path, newContent });
|
|
584
792
|
}
|
|
585
793
|
return results;
|
|
586
794
|
}
|
|
@@ -990,6 +1198,14 @@ function firstXmlIn(dir, namePattern) {
|
|
|
990
1198
|
const match = entries.find((e) => namePattern.test(e));
|
|
991
1199
|
return match ? (0, import_node_path3.join)(dir, match) : null;
|
|
992
1200
|
}
|
|
1201
|
+
function readTestReportBytes(cwd = process.cwd()) {
|
|
1202
|
+
try {
|
|
1203
|
+
const report = findJUnitReport(cwd);
|
|
1204
|
+
return report ? (0, import_node_fs4.readFileSync)(report) : null;
|
|
1205
|
+
} catch {
|
|
1206
|
+
return null;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
993
1209
|
function findJUnitReport(cwd = process.cwd()) {
|
|
994
1210
|
return walkForJUnitByName(cwd) ?? firstXmlIn((0, import_node_path3.join)(cwd, "test-results"), /\.xml$/) ?? firstXmlIn((0, import_node_path3.join)(cwd, "reports"), /^junit.*\.xml$/) ?? firstXmlIn((0, import_node_path3.join)(cwd, "target", "surefire-reports"), /\.xml$/);
|
|
995
1211
|
}
|
|
@@ -1015,8 +1231,8 @@ async function reportToQuikTest(params) {
|
|
|
1015
1231
|
try {
|
|
1016
1232
|
console.log(`reporting ${report}`);
|
|
1017
1233
|
const ciUrl = buildCiUrl(githubServerUrl, githubRepository, githubRunId);
|
|
1018
|
-
const { readFileSync:
|
|
1019
|
-
const body =
|
|
1234
|
+
const { readFileSync: readFileSync7 } = await import("node:fs");
|
|
1235
|
+
const body = readFileSync7(report);
|
|
1020
1236
|
let url = `${resultsUrl}?ciUrl=${encodeURIComponent(ciUrl)}&automationIdFormat=bracket`;
|
|
1021
1237
|
if (generatedFiles) {
|
|
1022
1238
|
url += `&generatedFiles=${encodeURIComponent(generatedFiles.join(","))}`;
|
|
@@ -1106,9 +1322,11 @@ function parseJUnitOutcomes(xml) {
|
|
|
1106
1322
|
TESTCASE_RE.lastIndex = 0;
|
|
1107
1323
|
while (match = TESTCASE_RE.exec(xml)) {
|
|
1108
1324
|
const [, attrs, body = ""] = match;
|
|
1109
|
-
const
|
|
1110
|
-
if (!
|
|
1111
|
-
const
|
|
1325
|
+
const rawName = NAME_ATTR_RE.exec(attrs)?.[1];
|
|
1326
|
+
if (!rawName) continue;
|
|
1327
|
+
const name = unescapeXmlEntities(rawName);
|
|
1328
|
+
const rawClassname = CLASSNAME_ATTR_RE.exec(attrs)?.[1];
|
|
1329
|
+
const classname = rawClassname === void 0 ? void 0 : unescapeXmlEntities(rawClassname);
|
|
1112
1330
|
const id = classname ? `${classname}::${name}` : name;
|
|
1113
1331
|
const failed = FAILURE_CHILD_RE.test(body);
|
|
1114
1332
|
outcomes.set(id, !failed);
|
|
@@ -1389,6 +1607,10 @@ async function runBaselineCheck(params) {
|
|
|
1389
1607
|
}
|
|
1390
1608
|
}
|
|
1391
1609
|
|
|
1610
|
+
// src/failureArtifact.ts
|
|
1611
|
+
var import_node_fs7 = require("node:fs");
|
|
1612
|
+
var import_node_path5 = require("node:path");
|
|
1613
|
+
|
|
1392
1614
|
// src/failureReason.ts
|
|
1393
1615
|
var MAX_FAILURE_REASON = 1e3;
|
|
1394
1616
|
var MIN_SECRET_LENGTH = 8;
|
|
@@ -1409,18 +1631,86 @@ function failureSummary(err) {
|
|
|
1409
1631
|
if (err instanceof RuntimeReplyError) return err.summary;
|
|
1410
1632
|
return err instanceof Error ? err.message : String(err);
|
|
1411
1633
|
}
|
|
1412
|
-
function
|
|
1634
|
+
function scrubSecrets(message, secrets = []) {
|
|
1413
1635
|
let text = message;
|
|
1414
1636
|
for (const secret of secrets) {
|
|
1415
1637
|
if (secret.length >= MIN_SECRET_LENGTH) text = text.split(secret).join("***");
|
|
1416
1638
|
}
|
|
1417
1639
|
for (const pattern of CREDENTIAL_PATTERNS) text = text.replace(pattern, "***");
|
|
1640
|
+
return text;
|
|
1641
|
+
}
|
|
1642
|
+
function describeFailureReason(message, secrets = []) {
|
|
1643
|
+
let text = scrubSecrets(message, secrets);
|
|
1418
1644
|
text = text.replace(/\s*\n\s*/g, " | ").trim();
|
|
1419
1645
|
return text.length > MAX_FAILURE_REASON ? `${text.slice(0, MAX_FAILURE_REASON)}\u2026` : text;
|
|
1420
1646
|
}
|
|
1421
1647
|
|
|
1648
|
+
// src/failureArtifact.ts
|
|
1649
|
+
var FAILURE_ARTIFACT_DIR = "quikpilot-failure";
|
|
1650
|
+
var REPORT_COPY = "final-test-report.xml";
|
|
1651
|
+
var MAX_REPORT_BYTES = 5 * 1024 * 1024;
|
|
1652
|
+
var MAX_FILES = 200;
|
|
1653
|
+
var RUNNER_VERSION = true ? "9.0.0" : "dev";
|
|
1654
|
+
function safeDestination(base, repoPath) {
|
|
1655
|
+
if (!repoPath || (0, import_node_path5.isAbsolute)(repoPath)) return null;
|
|
1656
|
+
const destination = (0, import_node_path5.resolve)(base, repoPath);
|
|
1657
|
+
const rel = (0, import_node_path5.relative)(base, destination);
|
|
1658
|
+
if (!rel || rel.startsWith("..") || (0, import_node_path5.isAbsolute)(rel)) return null;
|
|
1659
|
+
return destination;
|
|
1660
|
+
}
|
|
1661
|
+
function writeFailureArtifact(input) {
|
|
1662
|
+
if (input.results.length === 0) return { written: false };
|
|
1663
|
+
try {
|
|
1664
|
+
const base = (0, import_node_path5.resolve)(input.cwd, FAILURE_ARTIFACT_DIR);
|
|
1665
|
+
(0, import_node_fs7.rmSync)(base, { recursive: true, force: true });
|
|
1666
|
+
(0, import_node_fs7.mkdirSync)(base, { recursive: true });
|
|
1667
|
+
const files = [];
|
|
1668
|
+
const skipped = [];
|
|
1669
|
+
for (const result of input.results) {
|
|
1670
|
+
const destination = files.length < MAX_FILES ? safeDestination(base, result.targetPath) : null;
|
|
1671
|
+
if (!destination) {
|
|
1672
|
+
skipped.push(result.targetPath);
|
|
1673
|
+
continue;
|
|
1674
|
+
}
|
|
1675
|
+
(0, import_node_fs7.mkdirSync)((0, import_node_path5.dirname)(destination), { recursive: true });
|
|
1676
|
+
(0, import_node_fs7.writeFileSync)(destination, result.newContent);
|
|
1677
|
+
files.push({ path: result.targetPath, bytes: Buffer.byteLength(result.newContent) });
|
|
1678
|
+
}
|
|
1679
|
+
let testReport = "none";
|
|
1680
|
+
if (input.testReport) {
|
|
1681
|
+
if (input.testReport.byteLength > MAX_REPORT_BYTES) {
|
|
1682
|
+
testReport = "too-large";
|
|
1683
|
+
} else {
|
|
1684
|
+
(0, import_node_fs7.writeFileSync)((0, import_node_path5.join)(base, REPORT_COPY), input.testReport);
|
|
1685
|
+
testReport = "included";
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
const manifest = {
|
|
1689
|
+
runId: input.runId,
|
|
1690
|
+
issueKey: input.issueKey,
|
|
1691
|
+
runnerVersion: RUNNER_VERSION,
|
|
1692
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1693
|
+
targets: input.targets.map((t) => ({
|
|
1694
|
+
path: t.path,
|
|
1695
|
+
kind: t.kind,
|
|
1696
|
+
...t.automationIds && t.automationIds.length > 0 ? { automationIds: t.automationIds } : {}
|
|
1697
|
+
})),
|
|
1698
|
+
files,
|
|
1699
|
+
skipped,
|
|
1700
|
+
testReport,
|
|
1701
|
+
failureReasons: input.failureReasons
|
|
1702
|
+
};
|
|
1703
|
+
const text = JSON.stringify(manifest, (_key, value) => typeof value === "string" ? scrubSecrets(value, input.secrets) : value, 2);
|
|
1704
|
+
(0, import_node_fs7.writeFileSync)((0, import_node_path5.join)(base, "manifest.json"), text);
|
|
1705
|
+
return { written: (0, import_node_fs7.existsSync)((0, import_node_path5.join)(base, "manifest.json")) };
|
|
1706
|
+
} catch (err) {
|
|
1707
|
+
console.warn(`::warning::Could not keep the failed run's files: ${err instanceof Error ? err.message : String(err)}`);
|
|
1708
|
+
return { written: false };
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1422
1712
|
// src/scopeCheck.ts
|
|
1423
|
-
var
|
|
1713
|
+
var import_node_fs8 = require("node:fs");
|
|
1424
1714
|
var ROUTE_METHOD_RE = /\b(GET|POST|PUT|PATCH|DELETE)\b/i;
|
|
1425
1715
|
var ROUTE_PATH_RE = /\/[A-Za-z0-9_\-{}[\]]+(?:\/[A-Za-z0-9_\-:{}[\]]+)+/;
|
|
1426
1716
|
var ROUTE_EXPORT_RE = /\bexport\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\s*\(/;
|
|
@@ -1477,7 +1767,7 @@ function checkApiScope(results, targets, apiChanges) {
|
|
|
1477
1767
|
} else {
|
|
1478
1768
|
let currentContent;
|
|
1479
1769
|
try {
|
|
1480
|
-
currentContent = (0,
|
|
1770
|
+
currentContent = (0, import_node_fs8.readFileSync)(targetPath, "utf8");
|
|
1481
1771
|
} catch {
|
|
1482
1772
|
currentContent = "";
|
|
1483
1773
|
}
|
|
@@ -1496,7 +1786,7 @@ function describeScopeViolations(check) {
|
|
|
1496
1786
|
}
|
|
1497
1787
|
|
|
1498
1788
|
// src/targeting.ts
|
|
1499
|
-
var
|
|
1789
|
+
var import_node_path6 = require("node:path");
|
|
1500
1790
|
var CONTRACT_VERSION2 = "test-targets-1";
|
|
1501
1791
|
function listWorkspaceFiles(workspaceRoot) {
|
|
1502
1792
|
return listFiles(workspaceRoot ?? ".");
|
|
@@ -1525,9 +1815,9 @@ function validateTarget(target, workspaceRoot, workspaceListing, deniedPaths) {
|
|
|
1525
1815
|
}
|
|
1526
1816
|
return;
|
|
1527
1817
|
}
|
|
1528
|
-
const root = (0,
|
|
1529
|
-
const rel = (0,
|
|
1530
|
-
if (rel.startsWith("..") || (0,
|
|
1818
|
+
const root = (0, import_node_path6.resolve)(workspaceRoot ?? ".");
|
|
1819
|
+
const rel = (0, import_node_path6.relative)(root, (0, import_node_path6.resolve)(target.path));
|
|
1820
|
+
if (rel.startsWith("..") || (0, import_node_path6.isAbsolute)(rel)) {
|
|
1531
1821
|
throw new Error(
|
|
1532
1822
|
`pilot.targeting named a NEW_FILE path that escapes the workspace root: ${target.path}`
|
|
1533
1823
|
);
|
|
@@ -1582,10 +1872,19 @@ async function runTargeting(gateway, design, workspaceRoot, workspaceListing, re
|
|
|
1582
1872
|
for (const target of body.targets) {
|
|
1583
1873
|
validateTarget(target, workspaceRoot, listing, deniedPaths);
|
|
1584
1874
|
}
|
|
1875
|
+
const correctedKinds = [];
|
|
1876
|
+
const targets = body.targets.map((target) => {
|
|
1877
|
+
if (target.kind === "NEW_FILE" && listing.has(target.path)) {
|
|
1878
|
+
correctedKinds.push(target.path);
|
|
1879
|
+
return { ...target, kind: "EXISTING" };
|
|
1880
|
+
}
|
|
1881
|
+
return target;
|
|
1882
|
+
});
|
|
1585
1883
|
return {
|
|
1586
1884
|
outcome: "TARGETED",
|
|
1587
|
-
targets
|
|
1588
|
-
skippedCases: body.skippedCases ?? []
|
|
1885
|
+
targets,
|
|
1886
|
+
skippedCases: body.skippedCases ?? [],
|
|
1887
|
+
...correctedKinds.length > 0 ? { correctedKinds } : {}
|
|
1589
1888
|
};
|
|
1590
1889
|
}
|
|
1591
1890
|
|
|
@@ -1748,6 +2047,19 @@ async function exchangeForRunToken(tokenUrl, oidcToken) {
|
|
|
1748
2047
|
return body.data.runToken;
|
|
1749
2048
|
}
|
|
1750
2049
|
|
|
2050
|
+
// src/vanishedTests.ts
|
|
2051
|
+
var MAX_VANISHED_NAMES = 50;
|
|
2052
|
+
function findVanishedTests(baseline, after) {
|
|
2053
|
+
if (!baseline.results || !after.results) return null;
|
|
2054
|
+
const missing = [...baseline.results.keys()].filter((id) => !after.results.has(id));
|
|
2055
|
+
if (missing.length === 0) return null;
|
|
2056
|
+
return { names: missing.slice(0, MAX_VANISHED_NAMES), total: missing.length };
|
|
2057
|
+
}
|
|
2058
|
+
function describeVanishedTests(vanished) {
|
|
2059
|
+
const noun = vanished.total === 1 ? "1 test" : `${vanished.total} tests`;
|
|
2060
|
+
return `${noun} that ran before this change ${vanished.total === 1 ? "is" : "are"} missing from the final test report; the pull request lists ${vanished.total === 1 ? "it" : "them"}.`;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
1751
2063
|
// src/run.ts
|
|
1752
2064
|
async function run(env2) {
|
|
1753
2065
|
let hardFailure = false;
|
|
@@ -1764,8 +2076,11 @@ async function run(env2) {
|
|
|
1764
2076
|
};
|
|
1765
2077
|
let targeted = false;
|
|
1766
2078
|
let generatedFilePaths = [];
|
|
2079
|
+
let generatedResults = [];
|
|
2080
|
+
let finalTestReport = null;
|
|
1767
2081
|
let targetingResult;
|
|
1768
2082
|
let baselineFindings;
|
|
2083
|
+
let vanishedTests;
|
|
1769
2084
|
const contextLog = [];
|
|
1770
2085
|
try {
|
|
1771
2086
|
runToken = await exchangeForRunToken(env2.tokenUrl, await fetchGithubOidcToken());
|
|
@@ -1784,6 +2099,14 @@ async function run(env2) {
|
|
|
1784
2099
|
outOfScope
|
|
1785
2100
|
);
|
|
1786
2101
|
targetingResult = targeting;
|
|
2102
|
+
if (targeting.outcome === "TARGETED") {
|
|
2103
|
+
for (const path of targeting.correctedKinds ?? []) {
|
|
2104
|
+
const w = `QuikPilot named ${path} as a new file, but it already exists in the repository. It was treated as an existing file so its current content was kept in view.`;
|
|
2105
|
+
warnings.push(w);
|
|
2106
|
+
console.warn(`QuikPilot targeting warning (not blocking): ${w}`);
|
|
2107
|
+
console.log(JSON.stringify({ event: "targeting.kind_corrected", path }));
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
1787
2110
|
if (targeting.outcome === "NO_TARGET") {
|
|
1788
2111
|
writePhase0Marker(env2.runId);
|
|
1789
2112
|
runCommand(packet.commands.setup, "Setup");
|
|
@@ -1833,6 +2156,7 @@ async function run(env2) {
|
|
|
1833
2156
|
outOfScope
|
|
1834
2157
|
)
|
|
1835
2158
|
]);
|
|
2159
|
+
generatedResults = results;
|
|
1836
2160
|
const depCheck = checkImportDependencies(
|
|
1837
2161
|
results,
|
|
1838
2162
|
readDeclaredDependencies(results, packet.workspaceRoot),
|
|
@@ -1878,6 +2202,13 @@ async function run(env2) {
|
|
|
1878
2202
|
}
|
|
1879
2203
|
runCommand(packet.commands.lint, "Lint");
|
|
1880
2204
|
const after = captureTestSnapshot(packet.commands.test);
|
|
2205
|
+
finalTestReport = readTestReportBytes();
|
|
2206
|
+
vanishedTests = findVanishedTests(baseline, after) ?? void 0;
|
|
2207
|
+
if (vanishedTests) {
|
|
2208
|
+
const w = describeVanishedTests(vanishedTests);
|
|
2209
|
+
warnings.push(w);
|
|
2210
|
+
console.warn(`QuikPilot test warning (not blocking): ${w}`);
|
|
2211
|
+
}
|
|
1881
2212
|
const check = checkForRegressions(baseline, after);
|
|
1882
2213
|
if (check.blocked) {
|
|
1883
2214
|
throw new Error(
|
|
@@ -1933,6 +2264,22 @@ async function run(env2) {
|
|
|
1933
2264
|
recordFailure(err);
|
|
1934
2265
|
}
|
|
1935
2266
|
}
|
|
2267
|
+
if (hardFailure && generatedResults.length > 0) {
|
|
2268
|
+
try {
|
|
2269
|
+
writeFailureArtifact({
|
|
2270
|
+
cwd: process.cwd(),
|
|
2271
|
+
runId: env2.runId,
|
|
2272
|
+
issueKey: packet?.issueKey ?? null,
|
|
2273
|
+
targets: targetingResult?.outcome === "TARGETED" ? targetingResult.targets : [],
|
|
2274
|
+
results: generatedResults,
|
|
2275
|
+
testReport: finalTestReport,
|
|
2276
|
+
failureReasons,
|
|
2277
|
+
secrets: runToken ? [runToken] : []
|
|
2278
|
+
});
|
|
2279
|
+
} catch (err) {
|
|
2280
|
+
console.warn(`::warning::Could not keep the failed run's files: ${err instanceof Error ? err.message : String(err)}`);
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
1936
2283
|
await reportBack({
|
|
1937
2284
|
callbackUrl: env2.callbackUrl,
|
|
1938
2285
|
runToken,
|
|
@@ -1942,6 +2289,7 @@ async function run(env2) {
|
|
|
1942
2289
|
// Omitted when targeting itself never produced a result (nothing exists to record).
|
|
1943
2290
|
targeting: targetingResult ? buildTargetingRecord(targetingResult, contextLog) : void 0,
|
|
1944
2291
|
baselineFindings,
|
|
2292
|
+
vanishedTests,
|
|
1945
2293
|
unresolved: failureReasons.length > 0 ? failureReasons : void 0
|
|
1946
2294
|
});
|
|
1947
2295
|
return { hardFailure };
|