@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/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: ${res.status} ${res.statusText}`);
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 NOT_RETRIED_CODES = /* @__PURE__ */ new Set([FILE_TOO_LARGE_CODE, CONTEXT_TOO_LARGE_CODE]);
174
- async function runCodegen(gateway, targets, workspaceListing) {
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
- const res = await fetch(`${gateway.baseUrl}/ai/execute`, {
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
- const envelope = await res.json();
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: ${res.status} ${res.statusText}`);
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(env = process.env) {
664
+ async function fetchGithubOidcToken(env2 = process.env) {
466
665
  const requestUrl = required(
467
666
  "ACTIONS_ID_TOKEN_REQUEST_URL",
468
- env.ACTIONS_ID_TOKEN_REQUEST_URL
667
+ env2.ACTIONS_ID_TOKEN_REQUEST_URL
469
668
  );
470
669
  const requestToken = required(
471
670
  "ACTIONS_ID_TOKEN_REQUEST_TOKEN",
472
- env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
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: ${res.status} ${res.statusText}`);
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: readFileSync4 } = await import("node:fs");
666
- const body = readFileSync4(report);
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(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/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 results = /* @__PURE__ */ new Map();
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
- results.set(id, !FAILURE_CHILD_RE.test(body));
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 results;
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
- return { outcome, results: readJUnitOutcomes(cwd) };
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
- return { outcome, results: readJUnitOutcomes(cwd) };
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) regressions.push(testId);
730
- else if (before === false) preExisting.push(testId);
731
- else newFailures.push(testId);
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(`${check.regressions.length} regression(s) (passed before, now fail): ${check.regressions.join(", ")}`);
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(`${check.newFailures.length} new failure(s) (did not exist before): ${check.newFailures.join(", ")}`);
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 res = await fetch(`${gateway.baseUrl}/ai/execute`, {
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
- // NESTED, NOT FLATTENED — a second vocabulary for the design shape is exactly what
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 { outcome: "TARGETED", targets: body.targets };
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: ${res.status} ${res.statusText}`);
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(env = readRunnerEnv()) {
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(env.tokenUrl, await fetchGithubOidcToken());
835
- packet = await fetchPacket(env.packetUrl, runToken, env.githubRunId);
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(env.runId);
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
- if (overlaps.length > 0) {
854
- warnings = describeOverlapWarnings(overlaps);
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(packet.gateway, targeting.targets, workspaceListing)
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: env.githubServerUrl,
901
- githubRepository: env.githubRepository,
902
- githubRunId: env.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
- env.installationTokenUrl,
1396
+ env2.installationTokenUrl,
909
1397
  runToken
910
1398
  );
911
1399
  const result = commitAndPush(
912
1400
  packet.branch,
913
- env.runId,
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: env.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
- run().then(({ hardFailure }) => {
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));