forgedock 1.27.0 → 1.29.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 (159) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +39 -0
  4. package/bin/engine/telemetry-seam.mjs +37 -8
  5. package/bin/engine/telemetry-seam.test.mjs +31 -0
  6. package/bin/engine-cli.mjs +601 -42
  7. package/bin/engine.mjs +8 -4
  8. package/bin/forgedock.mjs +75 -9
  9. package/bin/gc.mjs +580 -0
  10. package/bin/lean/admission.mjs +33 -3
  11. package/bin/lean/batch.mjs +22 -4
  12. package/bin/lean/batch.test.mjs +5 -3
  13. package/bin/lean/checks.mjs +199 -0
  14. package/bin/lean/checks.test.mjs +183 -0
  15. package/bin/lean/conformance.mjs +234 -57
  16. package/bin/lean/conformance.test.mjs +168 -7
  17. package/bin/lean/context.mjs +333 -0
  18. package/bin/lean/context.test.mjs +279 -0
  19. package/bin/lean/credentials.mjs +29 -0
  20. package/bin/lean/depcache.mjs +312 -0
  21. package/bin/lean/depcache.test.mjs +298 -0
  22. package/bin/lean/drift.mjs +198 -0
  23. package/bin/lean/drift.test.mjs +223 -0
  24. package/bin/lean/effects.mjs +104 -9
  25. package/bin/lean/execution.e2e.test.mjs +81 -0
  26. package/bin/lean/execution.mjs +431 -0
  27. package/bin/lean/execution.test.mjs +583 -0
  28. package/bin/lean/heavy.mjs +289 -0
  29. package/bin/lean/heavy.test.mjs +207 -0
  30. package/bin/lean/lean.mjs +723 -154
  31. package/bin/lean/lean.test.mjs +1603 -35
  32. package/bin/lean/ledger.mjs +27 -2
  33. package/bin/lean/ledger.test.mjs +36 -1
  34. package/bin/lean/limits.mjs +58 -0
  35. package/bin/lean/limits.test.mjs +150 -0
  36. package/bin/lean/orchestrate.mjs +389 -19
  37. package/bin/lean/prefilter.mjs +2 -2
  38. package/bin/lean/prefilter.test.mjs +3 -0
  39. package/bin/lean/prompts.mjs +106 -17
  40. package/bin/lean/recovery.test.mjs +230 -0
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +49 -10
  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/testmap.mjs +84 -0
  53. package/bin/lean/testmap.test.mjs +58 -0
  54. package/bin/lean/toolchain.mjs +228 -0
  55. package/bin/lean/toolchain.test.mjs +154 -0
  56. package/bin/lean/verify.mjs +66 -10
  57. package/bin/lean/verify.test.mjs +104 -3
  58. package/bin/lean/worktree.mjs +69 -0
  59. package/bin/lean/worktree.test.mjs +141 -0
  60. package/bin/runner.mjs +53 -10
  61. package/bin/stats/github-source.mjs +1 -1
  62. package/bin/stats/index.mjs +24 -2
  63. package/bin/stats/local-source.mjs +13 -0
  64. package/bin/stats/query.mjs +99 -0
  65. package/bin/stats/stats.test.mjs +94 -1
  66. package/commands/audit.md +2 -0
  67. package/commands/autopilot.md +2 -0
  68. package/commands/bootstrap.md +35 -0
  69. package/commands/cleanup.md +7 -22
  70. package/commands/forge-stats.md +2 -1
  71. package/commands/forgedock-init.md +7 -0
  72. package/commands/issue.md +89 -830
  73. package/commands/milestone.md +55 -6
  74. package/commands/orchestrate.md +62 -8
  75. package/commands/upgrade-deps.md +3 -3
  76. package/commands/work-on/build/validate.md +4 -0
  77. package/commands/work-on/close.md +2 -0
  78. package/commands/work-on/decompose.md +24 -6
  79. package/docs/CI.md +3 -3
  80. package/docs/CONFIG.md +192 -2
  81. package/docs/OUTCOME-CODES.md +22 -0
  82. package/docs/RELEASE-NOTES.md +9 -0
  83. package/docs/TELEMETRY.md +1 -1
  84. package/docs/site/command-reference.md +22 -0
  85. package/docs/site/getting-started.md +2 -0
  86. package/docs/site/start-a-new-project.md +327 -0
  87. package/package.json +1 -1
  88. package/scripts/affected-files-check.sh +91 -0
  89. package/scripts/affected-files-check.test.sh +80 -0
  90. package/scripts/bootstrap-create.mjs +393 -0
  91. package/scripts/bootstrap-validate.mjs +36 -2
  92. package/scripts/calibration.mjs +116 -0
  93. package/scripts/check-devdocs-freshness.sh +9 -4
  94. package/scripts/check-devdocs-freshness.test.sh +11 -1
  95. package/scripts/check-spec-markers.sh +4 -0
  96. package/scripts/check-spec-size.sh +29 -11
  97. package/scripts/check-spec-size.test.sh +27 -4
  98. package/scripts/ci-local/lib.mjs +8 -1
  99. package/scripts/ci-local/parallelism.test.mjs +27 -0
  100. package/scripts/ci-local/run.mjs +3 -3
  101. package/scripts/ci-local/scoping.mjs +82 -0
  102. package/scripts/ci-local.test.sh +1 -1
  103. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  106. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  107. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  108. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  109. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  110. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  111. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  112. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
  113. package/scripts/fixtures/test-map/go/Makefile +5 -0
  114. package/scripts/fixtures/test-map/go/README.md +1 -0
  115. package/scripts/fixtures/test-map/go/go.mod +3 -0
  116. package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
  117. package/scripts/fixtures/test-map/go/main.go +3 -0
  118. package/scripts/fixtures/test-map/node/package.json +10 -0
  119. package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
  120. package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
  121. package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
  122. package/scripts/fixtures/test-map/node/src/a.ts +1 -0
  123. package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
  124. package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
  125. package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
  126. package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
  127. package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
  128. package/scripts/fixtures/test-map/python/uv.lock +1 -0
  129. package/scripts/forge-root.test.sh +1 -1
  130. package/scripts/hotspots.sh +100 -0
  131. package/scripts/hotspots.test.sh +101 -0
  132. package/scripts/issue-create.sh +197 -0
  133. package/scripts/issue-create.test.sh +166 -0
  134. package/scripts/maintenance-scan.sh +185 -0
  135. package/scripts/maintenance-scan.test.sh +89 -0
  136. package/scripts/milestone-drift.sh +206 -0
  137. package/scripts/milestone-drift.test.sh +120 -0
  138. package/scripts/refactor-verify.sh +368 -0
  139. package/scripts/refactor-verify.test.sh +202 -0
  140. package/scripts/repo-facts.sh +146 -0
  141. package/scripts/repo-facts.test.sh +106 -0
  142. package/scripts/structure-delta.sh +10 -5
  143. package/scripts/structure-delta.test.sh +3 -2
  144. package/scripts/symbol-index.mjs +253 -0
  145. package/scripts/symbol-index.py +354 -0
  146. package/scripts/symbol-index.test.sh +119 -0
  147. package/scripts/symbol-move-ts.test.sh +161 -0
  148. package/scripts/symbol-move.mjs +379 -0
  149. package/scripts/symbol-move.py +383 -0
  150. package/scripts/symbol-move.test.sh +168 -0
  151. package/scripts/test-map.sh +211 -0
  152. package/scripts/test-map.test.sh +165 -0
  153. package/scripts/wait-ci-green.sh +10 -4
  154. package/scripts/wait-ci-green.test.sh +9 -0
  155. package/templates/bootstrap/interview.md +34 -0
  156. package/templates/bootstrap/issue-body.md +42 -0
  157. package/templates/bootstrap/needs-human-issue.md +45 -0
  158. package/templates/bootstrap/stack-profiles.md +34 -0
  159. package/templates/bootstrap/technical-spike.md +75 -0
@@ -0,0 +1,383 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # symbol-move.py: codemod mover for refactor:split (forge#4210, #4196). Moves groups of top-level symbols out of one
6
+ # Python module into a sibling package and leaves a re-export shim at the old path. A CST tool (libcst) copies every
7
+ # moved statement byte-for-byte; no LLM, no network, nothing is retyped.
8
+ #
9
+ # Usage: python3 scripts/symbol-move.py --src <file.py> --plan <plan.json> [--package <name>] [--extend] [--dry-run]
10
+ # plan: {"<module>": ["<top-level symbol>", ...], ...}, names as scripts/symbol-index.py reports them.
11
+ # --package defaults to <stem>_parts, created next to --src. --extend (a chain step of a large split, forge#4211) adds
12
+ # the plan's modules to an existing package dir instead: its __init__.py is left untouched (created when absent) and a
13
+ # module file that already exists in it refuses. Without --extend an existing package path refuses.
14
+ # Output (stdout, one JSON object):
15
+ # ok: {"status":"ok","package":"<dotted package>","modules":{module:[symbols]},"shim":"<src path>"}
16
+ # refused: {"status":"refused","reason":"..."}
17
+ # Result: <package>/__init__.py (empty) and <package>/<module>.py per plan entry, each holding, in order, the
18
+ # source's `from __future__` imports, the source's module-level import statements its symbols use (verbatim;
19
+ # relative imports gain one level), `from .<other> import ...` for symbols moved to sibling modules, then the
20
+ # moved statements verbatim (leading comments included) in source order. --src keeps every unmoved statement
21
+ # verbatim and gains `from <dotted package>.<module> import (...)` re-exports for every moved name, private ones
22
+ # included, at the position of the first moved statement. Every changed line is a moved block or an import line,
23
+ # so scripts/refactor-verify.sh can prove the move.
24
+ # Refuses (no file touched) when libcst is not importable, the source does not parse, or the plan is invalid:
25
+ # unknown or non-top-level symbol, a symbol listed twice, a statement split across modules, a bad or colliding
26
+ # module name, an existing package path, a moved symbol that needs a name staying in --src, a name bound by both
27
+ # moved and kept statements, module-identity globals (__name__, globals(), ...) in moved code, a star import,
28
+ # or an import cycle between the new modules.
29
+ # Writes are atomic: every file is built in memory, written to a temp file and os.replace'd in, the shim last.
30
+ # Exit: 0 ok | 2 usage or missing file | 3 refused.
31
+
32
+ import json
33
+ import keyword
34
+ import os
35
+ import shutil
36
+ import sys
37
+ import tempfile
38
+
39
+ MODULE_GLOBALS = {"__name__", "__file__", "__spec__", "__package__", "__loader__", "__path__", "__builtins__",
40
+ "__cached__", "globals", "vars"}
41
+
42
+
43
+ class Refused(Exception):
44
+ pass
45
+
46
+
47
+ def emit(obj):
48
+ sys.stdout.write(json.dumps(obj, sort_keys=True, separators=(",", ":")) + "\n")
49
+
50
+
51
+ def usage():
52
+ sys.stderr.write("usage: symbol-move.py --src <file.py> --plan <plan.json> [--package <name>] [--extend] [--dry-run]\n")
53
+ return 2
54
+
55
+
56
+ def parse_args(argv):
57
+ opts, i = {"package": None, "dry_run": False, "extend": False}, 0
58
+ while i < len(argv):
59
+ a = argv[i]
60
+ if a == "--dry-run":
61
+ opts["dry_run"] = True
62
+ i += 1
63
+ elif a == "--extend":
64
+ opts["extend"] = True
65
+ i += 1
66
+ elif a in ("--src", "--plan", "--package") and i + 1 < len(argv):
67
+ opts[a[2:]] = argv[i + 1]
68
+ i += 2
69
+ else:
70
+ return None
71
+ return opts if opts.get("src") and opts.get("plan") else None
72
+
73
+
74
+ def dotted_package(directory):
75
+ """Dotted import path of a directory, walking up while __init__.py exists ('' for a plain directory)."""
76
+ parts, d = [], os.path.abspath(directory)
77
+ while os.path.isfile(os.path.join(d, "__init__.py")):
78
+ parts.append(os.path.basename(d))
79
+ parent = os.path.dirname(d)
80
+ if parent == d:
81
+ break
82
+ d = parent
83
+ return ".".join(reversed(parts))
84
+
85
+
86
+ def load_plan(path):
87
+ try:
88
+ with open(path, encoding="utf-8") as fh:
89
+ plan = json.load(fh)
90
+ except (OSError, ValueError) as e:
91
+ raise Refused("plan is not readable JSON: %s" % e)
92
+ if not isinstance(plan, dict) or not plan:
93
+ raise Refused("plan must be a non-empty object {module: [symbols]}")
94
+ for mod, names in plan.items():
95
+ if not isinstance(names, list) or not names or not all(isinstance(n, str) for n in names):
96
+ raise Refused("plan entry %r must be a non-empty list of symbol names" % mod)
97
+ if not mod.isidentifier() or keyword.iskeyword(mod) or mod == "__init__":
98
+ raise Refused("module name %r is not a valid identifier" % mod)
99
+ return plan
100
+
101
+
102
+ def build(cst, src, plan, package, extend=False):
103
+ from libcst.metadata import GlobalScope, MetadataWrapper, ScopeProvider
104
+
105
+ with open(src, "rb") as fh:
106
+ raw = fh.read()
107
+ try:
108
+ module = cst.parse_module(raw)
109
+ except Exception as e: # libcst.ParserSyntaxError and decode errors
110
+ raise Refused("source does not parse: %s" % str(e).splitlines()[0])
111
+ body = list(module.body)
112
+
113
+ def is_import_line(stmt):
114
+ return isinstance(stmt, cst.SimpleStatementLine) and all(
115
+ isinstance(s, (cst.Import, cst.ImportFrom)) for s in stmt.body)
116
+
117
+ def is_future(stmt):
118
+ return is_import_line(stmt) and all(
119
+ isinstance(s, cst.ImportFrom) and isinstance(s.module, cst.Name) and s.module.value == "__future__"
120
+ for s in stmt.body)
121
+
122
+ def target_names(t):
123
+ if isinstance(t, cst.Name):
124
+ return [t.value]
125
+ if isinstance(t, (cst.Tuple, cst.List)) and all(isinstance(e.value, cst.Name) for e in t.elements):
126
+ return [e.value.value for e in t.elements]
127
+ return None
128
+
129
+ # movable top-level statements: def, class, or a line of plain assignments; index -> names it binds
130
+ movable = {}
131
+ for idx, stmt in enumerate(body):
132
+ if isinstance(stmt, (cst.FunctionDef, cst.ClassDef)):
133
+ movable[idx] = [stmt.name.value]
134
+ elif isinstance(stmt, cst.SimpleStatementLine) and all(
135
+ isinstance(s, (cst.Assign, cst.AnnAssign)) for s in stmt.body):
136
+ names, ok = [], True
137
+ for s in stmt.body:
138
+ targets = [t.target for t in s.targets] if isinstance(s, cst.Assign) else [s.target]
139
+ for t in targets:
140
+ got = target_names(t)
141
+ if got is None:
142
+ ok = False
143
+ else:
144
+ names.extend(got)
145
+ if ok:
146
+ movable[idx] = names
147
+ if is_import_line(stmt) and any(isinstance(s, cst.ImportFrom) and isinstance(s.names, cst.ImportStar)
148
+ for s in stmt.body):
149
+ raise Refused("source has a star import at line %d; names cannot be resolved" % (idx + 1))
150
+
151
+ symbol_stmt = {}
152
+ for idx, names in movable.items():
153
+ for n in names:
154
+ symbol_stmt.setdefault(n, idx)
155
+
156
+ # plan -> statement index -> module
157
+ assigned, stmt_module = {}, {}
158
+ for mod, names in plan.items():
159
+ for n in names:
160
+ if n in assigned:
161
+ raise Refused("symbol %r is listed twice (%s, %s)" % (n, assigned[n], mod))
162
+ assigned[n] = mod
163
+ if "." in n:
164
+ raise Refused("%r is not a top-level symbol; only top-level symbols move" % n)
165
+ if n not in symbol_stmt:
166
+ raise Refused("unknown symbol %r: not a top-level def, class or assignment in %s" % (n, src))
167
+ idx = symbol_stmt[n]
168
+ if stmt_module.setdefault(idx, mod) != mod:
169
+ raise Refused("symbol %r shares a statement with symbols planned for another module" % n)
170
+ for idx, mod in stmt_module.items():
171
+ missing = [n for n in movable[idx] if assigned.get(n) != mod]
172
+ if missing:
173
+ raise Refused("statement binding %s also binds %s, which the plan does not move with it"
174
+ % (", ".join(sorted(set(movable[idx]) - set(missing))), ", ".join(missing)))
175
+
176
+ # node -> owning top-level statement
177
+ owner = {}
178
+ for idx, stmt in enumerate(body):
179
+ stack = [stmt]
180
+ while stack:
181
+ node = stack.pop()
182
+ owner[id(node)] = idx
183
+ stack.extend(node.children)
184
+
185
+ wrapper = MetadataWrapper(module, unsafe_skip_copy=True)
186
+ scopes = wrapper.resolve(ScopeProvider)
187
+ gscope = next((s for s in scopes.values() if isinstance(s, GlobalScope)), None)
188
+ binders, users = {}, {}
189
+ if gscope is not None:
190
+ for a in gscope.assignments:
191
+ b = owner.get(id(a.node))
192
+ if b is None:
193
+ continue
194
+ binders.setdefault(a.name, set()).add(b)
195
+ for r in a.references:
196
+ u = owner.get(id(r.node))
197
+ if u is not None:
198
+ users.setdefault(a.name, set()).add(u)
199
+
200
+ top_names = set(binders) | set(symbol_stmt)
201
+ for mod in plan:
202
+ if mod in top_names:
203
+ raise Refused("module name %r collides with a top-level name in %s" % (mod, src))
204
+
205
+ for name, bs in sorted(binders.items()):
206
+ mods = {stmt_module.get(b) for b in bs}
207
+ if len(mods) > 1:
208
+ where = sorted(m or "<kept>" for m in mods)
209
+ raise Refused("name %r is bound by statements going to different places (%s)" % (name, ", ".join(where)))
210
+
211
+ # identity-dependent globals in moved code would change meaning in a new module
212
+ for idx in sorted(stmt_module):
213
+ stack = [(body[idx], None)]
214
+ while stack:
215
+ node, parent = stack.pop()
216
+ if isinstance(node, cst.Name) and node.value in MODULE_GLOBALS and not (
217
+ isinstance(parent, cst.Attribute) and parent.attr is node):
218
+ raise Refused("moved symbol %s uses module-identity global %r" % (movable[idx][0], node.value))
219
+ stack.extend((c, node) for c in node.children)
220
+
221
+ need_imports = {m: set() for m in plan} # module -> import statement indexes
222
+ need_from = {m: {} for m in plan} # module -> {other module: set(names)}
223
+ for name in sorted(users):
224
+ bs = binders.get(name, set())
225
+ for u in sorted(users[name]):
226
+ mod = stmt_module.get(u)
227
+ if mod is None or not bs:
228
+ continue
229
+ kept = [b for b in bs if b not in stmt_module and not is_import_line(body[b])]
230
+ if kept:
231
+ raise Refused("moved symbol %s (-> %s) needs %r, which stays in %s (line of statement %d)"
232
+ % (movable[u][0], mod, name, src, min(kept) + 1))
233
+ other = stmt_module.get(next(iter(bs)))
234
+ if other is None:
235
+ need_imports[mod].update(bs)
236
+ elif other != mod:
237
+ need_from[mod].setdefault(other, set()).add(name)
238
+
239
+ # import cycles between the new modules
240
+ state = {}
241
+
242
+ def visit(m, path):
243
+ state[m] = 1
244
+ for o in sorted(need_from[m]):
245
+ if state.get(o) == 1:
246
+ raise Refused("plan creates an import cycle: %s" % " -> ".join(path[path.index(o):] + [o]))
247
+ if o not in state:
248
+ visit(o, path + [o])
249
+ state[m] = 2
250
+ for m in sorted(plan):
251
+ if m not in state:
252
+ visit(m, [m])
253
+
254
+ src_dir = os.path.dirname(os.path.abspath(src))
255
+ pkg_dir = os.path.join(src_dir, package)
256
+ if os.path.exists(pkg_dir + ".py") or (os.path.exists(pkg_dir) and not (extend and os.path.isdir(pkg_dir))):
257
+ raise Refused("package path already exists: %s" % pkg_dir)
258
+ if extend:
259
+ taken = [m for m in plan if os.path.exists(os.path.join(pkg_dir, m + ".py"))]
260
+ if taken:
261
+ raise Refused("module already exists in package %s: %s" % (pkg_dir, ", ".join(sorted(taken))))
262
+ base = dotted_package(src_dir)
263
+ dotted = (base + "." if base else "") + package
264
+
265
+ def code(stmt, strip_leading=False):
266
+ if strip_leading:
267
+ stmt = stmt.with_changes(leading_lines=())
268
+ return module.code_for_node(stmt)
269
+
270
+ def import_code(idx):
271
+ stmt = body[idx]
272
+ stmt = stmt.with_changes(body=[
273
+ s.with_changes(relative=[cst.Dot()] + list(s.relative))
274
+ if isinstance(s, cst.ImportFrom) and s.relative else s for s in stmt.body])
275
+ return code(stmt, strip_leading=True)
276
+
277
+ newline = module.default_newline
278
+
279
+ def from_import(target, names):
280
+ return "from %s import (%s%s)%s" % (target, newline, "".join(" %s,%s" % (n, newline) for n in names), newline)
281
+
282
+ futures = [i for i, s in enumerate(body) if is_future(s)]
283
+ order = sorted(plan, key=lambda m: min(i for i, mm in stmt_module.items() if mm == m))
284
+ files, modules_out = {}, {}
285
+ for mod in order:
286
+ moved = sorted(i for i, mm in stmt_module.items() if mm == mod)
287
+ head = [code(body[i], strip_leading=True) for i in futures]
288
+ head += [import_code(i) for i in sorted(need_imports[mod]) if i not in futures]
289
+ for other in sorted(need_from[mod], key=order.index):
290
+ names = sorted(need_from[mod][other], key=lambda n: (symbol_stmt[n], n))
291
+ head.append(from_import("." + other, names))
292
+ files[os.path.join(pkg_dir, mod + ".py")] = "".join(head) + "".join(code(body[i]) for i in moved)
293
+ modules_out[mod] = [n for i in moved for n in movable[i]]
294
+ if not os.path.exists(os.path.join(pkg_dir, "__init__.py")):
295
+ files[os.path.join(pkg_dir, "__init__.py")] = ""
296
+
297
+ first = min(stmt_module)
298
+ reexport = "".join(from_import("%s.%s" % (dotted, mod), modules_out[mod]) for mod in order)
299
+ shim_stmts = list(cst.parse_module(reexport).body)
300
+ blank = [ln for ln in body[first].leading_lines if ln.comment is None]
301
+ shim_stmts[0] = shim_stmts[0].with_changes(leading_lines=blank)
302
+ new_body = []
303
+ for idx, stmt in enumerate(body):
304
+ if idx == first:
305
+ new_body.extend(shim_stmts)
306
+ if idx not in stmt_module:
307
+ new_body.append(stmt)
308
+ shim = module.with_changes(body=new_body)
309
+ return {"files": files, "shim": shim.code, "encoding": module.encoding, "pkg_dir": pkg_dir,
310
+ "result": {"status": "ok", "package": dotted, "modules": modules_out, "shim": src}}
311
+
312
+
313
+ def write_atomic(src, plan_out):
314
+ enc, pkg_dir = plan_out["encoding"], plan_out["pkg_dir"]
315
+ temps, created, placed = [], False, []
316
+ try:
317
+ if not os.path.isdir(pkg_dir):
318
+ os.mkdir(pkg_dir)
319
+ created = True
320
+ staged = []
321
+ for path, text in sorted(plan_out["files"].items()) + [(src, plan_out["shim"])]:
322
+ fd, tmp = tempfile.mkstemp(prefix=".symbol-move-", suffix=".tmp", dir=os.path.dirname(os.path.abspath(path)))
323
+ temps.append(tmp)
324
+ with os.fdopen(fd, "wb") as fh:
325
+ fh.write(text.encode(enc))
326
+ if path == src:
327
+ shutil.copymode(src, tmp)
328
+ staged.append((tmp, path))
329
+ for tmp, path in staged: # shim last: until then --src is untouched
330
+ os.replace(tmp, path)
331
+ temps.remove(tmp)
332
+ if path != src:
333
+ placed.append(path)
334
+ except BaseException:
335
+ for tmp in temps:
336
+ try:
337
+ os.unlink(tmp)
338
+ except OSError:
339
+ pass
340
+ if created:
341
+ shutil.rmtree(pkg_dir, ignore_errors=True)
342
+ else: # --extend: remove only the files this run added to the existing package
343
+ for path in placed:
344
+ try:
345
+ os.unlink(path)
346
+ except OSError:
347
+ pass
348
+ raise
349
+
350
+
351
+ def main(argv):
352
+ opts = parse_args(argv)
353
+ if opts is None:
354
+ return usage()
355
+ src, plan_path = opts["src"], opts["plan"]
356
+ for p in (src, plan_path):
357
+ if not os.path.exists(p):
358
+ sys.stderr.write("symbol-move: no such file: %s\n" % p)
359
+ return 2
360
+ try:
361
+ import libcst as cst
362
+ except ImportError:
363
+ emit({"status": "refused",
364
+ "reason": "no codemod tool: libcst (or rope) not importable for %s" % sys.executable})
365
+ return 3
366
+ try:
367
+ if not src.endswith(".py") or not os.path.isfile(src):
368
+ raise Refused("source is not a .py file: %s" % src)
369
+ package = opts["package"] or os.path.splitext(os.path.basename(src))[0] + "_parts"
370
+ if not package.isidentifier() or keyword.iskeyword(package):
371
+ raise Refused("package name %r is not a valid identifier" % package)
372
+ out = build(cst, src, load_plan(plan_path), package, opts["extend"])
373
+ except Refused as r:
374
+ emit({"status": "refused", "reason": str(r)})
375
+ return 3
376
+ if not opts["dry_run"]:
377
+ write_atomic(src, out)
378
+ emit(out["result"])
379
+ return 0
380
+
381
+
382
+ if __name__ == "__main__":
383
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env bash
2
+ # symbol-move.test.sh — cases for scripts/symbol-move.py (forge#4210).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ # Builds a throwaway git repo from scripts/fixtures/symbol-move/gen_big.py (~3k-line module + unittest suite), moves
6
+ # four symbol groups into pkg/big_parts/ and proves the move with scripts/refactor-verify.sh; then checks refusals and
7
+ # the --extend chain step (forge#4211).
8
+ # Interpreter with libcst: $SYMBOL_MOVE_PYTHON, else python3 if it has libcst, else `uv run --no-project --with libcst
9
+ # python`. Without one the libcst cases are skipped; SYMBOL_MOVE_REQUIRE_LIBCST=1 turns that skip into a failure.
10
+ set -uo pipefail
11
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12
+ SM="$HERE/symbol-move.py"; RV="$HERE/refactor-verify.sh"; GEN="$HERE/fixtures/symbol-move/gen_big.py"
13
+ PASS=0; FAILN=0
14
+ TMP="$(mktemp -d "${TMPDIR:-/tmp}/symbol-move-test.XXXXXX")"; trap 'rm -rf "$TMP"' EXIT
15
+ ok() { PASS=$((PASS+1)); }
16
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
17
+ eq() { if [ "$2" = "$3" ]; then ok; else bad "$1 (got: $2 want: $3)"; fi }
18
+ q() { printf '%s' "$OUT" | jq -c "$1"; }
19
+ run() { OUT="$("$@" 2>"$TMP/err")"; RC=$?; }
20
+ export PYTHONDONTWRITEBYTECODE=1
21
+
22
+ PYCMD=""
23
+ if [ -n "${SYMBOL_MOVE_PYTHON:-}" ]; then
24
+ PYCMD="$SYMBOL_MOVE_PYTHON"
25
+ elif python3 -c 'import libcst' >/dev/null 2>&1; then
26
+ PYCMD="python3"
27
+ elif command -v uv >/dev/null 2>&1 && uv run --no-project --with libcst python -c 'import libcst' >/dev/null 2>&1; then
28
+ PYCMD="uv run --no-project --with libcst python"
29
+ fi
30
+ # shellcheck disable=SC2086 # PYCMD is a command line
31
+ mover() { run $PYCMD "$SM" "$@"; }
32
+
33
+ R="$TMP/repo"; B="$TMP/baseline.json"; P="$TMP/plan.json"
34
+ TEST_CMD='python3 -m unittest discover -s tests -t .'
35
+ g() { git -C "$R" -c user.name=t -c user.email=t@example.invalid "$@"; }
36
+ mkdir -p "$R"; git init -q "$R"
37
+ printf '__pycache__/\n' > "$R/.gitignore"
38
+ python3 "$GEN" "$R" || bad "generator failed"
39
+ g add -A && g commit -qm base && g tag base
40
+ eq "fixture size >= 2500 lines" "$([ "$(wc -l < "$R/pkg/big.py")" -ge 2500 ] && echo yes || echo no)" yes
41
+ SHA0="$(git -C "$R" hash-object pkg/big.py)"
42
+
43
+ plan() { python3 -c 'import json,sys; print(json.dumps(json.loads(sys.argv[1])))' "$1" > "$2"; }
44
+ FULL='{"consts":['"$(printf '"LIMIT_%02d",' $(seq 0 89))"'"_SEPARATOR"],
45
+ "helpers":['"$(for i in $(seq -w 0 89); do printf '"_scale_%s","compute_%s",' "$i" "$i"; done)"'"join_parts"],
46
+ "aio":['"$(printf '"fetch_%02d",' $(seq 0 28))"'"fetch_29"],
47
+ "models":['"$(printf '"Item_%02d",' $(seq 0 28))"'"Item_29"]}'
48
+ plan "$FULL" "$P"
49
+
50
+ # refused <label>: rc 3, status refused, a reason, and nothing changed in the repo
51
+ refused() {
52
+ eq "$1: rc" "$RC" 3
53
+ eq "$1: status" "$(q '.status')" '"refused"'
54
+ eq "$1: has reason" "$(q '.reason|length>0')" true
55
+ eq "$1: tree untouched" "$(git -C "$R" status --porcelain)" ""
56
+ eq "$1: shim untouched" "$(git -C "$R" hash-object pkg/big.py)" "$SHA0"
57
+ }
58
+
59
+ # ---- 1. no codemod tool: stdlib-only interpreter (no site-packages) refuses before touching anything ----
60
+ run python3 -I -S "$SM" --src "$R/pkg/big.py" --plan "$P"
61
+ refused "no codemod tool"
62
+ eq "no codemod tool: reason" "$(q '.reason|startswith("no codemod tool")')" true
63
+ eq "no codemod tool: one JSON object" "$(printf '%s\n' "$OUT" | wc -l | tr -d ' ')" 1
64
+
65
+ # ---- 2. usage ----
66
+ python3 -I -S "$SM" >/dev/null 2>&1; eq "no args" "$?" 2
67
+ python3 -I -S "$SM" --src "$R/pkg/missing.py" --plan "$P" >/dev/null 2>&1; eq "missing src" "$?" 2
68
+
69
+ if [ -z "$PYCMD" ]; then
70
+ echo "symbol-move.test.sh: libcst cases skipped (no python with libcst; set SYMBOL_MOVE_PYTHON)"
71
+ [ "${SYMBOL_MOVE_REQUIRE_LIBCST:-}" = 1 ] && bad "libcst required (SYMBOL_MOVE_REQUIRE_LIBCST=1) but not available"
72
+ else
73
+ # ---- 3. invalid plans refuse with no edits ----
74
+ plan '{"a":["compute_00","no_such_symbol"]}' "$TMP/p1.json"
75
+ mover --src "$R/pkg/big.py" --plan "$TMP/p1.json"; refused "unknown symbol"
76
+ eq "unknown symbol: reason" "$(q '.reason|contains("no_such_symbol")')" true
77
+ plan '{"a":["compute_00","LIMIT_00"],"b":["LIMIT_00"]}' "$TMP/p2.json"
78
+ mover --src "$R/pkg/big.py" --plan "$TMP/p2.json"; refused "symbol twice"
79
+ eq "symbol twice: reason" "$(q '.reason|contains("listed twice")')" true
80
+ plan '{"helpers":["_scale_00","compute_00"]}' "$TMP/p3.json"
81
+ mover --src "$R/pkg/big.py" --plan "$TMP/p3.json"; refused "depends on kept symbol"
82
+ eq "depends on kept symbol: reason" "$(q '.reason|contains("LIMIT_00")')" true
83
+ plan '{"a":["Item_00.label"]}' "$TMP/p4.json"
84
+ mover --src "$R/pkg/big.py" --plan "$TMP/p4.json"; refused "method"
85
+ plan '{"x":["Item_00","LIMIT_00","_SEPARATOR"],"y":["_scale_00","compute_00","join_parts"]}' "$TMP/p6.json"
86
+ mover --src "$R/pkg/big.py" --plan "$TMP/p6.json"; refused "import cycle"
87
+ eq "import cycle: reason" "$(q '.reason|contains("import cycle")')" true
88
+ plan '{"consts":["LIMIT_00"],"helpers":["_scale_00","compute_00"],"back":["_SEPARATOR"]}' "$TMP/p5.json"
89
+ mover --src "$R/pkg/big.py" --plan "$TMP/p5.json" --dry-run
90
+ eq "independent subset moves in dry-run" "$RC" 0
91
+ eq "dry-run writes nothing" "$(git -C "$R" status --porcelain)" ""
92
+ mkdir -p "$R/pkg/big_parts"; : > "$R/pkg/big_parts/keep.txt"
93
+ mover --src "$R/pkg/big.py" --plan "$P"
94
+ eq "existing package: reason" "$(q '.reason|contains("already exists")')" true
95
+ eq "existing package: rc" "$RC" 3
96
+ eq "existing package: kept" "$(ls "$R/pkg/big_parts")" keep.txt
97
+ rm -rf "$R/pkg/big_parts"
98
+ refused "existing package"
99
+
100
+ # ---- 4. full plan: package + import-only shim, proved by refactor-verify ----
101
+ run bash "$RV" baseline --root "$R" --target pkg/big.py --test-cmd "$TEST_CMD" --out "$B"
102
+ eq "baseline rc" "$RC" 0
103
+ eq "baseline counts" "$(jq -c .test "$B")" '{"exit":0,"passed":10,"skipped":1}'
104
+
105
+ mover --src "$R/pkg/big.py" --plan "$P" --dry-run
106
+ eq "dry-run rc" "$RC" 0
107
+ eq "dry-run touches nothing" "$(git -C "$R" status --porcelain)" ""
108
+
109
+ mover --src "$R/pkg/big.py" --plan "$P"
110
+ eq "move rc" "$RC" 0
111
+ eq "move status" "$(q '.status')" '"ok"'
112
+ eq "move package" "$(q '.package')" '"pkg.big_parts"'
113
+ eq "move modules" "$(q '.modules|keys')" '["aio","consts","helpers","models"]'
114
+ eq "move module sizes" "$(q '[.modules[]|length]')" '[30,91,181,30]'
115
+ for f in __init__ consts helpers aio models; do
116
+ eq "package file $f" "$([ -f "$R/pkg/big_parts/$f.py" ] && echo yes || echo no)" yes
117
+ done
118
+ eq "package __init__ empty" "$(wc -c < "$R/pkg/big_parts/__init__.py" | tr -d ' ')" 0
119
+ g add -A && g commit -qm move
120
+ eq "shim: added lines are imports only" \
121
+ "$(git -C "$R" diff base HEAD -- pkg/big.py | grep '^+' | grep -v '^+++' | sed 's/^+//' \
122
+ | grep -vE '^\s*$|^\s*(from \S+ import|import \S)|^\s+[A-Za-z_][A-Za-z_0-9]*,$|^\)$' | wc -l | tr -d ' ')" 0
123
+ eq "shim keeps unmoved code" "$(grep -c '^def describe\|^VERSION = \|^DEFAULT_TOTAL = ' "$R/pkg/big.py")" 3
124
+ eq "symbol moved verbatim" "$(git -C "$R" show base:pkg/big.py | sed -n '/^@functools.total_ordering$/,/^class Item_00:/p' | head -2 | tr '\n' '|')" \
125
+ "$(sed -n '/^@functools.total_ordering$/,/^class Item_00:/p' "$R/pkg/big_parts/models.py" | head -2 | tr '\n' '|')"
126
+ run bash "$RV" verify --baseline "$B" --base base
127
+ eq "verify rc" "$RC" 0
128
+ eq "verify ok" "$(q '.ok')" true
129
+ eq "verify failures" "$(q '.failures')" '[]'
130
+ (cd "$R" && $TEST_CMD >"$TMP/after.txt" 2>&1); eq "tests after move" "$?" 0
131
+ eq "identical test result" "$(tail -1 "$TMP/after.txt")" "OK (skipped=1)"
132
+
133
+ # ---- 5. --extend (forge#4211 chain step): a second PR adds modules to the package the first one created ----
134
+ R2="$TMP/repo2"; B2="$TMP/baseline2.json"
135
+ g2() { git -C "$R2" -c user.name=t -c user.email=t@example.invalid "$@"; }
136
+ mkdir -p "$R2"; git init -q "$R2"; printf '__pycache__/\n' > "$R2/.gitignore"
137
+ python3 "$GEN" "$R2" && g2 add -A && g2 commit -qm base
138
+ plan '{"consts":['"$(printf '"LIMIT_%02d",' $(seq 0 89))"'"_SEPARATOR"]}' "$TMP/step1.json"
139
+ mover --src "$R2/pkg/big.py" --plan "$TMP/step1.json"
140
+ eq "chain step 1 rc" "$RC" 0
141
+ g2 add -A && g2 commit -qm step1 && g2 tag step1
142
+ plan '{"helpers":['"$(for i in $(seq -w 0 89); do printf '"_scale_%s","compute_%s",' "$i" "$i"; done)"'"join_parts"]}' "$TMP/step2.json"
143
+ mover --src "$R2/pkg/big.py" --plan "$TMP/step2.json"
144
+ eq "existing package without --extend: rc" "$RC" 3
145
+ eq "existing package without --extend: reason" "$(q '.reason|contains("already exists")')" true
146
+ eq "existing package without --extend: untouched" "$(git -C "$R2" status --porcelain)" ""
147
+ jq -c '{consts: .helpers}' "$TMP/step2.json" > "$TMP/step2bad.json"
148
+ mover --src "$R2/pkg/big.py" --plan "$TMP/step2bad.json" --extend
149
+ eq "--extend colliding module: rc" "$RC" 3
150
+ eq "--extend colliding module: reason" "$(q '.reason|contains("module already exists")')" true
151
+ eq "--extend colliding module: untouched" "$(git -C "$R2" status --porcelain)" ""
152
+ run bash "$RV" baseline --root "$R2" --target pkg/big.py --test-cmd "$TEST_CMD" --out "$B2"
153
+ eq "chain baseline rc" "$RC" 0
154
+ INIT_SHA="$(git -C "$R2" hash-object pkg/big_parts/__init__.py)"
155
+ mover --src "$R2/pkg/big.py" --plan "$TMP/step2.json" --extend
156
+ eq "--extend rc" "$RC" 0
157
+ eq "--extend package" "$(q '.package')" '"pkg.big_parts"'
158
+ eq "--extend modules" "$(q '.modules|keys')" '["helpers"]'
159
+ eq "--extend keeps __init__" "$(git -C "$R2" hash-object pkg/big_parts/__init__.py)" "$INIT_SHA"
160
+ eq "--extend keeps step-1 module" "$(git -C "$R2" status --porcelain pkg/big_parts/consts.py)" ""
161
+ g2 add -A && g2 commit -qm step2
162
+ run bash "$RV" verify --baseline "$B2" --base step1
163
+ eq "--extend verify rc" "$RC" 0
164
+ eq "--extend verify failures" "$(q '.failures')" '[]'
165
+ fi
166
+
167
+ echo "symbol-move.test.sh: $PASS passed, $FAILN failed"
168
+ [ "$FAILN" -eq 0 ]