forgedock 1.29.1 → 1.31.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 (166) hide show
  1. package/bin/engine/outcome-codes.mjs +5 -2
  2. package/bin/engine/phases.mjs +20 -3
  3. package/bin/engine/runlog.mjs +82 -12
  4. package/bin/engine/telemetry-seam.mjs +11 -4
  5. package/bin/engine/telemetry-seam.test.mjs +18 -0
  6. package/bin/engine-cli.mjs +453 -66
  7. package/bin/engine.mjs +50 -38
  8. package/bin/forgedock.mjs +35 -3
  9. package/bin/gc.mjs +50 -17
  10. package/bin/labels.json +5 -0
  11. package/bin/lean/apishape.mjs +170 -0
  12. package/bin/lean/apishape.test.mjs +187 -0
  13. package/bin/lean/batch.mjs +32 -0
  14. package/bin/lean/batch.test.mjs +356 -2
  15. package/bin/lean/budget.mjs +89 -0
  16. package/bin/lean/budget.test.mjs +106 -0
  17. package/bin/lean/checks.mjs +32 -9
  18. package/bin/lean/checks.test.mjs +78 -1
  19. package/bin/lean/conformance.e2e.test.mjs +27 -5
  20. package/bin/lean/conformance.mjs +278 -22
  21. package/bin/lean/conformance.test.mjs +251 -5
  22. package/bin/lean/context.mjs +133 -55
  23. package/bin/lean/context.test.mjs +175 -9
  24. package/bin/lean/decompose.mjs +90 -0
  25. package/bin/lean/decompose.test.mjs +105 -0
  26. package/bin/lean/depcache.mjs +11 -7
  27. package/bin/lean/depcache.test.mjs +81 -0
  28. package/bin/lean/e2e.mjs +102 -0
  29. package/bin/lean/e2e.test.mjs +86 -0
  30. package/bin/lean/effects.mjs +55 -19
  31. package/bin/lean/enumerating.mjs +189 -0
  32. package/bin/lean/enumerating.test.mjs +97 -0
  33. package/bin/lean/explore.mjs +47 -0
  34. package/bin/lean/explore.test.mjs +61 -0
  35. package/bin/lean/facts.mjs +84 -0
  36. package/bin/lean/facts.test.mjs +66 -0
  37. package/bin/lean/fixround.mjs +90 -0
  38. package/bin/lean/fixround.test.mjs +117 -0
  39. package/bin/lean/heavy.mjs +35 -2
  40. package/bin/lean/heavy.test.mjs +32 -2
  41. package/bin/lean/lean.mjs +799 -127
  42. package/bin/lean/lean.test.mjs +1749 -37
  43. package/bin/lean/ledger.mjs +5 -3
  44. package/bin/lean/ledger.test.mjs +18 -0
  45. package/bin/lean/light.mjs +135 -0
  46. package/bin/lean/light.test.mjs +173 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +341 -99
  49. package/bin/lean/pitfalls.mjs +136 -0
  50. package/bin/lean/pitfalls.test.mjs +117 -0
  51. package/bin/lean/postmerge.mjs +85 -0
  52. package/bin/lean/postmerge.test.mjs +79 -0
  53. package/bin/lean/precheck.mjs +180 -0
  54. package/bin/lean/precheck.test.mjs +205 -0
  55. package/bin/lean/prefilter.mjs +180 -9
  56. package/bin/lean/prefilter.test.mjs +175 -3
  57. package/bin/lean/preflight.mjs +238 -0
  58. package/bin/lean/preflight.test.mjs +108 -0
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +134 -12
  62. package/bin/lean/recovery.test.mjs +75 -0
  63. package/bin/lean/render.mjs +7 -3
  64. package/bin/lean/resume.mjs +67 -0
  65. package/bin/lean/resume.test.mjs +84 -0
  66. package/bin/lean/review-trigger.mjs +12 -0
  67. package/bin/lean/review-trigger.test.mjs +17 -1
  68. package/bin/lean/routing.mjs +74 -0
  69. package/bin/lean/routing.test.mjs +67 -0
  70. package/bin/lean/sched.test.mjs +127 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +149 -3
  73. package/bin/lean/selfreview.mjs +37 -0
  74. package/bin/lean/selfreview.test.mjs +51 -0
  75. package/bin/lean/seqlease.mjs +40 -0
  76. package/bin/lean/seqlease.test.mjs +101 -0
  77. package/bin/lean/sequence.mjs +385 -0
  78. package/bin/lean/sequence.test.mjs +373 -0
  79. package/bin/lean/testmap.mjs +43 -6
  80. package/bin/lean/testmap.test.mjs +38 -1
  81. package/bin/lean/toolchain.mjs +60 -15
  82. package/bin/lean/toolchain.test.mjs +120 -3
  83. package/bin/lean/toolstats.mjs +72 -0
  84. package/bin/lean/toolstats.test.mjs +47 -0
  85. package/bin/lean/verifier.mjs +232 -0
  86. package/bin/lean/verifier.test.mjs +163 -0
  87. package/bin/lean/verify.mjs +77 -10
  88. package/bin/lean/verify.test.mjs +74 -3
  89. package/bin/review-staging.mjs +533 -0
  90. package/bin/runner.mjs +82 -13
  91. package/bin/stats/aggregate.mjs +87 -2
  92. package/bin/stats/efficiency.mjs +319 -0
  93. package/bin/stats/efficiency.test.mjs +233 -0
  94. package/bin/stats/github-source.mjs +8 -3
  95. package/bin/stats/index.mjs +69 -6
  96. package/bin/stats/local-source.mjs +86 -3
  97. package/bin/stats/query.mjs +48 -0
  98. package/bin/stats/render.mjs +22 -0
  99. package/bin/stats/stats.test.mjs +224 -5
  100. package/commands/orchestrate.md +6 -2
  101. package/commands/review-pr-agents/protocols.md +1 -1
  102. package/commands/review-pr-staging.md +84 -831
  103. package/commands/review-pr.md +1 -1
  104. package/commands/work-on/build/implement.md +1 -0
  105. package/commands/work-on/build/validate.md +11 -1
  106. package/docs/CONFIG.md +88 -6
  107. package/docs/OUTCOME-CODES.md +7 -4
  108. package/docs/RELEASE-NOTES.md +10 -0
  109. package/docs/STATS.md +53 -1
  110. package/docs/TELEMETRY.md +1 -1
  111. package/docs/spec/review-protocol.md +1 -1
  112. package/package.json +1 -1
  113. package/scripts/acceptance-red-green.sh +13 -4
  114. package/scripts/acceptance-red-green.test.sh +14 -0
  115. package/scripts/ci-local/lib.mjs +20 -0
  116. package/scripts/ci-local/plan.mjs +2 -12
  117. package/scripts/ci-local/scoping.mjs +10 -1
  118. package/scripts/ci-local.test.sh +2 -0
  119. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  120. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  121. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  122. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  123. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  124. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  125. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  126. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  127. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  128. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  129. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  130. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  131. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  132. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  133. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  134. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  135. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  136. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  137. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  139. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  140. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  144. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  145. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  146. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  147. package/scripts/forge-root.test.sh +2 -2
  148. package/scripts/publish-staging-sync.test.sh +62 -4
  149. package/scripts/review-finalize.sh +17 -6
  150. package/scripts/review-finalize.test.sh +47 -0
  151. package/scripts/review-pr-arguments.test.sh +13 -1
  152. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  153. package/scripts/review-pr-gate2.test.sh +6 -14
  154. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  155. package/scripts/review-pr-staging-scope.test.sh +39 -33
  156. package/scripts/review-prepare.sh +73 -6
  157. package/scripts/review-prepare.test.sh +85 -0
  158. package/scripts/review-staging-post.sh +293 -0
  159. package/scripts/review-staging-post.test.sh +143 -0
  160. package/scripts/review-staging-pre.sh +401 -0
  161. package/scripts/review-staging-pre.test.sh +150 -0
  162. package/scripts/symbol-index.mjs +26 -7
  163. package/scripts/symbol-index.py +55 -9
  164. package/scripts/test-map.sh +63 -17
  165. package/scripts/test-map.test.sh +55 -0
  166. package/scripts/trusted-comments.test.sh +1 -1
@@ -8,13 +8,17 @@
8
8
  // run prints only {"unsupported":"ts"|"js","reason":"no-parser"} and exits 0.
9
9
  // .py targets are handed to symbol-index.py. Never emits source text or literal values.
10
10
  //
11
- // Usage: node scripts/symbol-index.mjs <file | dir>...
11
+ // Usage: node scripts/symbol-index.mjs [--shape] <file | dir>...
12
12
  // Output: {"symbols":[{name,kind,start,end,lines,public[,file]}], "edges":[{from,to}],
13
13
  // "imports":[{module,names,line[,file]}], "cycles":[[module,...],...]}
14
14
  // kind: function|async_function|class|method|const|interface|type|enum. public = exported (methods: exported class
15
15
  // and not private/#private). One file: bare names; otherwise `file` on entries and "<module>:<symbol>" edge ends,
16
16
  // module = path relative to its root without extension (index files name their directory). cycles = strongly
17
17
  // connected components of the relative-import graph between indexed modules (`import type` ignored).
18
+ // --shape (forge#4449, bin/lean/apishape.mjs): functions, methods/accessors/constructors and consts initialised with
19
+ // an arrow or function expression also carry async, generator (`function*`) and sig: the normalized parameter list
20
+ // (`...` rest, name, `?` optional, `: <type text>`, `=` for a default but never its value) plus " -> <return type>".
21
+ // Kinds are unchanged; without --shape the output is unchanged. --shape is forwarded to symbol-index.py.
18
22
  // Exit: 0 ok or unsupported | 2 usage or missing path.
19
23
 
20
24
  import { spawnSync } from "node:child_process";
@@ -94,7 +98,18 @@ function tarjan(graph) {
94
98
  return comps;
95
99
  }
96
100
 
97
- function index(ts, entries, multi) {
101
+ function fnShape(ts, fn, src) {
102
+ const K = ts.SyntaxKind;
103
+ const params = fn.parameters.map((p) => `${p.dotDotDotToken ? "..." : ""}${p.name.getText(src)}${p.questionToken ? "?" : ""}`
104
+ + `${p.type ? `: ${p.type.getText(src)}` : ""}${p.initializer ? "=" : ""}`);
105
+ return {
106
+ async: (ts.getModifiers?.(fn) ?? fn.modifiers ?? []).some((m) => m.kind === K.AsyncKeyword),
107
+ generator: !!fn.asteriskToken,
108
+ sig: `(${params.join(", ")})${fn.type ? ` -> ${fn.type.getText(src)}` : ""}`,
109
+ };
110
+ }
111
+
112
+ function index(ts, entries, multi, shape = false) {
98
113
  const K = ts.SyntaxKind;
99
114
  const byFile = new Map(entries.map((e) => [path.resolve(e.file), e]));
100
115
  const symbols = [], imports = [], edges = new Set(), graph = new Map(entries.map((e) => [e.module, new Set()]));
@@ -114,7 +129,9 @@ function index(ts, entries, multi) {
114
129
  tables.set(e.module, table);
115
130
  const add = (name, kind, node, pub, owner = null) => {
116
131
  const start = line(src, node.getStart(src)), end = line(src, node.getEnd());
117
- symbols.push({ name, kind, start, end, lines: end - start + 1, public: !!pub, ...(multi ? { file: e.file } : {}) });
132
+ const fn = !shape ? null : ts.isFunctionLike(node) ? node
133
+ : ts.isVariableDeclaration(node) && node.initializer && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)) ? node.initializer : null;
134
+ symbols.push({ name, kind, start, end, lines: end - start + 1, public: !!pub, ...(multi ? { file: e.file } : {}), ...(fn ? fnShape(ts, fn, src) : {}) });
118
135
  table.set(name, { node, owner });
119
136
  };
120
137
  const exportNames = new Set();
@@ -218,9 +235,11 @@ function index(ts, entries, multi) {
218
235
  };
219
236
  }
220
237
 
221
- function main(argv) {
238
+ function main(args) {
239
+ const shape = args.includes("--shape");
240
+ const argv = args.filter((a) => a !== "--shape");
222
241
  if (!argv.length || argv[0] === "-h" || argv[0] === "--help") {
223
- process.stderr.write("usage: symbol-index.mjs <file | dir>...\n");
242
+ process.stderr.write("usage: symbol-index.mjs [--shape] <file | dir>...\n");
224
243
  return 2;
225
244
  }
226
245
  for (const p of argv) if (!existsSync(p)) { process.stderr.write(`symbol-index: no such path: ${p}\n`); return 2; }
@@ -229,7 +248,7 @@ function main(argv) {
229
248
  if (exts.includes(".py")) {
230
249
  if (exts.some((x) => x !== ".py")) return emit({ unsupported: "mixed", reason: "mixed-languages" }), 0;
231
250
  const py = path.join(path.dirname(fileURLToPath(import.meta.url)), "symbol-index.py");
232
- const r = spawnSync("python3", [py, ...argv], { stdio: "inherit" });
251
+ const r = spawnSync("python3", [py, ...(shape ? ["--shape"] : []), ...argv], { stdio: "inherit" });
233
252
  if (r.error) return emit({ unsupported: "python", reason: "no-python3" }), 0;
234
253
  return r.status ?? 2;
235
254
  }
@@ -242,7 +261,7 @@ function main(argv) {
242
261
  if (!ts) return emit({ unsupported: lang, reason: "no-parser" }), 0;
243
262
  const multi = !(argv.length === 1 && files.length === 1);
244
263
  let out;
245
- try { out = index(ts, entries, multi); } catch (err) {
264
+ try { out = index(ts, entries, multi, shape); } catch (err) {
246
265
  if (err && err.reason) return emit({ unsupported: err.lang, reason: err.reason }), 0;
247
266
  throw err;
248
267
  }
@@ -5,13 +5,16 @@
5
5
  # symbol-index.py: deterministic Python symbol index + module import-cycle detector for refactor:split (forge#4208).
6
6
  # stdlib only (ast). No LLM, no network. Never emits source text, docstrings or literal values.
7
7
  #
8
- # Usage: python3 scripts/symbol-index.py <file.py | dir>...
8
+ # Usage: python3 scripts/symbol-index.py [--shape] <file.py | dir>...
9
9
  # Output (stdout, one JSON object):
10
10
  # {"symbols":[{name,kind,start,end,lines,public[,file]}], "edges":[{from,to}],
11
11
  # "imports":[{module,names,line[,file]}], "cycles":[[module,...],...]}
12
12
  # kind: function|async_function|class|method|const. Methods (and nested classes) are named Class.method.
13
13
  # start includes decorators; lines = end - start + 1. public = no leading underscore (method dunders count as
14
14
  # public), and listed in a static __all__ when the module defines one.
15
+ # --shape (forge#4449, bin/lean/apishape.mjs): function/async_function/method entries also carry async (bool),
16
+ # generator (yield in the def's own scope) and sig: the normalized parameter list (kinds, names, annotation text,
17
+ # "=" for a default but never its value) plus " -> <return annotation>". Without --shape the output is unchanged.
15
18
  # One .py file: names are bare. Otherwise (a directory or several files) each entry carries `file` and edge ends
16
19
  # are "<module>:<symbol>"; module names are dotted paths relative to each root (a root that has __init__.py is
17
20
  # itself the top package, so root .../pkg yields pkg.a). edges = references from one indexed symbol to another.
@@ -108,9 +111,47 @@ def module_imports(body):
108
111
  return out
109
112
 
110
113
 
114
+ def is_generator(fn):
115
+ """True when yield / yield from occurs in fn's own scope (not in nested defs, lambdas or classes)."""
116
+ work = list(ast.iter_child_nodes(fn))
117
+ while work:
118
+ n = work.pop()
119
+ if isinstance(n, (ast.Yield, ast.YieldFrom)):
120
+ return True
121
+ if not isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)):
122
+ work.extend(ast.iter_child_nodes(n))
123
+ return False
124
+
125
+
126
+ def signature(fn):
127
+ """Normalized parameter list of a def: names, kinds, annotation text, '=' for a default (never its value)."""
128
+ a = fn.args
129
+
130
+ def one(arg, default=False, prefix=""):
131
+ out = prefix + arg.arg
132
+ if arg.annotation is not None:
133
+ out += ": " + ast.unparse(arg.annotation)
134
+ return out + ("=" if default else "")
135
+
136
+ positional = a.posonlyargs + a.args
137
+ first_default = len(positional) - len(a.defaults)
138
+ parts = [one(x, i >= first_default) for i, x in enumerate(positional)]
139
+ if a.posonlyargs:
140
+ parts.insert(len(a.posonlyargs), "/")
141
+ if a.vararg is not None:
142
+ parts.append(one(a.vararg, prefix="*"))
143
+ elif a.kwonlyargs:
144
+ parts.append("*")
145
+ parts += [one(x, d is not None) for x, d in zip(a.kwonlyargs, a.kw_defaults)]
146
+ if a.kwarg is not None:
147
+ parts.append(one(a.kwarg, prefix="**"))
148
+ sig = "(" + ", ".join(parts) + ")"
149
+ return sig + (" -> " + ast.unparse(fn.returns) if fn.returns is not None else "")
150
+
151
+
111
152
  class Module:
112
- def __init__(self, path, name, is_pkg):
113
- self.path, self.name, self.is_pkg = path, name, is_pkg
153
+ def __init__(self, path, name, is_pkg, shape=False):
154
+ self.path, self.name, self.is_pkg, self.shape = path, name, is_pkg, shape
114
155
  self.symbols = [] # dicts without file
115
156
  self.nodes = {} # symbol name -> (ast node, owning class name or None)
116
157
  self.imports = [] # (ast import node)
@@ -131,8 +172,11 @@ class Module:
131
172
 
132
173
  def add(name, kind, node, public, owner=None):
133
174
  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})
175
+ sym = {"name": name, "kind": kind, "start": start, "end": end, "lines": end - start + 1, "public": public}
176
+ if self.shape and isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
177
+ sym.update({"async": isinstance(node, ast.AsyncFunctionDef), "generator": is_generator(node),
178
+ "sig": signature(node)})
179
+ self.symbols.append(sym)
136
180
  self.nodes[name] = (node, owner)
137
181
 
138
182
  def add_class(node, prefix, public):
@@ -241,12 +285,12 @@ def tarjan(graph):
241
285
  return comps
242
286
 
243
287
 
244
- def build(paths):
288
+ def build(paths, shape=False):
245
289
  entries = walk_roots(paths)
246
290
  multi = not (len(entries) == 1 and os.path.isfile(paths[0]) and len(paths) == 1)
247
291
  modules, by_name = [], {}
248
292
  for path, name, is_pkg in entries:
249
- m = Module(path, name, is_pkg)
293
+ m = Module(path, name, is_pkg, shape)
250
294
  m.parse()
251
295
  modules.append(m)
252
296
  by_name.setdefault(name, m)
@@ -335,15 +379,17 @@ def build(paths):
335
379
 
336
380
 
337
381
  def main(argv):
382
+ shape = "--shape" in argv
383
+ argv = [a for a in argv if a != "--shape"]
338
384
  if not argv or argv[0] in ("-h", "--help"):
339
- sys.stderr.write("usage: symbol-index.py <file.py | dir>...\n")
385
+ sys.stderr.write("usage: symbol-index.py [--shape] <file.py | dir>...\n")
340
386
  return 2
341
387
  for p in argv:
342
388
  if not os.path.exists(p):
343
389
  sys.stderr.write("symbol-index: no such path: %s\n" % p)
344
390
  return 2
345
391
  try:
346
- out = build(argv)
392
+ out = build(argv, shape)
347
393
  except Unsupported as u:
348
394
  out = {"unsupported": u.lang, "reason": u.reason}
349
395
  sys.stdout.write(json.dumps(out, sort_keys=True, separators=(",", ":")) + "\n")
@@ -27,6 +27,12 @@
27
27
  # substitution, `${{ }}`, `$GITHUB_*`, redirect or `<(...)`/`>(...)`, a non-check make target, or a fixer form
28
28
  # (`--fix`, `--write`, `-w`, `black`/`ruff format`/`gofmt`/`prettier` without their check flag) drops the step (forge#4316). Globs from the tool (python/go/shell/js, under the cwd), else the
29
29
  # workflow's `paths:` filter, else `DIR/**` for a cwd; a workflow entry never gets a bare `**`.
30
+ # forge#4318: also dropped: any quote, fixer/snapshot flag families (`--fix-only`, `--add-noqa`, `--write=*`, `--w`,
31
+ # `--update*`, `--updateSnapshot`, `--snapshot-update`, `-u` on jest/vitest/package managers), write targets (`go -o`,
32
+ # `-exec`, `-toolexec`, `tsc --outDir/--outFile`, `pytest --junitxml/--basetemp/--html`, any absolute or `..` path) and
33
+ # tox/nox `dist`/`pypi`/`twine` envs in any spelling; `-w` is a workspace on npm/pnpm. Also mapped: `uv run python -m X`,
34
+ # `python3.N -m X`, `npx --yes X`, `pnpm --filter X`, `npm -w PKG`, `make -C DIR` (DIR becomes the cwd), `cargo +TC <sub>`,
35
+ # `cargo fmt --check`, gradle `--tests X` / maven `-pl X`, `./x.test.sh`, `bash -x x.test.sh`. `clean` goals stay unmapped.
30
36
  # Exit: 0 ok | 2 usage or bad repo path. bash 3.2 portable.
31
37
  set -uo pipefail
32
38
 
@@ -207,6 +213,8 @@ check_cmd() {
207
213
  esac
208
214
  c=${c%" && popd"}
209
215
  case "$c" in *';'*|*'|'*|*'&'*) return 1 ;; esac
216
+ # forge#4318: a quoted word hides a flag from the word checks below ("--fix"); a real check never needs quotes
217
+ case "$c" in *\"*|*\'*) return 1 ;; esac
210
218
  CK_CMD=$c
211
219
  set -f
212
220
  # shellcheck disable=SC2086 # split into words on purpose; set -f stops glob expansion
@@ -214,11 +222,24 @@ check_cmd() {
214
222
  set +f
215
223
  while [ $# -gt 0 ]; do case "$1" in [A-Za-z_]*=*) shift ;; *) break ;; esac; done
216
224
  case "${1:-} ${2:-}" in
217
- "uv run"|"poetry run"|"pnpm exec"|"yarn exec"|"python -m"|"python3 -m"|"bundle exec") shift 2 ;;
218
- "npx "*|"bunx "*) shift ;;
225
+ "uv run"|"poetry run") shift 2; case "${1:-} ${2:-}" in "python -m"|"python3 -m") shift 2 ;; esac ;; # forge#4318: uv run python -m X
226
+ "pnpm exec"|"yarn exec"|"python -m"|"python3 -m"|"bundle exec") shift 2 ;;
227
+ "python3."[0-9]" -m"|"python3."[0-9][0-9]" -m") shift 2 ;; # forge#4318: a versioned interpreter
228
+ "npx "*|"bunx "*) shift; case "${1:-}" in --yes|-y) shift ;; esac ;;
219
229
  esac
220
- # forge#4316: a fixer rewrites the tree; only its read-only check form is a check
221
- for w in "$@"; do case "$w" in --fix|--fix=*|--write|-w) return 1 ;; esac; done
230
+ tool=${1:-}
231
+ # forge#4316/#4318: a fixer, snapshot updater or write-target flag rewrites the tree or writes outside it; matched as a
232
+ # flag family (`--fix-only`, `--write=true`, `--w`), and `-w` only off a package manager (there it names a workspace)
233
+ for w in "$@"; do
234
+ case "$w" in
235
+ --fix|--fix[=-]*|--add-noqa*|--unsafe-fixes*|--write|--write[=-]*|--update|--update[=-]*|--updateSnapshot*|--snapshot-update*) return 1 ;;
236
+ --outDir*|--outdir*|--out-dir*|--outFile*|--declarationDir*|--tsBuildInfoFile*|--junitxml*|--junit-xml*|--basetemp*|--html*|-exec|-exec=*|-toolexec*) return 1 ;;
237
+ /*|*=/*|..|../*|*/..|*/../*|*=..|*=../*) return 1 ;; # an absolute or parent path is outside the checkout
238
+ -w|-w=*|--w|--w=*) case "$tool" in npm|pnpm|yarn|bun) ;; *) return 1 ;; esac ;;
239
+ -u) case "$tool" in jest|vitest|npm|pnpm|yarn|bun) return 1 ;; esac ;;
240
+ -o|-o=*) [ "$tool" = go ] && return 1 ;;
241
+ esac
242
+ done
222
243
  case "${1:-} ${2:-}" in
223
244
  "black "*|"ruff format") has_word --check "$@" || return 1 ;;
224
245
  "gofmt "*) has_word -l "$@" || has_word -d "$@" || return 1 ;;
@@ -227,21 +248,26 @@ check_cmd() {
227
248
  case "${1:-}" in
228
249
  npm|pnpm|yarn|bun)
229
250
  pm=$1; shift
230
- case "$pm ${1:-}" in # forge#4315: pnpm -r test, npm --prefix DIR test
231
- "pnpm -r"|"pnpm --recursive") shift ;;
232
- "npm --prefix") [ $# -ge 2 ] && safe_dir "$2" || return 1; shift 2 ;;
251
+ case "$pm ${1:-}" in # forge#4315: pnpm -r test, npm --prefix DIR test; forge#4318: npm -w PKG, pnpm -w, pnpm --filter X
252
+ "pnpm -r"|"pnpm --recursive"|"pnpm -w"|"pnpm --workspace-root") shift ;;
253
+ "npm --prefix"|"npm -w"|"npm --workspace") [ $# -ge 2 ] && safe_dir "$2" || return 1; shift 2 ;;
254
+ "pnpm --filter"|"pnpm -F") [ $# -ge 2 ] || return 1; case "$2" in *[!A-Za-z0-9@._/-]*) return 1 ;; esac; shift 2 ;;
233
255
  esac
234
256
  [ "${1:-}" = test ] && return 0
235
257
  [ "${1:-}" = run ] && pkg_check_script "${2:-}" && return 0
236
258
  case "$pm" in yarn|pnpm) pkg_check_script "${1:-}" && return 0 ;; esac
237
259
  return 1 ;;
238
- cargo) case "${2:-}" in test|clippy|check) return 0 ;; esac; return 1 ;;
260
+ cargo)
261
+ a=${2:-}; [ "${a#+}" != "$a" ] && a=${3:-} # forge#4318: cargo +TOOLCHAIN <sub>
262
+ case "$a" in test|clippy|check) return 0 ;; fmt) has_word --check "$@"; return ;; esac; return 1 ;;
239
263
  ./gradlew|gradlew|gradle|./mvnw|mvnw|mvn)
240
- # every non-flag word is a check task/goal (`:mod:test` qualified for gradle); publish/deploy/install never pass
264
+ # every non-flag word is a check task/goal (`:mod:test` qualified for gradle); publish/deploy/install/clean never pass;
265
+ # forge#4318: the value of --tests / -pl / --projects / -P / -D is not a goal
241
266
  case "$1" in *mvn*) goals="test|verify|validate" ;; *) goals="test|check|lint" ;; esac
242
- shift; nt=0
267
+ shift; nt=0; skipv=""
243
268
  for w in "$@"; do
244
- case "$w" in -*) continue ;; esac
269
+ if [ -n "$skipv" ]; then skipv=""; continue; fi
270
+ case "$w" in --tests|-pl|--projects|-P|-D) skipv=1; continue ;; -*) continue ;; esac
245
271
  t=${w##*:}
246
272
  case "|$goals|" in *"|$t|"*) [ -n "$t" ] && nt=$((nt+1)) ;; *) return 1 ;; esac
247
273
  done
@@ -249,19 +275,39 @@ check_cmd() {
249
275
  rspec|unittest) return 0 ;;
250
276
  python|python3) [ "${2:-}" = manage.py ] && [ "${3:-}" = test ]; return ;;
251
277
  tox|nox)
278
+ # forge#4318: `--env=X`, `-eX` and comma lists are split, case-folded, and packaging envs (dist, pypi) are publish too
252
279
  shift; prev=""
253
280
  for w in "$@"; do
254
- case "$prev" in -e|-s|--env|--session|--sessions) case ",$w," in *publish*|*release*|*deploy*|*upload*) return 1 ;; esac ;; esac
255
- case "$w" in -e*|-s*) case "${w#-?}" in *publish*|*release*|*deploy*|*upload*) return 1 ;; esac ;; esac
281
+ v=""
282
+ case "$prev" in -e|-s|--env|--session|--sessions) v=$w ;; esac
283
+ case "$w" in --env=*|--session=*|--sessions=*) v=${w#*=} ;; --*) ;; -e?*|-s?*) v=${w#-?} ;; esac
284
+ if [ -n "$v" ]; then
285
+ v=$(printf '%s' "$v" | tr '[:upper:]' '[:lower:]')
286
+ case ",$v," in *publish*|*release*|*deploy*|*upload*|*pypi*|*twine*|*,dist,*) return 1 ;; esac
287
+ fi
256
288
  prev=$w
257
289
  done
258
290
  return 0 ;;
259
291
  deno) [ "${2:-}" = test ]; return ;;
260
- bash|sh) case "${2:-}" in *.test.sh) safe_dir "$2"; return ;; esac; return 1 ;;
292
+ bash|sh)
293
+ # forge#4318: shell option words (-x, -eu) before the script are allowed
294
+ shift; while [ $# -gt 0 ]; do case "$1" in -[a-z]*) shift ;; *) break ;; esac; done
295
+ case "${1:-}" in *.test.sh) safe_dir "$1"; return ;; esac; return 1 ;;
296
+ ./*.test.sh) safe_dir "${1#./}"; return ;;
261
297
  make)
262
298
  # forge#4316: every target must be a check (`make test deploy` is dropped); VAR=value and flags are not targets
263
- shift; nt=0
264
- for w in "$@"; do case "$w" in -*|*=*) continue ;; esac; make_check_target "$w" || return 1; nt=$((nt+1)); done
299
+ # forge#4318: `make -C DIR` runs in DIR: it becomes the entry cwd, not a target
300
+ shift; nt=0; prev=""; mdir=""
301
+ for w in "$@"; do
302
+ if [ "$prev" = -C ]; then mdir=$w; prev=$w; continue; fi
303
+ prev=$w
304
+ case "$w" in -*|*=*) continue ;; esac; make_check_target "$w" || return 1; nt=$((nt+1))
305
+ done
306
+ if [ -n "$mdir" ]; then
307
+ safe_dir "$mdir" || return 1
308
+ CK_CMD=${CK_CMD/ -C $mdir/}; mdir=${mdir%/}
309
+ if [ "$mdir" != . ]; then if [ "$CK_CWD" = . ]; then CK_CWD=$mdir; else CK_CWD="$CK_CWD/$mdir"; fi; fi
310
+ fi
265
311
  [ "$nt" -gt 0 ]; return ;;
266
312
  go) case "${2:-}" in test|vet|build) return 0 ;; esac; return 1 ;;
267
313
  node) [ "${2:-}" = --test ]; return ;;
@@ -280,7 +326,7 @@ tool_globs() {
280
326
  *\ deno\ *|*\ bun\ *|*\ bunx\ *) ext_globs . "$JS_EXT"; return ;;
281
327
  *[\ /]ruff\ *|*[\ /]flake8\ *|*[\ /]pylint\ *|*[\ /]mypy\ *|*[\ /]black\ *|*[\ /]pytest\ *) printf '**/*.py'; return ;;
282
328
  *[\ /]golangci-lint\ *|*[\ /]gofmt\ *|*[\ /]staticcheck\ *|*\ go\ *) printf '**/*.go'; return ;;
283
- *[\ /]shellcheck\ *|*\ bash\ *.sh\ *|*\ sh\ *.sh\ *) printf '**/*.sh'; return ;;
329
+ *[\ /]shellcheck\ *|*\ bash\ *.sh\ *|*\ sh\ *.sh\ *|*.test.sh\ *) printf '**/*.sh'; return ;;
284
330
  *[\ /]eslint\ *|*[\ /]tsc\ *|*[\ /]prettier\ *|*[\ /]vitest\ *|*[\ /]jest\ *|*\ node\ --test*|*\ npm\ *|*\ pnpm\ *|*\ yarn\ *|*\ npx\ *) ext_globs . "$JS_EXT"; return ;;
285
331
  esac
286
332
  printf ''
@@ -345,6 +345,61 @@ eslint .
345
345
  WF
346
346
  eq "unsafe forms dropped, check forms kept" "$(cmds)" "black --check .|eslint .|gofmt -l .|make test V=1|make test lint|prettier --check .|ruff format --check ."
347
347
 
348
+ # ---- 5g. forge#4318: flag-family fixers, snapshot updaters, write targets, quotes and tox publish envs are dropped ----
349
+ wf_case bypass <<'WF'
350
+ cargo clippy --fix
351
+ tox --env=publish
352
+ tox --session=release
353
+ tox -ePUBLISH
354
+ tox -e dist,pypi
355
+ nox -s Release
356
+ eslint "--fix" .
357
+ gofmt -l --w .
358
+ gofmt -w=true .
359
+ prettier --write=true .
360
+ prettier -cw .
361
+ ruff --fix-only .
362
+ ruff check --add-noqa .
363
+ jest -u
364
+ jest --updateSnapshot
365
+ vitest -u
366
+ vitest --update
367
+ pytest --snapshot-update
368
+ npm test -- -u
369
+ go build -o /tmp/x ./...
370
+ go test -o out.test ./...
371
+ go test -exec foo ./...
372
+ tsc --outDir /tmp/out
373
+ tsc --outFile out.js
374
+ pytest --junitxml=/abs/x.xml
375
+ pytest --basetemp=../t
376
+ mvn clean test
377
+ ./gradlew clean test
378
+ tox -e py311,lint
379
+ pytest -q
380
+ WF
381
+ eq "bypass forms dropped" "$(cmds)" "pytest -q|tox -e py311,lint"
382
+
383
+ # ---- 5h. forge#4318: workspace flags, versioned/wrapped runners, -C, toolchains and test scripts are mapped ----
384
+ wf_case forms <<'WF'
385
+ npm -w pkg test
386
+ pnpm -w test
387
+ uv run python -m pytest
388
+ python3.12 -m pytest
389
+ npx --yes vitest
390
+ pnpm --filter web test
391
+ make -C sub test
392
+ mvn -pl mod test
393
+ ./gradlew test --tests Foo
394
+ cargo +nightly test
395
+ cargo fmt --check
396
+ ./x.test.sh
397
+ bash -x x.test.sh
398
+ WF
399
+ eq "real forms mapped" "$(cmds)" "./gradlew test --tests Foo|./x.test.sh|bash -x x.test.sh|cargo +nightly test|cargo fmt --check|make test|mvn -pl mod test|npm -w pkg test|npx --yes vitest|pnpm --filter web test|pnpm -w test|python3.12 -m pytest|uv run python -m pytest"
400
+ eq "make -C dir is the cwd" "$(printf '%s' "$OUTS" | jq -r '.entries[] | select(.command == "make test") | "\(.cwd) \(.globs | join(","))"')" "sub sub/**"
401
+ eq "test script globs" "$(globs_of "./x.test.sh")" "**/*.sh"
402
+
348
403
  # ---- 6. usage ----
349
404
  bash "$S" --repo-path "$TMP/missing" >/dev/null 2>&1; eq "bad repo path" "$?" 2
350
405
  bash "$S" --nope >/dev/null 2>&1; eq "unknown option" "$?" 2
@@ -67,7 +67,7 @@ eq "trusted synthesis accepted" "$(printf '[%s]' "$(c NONE Bot b "$FAKE_SYN")" |
67
67
  eq "synthesis marker mid-body rejected" "$(printf '[%s]' "$(c NONE Bot b "intro${NL}${FAKE_SYN}")" | bash "$TC" count "$SYNTH_RE")" 0
68
68
  eq "synthesis for a different head rejected" "$(printf '[%s]' "$(c NONE Bot b "<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->${NL}Reviewed-SHA: ${OTHER}")" | bash "$TC" count "$SYNTH_RE")" 0
69
69
 
70
- # Staging review spec (commands/review-pr-staging.md): same predicate, domains may carry digits/hyphens (bug-hunter-api)
70
+ # Staging review (scripts/review-staging-post.sh delivery, formerly the commands/review-pr-staging.md fences): same predicate, domains may carry digits/hyphens (bug-hunter-api)
71
71
  STG_RE="^<!-- FORGE:REVIEW-AGENT:[a-z0-9-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${SHA}(\\r?\\n|\$)"
72
72
  STG_DOM_RE="^<!-- FORGE:REVIEW-AGENT:bug-hunter-api -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${SHA}(\\r?\\n|\$)"
73
73
  STG_TRUE="<!-- FORGE:REVIEW-AGENT:bug-hunter-api -->${NL}Reviewed-SHA: ${SHA}${NL}<!-- FINDING:real-2 -->"