forgedock 1.16.2 → 1.18.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 (114) hide show
  1. package/bin/engine/outcome-codes.mjs +114 -0
  2. package/bin/engine/phases.mjs +211 -100
  3. package/bin/engine/projector.mjs +48 -1
  4. package/bin/engine/runlog.mjs +20 -5
  5. package/bin/engine/telemetry-seam.mjs +62 -0
  6. package/bin/engine/telemetry-seam.test.mjs +60 -0
  7. package/bin/engine/telemetry.mjs +148 -0
  8. package/bin/engine/telemetry.test.mjs +120 -0
  9. package/bin/engine-cli.mjs +17 -1
  10. package/bin/engine.mjs +227 -48
  11. package/bin/forgedock.mjs +8 -2
  12. package/bin/hooks/interactive-engine.mjs +10 -0
  13. package/bin/hooks/pre-tool-use.mjs +220 -0
  14. package/bin/runner.mjs +70 -4
  15. package/bin/stats/aggregate.mjs +231 -0
  16. package/bin/stats/cache.mjs +40 -0
  17. package/bin/stats/github-source.mjs +211 -0
  18. package/bin/stats/index.mjs +154 -0
  19. package/bin/stats/local-source.mjs +79 -0
  20. package/bin/stats/package.json +6 -0
  21. package/bin/stats/render.mjs +82 -0
  22. package/bin/stats/stats.test.mjs +312 -0
  23. package/commands/cleanup.md +90 -2
  24. package/commands/deploy-info.md +8 -4
  25. package/commands/deploy-pr.md +64 -0
  26. package/commands/fix-ci.md +20 -6
  27. package/commands/milestone.md +34 -17
  28. package/commands/orchestrate/phase-1-resolve.md +4 -1
  29. package/commands/orchestrate/phase-4-execution.md +28 -6
  30. package/commands/orchestrate/phase-5-cleanup.md +18 -2
  31. package/commands/pipeline-health/phase-1-context.md +2 -0
  32. package/commands/pipeline-health/phase-2-metrics.md +12 -5
  33. package/commands/pipeline-status.md +21 -1
  34. package/commands/review-pr-agents/protocols.md +1 -0
  35. package/commands/review-pr-agents.md +10 -2
  36. package/commands/review-pr-staging.md +196 -496
  37. package/commands/review-pr.md +512 -3134
  38. package/commands/test-gate.md +25 -10
  39. package/commands/work-on/build/architect.md +13 -1
  40. package/commands/work-on/build/implement.md +28 -6
  41. package/commands/work-on/build/validate.md +5 -2
  42. package/commands/work-on/build.md +197 -145
  43. package/commands/work-on/close.md +66 -20
  44. package/commands/work-on/decompose.md +113 -34
  45. package/commands/work-on/investigate.md +15 -14
  46. package/commands/work-on/remediate.md +49 -19
  47. package/commands/work-on/review.md +7 -5
  48. package/commands/work-on.md +1 -2
  49. package/docs/CONFIG.md +76 -24
  50. package/docs/OUTCOME-CODES.md +53 -0
  51. package/docs/RELEASE-NOTES.md +24 -0
  52. package/docs/STATS.md +63 -0
  53. package/docs/TELEMETRY.md +65 -0
  54. package/docs/WORK-ON-RUNTIME.md +19 -0
  55. package/docs/site/command-reference.md +1 -1
  56. package/docs/spec/forge-protocol-v1.md +2 -2
  57. package/docs/spec/label-state-machine.md +1 -1
  58. package/package.json +3 -2
  59. package/packages/protocol/src/phases.js +5 -5
  60. package/scripts/builder-resume-match.test.sh +58 -0
  61. package/scripts/cap-findings.sh +110 -0
  62. package/scripts/cap-findings.test.sh +130 -0
  63. package/scripts/check-spec-bash.sh +5 -1
  64. package/scripts/check-spec-bash.test.sh +20 -0
  65. package/scripts/check-spec-markers.sh +2 -0
  66. package/scripts/classify-finding.sh +65 -16
  67. package/scripts/classify-finding.test.sh +43 -1
  68. package/scripts/classify-grep-exit.sh +20 -0
  69. package/scripts/classify-grep-exit.test.sh +46 -0
  70. package/scripts/close-parent-c3.test.sh +123 -0
  71. package/scripts/cost-report.sh +146 -0
  72. package/scripts/cost-report.test.sh +80 -0
  73. package/scripts/diff-size.sh +97 -3
  74. package/scripts/diff-size.test.sh +59 -2
  75. package/scripts/doctor-pipeline-state.sh +43 -0
  76. package/scripts/file-deferred-followups.sh +159 -0
  77. package/scripts/file-deferred-followups.test.sh +106 -0
  78. package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
  79. package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
  80. package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
  81. package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
  82. package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
  83. package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
  84. package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
  85. package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
  86. package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
  87. package/scripts/forge-root.test.sh +18 -11
  88. package/scripts/lib/fence-bootstrap.sh +4 -3
  89. package/scripts/lib/pr-issue-number.sh +18 -0
  90. package/scripts/lib/review-template-resolve.sh +41 -0
  91. package/scripts/lib/telemetry.sh +168 -0
  92. package/scripts/publish-staging-sync.test.sh +48 -0
  93. package/scripts/remediate-liveness.sh +42 -6
  94. package/scripts/resolve-deploy-gate.sh +32 -6
  95. package/scripts/resolve-deploy-gate.test.sh +42 -1
  96. package/scripts/review-acceptance-gate.sh +16 -7
  97. package/scripts/review-acceptance-gate.test.sh +13 -0
  98. package/scripts/review-finalize.sh +1427 -0
  99. package/scripts/review-finalize.test.sh +833 -0
  100. package/scripts/review-pr-delta-wiring.test.sh +19 -72
  101. package/scripts/review-pr-domain-map.test.sh +23 -13
  102. package/scripts/review-pr-gate2.test.sh +24 -65
  103. package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
  104. package/scripts/review-pr-staging-scope.test.sh +56 -80
  105. package/scripts/review-prepare.sh +553 -0
  106. package/scripts/review-prepare.test.sh +288 -0
  107. package/scripts/review-roster.sh +186 -0
  108. package/scripts/review-roster.test.sh +185 -0
  109. package/scripts/run-shell-tests.sh +3 -0
  110. package/scripts/telemetry.test.sh +106 -0
  111. package/scripts/trusted-comments.test.sh +9 -2
  112. package/scripts/wait-ci-green.sh +8 -1
  113. package/scripts/wait-ci-green.test.sh +3 -3
  114. package/templates/forge.yaml.minimal +9 -0
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bash
2
+ # cost-report.test.sh — cases for scripts/cost-report.sh (forge#3824).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ set -uo pipefail
6
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ S="$HERE/cost-report.sh"
8
+ FIX="$HERE/fixtures/cost-report"
9
+ PASS=0; FAILN=0
10
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
11
+ ok() { PASS=$((PASS+1)); }
12
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
13
+ has() { # name text needle
14
+ case "$2" in *"$3"*) ok ;; *) bad "$1 (missing: $3)"; printf '%s\n' "$2" | sed 's/^/ /' ;; esac
15
+ }
16
+ hasnt() { # name text needle
17
+ case "$2" in *"$3"*) bad "$1 (unexpected: $3)" ;; *) ok ;; esac
18
+ }
19
+
20
+ RUNS="$TMP/runs"; mkdir -p "$RUNS"
21
+ cp "$FIX"/*.jsonl "$RUNS"/
22
+ # Old logs carry no version: their mtime (UTC) is mapped to the release active at that time.
23
+ TZ=UTC touch -t 200201010000 "$RUNS/old-log-no-version.jsonl"
24
+ printf '1.0.0 1000000000\n2.0.0 1100000000\n' > "$TMP/releases.txt"
25
+
26
+ OUT=$(bash "$S" --runs-dir "$RUNS" --release-times "$TMP/releases.txt"); rc=$?
27
+ [ "$rc" -eq 0 ] && ok || bad "exit 0 on a valid runs dir (rc=$rc)"
28
+
29
+ # Repo split: o/a (3 finished runs) and o/b (1) are separate rows.
30
+ ROW_A=$(printf '%s\n' "$OUT" | grep '^| 2\.0\.0 | o/a |')
31
+ ROW_B=$(printf '%s\n' "$OUT" | grep '^| 2\.0\.0 | o/b |')
32
+ [ -n "$ROW_A" ] && ok || bad "row for 2.0.0 o/a"
33
+ [ -n "$ROW_B" ] && ok || bad "row for 2.0.0 o/b"
34
+
35
+ # o/a: unfinished (no terminal, reopened) and hydrated runs are excluded, so n=3.
36
+ has "o/a n=3 (unfinished and hydrated excluded)" "$ROW_A" "| 2.0.0 | o/a | 3 |"
37
+ # totals 330k, 560k, 100k: mean 330k, median 330k
38
+ has "o/a mean and median" "$ROW_A" "| 330k | 330k |"
39
+ # per-phase means divide by n: investigate 30k/3, build 390k/3, review 500k/3, remediate 40k/3, close 30k/3
40
+ has "o/a per-phase means" "$ROW_A" "| 10k | 130k | 167k | 13k | 10k |"
41
+ # 1 of 3 remediated, 2 of 3 merged (needs-human is not merged)
42
+ has "o/a remediated and merged rates" "$ROW_A" "| 33% | 67% |"
43
+ # per merged issue: 990k total over 2 merged runs
44
+ has "o/a tokens per merged issue" "$ROW_A" "| 495k |"
45
+ has "o/a low-n flag" "$ROW_A" "low-n (<10)"
46
+ has "o/b single run per-merged math" "$ROW_B" "| 2.0.0 | o/b | 1 | 1000k | 1000k |"
47
+ has "o/b per merged" "$ROW_B" "| 100% | 1000k |"
48
+ hasnt "unfinished usage never counted" "$OUT" "9999k"
49
+
50
+ # o/c: new-style log (PHASE_ATTEMPT_USAGE). Per-phase attempts are summed once: investigate 10k + build 100k + 50k
51
+ # = 160k; the cumulative PHASE_COMMIT (150k) and the PHASE_FAILED (100k) must not be added on top.
52
+ ROW_C=$(printf '%s\n' "$OUT" | grep '^| 2\.0\.0 | o/c |')
53
+ has "o/c attempt events not double counted" "$ROW_C" "| 2.0.0 | o/c | 1 | 160k | 160k |"
54
+
55
+ # Old log: version estimated from mtime (marked ~), repo unknown.
56
+ has "old-log fallback" "$OUT" "| 1.0.0~ | unknown | 1 | 70k |"
57
+ has "excluded footer" "$OUT" "Excluded: 2 unfinished, 1 without usage data"
58
+
59
+ # --min-n lowers the flag threshold.
60
+ OUT2=$(bash "$S" --runs-dir "$RUNS" --release-times "$TMP/releases.txt" --min-n 3)
61
+ has "min-n 3 marks o/a ok" "$(printf '%s\n' "$OUT2" | grep '^| 2\.0\.0 | o/a |')" "| ok |"
62
+
63
+ # --last / --versions filtering.
64
+ OUT3=$(bash "$S" --runs-dir "$RUNS" --release-times "$TMP/releases.txt" --last 1)
65
+ hasnt "--last 1 drops the older estimated version" "$OUT3" "1.0.0~"
66
+ has "--last 1 keeps the newest" "$OUT3" "| 2.0.0 | o/a |"
67
+ OUT4=$(bash "$S" --runs-dir "$RUNS" --release-times "$TMP/releases.txt" --versions 1.0.0)
68
+ has "--versions matches an estimated version" "$OUT4" "| 1.0.0~ |"
69
+ hasnt "--versions excludes others" "$OUT4" "| 2.0.0 |"
70
+
71
+ # Fail closed: missing runs dir exits 2; empty dir still reports (header only).
72
+ bash "$S" --runs-dir "$TMP/nope" >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "missing runs dir exits 2"
73
+ bash "$S" --bogus >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "unknown flag exits 2"
74
+ mkdir "$TMP/empty"
75
+ OUT5=$(bash "$S" --runs-dir "$TMP/empty" --release-times "$TMP/releases.txt"); rc=$?
76
+ [ "$rc" -eq 0 ] && ok || bad "empty runs dir exits 0"
77
+ has "empty dir footer" "$OUT5" "Excluded: 0 unfinished"
78
+
79
+ echo "cost-report.test: $PASS passed, $FAILN failed"
80
+ [ "$FAILN" -eq 0 ]
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env bash
2
- # diff-size.sh — the ONE measurement of a build's diff size for the build phase size gate (forge#3450).
2
+ # diff-size.sh — the ONE measurement of a build's diff size and scope (forge#3450, forge#3862).
3
3
  # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
4
  # SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  #
6
6
  # Usage: diff-size.sh --repo-path P --base B [--threshold N] [--exclude-glob G]...
7
+ # [--test-weight PCT] [--module-depth N] [--plan-files FILE] [--estimate N] [--overrun-pct PCT]
8
+ # The size is never a gate (forge#3862): review_weight scales review depth and the scope diff (--plan-files) flags
9
+ # files outside the architect plan. --threshold/over/cohesive are DEPRECATED (kept one release for old callers).
7
10
  # Measures lines changed (added + deleted) by the index plus any earlier commits against the MERGE BASE of
8
11
  # HEAD and origin/B (git diff --cached --numstat --no-renames $(git merge-base HEAD origin/B)), so commits that
9
12
  # landed on the base after the branch point are never counted as this build's changes. Exit 2 when the merge
@@ -15,7 +18,7 @@
15
18
  # build/ dist/ generated/ fixtures/ vendor/ are anchored to the repo root (so first-party dirs such as
16
19
  # commands/work-on/build/ are counted); __generated__/ and __snapshots__/ match at any depth.
17
20
  # Globs are data, never evaluated.
18
- # Default globs (mirrored in docs/CONFIG.md under build.diff_size.exclude_globs):
21
+ # Default globs (mirrored in docs/CONFIG.md under build.scope.exclude_globs, deprecated alias build.diff_size.exclude_globs):
19
22
  # lockfiles (package-lock.json yarn.lock pnpm-lock.yaml Cargo.lock poetry.lock uv.lock go.sum Gemfile.lock
20
23
  # composer.lock), *.min.*, *.snap, root-anchored dist/ build/ generated/ fixtures/ vendor/, any-depth
21
24
  # __generated__/ __snapshots__/
@@ -27,11 +30,25 @@
27
30
  # by basename); cohesive_lines = the union (a new test file counts once). cohesive=true when diff_lines>0 and
28
31
  # cohesive_lines*100 >= 70*diff_lines and diff_lines <= threshold*3/2 (integer math; threshold 0 -> false).
29
32
  # Test lines are classified, never discounted from diff_lines.
33
+ # review_weight (forge#3862): ws_lines = lines changed with whitespace ignored (git diff -w; a pure re-indent is 0)
34
+ # over the counted (non-excluded) files; review_weight = ceil((ws_lines of non-test files + --test-weight percent
35
+ # (default 50) of ws_lines of test files)); modules = distinct directories (first --module-depth path segments,
36
+ # default 2) holding a file with at least one non-whitespace changed line. Keys ws_lines, review_weight, modules
37
+ # follow cohesive= and precede top=.
38
+ # Scope diff, emitted only with --plan-files and/or --estimate: --plan-files FILE lists the architect's planned files
39
+ # (one per line; a leading "- " or "* ", backticks and "./" are stripped; an entry ending in "/" is a directory prefix;
40
+ # blank and "#" lines are ignored). scope=no_baseline (no entries) | ok | out_of_plan; plan_files=N; out_of_plan_count=K;
41
+ # up to 50 "out_of_plan=<path>" lines (counted files absent from the plan). --estimate N with --overrun-pct P
42
+ # (default 200) sets overrun=true when ws_lines*100 > N*P (N=0 means no estimate: overrun=false). Unreadable
43
+ # --plan-files is exit 2 (fail closed: an unreadable plan is never "in plan").
30
44
  # Exit: 0 measured (even when over); 2 usage or git failure with EMPTY stdout (fail closed: unknown is not zero).
31
45
 
32
46
  set -euo pipefail
47
+ # Telemetry (forge#3834): best-effort decision record; never changes stdout, stderr or the exit code.
48
+ _TEL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/telemetry.sh"; [ -f "$_TEL" ] && . "$_TEL" 2>/dev/null
49
+ type forge_emit >/dev/null 2>&1 || forge_emit() { :; }
33
50
 
34
- REPO_PATH=""; BASE=""; THRESHOLD="1000"; EXTRA=()
51
+ REPO_PATH=""; BASE=""; THRESHOLD="1000"; EXTRA=(); TEST_WEIGHT="50"; MODULE_DEPTH="2"; PLAN_FILE=""; ESTIMATE=""; OVERRUN_PCT="200"
35
52
  die() { echo "diff-size.sh: $*" >&2; exit 2; }
36
53
  while [ $# -gt 0 ]; do
37
54
  case "$1" in
@@ -39,12 +56,33 @@ while [ $# -gt 0 ]; do
39
56
  --base) [ $# -ge 2 ] || die "missing value for $1"; BASE="$2"; shift 2 ;;
40
57
  --threshold) [ $# -ge 2 ] || die "missing value for $1"; THRESHOLD="$2"; shift 2 ;;
41
58
  --exclude-glob) [ $# -ge 2 ] || die "missing value for $1"; [ -z "$2" ] || EXTRA+=("$2"); shift 2 ;;
59
+ --test-weight) [ $# -ge 2 ] || die "missing value for $1"; TEST_WEIGHT="$2"; shift 2 ;;
60
+ --module-depth) [ $# -ge 2 ] || die "missing value for $1"; MODULE_DEPTH="$2"; shift 2 ;;
61
+ --plan-files) [ $# -ge 2 ] || die "missing value for $1"; PLAN_FILE="$2"; shift 2 ;;
62
+ --estimate) [ $# -ge 2 ] || die "missing value for $1"; ESTIMATE="$2"; shift 2 ;;
63
+ --overrun-pct) [ $# -ge 2 ] || die "missing value for $1"; OVERRUN_PCT="$2"; shift 2 ;;
42
64
  *) die "unknown argument: $1" ;;
43
65
  esac
44
66
  done
45
67
  [ -n "$REPO_PATH" ] && [ -d "$REPO_PATH" ] || die "--repo-path must be an existing directory"
46
68
  [ -n "$BASE" ] || die "--base is required"
47
69
  case "$THRESHOLD" in ""|*[!0-9]*) die "--threshold must be a non-negative integer" ;; esac
70
+ case "$TEST_WEIGHT" in ""|*[!0-9]*) die "--test-weight must be a non-negative integer percent" ;; esac
71
+ case "$MODULE_DEPTH" in ""|0|*[!0-9]*) die "--module-depth must be a positive integer" ;; esac
72
+ case "$OVERRUN_PCT" in ""|*[!0-9]*) die "--overrun-pct must be a non-negative integer percent" ;; esac
73
+ case "$ESTIMATE" in ""|*[!0-9]*) [ -z "$ESTIMATE" ] || die "--estimate must be a non-negative integer" ;; esac
74
+ PLAN=(); PLAN_SET=0
75
+ if [ -n "$PLAN_FILE" ]; then
76
+ [ -f "$PLAN_FILE" ] && [ -r "$PLAN_FILE" ] || die "--plan-files must be a readable file"
77
+ PLAN_SET=1
78
+ while IFS= read -r _l || [ -n "$_l" ]; do
79
+ _l="${_l#"${_l%%[![:space:]]*}"}"; _l="${_l%"${_l##*[![:space:]]}"}"
80
+ case "$_l" in ""|"#"*) continue ;; esac
81
+ case "$_l" in "- "*|"* "*) _l="${_l#??}" ;; esac
82
+ _l="${_l//\`/}"; _l="${_l#./}"
83
+ [ -n "$_l" ] && PLAN+=("$_l")
84
+ done < "$PLAN_FILE"
85
+ fi
48
86
 
49
87
  DEFAULTS=(package-lock.json yarn.lock pnpm-lock.yaml Cargo.lock poetry.lock uv.lock go.sum Gemfile.lock composer.lock
50
88
  '*.min.*' '*.snap' __generated__/ __snapshots__/)
@@ -99,6 +137,32 @@ while IFS= read -r -d $'\001' rec; do
99
137
  fi
100
138
  done <<< "$RAW"
101
139
 
140
+ # Whitespace-insensitive pass (forge#3862): review_weight and module count come from `git diff -w`.
141
+ RAWW="$(git -C "$REPO_PATH" diff --cached -w --numstat --no-renames -z "$MB" -- | tr '\0' '\001')" \
142
+ || die "git diff -w against merge base $MB failed"
143
+ WSL=0; WS_NT=0; WS_T=0; MODS=$'\001'; NMODS=0
144
+ while IFS= read -r -d $'\001' rec; do
145
+ [ -n "$rec" ] || continue
146
+ a="${rec%%$'\t'*}"; rest="${rec#*$'\t'}"; d="${rest%%$'\t'*}"; path="${rest#*$'\t'}"
147
+ case "$a" in ''|*[!0-9]*) a=0 ;; esac
148
+ case "$d" in ''|*[!0-9]*) d=0 ;; esac
149
+ n=$((a + d))
150
+ excluded "$path" && continue
151
+ WSL=$((WSL + n))
152
+ if is_test "$path"; then WS_T=$((WS_T + n)); else WS_NT=$((WS_NT + n)); fi
153
+ if [ "$n" -gt 0 ]; then
154
+ dir="${path%/*}"; [ "$dir" = "$path" ] && dir="."
155
+ mod=""; _i=0; _rest="$dir"
156
+ while [ "$_i" -lt "$MODULE_DEPTH" ] && [ -n "$_rest" ]; do
157
+ _seg="${_rest%%/*}"; mod="${mod:+$mod/}$_seg"
158
+ case "$_rest" in */*) _rest="${_rest#*/}" ;; *) _rest="" ;; esac
159
+ _i=$((_i + 1))
160
+ done
161
+ case "$MODS" in *$'\001'"$mod"$'\001'*) ;; *) MODS="${MODS}${mod}"$'\001'; NMODS=$((NMODS + 1)) ;; esac
162
+ fi
163
+ done <<< "$RAWW"
164
+ REVIEW_WEIGHT=$(( (WS_NT * 100 + WS_T * TEST_WEIGHT + 99) / 100 ))
165
+
102
166
  OVER=false
103
167
  if [ "$THRESHOLD" -gt 0 ] && [ "$TOTAL" -gt "$THRESHOLD" ]; then OVER=true; fi
104
168
  echo "diff_lines=$TOTAL"
@@ -112,6 +176,36 @@ echo "new_lines=$NEWL"
112
176
  echo "test_lines=$TESTL"
113
177
  echo "cohesive_lines=$COHL"
114
178
  echo "cohesive=$COHESIVE"
179
+ echo "ws_lines=$WSL"
180
+ echo "review_weight=$REVIEW_WEIGHT"
181
+ echo "modules=$NMODS"
182
+ OOP_LIST=""; OOP=0; SCOPE=ok
183
+ if [ "$PLAN_SET" = 1 ] || [ -n "$ESTIMATE" ]; then
184
+ if [ "${#PLAN[@]}" -eq 0 ]; then SCOPE=no_baseline; else
185
+ while IFS= read -r -d $'\001' rec; do
186
+ [ -n "$rec" ] || continue
187
+ path="${rec#*$'\t'}"; path="${path#*$'\t'}"
188
+ excluded "$path" && continue
189
+ inplan=0
190
+ for _p in "${PLAN[@]}"; do
191
+ case "$_p" in */) case "$path" in "$_p"*) inplan=1; break ;; esac ;; *) [ "$path" = "$_p" ] && { inplan=1; break; } ;; esac
192
+ done
193
+ if [ "$inplan" = 0 ]; then OOP=$((OOP + 1)); OOP_LIST="${OOP_LIST}${path}"$'\n'; fi
194
+ done <<< "$RAW"
195
+ [ "$OOP" -gt 0 ] && SCOPE=out_of_plan
196
+ fi
197
+ OVERRUN=false
198
+ if [ -n "$ESTIMATE" ] && [ "$ESTIMATE" -gt 0 ] && [ $((WSL * 100)) -gt $((ESTIMATE * OVERRUN_PCT)) ]; then OVERRUN=true; fi
199
+ echo "scope=$SCOPE"
200
+ echo "plan_files=${#PLAN[@]}"
201
+ echo "out_of_plan_count=$OOP"
202
+ [ -n "$ESTIMATE" ] && { echo "estimate=$ESTIMATE"; echo "overrun=$OVERRUN"; }
203
+ fi
204
+ _res=OK; if [ "$COHESIVE" = true ]; then _res=COHESIVE; elif [ "$OVER" = true ]; then _res=OVER; fi
205
+ forge_emit diff_size lines="$TOTAL" excluded="$EXCL" threshold="$THRESHOLD" result="$_res" review_weight="$REVIEW_WEIGHT" modules="$NMODS" ws_lines="$WSL"
115
206
  if [ -n "$LIST" ]; then
116
207
  printf '%s' "$LIST" | sort -t' ' -k1,1nr | head -n 10 | sed 's/^/top=/' || true
117
208
  fi
209
+ if [ -n "$OOP_LIST" ]; then
210
+ printf '%s' "$OOP_LIST" | head -n 50 | sed 's/^/out_of_plan=/' || true
211
+ fi
@@ -137,10 +137,67 @@ check "test name patterns" "$OUT" test_lines 30
137
137
  reset; gen 20 > keep.py; gen 900 > package-lock.json; git add -A
138
138
  OUT="$(run --threshold 1000)"; check "excluded not in new_lines" "$OUT" new_lines 20
139
139
 
140
- # existing key order is unchanged: cohesion keys sit between over= and top=
140
+ # existing key order is unchanged: cohesion keys sit between over= and top=; review keys follow cohesive=
141
141
  reset; gen 3 > s.txt; git add -A
142
142
  KEYS="$(run | sed -n 's/^\([a-z_]*\)=.*/\1/p' | tr '\n' ' ')"
143
- [ "$KEYS" = "diff_lines excluded_lines threshold over new_lines test_lines cohesive_lines cohesive top " ] && ok || bad "key order ($KEYS)"
143
+ [ "$KEYS" = "diff_lines excluded_lines threshold over new_lines test_lines cohesive_lines cohesive ws_lines review_weight modules top " ] && ok || bad "key order ($KEYS)"
144
+
145
+ # review_weight (forge#3862): a pure re-indent counts about 0, whatever its raw size
146
+ reset; mkdir -p svc; awk 'BEGIN{for(i=0;i<120;i++) print "if x:\n y" i}' > svc/h.py; git add -A; git commit -qm h; git push -q origin feat:staging 2>/dev/null
147
+ awk 'BEGIN{for(i=0;i<120;i++) print "if x:\n y" i}' > svc/h.py; git add -A
148
+ OUT="$(run)"; check "re-indent raw diff_lines" "$OUT" diff_lines 240; check "re-indent ws_lines" "$OUT" ws_lines 0
149
+ check "re-indent review_weight" "$OUT" review_weight 0; check "re-indent modules" "$OUT" modules 0
150
+ git reset -q --hard origin/staging
151
+
152
+ # tests are weighted (default 50%, configurable); ws_lines is unweighted
153
+ reset; mkdir -p src tests; gen 100 > src/m.py; gen 200 > tests/test_m.py; git add -A
154
+ OUT="$(run)"; check "weight ws_lines" "$OUT" ws_lines 300; check "weight default 50" "$OUT" review_weight 200
155
+ OUT="$(run --test-weight 25)"; check "weight 25" "$OUT" review_weight 150
156
+ OUT="$(run --test-weight 0)"; check "weight 0" "$OUT" review_weight 100
157
+ OUT="$(run --test-weight 100)"; check "weight 100" "$OUT" review_weight 300
158
+ reset; mkdir -p src; gen 3 > src/o.py; mkdir -p tests; gen 1 > tests/test_o.py; git add -A
159
+ OUT="$(run)"; check "weight rounds up" "$OUT" review_weight 4
160
+
161
+ # generated and vendored code is excluded from review_weight and modules
162
+ reset; gen 10 > keep.py; mkdir -p vendor generated; gen 900 > vendor/v.js; gen 900 > generated/g.js; gen 900 > package-lock.json; git add -A
163
+ OUT="$(run)"; check "excluded ws_lines" "$OUT" ws_lines 10; check "excluded review_weight" "$OUT" review_weight 10; check "excluded modules" "$OUT" modules 1
164
+
165
+ # module count: distinct directories at --module-depth
166
+ reset; mkdir -p a/b/c a/b/d a/e f; gen 5 > a/b/c/1.py; gen 5 > a/b/d/2.py; gen 5 > a/e/3.py; gen 5 > f/4.py; gen 5 > root.py; git add -A
167
+ OUT="$(run)"; check "modules depth 2" "$OUT" modules 4
168
+ OUT="$(run --module-depth 1)"; check "modules depth 1" "$OUT" modules 3
169
+ OUT="$(run --module-depth 3)"; check "modules depth 3" "$OUT" modules 5
170
+
171
+ # scope diff: in-plan, out-of-plan, prefix entries, formatting noise, no baseline, overrun
172
+ reset; mkdir -p src tests docs; gen 10 > src/a.py; gen 10 > src/b.py; gen 10 > tests/test_a.py; gen 10 > docs/x.md; git add -A
173
+ printf '%s\n' '# plan' '- `src/a.py`' '* ./src/b.py' '' 'tests/' > "$TMP/plan"
174
+ OUT="$(run --plan-files "$TMP/plan")"
175
+ check "scope out_of_plan" "$OUT" scope out_of_plan; check "scope count" "$OUT" out_of_plan_count 1
176
+ check "scope plan_files" "$OUT" plan_files 3; check "scope path" "$OUT" out_of_plan docs/x.md
177
+ printf '%s\n' src/a.py src/b.py tests/test_a.py docs/x.md > "$TMP/plan"
178
+ OUT="$(run --plan-files "$TMP/plan")"; check "scope ok" "$OUT" scope ok; check "scope ok count" "$OUT" out_of_plan_count 0
179
+ printf '%s\n' 'src/a.py' > "$TMP/plan"
180
+ OUT="$(run --plan-files "$TMP/plan")"; check "scope 3 out" "$OUT" out_of_plan_count 3
181
+ : > "$TMP/plan"; OUT="$(run --plan-files "$TMP/plan")"; check "empty plan is no_baseline" "$OUT" scope no_baseline; check "no_baseline count" "$OUT" out_of_plan_count 0
182
+ OUT="$(run)"; [ -z "$(val "$OUT" scope)" ] && ok || bad "scope keys absent without plan args"
183
+ # excluded files are never out-of-plan; a path is data (glob chars, spaces) not a pattern
184
+ reset; mkdir -p vendor; gen 5 > vendor/z.js; gen 5 > 'we ird[1].txt'; git add -A
185
+ printf '%s\n' 'src/a.py' > "$TMP/plan"
186
+ OUT="$(run --plan-files "$TMP/plan")"; check "excluded not out-of-plan" "$OUT" out_of_plan_count 1; check "path with space and glob quoted" "$OUT" out_of_plan 'we ird[1].txt'
187
+ printf '%s\n' '*.txt' > "$TMP/plan"; OUT="$(run --plan-files "$TMP/plan")"; check "plan entry is not a glob" "$OUT" out_of_plan_count 1
188
+ # overrun against the plan estimate (ws-insensitive actual)
189
+ reset; gen 500 > big.py; git add -A
190
+ OUT="$(run --estimate 200)"; check "overrun 2.5x" "$OUT" overrun true; check "overrun estimate" "$OUT" estimate 200; check "overrun no plan file is no_baseline" "$OUT" scope no_baseline
191
+ OUT="$(run --estimate 250)"; check "at 2x not overrun" "$OUT" overrun false
192
+ OUT="$(run --estimate 200 --overrun-pct 300)"; check "custom overrun pct" "$OUT" overrun false
193
+ OUT="$(run --estimate 0)"; check "estimate 0 is no estimate" "$OUT" overrun false
194
+ # unreadable plan fails closed; bad numeric args are usage errors
195
+ OUT="$(bash "$S" --repo-path "$TMP/w" --base staging --plan-files "$TMP/none" 2>/dev/null)"; rc=$?
196
+ { [ "$rc" = 2 ] && [ -z "$OUT" ]; } && ok || bad "unreadable plan (rc=$rc)"
197
+ for bad_arg in "--test-weight x" "--module-depth 0" "--overrun-pct -1" "--estimate x"; do
198
+ OUT="$(bash "$S" --repo-path "$TMP/w" --base staging $bad_arg 2>/dev/null)"; rc=$?
199
+ { [ "$rc" = 2 ] && [ -z "$OUT" ]; } && ok || bad "bad arg $bad_arg (rc=$rc)"
200
+ done
144
201
 
145
202
  # fail closed: bad repo path, bad base, bad threshold, unknown arg, missing base
146
203
  OUT="$(bash "$S" --repo-path "$TMP/nope" --base staging 2>/dev/null)"; rc=$?
@@ -49,6 +49,8 @@
49
49
  # PRIOR_GIST annotations in recent issue comments)
50
50
  # I9 Issues stuck in workflow:remediating beyond threshold (the state must
51
51
  # never outlive its remediation run)
52
+ # I10 Issues in workflow:engine-error that also carry another in-flight
53
+ # workflow:* label, or that sit there beyond threshold
52
54
  #
53
55
  # /pipeline-health should consume this script's JSON output rather than
54
56
  # re-discovering state from scratch.
@@ -357,6 +359,47 @@ while IFS= read -r row; do
357
359
  fi
358
360
  done < <(echo "$REMEDIATING" | jq -c '.[]')
359
361
 
362
+ # ---------------------------------------------------------------------------
363
+ # I10: Issues stuck in workflow:engine-error
364
+ # ---------------------------------------------------------------------------
365
+ # engine-error is a terminal projection: it must not coexist with another
366
+ # in-flight workflow:* label (stranded state) and must not sit unrecovered past
367
+ # the threshold. Same fail-inconclusive pattern as I9.
368
+ if ! ENGINE_ERRORS=$(gh issue list $GH_FLAG \
369
+ --state open --label "workflow:engine-error" \
370
+ --limit 100 \
371
+ --json number,title,updatedAt,labels 2>"$GH_STDERR_TMP"); then
372
+ echo "WARNING: gh issue list failed for workflow:engine-error — I10 check inconclusive, skipping (not treated as empty): $(cat "$GH_STDERR_TMP")" >&2
373
+ add_skip "I10" "all" "gh issue list failed: $(bounded_reason "$(cat "$GH_STDERR_TMP")")"
374
+ ENGINE_ERRORS="[]"
375
+ fi
376
+
377
+ while IFS= read -r row; do
378
+ num=$(echo "$row" | jq -r '.number')
379
+ labels=$(echo "$row" | jq -c '[.labels[].name]')
380
+ if has_terminal_label "$labels"; then
381
+ continue
382
+ fi
383
+ updated=$(echo "$row" | jq -r '.updatedAt')
384
+ hours=$(hours_since "$updated")
385
+ other=$(echo "$labels" | jq -r '[.[] | select(startswith("workflow:") and . != "workflow:engine-error")] | first // empty')
386
+ if [ -n "$other" ]; then
387
+ add_finding \
388
+ "stuck_engine_error" "warning" \
389
+ "$num" "workflow:engine-error" "$hours" \
390
+ "FORGE:STATE" \
391
+ "forgedock run-issue $num --retry" \
392
+ "Issue #$num carries workflow:engine-error together with $other (stranded state). Remove the stale label or recover with forgedock run-issue $num --retry"
393
+ elif [ "$hours" -ge "$STUCK_HOURS" ]; then
394
+ add_finding \
395
+ "stuck_engine_error" "warning" \
396
+ "$num" "workflow:engine-error" "$hours" \
397
+ "FORGE:STATE" \
398
+ "forgedock run-issue $num --retry" \
399
+ "Issue #$num stuck in workflow:engine-error for ${hours}h (threshold: ${STUCK_HOURS}h). Recover with forgedock run-issue $num --retry"
400
+ fi
401
+ done < <(echo "$ENGINE_ERRORS" | jq -c '.[]')
402
+
360
403
  # ---------------------------------------------------------------------------
361
404
  # I4: Issues in workflow:building with no FORGE:BUILDER annotation
362
405
  # ---------------------------------------------------------------------------
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env bash
2
+ # file-deferred-followups.sh — file ALL deferred out-of-scope items of one build as ONE follow-up issue.
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # The builder decides fold-vs-defer (judgment, build.md B2.0); this script does the mechanics for the items
7
+ # that stay deferred: trusted lookup of an open follow-up carrying <!-- FORGE:DEFERRED_FROM: #N -->, append the
8
+ # missing items to it as checklist entries (exact path match), or create ONE issue with a checklist.
9
+ #
10
+ # Usage: file-deferred-followups.sh --parent N --repo OWNER/REPO [--items FILE|-] [--folded N]
11
+ # items TSV, one per line: <path> TAB <why> TAB <known-flaw flag: known-flaw|1|yes|true, else empty>
12
+ # Env: DRY_RUN (non-empty: no create/edit, issue number 0), FORGE_TRAIL_TRUSTED_ASSOCIATIONS (default
13
+ # OWNER,MEMBER,COLLABORATOR), FORGE_TRAIL_TRUSTED_LOGINS (default empty), FORGE_DEFERRED_RETRY_SLEEP (secs).
14
+ # Stdout: `path<TAB>issue` per item, then DEFERRED_TELEMETRY deferred_items=N folded=N batched_into=#M filed=N.
15
+ # Exit: 0 ok | 2 usage | 3 lookup failed (DEFERRED_LOOKUP=FAILED) | 4 create/append failed (DEFERRED_FILE=FAILED).
16
+ # A failed lookup is never "none found". bash 3.2 portable.
17
+
18
+ set -uo pipefail
19
+
20
+ PARENT=""; REPO=""; ITEMS="-"; FOLDED=0
21
+ while [ $# -gt 0 ]; do
22
+ case "$1" in
23
+ --parent|--repo|--items|--folded)
24
+ [ $# -ge 2 ] || { echo "file-deferred-followups: $1 requires a value" >&2; exit 2; }
25
+ case "$1" in --parent) PARENT="$2" ;; --repo) REPO="$2" ;; --items) ITEMS="$2" ;; --folded) FOLDED="$2" ;; esac
26
+ shift 2 ;;
27
+ *) echo "usage: file-deferred-followups.sh --parent N --repo OWNER/REPO [--items FILE|-] [--folded N]" >&2; exit 2 ;;
28
+ esac
29
+ done
30
+ case "$PARENT" in ''|*[!0-9]*) echo "file-deferred-followups: --parent must be a number" >&2; exit 2 ;; esac
31
+ case "$FOLDED" in ''|*[!0-9]*) FOLDED=0 ;; esac
32
+ [ -n "$REPO" ] || { echo "file-deferred-followups: --repo required" >&2; exit 2; }
33
+ if [ "$ITEMS" != "-" ] && [ ! -f "$ITEMS" ]; then echo "file-deferred-followups: items file not found: $ITEMS" >&2; exit 2; fi
34
+
35
+ TMP=$(mktemp -d "${TMPDIR:-/tmp}/deferred-followups.XXXXXX") || exit 2
36
+ trap 'rm -rf "$TMP"' EXIT
37
+ if [ "$ITEMS" = "-" ]; then cat > "$TMP/items.raw"; else cat "$ITEMS" > "$TMP/items.raw"; fi
38
+
39
+ # Normalise: drop blank lines and duplicate paths (first wins), strip backticks from the path.
40
+ # Internal file uses a non-whitespace separator (US, \x1f): a tab IFS collapses an empty `why` and drops the known-flaw flag.
41
+ SEP=$(printf '\037')
42
+ : > "$TMP/items"
43
+ while IFS= read -r line || [ -n "$line" ]; do
44
+ # Split on TAB by hand: `IFS=$'\t' read` would collapse an empty why field and shift the flag into it.
45
+ p="${line%%$'\t'*}"; rest=""; w=""; k=""
46
+ case "$line" in *$'\t'*) rest="${line#*$'\t'}"; w="${rest%%$'\t'*}"; case "$rest" in *$'\t'*) k="${rest#*$'\t'}" ;; esac ;; esac
47
+ p=$(printf '%s' "${p:-}" | tr -d '`' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
48
+ [ -n "$p" ] || continue
49
+ if cut -d "$SEP" -f1 "$TMP/items" | grep -qxF -- "$p"; then continue; fi
50
+ case "${k:-}" in known-flaw|1|yes|true) k="known-flaw" ;; *) k="" ;; esac
51
+ w=$(printf '%s' "${w:-}" | tr '\r\n' ' ')
52
+ printf '%s%s%s%s%s\n' "$p" "$SEP" "$w" "$SEP" "$k" >> "$TMP/items"
53
+ done < "$TMP/items.raw"
54
+ TOTAL=$(wc -l < "$TMP/items" | tr -d ' ')
55
+
56
+ MARKER="<!-- FORGE:DEFERRED_FROM: #${PARENT} -->"
57
+ TRUSTED_ASSOC="${FORGE_TRAIL_TRUSTED_ASSOCIATIONS-OWNER,MEMBER,COLLABORATOR}"
58
+ TRUSTED_LOGINS="${FORGE_TRAIL_TRUSTED_LOGINS-}"
59
+
60
+ finish() { # $1 = batched_into number or empty, $2 = filed count
61
+ local into="none"; [ -n "$1" ] && into="#$1"
62
+ local line="DEFERRED_TELEMETRY deferred_items=${TOTAL} folded=${FOLDED} batched_into=${into} filed=$2"
63
+ echo "$line"
64
+ # #3834 emitter (scripts/lib/telemetry.sh): optional, never blocks, never changes stdout or the exit code.
65
+ local d; d=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
66
+ if [ -f "$d/lib/telemetry.sh" ]; then
67
+ FORGE_ISSUE="${FORGE_ISSUE:-$PARENT}" FORGE_REPO="${FORGE_REPO:-$REPO}" \
68
+ bash "$d/lib/telemetry.sh" deferred_followups deferred_items="$TOTAL" folded="$FOLDED" \
69
+ batched_into="${1:-none}" filed="$2" >/dev/null 2>&1 </dev/null || true
70
+ fi
71
+ }
72
+
73
+ # --- Trusted lookup (author predicate applied before path match), one retry ---------------------------------
74
+ do_lookup() {
75
+ gh api -X GET search/issues -f per_page=50 \
76
+ -f q="\"FORGE:DEFERRED_FROM: #${PARENT}\" in:body repo:${REPO} is:issue is:open" 2>/dev/null \
77
+ | jq -c --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" --arg m "$MARKER" '
78
+ ($assoc | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $A
79
+ | ($logins | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $L
80
+ | [.items[]
81
+ | select(((.author_association // "") as $x | $A | index($x) != null)
82
+ or ((.user.type // "") == "Bot")
83
+ or ((.user.login // "") as $x | $L | index($x) != null))
84
+ | select((.body // "") | contains($m))
85
+ | {number, body: (.body // "")}]
86
+ | sort_by(.number)' 2>/dev/null
87
+ }
88
+ LOOKUP=""
89
+ for attempt in 1 2; do
90
+ LOOKUP=$(do_lookup) && [ -n "$LOOKUP" ] && break
91
+ LOOKUP=""
92
+ [ "$attempt" = 1 ] && sleep "${FORGE_DEFERRED_RETRY_SLEEP:-2}"
93
+ done
94
+ if [ -z "$LOOKUP" ]; then
95
+ echo "DEFERRED_LOOKUP=FAILED"
96
+ exit 3
97
+ fi
98
+
99
+ # Checklist line for one item. Path always backticked so review-pr 6B.5 `contains` verification holds.
100
+ entry() { # path why flag
101
+ local l="- [ ] \`$1\`"
102
+ [ -n "$3" ] && l="$l **known-flaw**"
103
+ [ -n "$2" ] && l="$l — $2"
104
+ printf '%s\n' "$l"
105
+ }
106
+ has_entry() { # body-file path : exact checklist-line match, not substring
107
+ grep -qE '^- \[[ xX]\] `' "$1" 2>/dev/null || return 1
108
+ grep -F -- "\`$2\`" "$1" | grep -qE "^- \[[ xX]\] \`$(printf '%s' "$2" | sed 's/[][\.*^$+?(){}|]/\\&/g')\`( |\$)"
109
+ }
110
+
111
+ NUM=$(printf '%s' "$LOOKUP" | jq -r 'first | .number // empty' 2>/dev/null)
112
+
113
+ if [ -n "$NUM" ]; then
114
+ # Re-read the body immediately before editing (parallel builds of the same parent), then append what is missing.
115
+ if ! gh api "repos/${REPO}/issues/${NUM}" --jq '.body // ""' > "$TMP/body" 2>/dev/null; then
116
+ echo "DEFERRED_FILE=FAILED"; exit 4
117
+ fi
118
+ : > "$TMP/add"
119
+ while IFS="$SEP" read -r p w k; do
120
+ has_entry "$TMP/body" "$p" || entry "$p" "$w" "$k" >> "$TMP/add"
121
+ done < "$TMP/items"
122
+ if [ -s "$TMP/add" ]; then
123
+ { cat "$TMP/body"; printf '\n### Added by a later build of #%s\n\n' "$PARENT"; cat "$TMP/add"; } > "$TMP/newbody"
124
+ if [ -n "${DRY_RUN:-}" ]; then
125
+ echo "DRY_RUN: gh issue edit $NUM (append $(wc -l < "$TMP/add" | tr -d ' ') item(s))" >&2
126
+ elif ! gh issue edit "$NUM" -R "$REPO" --body-file "$TMP/newbody" >/dev/null 2>&1; then
127
+ echo "DEFERRED_FILE=FAILED"; exit 4
128
+ fi
129
+ fi
130
+ while IFS="$SEP" read -r p w k; do printf '%s\t%s\n' "$p" "$NUM"; done < "$TMP/items"
131
+ finish "$NUM" 0
132
+ exit 0
133
+ fi
134
+
135
+ [ "$TOTAL" -gt 0 ] || { finish "" 0; exit 0; }
136
+
137
+ # --- Create ONE issue with a checklist for every item ---------------------------------------------------------
138
+ {
139
+ printf '## Problem\n\n'
140
+ printf 'Deferred from #%s: the items below were declared out of scope in its contract and are tracked here, in one issue, instead of surfacing later as review findings.\n\n' "$PARENT"
141
+ printf '## Affected Files\n\n'
142
+ while IFS="$SEP" read -r p w k; do printf -- '- `%s`\n' "$p"; done < "$TMP/items"
143
+ printf '\n## Acceptance Criteria\n\n'
144
+ while IFS="$SEP" read -r p w k; do entry "$p" "$w" "$k"; done < "$TMP/items"
145
+ printf '\n%s\n' "$MARKER"
146
+ } > "$TMP/newissue"
147
+
148
+ if [ -n "${DRY_RUN:-}" ]; then
149
+ echo "DRY_RUN: gh issue create (follow-up: ${TOTAL} deferred item(s) from #${PARENT})" >&2
150
+ NUM=0
151
+ else
152
+ URL=$(gh issue create -R "$REPO" --title "follow-up: ${TOTAL} deferred item(s) (deferred from #${PARENT})" \
153
+ --body-file "$TMP/newissue" 2>/dev/null) || URL=""
154
+ NUM=$(printf '%s' "$URL" | grep -oE '[0-9]+$' | tail -n 1)
155
+ [ -n "$NUM" ] || { echo "DEFERRED_FILE=FAILED"; exit 4; }
156
+ fi
157
+ while IFS="$SEP" read -r p w k; do printf '%s\t%s\n' "$p" "$NUM"; done < "$TMP/items"
158
+ finish "$NUM" 1
159
+ exit 0
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env bash
2
+ # file-deferred-followups.test.sh — stubbed-gh tests for scripts/file-deferred-followups.sh
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ set -uo pipefail
6
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ S="$HERE/file-deferred-followups.sh"
8
+ T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
9
+ mkdir "$T/bin"
10
+ export FORGE_DEFERRED_RETRY_SLEEP=0
11
+ export GH_LOG="$T/gh.log" SEARCH_JSON="$T/search.json" ISSUE_BODY="$T/issue.body"
12
+
13
+ cat > "$T/bin/gh" <<'MOCK'
14
+ #!/usr/bin/env bash
15
+ echo "$*" >> "$GH_LOG"
16
+ case "$1 $2" in
17
+ "api -X") [ -n "${LOOKUP_FAIL:-}" ] && exit 1; cat "$SEARCH_JSON"; exit 0 ;;
18
+ "issue create") [ -n "${CREATE_FAIL:-}" ] && exit 1; echo "https://github.com/o/r/issues/901"; exit 0 ;;
19
+ "issue edit")
20
+ [ -n "${EDIT_FAIL:-}" ] && exit 1
21
+ while [ $# -gt 0 ]; do [ "$1" = "--body-file" ] && cp "$2" "$ISSUE_BODY.edited"; shift; done; exit 0 ;;
22
+ esac
23
+ case "$1" in api) cat "$ISSUE_BODY"; exit 0 ;; esac
24
+ exit 1
25
+ MOCK
26
+ chmod +x "$T/bin/gh"; export PATH="$T/bin:$PATH"
27
+
28
+ PASS=0; FAIL=0
29
+ ok() { PASS=$((PASS+1)); echo "PASS: $1"; }
30
+ bad() { FAIL=$((FAIL+1)); echo "FAIL: $1"; }
31
+ check() { if eval "$2"; then ok "$1"; else bad "$1"; fi; }
32
+ reset() { : > "$GH_LOG"; rm -f "$ISSUE_BODY.edited"; unset LOOKUP_FAIL CREATE_FAIL EDIT_FAIL DRY_RUN; echo '{"items":[]}' > "$SEARCH_JSON"; }
33
+ printf 'a/x.sh\twhy a\t\nb/y.sh\twhy b\tknown-flaw\nc/z.sh\twhy c\t\n' > "$T/items.tsv"
34
+
35
+ # 1. N items -> exactly one create
36
+ reset
37
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" --folded 2); RC=$?
38
+ check "create: rc 0" "[ $RC -eq 0 ]"
39
+ check "create: one issue create for 3 items" "[ \$(grep -c '^issue create' '$GH_LOG') -eq 1 ]"
40
+ check "create: rows share issue" "[ \$(printf '%s\n' \"\$OUT\" | grep -c '901\$') -ge 3 ]"
41
+ check "create: telemetry" "printf '%s' \"\$OUT\" | grep -q 'DEFERRED_TELEMETRY deferred_items=3 folded=2 batched_into=#901 filed=1'"
42
+
43
+ # 2. append to existing trusted open issue; exact path match
44
+ reset
45
+ printf '## Affected Files\n- `a/x.sh`\n\n- [ ] `a/x.sh.bak` — other\n<!-- FORGE:DEFERRED_FROM: #10 -->\n' > "$ISSUE_BODY"
46
+ echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
47
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
48
+ check "append: rc 0, no create" "[ $RC -eq 0 ] && ! grep -q '^issue create' '$GH_LOG'"
49
+ check "append: telemetry batched into 77 filed=0" "printf '%s' \"\$OUT\" | grep -q 'batched_into=#77 filed=0'"
50
+ check "append: all three added (substring .bak does not dedup a/x.sh)" "[ \$(grep -c '^- \[ \] \`' '$ISSUE_BODY.edited') -eq 4 ]"
51
+ check "append: known-flaw flagged" "grep -q 'b/y.sh. \*\*known-flaw\*\*' '$ISSUE_BODY.edited'"
52
+
53
+ # 2b. empty why + known-flaw: the flag must survive (a whitespace IFS would collapse the empty field)
54
+ reset
55
+ printf 'd/w.sh\t\tknown-flaw\n' > "$T/emptywhy.tsv"
56
+ bash "$S" --parent 10 --repo o/r --items "$T/emptywhy.tsv" >/dev/null; RC=$?
57
+ rm -f "$ISSUE_BODY.edited"; printf 'x\n' > "$ISSUE_BODY"
58
+ echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
59
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/emptywhy.tsv"); RC=$?
60
+ check "empty-why: append keeps known-flaw flag" "[ $RC -eq 0 ] && grep -q 'd/w.sh. \*\*known-flaw\*\*' '$ISSUE_BODY.edited'"
61
+
62
+ # 2c. missing option value -> usage exit 2
63
+ bash "$S" --parent >/dev/null 2>&1; RC=$?
64
+ check "missing option value: rc 2" "[ $RC -eq 2 ]"
65
+
66
+ # 3. already-listed path is not duplicated; nothing to add -> no edit
67
+ reset
68
+ printf -- '- [ ] `a/x.sh` — w\n- [x] `b/y.sh`\n- [ ] `c/z.sh`\n' > "$ISSUE_BODY"
69
+ echo '{"items":[{"number":77,"author_association":"MEMBER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
70
+ bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" >/dev/null; RC=$?
71
+ check "idempotent: no edit, no create on resume" "[ $RC -eq 0 ] && ! grep -qE '^issue (edit|create)' '$GH_LOG'"
72
+
73
+ # 4. untrusted match ignored -> new issue
74
+ reset
75
+ echo '{"items":[{"number":66,"author_association":"NONE","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
76
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv")
77
+ check "untrusted: ignored, create filed" "grep -q '^issue create' '$GH_LOG' && printf '%s' \"\$OUT\" | grep -q 'filed=1'"
78
+
79
+ # 5. lookup failure -> FAILED, nothing filed
80
+ reset; export LOOKUP_FAIL=1
81
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
82
+ check "lookup failure: rc 3, DEFERRED_LOOKUP=FAILED, no create" "[ $RC -eq 3 ] && printf '%s' \"\$OUT\" | grep -q DEFERRED_LOOKUP=FAILED && ! grep -q '^issue create' '$GH_LOG'"
83
+
84
+ # 6. create / edit failure not counted as success
85
+ reset; export CREATE_FAIL=1
86
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
87
+ check "create failure: rc 4, no telemetry" "[ $RC -eq 4 ] && ! printf '%s' \"\$OUT\" | grep -q DEFERRED_TELEMETRY"
88
+ reset; export EDIT_FAIL=1
89
+ printf 'x\n' > "$ISSUE_BODY"
90
+ echo '{"items":[{"number":77,"author_association":"OWNER","user":{"type":"User"},"body":"<!-- FORGE:DEFERRED_FROM: #10 -->"}]}' > "$SEARCH_JSON"
91
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv"); RC=$?
92
+ check "edit failure: rc 4, no telemetry" "[ $RC -eq 4 ] && ! printf '%s' \"\$OUT\" | grep -q DEFERRED_TELEMETRY"
93
+
94
+ # 7. DRY_RUN
95
+ reset; export DRY_RUN=1
96
+ OUT=$(bash "$S" --parent 10 --repo o/r --items "$T/items.tsv" 2>/dev/null)
97
+ check "dry-run: no create" "! grep -q '^issue create' '$GH_LOG' && printf '%s' \"\$OUT\" | grep -q 'filed=1'"
98
+
99
+ # 8. empty items, usage errors
100
+ reset
101
+ OUT=$(printf '' | bash "$S" --parent 10 --repo o/r)
102
+ check "empty: no create, deferred_items=0" "! grep -q '^issue create' '$GH_LOG' && printf '%s' \"\$OUT\" | grep -q 'deferred_items=0'"
103
+ bash "$S" --repo o/r >/dev/null 2>&1; check "usage: missing parent rc 2" "[ $? -eq 2 ]"
104
+
105
+ echo "passed=$PASS failed=$FAIL"
106
+ [ "$FAIL" -eq 0 ]