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.
- package/README.md +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +13 -0
- package/bin/engine/phases.mjs +38 -2
- package/bin/engine/telemetry-seam.mjs +41 -6
- package/bin/engine/telemetry-seam.test.mjs +35 -0
- package/bin/engine-cli.mjs +616 -54
- package/bin/engine.mjs +13 -5
- package/bin/forgedock.mjs +169 -39
- package/bin/gc.mjs +580 -0
- package/bin/init-detect.mjs +68 -3
- package/bin/init-enrich.mjs +3 -0
- package/bin/init-repo.mjs +213 -0
- package/bin/journey.mjs +278 -43
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +24 -5
- package/bin/lean/batch.test.mjs +54 -4
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +235 -37
- package/bin/lean/conformance.test.mjs +143 -5
- package/bin/lean/credentials.mjs +29 -0
- package/bin/lean/depcache.mjs +312 -0
- package/bin/lean/depcache.test.mjs +298 -0
- package/bin/lean/drift.mjs +198 -0
- package/bin/lean/drift.test.mjs +223 -0
- package/bin/lean/effects.mjs +178 -16
- package/bin/lean/execution.e2e.test.mjs +81 -0
- package/bin/lean/execution.mjs +431 -0
- package/bin/lean/execution.test.mjs +583 -0
- package/bin/lean/heavy.mjs +289 -0
- package/bin/lean/heavy.test.mjs +207 -0
- package/bin/lean/lean.mjs +877 -151
- package/bin/lean/lean.test.mjs +1798 -28
- package/bin/lean/ledger.mjs +92 -0
- package/bin/lean/ledger.test.mjs +100 -0
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +321 -19
- package/bin/lean/prompts.mjs +125 -17
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +25 -6
- package/bin/lean/review-trigger.mjs +134 -0
- package/bin/lean/review-trigger.test.mjs +104 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
- package/bin/lean/sandbox-remote.mjs +142 -0
- package/bin/lean/sched.mjs +221 -0
- package/bin/lean/sched.test.mjs +310 -0
- package/bin/lean/schedd.mjs +247 -0
- package/bin/lean/schedd.test.mjs +180 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +124 -0
- package/bin/lean/verify.test.mjs +118 -0
- package/bin/lean/worktree.mjs +69 -0
- package/bin/lean/worktree.test.mjs +141 -0
- package/bin/runner.mjs +53 -10
- package/bin/stats/github-source.mjs +1 -1
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/query.mjs +72 -0
- package/bin/stats/stats.test.mjs +54 -0
- package/commands/adopt.md +3 -0
- package/commands/audit.md +5 -8
- package/commands/autopilot.md +2 -0
- package/commands/bootstrap.md +35 -0
- package/commands/cleanup.md +7 -22
- package/commands/forge-stats.md +2 -1
- package/commands/forgedock-init.md +125 -6
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +10 -2
- package/commands/review-pr.md +2 -2
- package/commands/upgrade-deps.md +3 -3
- package/commands/work-on/build/validate.md +4 -0
- package/commands/work-on/close.md +2 -0
- package/commands/work-on/decompose.md +24 -6
- package/docs/CI.md +3 -3
- package/docs/CONFIG.md +227 -3
- package/docs/OUTCOME-CODES.md +11 -0
- package/docs/TELEMETRY.md +1 -1
- package/docs/site/command-reference.md +22 -0
- package/docs/site/getting-started.md +2 -0
- package/docs/site/start-a-new-project.md +327 -0
- package/package.json +1 -1
- package/scripts/affected-files-check.sh +91 -0
- package/scripts/affected-files-check.test.sh +80 -0
- package/scripts/bootstrap-create.mjs +393 -0
- package/scripts/bootstrap-validate.mjs +321 -0
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +125 -0
- package/scripts/check-devdocs-freshness.test.sh +130 -0
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/check-spec-size.sh +29 -11
- package/scripts/check-spec-size.test.sh +27 -4
- package/scripts/ci-local/lib.mjs +8 -1
- package/scripts/ci-local/parallelism.test.mjs +27 -0
- package/scripts/ci-local/run.mjs +16 -5
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +14 -2
- package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
- package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
- package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
- package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
- package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
- package/scripts/fixtures/symbol-index/py/sample.py +41 -0
- package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
- package/scripts/fixtures/symbol-move/gen_big.py +201 -0
- package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/hotspots.sh +100 -0
- package/scripts/hotspots.test.sh +101 -0
- package/scripts/issue-create.sh +197 -0
- package/scripts/issue-create.test.sh +166 -0
- package/scripts/maintenance-scan.sh +185 -0
- package/scripts/maintenance-scan.test.sh +89 -0
- package/scripts/milestone-drift.sh +206 -0
- package/scripts/milestone-drift.test.sh +120 -0
- package/scripts/northstar-docs.test.sh +68 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/review-prepare.sh +11 -9
- package/scripts/review-prepare.test.sh +14 -0
- package/scripts/structure-delta.sh +155 -0
- package/scripts/structure-delta.test.sh +136 -0
- package/scripts/symbol-index.mjs +253 -0
- package/scripts/symbol-index.py +354 -0
- package/scripts/symbol-index.test.sh +119 -0
- package/scripts/symbol-move-ts.test.sh +161 -0
- package/scripts/symbol-move.mjs +379 -0
- package/scripts/symbol-move.py +383 -0
- package/scripts/symbol-move.test.sh +168 -0
- package/templates/bootstrap/interview.md +34 -0
- package/templates/bootstrap/issue-body.md +42 -0
- package/templates/bootstrap/needs-human-issue.md +45 -0
- package/templates/bootstrap/stack-profiles.md +34 -0
- 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
|
|
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
|