forgedock 1.26.0 → 1.28.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 (137) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +13 -0
  4. package/bin/engine/phases.mjs +38 -2
  5. package/bin/engine/telemetry-seam.mjs +41 -6
  6. package/bin/engine/telemetry-seam.test.mjs +35 -0
  7. package/bin/engine-cli.mjs +616 -54
  8. package/bin/engine.mjs +13 -5
  9. package/bin/forgedock.mjs +169 -39
  10. package/bin/gc.mjs +580 -0
  11. package/bin/init-detect.mjs +68 -3
  12. package/bin/init-enrich.mjs +3 -0
  13. package/bin/init-repo.mjs +213 -0
  14. package/bin/journey.mjs +278 -43
  15. package/bin/lean/admission.mjs +33 -3
  16. package/bin/lean/batch.mjs +24 -5
  17. package/bin/lean/batch.test.mjs +54 -4
  18. package/bin/lean/checks.mjs +199 -0
  19. package/bin/lean/checks.test.mjs +183 -0
  20. package/bin/lean/conformance.mjs +235 -37
  21. package/bin/lean/conformance.test.mjs +143 -5
  22. package/bin/lean/credentials.mjs +29 -0
  23. package/bin/lean/depcache.mjs +312 -0
  24. package/bin/lean/depcache.test.mjs +298 -0
  25. package/bin/lean/drift.mjs +198 -0
  26. package/bin/lean/drift.test.mjs +223 -0
  27. package/bin/lean/effects.mjs +178 -16
  28. package/bin/lean/execution.e2e.test.mjs +81 -0
  29. package/bin/lean/execution.mjs +431 -0
  30. package/bin/lean/execution.test.mjs +583 -0
  31. package/bin/lean/heavy.mjs +289 -0
  32. package/bin/lean/heavy.test.mjs +207 -0
  33. package/bin/lean/lean.mjs +877 -151
  34. package/bin/lean/lean.test.mjs +1798 -28
  35. package/bin/lean/ledger.mjs +92 -0
  36. package/bin/lean/ledger.test.mjs +100 -0
  37. package/bin/lean/limits.mjs +58 -0
  38. package/bin/lean/limits.test.mjs +150 -0
  39. package/bin/lean/orchestrate.mjs +321 -19
  40. package/bin/lean/prompts.mjs +125 -17
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +25 -6
  44. package/bin/lean/review-trigger.mjs +134 -0
  45. package/bin/lean/review-trigger.test.mjs +104 -0
  46. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  47. package/bin/lean/sandbox-remote.mjs +142 -0
  48. package/bin/lean/sched.mjs +221 -0
  49. package/bin/lean/sched.test.mjs +310 -0
  50. package/bin/lean/schedd.mjs +247 -0
  51. package/bin/lean/schedd.test.mjs +180 -0
  52. package/bin/lean/toolchain.mjs +228 -0
  53. package/bin/lean/toolchain.test.mjs +154 -0
  54. package/bin/lean/verify.mjs +124 -0
  55. package/bin/lean/verify.test.mjs +118 -0
  56. package/bin/lean/worktree.mjs +69 -0
  57. package/bin/lean/worktree.test.mjs +141 -0
  58. package/bin/runner.mjs +53 -10
  59. package/bin/stats/github-source.mjs +1 -1
  60. package/bin/stats/index.mjs +14 -2
  61. package/bin/stats/query.mjs +72 -0
  62. package/bin/stats/stats.test.mjs +54 -0
  63. package/commands/adopt.md +3 -0
  64. package/commands/audit.md +5 -8
  65. package/commands/autopilot.md +2 -0
  66. package/commands/bootstrap.md +35 -0
  67. package/commands/cleanup.md +7 -22
  68. package/commands/forge-stats.md +2 -1
  69. package/commands/forgedock-init.md +125 -6
  70. package/commands/issue.md +89 -830
  71. package/commands/milestone.md +55 -6
  72. package/commands/orchestrate.md +10 -2
  73. package/commands/review-pr.md +2 -2
  74. package/commands/upgrade-deps.md +3 -3
  75. package/commands/work-on/build/validate.md +4 -0
  76. package/commands/work-on/close.md +2 -0
  77. package/commands/work-on/decompose.md +24 -6
  78. package/docs/CI.md +3 -3
  79. package/docs/CONFIG.md +227 -3
  80. package/docs/OUTCOME-CODES.md +11 -0
  81. package/docs/TELEMETRY.md +1 -1
  82. package/docs/site/command-reference.md +22 -0
  83. package/docs/site/getting-started.md +2 -0
  84. package/docs/site/start-a-new-project.md +327 -0
  85. package/package.json +1 -1
  86. package/scripts/affected-files-check.sh +91 -0
  87. package/scripts/affected-files-check.test.sh +80 -0
  88. package/scripts/bootstrap-create.mjs +393 -0
  89. package/scripts/bootstrap-validate.mjs +321 -0
  90. package/scripts/calibration.mjs +116 -0
  91. package/scripts/check-devdocs-freshness.sh +125 -0
  92. package/scripts/check-devdocs-freshness.test.sh +130 -0
  93. package/scripts/check-spec-markers.sh +3 -0
  94. package/scripts/check-spec-size.sh +29 -11
  95. package/scripts/check-spec-size.test.sh +27 -4
  96. package/scripts/ci-local/lib.mjs +8 -1
  97. package/scripts/ci-local/parallelism.test.mjs +27 -0
  98. package/scripts/ci-local/run.mjs +16 -5
  99. package/scripts/ci-local/scoping.mjs +82 -0
  100. package/scripts/ci-local.test.sh +14 -2
  101. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  102. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  103. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  106. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  107. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  108. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  109. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  110. package/scripts/forge-root.test.sh +1 -1
  111. package/scripts/hotspots.sh +100 -0
  112. package/scripts/hotspots.test.sh +101 -0
  113. package/scripts/issue-create.sh +197 -0
  114. package/scripts/issue-create.test.sh +166 -0
  115. package/scripts/maintenance-scan.sh +185 -0
  116. package/scripts/maintenance-scan.test.sh +89 -0
  117. package/scripts/milestone-drift.sh +206 -0
  118. package/scripts/milestone-drift.test.sh +120 -0
  119. package/scripts/northstar-docs.test.sh +68 -0
  120. package/scripts/refactor-verify.sh +368 -0
  121. package/scripts/refactor-verify.test.sh +202 -0
  122. package/scripts/review-prepare.sh +11 -9
  123. package/scripts/review-prepare.test.sh +14 -0
  124. package/scripts/structure-delta.sh +155 -0
  125. package/scripts/structure-delta.test.sh +136 -0
  126. package/scripts/symbol-index.mjs +253 -0
  127. package/scripts/symbol-index.py +354 -0
  128. package/scripts/symbol-index.test.sh +119 -0
  129. package/scripts/symbol-move-ts.test.sh +161 -0
  130. package/scripts/symbol-move.mjs +379 -0
  131. package/scripts/symbol-move.py +383 -0
  132. package/scripts/symbol-move.test.sh +168 -0
  133. package/templates/bootstrap/interview.md +34 -0
  134. package/templates/bootstrap/issue-body.md +42 -0
  135. package/templates/bootstrap/needs-human-issue.md +45 -0
  136. package/templates/bootstrap/stack-profiles.md +34 -0
  137. package/templates/bootstrap/technical-spike.md +75 -0
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # refactor-verify.sh: deterministic baseline-vs-after proof that a refactor:split only moved code (forge#4209, #4196).
6
+ # No LLM, no network. Never modifies the target repo, its index or its worktree.
7
+ #
8
+ # Usage:
9
+ # refactor-verify.sh baseline --root <dir> --target <file|dir> --test-cmd '<cmd>' --out <file.json>
10
+ # refactor-verify.sh verify --baseline <file.json> --base <ref> [--head <ref>] (head defaults to HEAD)
11
+ #
12
+ # baseline: runs <cmd> via `bash -c` in --root and parses passed/skipped counts from its summary (pytest, unittest,
13
+ # node TAP/spec, jest/vitest; several summaries are summed). Indexes --target with scripts/symbol-index.py (.py) or
14
+ # scripts/symbol-index.mjs and records every public symbol as {module,name}: module = dotted path under --root for
15
+ # Python (pkg/__init__.py -> pkg), the root-relative file path for JS/TS; name may be Class.method. Indexes the
16
+ # target's package directory (the target if a dir, else its parent) and records its import cycles. Writes one
17
+ # key-sorted JSON {test:{exit,passed,skipped},symbols,cycles,root,target,index_dir,lang,test_cmd} to --out.
18
+ # verify: run with --root's worktree checked out at --head. Re-runs test_cmd and the cycle index, resolves every
19
+ # baseline symbol from its OLD module path at runtime (Python importlib + getattr chain, so re-export shims and a
20
+ # module turned into a package resolve; JS/TS node import(); JS/TS interfaces and type aliases, erased at runtime,
21
+ # statically: the old module still exports the name, following `export * from`), and reads
22
+ # `git diff --color-moved=plain --no-renames <base> <head>`. Prints key-sorted JSON {ok,failures:[...]}, kinds:
23
+ # logic-diff {file,line,text} an added/removed line that is not moved, not blank and not an import line
24
+ # (Python import/from-import incl. parenthesised continuations; JS/TS import,
25
+ # export ... from, export * from, incl. braced continuations)
26
+ # missing-symbol {module,name} a baseline public symbol no longer resolves from its old path
27
+ # count-changed {before,after} passed or skipped changed, or the test exit went from 0 to non-zero
28
+ # new-cycle {cycle} an import cycle not present in the baseline (set difference)
29
+ # Exit: 0 ok | 1 verify found failures | 2 usage, missing file, unknown ref, unparseable counts or unsupported language.
30
+ # Requires git, jq, python3; node only for JS/TS targets. Bash 3.2 portable.
31
+ set -uo pipefail
32
+
33
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
34
+ SI_PY="$HERE/symbol-index.py"; SI_MJS="$HERE/symbol-index.mjs"
35
+
36
+ usage() {
37
+ echo "usage: refactor-verify.sh baseline --root <dir> --target <file|dir> --test-cmd '<cmd>' --out <file.json>" >&2
38
+ echo " refactor-verify.sh verify --baseline <file.json> --base <ref> [--head <ref>]" >&2
39
+ exit 2
40
+ }
41
+ die() { echo "refactor-verify.sh: $*" >&2; exit 2; }
42
+
43
+ for tool in git jq python3; do command -v "$tool" >/dev/null 2>&1 || die "missing required tool: $tool"; done
44
+
45
+ SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/refactor-verify.XXXXXX")" || die "cannot create scratch dir"
46
+ trap 'rm -rf "$SCRATCH"' EXIT
47
+
48
+ # ---- passed/skipped counts from a test-run output file: prints "<passed> <skipped>" or exits 1 ----
49
+ parse_counts() {
50
+ python3 -I - "$1" <<'PY'
51
+ import re, sys
52
+ text = open(sys.argv[1], encoding="utf-8", errors="replace").read()
53
+ text = re.sub(r"\x1b\[[0-9;]*m", "", text)
54
+ lines = text.splitlines()
55
+
56
+ def num(pat, s):
57
+ m = re.search(pat, s)
58
+ return int(m.group(1)) if m else 0
59
+
60
+ def result(p, s):
61
+ print(p, s)
62
+ sys.exit(0)
63
+
64
+ # jest ("Tests: 1 skipped, 3 passed, 4 total") / vitest (" Tests 3 passed | 1 skipped (4)")
65
+ found, p, s = False, 0, 0
66
+ for ln in lines:
67
+ if re.match(r"^\s*Tests:?\s+.*\d+ (passed|skipped|failed|todo)", ln):
68
+ found = True
69
+ p += num(r"(\d+) passed", ln); s += num(r"(\d+) skipped", ln) + num(r"(\d+) todo", ln)
70
+ if found: result(p, s)
71
+
72
+ # node --test TAP ("# pass 3", "# skipped 1" / "# skip 1") and spec reporter ("ℹ pass 3", "ℹ skipped 1")
73
+ found, p, s = False, 0, 0
74
+ for ln in lines:
75
+ m = re.match(r"^\s*(?:#|ℹ) pass (\d+)\s*$", ln)
76
+ if m: found = True; p += int(m.group(1)); continue
77
+ m = re.match(r"^\s*(?:#|ℹ) skip(?:ped)? (\d+)\s*$", ln)
78
+ if m: found = True; s += int(m.group(1))
79
+ if found: result(p, s)
80
+
81
+ # unittest: "Ran N tests" followed by "OK (skipped=M)" / "FAILED (failures=1, errors=2, skipped=3)"
82
+ found, p, s, ran = False, 0, 0, None
83
+ for ln in lines:
84
+ m = re.match(r"^Ran (\d+) tests? in ", ln)
85
+ if m: ran = int(m.group(1)); continue
86
+ m = re.match(r"^(OK|FAILED)\b(?:\s*\((.*)\))?\s*$", ln)
87
+ if m and ran is not None:
88
+ det = m.group(2) or ""
89
+ sk = num(r"skipped=(\d+)", det)
90
+ bad = num(r"failures=(\d+)", det) + num(r"errors=(\d+)", det)
91
+ found = True; p += ran - sk - bad; s += sk; ran = None
92
+ if found: result(p, s)
93
+
94
+ # pytest summary ("==== 3 passed, 1 skipped in 0.12s ====" or -q "3 passed, 1 skipped in 0.12s")
95
+ found, p, s = False, 0, 0
96
+ item = r"\d+ (?:passed|failed|skipped|errors?|deselected|xfailed|xpassed|warnings?)"
97
+ for ln in lines:
98
+ if re.search(r"(?:^|[ =])" + item + r"(?:, " + item + r")* in [\d.]+s", ln):
99
+ found = True
100
+ p += num(r"(\d+) passed", ln); s += num(r"(\d+) skipped", ln)
101
+ if found: result(p, s)
102
+ sys.exit(1)
103
+ PY
104
+ }
105
+
106
+ # run_tests <root> <cmd>: sets T_EXIT T_PASSED T_SKIPPED, or dies when no summary can be parsed.
107
+ run_tests() {
108
+ local out="$SCRATCH/test-output.txt" counts
109
+ (cd "$1" && bash -c "$2") >"$out" 2>&1
110
+ T_EXIT=$?
111
+ counts="$(parse_counts "$out")" || { tail -n 20 "$out" >&2; die "could not parse pass/skip counts from: $2"; }
112
+ T_PASSED="${counts% *}"; T_SKIPPED="${counts#* }"
113
+ }
114
+
115
+ # index <path> <lang>: symbol-index JSON on stdout; dies on unsupported output.
116
+ index() {
117
+ local json
118
+ if [ "$2" = "python" ]; then json="$(python3 -I "$SI_PY" "$1")"; else json="$(node "$SI_MJS" "$1")"; fi
119
+ [ -n "$json" ] || die "symbol index failed for $1"
120
+ if printf '%s' "$json" | jq -e 'has("unsupported")' >/dev/null; then
121
+ die "unsupported: $(printf '%s' "$json" | jq -c .)"
122
+ fi
123
+ printf '%s\n' "$json"
124
+ }
125
+
126
+ lang_of() {
127
+ local t="$1"
128
+ if [ -d "$t" ]; then
129
+ if [ -n "$(find "$t" -name '*.py' -not -path '*/node_modules/*' | head -n 1)" ]; then echo python; else echo js; fi
130
+ else
131
+ case "$t" in *.py) echo python ;; *) echo js ;; esac
132
+ fi
133
+ }
134
+
135
+ cmd_baseline() {
136
+ local root="" target="" test_cmd="" out=""
137
+ while [ $# -gt 0 ]; do
138
+ case "$1" in
139
+ --root) root="${2-}"; shift 2 || usage ;;
140
+ --target) target="${2-}"; shift 2 || usage ;;
141
+ --test-cmd) test_cmd="${2-}"; shift 2 || usage ;;
142
+ --out) out="${2-}"; shift 2 || usage ;;
143
+ *) usage ;;
144
+ esac
145
+ done
146
+ [ -n "$root" ] && [ -n "$target" ] && [ -n "$test_cmd" ] && [ -n "$out" ] || usage
147
+ [ -d "$root" ] || die "root not found: $root"
148
+ root="$(cd "$root" && pwd -P)"
149
+ case "$target" in /*) ;; *) target="$root/$target" ;; esac
150
+ [ -e "$target" ] || die "target not found: $target"
151
+ local lang index_dir sym_json cyc_json
152
+ lang="$(lang_of "$target")"
153
+ if [ -d "$target" ]; then index_dir="$(cd "$target" && pwd -P)"; else index_dir="$(cd "$(dirname "$target")" && pwd -P)"; target="$index_dir/$(basename "$target")"; fi
154
+
155
+ run_tests "$root" "$test_cmd"
156
+ sym_json="$(index "$target" "$lang")" || exit 2
157
+ cyc_json="$(index "$index_dir" "$lang")" || exit 2
158
+
159
+ printf '%s' "$sym_json" > "$SCRATCH/symbols.json"
160
+ local symbols
161
+ symbols="$(python3 -I - "$SCRATCH/symbols.json" "$root" "$target" "$lang" <<'PY'
162
+ import json, os, sys
163
+ data = json.load(open(sys.argv[1])); root, target, lang = sys.argv[2], sys.argv[3], sys.argv[4]
164
+ def module(path):
165
+ rel = os.path.relpath(os.path.realpath(path), root).replace(os.sep, "/")
166
+ if lang != "python":
167
+ return rel
168
+ rel = rel[:-3] if rel.endswith(".py") else rel
169
+ if rel.endswith("/__init__") or rel == "__init__":
170
+ rel = rel[: -len("__init__")].rstrip("/")
171
+ return rel.replace("/", ".")
172
+ # JS/TS interfaces and type aliases are erased at runtime: flagged "type" so verify resolves them statically (#4212).
173
+ erased = lambda s: lang != "python" and s.get("kind") in ("interface", "type")
174
+ out = sorted({(module(s.get("file", target)), s["name"], erased(s)) for s in data["symbols"] if s.get("public")})
175
+ print(json.dumps([dict({"module": m, "name": n}, **({"type": True} if t else {})) for m, n, t in out]))
176
+ PY
177
+ )" || die "cannot build symbol list"
178
+
179
+ jq -nS --argjson exit "$T_EXIT" --argjson passed "$T_PASSED" --argjson skipped "$T_SKIPPED" \
180
+ --argjson symbols "$symbols" --argjson cycles "$(printf '%s' "$cyc_json" | jq -c .cycles)" \
181
+ --arg root "$root" --arg target "$target" --arg index_dir "$index_dir" --arg lang "$lang" --arg test_cmd "$test_cmd" \
182
+ '{test:{exit:$exit,passed:$passed,skipped:$skipped},symbols:$symbols,cycles:$cycles,root:$root,target:$target,
183
+ index_dir:$index_dir,lang:$lang,test_cmd:$test_cmd}' > "$out" || die "cannot write $out"
184
+ }
185
+
186
+ # resolve_symbols <baseline.json>: prints a JSON array of unresolved {module,name}.
187
+ resolve_symbols() {
188
+ local root lang
189
+ root="$(jq -r .root "$1")"; lang="$(jq -r .lang "$1")"
190
+ jq -c .symbols "$1" > "$SCRATCH/want.json"
191
+ if [ "$lang" = "python" ]; then
192
+ (cd "$root" && python3 -I -B - "$SCRATCH/want.json" "$root" <<'PY'
193
+ import importlib, io, json, sys, contextlib
194
+ want = json.load(open(sys.argv[1])); sys.path.insert(0, sys.argv[2])
195
+ missing = []
196
+ for s in want:
197
+ try:
198
+ with contextlib.redirect_stdout(io.StringIO()):
199
+ obj = importlib.import_module(s["module"])
200
+ for part in s["name"].split("."):
201
+ obj = getattr(obj, part)
202
+ except Exception:
203
+ missing.append(s)
204
+ print(json.dumps(missing))
205
+ PY
206
+ )
207
+ else
208
+ command -v node >/dev/null 2>&1 || die "node is required to resolve JS/TS symbols"
209
+ (cd "$root" && node --input-type=module - "$SCRATCH/want.json" "$root" <<'JS'
210
+ import { readFileSync } from "node:fs";
211
+ import path from "node:path";
212
+ import { pathToFileURL } from "node:url";
213
+ import { existsSync, statSync } from "node:fs";
214
+ import { createRequire } from "node:module";
215
+ const [want, root] = [JSON.parse(readFileSync(process.argv[2], "utf8")), process.argv[3]];
216
+ // Type-only names (interface/type alias, erased at runtime) resolve statically: the old module still exports the
217
+ // name by declaration, `export { ... }` list or `export ... from`, following `export * from` relative modules.
218
+ let ts = null;
219
+ for (const m of ["typescript", "ts-morph"]) {
220
+ try { const r = createRequire(path.join(root, "noop.js"))(m); ts = m === "typescript" ? r : r.ts; if (typeof ts?.createSourceFile === "function") break; ts = null; } catch { /* next */ }
221
+ }
222
+ const exportsOf = (file, seen = new Set()) => {
223
+ const names = new Set();
224
+ if (!ts || seen.has(file) || !existsSync(file) || !statSync(file).isFile()) return names;
225
+ seen.add(file);
226
+ const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true);
227
+ const exported = (n) => (ts.getCombinedModifierFlags(n) & ts.ModifierFlags.Export) !== 0;
228
+ const resolve = (spec) => {
229
+ const base = path.resolve(path.dirname(file), spec), stem = base.replace(/\.(m|c)?jsx?$/, "");
230
+ for (const c of [base, ...[".ts", ".tsx", ".mts", ".cts", ".d.ts"].flatMap((x) => [stem + x, path.join(base, "index" + x)])]) if (existsSync(c) && statSync(c).isFile()) return c;
231
+ return null;
232
+ };
233
+ for (const st of src.statements) {
234
+ if (ts.isExportDeclaration(st)) {
235
+ if (st.exportClause && ts.isNamedExports(st.exportClause)) for (const el of st.exportClause.elements) names.add(el.name.text);
236
+ else if (!st.exportClause && st.moduleSpecifier?.text?.startsWith(".")) { const f = resolve(st.moduleSpecifier.text); if (f) for (const n of exportsOf(f, seen)) names.add(n); }
237
+ } else if (ts.isVariableStatement(st) && exported(st)) {
238
+ for (const d of st.declarationList.declarations) if (ts.isIdentifier(d.name)) names.add(d.name.text);
239
+ } else if (st.name && ts.isIdentifier(st.name) && exported(st)) names.add(st.name.text);
240
+ }
241
+ return names;
242
+ };
243
+ const missing = [];
244
+ for (const s of want) {
245
+ if (s.type) {
246
+ if (!exportsOf(path.join(root, s.module)).has(s.name)) missing.push(s);
247
+ continue;
248
+ }
249
+ try {
250
+ let obj = await import(pathToFileURL(path.join(root, s.module)).href);
251
+ for (const part of s.name.split(".")) {
252
+ const next = obj?.[part] ?? obj?.prototype?.[part];
253
+ if (next === undefined) throw new Error("unresolved");
254
+ obj = next;
255
+ }
256
+ } catch { missing.push(s); }
257
+ }
258
+ process.stdout.write(JSON.stringify(missing) + "\n");
259
+ JS
260
+ )
261
+ fi
262
+ }
263
+
264
+ # logic_diff <root> <base> <head>: prints a JSON array of {file,line,text} for non-moved, non-import changed lines.
265
+ logic_diff() {
266
+ local diff="$SCRATCH/diff.txt"
267
+ git -C "$1" -c color.diff.old=red -c color.diff.new=green -c color.diff.oldMoved=magenta -c color.diff.newMoved=cyan \
268
+ -c color.diff.oldMovedAlternative=magenta -c color.diff.newMovedAlternative=cyan \
269
+ -c color.diff.whitespace=normal -c core.whitespace=-blank-at-eol,-blank-at-eof,-space-before-tab \
270
+ diff --color=always --color-moved=plain --no-renames --no-ext-diff "$2" "$3" -- > "$diff" \
271
+ || die "git diff failed for $2..$3"
272
+ python3 -I - "$diff" <<'PY'
273
+ import json, re, sys
274
+ ESC = re.compile(r"\x1b\[[0-9;]*m")
275
+ MOVED = ("\x1b[35m", "\x1b[36m")
276
+ PY_IMPORT = re.compile(r"^\s*(import\s+\S|from\s+\S+\s+import\b)")
277
+ JS_IMPORT = re.compile(r"^\s*(import\b|export\s+(type\s+)?(\*|\{).*\bfrom\b|export\s+\*)")
278
+ def opens_block(s):
279
+ if re.match(r"^\s*from\s+\S+\s+import\s*\(", s) and ")" not in s:
280
+ return ")"
281
+ if re.match(r"^\s*(import|export)\s+(type\s+)?\{", s) and "}" not in s:
282
+ return "}"
283
+ return None
284
+ fails, path, old, in_hunk = [], None, None, False
285
+ lineno = {"-": 0, "+": 0}
286
+ block = {"-": None, "+": None} # closing char of an open multi-line import, per side
287
+ for raw in open(sys.argv[1], encoding="utf-8", errors="replace").read().split("\n"):
288
+ plain = ESC.sub("", raw)
289
+ if plain.startswith("diff --git "):
290
+ in_hunk, path, old = False, None, None; block = {"-": None, "+": None}; continue
291
+ if not in_hunk and plain.startswith("--- "):
292
+ old = plain[4:]; old = old[2:] if old.startswith("a/") else old; continue
293
+ if not in_hunk and plain.startswith("+++ "):
294
+ new = plain[4:]; path = old if new == "/dev/null" else (new[2:] if new.startswith("b/") else new); continue
295
+ m = re.match(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", plain)
296
+ if m:
297
+ in_hunk = True; lineno = {"-": int(m.group(1)), "+": int(m.group(2))}; continue
298
+ if not in_hunk or not plain or plain.startswith("\\"):
299
+ continue
300
+ sign, body = plain[0], plain[1:]
301
+ sides = ("-", "+") if sign == " " else (sign,)
302
+ is_import = False
303
+ for side in sides:
304
+ if block[side]:
305
+ is_import = True
306
+ if block[side] in body:
307
+ block[side] = None
308
+ elif PY_IMPORT.match(body) or JS_IMPORT.match(body):
309
+ is_import = True
310
+ block[side] = opens_block(body)
311
+ if sign in "+-":
312
+ if not raw.startswith(MOVED) and body.strip() and not is_import:
313
+ fails.append({"kind": "logic-diff", "file": path, "line": lineno[sign], "text": body.strip()[:200]})
314
+ for side in sides:
315
+ lineno[side] += 1
316
+ print(json.dumps(fails))
317
+ PY
318
+ }
319
+
320
+ cmd_verify() {
321
+ local baseline="" base="" head="HEAD"
322
+ while [ $# -gt 0 ]; do
323
+ case "$1" in
324
+ --baseline) baseline="${2-}"; shift 2 || usage ;;
325
+ --base) base="${2-}"; shift 2 || usage ;;
326
+ --head) head="${2-}"; shift 2 || usage ;;
327
+ *) usage ;;
328
+ esac
329
+ done
330
+ [ -n "$baseline" ] && [ -n "$base" ] || usage
331
+ [ -f "$baseline" ] || die "baseline file not found: $baseline"
332
+ jq -e '.test and .symbols and .cycles and .root and .index_dir and .lang and .test_cmd' "$baseline" >/dev/null 2>&1 \
333
+ || die "not a refactor-verify baseline: $baseline"
334
+ local root index_dir lang
335
+ root="$(jq -r .root "$baseline")"; index_dir="$(jq -r .index_dir "$baseline")"; lang="$(jq -r .lang "$baseline")"
336
+ [ -d "$root" ] || die "baseline root not found: $root"
337
+ git -C "$root" rev-parse --verify --quiet "$base^{commit}" >/dev/null || die "unknown ref: $base"
338
+ git -C "$root" rev-parse --verify --quiet "$head^{commit}" >/dev/null || die "unknown ref: $head"
339
+
340
+ local diff_fails missing cycles_after
341
+ diff_fails="$(logic_diff "$root" "$base" "$head")" || exit 2
342
+ run_tests "$root" "$(jq -r .test_cmd "$baseline")"
343
+ missing="$(resolve_symbols "$baseline")" || die "symbol resolution failed"
344
+ if [ -d "$index_dir" ]; then
345
+ cycles_after="$(index "$index_dir" "$lang" | jq -c .cycles)" || exit 2
346
+ else
347
+ cycles_after='[]'
348
+ fi
349
+
350
+ jq -nS --slurpfile b "$baseline" --argjson diff "$diff_fails" --argjson missing "$missing" \
351
+ --argjson cycles "$cycles_after" --argjson exit "$T_EXIT" --argjson passed "$T_PASSED" --argjson skipped "$T_SKIPPED" '
352
+ ($b[0].test) as $before | {exit:$exit,passed:$passed,skipped:$skipped} as $after
353
+ | ( $diff
354
+ + [$missing[] | {kind:"missing-symbol",module,name}]
355
+ + (if $before.passed != $after.passed or $before.skipped != $after.skipped or ($before.exit == 0 and $after.exit != 0)
356
+ then [{kind:"count-changed",before:$before,after:$after}] else [] end)
357
+ + [($cycles - $b[0].cycles)[] | {kind:"new-cycle",cycle:.}] ) as $f
358
+ | {ok:($f|length == 0),failures:$f}' > "$SCRATCH/result.json" || die "cannot build result"
359
+ cat "$SCRATCH/result.json"
360
+ jq -e .ok "$SCRATCH/result.json" >/dev/null && return 0
361
+ return 1
362
+ }
363
+
364
+ case "${1-}" in
365
+ baseline) shift; cmd_baseline "$@" ;;
366
+ verify) shift; cmd_verify "$@" ;;
367
+ *) usage ;;
368
+ esac
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env bash
2
+ # refactor-verify.test.sh — cases for scripts/refactor-verify.sh (forge#4209).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ # Builds a throwaway git repo (Python package + unittest suite) and checks baseline/verify on a pure move plus shim
6
+ # and on each failure kind.
7
+ set -uo pipefail
8
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ RV="$HERE/refactor-verify.sh"
10
+ PASS=0; FAILN=0
11
+ TMP="$(mktemp -d "${TMPDIR:-/tmp}/refactor-verify-test.XXXXXX")"; trap 'rm -rf "$TMP"' EXIT
12
+ ok() { PASS=$((PASS+1)); }
13
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
14
+ eq() { if [ "$2" = "$3" ]; then ok; else bad "$1 (got: $2 want: $3)"; fi }
15
+ q() { printf '%s' "$OUT" | jq -c "$1"; }
16
+ run() { OUT="$("$@" 2>"$TMP/err")"; RC=$?; }
17
+ export PYTHONDONTWRITEBYTECODE=1
18
+
19
+ R="$TMP/repo"; B="$TMP/baseline.json"
20
+ TEST_CMD='python3 -m unittest discover -s tests -t .'
21
+ g() { git -C "$R" -c user.name=t -c user.email=t@example.invalid "$@"; }
22
+ commit() { g add -A && g commit -qm "$1"; }
23
+
24
+ # ---- fixture: pkg/big.py + 4 unittest tests (1 skipped) ----
25
+ mkdir -p "$R/pkg" "$R/tests"
26
+ git init -q "$R"
27
+ printf '__pycache__/\n' > "$R/.gitignore"
28
+ : > "$R/pkg/__init__.py"
29
+ : > "$R/tests/__init__.py"
30
+ cat > "$R/pkg/big.py" <<'EOF'
31
+ import os
32
+
33
+ LIMIT = 3
34
+
35
+
36
+ def _helper(x):
37
+ return x * 2
38
+
39
+
40
+ def double(x):
41
+ return _helper(x)
42
+
43
+
44
+ def env_name():
45
+ return os.name
46
+
47
+
48
+ class Counter:
49
+ def __init__(self):
50
+ self.n = 0
51
+
52
+ def bump(self):
53
+ self.n += 1
54
+ return self.n
55
+ EOF
56
+ cat > "$R/tests/test_big.py" <<'EOF'
57
+ import unittest
58
+
59
+ from pkg.big import LIMIT, Counter, double
60
+
61
+
62
+ class BigTest(unittest.TestCase):
63
+ def test_double(self):
64
+ self.assertEqual(double(2), 4)
65
+
66
+ def test_limit(self):
67
+ self.assertEqual(LIMIT, 3)
68
+
69
+ def test_counter(self):
70
+ c = Counter()
71
+ c.bump()
72
+ self.assertEqual(c.bump(), 2)
73
+
74
+ @unittest.skip("not yet")
75
+ def test_later(self):
76
+ pass
77
+ EOF
78
+ commit base; g tag base
79
+
80
+ # ---- 1. baseline: counts, public symbols, cycles ----
81
+ run bash "$RV" baseline --root "$R" --target pkg/big.py --test-cmd "$TEST_CMD" --out "$B"
82
+ eq "baseline rc" "$RC" 0
83
+ OUT="$(cat "$B" 2>/dev/null)"
84
+ eq "baseline keys" "$(q 'keys')" '["cycles","index_dir","lang","root","symbols","target","test","test_cmd"]'
85
+ eq "baseline counts" "$(q '.test')" '{"exit":0,"passed":3,"skipped":1}'
86
+ eq "baseline public symbols" "$(q '[.symbols[]|"\(.module):\(.name)"]')" \
87
+ '["pkg.big:Counter","pkg.big:Counter.__init__","pkg.big:Counter.bump","pkg.big:LIMIT","pkg.big:double","pkg.big:env_name"]'
88
+ eq "baseline excludes private" "$(q '[.symbols[]|select(.name=="_helper")]|length')" 0
89
+ eq "baseline cycles" "$(q '.cycles')" '[]'
90
+ eq "baseline lang" "$(q '.lang')" '"python"'
91
+
92
+ # pure move: symbols verbatim into pkg/big_parts/{core,counter}.py, pkg/big.py becomes an import-only shim.
93
+ pure_move() {
94
+ mkdir -p "$R/pkg/big_parts"
95
+ : > "$R/pkg/big_parts/__init__.py"
96
+ cat > "$R/pkg/big_parts/core.py" <<'EOF'
97
+ import os
98
+
99
+ LIMIT = 3
100
+
101
+
102
+ def _helper(x):
103
+ return x * 2
104
+
105
+
106
+ def double(x):
107
+ return _helper(x)
108
+
109
+
110
+ def env_name():
111
+ return os.name
112
+ EOF
113
+ cat > "$R/pkg/big_parts/counter.py" <<'EOF'
114
+ class Counter:
115
+ def __init__(self):
116
+ self.n = 0
117
+
118
+ def bump(self):
119
+ self.n += 1
120
+ return self.n
121
+ EOF
122
+ cat > "$R/pkg/big.py" <<'EOF'
123
+ from pkg.big_parts.core import (
124
+ LIMIT,
125
+ _helper,
126
+ double,
127
+ env_name,
128
+ )
129
+ from pkg.big_parts.counter import Counter
130
+ EOF
131
+ }
132
+ case_branch() { g checkout -q -B "$1" base; }
133
+ verify() { run bash "$RV" verify --baseline "$B" --base base; }
134
+
135
+ # ---- 2. pure move plus shim passes ----
136
+ case_branch pure; pure_move; commit pure
137
+ verify
138
+ eq "pure move rc" "$RC" 0
139
+ eq "pure move ok" "$(q '.ok')" true
140
+ eq "pure move no failures" "$(q '.failures')" '[]'
141
+
142
+ # ---- 3. logic diff in a moved body ----
143
+ case_branch logic; pure_move
144
+ sed -i.bak 's/return _helper(x)/return _helper(x) + 0/' "$R/pkg/big_parts/core.py"; rm -f "$R/pkg/big_parts/core.py.bak"
145
+ commit logic
146
+ verify
147
+ eq "logic diff rc" "$RC" 1
148
+ eq "logic diff kinds" "$(q '[.failures[].kind]|unique')" '["logic-diff"]'
149
+ eq "logic diff location" "$(q '[.failures[]|select(.file=="pkg/big_parts/core.py")|.text]')" '["return _helper(x) + 0"]'
150
+
151
+ # ---- 4. a public symbol no longer resolves from its old path ----
152
+ case_branch missing; pure_move
153
+ printf 'from pkg.big_parts.core import LIMIT, double\nfrom pkg.big_parts.counter import Counter\n' > "$R/pkg/big.py"
154
+ commit missing
155
+ verify
156
+ eq "missing symbol rc" "$RC" 1
157
+ eq "missing symbol" "$(q '[.failures[]|select(.kind=="missing-symbol")|"\(.module):\(.name)"]')" '["pkg.big:env_name"]'
158
+ eq "missing symbol: shim stays import-only" "$(q '[.failures[]|select(.kind=="logic-diff")]|length')" 0
159
+
160
+ # ---- 5. changed pass/skip count ----
161
+ case_branch counts; pure_move
162
+ sed -i.bak 's/def test_limit/@unittest.skip("x")\n def test_limit/' "$R/tests/test_big.py"; rm -f "$R/tests/test_big.py.bak"
163
+ commit counts
164
+ verify
165
+ eq "count change rc" "$RC" 1
166
+ eq "count change" "$(q '[.failures[]|select(.kind=="count-changed")|[.before.passed,.before.skipped,.after.passed,.after.skipped]]')" \
167
+ '[[3,1,2,2]]'
168
+
169
+ # ---- 6. new import cycle (import lines only, so no logic diff) ----
170
+ case_branch cycle; pure_move
171
+ printf 'import pkg.big_parts.counter\n' | cat - "$R/pkg/big_parts/core.py" > "$TMP/core.py" && mv "$TMP/core.py" "$R/pkg/big_parts/core.py"
172
+ printf 'import pkg.big_parts.core\n' | cat - "$R/pkg/big_parts/counter.py" > "$TMP/counter.py" && mv "$TMP/counter.py" "$R/pkg/big_parts/counter.py"
173
+ commit cycle
174
+ verify
175
+ eq "new cycle rc" "$RC" 1
176
+ eq "new cycle kinds" "$(q '[.failures[].kind]|unique')" '["new-cycle"]'
177
+ eq "new cycle" "$(q '[.failures[].cycle]')" '[["pkg.big_parts.core","pkg.big_parts.counter"]]'
178
+
179
+ # ---- 7. summary parsers (pytest, TAP, node spec, jest) and unparseable output ----
180
+ g checkout -q base
181
+ counts_of() { run bash "$RV" baseline --root "$R" --target pkg/big.py --test-cmd "$1" --out "$TMP/c.json"; jq -c .test "$TMP/c.json"; }
182
+ eq "pytest summary" "$(counts_of "printf '===== 3 passed, 2 skipped in 0.05s =====\n'")" '{"exit":0,"passed":3,"skipped":2}'
183
+ eq "pytest -q summary" "$(counts_of "printf '7 passed, 1 skipped, 2 warnings in 1.20s\n'")" '{"exit":0,"passed":7,"skipped":1}'
184
+ eq "tap summary" "$(counts_of "printf 'ok 1 - a\n# tests 6\n# pass 5\n# skipped 1\n# fail 0\n'")" '{"exit":0,"passed":5,"skipped":1}'
185
+ eq "node spec summary" "$(counts_of "printf 'ℹ tests 4\nℹ pass 4\nℹ skipped 0\n'")" '{"exit":0,"passed":4,"skipped":0}'
186
+ eq "jest summary" "$(counts_of "printf 'Tests: 1 skipped, 4 passed, 5 total\n'")" '{"exit":0,"passed":4,"skipped":1}'
187
+ run bash "$RV" baseline --root "$R" --target pkg/big.py --test-cmd "echo nothing to see" --out "$TMP/u.json"
188
+ eq "unparseable counts rc" "$RC" 2
189
+ eq "unparseable counts writes nothing" "$([ -e "$TMP/u.json" ] && echo written || echo none)" none
190
+
191
+ # ---- 8. usage ----
192
+ bash "$RV" >/dev/null 2>&1; eq "no args" "$?" 2
193
+ bash "$RV" frobnicate >/dev/null 2>&1; eq "unknown subcommand" "$?" 2
194
+ bash "$RV" verify --baseline "$TMP/none.json" --base base >/dev/null 2>&1; eq "missing baseline" "$?" 2
195
+ bash "$RV" verify --baseline "$B" --base no-such-ref >/dev/null 2>&1; eq "unknown ref" "$?" 2
196
+ bash "$RV" baseline --root "$R" --target missing.py --test-cmd true --out "$TMP/x.json" >/dev/null 2>&1; eq "missing target" "$?" 2
197
+
198
+ # ---- 9. read-only on the target repo ----
199
+ eq "worktree clean after runs" "$(git -C "$R" status --porcelain)" ""
200
+
201
+ echo "refactor-verify.test.sh: $PASS passed, $FAILN failed"
202
+ [ "$FAILN" -eq 0 ]
@@ -21,7 +21,8 @@
21
21
  # reported "SKIPPED — typescript.build not configured", and blocking it would stop every deploy of a repo
22
22
  # with plain .js files and no build command, ForgeDock itself included).
23
23
  # --no-delta skip the FULL/DELTA/BASE_SYNC_ONLY resolver: the review mode is always FULL (a staging bundle review always reviews the
24
- # whole bundle), without widening the roster to the full union the way --thorough does
24
+ # whole bundle), without widening the roster to the full union the way --thorough does; it also enables the pinned
25
+ # local-git diff fallback when `gh pr diff` fails (e.g. HTTP 406 over 20,000 lines), which a milestone->staging PR gets without it
25
26
  # Env: FORGE_CONFIG (forge.yaml path; default: the main checkout's forge.yaml, then the git top-level one),
26
27
  # REVIEW_PREPARE_SLEEP (seconds between mergeability retries, default 5),
27
28
  # REVIEW_PREPARE_INDEX_TIMEOUT (seconds the code-index build may take, default 120; on timeout or failure the slices say
@@ -126,15 +127,22 @@ case "$NFILES" in ''|*[!0-9]*) NFILES="" ;; esac
126
127
  LINES=$((ADDITIONS + DELETIONS))
127
128
  [ -z "$PR_INFO" ] || [ -n "$REVIEW_SHA" ] || block "could not read the PR head SHA"
128
129
 
130
+ # Lane (the router already diverted staging->main; these gate the diff fallback, the full-build check and the roster).
131
+ IS_STAGING_TO_MAIN="false"; [ "$HEAD" = "staging" ] && [ "$BASE" = "main" ] && IS_STAGING_TO_MAIN="true"
132
+ IS_MILESTONE_TO_STAGING="false"; case "$HEAD" in milestone/*) [ "$BASE" = "staging" ] && IS_MILESTONE_TO_STAGING="true" ;; esac
133
+ REQUIRES_FULL_BUILD="false"
134
+ { [ "$IS_STAGING_TO_MAIN" = "true" ] || [ "$IS_MILESTONE_TO_STAGING" = "true" ]; } && REQUIRES_FULL_BUILD="true"
135
+
129
136
  FILES=""; DIFF=""
130
137
  if [ -z "$BLOCKERS" ]; then
131
138
  FILES=$(gh pr diff "$PR_NUMBER" -R "$REPO" --name-only 2>/dev/null) || FILES="__FAILED__"
132
139
  [ "$FILES" = "__FAILED__" ] || DIFF=$(gh pr diff "$PR_NUMBER" -R "$REPO" 2>/dev/null) || FILES="__FAILED__"
133
140
  if [ "$FILES" = "__FAILED__" ]; then
134
- # A staging bundle can exceed the API's diff size limit: read it from the long-lived branches instead (--no-delta only; a per-PR review blocks).
141
+ # A staging bundle or a milestone->staging PR can exceed the API's diff size limit: read it from the long-lived branches instead
142
+ # (--no-delta or the milestone lane only, both always FULL; any other per-PR review blocks — forge#4154).
135
143
  FILES=""; DIFF=""
136
144
  # Pinned (forge#3935): same-repo PRs only (a fork's branch name resolves to origin's own branch), and the fetched tip must equal REVIEW_SHA; the diff is taken against REVIEW_SHA, never the branch name.
137
- if [ "$NO_DELTA" = "true" ] && [ -n "$BASE" ] && [ -n "$HEAD" ] && [ "$(printf '%s' "$PR_INFO" | jq -r .isCrossRepository 2>/dev/null)" = "false" ] && printf '%s' "$REVIEW_SHA" | grep -qE '^[0-9a-f]{40}$' \
145
+ if { [ "$NO_DELTA" = "true" ] || [ "$IS_MILESTONE_TO_STAGING" = "true" ]; } && [ -n "$BASE" ] && [ -n "$HEAD" ] && [ "$(printf '%s' "$PR_INFO" | jq -r .isCrossRepository 2>/dev/null)" = "false" ] && printf '%s' "$REVIEW_SHA" | grep -qE '^[0-9a-f]{40}$' \
138
146
  && git fetch -q origin "$BASE" "$HEAD" 2>/dev/null && [ "$(git rev-parse -q --verify "refs/remotes/origin/${HEAD}^{commit}" 2>/dev/null)" = "$REVIEW_SHA" ]; then
139
147
  FILES=$(git diff --name-only "origin/${BASE}...${REVIEW_SHA}" 2>/dev/null) && DIFF=$(git diff "origin/${BASE}...${REVIEW_SHA}" 2>/dev/null) || { FILES=""; DIFF=""; }
140
148
  fi
@@ -161,12 +169,6 @@ if [ -n "$PR_AUTHOR" ]; then
161
169
  [ "$PRIOR_MERGED_COUNT" -eq 0 ] && IS_FIRST_TIME_CONTRIBUTOR="true"
162
170
  fi
163
171
 
164
- # Lane (the router already diverted staging->main; these still gate the full-build check and the roster).
165
- IS_STAGING_TO_MAIN="false"; [ "$HEAD" = "staging" ] && [ "$BASE" = "main" ] && IS_STAGING_TO_MAIN="true"
166
- IS_MILESTONE_TO_STAGING="false"; case "$HEAD" in milestone/*) [ "$BASE" = "staging" ] && IS_MILESTONE_TO_STAGING="true" ;; esac
167
- REQUIRES_FULL_BUILD="false"
168
- { [ "$IS_STAGING_TO_MAIN" = "true" ] || [ "$IS_MILESTONE_TO_STAGING" = "true" ]; } && REQUIRES_FULL_BUILD="true"
169
-
170
172
  # --- optional PR checkout (throwaway review checkout only) ---
171
173
  if [ "$CHECKOUT_PR" = "true" ] && [ -z "$BLOCKERS" ]; then
172
174
  ORIG_REF=$(git symbolic-ref -q --short HEAD 2>/dev/null || git rev-parse HEAD 2>/dev/null || echo "")
@@ -287,6 +287,20 @@ CROSS_REPO=true setup pr-branch main "README.md" "$DIFF_PLAIN"; : > "$FIX/difffa
287
287
  prep "$ROOT" --no-delta; rc_is "--no-delta fallback refuses a cross-repository PR" 3
288
288
  setup feat/x staging "README.md" "$DIFF_PLAIN"; : > "$FIX/difffail"
289
289
  prep "$ROOT" --no-delta; rc_is "--no-delta with unfetchable branches still fails closed" 3; eq "unfetchable blocked" .status BLOCKED
290
+ # forge#4154: a milestone->staging PR (always FULL) gets the same pinned fallback without --no-delta; other per-PR lanes still block
291
+ git -C "$REPO_DIR" branch -f milestone/m1 "$SHA_B"; git -C "$REPO_DIR" branch -f staging "$SHA_A"
292
+ setup milestone/m1 staging "README.md" "$DIFF_PLAIN"; : > "$FIX/difffail"
293
+ prep "$ROOT"; rc_is "milestone->staging falls back to git when the API diff fails" 0; eq "milestone fallback status" .status OK
294
+ eq "milestone fallback lane" .lane.is_milestone_to_staging true; eq "milestone fallback mode is FULL" .delta.mode FULL
295
+ eq "milestone fallback file list comes from git" '.full_files | index("services/api/auth.py") != null' true
296
+ setup milestone/m1 staging "README.md" "$DIFF_PLAIN" "0000000000000000000000000000000000000000"; : > "$FIX/difffail"
297
+ prep "$ROOT"; rc_is "milestone fallback refuses a branch tip that is not the review SHA" 3
298
+ CROSS_REPO=true setup milestone/m1 staging "README.md" "$DIFF_PLAIN"; : > "$FIX/difffail"
299
+ prep "$ROOT"; rc_is "milestone fallback refuses a cross-repository PR" 3
300
+ git -C "$REPO_DIR" branch -f feat/x "$SHA_B"
301
+ setup feat/x staging "README.md" "$DIFF_PLAIN"; : > "$FIX/difffail"
302
+ prep "$ROOT"; rc_is "a fetchable feat->staging per-PR review still blocks without --no-delta" 3; eq "feat blocked" '.blockers | join(" ") | test("gh pr diff failed")' true
303
+ git -C "$REPO_DIR" branch -D milestone/m1 staging feat/x >/dev/null
290
304
  setup feat/x staging "README.md" "$DIFF_PLAIN"
291
305
  for bad_args in "--pr abc --repo o/r" "--pr 7 --repo noslash" "--pr 7 --repo o/r;x/y" "--pr 7 --repo o/r --bogus" "--repo o/r --pr"; do
292
306
  # shellcheck disable=SC2086