@nanobpm/nano-workforce 0.35.2 → 0.37.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.
Files changed (75) hide show
  1. package/.github/workflows/ci.yml +10 -17
  2. package/AGENTS.md +8 -7
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +1 -21
  5. package/actions/abandon.test.ts +8 -16
  6. package/actions/blackboard.test.ts +13 -21
  7. package/actions/blackboard.ts +1 -0
  8. package/actions/feature-answer-hook.ts +1 -0
  9. package/actions/plan-hook.ts +2 -1
  10. package/actions/webhook-submit.ts +2 -1
  11. package/app/abandon.test.ts +10 -15
  12. package/app/baseGuard.test.ts +7 -6
  13. package/app/blackboard.test.ts +22 -32
  14. package/app/blackboard.ts +7 -6
  15. package/app/ensure-pr.test.ts +10 -12
  16. package/app/github.test.ts +9 -8
  17. package/app/github.ts +24 -22
  18. package/app/instance-tracking.test.ts +8 -6
  19. package/app/mergeExclusion.test.ts +11 -20
  20. package/app/mergeExclusion.ts +8 -3
  21. package/app/mergeProtocol.test.ts +14 -13
  22. package/app/mergeProtocol.ts +1 -0
  23. package/app/mergeRebaseArm.test.ts +8 -6
  24. package/app/mergeTrain.test.ts +10 -9
  25. package/app/persist-escalation.test.ts +9 -30
  26. package/app/persist-round.test.ts +6 -19
  27. package/app/plan.test.ts +19 -42
  28. package/app/plan.ts +1 -1
  29. package/app/record-plan-review.test.ts +8 -7
  30. package/app/retro.test.ts +24 -48
  31. package/app/retro.ts +4 -2
  32. package/app/reviewWait.test.ts +12 -11
  33. package/app/rounds.test.ts +13 -12
  34. package/app/service.test.ts +14 -27
  35. package/app/service.ts +8 -4
  36. package/app/taskDelta.test.ts +8 -17
  37. package/app/taskDelta.ts +1 -0
  38. package/app/trialMerge.test.ts +4 -3
  39. package/app/version.ts +9 -21
  40. package/app/waves.test.ts +17 -16
  41. package/biome.json +143 -0
  42. package/operations/getVersion.test.ts +8 -12
  43. package/operations/getVersion.ts +2 -3
  44. package/operations/listActivePrs.test.ts +8 -14
  45. package/operations/listActivePrs.ts +2 -3
  46. package/operations/postMessage.ts +3 -3
  47. package/operations/startAndMessage.test.ts +6 -12
  48. package/package.json +9 -4
  49. package/plugins/no-unsafe-type-assertion.grit +12 -0
  50. package/scripts/check-agent-prompts.test.ts +15 -11
  51. package/scripts/check-agent-prompts.ts +4 -2
  52. package/scripts/layout-bpmn.ts +6 -26
  53. package/scripts/pages-contract.test.ts +14 -13
  54. package/scripts/purge-db.ts +3 -2
  55. package/scripts/upgrade-from-pack.ts +9 -5
  56. package/test/assert.ts +74 -0
  57. package/tsconfig.json +4 -1
  58. package/workers/finalize/worker.ts +2 -1
  59. package/workers/merge/worker.ts +3 -3
  60. package/workers/persist-escalation/worker.ts +2 -1
  61. package/workers/persist-round/worker.ts +2 -1
  62. package/workers/record-plan-review/worker.test.ts +6 -9
  63. package/workers/record-plan-review/worker.ts +2 -1
  64. package/workers/record-results/worker.test.ts +5 -8
  65. package/workers/record-results/worker.ts +2 -1
  66. package/workers/record-trial-merge/worker.test.ts +7 -18
  67. package/workers/record-trial-merge/worker.ts +6 -6
  68. package/workers/record-wave/worker.test.ts +13 -20
  69. package/workers/record-wave/worker.ts +7 -8
  70. package/workers/retro-gather/worker.test.ts +4 -17
  71. package/workers/retro-record/worker.test.ts +8 -27
  72. package/workers/retro-record/worker.ts +3 -3
  73. package/workers/select-wave/worker.test.ts +5 -8
  74. package/deno.json +0 -24
  75. package/deno.lock +0 -1776
@@ -1,5 +1,6 @@
1
1
  // Unit tests for the epic coordination blackboard (Tier 1, issues #51 / #49 D4).
2
- import { assert, assertEquals, assertStringIncludes } from "jsr:@std/assert@1";
2
+ import { test } from "node:test";
3
+ import { assert, assertEquals, assertStringIncludes } from "#test-assert";
3
4
  import type { DataLayer } from "@nanobpm/urban";
4
5
  import {
5
6
  appendEntry,
@@ -17,16 +18,12 @@ import {
17
18
 
18
19
  // A tiny in-memory stand-in for the record gateway, matching the subset of the Table<T> API the
19
20
  // blackboard uses (insert/find/findOne). Mirrors the fake-app style used across the app tests.
20
- // deno-lint-ignore no-explicit-any
21
21
  function memData(): { data: DataLayer; stores: Record<string, any[]> } {
22
- // deno-lint-ignore no-explicit-any
23
22
  const stores: Record<string, any[]> = {};
24
23
  const seq: Record<string, number> = {};
25
24
  function tbl(name: string, pk = "id") {
26
- // deno-lint-ignore no-explicit-any
27
25
  const rows = (stores[name] ??= [] as any[]);
28
26
  return {
29
- // deno-lint-ignore no-explicit-any require-await
30
27
  async insert(row: any) {
31
28
  if (pk === "id") {
32
29
  const id = (seq[name] = (seq[name] ?? 0) + 1);
@@ -36,22 +33,19 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
36
33
  rows.push({ ...row });
37
34
  return row[pk];
38
35
  },
39
- // deno-lint-ignore no-explicit-any require-await
40
36
  async find(where: any = {}) {
41
37
  return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
42
38
  },
43
- // deno-lint-ignore no-explicit-any require-await
44
39
  async findOne(where: any = {}) {
45
40
  return rows.find((r) => Object.entries(where).every(([k, v]) => r[k] === v));
46
41
  },
47
42
  };
48
43
  }
49
- // deno-lint-ignore no-explicit-any
50
44
  const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
51
45
  return { data, stores };
52
46
  }
53
47
 
54
- Deno.test("mintBlackboardToken: URL-safe, unguessable, unique", () => {
48
+ test("mintBlackboardToken: URL-safe, unguessable, unique", () => {
55
49
  const a = mintBlackboardToken();
56
50
  const b = mintBlackboardToken();
57
51
  assert(a !== b, "two mints must differ");
@@ -59,12 +53,12 @@ Deno.test("mintBlackboardToken: URL-safe, unguessable, unique", () => {
59
53
  assert(a.length >= 32, "token should carry enough entropy");
60
54
  });
61
55
 
62
- Deno.test("publicBaseUrl: honours the env override and trims a trailing slash", () => {
56
+ test("publicBaseUrl: honours the env override and trims a trailing slash", () => {
63
57
  assertEquals(publicBaseUrl("https://pr.example.com/"), "https://pr.example.com");
64
58
  assertEquals(publicBaseUrl("https://pr.example.com///"), "https://pr.example.com");
65
59
  });
66
60
 
67
- Deno.test("publicBaseUrl: a blank/whitespace override falls back instead of yielding a bad URL", () => {
61
+ test("publicBaseUrl: a blank/whitespace override falls back instead of yielding a bad URL", () => {
68
62
  const prev = process.env.NANO_PR_BASE_URL;
69
63
  delete process.env.NANO_PR_BASE_URL;
70
64
  try {
@@ -77,14 +71,14 @@ Deno.test("publicBaseUrl: a blank/whitespace override falls back instead of yiel
77
71
  }
78
72
  });
79
73
 
80
- Deno.test("blackboardUrl: capability token rides the query string", () => {
74
+ test("blackboardUrl: capability token rides the query string", () => {
81
75
  assertEquals(
82
76
  blackboardUrl("tok+en/x", "https://h"),
83
77
  "https://h/hooks/blackboard?token=tok%2Ben%2Fx",
84
78
  );
85
79
  });
86
80
 
87
- Deno.test("normalizeKind: valid passes through, anything else becomes note", () => {
81
+ test("normalizeKind: valid passes through, anything else becomes note", () => {
88
82
  assertEquals(normalizeKind("file-claim"), "file-claim");
89
83
  assertEquals(normalizeKind("constraint-change"), "constraint-change");
90
84
  assertEquals(normalizeKind("learning"), "learning");
@@ -92,7 +86,7 @@ Deno.test("normalizeKind: valid passes through, anything else becomes note", ()
92
86
  assertEquals(normalizeKind(undefined), "note");
93
87
  });
94
88
 
95
- Deno.test("renderCoordinationBrief: leads with a separator and teaches the protocol + URL", () => {
89
+ test("renderCoordinationBrief: leads with a separator and teaches the protocol + URL", () => {
96
90
  const url = "https://h/hooks/blackboard?token=abc";
97
91
  const brief = renderCoordinationBrief(url);
98
92
  assert(brief.startsWith("\n\n---"), "must own a leading separator (appendPrompt adds none)");
@@ -112,7 +106,7 @@ Deno.test("renderCoordinationBrief: leads with a separator and teaches the proto
112
106
  assertStringIncludes(brief, "Share what you learn");
113
107
  });
114
108
 
115
- Deno.test("planKeyForToken: resolves a token to its plan, undefined otherwise", async () => {
109
+ test("planKeyForToken: resolves a token to its plan, undefined otherwise", async () => {
116
110
  const { data } = memData();
117
111
  await data.table("plans", "plan_key").insert({ plan_key: "o/r#7", blackboard_token: "tok7" });
118
112
  assertEquals(await planKeyForToken(data, "tok7"), "o/r#7");
@@ -120,7 +114,7 @@ Deno.test("planKeyForToken: resolves a token to its plan, undefined otherwise",
120
114
  assertEquals(await planKeyForToken(data, ""), undefined);
121
115
  });
122
116
 
123
- Deno.test("appendEntry + readBlackboard: append, encode files, read back in write order", async () => {
117
+ test("appendEntry + readBlackboard: append, encode files, read back in write order", async () => {
124
118
  const { data } = memData();
125
119
  await appendEntry(data, "o/r#1", { author_task: "gap-2", kind: "file-claim", files: ["a.rs"], body: "touches a.rs" });
126
120
  await appendEntry(data, "o/r#1", { author_task: "gap-8", kind: "note", body: "heads up" });
@@ -133,14 +127,14 @@ Deno.test("appendEntry + readBlackboard: append, encode files, read back in writ
133
127
  assertEquals(entries[1].author_task, "gap-8");
134
128
  });
135
129
 
136
- Deno.test("appendEntry: trims whitespace-padded file paths so stored/read values are clean", async () => {
130
+ test("appendEntry: trims whitespace-padded file paths so stored/read values are clean", async () => {
137
131
  const { data } = memData();
138
132
  await appendEntry(data, "p", { kind: "file-claim", files: [" engine/state.rs ", "\tengine/mine.rs\n"], body: "claims" });
139
133
  const [e] = await readBlackboard(data, "p");
140
134
  assertEquals(e.files, ["engine/state.rs", "engine/mine.rs"], "paths stored trimmed, not whitespace-padded");
141
135
  });
142
136
 
143
- Deno.test("appendEntry: a missing author defaults to 'system' and kind is normalised", async () => {
137
+ test("appendEntry: a missing author defaults to 'system' and kind is normalised", async () => {
144
138
  const { data } = memData();
145
139
  await appendEntry(data, "p", { body: "x", kind: "weird" as unknown });
146
140
  const [e] = await readBlackboard(data, "p");
@@ -148,7 +142,7 @@ Deno.test("appendEntry: a missing author defaults to 'system' and kind is normal
148
142
  assertEquals(e.kind, "note");
149
143
  });
150
144
 
151
- Deno.test("appendEntry: idempotent on dedupe_key (a job retry re-POST is a no-op)", async () => {
145
+ test("appendEntry: idempotent on dedupe_key (a job retry re-POST is a no-op)", async () => {
152
146
  const { data, stores } = memData();
153
147
  const first = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
154
148
  const again = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
@@ -158,15 +152,13 @@ Deno.test("appendEntry: idempotent on dedupe_key (a job retry re-POST is a no-op
158
152
  assertEquals(stores["plan_blackboard"].length, 1, "exactly one row persisted");
159
153
  });
160
154
 
161
- Deno.test("appendEntry: a lost UNIQUE race collapses to a no-op instead of a 500", async () => {
155
+ test("appendEntry: a lost UNIQUE race collapses to a no-op instead of a 500", async () => {
162
156
  // Simulate the concurrency window: two POSTs share a dedupe_key, both miss the findOne
163
157
  // pre-check, then insert loses the race on the UNIQUE (plan_key, dedupe_key) index. The
164
158
  // catch branch must re-read the winner's row and return it rather than propagate the throw.
165
159
  const winner = { id: 42, plan_key: "p", dedupe_key: "t:claim:1", author_task: "t", body: "claim" };
166
160
  let preCheckDone = false;
167
- // deno-lint-ignore no-explicit-any
168
161
  const table: any = {
169
- // deno-lint-ignore require-await
170
162
  async findOne() {
171
163
  // Pre-check misses (row not yet visible); the recovery read after the collision hits.
172
164
  if (!preCheckDone) {
@@ -175,21 +167,19 @@ Deno.test("appendEntry: a lost UNIQUE race collapses to a no-op instead of a 500
175
167
  }
176
168
  return winner;
177
169
  },
178
- // deno-lint-ignore require-await
179
170
  async insert() {
180
171
  throw Object.assign(new Error("UNIQUE constraint failed: plan_blackboard.dedupe_key"), {
181
172
  code: "SQLITE_CONSTRAINT_UNIQUE",
182
173
  });
183
174
  },
184
175
  };
185
- // deno-lint-ignore no-explicit-any
186
176
  const data = { table: () => table } as any as DataLayer;
187
177
  const res = await appendEntry(data, "p", { author_task: "t", body: "claim", dedupe_key: "t:claim:1" });
188
178
  assertEquals(res.inserted, false, "a lost race is not a fresh insert");
189
179
  assertEquals(res.id, 42, "returns the winning row's id");
190
180
  });
191
181
 
192
- Deno.test("appendEntry: a blank body is rejected", async () => {
182
+ test("appendEntry: a blank body is rejected", async () => {
193
183
  const { data } = memData();
194
184
  let threw = false;
195
185
  try {
@@ -200,7 +190,7 @@ Deno.test("appendEntry: a blank body is rejected", async () => {
200
190
  assert(threw, "blank body must throw");
201
191
  });
202
192
 
203
- Deno.test("readBlackboard: since returns only newer entries (incremental poll)", async () => {
193
+ test("readBlackboard: since returns only newer entries (incremental poll)", async () => {
204
194
  const { data } = memData();
205
195
  await appendEntry(data, "p", { body: "one" });
206
196
  await appendEntry(data, "p", { body: "two" });
@@ -210,7 +200,7 @@ Deno.test("readBlackboard: since returns only newer entries (incremental poll)",
210
200
  assertEquals(tail.map((e) => e.body), ["two", "three"]);
211
201
  });
212
202
 
213
- Deno.test("readBlackboardPage: cursor is the plan head and lets an agent poll to caught-up (Tier 2)", async () => {
203
+ test("readBlackboardPage: cursor is the plan head and lets an agent poll to caught-up (Tier 2)", async () => {
214
204
  const { data } = memData();
215
205
  await appendEntry(data, "p", { body: "one" });
216
206
  await appendEntry(data, "p", { body: "two" });
@@ -231,14 +221,14 @@ Deno.test("readBlackboardPage: cursor is the plan head and lets an agent poll to
231
221
  assertEquals(next.cursor, next.entries[0].id);
232
222
  });
233
223
 
234
- Deno.test("readBlackboardPage: an empty plan yields no entries and a zero cursor", async () => {
224
+ test("readBlackboardPage: an empty plan yields no entries and a zero cursor", async () => {
235
225
  const { data } = memData();
236
226
  const page = await readBlackboardPage(data, "empty");
237
227
  assertEquals(page.entries, []);
238
228
  assertEquals(page.cursor, 0);
239
229
  });
240
230
 
241
- Deno.test("detectFileClaimConflicts: a sibling's prior claim on the same file is surfaced", async () => {
231
+ test("detectFileClaimConflicts: a sibling's prior claim on the same file is surfaced", async () => {
242
232
  const { data } = memData();
243
233
  await appendEntry(data, "p", { author_task: "gap-2", kind: "file-claim", files: ["engine/state.rs"], body: "owns state.rs" });
244
234
 
@@ -251,7 +241,7 @@ Deno.test("detectFileClaimConflicts: a sibling's prior claim on the same file is
251
241
  assertEquals(conflicts[0].author_task, "gap-2", "reports the first (winning) claimer");
252
242
  });
253
243
 
254
- Deno.test("detectFileClaimConflicts: your own prior claim and non-file-claim entries are not conflicts", async () => {
244
+ test("detectFileClaimConflicts: your own prior claim and non-file-claim entries are not conflicts", async () => {
255
245
  const { data } = memData();
256
246
  await appendEntry(data, "p", { author_task: "gap-2", kind: "file-claim", files: ["a.rs"], body: "my earlier claim" });
257
247
  await appendEntry(data, "p", { author_task: "gap-8", kind: "note", files: ["a.rs"], body: "just a note about a.rs" });
@@ -265,7 +255,7 @@ Deno.test("detectFileClaimConflicts: your own prior claim and non-file-claim ent
265
255
  assertEquals(await detectFileClaimConflicts(data, "p", { author_task: "gap-9", files: [] }), []);
266
256
  });
267
257
 
268
- Deno.test("detectFileClaimConflicts: beforeId restricts to strictly prior claims (insertion order wins)", async () => {
258
+ test("detectFileClaimConflicts: beforeId restricts to strictly prior claims (insertion order wins)", async () => {
269
259
  const { data } = memData();
270
260
  const prior = await appendEntry(data, "p", {
271
261
  author_task: "gap-2",
@@ -299,7 +289,7 @@ Deno.test("detectFileClaimConflicts: beforeId restricts to strictly prior claims
299
289
  assert(Number(later.id) > Number(mine.id));
300
290
  });
301
291
 
302
- Deno.test("isUniqueViolation: true for UNIQUE/PK, false for FOREIGN KEY and unrelated errors", () => {
292
+ test("isUniqueViolation: true for UNIQUE/PK, false for FOREIGN KEY and unrelated errors", () => {
303
293
  // Extended SQLite codes.
304
294
  assert(isUniqueViolation(Object.assign(new Error("x"), { code: "SQLITE_CONSTRAINT_UNIQUE" })));
305
295
  assert(isUniqueViolation(Object.assign(new Error("x"), { code: "SQLITE_CONSTRAINT_PRIMARYKEY" })));
package/app/blackboard.ts CHANGED
@@ -58,11 +58,9 @@ export interface BlackboardInput {
58
58
  dedupe_key?: string;
59
59
  }
60
60
 
61
- const KIND_SET = new Set<string>(BLACKBOARD_KINDS);
62
-
63
61
  /** Coerce an arbitrary `kind` to a known value, defaulting to "note" for anything unrecognised. */
64
62
  export function normalizeKind(kind: unknown): BlackboardKind {
65
- return typeof kind === "string" && KIND_SET.has(kind) ? (kind as BlackboardKind) : "note";
63
+ return BLACKBOARD_KINDS.find((k) => k === kind) ?? "note";
66
64
  }
67
65
 
68
66
  /** A URL-safe, unguessable capability token (192 bits of randomness, base64url, no padding). */
@@ -79,9 +77,10 @@ export function mintBlackboardToken(): string {
79
77
  export function publicBaseUrl(env: string | undefined = process.env.NANO_PR_PUBLIC_BASE_URL): string {
80
78
  // Cascade through the fallback chain, skipping any value that is unset OR blank/whitespace, so an
81
79
  // explicitly-set-but-empty NANO_PR_PUBLIC_BASE_URL can't yield a malformed capability URL.
82
- const base = [env, process.env.NANO_PR_BASE_URL, "http://localhost:3000"]
83
- .map((v) => v?.trim())
84
- .find((v) => v) as string;
80
+ const base =
81
+ [env, process.env.NANO_PR_BASE_URL, "http://localhost:3000"]
82
+ .map((v) => v?.trim())
83
+ .find((v): v is string => Boolean(v)) ?? "http://localhost:3000";
85
84
  return base.replace(/\/+$/, "");
86
85
  }
87
86
 
@@ -313,8 +312,10 @@ export async function appendEntry(
313
312
  * corruption, not a benign duplicate) is always rethrown rather than silently swallowed. */
314
313
  export function isUniqueViolation(err: unknown): boolean {
315
314
  if (!err || typeof err !== "object") return false;
315
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
316
316
  const code = (err as { code?: unknown }).code;
317
317
  if (code === "SQLITE_CONSTRAINT_UNIQUE" || code === "SQLITE_CONSTRAINT_PRIMARYKEY") return true;
318
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
318
319
  const message = (err as { message?: unknown }).message;
319
320
  return typeof message === "string" &&
320
321
  /(unique|primary key) constraint failed|duplicate/i.test(message);
@@ -2,7 +2,8 @@
2
2
  // `pull_requests` FK parent before a child (`rounds`/`escalations`/`merges`) insert, so an
3
3
  // engine/app.db store desync never parks an opaque `FOREIGN KEY constraint failed` incident
4
4
  // (observed on convergence-loop instance 94).
5
- import { assert, assertEquals } from "jsr:@std/assert@1";
5
+ import { test } from "node:test";
6
+ import { assert, assertEquals } from "#test-assert";
6
7
  import type { DataLayer } from "@nanobpm/urban";
7
8
  import { canonicalPrUrl, ensurePr } from "./service.ts";
8
9
 
@@ -18,11 +19,9 @@ function memData(opts: { throwOnInsert?: boolean; seedOnThrow?: boolean } = {}):
18
19
  let insertCalls = 0;
19
20
  function tbl(name: string, key: string) {
20
21
  return {
21
- // deno-lint-ignore require-await
22
22
  async get(id: string) {
23
23
  return rows.get(id);
24
24
  },
25
- // deno-lint-ignore no-explicit-any require-await
26
25
  async insert(row: any) {
27
26
  insertCalls++;
28
27
  if (opts.throwOnInsert) {
@@ -34,14 +33,13 @@ function memData(opts: { throwOnInsert?: boolean; seedOnThrow?: boolean } = {}):
34
33
  },
35
34
  };
36
35
  }
37
- // deno-lint-ignore no-explicit-any
38
36
  const data = { table: (n: string, k: string) => tbl(n, k) } as any as DataLayer;
39
37
  return { data, rows, get insertCalls() {
40
38
  return insertCalls;
41
39
  } };
42
40
  }
43
41
 
44
- Deno.test("ensurePr is a no-op when the parent already exists", async () => {
42
+ test("ensurePr is a no-op when the parent already exists", async () => {
45
43
  const mem = memData();
46
44
  const { data, rows } = mem;
47
45
  rows.set("o/r#1", { pr_key: "o/r#1", status: "converging" });
@@ -52,7 +50,7 @@ Deno.test("ensurePr is a no-op when the parent already exists", async () => {
52
50
  assertEquals(mem.insertCalls, 0, "insert is never attempted — the no-write guarantee holds");
53
51
  });
54
52
 
55
- Deno.test("ensurePr reconstructs a minimal converging row when the parent is absent", async () => {
53
+ test("ensurePr reconstructs a minimal converging row when the parent is absent", async () => {
56
54
  const { data, rows } = memData();
57
55
  await ensurePr(data, { prKey: "o/r#2", repo: "o/r", number: 2, round: 3 });
58
56
  const row = rows.get("o/r#2")!;
@@ -65,7 +63,7 @@ Deno.test("ensurePr reconstructs a minimal converging row when the parent is abs
65
63
  assert(typeof row.abandon_token === "string" && (row.abandon_token as string).length > 0);
66
64
  });
67
65
 
68
- Deno.test("ensurePr defaults current_round to 1 (rounds are 1-based) when none is passed", async () => {
66
+ test("ensurePr defaults current_round to 1 (rounds are 1-based) when none is passed", async () => {
69
67
  const { data, rows } = memData();
70
68
  await ensurePr(data, { prKey: "o/r#5", repo: "o/r", number: 5 });
71
69
  assertEquals(
@@ -75,7 +73,7 @@ Deno.test("ensurePr defaults current_round to 1 (rounds are 1-based) when none i
75
73
  );
76
74
  });
77
75
 
78
- Deno.test("ensurePr reuses a supplied abandon token instead of minting a new one", async () => {
76
+ test("ensurePr reuses a supplied abandon token instead of minting a new one", async () => {
79
77
  const { data, rows } = memData();
80
78
  await ensurePr(data, { prKey: "o/r#7", repo: "o/r", number: 7, abandonToken: "TOK-en_123" });
81
79
  assertEquals(
@@ -85,27 +83,27 @@ Deno.test("ensurePr reuses a supplied abandon token instead of minting a new one
85
83
  );
86
84
  });
87
85
 
88
- Deno.test("ensurePr mints a token when none is supplied", async () => {
86
+ test("ensurePr mints a token when none is supplied", async () => {
89
87
  const { data, rows } = memData();
90
88
  await ensurePr(data, { prKey: "o/r#8", repo: "o/r", number: 8 });
91
89
  const tok = rows.get("o/r#8")!.abandon_token;
92
90
  assert(typeof tok === "string" && (tok as string).length > 0, "a fresh token is minted as a fallback");
93
91
  });
94
92
 
95
- Deno.test("ensurePr prefers an explicit url over the canonical one", async () => {
93
+ test("ensurePr prefers an explicit url over the canonical one", async () => {
96
94
  const { data, rows } = memData();
97
95
  const url = "https://github.com/o/r/pull/9";
98
96
  await ensurePr(data, { prKey: "o/r#9", repo: "o/r", number: 9, url });
99
97
  assertEquals(rows.get("o/r#9")!.url, url);
100
98
  });
101
99
 
102
- Deno.test("ensurePr swallows an insert race when the row appears anyway", async () => {
100
+ test("ensurePr swallows an insert race when the row appears anyway", async () => {
103
101
  // insert throws (unique-violation / concurrent writer) but the row is now present → healed.
104
102
  const { data } = memData({ throwOnInsert: true, seedOnThrow: true });
105
103
  await ensurePr(data, { prKey: "o/r#3", repo: "o/r", number: 3 });
106
104
  });
107
105
 
108
- Deno.test("ensurePr rethrows when the insert fails and the row is still absent", async () => {
106
+ test("ensurePr rethrows when the insert fails and the row is still absent", async () => {
109
107
  const { data } = memData({ throwOnInsert: true, seedOnThrow: false });
110
108
  let threw = false;
111
109
  try {
@@ -1,7 +1,8 @@
1
1
  // Unit tests for `fetchPrFiles` token-transport paging (issue #58): the D2 conflict-scan must get
2
2
  // a COMPLETE file list or a thrown error — never a silently truncated one that under-approximates
3
3
  // the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
4
- import { assertEquals, assertRejects } from "jsr:@std/assert@1";
4
+ import { test } from "node:test";
5
+ import { assertEquals, assertRejects } from "#test-assert";
5
6
  import { fetchPrFiles } from "./github.ts";
6
7
 
7
8
  // A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
@@ -25,31 +26,31 @@ function stubFetch(pages: number[]) {
25
26
  }
26
27
 
27
28
  async function withTokenTransport<T>(pages: number[], fn: () => Promise<T>): Promise<T> {
28
- const prevMode = Deno.env.get("NANO_PR_GITHUB_TRANSPORT");
29
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
29
30
  const prevFetch = globalThis.fetch;
30
- Deno.env.set("NANO_PR_GITHUB_TRANSPORT", "token");
31
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
31
32
  globalThis.fetch = stubFetch(pages) as typeof fetch;
32
33
  try {
33
34
  return await fn();
34
35
  } finally {
35
36
  globalThis.fetch = prevFetch;
36
- if (prevMode === undefined) Deno.env.delete("NANO_PR_GITHUB_TRANSPORT");
37
- else Deno.env.set("NANO_PR_GITHUB_TRANSPORT", prevMode);
37
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
38
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
38
39
  }
39
40
  }
40
41
 
41
- Deno.test("fetchPrFiles: returns the complete list for a sub-cap PR (short final page)", async () => {
42
+ test("fetchPrFiles: returns the complete list for a sub-cap PR (short final page)", async () => {
42
43
  const files = await withTokenTransport([100, 42], () => fetchPrFiles("o/r", 1, "tok"));
43
44
  assertEquals(files?.length, 142);
44
45
  });
45
46
 
46
- Deno.test("fetchPrFiles: exactly 500 files with no next page is complete, not truncated", async () => {
47
+ test("fetchPrFiles: exactly 500 files with no next page is complete, not truncated", async () => {
47
48
  // 5 full pages, but no `rel="next"` on the last → the list is exactly complete at the cap.
48
49
  const files = await withTokenTransport([100, 100, 100, 100, 100], () => fetchPrFiles("o/r", 2, "tok"));
49
50
  assertEquals(files?.length, 500);
50
51
  });
51
52
 
52
- Deno.test("fetchPrFiles: throws when the cap genuinely truncates (full last page + next)", async () => {
53
+ test("fetchPrFiles: throws when the cap genuinely truncates (full last page + next)", async () => {
53
54
  // 6 pages available but only 5 fetched → the 5th page still advertises `rel="next"`.
54
55
  await assertRejects(
55
56
  () => withTokenTransport([100, 100, 100, 100, 100, 100], () => fetchPrFiles("o/r", 3, "tok")),
package/app/github.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // • auto — prefer `gh` when the binary is present; otherwise fall back to `token`.
10
10
  //
11
11
  // The poller is app-side host glue (main.ts), so host-specific subprocess I/O is allowed here.
12
- // Cross-runtime: runs under Node (`node:child_process`) and Deno (`Deno.Command`).
12
+ // Cross-runtime: runs under Node (`node:child_process`).
13
13
 
14
14
  /** A GitHub pull-request review, narrowed to the fields the poller needs. */
15
15
  export interface GhReview {
@@ -26,29 +26,10 @@ export function githubTransport(): GithubTransport {
26
26
  return t === "gh" || t === "token" ? t : "auto";
27
27
  }
28
28
 
29
- interface DenoCommandCtor {
30
- new (
31
- command: string,
32
- options: { args: string[]; stdout: "piped"; stderr: "piped" },
33
- ): { output(): Promise<{ code: number; stdout: Uint8Array; stderr: Uint8Array }> };
34
- }
35
-
36
29
  /** Run the host `gh` CLI with the given args (no shell — args are passed as a vector, so a
37
30
  * `repo`/`number` from the datastore cannot inject a command). Resolves stdout, rejects on a
38
31
  * non-zero exit with stderr as the message. */
39
32
  async function runGh(args: string[]): Promise<string> {
40
- const g = globalThis as { Deno?: { Command?: DenoCommandCtor } };
41
- if (g.Deno?.Command) {
42
- const { code, stdout, stderr } = await new g.Deno.Command("gh", {
43
- args,
44
- stdout: "piped",
45
- stderr: "piped",
46
- }).output();
47
- if (code !== 0) {
48
- throw new Error(new TextDecoder().decode(stderr).trim() || `gh exited ${code}`);
49
- }
50
- return new TextDecoder().decode(stdout);
51
- }
52
33
  const { execFile } = await import("node:child_process");
53
34
  return await new Promise<string>((resolve, reject) => {
54
35
  execFile(
@@ -66,7 +47,10 @@ async function runGh(args: string[]): Promise<string> {
66
47
  let ghAvailable: Promise<boolean> | undefined;
67
48
  /** Whether the host `gh` CLI is present (memoized — probed at most once per process). */
68
49
  function isGhAvailable(): Promise<boolean> {
69
- return (ghAvailable ??= runGh(["--version"]).then(() => true, () => false));
50
+ if (!ghAvailable) {
51
+ ghAvailable = runGh(["--version"]).then(() => true, () => false);
52
+ }
53
+ return ghAvailable;
70
54
  }
71
55
 
72
56
  /** Fetch the reviews for one PR via the configured transport. Throws on transport failure so
@@ -81,6 +65,7 @@ export async function fetchPrReviews(
81
65
  const path = `repos/${repo}/pulls/${number}/reviews?per_page=100`;
82
66
  if (useGh) {
83
67
  const out = await runGh(["api", path, "-H", "Accept: application/vnd.github+json"]);
68
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
84
69
  return JSON.parse(out) as GhReview[];
85
70
  }
86
71
  if (!token) return null; // token mode with no token → poller idles
@@ -88,6 +73,7 @@ export async function fetchPrReviews(
88
73
  headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
89
74
  });
90
75
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
76
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
91
77
  return (await r.json()) as GhReview[];
92
78
  }
93
79
 
@@ -122,6 +108,7 @@ export async function hasPendingCopilotReviewer(
122
108
  let users: { login?: string }[];
123
109
  if (await useGh()) {
124
110
  const out = await runGh(["api", path, "-H", "Accept: application/vnd.github+json"]);
111
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
125
112
  users = (JSON.parse(out) as { users?: { login?: string }[] }).users ?? [];
126
113
  } else {
127
114
  if (!token) return null;
@@ -129,6 +116,7 @@ export async function hasPendingCopilotReviewer(
129
116
  headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
130
117
  });
131
118
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
119
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
132
120
  users = ((await r.json()) as { users?: { login?: string }[] }).users ?? [];
133
121
  }
134
122
  return users.some((u) => isCopilot(u.login));
@@ -198,6 +186,7 @@ export async function fetchPrMeta(
198
186
  ): Promise<PrMeta | null> {
199
187
  if (await useGh()) {
200
188
  const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "title,body"]);
189
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
201
190
  const j = JSON.parse(out) as { title?: string; body?: string };
202
191
  return { title: j.title ?? null, body: j.body ?? "" };
203
192
  }
@@ -206,6 +195,7 @@ export async function fetchPrMeta(
206
195
  headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
207
196
  });
208
197
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
198
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
209
199
  const j = (await r.json()) as { title?: string; body?: string };
210
200
  return { title: j.title ?? null, body: j.body ?? "" };
211
201
  }
@@ -273,6 +263,7 @@ export async function fetchPrState(
273
263
  "--json",
274
264
  "state,mergedAt,mergeStateStatus,statusCheckRollup,isDraft,headRefOid",
275
265
  ]);
266
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
276
267
  const j = JSON.parse(out) as {
277
268
  state?: string;
278
269
  mergedAt?: string | null;
@@ -298,6 +289,7 @@ export async function fetchPrState(
298
289
  headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
299
290
  });
300
291
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
292
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
301
293
  const j = (await r.json()) as {
302
294
  merged?: boolean;
303
295
  merged_at?: string | null;
@@ -328,6 +320,7 @@ export async function fetchPrFiles(
328
320
  ): Promise<string[] | null> {
329
321
  if (await useGh()) {
330
322
  const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "files"]);
323
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
331
324
  const j = JSON.parse(out) as { files?: { path?: string }[] };
332
325
  return (j.files ?? []).map((f) => f.path ?? "").filter((p) => p !== "");
333
326
  }
@@ -341,6 +334,7 @@ export async function fetchPrFiles(
341
334
  { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" } },
342
335
  );
343
336
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
337
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
344
338
  const batch = (await r.json()) as { filename?: string }[];
345
339
  for (const f of batch) if (f.filename) paths.push(f.filename);
346
340
  // A short final page means we've read every file — the list is complete.
@@ -367,6 +361,7 @@ export async function fetchPrHead(
367
361
  ): Promise<{ headRef: string | null; headSha: string | null } | null> {
368
362
  if (await useGh()) {
369
363
  const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "headRefName,headRefOid"]);
364
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
370
365
  const j = JSON.parse(out) as { headRefName?: string | null; headRefOid?: string | null };
371
366
  return { headRef: j.headRefName ?? null, headSha: j.headRefOid ?? null };
372
367
  }
@@ -375,6 +370,7 @@ export async function fetchPrHead(
375
370
  headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
376
371
  });
377
372
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
373
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
378
374
  const j = (await r.json()) as { head?: { ref?: string | null; sha?: string | null } };
379
375
  return { headRef: j.head?.ref ?? null, headSha: j.head?.sha ?? null };
380
376
  }
@@ -389,6 +385,7 @@ export async function fetchPrBase(
389
385
  ): Promise<string | null> {
390
386
  if (await useGh()) {
391
387
  const out = await runGh(["pr", "view", String(number), "--repo", repo, "--json", "baseRefName"]);
388
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
392
389
  const j = JSON.parse(out) as { baseRefName?: string };
393
390
  return j.baseRefName ?? null;
394
391
  }
@@ -397,6 +394,7 @@ export async function fetchPrBase(
397
394
  headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
398
395
  });
399
396
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
397
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
400
398
  const j = (await r.json()) as { base?: { ref?: string } };
401
399
  return j.base?.ref ?? null;
402
400
  }
@@ -413,6 +411,7 @@ export async function fetchDefaultBranch(repo: string, token: string): Promise<s
413
411
  let name: string | null = null;
414
412
  if (await useGh()) {
415
413
  const out = await runGh(["repo", "view", repo, "--json", "defaultBranchRef"]);
414
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
416
415
  const j = JSON.parse(out) as { defaultBranchRef?: { name?: string } };
417
416
  name = j.defaultBranchRef?.name ?? null;
418
417
  } else if (token) {
@@ -420,6 +419,7 @@ export async function fetchDefaultBranch(repo: string, token: string): Promise<s
420
419
  headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
421
420
  });
422
421
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
422
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
423
423
  const j = (await r.json()) as { default_branch?: string };
424
424
  name = j.default_branch ?? null;
425
425
  } else {
@@ -455,6 +455,7 @@ export async function baseBranchLanded(
455
455
  "--limit",
456
456
  "20",
457
457
  ]);
458
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
458
459
  const arr = JSON.parse(out) as { state?: string }[];
459
460
  if (arr.some((p) => (p.state ?? "").toUpperCase() === "MERGED")) return "landed";
460
461
  if (arr.some((p) => (p.state ?? "").toUpperCase() === "OPEN")) return "open";
@@ -467,6 +468,7 @@ export async function baseBranchLanded(
467
468
  { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" } },
468
469
  );
469
470
  if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
471
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
470
472
  const arr = (await r.json()) as { state?: string; merged_at?: string | null }[];
471
473
  if (arr.some((p) => p.merged_at || (p.state ?? "").toUpperCase() === "MERGED")) return "landed";
472
474
  if (arr.some((p) => (p.state ?? "").toLowerCase() === "open")) return "open";
@@ -491,7 +493,6 @@ export function classifyMergeability(s: PrState): Mergeability {
491
493
  // A required check failed -> a human must act. Pending checks / awaiting review -> wait.
492
494
  // When we can't enumerate checks (failingChecks < 0, token mode) stay conservative: wait.
493
495
  return s.failingChecks > 0 ? "blocked" : "waiting";
494
- case "DRAFT":
495
496
  default: // UNKNOWN / "" — GitHub is still computing mergeability
496
497
  return "waiting";
497
498
  }
@@ -550,6 +551,7 @@ export async function mergePr(
550
551
  // in this pass. Trust `merged` when true; otherwise verify the PR's actual state and report
551
552
  // `queued` when it hasn't landed yet, so the merge-loop waits for `merge-landed` rather than
552
553
  // marking it merged prematurely.
554
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
553
555
  const body = (await r.json().catch(() => ({}))) as { merged?: boolean };
554
556
  if (body.merged) return { outcome: "merged", detail: "merged" };
555
557
  const st = await fetchPrState(repo, number, token).catch(() => null);