forgedock 1.30.0 → 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 (155) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/runlog.mjs +82 -12
  3. package/bin/engine/telemetry-seam.mjs +11 -4
  4. package/bin/engine/telemetry-seam.test.mjs +18 -0
  5. package/bin/engine-cli.mjs +362 -33
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/labels.json +5 -0
  10. package/bin/lean/apishape.mjs +170 -0
  11. package/bin/lean/apishape.test.mjs +187 -0
  12. package/bin/lean/batch.mjs +32 -0
  13. package/bin/lean/batch.test.mjs +294 -1
  14. package/bin/lean/budget.mjs +89 -0
  15. package/bin/lean/budget.test.mjs +106 -0
  16. package/bin/lean/checks.mjs +32 -9
  17. package/bin/lean/checks.test.mjs +78 -1
  18. package/bin/lean/conformance.e2e.test.mjs +27 -5
  19. package/bin/lean/conformance.mjs +150 -15
  20. package/bin/lean/conformance.test.mjs +175 -3
  21. package/bin/lean/context.mjs +116 -43
  22. package/bin/lean/context.test.mjs +139 -4
  23. package/bin/lean/decompose.mjs +90 -0
  24. package/bin/lean/decompose.test.mjs +105 -0
  25. package/bin/lean/depcache.mjs +11 -7
  26. package/bin/lean/depcache.test.mjs +81 -0
  27. package/bin/lean/e2e.mjs +102 -0
  28. package/bin/lean/e2e.test.mjs +86 -0
  29. package/bin/lean/effects.mjs +37 -15
  30. package/bin/lean/explore.mjs +47 -0
  31. package/bin/lean/explore.test.mjs +61 -0
  32. package/bin/lean/facts.mjs +84 -0
  33. package/bin/lean/facts.test.mjs +66 -0
  34. package/bin/lean/fixround.mjs +90 -0
  35. package/bin/lean/fixround.test.mjs +117 -0
  36. package/bin/lean/heavy.mjs +35 -2
  37. package/bin/lean/heavy.test.mjs +32 -2
  38. package/bin/lean/lean.mjs +650 -124
  39. package/bin/lean/lean.test.mjs +1450 -37
  40. package/bin/lean/ledger.mjs +5 -3
  41. package/bin/lean/ledger.test.mjs +18 -0
  42. package/bin/lean/light.mjs +135 -0
  43. package/bin/lean/light.test.mjs +173 -0
  44. package/bin/lean/limits.test.mjs +36 -1
  45. package/bin/lean/orchestrate.mjs +261 -38
  46. package/bin/lean/pitfalls.mjs +136 -0
  47. package/bin/lean/pitfalls.test.mjs +117 -0
  48. package/bin/lean/postmerge.mjs +85 -0
  49. package/bin/lean/postmerge.test.mjs +79 -0
  50. package/bin/lean/precheck.mjs +138 -13
  51. package/bin/lean/precheck.test.mjs +113 -1
  52. package/bin/lean/prefilter.mjs +159 -5
  53. package/bin/lean/prefilter.test.mjs +152 -3
  54. package/bin/lean/preflight.mjs +108 -3
  55. package/bin/lean/preflight.test.mjs +48 -1
  56. package/bin/lean/probe.mjs +297 -0
  57. package/bin/lean/probe.test.mjs +210 -0
  58. package/bin/lean/prompts.mjs +101 -13
  59. package/bin/lean/render.mjs +7 -3
  60. package/bin/lean/resume.mjs +67 -0
  61. package/bin/lean/resume.test.mjs +84 -0
  62. package/bin/lean/review-trigger.mjs +12 -0
  63. package/bin/lean/review-trigger.test.mjs +17 -1
  64. package/bin/lean/routing.mjs +74 -0
  65. package/bin/lean/routing.test.mjs +67 -0
  66. package/bin/lean/sched.test.mjs +127 -1
  67. package/bin/lean/schedd.mjs +55 -9
  68. package/bin/lean/schedd.test.mjs +149 -3
  69. package/bin/lean/selfreview.mjs +37 -0
  70. package/bin/lean/selfreview.test.mjs +51 -0
  71. package/bin/lean/seqlease.mjs +40 -0
  72. package/bin/lean/seqlease.test.mjs +101 -0
  73. package/bin/lean/sequence.mjs +296 -13
  74. package/bin/lean/sequence.test.mjs +263 -2
  75. package/bin/lean/toolchain.mjs +60 -15
  76. package/bin/lean/toolchain.test.mjs +120 -3
  77. package/bin/lean/toolstats.mjs +72 -0
  78. package/bin/lean/toolstats.test.mjs +47 -0
  79. package/bin/lean/verifier.mjs +232 -0
  80. package/bin/lean/verifier.test.mjs +163 -0
  81. package/bin/lean/verify.mjs +72 -10
  82. package/bin/lean/verify.test.mjs +68 -3
  83. package/bin/review-staging.mjs +533 -0
  84. package/bin/runner.mjs +82 -13
  85. package/bin/stats/aggregate.mjs +87 -2
  86. package/bin/stats/efficiency.mjs +319 -0
  87. package/bin/stats/efficiency.test.mjs +233 -0
  88. package/bin/stats/github-source.mjs +8 -3
  89. package/bin/stats/index.mjs +69 -6
  90. package/bin/stats/local-source.mjs +86 -3
  91. package/bin/stats/query.mjs +48 -0
  92. package/bin/stats/render.mjs +22 -0
  93. package/bin/stats/stats.test.mjs +224 -5
  94. package/commands/orchestrate.md +3 -1
  95. package/commands/review-pr-agents/protocols.md +1 -1
  96. package/commands/review-pr-staging.md +84 -831
  97. package/commands/review-pr.md +1 -1
  98. package/commands/work-on/build/implement.md +1 -0
  99. package/commands/work-on/build/validate.md +11 -1
  100. package/docs/CONFIG.md +80 -5
  101. package/docs/OUTCOME-CODES.md +2 -1
  102. package/docs/STATS.md +53 -1
  103. package/docs/TELEMETRY.md +1 -1
  104. package/docs/spec/review-protocol.md +1 -1
  105. package/package.json +1 -1
  106. package/scripts/ci-local/lib.mjs +20 -0
  107. package/scripts/ci-local/plan.mjs +2 -12
  108. package/scripts/ci-local/scoping.mjs +10 -1
  109. package/scripts/ci-local.test.sh +2 -0
  110. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  111. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  112. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  113. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  114. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  115. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  116. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  117. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  118. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  119. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  120. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  121. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  122. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  123. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  124. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  125. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  126. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  127. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  128. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  129. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  130. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  131. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  132. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  133. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  134. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  135. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  136. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  137. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  138. package/scripts/forge-root.test.sh +2 -2
  139. package/scripts/publish-staging-sync.test.sh +62 -4
  140. package/scripts/review-finalize.sh +17 -6
  141. package/scripts/review-finalize.test.sh +47 -0
  142. package/scripts/review-pr-arguments.test.sh +13 -1
  143. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  144. package/scripts/review-pr-gate2.test.sh +6 -14
  145. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  146. package/scripts/review-pr-staging-scope.test.sh +39 -33
  147. package/scripts/review-prepare.sh +73 -6
  148. package/scripts/review-prepare.test.sh +85 -0
  149. package/scripts/review-staging-post.sh +293 -0
  150. package/scripts/review-staging-post.test.sh +143 -0
  151. package/scripts/review-staging-pre.sh +401 -0
  152. package/scripts/review-staging-pre.test.sh +150 -0
  153. package/scripts/symbol-index.mjs +26 -7
  154. package/scripts/symbol-index.py +55 -9
  155. package/scripts/trusted-comments.test.sh +1 -1
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env bash
2
+ # review-staging-pre.test.sh — cases for scripts/review-staging-pre.sh (forge#4358): bundle PR set, open review-finding gate,
3
+ # 0B.1 scope. Since #4359 the script is the only copy of these blocks (the spec calls `forgedock review-staging`).
4
+ # gh is a PATH stub reading per-case fixtures; git runs against a throwaway repo with a local bare "origin".
5
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ set -uo pipefail
8
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
9
+ PRE="$ROOT/scripts/review-staging-pre.sh"
10
+ PASS=0; FAILN=0
11
+ ok() { PASS=$((PASS+1)); }
12
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
13
+ command -v jq >/dev/null 2>&1 || { echo "review-staging-pre.test.sh: jq missing, skipped"; exit 0; }
14
+ command -v git >/dev/null 2>&1 || { echo "review-staging-pre.test.sh: git missing, skipped"; exit 0; }
15
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
16
+ export FORGEDOCK_HOME="$ROOT" # resolve helpers from this checkout, not an ambient install
17
+
18
+ # (1) the blocks live only here: present in the script, absent from the spec
19
+ for b in bundle-pr-set bundle-pr-kind bundle-delta-credit bundle-scope-loop; do
20
+ grep -qx "# BEGIN $b" "$PRE" && grep -qx "# END $b" "$PRE" && ok || bad "block $b missing from review-staging-pre.sh"
21
+ grep -qx "# BEGIN $b" "$ROOT/commands/review-pr-staging.md" && bad "block $b still copied into commands/review-pr-staging.md" || ok
22
+ done
23
+
24
+ # (2) usage errors
25
+ bash "$PRE" bogus --repo o/r --pr 1 >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "unknown subcommand must exit 2"
26
+ bash "$PRE" gate --repo o/r --pr x --prs-file /dev/null >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "non-numeric --pr must exit 2"
27
+ bash "$PRE" gate --repo o/r --pr 9 --prs-file "$TMP/none" >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "unreadable --prs-file must exit 2 (never an empty bundle)"
28
+ bash "$PRE" scope --repo o/r --pr 9 --default main --staging staging --prs-file /dev/null --sd "$TMP/sd" --bogus 1 >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "unknown flag must exit 2"
29
+
30
+ # gh stub: fixtures in $FIX; posted comment bodies are appended to $FIX/posted
31
+ mkdir -p "$TMP/bin"
32
+ cat > "$TMP/bin/gh" <<'STUB'
33
+ #!/usr/bin/env bash
34
+ F="$FIX"; a="$*"
35
+ # fail-labels / fail-issues: the degraded-label / review-finding read fails (rate limit, 5xx, expired auth)
36
+ case "$a" in "pr view"*"--json labels"*) [ -f "$F/fail-labels" ] && exit 1 ;; "issue list"*) [ -f "$F/fail-issues" ] && exit 1 ;; esac
37
+ case "$1 $2" in
38
+ "pr comment") while [ $# -gt 0 ]; do [ "$1" = --body ] && { printf '%s\n@@\n' "$2" >> "$F/posted"; }; shift; done; exit 0 ;;
39
+ "pr diff") [ -f "$F/files-$3" ] && { cat "$F/files-$3"; exit 0; }; exit 1 ;;
40
+ "pr view")
41
+ case "$a" in *"--json labels"*) [ -f "$F/degraded-$3" ] && echo true || echo false; exit 0 ;; esac
42
+ [ -f "$F/pr-$3.json" ] && { cat "$F/pr-$3.json"; exit 0; }; exit 1 ;;
43
+ "issue list")
44
+ n=$(printf '%s' "$a" | sed -n 's/.*--search PR #\([0-9]*\).*/\1/p')
45
+ [ -f "$F/findings-$n.json" ] && cat "$F/findings-$n.json" || echo '[]'; exit 0 ;;
46
+ esac
47
+ if [ "$1" = api ]; then
48
+ case "$a" in
49
+ *"/comments"*) n=$(printf '%s' "$a" | sed -n 's#.*issues/\([0-9]*\)/comments.*#\1#p'); [ -f "$F/comments-$n.json" ] && cat "$F/comments-$n.json" || echo '[]'; exit 0 ;;
50
+ *"repos/o/r/issues/"*) n=$(printf '%s' "$a" | sed -n 's#.*repos/o/r/issues/\([0-9]*\).*#\1#p'); [ -f "$F/issue-$n" ] && echo issue || echo pr; exit 0 ;;
51
+ esac
52
+ fi
53
+ echo "gh stub: unhandled: $a" >&2; exit 1
54
+ STUB
55
+ chmod +x "$TMP/bin/gh"
56
+ export PATH="$TMP/bin:$PATH"
57
+ newfix() { FIX="$TMP/fix.$1"; rm -rf "$FIX"; mkdir -p "$FIX"; export FIX; }
58
+
59
+ # throwaway repo: main, then staging = merge of PR #5 (feature commit) + a direct commit citing issue #77
60
+ G() { git -C "$TMP/work" -c user.email=t@t -c user.name=t -c commit.gpgsign=false "$@"; }
61
+ git init -q --bare "$TMP/origin.git"
62
+ git init -q "$TMP/work"; G checkout -q -b main
63
+ echo base > "$TMP/work/base.txt"; G add -A; G commit -qm base; G remote add origin "$TMP/origin.git"; G push -q origin main
64
+ G checkout -q -b staging; G checkout -q -b feat
65
+ echo a > "$TMP/work/a.js"; G add -A; G commit -qm "feat: a"; FEAT=$(G rev-parse HEAD)
66
+ G checkout -q staging; G merge -q --no-ff feat -m "Merge pull request #5 from o/feat"; MERGE=$(G rev-parse HEAD)
67
+ mkdir -p "$TMP/work/.github/workflows"; echo b > "$TMP/work/b.js"; echo x > "$TMP/work/.github/workflows/ci.yml"
68
+ G add -A; G commit -qm "fix: thing (#77)"; DIRECT=$(G rev-parse HEAD); G push -q origin staging
69
+ run() { (cd "$TMP/work" && bash "$PRE" "$@"); }
70
+
71
+ # (3) bundle: the merge-subject PR is kept, the issue cited in a subject is skipped, the list is persisted
72
+ newfix bundle; : > "$FIX/issue-77"
73
+ OUT=$(run bundle --repo o/r --default main --staging staging --pr 9 --sd "$TMP/sd" 2>&1); RC=$?
74
+ [ "$RC" -eq 0 ] && ok || bad "bundle must exit 0 (got $RC: $OUT)"
75
+ [ "$(cat "$TMP/sd/9_bundle-prs.txt" 2>/dev/null)" = 5 ] && ok || bad "bundle must persist exactly PR 5 (got '$(cat "$TMP/sd/9_bundle-prs.txt" 2>/dev/null)')"
76
+ case "$OUT" in *"skipped): 77"*) ok ;; *) bad "bundle must report the skipped issue 77 (got '$OUT')" ;; esac
77
+
78
+ # (4) gate
79
+ printf '5\n' > "$TMP/prs"
80
+ newfix clean; OUT=$(run gate --repo o/r --pr 9 --prs-file "$TMP/prs" 2>&1); RC=$?
81
+ [ "$RC" -eq 0 ] && ok || bad "clean gate must exit 0 (got $RC)"
82
+ case "$OUT" in *"RESULT: PASS"*) ok ;; *) bad "clean gate must print RESULT: PASS" ;; esac
83
+ grep -qx '<!-- FORGE:GATE_PASS:TYPE=open-review-finding -->' "$FIX/posted" && head -1 "$FIX/posted" | grep -qx '<!-- FORGE:GATE_PASS -->' && ok || bad "clean gate must post the unchanged GATE_PASS body"
84
+ newfix degraded; : > "$FIX/degraded-5"
85
+ run gate --repo o/r --pr 9 --prs-file "$TMP/prs" >/dev/null 2>&1; RC=$?
86
+ [ "$RC" -eq 1 ] && grep -q 'FORGE:GATE_FAILURE:TYPE=review-panel-integrity' "$FIX/posted" && ok || bad "degraded panel must block with review-panel-integrity (rc $RC)"
87
+ newfix finding; jq -n '[{number:41,title:"bug (PR #5)",body:"x"}]' > "$FIX/findings-5.json"
88
+ OUT=$(run gate --repo o/r --pr 9 --prs-file "$TMP/prs" 2>&1); RC=$?
89
+ [ "$RC" -eq 1 ] && ok || bad "an open finding must block (rc $RC)"
90
+ case "$OUT" in *"RESULT: BLOCK DEPLOY"*) ok ;; *) bad "blocked gate must print RESULT: BLOCK DEPLOY" ;; esac
91
+ grep -q 'FORGE:GATE_FAILURE:TYPE=open-review-finding|FINDINGS=1' "$FIX/posted" && ok || bad "blocked gate must post the GATE_FAILURE marker with FINDINGS=1"
92
+ newfix unanchored; jq -n '[{number:41,title:"bug (PR #55)",body:"x"}]' > "$FIX/findings-5.json"
93
+ run gate --repo o/r --pr 9 --prs-file "$TMP/prs" >/dev/null 2>&1; [ $? -eq 0 ] && ok || bad "a finding for another PR (#55) must not block #5"
94
+ newfix override; jq -n '[{number:41,title:"bug (PR #5)",body:"x"}]' > "$FIX/findings-5.json"
95
+ jq -n '[{body:"OVERRIDE: shipping with open findings — known",author_association:"OWNER",user:{type:"User",login:"o"}}]' > "$FIX/comments-9.json"
96
+ OUT=$(run gate --repo o/r --pr 9 --prs-file "$TMP/prs" 2>&1); RC=$?
97
+ [ "$RC" -eq 0 ] && case "$OUT" in *"RESULT: PASS"*) true ;; *) false ;; esac && ok || bad "a trusted override must pass (rc $RC)"
98
+ newfix untrusted; jq -n '[{number:41,title:"bug (PR #5)",body:"x"}]' > "$FIX/findings-5.json"
99
+ jq -n '[{body:"OVERRIDE: shipping with open findings — x",author_association:"NONE",user:{type:"User",login:"z"}}]' > "$FIX/comments-9.json"
100
+ run gate --repo o/r --pr 9 --prs-file "$TMP/prs" >/dev/null 2>&1; [ $? -eq 1 ] && ok || bad "an untrusted override must not pass"
101
+ # forge#4470: the gate fails closed. A failed gh read or an empty bundle never posts GATE_PASS.
102
+ printf '101\n102\n' > "$TMP/prs2"
103
+ newfix ghfail; : > "$FIX/fail-labels"; : > "$FIX/fail-issues"
104
+ OUT=$(run gate --repo o/r --pr 9 --prs-file "$TMP/prs2" 2>&1); RC=$?
105
+ [ "$RC" -eq 1 ] && ok || bad "gh read failure blocks: gate must exit 1 when every gh read fails (rc $RC)"
106
+ case "$OUT" in *"RESULT: BLOCK DEPLOY"*) ok ;; *) bad "gh read failure blocks: must print RESULT: BLOCK DEPLOY (got '$OUT')" ;; esac
107
+ ! grep -q 'FORGE:GATE_PASS' "$FIX/posted" 2>/dev/null && ok || bad "gh read failure blocks: must not post GATE_PASS"
108
+ grep -q 'FORGE:GATE_FAILURE:TYPE=gate-read-failure' "$FIX/posted" 2>/dev/null && ok || bad "gh read failure blocks: must post TYPE=gate-read-failure"
109
+ newfix issuefail; : > "$FIX/fail-issues"
110
+ run gate --repo o/r --pr 9 --prs-file "$TMP/prs" >/dev/null 2>&1; RC=$?
111
+ [ "$RC" -eq 1 ] && ! grep -q 'FORGE:GATE_PASS' "$FIX/posted" 2>/dev/null && ok || bad "a failed review-finding read alone must block without GATE_PASS (rc $RC)"
112
+ newfix labelfail; : > "$FIX/fail-labels"
113
+ run gate --repo o/r --pr 9 --prs-file "$TMP/prs" >/dev/null 2>&1; RC=$?
114
+ [ "$RC" -eq 1 ] && ! grep -q 'FORGE:GATE_PASS' "$FIX/posted" 2>/dev/null && ok || bad "a failed degraded-label read alone must block without GATE_PASS (rc $RC)"
115
+ newfix readfailoverride; : > "$FIX/fail-issues"
116
+ jq -n '[{body:"OVERRIDE: shipping with open findings — known",author_association:"OWNER",user:{type:"User",login:"o"}}]' > "$FIX/comments-9.json"
117
+ run gate --repo o/r --pr 9 --prs-file "$TMP/prs" >/dev/null 2>&1; [ $? -eq 1 ] && ok || bad "a trusted override must not waive a read failure"
118
+ newfix emptyprs; : > "$TMP/prs-empty"
119
+ OUT=$(run gate --repo o/r --pr 9 --prs-file "$TMP/prs-empty" 2>&1); RC=$?
120
+ [ "$RC" -eq 1 ] && ! grep -q 'FORGE:GATE_PASS' "$FIX/posted" 2>/dev/null && ok || bad "an empty prs-file must block without GATE_PASS (rc $RC: $OUT)"
121
+ newfix emptybundle
122
+ run bundle --repo o/r --default staging --staging staging --pr 8 --sd "$TMP/sd" >/dev/null 2>&1; RC=$?
123
+ [ "$RC" -ne 0 ] && ok || bad "an empty bundle PR set must exit non-zero"
124
+ newfix fetchfail
125
+ run bundle --repo o/r --default main --staging no-such-branch --pr 8 --sd "$TMP/sd" >/dev/null 2>&1; RC=$?
126
+ [ "$RC" -ne 0 ] && ok || bad "a failed git fetch must exit non-zero"
127
+
128
+ # (5) scope: PR 5 reviewed at its final head -> scoped; the direct commit and its files are unreviewed
129
+ newfix scope; H7=${FEAT:0:7}
130
+ jq -n --arg h "$FEAT" --arg m "$MERGE" '{headRefOid:$h,mergeCommit:{oid:$m},commits:[{oid:$h}],state:"MERGED",baseRefName:"staging",labels:[]}' > "$FIX/pr-5.json"
131
+ printf 'a.js\n' > "$FIX/files-5"
132
+ jq -n --arg r "<!-- FORGE:REVIEW_ROUTE mode=single-pr spec=review-pr.md sha=$H7 -->" \
133
+ --arg g "$(printf '<!-- FORGE:REVIEW-AGENT:security -->\nReviewed-SHA: %s\n\n<!-- REVIEW-FINDINGS-START -->\n<!-- REVIEW-FINDINGS-END -->' "$FEAT")" \
134
+ '[{body:$r,author_association:"NONE",user:{type:"Bot",login:"b"}},{body:$g,author_association:"NONE",user:{type:"Bot",login:"b"}}]' > "$FIX/comments-5.json"
135
+ OUT=$(run scope --repo o/r --default main --staging staging --pr 9 --prs-file "$TMP/prs" --sd "$TMP/sd" 2>&1); RC=$?
136
+ [ "$RC" -eq 0 ] && ok || bad "scope must exit 0 (got $RC: $OUT)"
137
+ J="$TMP/sd/9_scope.json"
138
+ [ "$(jq -r .scope_mode "$J" 2>/dev/null)" = scoped ] && ok || bad "a reviewed bundle must be scoped (got $(jq -c . "$J" 2>/dev/null); $OUT)"
139
+ [ "$(jq -r '.unreviewed_commits | join(" ")' "$J" 2>/dev/null)" = "$DIRECT" ] && ok || bad "only the direct commit is unreviewed (got $(jq -c .unreviewed_commits "$J" 2>/dev/null))"
140
+ jq -e '.unreviewed_files | index("b.js")' "$J" >/dev/null 2>&1 && ! jq -e '.unreviewed_files | index("a.js")' "$J" >/dev/null 2>&1 && ok || bad "unreviewed files must hold b.js and not a.js"
141
+ jq -e '.deploy_wiring_files | index(".github/workflows/ci.yml")' "$J" >/dev/null 2>&1 && ok || bad "workflow files are deploy wiring"
142
+ jq -e '.reviewed_pr_count == 1' "$J" >/dev/null 2>&1 && ok || bad "reviewed_pr_count must be 1"
143
+ grep -q '^SCOPE_MODE=scoped$' "$TMP/sd/9_scope.env" && grep -q '^5 a.js$' "$TMP/sd/9_bundle-pr-files.txt" && ok || bad "scope.env and bundle-pr-files.txt keep the spec's format"
144
+ case "$OUT" in *"SCOPE_MODE=scoped reviewed_prs=1"*) ok ;; *) bad "scope must print the one-line summary" ;; esac
145
+ newfix scopefail
146
+ run scope --repo o/r --default main --staging staging --pr 9 --prs-file "$TMP/prs" --sd "$TMP/sd" >/dev/null 2>&1
147
+ [ "$(jq -r .scope_mode "$TMP/sd/9_scope.json" 2>/dev/null)" = full ] && ok || bad "a failed gh pr view must widen scope to full"
148
+
149
+ echo "review-staging-pre.test.sh: passed=$PASS failed=$FAILN"
150
+ [ "$FAILN" -eq 0 ]
@@ -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")
@@ -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 -->"