mandrel-platform 1.13.0 → 1.13.2

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.
@@ -9,7 +9,13 @@
9
9
  // • label discovery pages past the first 200, and a create refused as
10
10
  // already-existing is a skip rather than a failure;
11
11
  // • fire semantics INVERT on configuration — a configured-and-refused fire
12
- // reds the run, an unwired one warns and stays green.
12
+ // reds the run, an unwired one warns and stays green;
13
+ // • the run is IDEMPOTENT — the trigger label is re-read live before any
14
+ // write, because a re-run replays a payload that predates the first run's
15
+ // own label write, and an issue already wearing it is left alone;
16
+ // • the fire is BOUNDED by a race rather than a forwarded signal, which is
17
+ // precisely what the fakes below prove: every one of them ignores
18
+ // `init.signal`, exactly like a wedged endpoint's socket.
13
19
  //
14
20
  // Everything here runs offline: the `gh` adapter and `fetch` are injected
15
21
  // seams, so even the behavioural claims are asserted without network access.
@@ -25,6 +31,8 @@ import { join } from "node:path";
25
31
  import {
26
32
  ANTHROPIC_BETA,
27
33
  ANTHROPIC_VERSION,
34
+ BOOLEAN_INPUT_VALUES,
35
+ DEFAULT_FIRE_TIMEOUT_MS,
28
36
  DEFAULT_LABEL_PREFIX,
29
37
  DUPLICATE,
30
38
  IGNORED,
@@ -47,11 +55,16 @@ import {
47
55
  lookupPreset,
48
56
  main,
49
57
  matchesBodyShape,
58
+ parseBooleanInput,
50
59
  parseLogins,
60
+ parsePayloadLabels,
61
+ readIssueLabels,
51
62
  renderOutputEntry,
52
63
  resolveConfig,
53
64
  resolveFingerprint,
54
65
  resolveFireOutcome,
66
+ resolveFireTimeoutMs,
67
+ resolveTriageState,
55
68
  selectMissingLabels,
56
69
  writeGithubOutput,
57
70
  } from "../.github/actions/issue-intake/issue-intake.mjs";
@@ -62,13 +75,21 @@ const LOGINS = [PRODUCER, "second-bot"];
62
75
  const PRESET = "structured-report";
63
76
  const MATCHING_BODY = ["An alert fired in production.", "", "Fingerprint: alert-7731-cpu", ""].join("\n");
64
77
  const NON_MATCHING_BODY = "Hey, the site felt slow this morning. Could someone look?";
78
+ const ISSUE_NUMBER = 412;
79
+ const TRIAGE_LABEL = "intake:triage";
80
+ // The fire endpoint is asserted through MARKER, never by substring-matching the
81
+ // URL itself: `js/incomplete-url-substring-sanitization` fires on a containment
82
+ // check against a URL-shaped constant, and it is a high-severity CodeQL block.
83
+ const FIRE_MARKER = "routine-endpoint";
84
+ const FIRE_URL = `https://${FIRE_MARKER}.test/v1/routines`;
85
+ const FIRE_TOKEN = "not-a-real-token-fixture";
65
86
 
66
87
  // ---------------------------------------------------------------------------
67
88
  // Fake `gh` runner — records every call so "wrote nothing" is assertable.
68
89
  // ---------------------------------------------------------------------------
69
90
 
70
91
  /**
71
- * @param {{labels?: string[], duplicates?: Array<{number: number, body: string}>, createFails?: (name: string) => Error|null}} [opts]
92
+ * @param {{labels?: string[], issueLabels?: string[] | (() => string[]), duplicates?: Array<{number: number, body: string}>, createFails?: (name: string) => Error|null}} [opts]
72
93
  */
73
94
  function fakeRunner(opts = {}) {
74
95
  const labels = opts.labels ?? [];
@@ -78,6 +99,17 @@ function fakeRunner(opts = {}) {
78
99
  const runner = (args, ctx) => {
79
100
  calls.push({ args, ctx });
80
101
 
102
+ // The live read of ONE issue's labels. Matched before the repo-wide label
103
+ // page below, because `repos/<repo>/issues/<n>/labels` ends in `/labels`
104
+ // too — a fake that confused the two would answer the wrong question.
105
+ // Compared segment by segment rather than by substring or regex: CodeQL
106
+ // blocks both shapes on endpoint-like strings, and equality is clearer.
107
+ const endpoint = String(args[1]).split("/");
108
+ if (args[0] === "api" && endpoint.length === 6 && endpoint[3] === "issues" && endpoint[5] === "labels") {
109
+ const source = opts.issueLabels ?? [];
110
+ const names = typeof source === "function" ? source() : source;
111
+ return JSON.stringify(names.map((name) => ({ name })));
112
+ }
81
113
  if (args[0] === "api" && String(args[1]).endsWith("/labels")) {
82
114
  const pageArg = args.find((a) => String(a).startsWith("page="));
83
115
  const page = Number(String(pageArg).slice("page=".length));
@@ -100,6 +132,7 @@ function fakeRunner(opts = {}) {
100
132
  };
101
133
 
102
134
  runner.calls = calls;
135
+ runner.reads = () => calls.filter(({ args }) => args[0] === "api");
103
136
  runner.mutations = () =>
104
137
  calls.filter(
105
138
  ({ args }) =>
@@ -140,6 +173,8 @@ const envFor = (overrides = {}) => ({
140
173
  INTAKE_FIRE_URL: "",
141
174
  INTAKE_FIRE_TOKEN: "",
142
175
  INTAKE_DRY_RUN: "false",
176
+ // What a re-run replays: the labels as they stood when the issue was OPENED.
177
+ INTAKE_ISSUE_LABELS: "[]",
143
178
  ...overrides,
144
179
  });
145
180
 
@@ -330,19 +365,24 @@ test("AC-3: label discovery pages past the first 200 (313-label fixture)", () =>
330
365
 
331
366
  test("AC-3: an intake label sitting past page one is never re-created", () => {
332
367
  const plan = intakeLabelPlan(DEFAULT_LABEL_PREFIX);
333
- const labels = [
334
- ...Array.from({ length: 311 }, (_, i) => `label-${i}`),
335
- plan[0].name,
336
- plan[1].name,
337
- ];
368
+ const labels = [...Array.from({ length: 312 }, (_, i) => `label-${i}`), plan[0].name];
338
369
  const runner = fakeRunner({ labels });
339
370
  const result = ensureIntakeLabels({ repo: REPO, prefix: DEFAULT_LABEL_PREFIX }, runner);
340
371
 
341
372
  assert.equal(result.discovered, 313);
342
- assert.deepEqual(result.created, [plan[2].name], "only the genuinely absent label is created");
373
+ assert.deepEqual(result.created, [plan[1].name], "only the genuinely absent label is created");
343
374
  assert.deepEqual(result.skipped, []);
344
375
  });
345
376
 
377
+ test("AC-3: `gh api` is scoped by its endpoint path, never by a --repo flag", () => {
378
+ const runner = fakeRunner({ labels: [] });
379
+ listRepoLabels({ repo: REPO }, runner);
380
+
381
+ const [{ args }] = runner.reads();
382
+ assert.ok(!args.includes("--repo"), "`gh api` exits non-zero on an unknown --repo flag");
383
+ assert.equal(args[1], `repos/${REPO}/labels`, "the repo is spelled into the endpoint instead");
384
+ });
385
+
346
386
  test("AC-3: a create refused as already-existing returns success as a skip", () => {
347
387
  const runner = fakeRunner({
348
388
  labels: [],
@@ -354,7 +394,7 @@ test("AC-3: a create refused as already-existing returns success as a skip", ()
354
394
  const result = ensureIntakeLabels({ repo: REPO, prefix: DEFAULT_LABEL_PREFIX }, runner);
355
395
 
356
396
  assert.deepEqual(result.skipped, [labelFor(DEFAULT_LABEL_PREFIX, DUPLICATE)]);
357
- assert.equal(result.created.length, 2);
397
+ assert.deepEqual(result.created, [labelFor(DEFAULT_LABEL_PREFIX, TRIAGE)]);
358
398
  });
359
399
 
360
400
  test("a genuine create failure is still an error", () => {
@@ -373,7 +413,48 @@ test("classifyLabelCreateFailure separates the benign refusal from a real failur
373
413
 
374
414
  test("missing-label selection ignores case, matching GitHub's own collision rule", () => {
375
415
  const plan = intakeLabelPlan("Intake");
376
- assert.deepEqual(selectMissingLabels(["intake:triage", "intake:ignored"], plan), [plan[1]]);
416
+ assert.deepEqual(selectMissingLabels(["intake:triage", "needs-triage"], plan), [plan[1]]);
417
+ });
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // AC-6 — the label plan owns TWO labels
421
+ // ---------------------------------------------------------------------------
422
+
423
+ test("AC-6: the label plan owns exactly two labels, and `ignored` is not one", () => {
424
+ const plan = intakeLabelPlan(DEFAULT_LABEL_PREFIX);
425
+ assert.deepEqual(
426
+ plan.map((l) => l.name),
427
+ [labelFor(DEFAULT_LABEL_PREFIX, TRIAGE), labelFor(DEFAULT_LABEL_PREFIX, DUPLICATE)],
428
+ );
429
+ assert.ok(
430
+ !plan.some((l) => l.name === labelFor(DEFAULT_LABEL_PREFIX, IGNORED)),
431
+ "an `ignored` label could never be applied — the verdict is inert by invariant",
432
+ );
433
+ });
434
+
435
+ test("AC-6: only the two planned labels are ever created, on a repo with none", () => {
436
+ const runner = fakeRunner({ labels: [] });
437
+ const result = ensureIntakeLabels({ repo: REPO, prefix: DEFAULT_LABEL_PREFIX }, runner);
438
+
439
+ assert.deepEqual(result.created, ["intake:triage", "intake:duplicate"]);
440
+ assert.equal(
441
+ runner.calls.filter(({ args }) => args[0] === "label" && args[1] === "create").length,
442
+ 2,
443
+ );
444
+ });
445
+
446
+ test("AC-6: an ignored verdict still writes nothing, with the label gone", async () => {
447
+ const runner = fakeRunner({ labels: [] });
448
+ const { result } = await withCapturedConsole(() =>
449
+ main(envFor({ INTAKE_ISSUE_AUTHOR: "stranger" }), {
450
+ runner,
451
+ fetchImpl: async () => {
452
+ throw new Error("an ignored issue must never fire");
453
+ },
454
+ }),
455
+ );
456
+ assert.equal(result, 0);
457
+ assert.deepEqual(runner.calls, [], "not even the live label read is worth spending on it");
377
458
  });
378
459
 
379
460
  // ---------------------------------------------------------------------------
@@ -424,8 +505,8 @@ test("AC-5: a configured fire that is refused exits non-zero", async () => {
424
505
  const { result, err } = await withCapturedConsole(() =>
425
506
  main(
426
507
  envFor({
427
- INTAKE_FIRE_URL: "https://api.anthropic.test/v1/routines",
428
- INTAKE_FIRE_TOKEN: "routine-token",
508
+ INTAKE_FIRE_URL: FIRE_URL,
509
+ INTAKE_FIRE_TOKEN: FIRE_TOKEN,
429
510
  }),
430
511
  { runner, fetchImpl: async () => ({ ok: false, status: 503 }) },
431
512
  ),
@@ -458,8 +539,8 @@ test("AC-5: a configured fire that is accepted exits zero", async () => {
458
539
  const { result } = await withCapturedConsole(() =>
459
540
  main(
460
541
  envFor({
461
- INTAKE_FIRE_URL: "https://api.anthropic.test/v1/routines",
462
- INTAKE_FIRE_TOKEN: "routine-token",
542
+ INTAKE_FIRE_URL: FIRE_URL,
543
+ INTAKE_FIRE_TOKEN: FIRE_TOKEN,
463
544
  }),
464
545
  {
465
546
  runner,
@@ -474,7 +555,7 @@ test("AC-5: a configured fire that is accepted exits zero", async () => {
474
555
  assert.equal(result, 0);
475
556
  assert.equal(seen.length, 1);
476
557
  assert.equal(seen[0].init.method, "POST");
477
- assert.equal(seen[0].url, "https://api.anthropic.test/v1/routines");
558
+ assert.equal(seen[0].url, FIRE_URL);
478
559
  assert.equal(seen[0].init.headers["anthropic-version"], ANTHROPIC_VERSION);
479
560
  assert.equal(seen[0].init.headers["anthropic-beta"], ANTHROPIC_BETA);
480
561
  assert.deepEqual(
@@ -518,7 +599,7 @@ test("the fire body is a single `text` field naming the issue, not quoting it",
518
599
 
519
600
  test("a transport-level fire failure is a refusal, not a crash", async () => {
520
601
  const outcome = await fireRoutine(
521
- { url: "https://api.anthropic.test/v1/routines", token: "t", payload: "{}" },
602
+ { url: FIRE_URL, token: "t", payload: "{}" },
522
603
  async () => {
523
604
  throw new Error("ECONNREFUSED");
524
605
  },
@@ -534,8 +615,8 @@ test("a duplicate never re-fires — that storm is what dedupe exists to stop",
534
615
  const { result } = await withCapturedConsole(() =>
535
616
  main(
536
617
  envFor({
537
- INTAKE_FIRE_URL: "https://api.anthropic.test/v1/routines",
538
- INTAKE_FIRE_TOKEN: "routine-token",
618
+ INTAKE_FIRE_URL: FIRE_URL,
619
+ INTAKE_FIRE_TOKEN: FIRE_TOKEN,
539
620
  }),
540
621
  {
541
622
  runner,
@@ -548,15 +629,263 @@ test("a duplicate never re-fires — that storm is what dedupe exists to stop",
548
629
  assert.equal(result, 0);
549
630
  });
550
631
 
632
+ // ---------------------------------------------------------------------------
633
+ // AC-1 / AC-2 — the CURRENT labels decide, and they are read LIVE
634
+ // ---------------------------------------------------------------------------
635
+
636
+ test("AC-1: an issue already carrying the trigger label is inert on a re-run", async () => {
637
+ const runner = fakeRunner({ labels: [], issueLabels: [TRIAGE_LABEL] });
638
+ const { result, log } = await withCapturedConsole(() =>
639
+ main(envFor({ INTAKE_FIRE_URL: FIRE_URL, INTAKE_FIRE_TOKEN: FIRE_TOKEN }), {
640
+ runner,
641
+ fetchImpl: async () => {
642
+ throw new Error("an already-triaged issue must never wake a second routine");
643
+ },
644
+ }),
645
+ );
646
+
647
+ assert.equal(result, 0, "a re-run over settled work is a no-op, not a failure");
648
+ assert.deepEqual(runner.mutations(), [], "no label is created and none is applied");
649
+ assert.ok(
650
+ log.some((l) => l.includes("already carries")),
651
+ "the run says why it stopped, so a reader is not left guessing",
652
+ );
653
+ });
654
+
655
+ test("AC-2: the LIVE read decides, not the payload a re-run replays", async () => {
656
+ // Exactly the re-run shape: `issues.opened` captured an unlabelled issue,
657
+ // and the first run's own label write happened after that snapshot.
658
+ const runner = fakeRunner({ labels: [], issueLabels: [TRIAGE_LABEL] });
659
+ const { result } = await withCapturedConsole(() =>
660
+ main(
661
+ envFor({
662
+ INTAKE_ISSUE_LABELS: "[]",
663
+ INTAKE_FIRE_URL: FIRE_URL,
664
+ INTAKE_FIRE_TOKEN: FIRE_TOKEN,
665
+ }),
666
+ {
667
+ runner,
668
+ fetchImpl: async () => {
669
+ throw new Error("the stale payload must not be allowed to re-fire");
670
+ },
671
+ },
672
+ ),
673
+ );
674
+
675
+ assert.equal(result, 0);
676
+ assert.deepEqual(runner.mutations(), []);
677
+ });
678
+
679
+ test("AC-2: a failed live read falls back to the payload, and says so", async () => {
680
+ const runner = fakeRunner({
681
+ labels: [],
682
+ issueLabels: () => {
683
+ throw new Error("HTTP 503: upstream unavailable");
684
+ },
685
+ });
686
+ const { result, err } = await withCapturedConsole(() =>
687
+ main(envFor({ INTAKE_ISSUE_LABELS: JSON.stringify([{ name: TRIAGE_LABEL }]) }), { runner }),
688
+ );
689
+
690
+ assert.equal(result, 0, "the payload still reports the issue as triaged");
691
+ assert.deepEqual(runner.mutations(), [], "so the fallback answer is honoured");
692
+ assert.ok(
693
+ err.some((l) => l.startsWith("::warning::") && l.includes("falling back")),
694
+ "taking the stale answer is never silent",
695
+ );
696
+ });
697
+
698
+ test("AC-2: a live read that fails over an UNLABELLED payload still proceeds", async () => {
699
+ const runner = fakeRunner({
700
+ labels: [],
701
+ issueLabels: () => {
702
+ throw new Error("HTTP 503: upstream unavailable");
703
+ },
704
+ });
705
+ const { result } = await withCapturedConsole(() => main(envFor(), { runner }));
706
+
707
+ assert.equal(result, 0);
708
+ const edit = runner.calls.find(({ args }) => args[0] === "issue" && args[1] === "edit");
709
+ assert.ok(edit.args.includes(TRIAGE_LABEL), "a read outage must not strand intake entirely");
710
+ });
711
+
712
+ test("the live read asks about ONE issue, not the repo's label catalogue", () => {
713
+ const runner = fakeRunner({ issueLabels: ["bug", TRIAGE_LABEL] });
714
+ const names = readIssueLabels({ repo: REPO, issueNumber: ISSUE_NUMBER }, runner);
715
+
716
+ assert.deepEqual(names, ["bug", TRIAGE_LABEL]);
717
+ assert.equal(runner.calls[0].args[1], `repos/${REPO}/issues/${ISSUE_NUMBER}/labels`);
718
+ assert.ok(!runner.calls[0].args.includes("--repo"), "`gh api` takes no --repo flag");
719
+ });
720
+
721
+ test("the trigger-label comparison is case-insensitive, as GitHub's own is", () => {
722
+ const runner = fakeRunner({ issueLabels: ["Intake:Triage"] });
723
+ const state = resolveTriageState(
724
+ { repo: REPO, issueNumber: ISSUE_NUMBER, triageLabel: TRIAGE_LABEL, payloadLabels: "[]" },
725
+ runner,
726
+ );
727
+ assert.equal(state.alreadyTriaged, true, "GitHub refuses two labels differing only in case");
728
+ assert.equal(state.source, "live");
729
+ });
730
+
731
+ test("payload labels parse from either shape, and unparseable input is empty", () => {
732
+ assert.deepEqual(parsePayloadLabels(JSON.stringify([{ name: "a" }, { name: "b" }])), ["a", "b"]);
733
+ assert.deepEqual(parsePayloadLabels(JSON.stringify(["a", "b"])), ["a", "b"]);
734
+ assert.deepEqual(parsePayloadLabels(""), []);
735
+ assert.deepEqual(parsePayloadLabels(undefined), []);
736
+ assert.deepEqual(parsePayloadLabels("{not json"), [], "the degraded path must not throw");
737
+ assert.deepEqual(parsePayloadLabels(JSON.stringify({ name: "a" })), []);
738
+ });
739
+
740
+ // ---------------------------------------------------------------------------
741
+ // AC-3 — the fire is BOUNDED, by a race and not by a forwarded signal
742
+ // ---------------------------------------------------------------------------
743
+
744
+ test("AC-3: a fetch that never settles and ignores init.signal still times out", async () => {
745
+ const runner = fakeRunner({ labels: [] });
746
+ const started = Date.now();
747
+ const { result, err } = await withCapturedConsole(() =>
748
+ main(
749
+ envFor({
750
+ INTAKE_FIRE_URL: FIRE_URL,
751
+ INTAKE_FIRE_TOKEN: FIRE_TOKEN,
752
+ INTAKE_FIRE_TIMEOUT_MS: "60",
753
+ }),
754
+ {
755
+ runner,
756
+ // A wedged endpoint: the connection is accepted and nothing ever comes
757
+ // back. Forwarding `init.signal` alone would hang here forever.
758
+ fetchImpl: () => new Promise(() => {}),
759
+ },
760
+ ),
761
+ );
762
+ const elapsed = Date.now() - started;
763
+
764
+ assert.equal(result, 1, "a bounded-out fire is a refusal, and a refusal reds the run");
765
+ assert.ok(elapsed < 2000, `the run must not outlive its bound (took ${elapsed}ms)`);
766
+ assert.ok(err.some((l) => l.startsWith("::error::") && l.includes("refused")));
767
+ });
768
+
769
+ test("AC-3: the timeout is reported as such, not as a mystery transport error", async () => {
770
+ const outcome = await fireRoutine(
771
+ { url: FIRE_URL, token: FIRE_TOKEN, payload: "{}", timeoutMs: 25 },
772
+ () => new Promise(() => {}),
773
+ );
774
+ assert.equal(outcome.delivered, false);
775
+ assert.match(outcome.detail, /no response within 25ms/);
776
+ });
777
+
778
+ test("AC-3: an abort signal is still forwarded, for a fetch that honours one", async () => {
779
+ const seen = [];
780
+ await fireRoutine({ url: FIRE_URL, token: FIRE_TOKEN, payload: "{}", timeoutMs: 500 }, async (_u, init) => {
781
+ seen.push(init.signal);
782
+ return { ok: true, status: 200 };
783
+ });
784
+ assert.equal(seen.length, 1);
785
+ assert.ok(seen[0] instanceof AbortSignal, "a compliant fetch should still tear the socket down");
786
+ assert.equal(seen[0].aborted, false);
787
+ });
788
+
789
+ test("AC-3: a fire well inside its bound is delivered normally", async () => {
790
+ const outcome = await fireRoutine(
791
+ { url: FIRE_URL, token: FIRE_TOKEN, payload: "{}", timeoutMs: 1000 },
792
+ async () => ({ ok: true, status: 202 }),
793
+ );
794
+ assert.deepEqual(outcome, { delivered: true, detail: "HTTP 202" });
795
+ });
796
+
797
+ test("the fire timeout falls back to the production default on a nonsense value", () => {
798
+ assert.equal(resolveFireTimeoutMs("250"), 250);
799
+ assert.equal(resolveFireTimeoutMs(" 250 "), 250);
800
+ assert.equal(resolveFireTimeoutMs(""), DEFAULT_FIRE_TIMEOUT_MS);
801
+ assert.equal(resolveFireTimeoutMs(undefined), DEFAULT_FIRE_TIMEOUT_MS);
802
+ assert.equal(resolveFireTimeoutMs("soon"), DEFAULT_FIRE_TIMEOUT_MS);
803
+ assert.equal(resolveFireTimeoutMs("0"), DEFAULT_FIRE_TIMEOUT_MS, "a zero bound would refuse everything");
804
+ assert.equal(resolveFireTimeoutMs("-5"), DEFAULT_FIRE_TIMEOUT_MS);
805
+ assert.equal(resolveConfig(envFor({ INTAKE_FIRE_TIMEOUT_MS: "90" })).fireTimeoutMs, 90);
806
+ });
807
+
808
+ // ---------------------------------------------------------------------------
809
+ // AC-4 / AC-5 — dry-run previews the LIVE verdict, and parses strictly
810
+ // ---------------------------------------------------------------------------
811
+
812
+ test("AC-4: a dry run previews `duplicate`, because the lookup is a read", async () => {
813
+ const runner = fakeRunner({
814
+ labels: [],
815
+ duplicates: [{ number: 88, body: "Fingerprint: alert-7731-cpu" }],
816
+ });
817
+ const { result, log } = await withCapturedConsole(() =>
818
+ main(envFor({ INTAKE_DRY_RUN: "true", INTAKE_FIRE_URL: FIRE_URL, INTAKE_FIRE_TOKEN: FIRE_TOKEN }), {
819
+ runner,
820
+ fetchImpl: async () => {
821
+ throw new Error("a dry run must not fire");
822
+ },
823
+ }),
824
+ );
825
+
826
+ assert.equal(result, 0);
827
+ assert.deepEqual(runner.mutations(), [], "a preview writes nothing");
828
+ assert.ok(
829
+ log.some((l) => l.includes(`${DUPLICATE} —`)),
830
+ "the preview reports the verdict the real run would reach",
831
+ );
832
+ assert.ok(
833
+ !log.some((l) => l.includes("would label issue #412 `intake:triage`")),
834
+ "a preview that skipped the lookup would print the wrong verdict",
835
+ );
836
+ });
837
+
838
+ test("AC-5: dry-run accepts either boolean in any casing", () => {
839
+ for (const raw of ["true", "True", "TRUE", " true "]) {
840
+ assert.equal(resolveConfig(envFor({ INTAKE_DRY_RUN: raw })).dryRun, true, raw);
841
+ }
842
+ for (const raw of ["false", "FALSE", "False", ""]) {
843
+ const cfg = resolveConfig(envFor({ INTAKE_DRY_RUN: raw }));
844
+ assert.equal(cfg.dryRun, false, raw);
845
+ assert.equal(cfg.error, null, raw);
846
+ }
847
+ });
848
+
849
+ test("AC-5: `dry-run: True` really does suppress the writes", async () => {
850
+ const runner = fakeRunner({ labels: [] });
851
+ const { result, log } = await withCapturedConsole(() =>
852
+ main(envFor({ INTAKE_DRY_RUN: "True" }), { runner }),
853
+ );
854
+ assert.equal(result, 0);
855
+ assert.deepEqual(runner.mutations(), [], "a strict `=== \"true\"` compare would have written here");
856
+ assert.ok(log.some((l) => l.includes("(dry-run)")));
857
+ });
858
+
859
+ test("AC-5: an unrecognised dry-run value fails the run and names what is accepted", async () => {
860
+ const runner = fakeRunner({ labels: [] });
861
+ const { result, err } = await withCapturedConsole(() =>
862
+ main(envFor({ INTAKE_DRY_RUN: "yes" }), { runner }),
863
+ );
864
+
865
+ assert.equal(result, 1, "guessing a typo's meaning could turn a preview into a real run");
866
+ assert.deepEqual(runner.calls, [], "the refusal precedes every call");
867
+ const message = err.join("\n");
868
+ for (const accepted of BOOLEAN_INPUT_VALUES) assert.ok(message.includes(accepted), accepted);
869
+ });
870
+
871
+ test("parseBooleanInput reports the offending value, and falls back safely", () => {
872
+ assert.deepEqual(parseBooleanInput("TRUE", { name: "dry-run" }), { value: true, error: null });
873
+ assert.deepEqual(parseBooleanInput(undefined, { name: "dry-run" }), { value: false, error: null });
874
+ const bad = parseBooleanInput("1", { name: "dry-run" });
875
+ assert.equal(bad.value, false, "the fallback is the safe reading, whatever the error does");
876
+ assert.match(String(bad.error), /dry-run must be one of true, false/);
877
+ assert.match(String(bad.error), /got "1"/);
878
+ });
879
+
551
880
  // ---------------------------------------------------------------------------
552
881
  // Configuration contract
553
882
  // ---------------------------------------------------------------------------
554
883
 
555
- test("resolveConfig defaults the label prefix and reads the dry-run flag literally", () => {
556
- const cfg = resolveConfig(envFor({ INTAKE_LABEL_PREFIX: "", INTAKE_DRY_RUN: "TRUE" }));
884
+ test("resolveConfig defaults the label prefix and the fire bound", () => {
885
+ const cfg = resolveConfig(envFor({ INTAKE_LABEL_PREFIX: "", INTAKE_FIRE_TIMEOUT_MS: "" }));
557
886
  assert.equal(cfg.error, null);
558
887
  assert.equal(cfg.labelPrefix, DEFAULT_LABEL_PREFIX);
559
- assert.equal(cfg.dryRun, false, "only the exact string `true` enables a dry run");
888
+ assert.equal(cfg.fireTimeoutMs, DEFAULT_FIRE_TIMEOUT_MS);
560
889
  });
561
890
 
562
891
  test("resolveConfig rejects each misconfiguration with a named reason", () => {
@@ -569,6 +898,7 @@ test("resolveConfig rejects each misconfiguration with a named reason", () => {
569
898
  [{ INTAKE_PRODUCER_LOGINS: " , " }, /INTAKE_PRODUCER_LOGINS is required/],
570
899
  [{ INTAKE_LABEL_PREFIX: "not a prefix!" }, /not a usable label-name prefix/],
571
900
  [{ INTAKE_FIRE_URL: "https://x.test", INTAKE_FIRE_TOKEN: "" }, /needs its bearer token/],
901
+ [{ INTAKE_DRY_RUN: "yes" }, /dry-run must be one of true, false/],
572
902
  ];
573
903
  for (const [overrides, shape] of cases) {
574
904
  const cfg = resolveConfig(envFor(overrides));