@kontextmind/kxm 0.7.170 → 0.7.172

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.
@@ -26,6 +26,12 @@ const STAGES = Object.freeze([
26
26
  const VERIFY_FRESH_MS = 30 * 60 * 1000;
27
27
  const REBASE_ROUNDS = 5;
28
28
  const POLL_MS = positiveInt(process.env.KXM_LAND_POLL_MS, 30_000);
29
+ const MERGE_STATE_WAIT_MS = positiveInt(process.env.KXM_LAND_MERGE_STATE_WAIT_MS, 120_000);
30
+ const MERGE_STATE_POLL_MS = positiveInt(process.env.KXM_LAND_MERGE_STATE_POLL_MS, 1_000);
31
+ const MERGE_STATE_POLL_CAP_MS = 30_000;
32
+ const MERGE_READY = new Set(["CLEAN", "HAS_HOOKS", "ABSENT", ""]);
33
+ const MERGE_REBASE = new Set(["BEHIND", "DIRTY"]);
34
+ const VERIFY_FAIL_LINE = /✖|not ok\b|error TS\d*|error MD\d*|out of date|npm ERR!|ELIFECYCLE|AssertionError|# fail\b/i;
29
35
  const MERGE_WAIT_MS = 20 * 60 * 1000;
30
36
  const RELEASE_WAIT_MS = 20 * 60 * 1000;
31
37
  const RELEASE_PROGRESS_MS = 2 * 60 * 1000;
@@ -76,11 +82,15 @@ function run(command, args, timeout = 120_000) {
76
82
  };
77
83
  }
78
84
 
79
- function safeDetail(text) {
85
+ function redact(text) {
80
86
  return String(text ?? "")
81
87
  .replace(/ghp_[A-Za-z0-9]+/g, "[redacted]")
82
88
  .replace(/github_pat_[A-Za-z0-9_]+/g, "[redacted]")
83
- .replace(/Bearer\s+\S+/gi, "Bearer [redacted]")
89
+ .replace(/Bearer\s+\S+/gi, "Bearer [redacted]");
90
+ }
91
+
92
+ function safeDetail(text) {
93
+ return redact(text)
84
94
  .replace(/\s+/g, " ")
85
95
  .trim()
86
96
  .slice(0, 500);
@@ -95,8 +105,8 @@ function pass(stage, extra = {}) {
95
105
  return 0;
96
106
  }
97
107
 
98
- function refuse(stage, code, detail) {
99
- const payload = { stage, ok: false, code };
108
+ function refuse(stage, code, detail, extra = {}) {
109
+ const payload = { stage, ok: false, code, ...extra };
100
110
  if (detail) payload.detail = safeDetail(detail);
101
111
  emit(payload);
102
112
  return 1;
@@ -184,6 +194,30 @@ function writeLog(name, value) {
184
194
  return path;
185
195
  }
186
196
 
197
+ function writeTextLog(name, text) {
198
+ const path = join(logsDir(), name);
199
+ const body = text.endsWith("\n") ? text : `${text}\n`;
200
+ writeFileSync(path, body, { encoding: "utf8", mode: 0o600 });
201
+ return path;
202
+ }
203
+
204
+ function verifyFailureReport(stdout, stderr) {
205
+ const lines = `${stdout}\n${stderr}`.split(/\r?\n/);
206
+ const failing = [];
207
+ let step = "";
208
+ for (const line of lines) {
209
+ const text = line.trim();
210
+ if (!text) continue;
211
+ if (/^>\s+\S/.test(text) && !/^npm ERR!/i.test(text)) step = text.slice(0, 180);
212
+ if (VERIFY_FAIL_LINE.test(text)) failing.push(text.slice(0, 180));
213
+ }
214
+ const picked = (failing.length > 0 ? failing : lines.map((line) => line.trim()).filter(Boolean).slice(-4)).slice(-8);
215
+ return {
216
+ step,
217
+ excerpt: redact(picked.join(" | ")).replace(/\s+/g, " ").trim().slice(0, 700),
218
+ };
219
+ }
220
+
187
221
  function parseArgs(argv) {
188
222
  const options = { json: false, dryRun: false };
189
223
  for (let index = 0; index < argv.length; index += 1) {
@@ -262,7 +296,34 @@ function verifyStage() {
262
296
  }
263
297
  const verified = run("npm", ["run", "verify"], 3_600_000);
264
298
  if (verified.status !== 0) {
265
- return refuse("verify", "land_verify_failed", verified.stderr || verified.stdout);
299
+ const logName = `land-verify-${tree}.log`;
300
+ const logRel = `.kxm/logs/${logName}`;
301
+ const report = verifyFailureReport(verified.stdout, verified.stderr);
302
+ const excerpt = report.excerpt || "(no output)";
303
+ writeTextLog(logName, redact(`exit ${verified.status}\n--- stdout ---\n${verified.stdout}\n--- stderr ---\n${verified.stderr}\n`));
304
+ const receipt = {
305
+ stage: "verify",
306
+ ok: false,
307
+ tree,
308
+ exitCode: verified.status,
309
+ excerpt,
310
+ log: logRel,
311
+ finishedAt: new Date().toISOString(),
312
+ };
313
+ if (report.step) receipt.step = report.step;
314
+ writeLog(receiptName, receipt);
315
+ const step = report.step ? `; step ${report.step}` : "";
316
+ return refuse(
317
+ "verify",
318
+ "land_verify_failed",
319
+ `exit ${verified.status}; log ${logRel}${step}; ${excerpt}`,
320
+ {
321
+ exitCode: verified.status,
322
+ log: logRel,
323
+ excerpt,
324
+ ...(report.step ? { step: report.step } : {}),
325
+ },
326
+ );
266
327
  }
267
328
  writeLog(receiptName, { stage: "verify", ok: true, tree, finishedAt: new Date().toISOString() });
268
329
  return pass("verify", { detail: "verify: passed", tree });
@@ -572,7 +633,42 @@ function mergeState() {
572
633
  prNumber = String(found.pr.number);
573
634
  const viewed = ghJson(["pr", "view", prNumber, "--json", "mergeStateStatus,title,number"]);
574
635
  if (!viewed.ok) return { ok: false, detail: viewed.result.stderr || viewed.result.stdout };
575
- return { ok: true, status: String(viewed.value.mergeStateStatus ?? ""), title: viewed.value.title };
636
+ return { ok: true, status: String(viewed.value.mergeStateStatus ?? "").trim().toUpperCase(), title: viewed.value.title };
637
+ }
638
+
639
+ function classifyMergeStatus(status) {
640
+ if (MERGE_READY.has(status)) return "ready";
641
+ if (MERGE_REBASE.has(status)) return "rebase";
642
+ return "block";
643
+ }
644
+
645
+ /** UNKNOWN is not mergeable. Poll with bounded backoff until GitHub names a state. */
646
+ function awaitKnownMergeState(stage, failureCode) {
647
+ const deadline = Date.now() + MERGE_STATE_WAIT_MS;
648
+ let delay = Math.min(Math.max(MERGE_STATE_POLL_MS, 1), MERGE_STATE_POLL_CAP_MS);
649
+ let state = mergeState();
650
+ while (state.ok && state.status === "UNKNOWN") {
651
+ if (Date.now() >= deadline) {
652
+ return { ok: false, code: "land_merge_state_unknown", detail: "mergeStateStatus stayed UNKNOWN" };
653
+ }
654
+ emit({ stage, ok: true, detail: "mergeStateStatus UNKNOWN; polling" });
655
+ const remaining = deadline - Date.now();
656
+ if (remaining <= 0) {
657
+ return { ok: false, code: "land_merge_state_unknown", detail: "mergeStateStatus stayed UNKNOWN" };
658
+ }
659
+ sleep(Math.min(delay, remaining));
660
+ delay = Math.min(delay * 2, MERGE_STATE_POLL_CAP_MS);
661
+ state = mergeState();
662
+ }
663
+ if (!state.ok) return { ok: false, code: failureCode, detail: state.detail || "merge state unavailable" };
664
+ return { ok: true, state };
665
+ }
666
+
667
+ function readMergeStatus(stage, failureCode) {
668
+ const resolved = awaitKnownMergeState(stage, failureCode);
669
+ if (!resolved.ok) return resolved;
670
+ const status = resolved.state.status;
671
+ return { ok: true, status, action: classifyMergeStatus(status) };
576
672
  }
577
673
 
578
674
  function rebaseStage() {
@@ -583,17 +679,16 @@ function rebaseStage() {
583
679
  "git rebase origin/main",
584
680
  "git merge-tree --write-tree origin/main HEAD",
585
681
  "git push --force-with-lease -u origin <branch>",
586
- ], `at most ${REBASE_ROUNDS} rounds`);
682
+ ], `at most ${REBASE_ROUNDS} rounds; UNKNOWN is polled and never passed`);
587
683
  }
588
684
  const branch = branchName();
589
685
  if (branch === "main") return refuse("rebase", "land_conflict_manual", "refusing to rebase branch main");
590
686
  const manual = [];
591
687
  for (let round = 1; round <= REBASE_ROUNDS; round += 1) {
592
- const state = mergeState();
593
- if (!state.ok) return refuse("rebase", "land_conflict_manual", state.detail);
594
- if (state.status !== "BEHIND" && state.status !== "DIRTY") {
595
- return pass("rebase", { detail: `mergeStateStatus ${state.status || "absent"}` });
596
- }
688
+ const read = readMergeStatus("rebase", "land_conflict_manual");
689
+ if (!read.ok) return refuse("rebase", read.code, read.detail);
690
+ if (read.action === "block") return refuse("rebase", "land_merge_not_ready", `mergeStateStatus ${read.status}`);
691
+ if (read.action === "ready") return pass("rebase", { detail: `mergeStateStatus ${read.status || "absent"}` });
597
692
  const before = treeHash();
598
693
  const clean = run("git", ["merge-tree", "--write-tree", "origin/main", "HEAD"]).status === 0;
599
694
  const fetched = run("git", ["fetch", "origin"]);
@@ -623,12 +718,12 @@ function rebaseStage() {
623
718
  const pushCode = pushStage();
624
719
  if (pushCode !== 0) return pushCode;
625
720
  }
626
- const state = mergeState();
627
- if (state.ok && state.status !== "BEHIND" && state.status !== "DIRTY") {
628
- return pass("rebase", { detail: `mergeStateStatus ${state.status}` });
629
- }
721
+ const read = readMergeStatus("rebase", "land_conflict_manual");
722
+ if (!read.ok) return refuse("rebase", read.code, read.detail);
723
+ if (read.action === "ready") return pass("rebase", { detail: `mergeStateStatus ${read.status || "absent"}` });
724
+ if (read.action === "block") return refuse("rebase", "land_merge_not_ready", `mergeStateStatus ${read.status}`);
630
725
  const paths = [...new Set(manual)];
631
- const detail = paths.length > 0 ? paths.join(", ") : "still BEHIND or DIRTY after 5 rounds";
726
+ const detail = paths.length > 0 ? paths.join(", ") : `still ${read.status} after ${REBASE_ROUNDS} rounds`;
632
727
  return refuse("rebase", "land_conflict_manual", detail);
633
728
  }
634
729
 
@@ -764,6 +859,9 @@ function mergeStage() {
764
859
  const found = ensurePr();
765
860
  if (!found.ok || !found.pr?.number) return refuse("merge", "land_merge_failed", found.detail || "no open pull request");
766
861
  prNumber = String(found.pr.number);
862
+ const read = readMergeStatus("merge", "land_merge_failed");
863
+ if (!read.ok) return refuse("merge", read.code, read.detail);
864
+ if (read.action !== "ready") return refuse("merge", "land_merge_not_ready", `mergeStateStatus ${read.status}`);
767
865
  const title = String(found.pr.title ?? branchName());
768
866
  const nodeId = String(found.pr.id ?? "");
769
867
  const tags = newestTag();