forgedock 1.36.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 (112) hide show
  1. package/bin/bot-token-status.mjs +26 -0
  2. package/bin/engine/events.mjs +10 -1
  3. package/bin/engine/events.test.mjs +15 -1
  4. package/bin/engine/outcome-codes.mjs +39 -1
  5. package/bin/engine/phases.mjs +65 -5
  6. package/bin/engine/telemetry-seam.mjs +1 -1
  7. package/bin/engine/telemetry-seam.test.mjs +4 -0
  8. package/bin/engine-cli.mjs +268 -37
  9. package/bin/engine.mjs +3 -1
  10. package/bin/forgedock.mjs +108 -5
  11. package/bin/labels.json +50 -0
  12. package/bin/lean/batch.mjs +10 -4
  13. package/bin/lean/batch.test.mjs +75 -1
  14. package/bin/lean/conformance.e2e.test.mjs +1 -1
  15. package/bin/lean/conformance.mjs +125 -11
  16. package/bin/lean/conformance.test.mjs +122 -1
  17. package/bin/lean/decisions.mjs +181 -0
  18. package/bin/lean/decisions.test.mjs +71 -0
  19. package/bin/lean/effects.mjs +29 -14
  20. package/bin/lean/execution.mjs +60 -16
  21. package/bin/lean/execution.test.mjs +59 -2
  22. package/bin/lean/gatewatch.mjs +31 -0
  23. package/bin/lean/gatewatch.test.mjs +59 -0
  24. package/bin/lean/guardgate.mjs +170 -0
  25. package/bin/lean/guardgate.test.mjs +153 -0
  26. package/bin/lean/guards.mjs +243 -0
  27. package/bin/lean/guards.test.mjs +177 -0
  28. package/bin/lean/heavy.mjs +54 -8
  29. package/bin/lean/heavy.test.mjs +74 -1
  30. package/bin/lean/hostkill.mjs +1 -1
  31. package/bin/lean/integration.mjs +276 -0
  32. package/bin/lean/integration.test.mjs +189 -0
  33. package/bin/lean/lean.mjs +134 -28
  34. package/bin/lean/lean.test.mjs +239 -22
  35. package/bin/lean/lifecycle.mjs +129 -0
  36. package/bin/lean/lifecycle.test.mjs +118 -0
  37. package/bin/lean/light.mjs +1 -1
  38. package/bin/lean/milestonesync.mjs +130 -0
  39. package/bin/lean/milestonesync.test.mjs +133 -0
  40. package/bin/lean/orchestrate.mjs +83 -15
  41. package/bin/lean/parked-sweep.mjs +45 -0
  42. package/bin/lean/parked-sweep.test.mjs +71 -0
  43. package/bin/lean/postdeploy-check.mjs +38 -6
  44. package/bin/lean/postdeploy-check.test.mjs +62 -2
  45. package/bin/lean/postdeploy-monitor.mjs +12 -2
  46. package/bin/lean/postdeploy-monitor.test.mjs +27 -1
  47. package/bin/lean/prefilter.mjs +83 -23
  48. package/bin/lean/prefilter.test.mjs +73 -1
  49. package/bin/lean/probe.mjs +2 -2
  50. package/bin/lean/probe.test.mjs +1 -1
  51. package/bin/lean/prompts.mjs +15 -9
  52. package/bin/lean/resume.test.mjs +1 -1
  53. package/bin/lean/retry.mjs +28 -0
  54. package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
  55. package/bin/lean/sandbox-remote.mjs +4 -4
  56. package/bin/lean/schedd.mjs +53 -9
  57. package/bin/lean/schedd.test.mjs +77 -5
  58. package/bin/lean/sequence.mjs +24 -0
  59. package/bin/lean/sequence.test.mjs +31 -1
  60. package/bin/lean/signoff.mjs +66 -0
  61. package/bin/lean/signoff.test.mjs +102 -0
  62. package/bin/lean/train.mjs +177 -0
  63. package/bin/lean/train.test.mjs +113 -0
  64. package/bin/pool/cli.mjs +2 -0
  65. package/bin/pool/health.mjs +30 -0
  66. package/bin/review-context.mjs +298 -0
  67. package/bin/runner.mjs +26 -3
  68. package/bin/stats/compounding.mjs +157 -0
  69. package/bin/stats/compounding.test.mjs +74 -0
  70. package/bin/stats/index.mjs +21 -2
  71. package/commands/bootstrap.md +10 -8
  72. package/commands/deploy-info.md +3 -0
  73. package/commands/issue.md +2 -0
  74. package/commands/milestone.md +43 -54
  75. package/commands/orchestrate/phase-4-execution.md +2 -0
  76. package/commands/orchestrate.md +8 -1
  77. package/commands/pipeline-status.md +20 -0
  78. package/commands/review-pr.md +1 -0
  79. package/commands/work-on/build/implement.md +1 -1
  80. package/commands/work-on/build.md +4 -2
  81. package/commands/work-on/investigate.md +3 -1
  82. package/commands/work-on/remediate.md +1 -1
  83. package/docs/CONFIG.md +71 -2
  84. package/docs/EVENTS.md +1 -4
  85. package/docs/OUTCOME-CODES.md +9 -1
  86. package/docs/STATS.md +20 -0
  87. package/docs/site/start-a-new-project.md +1 -1
  88. package/docs/spec/label-state-machine.md +6 -0
  89. package/package.json +1 -1
  90. package/pool/install/install-agent.sh +4 -2
  91. package/pool/install/install-router.sh +4 -2
  92. package/pool/install/install-schedd.sh +28 -0
  93. package/pool/install/unit-hardening.sh +34 -0
  94. package/scripts/acceptance-red-green.sh +19 -2
  95. package/scripts/acceptance-red-green.test.sh +2 -0
  96. package/scripts/bootstrap-create.mjs +3 -2
  97. package/scripts/bootstrap-validate.mjs +31 -4
  98. package/scripts/file-deferred-followups.sh +285 -77
  99. package/scripts/file-deferred-followups.test.sh +111 -4
  100. package/scripts/hotspots.sh +23 -2
  101. package/scripts/hotspots.test.sh +9 -0
  102. package/scripts/plan-validate-live.mjs +111 -0
  103. package/scripts/readiness.mjs +141 -0
  104. package/scripts/review-prepare.sh +17 -1
  105. package/scripts/review-prepare.test.sh +17 -0
  106. package/scripts/settle-scope.mjs +74 -0
  107. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  108. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  109. package/scripts/wait-ci-green.sh +14 -3
  110. package/scripts/wait-ci-green.test.sh +3 -1
  111. package/templates/bootstrap/needs-human-issue.md +4 -1
  112. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
@@ -16,7 +16,8 @@ echo "$*" >> "$GH_LOG"
16
16
  case "$1 $2" in
17
17
  "api -X") [ -n "${LOOKUP_FAIL:-}" ] && exit 1; cat "$SEARCH_JSON"; exit 0 ;;
18
18
  "api --paginate") [ -n "${CLAIM_FAIL:-}" ] && exit 1; cat "$COMMENTS_JSON"; exit 0 ;;
19
- "issue create") [ -n "${CREATE_FAIL:-}" ] && exit 1; echo "https://github.com/o/r/issues/901"; exit 0 ;;
19
+ "issue create") [ -n "${CREATE_FAIL:-}" ] && exit 1; N=$(( $(cat "$T_DIR/create.n" 2>/dev/null || echo 900) + 1 )); echo "$N" > "$T_DIR/create.n"; while [ $# -gt 0 ]; do [ "$1" = "--body-file" ] && cp "$2" "$T_DIR/created.$N.body"; shift; done; echo "https://github.com/o/r/issues/$N"; exit 0 ;;
20
+ "label create") exit 0 ;;
20
21
  "issue comment") [ -n "${COMMENT_FAIL:-}" ] && exit 1; exit 0 ;;
21
22
  "issue reopen") [ -n "${REOPEN_FAIL:-}" ] && exit 1; exit 0 ;;
22
23
  "issue close") [ -n "${CLOSE_FAIL:-}" ] && exit 1; exit 0 ;;
@@ -25,6 +26,8 @@ case "$1 $2" in
25
26
  while [ $# -gt 0 ]; do [ "$1" = "--body-file" ] && cp "$2" "$ISSUE_BODY.edited"; shift; done; exit 0 ;;
26
27
  esac
27
28
  if [ "$1" = api ]; then
29
+ case "$*" in "api repos/o/r/milestones"*) cat "$T_DIR/milestones.json"; exit 0 ;; esac
30
+ case "$*" in "api repos/o/r/issues/10 --jq"*) cat "$T_DIR/parent.milestone"; exit 0 ;; esac
28
31
  case "$*" in *--jq*) cat "$ISSUE_BODY"; exit 0 ;; esac
29
32
  n="${2##*/}"; [ -f "$T_DIR/issue.$n.json" ] && { cat "$T_DIR/issue.$n.json"; exit 0; }
30
33
  echo "HTTP 404: Not Found" >&2; exit 1
@@ -37,7 +40,8 @@ PASS=0; FAIL=0
37
40
  ok() { PASS=$((PASS+1)); echo "PASS: $1"; }
38
41
  bad() { FAIL=$((FAIL+1)); echo "FAIL: $1"; }
39
42
  check() { if eval "$2"; then ok "$1"; else bad "$1"; fi; }
40
- reset() { : > "$GH_LOG"; rm -f "$ISSUE_BODY.edited" "$T"/issue.*.json; unset LOOKUP_FAIL CREATE_FAIL EDIT_FAIL CLAIM_FAIL COMMENT_FAIL REOPEN_FAIL CLOSE_FAIL DRY_RUN
43
+ reset() { : > "$GH_LOG"; rm -f "$ISSUE_BODY.edited" "$T"/issue.*.json "$T/create.n"; unset LOOKUP_FAIL CREATE_FAIL EDIT_FAIL CLAIM_FAIL COMMENT_FAIL REOPEN_FAIL CLOSE_FAIL DRY_RUN FORGE_FOLLOWUP_MILESTONE FORGE_FOLLOWUPS_DEFAULT_MILESTONE FORGE_EXTERNAL_PREREQ
44
+ echo '[]' > "$T/milestones.json"; : > "$T/parent.milestone"
41
45
  echo '{"items":[]}' > "$SEARCH_JSON"; echo '[]' > "$COMMENTS_JSON"; }
42
46
  # issue_json NUMBER STATE ASSOCIATION [PARENT]: REST view of a follow-up carrying the DEFERRED_FROM marker
43
47
  issue_json() { printf '{"number":%s,"state":"%s","author_association":"%s","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #%s -->"}\n' "$1" "$2" "$3" "${4:-10}" > "$T/issue.$1.json"; }
@@ -198,10 +202,113 @@ check "dry-run: no comment/reopen/close/edit" "! grep -qE '^issue (comment|reope
198
202
  # 18. #4626: a post-deploy why's [check:<id>] token is kept verbatim in the checklist line
199
203
  reset
200
204
  printf 'x\n' > "$ISSUE_BODY"
201
- echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"; issue_json 77 open OWNER
202
- printf 'R3: soak\tpost-deploy: [check:login-smoke] after a day\t\n' > "$T/check.tsv"
205
+ echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
206
+ printf '{"number":77,"state":"open","author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:POSTDEPLOY_ROLLUP: default -->\\n<!-- FORGE:DEFERRED_FROM: #10 -->\\n### #10\\n<!-- FORGE:DEFERRED_CLASS: post-deploy -->"}\n' > "$T/issue.77.json"
207
+ printf -- '-\tpost-deploy: [check:login-smoke] after a day\t\t\tR3: soak\n' > "$T/check.tsv"
203
208
  bash "$S" --parent 10 --repo o/r --items "$T/check.tsv" >/dev/null 2>&1
204
209
  check "check token kept in the checklist line" "grep -q 'post-deploy: \[check:login-smoke\] after a day' '$ISSUE_BODY.edited'"
205
210
 
211
+ # 19. #4664: milestone inheritance
212
+ MS='[{"title":"M1","state":"open","due_on":"2026-01-01T00:00:00Z"},{"title":"M0","state":"closed","due_on":"2025-06-01T00:00:00Z"},{"title":"M2","state":"open","due_on":"2026-06-01T00:00:00Z"},{"title":"Dflt","state":"open","due_on":null}]'
213
+ reset; echo "$MS" > "$T/milestones.json"; export FORGE_FOLLOWUP_MILESTONE=M1
214
+ bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null 2>&1
215
+ check "milestone: open parent milestone inherited" "grep '^issue create' '$GH_LOG' | grep -q -- '--milestone M1'"
216
+ reset; echo "$MS" > "$T/milestones.json"; echo M1 > "$T/parent.milestone"
217
+ bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null 2>&1
218
+ check "milestone: read from the parent when not passed" "grep '^issue create' '$GH_LOG' | grep -q -- '--milestone M1'"
219
+ reset; echo "$MS" > "$T/milestones.json"; export FORGE_FOLLOWUP_MILESTONE=M0
220
+ bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null 2>&1
221
+ check "milestone: shipped (closed) one -> next open by due date" "grep '^issue create' '$GH_LOG' | grep -q -- '--milestone M1'"
222
+ reset; echo '[{"title":"M0","state":"closed","due_on":"2025-06-01T00:00:00Z"},{"title":"Dflt","state":"open","due_on":null}]' > "$T/milestones.json"
223
+ export FORGE_FOLLOWUP_MILESTONE=M0 FORGE_FOLLOWUPS_DEFAULT_MILESTONE=Dflt
224
+ bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null 2>&1
225
+ check "milestone: shipped, no next -> followups.default_milestone" "grep '^issue create' '$GH_LOG' | grep -q -- '--milestone Dflt'"
226
+ reset; echo '[]' > "$T/milestones.json"
227
+ bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null 2>&1
228
+ check "milestone: parent without one -> none passed" "grep -q '^issue create' '$GH_LOG' && ! grep '^issue create' '$GH_LOG' | grep -q -- '--milestone'"
229
+
230
+ # 20. #4664: one issue per class, class labels
231
+ printf 'a/x.sh\twhy a\t\nb/y.sh\tpost-deploy: [check:smoke] later\t\t\nc/z.sh\texternal: needs a real credential\t\t\nd/w.sh\tops drill\t\tops\n-\tdefer: plain\t\t\tR9: plain item\n' > "$T/classes.tsv"
232
+ reset
233
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/classes.tsv"); RC=$?
234
+ check "classes: rc 0, one create per class (4)" "[ $RC -eq 0 ] && [ \$(grep -c '^issue create' '$GH_LOG') -eq 4 ]"
235
+ for c in engineering post-deploy external ops; do check "classes: label followup:$c applied" "grep '^issue create' '$GH_LOG' | grep -q -- '--label followup:$c'"; done
236
+ check "classes: external also labelled external-prerequisite" "grep '^issue create' '$GH_LOG' | grep -- 'followup:external' | grep -q -- '--label external-prerequisite'"
237
+ check "classes: engineering has the old title, others carry the class" "grep '^issue create' '$GH_LOG' | grep -q 'follow-up (ops): 1 deferred' && grep '^issue create' '$GH_LOG' | grep -q 'follow-up: 2 deferred'"
238
+ check "classes: telemetry lists every issue and class" "printf '%s' \"\$OUT\" | grep -q 'batched_into=#901,#902,#903,#904 filed=4 classes=engineering,external,ops,post-deploy'"
239
+ check "classes: rows point at their class issue (a/x.sh and the R9 item share one)" "printf '%s\n' \"\$OUT\" | grep -q '^c/z.sh 90[0-9]\$' && [ \$(printf '%s\n' \"\$OUT\" | grep -E '^(a/x.sh|R9: plain item) ' | cut -f2 | sort -u | wc -l) -eq 1 ]"
240
+ check "classes: claim markers carry the class" "grep '^issue comment 10' '$GH_LOG' | grep -q 'class=external'"
241
+ # an existing engineering follow-up is not reused for another class
242
+ reset
243
+ printf 'x\n' > "$ISSUE_BODY"; issue_json 77 open OWNER; claim_json OWNER 77
244
+ printf 'c/z.sh\texternal: needs a credential\t\n' > "$T/ext.tsv"
245
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/ext.tsv"); RC=$?
246
+ check "classes: engineering follow-up #77 not reused for an external item" "[ $RC -eq 0 ] && grep -q '^issue create' '$GH_LOG' && ! grep -q '^issue edit 77' '$GH_LOG'"
247
+ # a labelled-class follow-up is reused for its class
248
+ reset
249
+ printf 'x\n' > "$ISSUE_BODY"
250
+ printf '{"number":78,"state":"open","author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->\\n<!-- FORGE:DEFERRED_CLASS: external -->"}\n' > "$T/issue.78.json"; claim_json OWNER 78
251
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/ext.tsv"); RC=$?
252
+ check "classes: external follow-up #78 reused for external items" "[ $RC -eq 0 ] && ! grep -q '^issue create' '$GH_LOG' && grep -q '^issue edit 78' '$GH_LOG'"
253
+
254
+ # 21. #4664: requirement-text rows are rejected, nothing filed
255
+ for row in 'R1: a saved mapping per entity\twhy\t' 'R1: R2: a saved mapping\twhy\t' 'add a saved mapping\twhy\t' 'a/x.sh\twhy\t\tbogus'; do
256
+ reset; printf "$row\n" > "$T/bad.tsv"
257
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/bad.tsv" 2>/dev/null); RC=$?
258
+ check "rejected row ($(printf "$row" | cut -c1-24)): rc 2, DEFERRED_TSV=INVALID, no gh call" "[ $RC -eq 2 ] && printf '%s' \"\$OUT\" | grep -q DEFERRED_TSV=INVALID && [ ! -s '$GH_LOG' ]"
259
+ done
260
+ reset; printf 'a/x.sh\twhy\t\nR2: nope\twhy\t\n' > "$T/bad.tsv"
261
+ bash "$S" --parent 10 --repo o/r --items "$T/bad.tsv" >/dev/null 2>&1
262
+ check "one malformed row among valid ones files nothing" "[ $? -eq 2 ] && ! grep -q '^issue create' '$GH_LOG'"
263
+ # `-` + label is a valid requirement-level row; a doubled R-prefix in the label collapses; Affected Files lists only paths
264
+ reset; printf '%s\n' "$(printf -- '-\twhy\t\t\tR1: R2: a saved mapping')" "$(printf 'src/q.ts\twhy q\t')" > "$T/ok.tsv"
265
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/ok.tsv"); RC=$?
266
+ check "label row accepted, prefix collapsed in stdout" "[ $RC -eq 0 ] && printf '%s\n' \"\$OUT\" | grep -q '^R1: a saved mapping 901'"
267
+
268
+ check "created body: Affected Files lists only paths, checklist keeps the collapsed label" "grep -q '^- \`src/q.ts\`\$' '$T/created.901.body' && ! grep -q '^- \`R1' '$T/created.901.body' && grep -q '^- \[ \] \`R1: a saved mapping\`' '$T/created.901.body' && ! grep -q 'R1: R2:' '$T/created.901.body'"
269
+ check "created body: DEFERRED_FROM and DEFERRED_CLASS markers" "grep -q 'FORGE:DEFERRED_FROM: #10 -->' '$T/created.901.body' && grep -q 'FORGE:DEFERRED_CLASS: engineering' '$T/created.901.body'"
270
+ # #4739: legal path characters (parentheses, space, =, #, :) are accepted; one such row no longer aborts the batch
271
+ reset; printf 'app/(marketing)/page.tsx\twhy\t\ndocs/My Notes.md\twhy\t\nsrc/a=b.ts\twhy\t\nsrc/#x.ts\twhy\t\nsrc/foo:bar.ts\twhy\t\n' > "$T/legal.tsv"
272
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/legal.tsv" 2>/dev/null); RC=$?
273
+ check "legal paths with ( ) space = # : are accepted and filed" "[ $RC -eq 0 ] && ! printf '%s' \"\$OUT\" | grep -q DEFERRED_TSV=INVALID && grep -q '^issue create' '$GH_LOG' && grep -qF -- '- \`app/(marketing)/page.tsx\`' '$T/created.901.body' && grep -qF -- '- \`docs/My Notes.md\`' '$T/created.901.body' && grep -qF -- '- \`src/a=b.ts\`' '$T/created.901.body' && grep -qF -- '- \`src/#x.ts\`' '$T/created.901.body' && grep -qF -- '- \`src/foo:bar.ts\`' '$T/created.901.body'"
274
+ reset; printf -- '-\tpost-deploy: [check:login-smoke] soon\t\t\tR3: soak\n' > "$T/pd.tsv"
275
+ bash "$S" --parent 10 --repo o/r --items "$T/pd.tsv" >/dev/null 2>&1
276
+ check "post-deploy rollup: created with the lane marker, [check:] kept, section for the parent" "grep -q 'FORGE:POSTDEPLOY_ROLLUP: default' '$T/created.901.body' && grep -q 'post-deploy: \[check:login-smoke\] soon' '$T/created.901.body' && grep -q '^### #10\$' '$T/created.901.body'"
277
+
278
+ # 23. #4724: N lanes -> ONE rollup; sections per parent; duplicates stored once; owner items never join it
279
+ reset
280
+ printf -- '-\tpost-deploy: [bucket:post-deploy] [check:a-smoke] first\t\t\tR1: the login page renders\n-\towner: [bucket:owner] rotate the key. why\t\t\tR2: rotate the credential\n' > "$T/l1.tsv"
281
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/l1.tsv"); RC=$?
282
+ check "rollup: lane 1 creates one external follow-up (#901) and one rollup (#902)" "[ $RC -eq 0 ] && [ \$(grep -c '^issue create' '$GH_LOG') -eq 2 ] && grep -q 'FORGE:POSTDEPLOY_ROLLUP: default' '$T/created.902.body' && grep -q 'FORGE:DEFERRED_CLASS: external' '$T/created.901.body'"
283
+ check "rollup: the owner item is not in the rollup" "! grep -q 'rotate the credential' '$T/created.902.body' && grep -q 'rotate the credential' '$T/created.901.body'"
284
+ check "rollup: telemetry has per-bucket counts" "printf '%s' \"\$OUT\" | grep -q 'buckets=owner:1,post-deploy:1'"
285
+ cp "$T/created.902.body" "$ISSUE_BODY"; : > "$GH_LOG"
286
+ echo '{"items":[{"number":902}]}' > "$SEARCH_JSON"
287
+ printf '{"number":902,"state":"open","author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:POSTDEPLOY_ROLLUP: default -->"}\n' > "$T/issue.902.json"
288
+ printf -- '-\tpost-deploy: [bucket:post-deploy] [check:a-smoke] again\t\t\tR7: The Login page renders!\n-\tpost-deploy: [bucket:date] due:2026-12-01 from:2026-10-27 later\t\t\tR8: soak for a week\n' > "$T/l2.tsv"
289
+ OUT=$(bash "$S" --parent 11 --repo o/r --items "$T/l2.tsv"); RC=$?
290
+ check "rollup: lane 2 appends to #902, creates nothing" "[ $RC -eq 0 ] && ! grep -q '^issue create' '$GH_LOG' && grep -q '^issue edit 902' '$GH_LOG'"
291
+ check "rollup: own section for #11, soak item added" "grep -q '^### #11\$' '$ISSUE_BODY.edited' && grep -q 'R8: soak for a week' '$ISSUE_BODY.edited' && grep -q 'FORGE:DEFERRED_FROM: #11' '$ISSUE_BODY.edited'"
292
+ check "rollup: duplicate text from another lane stored once" "[ \$(grep -ci 'login page' '$ISSUE_BODY.edited') -eq 1 ] && printf '%s\n' \"\$OUT\" | grep -q '^R7: The Login page renders!.902'"
293
+ # a chain: parent 20 is itself a follow-up of #10 -> the items go under the root #10, no new issue
294
+ reset
295
+ cp "$T/created.902.body" "$ISSUE_BODY" 2>/dev/null || true
296
+ echo '{"items":[{"number":901}]}' > "$SEARCH_JSON"
297
+ printf '{"number":901,"state":"open","author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:POSTDEPLOY_ROLLUP: default -->"}\n' > "$T/issue.901.json"
298
+ printf '{"number":20,"state":"open","author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}\n' > "$T/issue.20.json"
299
+ printf -- '-\tpost-deploy: [bucket:post-deploy] [check:b-smoke] chain\t\t\tR9: a chained check\n' > "$T/l3.tsv"
300
+ bash "$S" --parent 20 --repo o/r --items "$T/l3.tsv" >/dev/null 2>&1
301
+ check "rollup: a follow-up of a follow-up appends under its root parent, no new issue" "! grep -q '^issue create' '$GH_LOG' && grep -q 'a chained check' '$ISSUE_BODY.edited' && ! grep -q '^### #20' '$ISSUE_BODY.edited'"
302
+ # verify-now: its own per-parent follow-up, never the rollup
303
+ reset
304
+ printf -- '-\tpost-deploy: [bucket:verify-now] on default already\t\t\tR1: check it now\n' > "$T/vn.tsv"
305
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/vn.tsv")
306
+ check "verify-now: separate per-parent issue, class verify-now, no rollup marker" "grep -q 'FORGE:DEFERRED_CLASS: verify-now' '$T/created.901.body' && ! grep -q POSTDEPLOY_ROLLUP '$T/created.901.body' && printf '%s' \"\$OUT\" | grep -q 'buckets=verify-now:1'"
307
+ # an owner bucket on a post-deploy class row is rerouted to external
308
+ reset
309
+ printf -- 'x/y.sh\towner: [bucket:owner] spend\t\tpost-deploy\n' > "$T/ow.tsv"
310
+ bash "$S" --parent 10 --repo o/r --items "$T/ow.tsv" >/dev/null 2>&1
311
+ check "owner bucket never lands in post-deploy, even when the row says so" "grep -q 'FORGE:DEFERRED_CLASS: external' '$T/created.901.body' && ! grep -q POSTDEPLOY_ROLLUP '$T/created.901.body'"
312
+
206
313
  echo "passed=$PASS failed=$FAIL"
207
314
  [ "$FAIL" -eq 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