@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 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((resolve4) => {
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) => resolve4(code === 0 ? "success" : "failure"));
117
- child.on("error", () => resolve4("failure"));
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((resolve4) => {
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
- resolve4(outcome);
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((path) => ({ path, content: (0, import_node_fs.readFileSync)(path, "utf8") }));
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
- currentContent,
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
- if (typeof envelope?.errorCode === "string" && NOT_RETRIED_CODES.has(envelope.errorCode)) {
545
- const detail = envelope.error ? `: ${envelope.error}` : "";
546
- throw new RuntimeReplyError(
547
- `Codegen refused ${target.path}: ${envelope.errorCode} (not retried)${detail}`,
548
- `Codegen refused ${target.path}: ${envelope.errorCode} (not retried)`
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 (envelope?.errorCode === JUDGED_BY_TOO_LARGE_CODE) {
552
- const detail = envelope.error ? `: ${envelope.error}` : "";
553
- throw new RuntimeReplyError(
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
- const body = unwrapExecute(envelope, "pilot.codegen");
559
- if (body.outcome !== "GENERATED" || typeof body.newContent !== "string") {
560
- throw new RuntimeReplyError(
561
- `Codegen refused ${target.path}${body.refusalReason ? `: ${body.refusalReason}` : ""}`,
562
- `Codegen refused ${target.path}`
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, body.newContent);
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: body.newContent.length,
580
- removedLines: isNewFile ? null : countRemovedLines(currentContent, body.newContent)
787
+ outputLength: newContent.length,
788
+ removedLines: isNewFile ? null : countRemovedLines(currentContent, newContent)
581
789
  })
582
790
  );
583
- results.push({ targetPath: target.path, newContent: body.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: readFileSync6 } = await import("node:fs");
1019
- const body = readFileSync6(report);
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 name = NAME_ATTR_RE.exec(attrs)?.[1];
1110
- if (!name) continue;
1111
- const classname = CLASSNAME_ATTR_RE.exec(attrs)?.[1];
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 describeFailureReason(message, secrets = []) {
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 import_node_fs7 = require("node:fs");
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, import_node_fs7.readFileSync)(targetPath, "utf8");
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 import_node_path5 = require("node:path");
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, import_node_path5.resolve)(workspaceRoot ?? ".");
1529
- const rel = (0, import_node_path5.relative)(root, (0, import_node_path5.resolve)(target.path));
1530
- if (rel.startsWith("..") || (0, import_node_path5.isAbsolute)(rel)) {
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: body.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 };