forgedock 1.35.0 → 1.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 (129) hide show
  1. package/README.md +78 -324
  2. package/bin/bot-token-status.mjs +26 -0
  3. package/bin/engine/events.mjs +161 -0
  4. package/bin/engine/events.test.mjs +142 -0
  5. package/bin/engine/outcome-codes.mjs +39 -1
  6. package/bin/engine/phases.mjs +65 -5
  7. package/bin/engine/telemetry-seam.mjs +5 -4
  8. package/bin/engine/telemetry-seam.test.mjs +4 -0
  9. package/bin/engine-cli.mjs +372 -46
  10. package/bin/engine.mjs +3 -1
  11. package/bin/experiment.mjs +55 -0
  12. package/bin/forgedock.mjs +108 -5
  13. package/bin/hooks/reread-nudge.mjs +15 -4
  14. package/bin/labels.json +50 -0
  15. package/bin/lean/batch.mjs +52 -6
  16. package/bin/lean/batch.test.mjs +176 -2
  17. package/bin/lean/conformance.e2e.test.mjs +1 -1
  18. package/bin/lean/conformance.mjs +136 -11
  19. package/bin/lean/conformance.test.mjs +148 -1
  20. package/bin/lean/decisions.mjs +181 -0
  21. package/bin/lean/decisions.test.mjs +71 -0
  22. package/bin/lean/effects.mjs +32 -14
  23. package/bin/lean/execution.mjs +62 -16
  24. package/bin/lean/execution.test.mjs +59 -2
  25. package/bin/lean/gatewatch.mjs +31 -0
  26. package/bin/lean/gatewatch.test.mjs +59 -0
  27. package/bin/lean/guardgate.mjs +170 -0
  28. package/bin/lean/guardgate.test.mjs +153 -0
  29. package/bin/lean/guards.mjs +243 -0
  30. package/bin/lean/guards.test.mjs +177 -0
  31. package/bin/lean/heavy.mjs +54 -8
  32. package/bin/lean/heavy.test.mjs +74 -1
  33. package/bin/lean/hostkill.mjs +1 -1
  34. package/bin/lean/i18n.mjs +65 -0
  35. package/bin/lean/i18n.test.mjs +47 -0
  36. package/bin/lean/integration.mjs +276 -0
  37. package/bin/lean/integration.test.mjs +189 -0
  38. package/bin/lean/lane.mjs +5 -2
  39. package/bin/lean/lean.mjs +169 -30
  40. package/bin/lean/lean.test.mjs +360 -24
  41. package/bin/lean/lifecycle.mjs +129 -0
  42. package/bin/lean/lifecycle.test.mjs +118 -0
  43. package/bin/lean/light.mjs +1 -1
  44. package/bin/lean/milestonesync.mjs +130 -0
  45. package/bin/lean/milestonesync.test.mjs +133 -0
  46. package/bin/lean/orchestrate.mjs +132 -20
  47. package/bin/lean/parked-sweep.mjs +45 -0
  48. package/bin/lean/parked-sweep.test.mjs +71 -0
  49. package/bin/lean/postdeploy-check.mjs +181 -0
  50. package/bin/lean/postdeploy-check.test.mjs +206 -0
  51. package/bin/lean/postdeploy-monitor.mjs +97 -0
  52. package/bin/lean/postdeploy-monitor.test.mjs +147 -0
  53. package/bin/lean/precheck.mjs +6 -4
  54. package/bin/lean/precheck.test.mjs +17 -2
  55. package/bin/lean/prefilter.mjs +106 -21
  56. package/bin/lean/prefilter.test.mjs +86 -6
  57. package/bin/lean/probe.mjs +4 -3
  58. package/bin/lean/probe.test.mjs +1 -1
  59. package/bin/lean/prompts.mjs +23 -9
  60. package/bin/lean/resume.test.mjs +1 -1
  61. package/bin/lean/retry.mjs +28 -0
  62. package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
  63. package/bin/lean/sandbox-remote.mjs +11 -4
  64. package/bin/lean/schedd.mjs +77 -13
  65. package/bin/lean/schedd.test.mjs +131 -5
  66. package/bin/lean/sequence.mjs +24 -0
  67. package/bin/lean/sequence.test.mjs +31 -1
  68. package/bin/lean/signoff.mjs +66 -0
  69. package/bin/lean/signoff.test.mjs +102 -0
  70. package/bin/lean/train.mjs +177 -0
  71. package/bin/lean/train.test.mjs +113 -0
  72. package/bin/pool/cli.mjs +2 -0
  73. package/bin/pool/health.mjs +30 -0
  74. package/bin/review-context.mjs +298 -0
  75. package/bin/runner.mjs +26 -3
  76. package/bin/stats/aggregate.mjs +1 -1
  77. package/bin/stats/compounding.mjs +157 -0
  78. package/bin/stats/compounding.test.mjs +74 -0
  79. package/bin/stats/experiment.mjs +44 -0
  80. package/bin/stats/github-source.mjs +2 -1
  81. package/bin/stats/index.mjs +62 -3
  82. package/bin/stats/release.mjs +75 -0
  83. package/bin/stats/release.test.mjs +68 -0
  84. package/commands/bootstrap.md +10 -8
  85. package/commands/deploy-info.md +3 -0
  86. package/commands/issue.md +2 -0
  87. package/commands/milestone.md +43 -54
  88. package/commands/orchestrate/phase-4-execution.md +2 -0
  89. package/commands/orchestrate.md +15 -3
  90. package/commands/pipeline-status.md +20 -0
  91. package/commands/review-pr.md +1 -0
  92. package/commands/work-on/build/implement.md +1 -1
  93. package/commands/work-on/build.md +4 -2
  94. package/commands/work-on/investigate.md +3 -1
  95. package/commands/work-on/remediate.md +1 -1
  96. package/docs/CONFIG.md +120 -2
  97. package/docs/EVENTS.md +65 -0
  98. package/docs/OUTCOME-CODES.md +9 -1
  99. package/docs/STATS.md +51 -0
  100. package/docs/TELEMETRY.md +3 -1
  101. package/docs/site/install.md +89 -0
  102. package/docs/site/start-a-new-project.md +1 -1
  103. package/docs/spec/label-state-machine.md +6 -0
  104. package/package.json +1 -1
  105. package/pool/install/install-agent.sh +4 -2
  106. package/pool/install/install-router.sh +4 -2
  107. package/pool/install/install-schedd.sh +28 -0
  108. package/pool/install/unit-hardening.sh +34 -0
  109. package/scripts/acceptance-red-green.sh +19 -2
  110. package/scripts/acceptance-red-green.test.sh +2 -0
  111. package/scripts/bench-lightlane.mjs +2 -1
  112. package/scripts/bootstrap-create.mjs +3 -2
  113. package/scripts/bootstrap-validate.mjs +31 -4
  114. package/scripts/eval-gate-scorecard.mjs +7 -0
  115. package/scripts/file-deferred-followups.sh +286 -77
  116. package/scripts/file-deferred-followups.test.sh +117 -2
  117. package/scripts/hotspots.sh +23 -2
  118. package/scripts/hotspots.test.sh +9 -0
  119. package/scripts/plan-validate-live.mjs +111 -0
  120. package/scripts/readiness.mjs +141 -0
  121. package/scripts/review-prepare.sh +17 -1
  122. package/scripts/review-prepare.test.sh +17 -0
  123. package/scripts/settle-scope.mjs +74 -0
  124. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  125. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  126. package/scripts/wait-ci-green.sh +14 -3
  127. package/scripts/wait-ci-green.test.sh +3 -1
  128. package/templates/bootstrap/needs-human-issue.md +4 -1
  129. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
@@ -6,7 +6,7 @@
6
6
  # yq (optional) only to read forge.yaml. Run from the repo root.
7
7
  #
8
8
  # Usage: hotspots.sh [--base <ref>] [--] <path>...
9
- # Config: forge.yaml maintenance.hotspot {enabled, file_lines, growth_lines, exclude_globs}; defaults true/2000/150/[]
9
+ # Config: forge.yaml maintenance.hotspot {enabled, file_lines, growth_lines, exclude_globs}; defaults true/2000/150/[] (lockfiles are always excluded)
10
10
  # (also when forge.yaml or yq is missing); maintenance.generated_paths (forge#4519): shell globs of generated,
11
11
  # bot-owned files, default [].
12
12
  # Output (stdout, one JSON object):
@@ -57,7 +57,28 @@ if [ -n "$BASE" ]; then
57
57
  MB=$(git merge-base "$BASE" HEAD) || die "no merge base for $BASE and HEAD"
58
58
  fi
59
59
  HAS_HEAD=0; git rev-parse --verify -q "HEAD^{commit}" >/dev/null && HAS_HEAD=1
60
- GLOBS=$(printf '%s' "$CFG" | jq -r '.exclude_globs[]')
60
+ # #4685: lockfiles are never hotspots (one dependency adds hundreds of lines); configured globs come on top
61
+ LOCKFILE_GLOBS='package-lock.json
62
+ */package-lock.json
63
+ yarn.lock
64
+ */yarn.lock
65
+ pnpm-lock.yaml
66
+ */pnpm-lock.yaml
67
+ bun.lockb
68
+ */bun.lockb
69
+ Cargo.lock
70
+ */Cargo.lock
71
+ poetry.lock
72
+ */poetry.lock
73
+ uv.lock
74
+ */uv.lock
75
+ go.sum
76
+ */go.sum
77
+ Gemfile.lock
78
+ */Gemfile.lock
79
+ composer.lock
80
+ */composer.lock'
81
+ GLOBS=$(printf '%s\n%s' "$LOCKFILE_GLOBS" "$(printf '%s' "$CFG" | jq -r '.exclude_globs[]')")
61
82
  GEN_GLOBS=$(printf '%s' "$GEN_CFG" | jq -r '.[]')
62
83
  # 0 when $1 matches a newline-separated list of shell patterns in $2 (`*` crosses `/`)
63
84
  glob_hit() {
@@ -116,5 +116,14 @@ if grep -q '"rev-list"' "$TMP/trace.json"; then ok; else bad "git trace not capt
116
116
  if grep -qE '"(cat-file|show)"' "$TMP/trace.json"; then bad "blob read without --base"; else ok; fi
117
117
  if [ -e "$TMP/net.log" ]; then bad "network tool invoked: $(tr '\n' ' ' < "$TMP/net.log")"; else ok; fi
118
118
 
119
+ # ---- #4685: lockfiles are excluded by default, with no forge.yaml; ordinary files are not ----
120
+ new_repo
121
+ lines web.lock 10; mkdir -p web; lines web/package-lock.json 10; lines app.py 10; commit base
122
+ git checkout -q -b feat2
123
+ lines more 900; cat more >> web/package-lock.json; cat more >> app.py; commit grow
124
+ OUT="$(bash "$S" --base main -- web/package-lock.json app.py)"
125
+ eq "lockfile excluded" "$(q '.files[0] | [.excluded,.growth]')" '[true,900]'
126
+ eq "ordinary file not excluded" "$(q '.files[1].excluded')" 'false'
127
+
119
128
  echo "hotspots.test.sh: $PASS passed, $FAILN failed"
120
129
  [ "$FAILN" -eq 0 ]
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/plan-validate-live.mjs — bootstrap-validate's dependency-order rules on LIVE GitHub state (#4659)
4
+ *
5
+ * `forgedock plan validate --live` and the parallel-start gate in `forgedock orchestrate`. Deterministic, no LLM.
6
+ * Milestone (train) order: due_on ascending (nulls last), then milestone number; GitHub has no explicit order field.
7
+ * Each cross-milestone `Depends on` edge is `ok` (dep in an earlier milestone and closed), `held` (earlier, not
8
+ * shipped) or `invalid` (dep in a later milestone). Edges are read with the engine's parseDependsOn.
9
+ */
10
+ import { execFileSync } from "node:child_process";
11
+ import { fileURLToPath } from "node:url";
12
+ import { parseDependsOn } from "../bin/engine/phases.mjs";
13
+
14
+ /** Milestones in train order. Input: [{ title, number, due_on }]. */
15
+ export function orderMilestones(milestones) {
16
+ const due = (m) => (m?.due_on ? Date.parse(m.due_on) : Infinity);
17
+ return [...milestones].sort((a, b) => (due(a) - due(b) || 0) || (a.number ?? 0) - (b.number ?? 0)).map((m) => m.title);
18
+ }
19
+
20
+ /**
21
+ * @param {{ order: string[], issues: Array<{ number: number, milestone: string|null, body?: string, state?: string }> }} live
22
+ * @returns {{ ok: boolean, edges: Array<{ from: number, to: number, fromMilestone: string, toMilestone: string, state: "ok"|"held"|"invalid" }> }}
23
+ */
24
+ export function classifyEdges({ order, issues }) {
25
+ const index = new Map(order.map((t, i) => [t, i]));
26
+ const byNumber = new Map(issues.map((it) => [it.number, it]));
27
+ const edges = [];
28
+ for (const it of issues) {
29
+ if (!it.milestone || !index.has(it.milestone)) continue;
30
+ for (const n of parseDependsOn(it.body, it.number)) {
31
+ const dep = byNumber.get(n);
32
+ if (!dep?.milestone || dep.milestone === it.milestone || !index.has(dep.milestone)) continue;
33
+ const state = index.get(dep.milestone) > index.get(it.milestone) ? "invalid"
34
+ : String(dep.state).toUpperCase() === "CLOSED" ? "ok" : "held";
35
+ edges.push({ from: it.number, to: n, fromMilestone: it.milestone, toMilestone: dep.milestone, state });
36
+ }
37
+ }
38
+ return { ok: !edges.some((e) => e.state === "invalid"), edges };
39
+ }
40
+
41
+ export function formatLiveReport({ edges }) {
42
+ if (!edges.length) return "plan validate --live: no cross-milestone edges";
43
+ const lines = edges.map((e) => `${e.state}: #${e.from} (${e.fromMilestone}) depends on #${e.to} (${e.toMilestone})`);
44
+ const n = (s) => edges.filter((e) => e.state === s).length;
45
+ return `${lines.join("\n")}\nplan validate --live: ${n("ok")} ok, ${n("held")} held, ${n("invalid")} invalid`;
46
+ }
47
+
48
+ const parse = (s) => { try { return JSON.parse(String(s)); } catch { return null; } };
49
+
50
+ /** Read milestones and all issues of `repo` through `gh` (injectable: async (args) => stdout). */
51
+ export async function readLive({ gh, repo }) {
52
+ const r = repo ? ["-R", repo] : [];
53
+ const ms = parse(await gh(["api", `repos/${repo || "{owner}/{repo}"}/milestones?state=all&per_page=100`, "--paginate", "--slurp"]));
54
+ const issues = parse(await gh(["issue", "list", ...r, "--state", "all", "--limit", "1000", "--json", "number,body,state,milestone"]));
55
+ if (!Array.isArray(ms) || !Array.isArray(issues)) throw new Error("could not read milestones/issues from GitHub");
56
+ return { order: orderMilestones(ms.flat()), issues: issues.map((i) => ({ ...i, milestone: i.milestone?.title ?? null })) };
57
+ }
58
+
59
+ /**
60
+ * Parallel-start gate: dependencies of `issues` (not themselves in the batch) that are not merged into `baseOf(issue)`.
61
+ * A dep is merged when it is CLOSED by a PR that is merged with that base. Unreadable issues are skipped (fail open:
62
+ * the engine's own openDependency still guards each lane); an unreadable dep counts as unmet.
63
+ * @returns {Promise<Array<{ issue: number, dep: number, base: string, why: string }>>}
64
+ */
65
+ export async function unmetDependencies({ issues, baseOf, gh, repo }) {
66
+ const r = repo ? ["-R", repo] : [];
67
+ const view = async (n, fields) => parse(await gh(["issue", "view", String(n), ...r, "--json", fields]));
68
+ const inBatch = new Set(issues);
69
+ const unmet = [];
70
+ for (const n of issues) {
71
+ let rec;
72
+ try { rec = await view(n, "body,milestone"); } catch { rec = null; }
73
+ if (!rec || !rec.milestone) continue;
74
+ const base = await baseOf(n);
75
+ if (!base) continue;
76
+ for (const dep of parseDependsOn(rec.body, n).filter((d) => !inBatch.has(d))) {
77
+ let why = null;
78
+ try {
79
+ const d = await view(dep, "state,closedByPullRequestsReferences");
80
+ if (String(d?.state).toUpperCase() !== "CLOSED") why = "not closed";
81
+ else {
82
+ let merged = false;
83
+ for (const pr of d.closedByPullRequestsReferences ?? []) {
84
+ const p = parse(await gh(["pr", "view", String(pr.number), ...r, "--json", "baseRefName,mergedAt"]));
85
+ if (p?.mergedAt && p.baseRefName === base) { merged = true; break; }
86
+ }
87
+ if (!merged) why = `closed but not merged into ${base}`;
88
+ }
89
+ } catch { why = "unreadable"; }
90
+ if (why) unmet.push({ issue: n, dep, base, why });
91
+ }
92
+ }
93
+ return unmet;
94
+ }
95
+
96
+ const ghExec = async (args) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 100 * 1024 * 1024, timeout: 60000 });
97
+
98
+ /** CLI body: `plan validate --live [--repo owner/repo] [--json]`. Returns the exit code (1 when any edge is invalid). */
99
+ export async function runLive(argv, { gh = ghExec, out = (t) => process.stdout.write(`${t}\n`), err = (t) => process.stderr.write(`${t}\n`) } = {}) {
100
+ if (argv[0] !== "validate" || !argv.includes("--live")) { err("usage: forgedock plan validate --live [--repo owner/repo] [--json]"); return 2; }
101
+ const i = argv.indexOf("--repo");
102
+ const repo = i >= 0 ? argv[i + 1] : "";
103
+ let report;
104
+ try { report = classifyEdges(await readLive({ gh, repo })); } catch (e) { err(`plan validate --live: ${e.message}`); return 2; }
105
+ out(argv.includes("--json") ? JSON.stringify(report) : formatLiveReport(report));
106
+ return report.ok ? 0 : 1;
107
+ }
108
+
109
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
110
+ process.exitCode = await runLive(process.argv.slice(2));
111
+ }
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ // readiness.mjs — preview/deploy gate readiness from external prerequisites (forge#4660).
3
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ // SPDX-License-Identifier: AGPL-3.0-or-later
5
+ //
6
+ // Usage: readiness.mjs [--repo|-R owner/name] [--target preview|staging|prod] [--digest] [--issues file.json|-]
7
+ //
8
+ // A prerequisite is an open issue labelled `needs-human:external` whose body has a `gates:` line
9
+ // (`preview|staging-deploy|prod-deploy|feature-flag:<name>`, comma separated) and an `owner:` line.
10
+ // A code issue that carries its own `gates:` line names a milestone the gate still needs.
11
+ // Default output is the Readiness report; --digest prints the ordered human actions; --target prints
12
+ // DEPLOYABLE or NOT DEPLOYABLE for that target and exits 1 when not deployable.
13
+ import { readFileSync } from "node:fs";
14
+ import { spawnSync } from "node:child_process";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ export const EXTERNAL_LABEL = "needs-human:external";
18
+ const GATE_RE = /^(?:preview|staging-deploy|prod-deploy|feature-flag:[A-Za-z0-9._-]+)$/;
19
+ /** Deploy target -> the gate whose open prerequisites make it not deployable. */
20
+ export const TARGET_GATE = { preview: "preview", staging: "staging-deploy", prod: "prod-deploy" };
21
+
22
+ const labelNames = (i) => (Array.isArray(i?.labels) ? i.labels.map((l) => (typeof l === "string" ? l : l?.name)) : []);
23
+
24
+ /** Valid gate names from the issue body's `gates:` line(s); invalid entries are dropped. */
25
+ export function parseGates(body) {
26
+ const out = [];
27
+ for (const m of String(body ?? "").matchAll(/^[ \t]*gates:[ \t]*(.*)$/gim)) {
28
+ for (const g of m[1].split(",").map((s) => s.trim().replace(/^`|`$/g, ""))) if (GATE_RE.test(g) && !out.includes(g)) out.push(g);
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /** `owner:` value, or null. */
34
+ export function parseOwner(body) {
35
+ const m = /^[ \t]*owner:[ \t]*(\S.*?)[ \t]*$/im.exec(String(body ?? ""));
36
+ return m ? m[1] : null;
37
+ }
38
+
39
+ const deps = (body) => {
40
+ const m = /^[ \t]*depends on[ \t]*:?[ \t]*(.*)$/gim;
41
+ const out = new Set();
42
+ for (const x of String(body ?? "").matchAll(m)) for (const n of x[1].matchAll(/#(\d+)/g)) out.add(Number(n[1]));
43
+ return [...out];
44
+ };
45
+
46
+ /**
47
+ * `issues`: open issues { number, title, body, labels, milestone?, createdAt? }; `now`: ms.
48
+ * Returns { gates: { <gate>: { red, blocksMerge, prerequisites:[{number,title,owner,ageDays}], milestones:[title] } }, digest:[...] }.
49
+ */
50
+ export function readiness(issues, now = Date.now()) {
51
+ const list = Array.isArray(issues) ? issues : [];
52
+ const prereqs = list.filter((i) => labelNames(i).includes(EXTERNAL_LABEL));
53
+ const gates = {};
54
+ const slot = (g) => (gates[g] ??= { red: true, blocksMerge: false, prerequisites: [], milestones: [] });
55
+ const entry = (i) => {
56
+ const t = Date.parse(i.createdAt);
57
+ return { number: i.number, title: String(i.title ?? ""), owner: parseOwner(i.body), ageDays: Number.isFinite(t) ? Math.max(0, Math.floor((now - t) / 86400000)) : null };
58
+ };
59
+ for (const i of prereqs) for (const g of parseGates(i.body)) slot(g).prerequisites.push(entry(i));
60
+ for (const i of list) {
61
+ if (labelNames(i).includes(EXTERNAL_LABEL)) continue;
62
+ const ms = typeof i.milestone === "string" ? i.milestone : i.milestone?.title;
63
+ if (!ms) continue;
64
+ for (const g of parseGates(i.body)) if (gates[g] && !gates[g].milestones.includes(ms)) gates[g].milestones.push(ms);
65
+ }
66
+ return { gates, digest: digest(prereqs, now) };
67
+ }
68
+
69
+ /** Open prerequisites in dependency order (a prerequisite follows the open ones it depends on); `after` lists those. */
70
+ export function digest(prereqs, now = Date.now()) {
71
+ const byNum = new Map(prereqs.map((i) => [i.number, i]));
72
+ const after = (i) => deps(i.body).filter((n) => n !== i.number && byNum.has(n));
73
+ const done = new Set(), order = [], visiting = new Set();
74
+ const visit = (i) => {
75
+ if (done.has(i.number) || visiting.has(i.number)) return; // a cycle is cut, never loops
76
+ visiting.add(i.number);
77
+ for (const n of after(i)) visit(byNum.get(n));
78
+ visiting.delete(i.number);
79
+ done.add(i.number);
80
+ order.push(i);
81
+ };
82
+ [...prereqs].sort((a, b) => a.number - b.number).forEach(visit);
83
+ return order.map((i) => {
84
+ const t = Date.parse(i.createdAt);
85
+ return { number: i.number, title: String(i.title ?? ""), owner: parseOwner(i.body), gates: parseGates(i.body),
86
+ ageDays: Number.isFinite(t) ? Math.max(0, Math.floor((now - t) / 86400000)) : null, after: after(i), unblocked: after(i).length === 0 };
87
+ });
88
+ }
89
+
90
+ /** `{ deployable, openPrerequisites }` for a target (preview|staging|prod). A feature-flag gate never affects it. */
91
+ export function deployable(report, target) {
92
+ const g = report.gates[TARGET_GATE[target]];
93
+ const open = g ? g.prerequisites : [];
94
+ return { deployable: open.length === 0, openPrerequisites: open };
95
+ }
96
+
97
+ const who = (p) => `#${p.number} ${p.title.slice(0, 60)} (owner: ${p.owner ?? "unassigned"}, age: ${p.ageDays ?? "?"}d)`;
98
+
99
+ export function formatReadinessReport(report) {
100
+ const names = Object.keys(report.gates).sort();
101
+ if (!names.length) return "Readiness: no open external prerequisites";
102
+ const lines = ["Readiness (open external prerequisites per gate):"];
103
+ for (const g of names) {
104
+ const x = report.gates[g];
105
+ const flag = g.startsWith("feature-flag:") ? " (flag-gated features may merge; gate stays red)" : "";
106
+ lines.push(` ${g}: RED${flag}`, ...x.prerequisites.map((p) => ` - ${who(p)}`));
107
+ lines.push(` code milestones needed: ${x.milestones.length ? x.milestones.join(", ") : "none recorded"}`);
108
+ }
109
+ return lines.join("\n");
110
+ }
111
+
112
+ export function formatReadinessDigest(report) {
113
+ if (!report.digest.length) return "Digest: no open human actions";
114
+ return ["Digest (human actions, dependency order):", ...report.digest.map((d, i) =>
115
+ ` ${i + 1}. #${d.number} ${d.title.slice(0, 60)} [${d.gates.join(",") || "no gates"}] owner: ${d.owner ?? "unassigned"}${d.unblocked ? " - unblocked" : ` - after ${d.after.map((n) => `#${n}`).join(", ")}`}`)].join("\n");
116
+ }
117
+
118
+ function main(argv) {
119
+ const opt = (f) => { const k = argv.indexOf(f); return k >= 0 ? argv[k + 1] : undefined; };
120
+ const src = opt("--issues");
121
+ let issues;
122
+ if (src) issues = JSON.parse(readFileSync(src === "-" ? 0 : src, "utf8"));
123
+ else {
124
+ const repo = opt("--repo") ?? opt("-R");
125
+ const r = spawnSync("gh", ["issue", "list", ...(repo ? ["-R", repo] : []), "--state", "open", "--limit", "200", "--json", "number,title,body,labels,milestone,createdAt"], { encoding: "utf8" });
126
+ if (r.status !== 0) { console.error("readiness: gh issue list failed"); return 2; }
127
+ issues = JSON.parse(r.stdout || "[]");
128
+ }
129
+ const report = readiness(issues);
130
+ const target = opt("--target");
131
+ if (target) {
132
+ if (!TARGET_GATE[target]) { console.error("readiness: --target must be preview|staging|prod"); return 2; }
133
+ const d = deployable(report, target);
134
+ console.log(d.deployable ? `DEPLOYABLE ${target}` : [`NOT DEPLOYABLE ${target}: ${TARGET_GATE[target]} gate has ${d.openPrerequisites.length} open prerequisite(s)`, ...d.openPrerequisites.map((p) => ` - ${who(p)}`)].join("\n"));
135
+ return d.deployable ? 0 : 1;
136
+ }
137
+ console.log(argv.includes("--digest") ? formatReadinessDigest(report) : formatReadinessReport(report));
138
+ return 0;
139
+ }
140
+
141
+ if (process.argv[1] === fileURLToPath(import.meta.url)) process.exit(main(process.argv.slice(2)));
@@ -232,6 +232,21 @@ dom_f API_DESIGN "router|routes"
232
232
  # --- Phase 2: read-only checks + the trusted full-build config gate ---
233
233
  P2_FINDINGS=""
234
234
  p2() { P2_FINDINGS="${P2_FINDINGS}${1}"$'\n'; }
235
+ # --- review.context staleness (#4666): advisory, never blocks. Unreadable config/checker/gh = unknown, not stale ---
236
+ CTX_GENERATED_AT=""; CTX_STALE="false"; CTX_ADVISORY="false"
237
+ _rc=""; if type forge_root >/dev/null 2>&1; then _rc="$(forge_root 2>/dev/null)/bin/review-context.mjs"; fi
238
+ if [ "$CONFIG_READABLE" = "true" ] && [ -f "$_rc" ] && command -v node >/dev/null 2>&1; then
239
+ _rcj=$(node "$_rc" check --config "$CONFIG_FILE" --root "${TOP:-$(dirname "$CONFIG_FILE")}" --repo "$REPO" --json 2>/dev/null || true)
240
+ if printf '%s' "$_rcj" | jq -e . >/dev/null 2>&1; then
241
+ CTX_GENERATED_AT=$(printf '%s' "$_rcj" | jq -r '.generated_at // ""')
242
+ [ "$(printf '%s' "$_rcj" | jq -r '.stale')" = "true" ] && CTX_STALE="true"
243
+ [ "$(printf '%s' "$_rcj" | jq -r '.advisory')" = "true" ] && CTX_ADVISORY="true"
244
+ while IFS= read -r _l; do [ -n "$_l" ] && p2 "REVIEW_CONTEXT_STALE: $_l"; done <<EOFRC
245
+ $(printf '%s' "$_rcj" | jq -r '.reasons[]?.detail')
246
+ EOFRC
247
+ [ "$CTX_ADVISORY" = "true" ] && p2 "REVIEW_CONTEXT_STALE: context generated ${CTX_GENERATED_AT}, $(printf '%s' "$_rcj" | jq -r '.merged_since') PRs merged since (limit $(printf '%s' "$_rcj" | jq -r '.max_merged_prs')): advisory"
248
+ fi
249
+ fi
235
250
  HAS_TS=$(printf '%s\n' "$FILES" | grep -E '\.(tsx?|jsx?)$' | head -1)
236
251
  HAS_PY=$(printf '%s\n' "$FILES" | grep -E '\.py$' | head -1)
237
252
  TS_BUILD=$(cfg .verification.commands.typescript.build); PY_FORMAT=$(cfg .verification.commands.python.format)
@@ -664,6 +679,7 @@ CTX=$(jq -n \
664
679
  --argjson sa "$SCORE_AUTH" --argjson sb "$SCORE_BILLING" --argjson sc "$SCORE_CONCURRENCY" --argjson sd "$SCORE_DATABASE" --argjson si "$SCORE_INFRA" \
665
680
  --argjson ss "$SCORE_SECURITY" --argjson ssc "$SCORE_SCRAPING" --argjson sf "$SCORE_FRONTEND" --argjson sap "$SCORE_API" \
666
681
  --arg cissue "$ISSUE_NUM" --arg cflags "$CONTRACT_RISK_FLAGS" \
682
+ --arg cgen "$CTX_GENERATED_AT" --arg cstale "$CTX_STALE" --arg cadv "$CTX_ADVISORY" \
667
683
  --arg rdepth "$REVIEW_DEPTH" --arg rweight "$REVIEW_WEIGHT" --arg rtier "$ROSTER_TIER" --arg rreason "$ROSTER_REASON" --argjson agents "$(j_words "$SELECTED_AGENTS")" \
668
684
  --arg dmode "$REVIEW_MODE" --arg dfrom "$DELTA_FROM" --arg dcarried "$CARRIED_FROM" --argjson drounds "$FULL_ROUNDS" --slurpfile dfiles "$TMP_DIR/dfiles.json" \
669
685
  --arg pname "$PROJECT_NAME" --rawfile pctx "$TMP_DIR/pctx.txt" --arg scraping_on "$SCRAPING_ON" --arg rpath "$REPO_PATH" \
@@ -684,7 +700,7 @@ CTX=$(jq -n \
684
700
  contract: {issue: ($cissue | tonumber? // null), flags: $cflags},
685
701
  roster: {tier: $rtier, review_depth: $rdepth, review_weight: $rweight, reason: $rreason, agents: $agents},
686
702
  delta: {mode: $dmode, from: $dfrom, carried_from: $dcarried, full_rounds: $drounds, files: $dfiles[0]},
687
- config: {project_name: $pname, project_context: $pctx, scraping_configured: ($scraping_on == "true"), repo_path: $rpath,
703
+ config: {project_name: $pname, project_context: $pctx, context_generated_at: $cgen, context_stale: ($cstale == "true"), context_advisory: ($cadv == "true"), scraping_configured: ($scraping_on == "true"), repo_path: $rpath,
688
704
  domains: {auth: $d_auth, billing: $d_billing, concurrency: $d_conc, infra: $d_infra, database: $d_db, frontend: $d_fe, security: $d_sec, api: $d_api, scraping: $d_scr}},
689
705
  templates: {source: $tsrc, base: $tbase, monolithic_catalog: $tmono},
690
706
  index_slices: {auth: $i_auth, billing: $i_billing, database: $i_db, api: $i_api, frontend: $i_fe, infrastructure: $i_infra},
@@ -488,5 +488,22 @@ eq "oversized slices JSON is over 128 KiB" '.slices | tojson | length > 131072'
488
488
  eq "oversized security slice keeps every path" '.slices.security.paths | length' 1100
489
489
  eq "oversized full_files keeps every path" '.full_files | length' 1100
490
490
 
491
+ # --- 13. stale review.context (#4666): REVIEW_CONTEXT_STALE finding + config flags; a fresh context emits neither ---
492
+ printf '{"name":"app","dependencies":{"react":"18"}}\n' > "$REPO_DIR/package.json"
493
+ STALE_CFG="$WORK/stale.yaml"
494
+ printf 'project:\n name: Demo\nreview:\n tech_stack: "Next.js 15, React"\n context: "Repo is docs-only; no code yet."\n context_generated_at: "2026-01-01"\n' > "$STALE_CFG"
495
+ setup feat/x staging "services/api/auth.py" "+x = 1"
496
+ CFG_OVERRIDE="$STALE_CFG" prep "$ROOT"
497
+ rc_is "stale context does not block" 0
498
+ eq "stale context flagged in config" '.config.context_stale' true
499
+ eq "stale context carries its generation date" '.config.context_generated_at' 2026-01-01
500
+ eq "stale context emits REVIEW_CONTEXT_STALE (docs-only)" '[.phase2.findings[] | select(startswith("REVIEW_CONTEXT_STALE:") and contains("docs-only"))] | length' 1
501
+ eq "stale context emits REVIEW_CONTEXT_STALE (tech_stack)" '[.phase2.findings[] | select(startswith("REVIEW_CONTEXT_STALE:") and contains("Next.js"))] | length' 1
502
+ printf 'project:\n name: Demo\nreview:\n tech_stack: "React"\n context: "A React app."\n' > "$STALE_CFG"
503
+ CFG_OVERRIDE="$STALE_CFG" prep "$ROOT"
504
+ eq "fresh context is not stale" '.config.context_stale' false
505
+ eq "fresh context emits no REVIEW_CONTEXT_STALE" '[.phase2.findings[] | select(startswith("REVIEW_CONTEXT_STALE:"))] | length' 0
506
+ rm -f "$REPO_DIR/package.json"
507
+
491
508
  echo "review-prepare tests: $pass passed, $fail failed"
492
509
  [ "$fail" -eq 0 ]
@@ -0,0 +1,74 @@
1
+ /**
2
+ * scripts/settle-scope.mjs — scope freeze and convergence for a milestone's settle (#4665). Pure, no I/O, no LLM.
3
+ *
4
+ * Settle (run the batch, re-check buildable, repeat) freezes the milestone's issue set when it begins. Issues filed
5
+ * later are classified by source: decomposition children of a frozen issue stay in the milestone (the parent's
6
+ * contract) and join the measured set; deferred follow-ups go to the next milestone or the backlog; review findings
7
+ * and anything else are counted as growth but are not part of the measured set.
8
+ */
9
+
10
+ const DEFERRED_FROM_RE = /<!--\s*FORGE:DEFERRED_FROM:\s*#(\d+)\s*-->/;
11
+ const FINDING_RE = /<!--\s*FORGE:REVIEW_FINDING\b|\breview[- ]finding\b/i;
12
+
13
+ export const SOURCES = ["decomposition-child", "deferred-followup", "review-finding", "other"];
14
+
15
+ /** The issue numbers open or closed in the milestone when settle begins. @param {Array<{number:number}|number>} issues */
16
+ export function freezeScope(issues) {
17
+ return new Set((issues ?? []).map((i) => (typeof i === "number" ? i : i?.number)).filter(Number.isSafeInteger));
18
+ }
19
+
20
+ /**
21
+ * @param {{ issue: { number: number, body?: string, parent?: number|null }, measured: Set<number>, decomposedParents?: Set<number> }} a
22
+ * `measured` is the frozen set plus the children already admitted; `decomposedParents` the issues that were decomposed.
23
+ * `issue.parent` is the decomposed parent when known (e.g. from the engine's decompose record).
24
+ * @returns {{ source: string, target: "milestone"|"next-milestone-or-backlog"|"unchanged" }}
25
+ */
26
+ export function classifyNew({ issue, measured, decomposedParents = new Set() }) {
27
+ const body = String(issue?.body ?? "");
28
+ const parent = Number.isSafeInteger(issue?.parent) ? issue.parent : null;
29
+ if (parent !== null && measured.has(parent) && (decomposedParents.size === 0 || decomposedParents.has(parent))) {
30
+ return { source: "decomposition-child", target: "milestone" };
31
+ }
32
+ if (DEFERRED_FROM_RE.test(body)) return { source: "deferred-followup", target: "next-milestone-or-backlog" };
33
+ if (FINDING_RE.test(body)) return { source: "review-finding", target: "unchanged" };
34
+ return { source: "other", target: "unchanged" };
35
+ }
36
+
37
+ /**
38
+ * Convergence on the frozen set plus the (transitive) children of decomposed issues in it.
39
+ * @param {{ frozen: Set<number>, current: Array<{ number: number, state?: string, body?: string, parent?: number|null }>, decomposedParents?: Set<number> }} a
40
+ * @returns {{ measured: number[], open: number[], grew: number, bySource: Record<string, number[]>, converged: boolean }}
41
+ */
42
+ export function convergence({ frozen, current, decomposedParents = new Set() }) {
43
+ const measured = new Set(frozen);
44
+ const bySource = Object.fromEntries(SOURCES.map((s) => [s, []]));
45
+ const fresh = (current ?? []).filter((i) => !frozen.has(i.number));
46
+ // Children can be filed under children: admit until no more join.
47
+ let changed = true;
48
+ const seen = new Set();
49
+ while (changed) {
50
+ changed = false;
51
+ for (const it of fresh) {
52
+ if (seen.has(it.number)) continue;
53
+ const c = classifyNew({ issue: it, measured, decomposedParents });
54
+ if (c.source !== "decomposition-child") continue;
55
+ seen.add(it.number); measured.add(it.number); bySource[c.source].push(it.number); changed = true;
56
+ }
57
+ }
58
+ for (const it of fresh) {
59
+ if (seen.has(it.number)) continue;
60
+ bySource[classifyNew({ issue: it, measured, decomposedParents }).source].push(it.number);
61
+ }
62
+ const open = (current ?? []).filter((i) => measured.has(i.number) && String(i.state ?? "OPEN").toUpperCase() !== "CLOSED").map((i) => i.number);
63
+ const grew = SOURCES.reduce((n, s) => n + bySource[s].length, 0);
64
+ return { measured: [...measured], open, grew, bySource, converged: open.length === 0 };
65
+ }
66
+
67
+ /** The STOP report text: how much the set grew and where the new issues came from. */
68
+ export function formatStopReport({ frozen, result, passes = null, limit = null }) {
69
+ const parts = SOURCES.filter((s) => result.bySource[s].length).map((s) => `${s} ${result.bySource[s].length} (${result.bySource[s].map((n) => `#${n}`).join(", ")})`);
70
+ const pass = passes !== null && limit !== null ? ` after ${passes}/${limit} passes` : "";
71
+ const open = result.open.map((n) => `#${n}`).join(", ") || "none";
72
+ return `settle STOP${pass}: ${result.open.length} open in the measured set (${open}); frozen ${frozen.size}, set grew by ${result.grew}`
73
+ + (parts.length ? `: ${parts.join("; ")}` : "");
74
+ }
@@ -0,0 +1,7 @@
1
+ [Unit]
2
+ Description=Refresh the ForgeDock GitHub App installation token (1h lifetime)
3
+
4
+ [Service]
5
+ Type=oneshot
6
+ # Edit the path to your ForgeDock checkout (or the persisted ~/.forge copy), then see docs/CONFIG.md "Bot token refresh".
7
+ ExecStart=%h/.forge/scripts/refresh-bot-token.sh
@@ -0,0 +1,11 @@
1
+ [Unit]
2
+ Description=Refresh the ForgeDock bot token every 45 minutes
3
+
4
+ [Timer]
5
+ OnBootSec=2min
6
+ OnUnitActiveSec=45min
7
+ # Run a missed refresh right after the host wakes or the user manager restarts.
8
+ Persistent=true
9
+
10
+ [Install]
11
+ WantedBy=timers.target
@@ -71,11 +71,13 @@ jitter() { awk -v s="$1" -v p="$JITTER_PCT" -v r="$RANDOM" 'BEGIN{ v=s*(1-p/100+
71
71
  # classify_gh <text> -> prints one class, or nothing when the text is unrecognised:
72
72
  # limited real rate-limit evidence (x-ratelimit-remaining: 0, HTTP 429, "API rate limit exceeded" or GraphQL "API rate limit already exceeded", secondary rate limit,
73
73
  # abuse detection, too many requests): a wait, bounded by the whole window
74
- # auth a bare 401/403 (bad credentials, missing scope): permanent, never waited on
74
+ # unauth a bare 401 (expired token, forge#4662): the refresh timer may fix it, so a small bounded retry, then an explicit auth error
75
+ # auth a bare 403 (missing scope): permanent, never waited on
75
76
  # transient 5xx / gateway / network failures: a small bounded retry (FORGE_CI_TRANSIENT_RETRIES), never the long wait
76
77
  classify_gh() {
77
78
  if printf '%s' "$1" | grep -Eqi 'api rate limit (already )?exceeded|secondary (rate )?limit|abuse detection|x-ratelimit-remaining: *0|HTTP 429|status 429|too many requests'; then echo limited
78
- elif printf '%s' "$1" | grep -Eqi 'HTTP (401|403)|status (401|403)|bad credentials|requires authentication'; then echo auth
79
+ elif printf '%s' "$1" | grep -Eqi 'HTTP 401|status 401|bad credentials|requires authentication'; then echo unauth
80
+ elif printf '%s' "$1" | grep -Eqi 'HTTP 403|status 403'; then echo auth
79
81
  elif printf '%s' "$1" | grep -Eqi 'HTTP 5[0-9][0-9]|status 5[0-9][0-9]|bad gateway|service unavailable|gateway time-?out|timed? ?out|i/o timeout|connection (reset|refused)|could not resolve host|unexpected EOF|network is unreachable|TLS handshake'; then echo transient
80
82
  fi
81
83
  }
@@ -94,6 +96,10 @@ while :; do
94
96
  START_SHA=""; _hc="$(classify_gh "$(cat "$_herr" 2>/dev/null)")"
95
97
  case "$_hc" in
96
98
  auth) emit ERROR; echo "cannot read PR #$PR head in $REPO (HTTP 401/403 without rate-limit evidence)"; exit 2 ;;
99
+ unauth)
100
+ _hfail=$((_hfail + 1))
101
+ if [ "$_hfail" -gt 2 ]; then emit ERROR; echo "cannot read PR #$PR head in $REPO: HTTP 401 (bot token expired or revoked?) ${_hfail} times in a row; run scripts/refresh-bot-token.sh"; exit 2; fi
102
+ sleep "$(jitter "$INTERVAL")" ;;
97
103
  limited)
98
104
  _hlim=$((_hlim + 1))
99
105
  if [ $(( $(date +%s) - _hstart )) -ge "$TIMEOUT" ]; then emit RATE_LIMITED; echo "PR #$PR: GitHub rate-limited for the whole ${TIMEOUT}s window reading the PR head; retry later"; exit 4; fi
@@ -117,8 +123,13 @@ while :; do
117
123
  NC_OUT="$(gh pr checks "$PR" -R "$REPO" 2>&1 || true)"
118
124
  if printf '%s' "$NC_OUT" | grep -qi 'no checks reported'; then JSON='[]'
119
125
  elif GH_CLASS="$(classify_gh "$NC_OUT")"; [ "$GH_CLASS" = auth ]; then
120
- # A bare 401/403 is an auth fault that never clears: fail fast instead of waiting out the window (forge#4037).
126
+ # A bare 403 is an auth fault that never clears: fail fast instead of waiting out the window (forge#4037).
121
127
  emit ERROR; echo "gh pr checks refused (HTTP 401/403 without rate-limit evidence); fix the token or its scopes"; exit 2
128
+ elif [ "$GH_CLASS" = unauth ]; then
129
+ # forge#4662: an expired token is unknown state, not "no checks": back off, then fail with an explicit auth message.
130
+ transient=$((transient + 1))
131
+ if [ "$transient" -gt "$TRANSIENT_MAX" ]; then emit ERROR; echo "gh pr checks refused (HTTP 401) ${transient} times in a row; the bot token is expired or revoked, run scripts/refresh-bot-token.sh"; exit 2; fi
132
+ sleep "$(jitter "$INTERVAL")"; continue
122
133
  elif [ "$GH_CLASS" = transient ]; then
123
134
  # Network timeout / 5xx: a small bounded retry, never the long rate-limit wait.
124
135
  transient=$((transient + 1))
@@ -74,7 +74,9 @@ expect "rate limited for the whole window is RATE_LIMITED rc 4" "4|CI_GATE: RATE
74
74
  expect "more than 5 rate-limit hits are not an ERROR" "0|CI_GATE: PASS" "$(run "$(mk rlmany RATE RATE RATE RATE RATE RATE RATE "$P")" --timeout 60)"
75
75
  # forge#4037: only real rate-limit evidence is RATE_LIMITED; a bare 401/403 fails fast; timeouts/5xx are a small bounded retry.
76
76
  expect "bare 403 is a permanent error rc 2" "2|CI_GATE: ERROR" "$(run "$(mk a403 A403 "$P")" --timeout 60)"
77
- expect "bare 401 is a permanent error rc 2" "2|CI_GATE: ERROR" "$(run "$(mk a401 A401 "$P")" --timeout 60)"
77
+ # forge#4662: a 401 is a refreshable token, so it is retried a bounded number of times before it is an error.
78
+ expect "401 then pass retries (token refreshed meanwhile)" "0|CI_GATE: PASS" "$(run "$(mk a401 A401 "$P")" --timeout 60)"
79
+ expect "persistent 401 is rc 2 after the bounded retries" "2|CI_GATE: ERROR" "$(run "$(mk a401all A401)" --timeout 60)"
78
80
  expect "HTTP 429 is RATE_LIMITED rc 4" "4|CI_GATE: RATE_LIMITED" "$(run "$(mk r429 R429)" --timeout 2)"
79
81
  expect "x-ratelimit-remaining: 0 on a 403 is RATE_LIMITED rc 4" "4|CI_GATE: RATE_LIMITED" "$(run "$(mk rhdr RHDR)" --timeout 2)"
80
82
  expect "network timeout then pass retries" "0|CI_GATE: PASS" "$(run "$(mk tmo TMO "$P")")"
@@ -1,10 +1,13 @@
1
1
  <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
2
2
  <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
3
3
  <!-- /bootstrap needs-human issue: one per credential, account or external input (OAuth client, cloud or DNS account,
4
- payment keys, API keys, legal review). Label it needs-human and set "needsHuman": true in the plan. Code issues
4
+ payment keys, API keys, legal review). Label it needs-human and needs-human:external (the script adds the latter), give it a `gates:` line (preview|staging-deploy|prod-deploy|feature-flag:<name>) and an `owner:` line, and set "needsHuman": true in the plan. Code issues
5
5
  reference it (#{{KEY}}) but never list it under Dependencies; their mocks keep dev and CI green. Copy from "## Problem" down. -->
6
6
  ## Problem
7
7
 
8
+ gates: preview
9
+ owner: maintainer
10
+
8
11
  {which credential, account or external input is needed, and which issues use it}
9
12
 
10
13
  ### Setup steps
@@ -0,0 +1,26 @@
1
+ // Guard test template for repos with a drizzle-style migration journal (#4661). Copy it next to your tests and set JOURNAL.
2
+ // Newest-only migrators (drizzle `migrate()`) skip an entry whose `when` is older than one already applied. CI migrates a
3
+ // fresh database, so it never notices; parallel milestone branches interleave stamps and hit this on persistent databases.
4
+ // Run in CI with the base ref fetched: BASE_REF=origin/main node --test migration-journal-guard.test.mjs
5
+ //
6
+ // Optional upgrade test (repo-specific, not provided): migrate a database to the base's head, apply the branch's
7
+ // migrations, then assert that every migration the branch added is recorded as applied.
8
+ import { test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { execFileSync } from "node:child_process";
11
+ import { readFileSync } from "node:fs";
12
+
13
+ const JOURNAL = process.env.JOURNAL ?? "drizzle/meta/_journal.json";
14
+ const BASE_REF = process.env.BASE_REF ?? "origin/main";
15
+ const entries = JSON.parse(readFileSync(JOURNAL, "utf8")).entries;
16
+
17
+ test("journal `when` strictly increases", () => {
18
+ for (let i = 1; i < entries.length; i++) assert.ok(entries[i].when > entries[i - 1].when, `${entries[i].tag} when ${entries[i].when} is not after ${entries[i - 1].tag}`);
19
+ });
20
+
21
+ test("no new entry is older than the base max `when`", (t) => {
22
+ let base;
23
+ try { base = JSON.parse(execFileSync("git", ["show", `${BASE_REF}:${JOURNAL}`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })).entries; } catch { return t.skip(`no ${JOURNAL} at ${BASE_REF}`); }
24
+ const known = new Set(base.map((e) => e.tag)), max = Math.max(...base.map((e) => e.when));
25
+ for (const e of entries.filter((x) => !known.has(x.tag))) assert.ok(e.when > max, `${e.tag} when ${e.when} is not above the base max ${max}: re-stamp it with the generator`);
26
+ });