forgedock 1.26.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +13 -0
  4. package/bin/engine/phases.mjs +38 -2
  5. package/bin/engine/telemetry-seam.mjs +41 -6
  6. package/bin/engine/telemetry-seam.test.mjs +35 -0
  7. package/bin/engine-cli.mjs +616 -54
  8. package/bin/engine.mjs +13 -5
  9. package/bin/forgedock.mjs +169 -39
  10. package/bin/gc.mjs +580 -0
  11. package/bin/init-detect.mjs +68 -3
  12. package/bin/init-enrich.mjs +3 -0
  13. package/bin/init-repo.mjs +213 -0
  14. package/bin/journey.mjs +278 -43
  15. package/bin/lean/admission.mjs +33 -3
  16. package/bin/lean/batch.mjs +24 -5
  17. package/bin/lean/batch.test.mjs +54 -4
  18. package/bin/lean/checks.mjs +199 -0
  19. package/bin/lean/checks.test.mjs +183 -0
  20. package/bin/lean/conformance.mjs +235 -37
  21. package/bin/lean/conformance.test.mjs +143 -5
  22. package/bin/lean/credentials.mjs +29 -0
  23. package/bin/lean/depcache.mjs +312 -0
  24. package/bin/lean/depcache.test.mjs +298 -0
  25. package/bin/lean/drift.mjs +198 -0
  26. package/bin/lean/drift.test.mjs +223 -0
  27. package/bin/lean/effects.mjs +178 -16
  28. package/bin/lean/execution.e2e.test.mjs +81 -0
  29. package/bin/lean/execution.mjs +431 -0
  30. package/bin/lean/execution.test.mjs +583 -0
  31. package/bin/lean/heavy.mjs +289 -0
  32. package/bin/lean/heavy.test.mjs +207 -0
  33. package/bin/lean/lean.mjs +877 -151
  34. package/bin/lean/lean.test.mjs +1798 -28
  35. package/bin/lean/ledger.mjs +92 -0
  36. package/bin/lean/ledger.test.mjs +100 -0
  37. package/bin/lean/limits.mjs +58 -0
  38. package/bin/lean/limits.test.mjs +150 -0
  39. package/bin/lean/orchestrate.mjs +321 -19
  40. package/bin/lean/prompts.mjs +125 -17
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +25 -6
  44. package/bin/lean/review-trigger.mjs +134 -0
  45. package/bin/lean/review-trigger.test.mjs +104 -0
  46. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  47. package/bin/lean/sandbox-remote.mjs +142 -0
  48. package/bin/lean/sched.mjs +221 -0
  49. package/bin/lean/sched.test.mjs +310 -0
  50. package/bin/lean/schedd.mjs +247 -0
  51. package/bin/lean/schedd.test.mjs +180 -0
  52. package/bin/lean/toolchain.mjs +228 -0
  53. package/bin/lean/toolchain.test.mjs +154 -0
  54. package/bin/lean/verify.mjs +124 -0
  55. package/bin/lean/verify.test.mjs +118 -0
  56. package/bin/lean/worktree.mjs +69 -0
  57. package/bin/lean/worktree.test.mjs +141 -0
  58. package/bin/runner.mjs +53 -10
  59. package/bin/stats/github-source.mjs +1 -1
  60. package/bin/stats/index.mjs +14 -2
  61. package/bin/stats/query.mjs +72 -0
  62. package/bin/stats/stats.test.mjs +54 -0
  63. package/commands/adopt.md +3 -0
  64. package/commands/audit.md +5 -8
  65. package/commands/autopilot.md +2 -0
  66. package/commands/bootstrap.md +35 -0
  67. package/commands/cleanup.md +7 -22
  68. package/commands/forge-stats.md +2 -1
  69. package/commands/forgedock-init.md +125 -6
  70. package/commands/issue.md +89 -830
  71. package/commands/milestone.md +55 -6
  72. package/commands/orchestrate.md +10 -2
  73. package/commands/review-pr.md +2 -2
  74. package/commands/upgrade-deps.md +3 -3
  75. package/commands/work-on/build/validate.md +4 -0
  76. package/commands/work-on/close.md +2 -0
  77. package/commands/work-on/decompose.md +24 -6
  78. package/docs/CI.md +3 -3
  79. package/docs/CONFIG.md +227 -3
  80. package/docs/OUTCOME-CODES.md +11 -0
  81. package/docs/TELEMETRY.md +1 -1
  82. package/docs/site/command-reference.md +22 -0
  83. package/docs/site/getting-started.md +2 -0
  84. package/docs/site/start-a-new-project.md +327 -0
  85. package/package.json +1 -1
  86. package/scripts/affected-files-check.sh +91 -0
  87. package/scripts/affected-files-check.test.sh +80 -0
  88. package/scripts/bootstrap-create.mjs +393 -0
  89. package/scripts/bootstrap-validate.mjs +321 -0
  90. package/scripts/calibration.mjs +116 -0
  91. package/scripts/check-devdocs-freshness.sh +125 -0
  92. package/scripts/check-devdocs-freshness.test.sh +130 -0
  93. package/scripts/check-spec-markers.sh +3 -0
  94. package/scripts/check-spec-size.sh +29 -11
  95. package/scripts/check-spec-size.test.sh +27 -4
  96. package/scripts/ci-local/lib.mjs +8 -1
  97. package/scripts/ci-local/parallelism.test.mjs +27 -0
  98. package/scripts/ci-local/run.mjs +16 -5
  99. package/scripts/ci-local/scoping.mjs +82 -0
  100. package/scripts/ci-local.test.sh +14 -2
  101. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  102. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  103. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  106. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  107. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  108. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  109. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  110. package/scripts/forge-root.test.sh +1 -1
  111. package/scripts/hotspots.sh +100 -0
  112. package/scripts/hotspots.test.sh +101 -0
  113. package/scripts/issue-create.sh +197 -0
  114. package/scripts/issue-create.test.sh +166 -0
  115. package/scripts/maintenance-scan.sh +185 -0
  116. package/scripts/maintenance-scan.test.sh +89 -0
  117. package/scripts/milestone-drift.sh +206 -0
  118. package/scripts/milestone-drift.test.sh +120 -0
  119. package/scripts/northstar-docs.test.sh +68 -0
  120. package/scripts/refactor-verify.sh +368 -0
  121. package/scripts/refactor-verify.test.sh +202 -0
  122. package/scripts/review-prepare.sh +11 -9
  123. package/scripts/review-prepare.test.sh +14 -0
  124. package/scripts/structure-delta.sh +155 -0
  125. package/scripts/structure-delta.test.sh +136 -0
  126. package/scripts/symbol-index.mjs +253 -0
  127. package/scripts/symbol-index.py +354 -0
  128. package/scripts/symbol-index.test.sh +119 -0
  129. package/scripts/symbol-move-ts.test.sh +161 -0
  130. package/scripts/symbol-move.mjs +379 -0
  131. package/scripts/symbol-move.py +383 -0
  132. package/scripts/symbol-move.test.sh +168 -0
  133. package/templates/bootstrap/interview.md +34 -0
  134. package/templates/bootstrap/issue-body.md +42 -0
  135. package/templates/bootstrap/needs-human-issue.md +45 -0
  136. package/templates/bootstrap/stack-profiles.md +34 -0
  137. package/templates/bootstrap/technical-spike.md +75 -0
@@ -0,0 +1,354 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # symbol-index.py: deterministic Python symbol index + module import-cycle detector for refactor:split (forge#4208).
6
+ # stdlib only (ast). No LLM, no network. Never emits source text, docstrings or literal values.
7
+ #
8
+ # Usage: python3 scripts/symbol-index.py <file.py | dir>...
9
+ # Output (stdout, one JSON object):
10
+ # {"symbols":[{name,kind,start,end,lines,public[,file]}], "edges":[{from,to}],
11
+ # "imports":[{module,names,line[,file]}], "cycles":[[module,...],...]}
12
+ # kind: function|async_function|class|method|const. Methods (and nested classes) are named Class.method.
13
+ # start includes decorators; lines = end - start + 1. public = no leading underscore (method dunders count as
14
+ # public), and listed in a static __all__ when the module defines one.
15
+ # One .py file: names are bare. Otherwise (a directory or several files) each entry carries `file` and edge ends
16
+ # are "<module>:<symbol>"; module names are dotted paths relative to each root (a root that has __init__.py is
17
+ # itself the top package, so root .../pkg yields pkg.a). edges = references from one indexed symbol to another.
18
+ # cycles = strongly connected components (size > 1, or a self-import) of the module-level import graph between
19
+ # indexed modules; function-local and `if TYPE_CHECKING:` imports are ignored. Everything is sorted, so a
20
+ # before/after pair of runs can be diffed for new cycles.
21
+ # Unsupported input (non-.py file, no .py files, a file that does not parse) prints only
22
+ # {"unsupported":"<lang>","reason":"..."} and exits 0: never partial output.
23
+ # Exit: 0 ok or unsupported | 2 usage or missing path.
24
+
25
+ import ast
26
+ import json
27
+ import os
28
+ import sys
29
+
30
+ LANGS = {
31
+ ".ts": "ts", ".tsx": "ts", ".mts": "ts", ".cts": "ts",
32
+ ".js": "js", ".jsx": "js", ".mjs": "js", ".cjs": "js",
33
+ ".go": "go", ".rs": "rust", ".rb": "ruby", ".java": "java", ".kt": "kotlin",
34
+ ".c": "c", ".h": "c", ".cc": "cpp", ".cpp": "cpp", ".hpp": "cpp", ".cs": "csharp",
35
+ ".php": "php", ".swift": "swift", ".sh": "shell", ".bash": "shell",
36
+ }
37
+ SKIP_DIRS = {"__pycache__", "node_modules", "site-packages", "venv", "env"}
38
+
39
+
40
+ class Unsupported(Exception):
41
+ def __init__(self, lang, reason):
42
+ super().__init__(reason)
43
+ self.lang, self.reason = lang, reason
44
+
45
+
46
+ def lang_of(path):
47
+ ext = os.path.splitext(path)[1].lower()
48
+ return LANGS.get(ext, ext[1:] if ext else "unknown")
49
+
50
+
51
+ def is_private(name):
52
+ return name.startswith("_")
53
+
54
+
55
+ def is_dunder(name):
56
+ return len(name) > 4 and name.startswith("__") and name.endswith("__")
57
+
58
+
59
+ def static_all(tree):
60
+ """Names in a statically readable module-level __all__, or None when absent or not static."""
61
+ names, seen = [], False
62
+ for node in tree.body:
63
+ if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id == "__all__" for t in node.targets):
64
+ value, seen, names = node.value, True, []
65
+ elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.target.id == "__all__":
66
+ value, seen, names = node.value, True, []
67
+ elif isinstance(node, ast.AugAssign) and isinstance(node.target, ast.Name) and node.target.id == "__all__":
68
+ value = node.value
69
+ else:
70
+ continue
71
+ if not isinstance(value, (ast.List, ast.Tuple)):
72
+ return None
73
+ for elt in value.elts:
74
+ if not (isinstance(elt, ast.Constant) and isinstance(elt.value, str)):
75
+ return None
76
+ names.append(elt.value)
77
+ return set(names) if seen else None
78
+
79
+
80
+ def def_start(node):
81
+ decos = getattr(node, "decorator_list", None) or []
82
+ return min([node.lineno] + [d.lineno for d in decos])
83
+
84
+
85
+ def is_type_checking(test):
86
+ return (isinstance(test, ast.Name) and test.id == "TYPE_CHECKING") or (
87
+ isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING")
88
+
89
+
90
+ def module_imports(body):
91
+ """Import statements at module level (descending into if/try/with, never into def/class or TYPE_CHECKING)."""
92
+ out = []
93
+ for node in body:
94
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
95
+ out.append(node)
96
+ elif isinstance(node, ast.If):
97
+ if not is_type_checking(node.test):
98
+ out.extend(module_imports(node.body))
99
+ out.extend(module_imports(node.orelse))
100
+ elif isinstance(node, (ast.Try, getattr(ast, "TryStar", ast.Try))):
101
+ out.extend(module_imports(node.body))
102
+ for h in node.handlers:
103
+ out.extend(module_imports(h.body))
104
+ out.extend(module_imports(node.orelse))
105
+ out.extend(module_imports(node.finalbody))
106
+ elif isinstance(node, (ast.With, ast.AsyncWith)):
107
+ out.extend(module_imports(node.body))
108
+ return out
109
+
110
+
111
+ class Module:
112
+ def __init__(self, path, name, is_pkg):
113
+ self.path, self.name, self.is_pkg = path, name, is_pkg
114
+ self.symbols = [] # dicts without file
115
+ self.nodes = {} # symbol name -> (ast node, owning class name or None)
116
+ self.imports = [] # (ast import node)
117
+ self.tree = None
118
+
119
+ def parse(self):
120
+ try:
121
+ with open(self.path, "rb") as fh:
122
+ self.tree = ast.parse(fh.read(), filename=self.path)
123
+ except SyntaxError as e:
124
+ raise Unsupported("python", "parse-error: %s:%s" % (self.path, e.lineno or 0))
125
+ except (ValueError, UnicodeDecodeError, OSError):
126
+ raise Unsupported("python", "parse-error: %s:0" % self.path)
127
+ exported = static_all(self.tree)
128
+
129
+ def top_public(name):
130
+ return not is_private(name) and (exported is None or name in exported)
131
+
132
+ def add(name, kind, node, public, owner=None):
133
+ start, end = def_start(node), node.end_lineno or node.lineno
134
+ self.symbols.append({"name": name, "kind": kind, "start": start, "end": end,
135
+ "lines": end - start + 1, "public": public})
136
+ self.nodes[name] = (node, owner)
137
+
138
+ def add_class(node, prefix, public):
139
+ name = prefix + node.name
140
+ add(name, "class", node, public)
141
+ for item in node.body:
142
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
143
+ mpub = public and (not is_private(item.name) or is_dunder(item.name))
144
+ add(name + "." + item.name, "method", item, mpub, owner=name)
145
+ elif isinstance(item, ast.ClassDef):
146
+ add_class(item, name + ".", public and not is_private(item.name))
147
+
148
+ for node in self.tree.body:
149
+ if isinstance(node, ast.FunctionDef):
150
+ add(node.name, "function", node, top_public(node.name))
151
+ elif isinstance(node, ast.AsyncFunctionDef):
152
+ add(node.name, "async_function", node, top_public(node.name))
153
+ elif isinstance(node, ast.ClassDef):
154
+ add_class(node, "", top_public(node.name))
155
+ elif isinstance(node, (ast.Assign, ast.AnnAssign)):
156
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
157
+ for t in targets:
158
+ for n in ([t] if isinstance(t, ast.Name) else getattr(t, "elts", [])):
159
+ if isinstance(n, ast.Name) and n.id not in self.nodes:
160
+ add(n.id, "const", node, top_public(n.id))
161
+ self.imports = module_imports(self.tree.body)
162
+
163
+ def package(self):
164
+ return self.name if self.is_pkg else self.name.rpartition(".")[0]
165
+
166
+ def resolve(self, node):
167
+ """Absolute module named by an ImportFrom (relative levels resolved), or None when it climbs past the root."""
168
+ if node.level == 0:
169
+ return node.module or ""
170
+ parts = self.package().split(".") if self.package() else []
171
+ if node.level - 1 > len(parts):
172
+ return None
173
+ base = parts[:len(parts) - (node.level - 1)]
174
+ if node.module:
175
+ base = base + node.module.split(".")
176
+ return ".".join(base)
177
+
178
+
179
+ def walk_roots(paths):
180
+ """[(path, module name, is_pkg)] for every .py file under the given roots, sorted."""
181
+ found = []
182
+ for root in paths:
183
+ root = os.path.normpath(root)
184
+ if os.path.isfile(root):
185
+ if not root.endswith(".py"):
186
+ raise Unsupported(lang_of(root), "unsupported-language")
187
+ stem = os.path.splitext(os.path.basename(root))[0]
188
+ found.append((root, stem, False))
189
+ continue
190
+ prefix = os.path.basename(os.path.abspath(root)) if os.path.isfile(os.path.join(root, "__init__.py")) else ""
191
+ for dirpath, dirnames, filenames in os.walk(root):
192
+ dirnames[:] = sorted(d for d in dirnames if not d.startswith(".") and d not in SKIP_DIRS)
193
+ for fn in sorted(filenames):
194
+ if not fn.endswith(".py"):
195
+ continue
196
+ path = os.path.join(dirpath, fn)
197
+ rel = os.path.relpath(path, root)[:-3].split(os.sep)
198
+ is_pkg = rel[-1] == "__init__"
199
+ if is_pkg:
200
+ rel = rel[:-1]
201
+ name = ".".join(([prefix] if prefix else []) + rel)
202
+ found.append((path, name, is_pkg))
203
+ if not found:
204
+ raise Unsupported("unknown", "no-python-files")
205
+ return sorted(found)
206
+
207
+
208
+ def tarjan(graph):
209
+ """Strongly connected components, iterative (no recursion limit on large packages)."""
210
+ index, low, on, stack, comps, counter = {}, {}, set(), [], [], [0]
211
+ for start in sorted(graph):
212
+ if start in index:
213
+ continue
214
+ work = [(start, iter(sorted(graph[start])))]
215
+ index[start] = low[start] = counter[0]; counter[0] += 1
216
+ stack.append(start); on.add(start)
217
+ while work:
218
+ v, it = work[-1]
219
+ advanced = False
220
+ for w in it:
221
+ if w not in index:
222
+ index[w] = low[w] = counter[0]; counter[0] += 1
223
+ stack.append(w); on.add(w)
224
+ work.append((w, iter(sorted(graph.get(w, ())))))
225
+ advanced = True
226
+ break
227
+ if w in on:
228
+ low[v] = min(low[v], index[w])
229
+ if advanced:
230
+ continue
231
+ work.pop()
232
+ if work:
233
+ low[work[-1][0]] = min(low[work[-1][0]], low[v])
234
+ if low[v] == index[v]:
235
+ comp = []
236
+ while True:
237
+ w = stack.pop(); on.discard(w); comp.append(w)
238
+ if w == v:
239
+ break
240
+ comps.append(sorted(comp))
241
+ return comps
242
+
243
+
244
+ def build(paths):
245
+ entries = walk_roots(paths)
246
+ multi = not (len(entries) == 1 and os.path.isfile(paths[0]) and len(paths) == 1)
247
+ modules, by_name = [], {}
248
+ for path, name, is_pkg in entries:
249
+ m = Module(path, name, is_pkg)
250
+ m.parse()
251
+ modules.append(m)
252
+ by_name.setdefault(name, m)
253
+
254
+ def ref(m, sym):
255
+ return "%s:%s" % (m.name, sym) if multi else sym
256
+
257
+ symbols, imports, edges, graph = [], [], set(), {m.name: set() for m in modules}
258
+ for m in modules:
259
+ rel = os.path.relpath(m.path)
260
+ for s in m.symbols:
261
+ symbols.append(dict(s, file=rel) if multi else s)
262
+ # bindings: local name -> (module, symbol or None for a module object)
263
+ bindings, first_import = {}, len(imports)
264
+ for node in m.imports:
265
+ if isinstance(node, ast.Import):
266
+ for a in node.names:
267
+ imports.append({"module": a.name, "names": [], "line": node.lineno})
268
+ if a.name in by_name:
269
+ graph[m.name].add(a.name)
270
+ if a.asname:
271
+ bindings[a.asname] = (a.name, None)
272
+ else:
273
+ bindings[a.name.split(".")[0]] = (a.name.split(".")[0], None)
274
+ else:
275
+ target = m.resolve(node)
276
+ shown = target if target is not None else "." * node.level + (node.module or "")
277
+ imports.append({"module": shown, "names": [a.name for a in node.names], "line": node.lineno})
278
+ if target is None:
279
+ continue
280
+ for a in node.names:
281
+ sub = (target + "." + a.name) if target else a.name
282
+ if sub in by_name:
283
+ graph[m.name].add(sub)
284
+ bindings[a.asname or a.name] = (sub, None)
285
+ elif target in by_name:
286
+ graph[m.name].add(target)
287
+ bindings[a.asname or a.name] = (target, a.name)
288
+ local = set(m.nodes)
289
+ for name, (node, owner) in m.nodes.items():
290
+ src = ref(m, name)
291
+ first_arg = None
292
+ if owner and isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.args.args:
293
+ first_arg = node.args.args[0].arg
294
+ body = [node]
295
+ if isinstance(node, ast.ClassDef): # methods and nested classes are their own symbols
296
+ body = node.decorator_list + node.bases + [k.value for k in node.keywords] + [
297
+ b for b in node.body if not isinstance(b, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))]
298
+ elif isinstance(node, (ast.Assign, ast.AnnAssign)):
299
+ body = [node.value] if node.value is not None else []
300
+ for root in body:
301
+ for n in ast.walk(root):
302
+ to = None
303
+ if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load):
304
+ if n.id in local:
305
+ to = ref(m, n.id)
306
+ elif n.id in bindings:
307
+ mod, sym = bindings[n.id]
308
+ if sym is not None and sym in by_name[mod].nodes:
309
+ to = "%s:%s" % (mod, sym)
310
+ elif isinstance(n, ast.Attribute) and isinstance(n.value, ast.Name):
311
+ base = n.value.id
312
+ if owner and base == first_arg and (owner + "." + n.attr) in local:
313
+ to = ref(m, owner + "." + n.attr)
314
+ elif base in local and (base + "." + n.attr) in local:
315
+ to = ref(m, base + "." + n.attr)
316
+ elif base in bindings and bindings[base][1] is None:
317
+ mod = bindings[base][0]
318
+ if mod in by_name and n.attr in by_name[mod].nodes:
319
+ to = "%s:%s" % (mod, n.attr)
320
+ if to is not None and to != src:
321
+ edges.add((src, to))
322
+ if multi:
323
+ for imp in imports[first_import:]:
324
+ imp["file"] = rel
325
+
326
+ cycles = sorted(c for c in tarjan(graph) if len(c) > 1 or c[0] in graph[c[0]])
327
+ if multi:
328
+ symbols.sort(key=lambda s: (s["file"], s["start"], s["name"]))
329
+ imports.sort(key=lambda i: (i["file"], i["line"], i["module"]))
330
+ else:
331
+ symbols.sort(key=lambda s: (s["start"], s["name"]))
332
+ imports.sort(key=lambda i: (i["line"], i["module"]))
333
+ return {"symbols": symbols, "edges": [{"from": f, "to": t} for f, t in sorted(edges)],
334
+ "imports": imports, "cycles": cycles}
335
+
336
+
337
+ def main(argv):
338
+ if not argv or argv[0] in ("-h", "--help"):
339
+ sys.stderr.write("usage: symbol-index.py <file.py | dir>...\n")
340
+ return 2
341
+ for p in argv:
342
+ if not os.path.exists(p):
343
+ sys.stderr.write("symbol-index: no such path: %s\n" % p)
344
+ return 2
345
+ try:
346
+ out = build(argv)
347
+ except Unsupported as u:
348
+ out = {"unsupported": u.lang, "reason": u.reason}
349
+ sys.stdout.write(json.dumps(out, sort_keys=True, separators=(",", ":")) + "\n")
350
+ return 0
351
+
352
+
353
+ if __name__ == "__main__":
354
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env bash
2
+ # symbol-index.test.sh — cases for scripts/symbol-index.{py,mjs} (forge#4208).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ # Optional: SYMBOL_INDEX_TS_NODE_MODULES=<node_modules holding typescript@5> also runs the TS-parser cases.
6
+ set -uo pipefail
7
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ PY="$HERE/symbol-index.py"; MJS="$HERE/symbol-index.mjs"; FX="$HERE/fixtures/symbol-index"
9
+ PASS=0; FAILN=0
10
+ TMP="$(mktemp -d "${TMPDIR:-/tmp}/symbol-index-test.XXXXXX")"; trap 'rm -rf "$TMP"' EXIT
11
+ ok() { PASS=$((PASS+1)); }
12
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
13
+ eq() { if [ "$2" = "$3" ]; then ok; else bad "$1 (got: $2 want: $3)"; fi }
14
+ q() { printf '%s' "$OUT" | jq -c "$1"; }
15
+ run() { OUT="$("$@" 2>"$TMP/err")"; RC=$?; }
16
+
17
+ # ---- 1. Python file: schema, symbols, spans, public flags ----
18
+ run python3 "$PY" "$FX/py/sample.py"
19
+ eq "py file rc" "$RC" 0
20
+ eq "py top-level keys" "$(q 'keys')" '["cycles","edges","imports","symbols"]'
21
+ eq "symbol keys" "$(q '[.symbols[]|keys]|unique')" '[["end","kind","lines","name","public","start"]]'
22
+ eq "symbol names" "$(q '[.symbols[].name]')" \
23
+ '["__all__","LIMIT","_cache","_normalise","greet","fetch","helper","Greeter","Greeter.__init__","Greeter.text","Greeter._shout","Greeter.shout"]'
24
+ eq "kinds" "$(q '[.symbols[]|select(.name=="LIMIT" or .name=="greet" or .name=="fetch" or .name=="Greeter" or .name=="Greeter.shout")|.kind]')" \
25
+ '["const","function","async_function","class","method"]'
26
+ eq "greet span" "$(q '.symbols[]|select(.name=="greet")|[.start,.end,.lines]')" '[15,16,2]'
27
+ eq "class span" "$(q '.symbols[]|select(.name=="Greeter")|[.start,.end,.lines]')" '[27,41,15]'
28
+ eq "decorator in span" "$(q '.symbols[]|select(.name=="Greeter.text")|[.start,.end,.lines]')" '[33,35,3]'
29
+ eq "public set" "$(q '[.symbols[]|select(.public)|.name]')" \
30
+ '["LIMIT","greet","fetch","Greeter","Greeter.__init__","Greeter.text","Greeter.shout"]'
31
+ eq "helper not in __all__" "$(q '.symbols[]|select(.name=="helper")|.public')" 'false'
32
+
33
+ # ---- 2. edges and imports ----
34
+ eq "edges" "$(q '[.edges[]|"\(.from)>\(.to)"]')" \
35
+ '["Greeter._shout>Greeter.text","Greeter.shout>Greeter._shout","Greeter.text>greet","fetch>LIMIT","fetch>_cache","greet>_normalise"]'
36
+ eq "imports" "$(q '.imports')" \
37
+ '[{"line":2,"module":"os","names":[]},{"line":3,"module":"collections","names":["OrderedDict"]}]'
38
+ eq "single file has no cycles" "$(q '.cycles')" '[]'
39
+
40
+ # ---- 3. never includes file bodies ----
41
+ for needle in SECRET_DOCSTRING_MARKER SECRET_CLASS_DOC_MARKER 'hi ' 'strip()' 'getcwd' 'return'; do
42
+ case "$OUT" in *"$needle"*) bad "output leaks source text: $needle" ;; *) ok ;; esac
43
+ done
44
+
45
+ # ---- 4. import cycles across modules ----
46
+ run python3 "$PY" "$FX/py/pkg"
47
+ eq "pkg rc" "$RC" 0
48
+ eq "pkg cycles (TYPE_CHECKING + function-local imports ignored)" "$(q '.cycles')" '[["pkg.a","pkg.b"]]'
49
+ eq "pkg relative import resolved" "$(q '[.imports[]|select(.file|endswith("b.py"))|.module]')" '["typing","pkg","pkg.a"]'
50
+ eq "pkg cross-module edges" "$(q '[.edges[]|"\(.from)>\(.to)"]')" '["pkg.a:run>pkg.b:step","pkg.b:step>pkg.c:value"]'
51
+ eq "pkg symbols carry file" "$(q '[.symbols[]|has("file")]|unique')" '[true]'
52
+ BEFORE="$(q '.cycles')"
53
+ cp -R "$FX/py/pkg" "$TMP/pkg"
54
+ printf 'from . import a\n' > "$TMP/pkg/d.py"
55
+ printf 'from . import d\n' >> "$TMP/pkg/c.py"
56
+ run python3 "$PY" "$TMP/pkg"
57
+ AFTER="$(q '.cycles')"
58
+ eq "after: cycle set grows" "$AFTER" '[["pkg.a","pkg.b","pkg.c","pkg.d"]]'
59
+ eq "before/after diff finds the new cycle" \
60
+ "$(jq -nc --argjson b "$BEFORE" --argjson a "$AFTER" '$a - $b')" '[["pkg.a","pkg.b","pkg.c","pkg.d"]]'
61
+ printf 'import pkg.e\n' > "$TMP/pkg/e.py"
62
+ run python3 "$PY" "$TMP/pkg"
63
+ eq "self-import is a cycle" "$(q '.cycles|map(select(.==["pkg.e"]))|length')" '1'
64
+
65
+ # ---- 5. deterministic ----
66
+ A1="$(python3 "$PY" "$FX/py/pkg")"; A2="$(python3 "$PY" "$FX/py/pkg")"
67
+ eq "byte-identical reruns" "$([ "$A1" = "$A2" ] && echo same)" same
68
+
69
+ # ---- 6. unsupported: exit 0, only {unsupported, reason} ----
70
+ for t in "$FX/other/sample.rb" "$FX/ts/sample.ts"; do
71
+ run python3 "$PY" "$t"
72
+ eq "py unsupported rc $(basename "$t")" "$RC" 0
73
+ eq "py unsupported keys $(basename "$t")" "$(q 'keys')" '["reason","unsupported"]'
74
+ done
75
+ eq "py unsupported lang" "$(python3 "$PY" "$FX/other/sample.rb" | jq -r .unsupported)" ruby
76
+ run python3 "$PY" "$FX/py/sample.py" "$FX/other/sample.rb"
77
+ eq "mixed input: no partial output" "$(q 'keys')" '["reason","unsupported"]'
78
+ mkdir -p "$TMP/empty"; run python3 "$PY" "$TMP/empty"
79
+ eq "dir without .py" "$(q '.')" '{"reason":"no-python-files","unsupported":"unknown"}'
80
+ mkdir -p "$TMP/broken"; printf 'def ok():\n return 1\n' > "$TMP/broken/good.py"; printf 'def broken(:\n' > "$TMP/broken/bad.py"
81
+ run python3 "$PY" "$TMP/broken"
82
+ eq "parse error rc" "$RC" 0
83
+ eq "parse error: no partial symbols" "$(q 'keys')" '["reason","unsupported"]'
84
+ eq "parse error reason" "$(q '.reason|startswith("parse-error: ")')" 'true'
85
+
86
+ run node "$MJS" "$FX/other/sample.rb"
87
+ eq "mjs ruby rc" "$RC" 0
88
+ eq "mjs ruby" "$(q '.')" '{"reason":"unknown-language","unsupported":"ruby"}'
89
+ mkdir -p "$TMP/ts"; cp "$FX/ts/sample.ts" "$TMP/ts/"
90
+ run node "$MJS" "$TMP/ts/sample.ts"
91
+ eq "mjs ts without parser rc" "$RC" 0
92
+ eq "mjs ts without parser" "$(q '.')" '{"reason":"no-parser","unsupported":"ts"}'
93
+ run node "$MJS" "$FX/py/sample.py"
94
+ eq "mjs delegates .py" "$(q '.symbols|length')" '12'
95
+
96
+ # ---- 7. usage ----
97
+ python3 "$PY" >/dev/null 2>&1; eq "py no args" "$?" 2
98
+ python3 "$PY" "$TMP/missing.py" >/dev/null 2>&1; eq "py missing path" "$?" 2
99
+ node "$MJS" >/dev/null 2>&1; eq "mjs no args" "$?" 2
100
+
101
+ # ---- 8. optional: TS parser path ----
102
+ if [ -n "${SYMBOL_INDEX_TS_NODE_MODULES:-}" ] && [ -d "$SYMBOL_INDEX_TS_NODE_MODULES/typescript" ]; then
103
+ ln -s "$SYMBOL_INDEX_TS_NODE_MODULES" "$TMP/ts/node_modules"
104
+ run node "$MJS" "$TMP/ts/sample.ts"
105
+ eq "ts symbols" "$(q '[.symbols[]|"\(.kind):\(.name):\(.start)-\(.end):\(.public)"]')" \
106
+ '["const:LIMIT:3-3:true","function:load:5-7:true","class:Loader:9-13:true","method:Loader.read:10-12:true"]'
107
+ eq "ts edges" "$(q '[.edges[]|"\(.from)>\(.to)"]')" '["Loader.read>load","load>LIMIT"]'
108
+ mkdir -p "$TMP/ts/lib"
109
+ printf 'import { b } from "./b";\nimport type { T } from "./c";\nexport function a(): number { return b(); }\n' > "$TMP/ts/lib/a.ts"
110
+ printf 'import * as A from "./a.js";\nexport const b = () => 2;\nexport function useA() { return A.a(); }\n' > "$TMP/ts/lib/b.ts"
111
+ printf 'import { a } from "./a";\nexport type T = number;\n' > "$TMP/ts/lib/c.ts"
112
+ run node "$MJS" "$TMP/ts/lib"
113
+ eq "ts cycles (import type ignored)" "$(q '.cycles')" '[["a","b"]]'
114
+ else
115
+ echo "symbol-index.test.sh: TS-parser cases skipped (set SYMBOL_INDEX_TS_NODE_MODULES)"
116
+ fi
117
+
118
+ echo "symbol-index.test.sh: $PASS passed, $FAILN failed"
119
+ [ "$FAILN" -eq 0 ]
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env bash
2
+ # symbol-move-ts.test.sh — cases for scripts/symbol-move.mjs, the ts-morph mover (forge#4212).
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-ts/gen_big.mjs (~700-line src/big.ts + node:test
6
+ # suite), moves four symbol groups into src/big_parts/ and proves the move with scripts/refactor-verify.sh; then
7
+ # checks the refusals, the --extend chain step and the no-ts-morph refusal (no partial edits).
8
+ # ts-morph: SYMBOL_MOVE_TS_NODE_MODULES=<node_modules holding ts-morph> (put on NODE_PATH for the mover, the symbol
9
+ # index and refactor-verify). Without it the ts-morph cases are skipped; SYMBOL_MOVE_REQUIRE_TS_MORPH=1 turns that
10
+ # skip into a failure.
11
+ set -uo pipefail
12
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
+ SM="$HERE/symbol-move.mjs"; RV="$HERE/refactor-verify.sh"; GEN="$HERE/fixtures/symbol-move-ts/gen_big.mjs"
14
+ PASS=0; FAILN=0
15
+ TMP="$(mktemp -d "${TMPDIR:-/tmp}/symbol-move-ts-test.XXXXXX")"; trap 'rm -rf "$TMP"' EXIT
16
+ ok() { PASS=$((PASS+1)); }
17
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
18
+ eq() { if [ "$2" = "$3" ]; then ok; else bad "$1 (got: $2 want: $3)"; fi }
19
+ has() { if grep -qF -- "$2" "$3"; then ok; else bad "$1 (no '$2' in $3)"; fi }
20
+ q() { printf '%s' "$OUT" | jq -c "$1"; }
21
+ TSM="${SYMBOL_MOVE_TS_NODE_MODULES:-}"
22
+ [ -n "$TSM" ] && [ -d "$TSM/ts-morph" ] || TSM=""
23
+ # without: no NODE_PATH and a HOME holding no node_modules, so ts-morph cannot resolve from anywhere
24
+ without() { OUT="$(cd "$R" && env -u NODE_PATH HOME="$TMP/home" node "$SM" "$@" 2>"$TMP/err")"; RC=$?; }
25
+ mover() { OUT="$(cd "$R" && NODE_PATH="$TSM" node "$SM" "$@" 2>"$TMP/err")"; RC=$?; }
26
+ mkdir -p "$TMP/home"
27
+
28
+ R="$TMP/repo"; B="$TMP/baseline.json"; P="$TMP/plan.json"
29
+ TEST_CMD='node --test test/big.test.ts'
30
+ g() { git -C "$R" -c user.name=t -c user.email=t@example.invalid "$@"; }
31
+ mkdir -p "$R"; git init -q "$R"
32
+ node "$GEN" "$R" || bad "generator failed"
33
+ g add -A && g commit -qm base && g tag base
34
+ eq "fixture size >= 600 lines" "$([ "$(wc -l < "$R/src/big.ts")" -ge 600 ] && echo yes || echo no)" yes
35
+ SHA0="$(git -C "$R" hash-object src/big.ts)"
36
+ reset() { g reset -q --hard base; g clean -fdq; }
37
+
38
+ seqp() { local f="$1" s=""; for i in $(seq -w 0 19); do s="$s\"$(printf "$f" "$i")\","; done; printf '%s' "${s%,}"; }
39
+ plan() { printf '%s' "$1" | jq -c . > "$2" || bad "bad plan json"; }
40
+ CONSTS="[$(seqp 'LIMIT_%s')]"
41
+ HELPERS="[$(for i in $(seq -w 0 19); do printf '"_scale_%s","compute_%s",' "$i" "$i"; done | sed 's/,$//')]"
42
+ MODELS="[$(seqp 'Item_%s'),$(seqp 'Store_%s')]"
43
+ AIO="[$(seqp 'fetch_%s')]"
44
+ FULL="{\"consts\":$CONSTS,\"helpers\":$HELPERS,\"models\":$MODELS,\"aio\":$AIO}"
45
+ plan "$FULL" "$P"
46
+
47
+ # refused <label>: rc 3, status refused, a reason, and nothing changed in the repo
48
+ refused() {
49
+ eq "$1: rc" "$RC" 3
50
+ eq "$1: status" "$(q '.status')" '"refused"'
51
+ eq "$1: has reason" "$(q '.reason|length>0')" true
52
+ eq "$1: tree untouched" "$(git -C "$R" status --porcelain)" ""
53
+ eq "$1: barrel untouched" "$(git -C "$R" hash-object src/big.ts)" "$SHA0"
54
+ }
55
+
56
+ # ---- 1. no codemod tool: ts-morph unresolvable -> clean refusal before anything is read or written ----
57
+ without --src src/big.ts --plan "$P"
58
+ refused "no ts-morph"
59
+ eq "no ts-morph: reason" "$(q '.reason|startswith("no codemod tool for ts: ts-morph is not resolvable")')" true
60
+ without --probe --src src/big.ts
61
+ eq "no ts-morph probe: rc" "$RC" 3
62
+ eq "no ts-morph probe: reason" "$(q '.reason|startswith("no codemod tool for ts:")')" true
63
+ eq "no ts-morph: no package dir" "$([ -e "$R/src/big_parts" ] && echo yes || echo no)" no
64
+
65
+ # ---- 2. usage ----
66
+ OUT="$(node "$SM" --plan "$P" 2>/dev/null)"; eq "usage: rc" "$?" 2
67
+ OUT="$(node "$SM" --src "$R/src/nope.ts" --plan "$P" 2>/dev/null)"; eq "missing src: rc" "$?" 2
68
+
69
+ if [ -z "$TSM" ]; then
70
+ if [ "${SYMBOL_MOVE_REQUIRE_TS_MORPH:-0}" = "1" ]; then bad "ts-morph required (SYMBOL_MOVE_REQUIRE_TS_MORPH=1) but SYMBOL_MOVE_TS_NODE_MODULES has no ts-morph"; fi
71
+ echo "symbol-move-ts.test.sh: ts-morph cases skipped (set SYMBOL_MOVE_TS_NODE_MODULES)"
72
+ echo "symbol-move-ts.test.sh: $PASS passed, $FAILN failed"
73
+ [ "$FAILN" -eq 0 ]; exit
74
+ fi
75
+ export NODE_PATH="$TSM"
76
+
77
+ # ---- 3. probe and dry run ----
78
+ mover --probe --src src/big.ts
79
+ eq "probe: rc" "$RC" 0
80
+ eq "probe: tool" "$(q '.tool')" '"ts-morph"'
81
+ mover --src src/big.ts --plan "$P" --dry-run
82
+ eq "dry-run: rc" "$RC" 0
83
+ eq "dry-run: tree untouched" "$(git -C "$R" status --porcelain)" ""
84
+
85
+ # ---- 4. the move: four groups into src/big_parts/, barrel at the old path, refactor-verify ok ----
86
+ bash "$RV" baseline --root "$R" --target src/big.ts --test-cmd "$TEST_CMD" --out "$B" >/dev/null 2>"$TMP/err" || { cat "$TMP/err"; bad "baseline failed"; }
87
+ eq "baseline counts" "$(jq -c .test "$B")" '{"exit":0,"passed":5,"skipped":1}'
88
+ eq "baseline flags erased types" "$(jq -c '[.symbols[]|select(.type)]|length' "$B")" 20
89
+ mover --src src/big.ts --plan "$P"
90
+ eq "move: rc" "$RC" 0
91
+ eq "move: status" "$(q '.status')" '"ok"'
92
+ eq "move: package" "$(q '.package')" '"src/big_parts"'
93
+ eq "move: modules" "$(q '.modules|keys')" '["aio","consts","helpers","models"]'
94
+ for m in consts helpers models aio; do
95
+ eq "module $m exists" "$([ -f "$R/src/big_parts/$m.ts" ] && echo yes || echo no)" yes
96
+ has "barrel re-exports $m" "} from \"./big_parts/$m.ts\";" "$R/src/big.ts"
97
+ done
98
+ has "barrel re-exports types with type" 'export { type Item_00, Store_00,' "$R/src/big.ts"
99
+ has "barrel imports names kept code uses" 'import { compute_00 } from "./big_parts/helpers.ts";' "$R/src/big.ts"
100
+ has "barrel keeps unmoved code" 'function _shared(x: number): number {' "$R/src/big.ts"
101
+ has "barrel keeps the header" '// SPDX-License-Identifier: MIT' "$R/src/big.ts"
102
+ has "relative import re-based" 'import { clamp } from "../util.ts";' "$R/src/big_parts/helpers.ts"
103
+ has "type import pruned and re-based" 'import { type Unit } from "../util.ts";' "$R/src/big_parts/models.ts"
104
+ has "sibling import" 'import { compute_00, compute_01,' "$R/src/big_parts/models.ts"
105
+ has "private helper moved verbatim" 'function _scale_07(x: number): number {' "$R/src/big_parts/helpers.ts"
106
+ has "doc comment moved" '/** compute 7: scaled and clamped. */' "$R/src/big_parts/helpers.ts"
107
+ eq "no moved declaration left" "$(grep -c '^export function compute_\|^function _scale_\|^export class Store_\|^export interface Item_' "$R/src/big.ts")" 0
108
+ g add -A && g commit -qm move
109
+ OUT="$(bash "$RV" verify --baseline "$B" --base base 2>"$TMP/err")"; RC=$?
110
+ eq "verify: rc" "$RC" 0
111
+ eq "verify: ok" "$(q '.ok')" true
112
+ # a dropped type re-export is caught statically
113
+ sed -i.bak 's/export { type Item_00, Store_00, /export { Store_00, /' "$R/src/big.ts"; rm -f "$R/src/big.ts.bak"
114
+ g commit -qam drop-type
115
+ OUT="$(bash "$RV" verify --baseline "$B" --base base 2>"$TMP/err")"; RC=$?
116
+ eq "dropped type: rc" "$RC" 1
117
+ eq "dropped type: missing" "$(q '[.failures[]|select(.kind=="missing-symbol")|.name]')" '["Item_00"]'
118
+ reset
119
+
120
+ # ---- 5. refusals: nothing touched ----
121
+ plan '{"a":["nope"]}' "$P"; mover --src src/big.ts --plan "$P"; refused "unknown symbol"
122
+ plan '{"a":["LIMIT_00"],"b":["LIMIT_00"]}' "$P"; mover --src src/big.ts --plan "$P"; refused "listed twice"
123
+ plan '{"a":["Store_00.add"]}' "$P"; mover --src src/big.ts --plan "$P"; refused "method"
124
+ plan '{"index":["LIMIT_00"]}' "$P"; mover --src src/big.ts --plan "$P"; refused "bad module name"
125
+ plan '{"a":["compute_00"]}' "$P"; mover --src src/big.ts --plan "$P"; refused "needs a kept name"
126
+ eq "needs a kept name: reason" "$(q '.reason|test("needs \"_scale_00\", which stays")')" true
127
+ plan '{"a":["_shared"]}' "$P"; mover --src src/big.ts --plan "$P"; refused "private used by kept code"
128
+ eq "private used by kept code: reason" "$(q '.reason|test("not exported but kept code uses it")')" true
129
+ plan '{"a":["_scale_00","LIMIT_00"],"b":["compute_00"]}' "$P"; mover --src src/big.ts --plan "$P"; refused "private used by a sibling"
130
+ eq "private used by a sibling: reason" "$(q '.reason|test("not exported")')" true
131
+ plan '{"a":["ping"],"b":["pong"]}' "$P"; mover --src src/big.ts --plan "$P"; refused "cycle"
132
+ eq "cycle: reason" "$(q '.reason|test("import cycle")')" true
133
+ mkdir -p "$R/src/big_parts"; plan "{\"consts\":$CONSTS}" "$P"; mover --src src/big.ts --plan "$P"
134
+ eq "existing package: rc" "$RC" 3
135
+ eq "existing package: reason" "$(q '.reason|test("already exists")')" true
136
+ rmdir "$R/src/big_parts"
137
+ printf 'export default function main(): number {\n return 1;\n}\n' >> "$R/src/big.ts"; g commit -qam default; SHA0="$(git -C "$R" hash-object src/big.ts)"
138
+ plan '{"a":["main"]}' "$P"; mover --src src/big.ts --plan "$P"; refused "export default"
139
+ printf 'export const HERE = import.meta.url;\n' >> "$R/src/big.ts"; g commit -qam meta; SHA0="$(git -C "$R" hash-object src/big.ts)"
140
+ plan '{"a":["HERE"]}' "$P"; mover --src src/big.ts --plan "$P"; refused "import.meta"
141
+ reset; SHA0="$(git -C "$R" hash-object src/big.ts)"
142
+
143
+ # ---- 6. chain: a second step adds modules to the existing package with --extend ----
144
+ plan "{\"consts\":$CONSTS,\"helpers\":$HELPERS}" "$P"; mover --src src/big.ts --plan "$P"
145
+ eq "chain step 1: rc" "$RC" 0
146
+ g add -A && g commit -qm step1
147
+ plan "{\"models\":$MODELS}" "$P"; mover --src src/big.ts --plan "$P" --package big_parts
148
+ eq "chain without --extend: rc" "$RC" 3
149
+ plan "{\"models\":$MODELS}" "$P"; mover --src src/big.ts --plan "$P" --package big_parts --extend
150
+ eq "chain step 2: rc" "$RC" 0
151
+ has "chain step 2: barrel" '} from "./big_parts/models.ts";' "$R/src/big.ts"
152
+ has "chain step 2: earlier module imported in-package" '} from "./helpers.ts";' "$R/src/big_parts/models.ts"
153
+ g add -A && g commit -qm step2
154
+ OUT="$(bash "$RV" verify --baseline "$B" --base base 2>"$TMP/err")"; RC=$?
155
+ eq "chain verify: ok" "$(q '.ok')" true
156
+ plan "{\"models\":[\"fetch_00\"]}" "$P"; mover --src src/big.ts --plan "$P" --package big_parts --extend
157
+ eq "chain existing module: rc" "$RC" 3
158
+ eq "chain existing module: reason" "$(q '.reason|test("module already exists")')" true
159
+
160
+ echo "symbol-move-ts.test.sh: $PASS passed, $FAILN failed"
161
+ [ "$FAILN" -eq 0 ]