forgedock 1.27.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 +10 -0
- package/bin/engine/telemetry-seam.mjs +37 -8
- package/bin/engine/telemetry-seam.test.mjs +31 -0
- package/bin/engine-cli.mjs +542 -38
- package/bin/engine.mjs +8 -4
- package/bin/forgedock.mjs +75 -9
- package/bin/gc.mjs +580 -0
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +22 -4
- package/bin/lean/batch.test.mjs +5 -3
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +190 -58
- package/bin/lean/conformance.test.mjs +116 -7
- 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 +85 -8
- 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 +572 -139
- package/bin/lean/lean.test.mjs +1193 -31
- package/bin/lean/ledger.mjs +27 -2
- package/bin/lean/ledger.test.mjs +36 -1
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +312 -19
- package/bin/lean/prompts.mjs +89 -15
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +24 -5
- 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 +20 -9
- package/bin/lean/verify.test.mjs +26 -3
- 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/audit.md +2 -0
- 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 +7 -0
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +10 -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 +177 -2
- package/docs/OUTCOME-CODES.md +8 -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 +36 -2
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +9 -4
- package/scripts/check-devdocs-freshness.test.sh +11 -1
- 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 +3 -3
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +1 -1
- 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/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/structure-delta.sh +10 -5
- package/scripts/structure-delta.test.sh +3 -2
- 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,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 ]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
|
|
2
|
+
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
|
3
|
+
# /bootstrap interview question bank
|
|
4
|
+
|
|
5
|
+
Ask conversationally and keep it short: skip any aspect the user's free text or docs already answer. Every aspect left
|
|
6
|
+
unanswered gets the **Default** and its rationale, tied to what the user did say, in the "Here's what I assumed" summary.
|
|
7
|
+
Aspects marked **Doubt** change architecture or cost. When the answer is unclear, ask them as multiple-choice questions
|
|
8
|
+
(at most 4 per batch, each with a recommended option). All other aspects default silently.
|
|
9
|
+
|
|
10
|
+
| # | Aspect | Question | Default (rationale pattern) | Doubt? |
|
|
11
|
+
|---|--------|----------|-----------------------------|--------|
|
|
12
|
+
| 1 | Users and segments | Who uses it, and which segments differ in what they need? | One primary segment from the pitch; admins as a second role (most products need an operator view) | no |
|
|
13
|
+
| 2 | Core jobs and features | What must a user be able to do on day one? | The jobs named in the pitch, each as a feature with its happy path | no |
|
|
14
|
+
| 3 | Scope | MVP first, or the full product? | MVP, full product as later milestones (cheapest path to feedback) | yes, when the pitch lists many features |
|
|
15
|
+
| 4 | Platforms | Web, mobile, desktop? | Responsive web (one codebase covers phones) | yes: mobile native vs PWA |
|
|
16
|
+
| 5 | Stack | Any language, framework or hosting preferences? | The closest preset in `stack-profiles.md` | no, unless the user names conflicting stacks |
|
|
17
|
+
| 6 | Auth | How do users sign in? | Email magic link + Google sign-in (lowest friction, no password storage) | no |
|
|
18
|
+
| 7 | Data and tenancy | Is data shared between organisations? | Postgres + row-level security for multi-tenant SaaS; single-tenant otherwise | yes: self-host vs managed DB |
|
|
19
|
+
| 8 | Integrations | Which external services? | A mock provider per credentialed integration so dev and CI stay green | no |
|
|
20
|
+
| 9 | Payments | Do you charge, and how? | Stripe subscriptions behind a mock in dev (most common, test mode is free) | yes, when pricing is unclear |
|
|
21
|
+
| 10 | i18n | Which languages and locales? | English with i18n-ready strings (cheap now, expensive later) | no |
|
|
22
|
+
| 11 | Compliance and residency | Any regulation (GDPR, HIPAA, DPDP) or data-residency rule? | Privacy by default (minimal PII, encrypted at rest); region of the primary market | yes: data residency |
|
|
23
|
+
| 12 | Design ambition and UI libraries | Polished brand or functional? Any UI library? | Accessible component library (e.g. shadcn/ui) plus a design-token theme; hero surfaces go to `/design` | no |
|
|
24
|
+
| 13 | Deployment target | VPS, Cloudflare or a cloud provider? | The stack preset's target with a staging-branch flow (staging -> main) | yes: when the cost profile differs |
|
|
25
|
+
| 14 | Scale | Users and data in year one? | Small (hundreds of users): one region, one DB, a scale path in the spike | no |
|
|
26
|
+
| 15 | Budget | Monthly infra budget? | Free or hobby tiers until launch | no |
|
|
27
|
+
| 16 | Team | Who builds and reviews? | A solo builder with ForgeDock agents | no |
|
|
28
|
+
| 17 | Timeline | Any hard date? | None: milestones in build order | no |
|
|
29
|
+
|
|
30
|
+
Always a doubt: the **product name** and the **repo owner** (user or org; the owner must be covered by the ForgeDock
|
|
31
|
+
GitHub App installation).
|
|
32
|
+
|
|
33
|
+
Guardrails and domain rules (e.g. "no auto-send", "India-only data residency") go into the plan's `guardrails` and
|
|
34
|
+
`domainRules`. bootstrap-create.mjs copies them into every issue's Context.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
|
|
2
|
+
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
|
3
|
+
<!-- /bootstrap build-issue body (Pipeline Issue Template). Copy from "## Problem" down; drop the UI block for
|
|
4
|
+
non-UI issues and the hero line for non-hero issues. Plan flags: "ui": true needs the full design spec, "hero": true
|
|
5
|
+
needs the /design line (scripts/bootstrap-validate.mjs). Reference other plan issues as #{{KEY}}. -->
|
|
6
|
+
## Problem
|
|
7
|
+
|
|
8
|
+
{what this issue delivers and for whom, one paragraph}
|
|
9
|
+
|
|
10
|
+
## Root Cause (if known)
|
|
11
|
+
|
|
12
|
+
Greenfield: not built yet. {spike section this implements}
|
|
13
|
+
|
|
14
|
+
## Affected Files
|
|
15
|
+
|
|
16
|
+
1. `{planned/path/file.ext}` — new
|
|
17
|
+
2. `{planned/path/other.ext}` — new
|
|
18
|
+
|
|
19
|
+
## Acceptance Criteria
|
|
20
|
+
|
|
21
|
+
- [ ] {observable behaviour} [type:unit]
|
|
22
|
+
- [ ] {API or integration behaviour} [type:api]
|
|
23
|
+
- [ ] {user-visible flow} [type:e2e]
|
|
24
|
+
|
|
25
|
+
### Design spec (UI issues)
|
|
26
|
+
|
|
27
|
+
- **Breakpoints**: {mobile 360, tablet 768, desktop 1280: layout at each}
|
|
28
|
+
- **States**: empty state {copy and action}; loading state {skeleton}; error state {message and retry}; offline state {cached view or banner}
|
|
29
|
+
- **Motion**: {transitions and durations; respects prefers-reduced-motion}
|
|
30
|
+
- **Accessibility**: {WCAG 2.2 AA: focus order, labels, contrast, keyboard paths}
|
|
31
|
+
- **Dark mode**: {token mapping; verified in both themes}
|
|
32
|
+
- **Hero surface**: route the visual design to `/design` before building this page.
|
|
33
|
+
|
|
34
|
+
## Context
|
|
35
|
+
|
|
36
|
+
**Parent**: #{{SPIKE}}
|
|
37
|
+
Spike: `docs/engineering/technical-spike.md` § {section}
|
|
38
|
+
{real credentials: see #{{NEEDS_HUMAN_KEY}}; until then the mock provider is used, so this issue never depends on it}
|
|
39
|
+
|
|
40
|
+
## Dependencies
|
|
41
|
+
|
|
42
|
+
Depends on #{{DEP_KEY}} — {why}
|