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
@@ -589,6 +589,8 @@ function scopeTsc(rest, cmd, cwdRel, sctx, join) {
589
589
  return scoped(join([rest[0], '-b', ...owners.map((d) => fromCwd(cwdRel, d)), ...flags.filter((f) => f !== '--noEmit')]), owners.length, null, 'tsc-project', { projects: owners });
590
590
  }
591
591
 
592
+ const isLintTask = (t) => t === 'lint' || t.startsWith('lint:') || t.endsWith(':lint');
593
+
592
594
  function scopeMono(tool, rest, cmd, sctx, join) {
593
595
  if (sctx.widen) return full(cmd, sctx.widen, { selector: `${tool}-affected` });
594
596
  const base = sctx.from || sctx.baseRef;
@@ -597,7 +599,14 @@ function scopeMono(tool, rest, cmd, sctx, join) {
597
599
  if (rest[1] === 'run-many' && !out.some((t) => t === '-t' || t.startsWith('--target'))) return full(cmd, '', { selector: 'unscoped:nx-no-target' });
598
600
  return scoped(join([...out, `--base=${base}`]), null, null, 'nx-affected');
599
601
  }
600
- if (tool === 'turbo' && rest[1] === 'run') { if (rest.some((t) => t.startsWith('--filter'))) return full(cmd, '', { selector: 'unscoped:explicit-filter' }); return scoped(join([...rest, `--filter=...[${base}]`]), null, null, 'turbo-affected'); }
602
+ if (tool === 'turbo' && rest[1] === 'run') {
603
+ if (rest.some((t) => t.startsWith('--filter'))) return full(cmd, '', { selector: 'unscoped:explicit-filter' });
604
+ // #4381: lint is per-file, so a lint-only run covers the changed packages, not their dependents (`...[base]`).
605
+ const end = rest.findIndex((t, i) => i > 1 && t.startsWith('-'));
606
+ const tasks = rest.slice(2, end < 0 ? rest.length : end);
607
+ if (tasks.length && tasks.every(isLintTask)) return scoped(join([...rest, `--filter=[${base}]`]), null, null, 'turbo-changed');
608
+ return scoped(join([...rest, `--filter=...[${base}]`]), null, null, 'turbo-affected');
609
+ }
601
610
  return null;
602
611
  }
603
612
 
@@ -23,6 +23,8 @@ export FORGE_TELEMETRY=off
23
23
  export FORGE_CI_LOCAL_HOME="$T/home"
24
24
  export FORGE_CI_LOCAL_SLOTS=4
25
25
  unset FORGE_CI_LOCAL_SERVICES FORGE_CI_LOCAL_NO_YQ FORGE_CI_LOCAL_BUDGET_SECONDS FORGE_CI_LOCAL_JOB_TIMEOUT FORGE_CI_LOCAL_NO_CACHE
26
+ # A lane's FORGE_HOST_PARALLELISM cap (#4182) would serialise the parallelism fixtures below; capParallel has its own test.
27
+ unset FORGE_HOST_PARALLELISM
26
28
  HOST=$(uname -s)
27
29
 
28
30
  pass=0; fail=0
@@ -0,0 +1,3 @@
1
+ package x
2
+
3
+ func Add(a, b int) int { return a + b }
@@ -0,0 +1,3 @@
1
+ package x
2
+
3
+ func Add(a, b, c int) int { return a + b + c }
@@ -0,0 +1,3 @@
1
+ def pairs(items):
2
+ for i in range(0, len(items), 2):
3
+ yield items[i:i + 2]
@@ -0,0 +1,2 @@
1
+ def pairs(items):
2
+ return [items[i:i + 2] for i in range(0, len(items), 2)]
@@ -0,0 +1,17 @@
1
+ from itertools import islice
2
+
3
+ __all__ = ["ichunked"]
4
+
5
+
6
+ def _take(iterator, n):
7
+ return list(islice(iterator, n))
8
+
9
+
10
+ def ichunked(iterable, n):
11
+ """Yield successive chunks of length n from iterable."""
12
+ iterator = iter(iterable)
13
+ while True:
14
+ chunk = _take(iterator, n)
15
+ if not chunk:
16
+ return
17
+ yield iter(chunk)
@@ -0,0 +1,20 @@
1
+ from itertools import islice
2
+
3
+ __all__ = ["ichunked"]
4
+
5
+
6
+ def _take(iterator, n):
7
+ return list(islice(iterator, n))
8
+
9
+
10
+ def _chunks(iterator, n):
11
+ while True:
12
+ chunk = _take(iterator, n)
13
+ if not chunk:
14
+ return
15
+ yield iter(chunk)
16
+
17
+
18
+ def ichunked(iterable, n):
19
+ """Return an iterator over successive chunks of length n from iterable."""
20
+ return _chunks(iter(iterable), n)
@@ -0,0 +1,6 @@
1
+ def _scale(value):
2
+ return value * 2
3
+
4
+
5
+ def total(values):
6
+ return sum(_scale(v) for v in values)
@@ -0,0 +1,9 @@
1
+ def _scale(value, factor=2):
2
+ return value * factor
3
+
4
+
5
+ def total(values):
6
+ result = 0
7
+ for v in values:
8
+ result += _scale(v)
9
+ return result
@@ -0,0 +1,6 @@
1
+ def add(a, b):
2
+ return a + b
3
+
4
+
5
+ def sub(a, b):
6
+ return a - b
@@ -0,0 +1,2 @@
1
+ def add(a, b):
2
+ return a + b
@@ -0,0 +1,2 @@
1
+ def slugify(value):
2
+ return value.lower().replace(" ", "-")
@@ -0,0 +1,2 @@
1
+ def to_slug(value):
2
+ return value.lower().replace(" ", "-")
@@ -0,0 +1,2 @@
1
+ def area(width, height):
2
+ return width * height
@@ -0,0 +1,2 @@
1
+ def area(width, height, *, scale=1):
2
+ return width * height * scale
@@ -0,0 +1,7 @@
1
+ def fetch(url):
2
+ return url
3
+
4
+
5
+ class Client:
6
+ def get(self, path):
7
+ return path
@@ -0,0 +1,7 @@
1
+ async def fetch(url):
2
+ return url
3
+
4
+
5
+ class Client:
6
+ async def get(self, path):
7
+ return path
@@ -0,0 +1,3 @@
1
+ export function* pairs(items) {
2
+ for (let i = 0; i < items.length; i += 2) yield items.slice(i, i + 2);
3
+ }
@@ -0,0 +1,5 @@
1
+ export function pairs(items) {
2
+ const out = [];
3
+ for (let i = 0; i < items.length; i += 2) out.push(items.slice(i, i + 2));
4
+ return out;
5
+ }
@@ -0,0 +1,7 @@
1
+ function scale(value: number): number {
2
+ return value * 2;
3
+ }
4
+
5
+ export function total(values: number[]): number {
6
+ return values.reduce((sum, v) => sum + scale(v), 0);
7
+ }
@@ -0,0 +1,9 @@
1
+ function scale(value: number, factor = 2): number {
2
+ return value * factor;
3
+ }
4
+
5
+ export function total(values: number[]): number {
6
+ let sum = 0;
7
+ for (const v of values) sum += scale(v);
8
+ return sum;
9
+ }
@@ -0,0 +1,7 @@
1
+ export function add(a: number, b: number): number {
2
+ return a + b;
3
+ }
4
+
5
+ export function sub(a: number, b: number): number {
6
+ return a - b;
7
+ }
@@ -0,0 +1,3 @@
1
+ export function add(a: number, b: number): number {
2
+ return a + b;
3
+ }
@@ -0,0 +1,3 @@
1
+ export function slugify(value: string): string {
2
+ return value.toLowerCase().replace(/ /g, "-");
3
+ }
@@ -0,0 +1,3 @@
1
+ export function toSlug(value: string): string {
2
+ return value.toLowerCase().replace(/ /g, "-");
3
+ }
@@ -0,0 +1,3 @@
1
+ export function area(width: number, height: number): number {
2
+ return width * height;
3
+ }
@@ -0,0 +1,3 @@
1
+ export function area(width: number, height: number, scale = 1): number {
2
+ return width * height * scale;
3
+ }
@@ -0,0 +1,5 @@
1
+ export function fetchUrl(url) {
2
+ return url;
3
+ }
4
+
5
+ export const load = (path) => path;
@@ -0,0 +1,5 @@
1
+ export async function fetchUrl(url) {
2
+ return url;
3
+ }
4
+
5
+ export const load = async (path) => path;
@@ -416,9 +416,9 @@ FR="$ROOT/scripts/lib/forge-resolve.sh"; FB="$ROOT/scripts/lib/fence-bootstrap.s
416
416
  [ -f "$FR" ] && [ -f "$FB" ] && ok || bad "scripts/lib/forge-resolve.sh and scripts/lib/fence-bootstrap.sh must exist"
417
417
  sed 's/^[[:space:]]*//' "$STG_SPEC" | grep '^type forge_resolve >/dev/null' | sort | uniq -c > "$T/loc_uniq"
418
418
  [ "$(wc -l < "$T/loc_uniq" | tr -d ' ')" = 1 ] && ok || bad "forge_resolve locator line must be byte-identical across every stub in review-pr-staging.md"
419
- [ "$(awk '{print $1}' "$T/loc_uniq")" -ge 25 ] && ok || bad "expected a locator on every bootstrap (21) and resolver (5) stub"
419
+ [ "$(awk '{print $1}' "$T/loc_uniq")" -ge 12 ] && ok || bad "expected a locator on every bootstrap (10) and resolver (2) stub (the pre/post-dispatch fences moved to forgedock review-staging, #4359)"
420
420
  grep -qE 'PWD|\$\(pwd\)|REPO_PATH' "$T/locator.sh" && bad "stub locator must not consult the cwd or repo under review" || ok
421
- [ "$(grep -c '# FENCE_BOOTSTRAP$' "$STG_SPEC")" = 21 ] && ok || bad "expected 21 fence bootstrap stubs"
421
+ [ "$(grep -c '# FENCE_BOOTSTRAP$' "$STG_SPEC")" = 10 ] && ok || bad "expected 10 fence bootstrap stubs"
422
422
  [ "$(grep '# FENCE_BOOTSTRAP$' "$STG_SPEC" | sort -u | wc -l | tr -d ' ')" = 1 ] && ok || bad "fence bootstrap stubs must be byte-identical"
423
423
  grep '# FENCE_BOOTSTRAP$' "$STG_SPEC" | sed 1q | grep -qF 'exit 1' && ok || bad "fence bootstrap stub must fail closed (exit 1) when the helper is unresolved"
424
424
  # the spec carries none of the old inlined blocks
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
- # publish-staging-sync.test.sh — guards the publish.yml "Sync version to staging" step (forge#3695)
3
- # and the deploy-pr Phase 2A.5 version-sync pre-flight. Static assertions, no network.
2
+ # publish-staging-sync.test.sh — guards the publish.yml "Sync version to staging" step (forge#3695, forge#4357)
3
+ # and the deploy-pr Phase 2A.5 version-sync pre-flight. Static assertions plus a local-only run of the step (bare origin, stub gh); no network.
4
4
  # Usage: bash scripts/publish-staging-sync.test.sh
5
5
  # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
6
6
  # SPDX-License-Identifier: AGPL-3.0-or-later
@@ -29,14 +29,72 @@ else bad "sync step sits between Push version bump and Create GitHub Release"; f
29
29
 
30
30
  stepHas() { if printf '%s\n' "$STEP" | grep -qF -e "$2"; then ok "$1"; else bad "$1 (missing '$2')"; fi; }
31
31
  stepHas "step fetches origin/staging" "origin/staging"
32
- stepHas "step pushes HEAD to staging" "git push origin HEAD:staging"
33
32
  stepHas "step is non-fatal" "continue-on-error: true"
34
33
  stepHas "step is guarded by SKIP_PUBLISH" "env.SKIP_PUBLISH != 'true'"
35
- stepHas "commit carries [skip ci]" "[skip ci]"
34
+ # forge#4357: staging is protected (GH006), so the sync goes through a PR, never a direct push.
35
+ stepHas "step pushes to a chore/sync-version-X.Y.Z branch" 'SYNC_BRANCH="chore/sync-version-${VERSION}"'
36
+ stepHas "step force-pushes the sync branch" 'git push -f origin "HEAD:refs/heads/$SYNC_BRANCH"'
37
+ stepHas "step looks up an existing sync PR" 'gh pr list --head "$SYNC_BRANCH" --base staging'
38
+ stepHas "step opens a PR to staging" 'gh pr create --base staging --head "$SYNC_BRANCH"'
39
+ stepHas "step authenticates gh" 'GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}'
40
+ stepHas "step skips when staging is at or above the version" "nothing to sync"
41
+ if printf '%s\n' "$STEP" | grep -E 'git push' | grep -qE 'HEAD:staging|refs/heads/staging|origin staging'; then
42
+ bad "step must not push directly to staging"; else ok "step does not push directly to staging"; fi
43
+ if printf '%s\n' "$STEP" | grep -E 'git commit' | grep -qF '[skip ci]'; then
44
+ bad "sync commit (PR head) must not carry [skip ci]"; else ok "sync commit does not carry [skip ci]"; fi
45
+ PERMS=$(awk '/^ permissions:/{f=1;next} f&&/^ [a-z]/{exit} f{print}' "$PUB")
46
+ if printf '%s\n' "$PERMS" | grep -qE '^ +pull-requests: write'; then ok "publish job grants pull-requests: write"; else bad "publish job grants pull-requests: write"; fi
36
47
  stepHas "only version files are staged" "git add package.json .claude-plugin/plugin.json"
37
48
  stepHas "failure emits a warning" "::warning::"
38
49
  if printf '%s\n' "$STEP" | grep -qE 'git (merge|pull)'; then bad "step must not merge main (version-only commit)"; else ok "step does not merge main"; fi
39
50
 
51
+ # Behavioural: run the step's script against a local bare origin with a stub gh.
52
+ RUN=$(printf '%s\n' "$STEP" | awk '/^ run: \|/{f=1;next} f{sub(/^ /,"");print}')
53
+ SB=$(mktemp -d "${TMPDIR:-/tmp}/pubsync.XXXXXX")
54
+ trap 'rm -rf "$SB"' EXIT
55
+ (
56
+ set -e
57
+ export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t
58
+ git init -q --bare "$SB/origin.git"
59
+ git init -q -b staging "$SB/seed"; cd "$SB/seed"
60
+ mkdir .claude-plugin
61
+ echo '{"name":"x","version":"1.9.0"}' > package.json
62
+ echo '{"name":"x","version":"1.9.0"}' > .claude-plugin/plugin.json
63
+ echo keep > other.txt
64
+ git add -A; git commit -qm seed; git push -q "$SB/origin.git" staging
65
+ git clone -q -b staging "$SB/origin.git" "$SB/work"
66
+ mkdir "$SB/bin"
67
+ printf '#!/bin/sh\necho "$*" >> "%s/gh.log"\ncase "$2" in create) echo https://example.test/pr/1;; list) [ -z "$GH_STUB_LIST" ] || echo "$GH_STUB_LIST";; esac\n' "$SB" > "$SB/bin/gh"
68
+ chmod +x "$SB/bin/gh"
69
+ ) >/dev/null 2>&1 || bad "behavioural sandbox setup"
70
+ # The workflow's bump step sets git user.name/email; give the step that identity here
71
+ # (CI runners have no global git config).
72
+ runStep() { (cd "$SB/work" && PATH="$SB/bin:$PATH" RUNNER_TEMP="$SB/rt" VERSION="$1" \
73
+ GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t bash -c "$RUN") >"$SB/out" 2>&1; }
74
+ mkdir -p "$SB/rt"
75
+ STAGING_BEFORE=$(git -C "$SB/origin.git" rev-parse staging 2>/dev/null)
76
+ if runStep 1.10.0; then ok "step exits 0"; else bad "step exits 0"; fi
77
+ [ "$(git -C "$SB/origin.git" rev-parse staging 2>/dev/null)" = "$STAGING_BEFORE" ] && ok "staging is not pushed to" || bad "staging is not pushed to"
78
+ SYNCV=$(git -C "$SB/origin.git" show chore/sync-version-1.10.0:package.json 2>/dev/null | node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(0,'utf8')).version)" 2>/dev/null)
79
+ [ "$SYNCV" = "1.10.0" ] && ok "sync branch carries the new version" || bad "sync branch carries the new version (got '$SYNCV')"
80
+ CHANGED=$(git -C "$SB/origin.git" diff --name-only staging chore/sync-version-1.10.0 2>/dev/null | sort | tr '\n' ' ')
81
+ [ "$CHANGED" = ".claude-plugin/plugin.json package.json " ] && ok "sync commit is version-only" || bad "sync commit is version-only (got '$CHANGED')"
82
+ grep -q -- 'pr create --base staging --head chore/sync-version-1.10.0' "$SB/gh.log" 2>/dev/null && ok "gh pr create targets staging" || bad "gh pr create targets staging"
83
+ : > "$SB/gh.log"
84
+ if GH_STUB_LIST=https://example.test/pr/7 runStep 1.10.0 && grep -q 'Updated existing staging sync PR https://example.test/pr/7' "$SB/out" \
85
+ && ! grep -q 'pr create' "$SB/gh.log"; then
86
+ ok "an open sync PR is updated, not duplicated"; else bad "an open sync PR is updated, not duplicated"; fi
87
+ : > "$SB/gh.log"
88
+ if runStep 1.9.0 && grep -q 'nothing to sync' "$SB/out" && [ ! -s "$SB/gh.log" ]; then
89
+ ok "skipped when staging is already at the version"; else bad "skipped when staging is already at the version"; fi
90
+ # A failed commit (no identity) must not push an unchanged branch or open a PR.
91
+ : > "$SB/gh.log"
92
+ git -C "$SB/origin.git" branch -D chore/sync-version-1.11.0 >/dev/null 2>&1 || true
93
+ (cd "$SB/work" && PATH="$SB/bin:$PATH" RUNNER_TEMP="$SB/rt" VERSION=1.11.0 GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 \
94
+ GIT_AUTHOR_NAME='' GIT_COMMITTER_NAME='' EMAIL='' bash -c "$RUN") >"$SB/out" 2>&1
95
+ if ! git -C "$SB/origin.git" rev-parse -q --verify chore/sync-version-1.11.0 >/dev/null && [ ! -s "$SB/gh.log" ]; then
96
+ ok "failed commit pushes nothing and opens no PR"; else bad "failed commit pushes nothing and opens no PR"; fi
97
+
40
98
  has "deploy-pr has the 2A.5 pre-flight" "### 2A.5: Version Sync Pre-flight" "$DEP"
41
99
  has "deploy-pr pre-flight compares package.json" "package.json" "$DEP"
42
100
 
@@ -364,12 +364,18 @@ do_extract() {
364
364
  }
365
365
  }' "$BODY_FILE" > "$SECTION_FILE" 2>/dev/null || : > "$SECTION_FILE"
366
366
  # Failure scenario: present / absent when the section was found, unknown when the carrying comment or section is unreadable.
367
+ # Read from the prose (#4356): a `**Failure scenario**` anywhere on a line, or a line that starts (after list/quote/bold markup, any
368
+ # case) with a `failure scenario`, `scenario`, `repro`, `reproduction` or `steps to reproduce` label followed by `:` or a closing `**`.
369
+ # The content sits on that line or the next non-blank one; a mid-sentence use of the word is not a label.
367
370
  SCENARIO="unknown"
368
371
  if [ -s "$SECTION_FILE" ]; then
369
372
  SCENARIO=$(awk '
370
- tolower($0) ~ /\*\*failure scenario\*\*/ { s = $0; sub(/^.*\*\*[Ff]ailure [Ss]cenario\*\*[*:[:space:]]*/, "", s); gsub(/[[:space:]]/, "", s)
371
- if (s != "") { print "present"; found = 1; exit } else { want = 1; next } }
372
- want { t = $0; gsub(/[[:space:]]/, "", t); if (t != "") { print "present"; found = 1; exit } }
373
+ want { t = $0; gsub(/[[:space:]]/, "", t); if (t != "") { print "present"; found = 1; exit } next }
374
+ { l = tolower($0); s = "" }
375
+ l ~ /\*\*failure scenario\*\*/ { s = l; sub(/^.*\*\*failure scenario\*\*/, "", s) }
376
+ s == "" { m = l; sub(/^[[:space:]]*([-*+>_][[:space:]]*)*/, "", m)
377
+ if (match(m, /^(failure scenario|steps to reproduce|reproduction|repro|scenario)((\*\*|__)?[[:space:]]*:|\*\*|__)/)) s = substr(m, RLENGTH + 1); else next }
378
+ { gsub(/[*_:[:space:]]/, "", s); if (s != "") { print "present"; found = 1; exit } else want = 1 }
373
379
  END { if (!found) print "absent" }' "$SECTION_FILE")
374
380
  fi
375
381
  FIX_EFF=$(grep -iE '^[[:space:]]*(\*\*Fix efficacy\*\*:?|FIX_EFFICACY:)[[:space:]]*\**[[:space:]]*(fails|regresses)' "$SECTION_FILE" 2>/dev/null | head -n 1 \
@@ -531,10 +537,15 @@ SYMEOF
531
537
  case "$SEV" in MEDIUM|LOW|INFO) ROUTE="pre-existing" ;; esac
532
538
  fi
533
539
  fi
534
- # Gate 3 (concrete failure): MEDIUM or lower without a **Failure scenario** is speculative hardening, demoted to a NOTE.
535
- # CRITICAL/HIGH are exempt; an unreadable carrying comment keeps the finding.
540
+ # Gate 3 (concrete failure): MEDIUM or lower without a failure scenario in its prose is speculative hardening, demoted to a NOTE.
541
+ # CRITICAL/HIGH (any confidence) and CONFIRMED MEDIUM are exempt (#4356): a confirmed finding is filed through the classifier with its
542
+ # agent and prose as evidence. An unreadable carrying comment keeps the finding.
536
543
  if [ "$ROUTE" = "file" ] && [ "$SCEN" = "absent" ]; then
537
- case "$SEV" in MEDIUM|LOW|INFO) ROUTE="demoted-no-scenario" ;; esac
544
+ CONF=$(upper "$(printf '%s' "$F" | jq -r '.confidence // ""')")
545
+ case "$SEV" in
546
+ MEDIUM) [ "$CONF" = "CONFIRMED" ] || ROUTE="demoted-no-scenario" ;;
547
+ LOW|INFO) ROUTE="demoted-no-scenario" ;;
548
+ esac
538
549
  fi
539
550
  case "$ROUTE" in pre-existing) N_PREEXISTING=$((N_PREEXISTING + 1)) ;; demoted-no-scenario) N_NO_SCENARIO=$((N_NO_SCENARIO + 1)) ;; esac
540
551
  N_KEPT=$((N_KEPT + 1))
@@ -286,6 +286,45 @@ want "scope: HIGH near an added line" '.findings[] | select(.id == "N-2") | .sco
286
286
  want "scope: MEDIUM near an added line" '.findings[] | select(.id == "N-3") | .scope' findings.json "introduced"
287
287
  want "scope: every finding carries a valid scope" '[.findings[].scope | select(. == "introduced" or . == "pre-existing" or . == "unknown")] | length' findings.json "$(jq '.findings | length' "$WORK/out/findings.json")"
288
288
  want "scope: introduced count" '.counts.findings_introduced' findings.json "$(jq '[.findings[] | select(.scope == "introduced")] | length' "$WORK/out/findings.json")"
289
+ # Gate 3 reads the scenario from the prose and never demotes a CONFIRMED MEDIUM+ for a missing field (#4356); the damper stays for the rest
290
+ reset
291
+ for f in a b c d e f g h; do printf '{"filename":"%s.py","status":"modified","patch":"@@ -9,0 +10,1 @@\\n+new line"}\n' "$f"; done | jq -s . > "$FIX/pr-files.json"
292
+ cm MEMBER User "$(agent_body security "$SHA" '### C-1 confirmed
293
+ Real bug described in prose only.
294
+ ### C-2 confirmed lower case
295
+ Real bug described in prose only.
296
+ ### C-3 confirmed low
297
+ Prose only.
298
+ ### C-4 plain label
299
+ Failure scenario: a missing key crashes the loader
300
+ ### C-5 repro label
301
+ - **Repro**: call load() twice
302
+ ### C-6 no scenario
303
+ Some prose without the field.
304
+ ### C-7 mid-sentence word
305
+ no scenario written here
306
+ ### C-8 label on next line
307
+ **Steps to reproduce**:
308
+
309
+ 1. run it
310
+ ' '<!-- FINDING:C-1|CONFIRMED|MEDIUM|a.py:10|Confirmed medium without a scenario field -->
311
+ <!-- FINDING:C-2|confirmed|medium|b.py:10|Lower case confirmed medium without a scenario field -->
312
+ <!-- FINDING:C-3|CONFIRMED|LOW|c.py:10|Confirmed low without a scenario field -->
313
+ <!-- FINDING:C-4|LIKELY|MEDIUM|d.py:10|Plain failure scenario label in prose -->
314
+ <!-- FINDING:C-5|POSSIBLE|MEDIUM|e.py:10|Bold repro label in a list item -->
315
+ <!-- FINDING:C-6|POSSIBLE|MEDIUM|f.py:10|Possible medium with no scenario anywhere -->
316
+ <!-- FINDING:C-7|LIKELY|MEDIUM|g.py:10|Scenario word mid sentence only -->
317
+ <!-- FINDING:C-8|LIKELY|MEDIUM|h.py:10|Scenario content on the next line -->')"
318
+ flush; run "$GOOD" extract; rc_is "gate3 prose" "$RC" 0
319
+ want "gate3: CONFIRMED MEDIUM without a scenario is filed" '.findings[] | select(.id == "C-1") | [.scenario, .route] | join(",")' findings.json "absent,file"
320
+ want "gate3: lower-case confirmed medium is filed" '.findings[] | select(.id == "C-2") | .route' findings.json "file"
321
+ want "gate3: CONFIRMED LOW without a scenario is still demoted" '.findings[] | select(.id == "C-3") | .route' findings.json "demoted-no-scenario"
322
+ want "gate3: plain Failure scenario label counts" '.findings[] | select(.id == "C-4") | [.scenario, .route] | join(",")' findings.json "present,file"
323
+ want "gate3: bold Repro label counts" '.findings[] | select(.id == "C-5") | [.scenario, .route] | join(",")' findings.json "present,file"
324
+ want "gate3: POSSIBLE MEDIUM with no scenario is demoted" '.findings[] | select(.id == "C-6") | .route' findings.json "demoted-no-scenario"
325
+ want "gate3: a mid-sentence scenario word is not a label" '.findings[] | select(.id == "C-7") | [.scenario, .route] | join(",")' findings.json "absent,demoted-no-scenario"
326
+ want "gate3: label content on the next line counts" '.findings[] | select(.id == "C-8") | .scenario' findings.json "present"
327
+ want "gate3: demoted count" '.counts.notes_demoted_no_scenario' findings.json "3"
289
328
  # an unreadable files API classifies nothing as pre-existing
290
329
  reset; echo '[]' > "$FIX/pr-files.json"
291
330
  cm MEMBER User "$(agent_body security "$SHA" '### P-1 pre
@@ -625,6 +664,14 @@ want "lineage damps LIKELY medium" '.findings[] | select(.id == "L-1") | .class'
625
664
  want "no-scenario note" '.findings[] | select(.id == "L-2") | .disposition' classified.json "NOTE no-scenario"
626
665
  posted_has "no-scenario counter" "notes_demoted_no_scenario=1"
627
666
  posted_has "no-scenario listed" "- L-2 a.py:9 [LIKELY] Demoted for lack of a scenario: no-scenario"
667
+ # a CONFIRMED MEDIUM that Gate 3 kept (scenario absent, route file) is classified, never a no-scenario NOTE (#4356); marker fields unchanged
668
+ reset
669
+ mkfind K-1 CONFIRMED MEDIUM a.py 5 "Confirmed medium kept without a scenario field"
670
+ FJ=$(printf '%s' "$FJ" | jq -c '.scenario = "absent"'); putfind 1
671
+ run "$GOOD" classify; rc_is "confirmed medium classify" "$RC" 0
672
+ want "confirmed medium without scenario is an issue" '.findings[] | select(.id == "K-1") | .class' classified.json "ISSUE"
673
+ has "confirmed medium without scenario is filed" "FINDINGS_FILED=1"
674
+ posted_has "confirmed medium: marker field set unchanged" "<!-- FORGE:NOTE_DISPOSITION: notes_fixed=0 notes_listed=0 notes_dropped=0 findings_filed=1 inpr_fix=0 contract_demoted=0 findings_dropped_stale=0 findings_preexisting=0 introduced=0 notes_demoted_no_scenario=0 lineage=none classifier=script notes_reflag=0 notes_capped=0 reviewed_sha=${SHA} -->"
628
675
  # the PR-body issue in the context alone never sets lineage: only the invoker's --issue does (#3952 SEC-1/QA-2)
629
676
  reset; echo review-finding > "$FIX/labels"; mkctx 1 FULL "" 12
630
677
  mkfind L-1 LIKELY MEDIUM a.py 2 "Lineage damped medium finding"
@@ -151,6 +151,17 @@ touch $SENTINEL"; do
151
151
  else bad "staging injection not rejected: '$got'"; fi
152
152
  done
153
153
 
154
+ # Test 6b (forge#4360): `--delta <full sha>` is forwarded to the CLI; anything but a full lowercase hex SHA is dropped
155
+ DELTA_LINE=$(grep -m1 '^STAGING_DELTA=' "$STAGING")
156
+ if [ -n "$DELTA_LINE" ] && grep -qF '${STAGING_DELTA:+--delta "$STAGING_DELTA"}' "$STAGING"; then ok; else bad "staging --delta parse/forward not found"; fi
157
+ run_delta() { STAGING_ARGS_RAW="$1" "${BASH:-bash}" -c "$DELTA_LINE"$'\n''printf "%s" "$STAGING_DELTA"'; }
158
+ D40=0123456789abcdef0123456789abcdef01234567
159
+ got=$(run_delta "3401 --delta $D40"); if [ "$got" = "$D40" ]; then ok; else bad "staging --delta not parsed: '$got'"; fi
160
+ got=$(run_delta "3401 --delta $D40 --thorough"); if [ "$got" = "$D40" ]; then ok; else bad "staging --delta before a flag: '$got'"; fi
161
+ for v in "abc123" "${D40}0" "XYZ" ""; do
162
+ got=$(run_delta "3401 --delta $v"); if [ -z "$got" ]; then ok; else bad "staging --delta '$v' not dropped: '$got'"; fi
163
+ done
164
+
154
165
  # Test 7: Phase -1 stops on an empty PR_NUMBER before any `gh pr view` (gh falls back to the current-branch PR)
155
166
  GUARD=$(awk '/^ if \[ -z "\$PR_NUMBER" \]; then$/{p=1} p{print} p && /^ fi$/{exit}' "$SPEC")
156
167
  if [ -n "$GUARD" ]; then ok; else bad "Phase -1 empty-PR_NUMBER guard not found"; fi
@@ -167,7 +178,8 @@ done
167
178
  got=$(run_route "3401 --auto-merge --issue 3398"); if [ "$got" = REACHED ]; then ok; else bad "route stopped a valid PR number: '$got'"; fi
168
179
 
169
180
  # Test 8: the whole staging fence stops (non-zero, no REVIEW_ROUTE post) on a rejected string
170
- STAGING_FENCE=$(awk '/^```bash/{buf=""; in_f=1; next} /^```$/{if (in_f && buf ~ /STAGING_ARGS_RAW/) {printf "%s", buf; exit}; in_f=0; next} in_f{buf=buf $0 "\n"}' "$STAGING")
181
+ # Cut before the CLI resolution (#4359): the fence ends by running `forgedock review-staging`, which this test never runs.
182
+ STAGING_FENCE=$(awk '/^```bash/{buf=""; in_f=1; next} /^```$/{if (in_f && buf ~ /STAGING_ARGS_RAW/) {printf "%s", buf; exit}; in_f=0; next} in_f && /^FD_ROOT=/{if (buf ~ /STAGING_ARGS_RAW/) {printf "%s", buf; exit}} in_f{buf=buf $0 "\n"}' "$STAGING")
171
183
  if [ -n "$STAGING_FENCE" ]; then ok; else bad "staging fence not found"; fi
172
184
  run_stg_fence() { gen_script "$STAGING_FENCE" "$1" "$WORK/stgf.sh" 'echo REACHED'; : > "$ERR"; (cd "$WORK" && "${BASH:-bash}" "$WORK/stgf.sh" 2>"$ERR"); }
173
185
  got=$(run_stg_fence "\$(touch $SENTINEL)"); rc=$?
@@ -235,8 +235,8 @@ case "$(p4 FULL 'security auth')" in "PANEL_STATUS: SHORTFALL"*) ok ;; *) bad "u
235
235
  # a verdict without a findings block is undelivered (#3976): not complete, never counted
236
236
  { cm "$(printf '<!-- FORGE:REVIEW-AGENT:security -->\nReviewed-SHA: %s\n\nVerdict: APPROVE, no findings.' "$HEAD")" User MEMBER; cm "$(body_agent auth "$HEAD")" User MEMBER; } | jq -s '.' > "$FAKE_COMMENTS"
237
237
  case "$(p4 FULL 'security auth')" in "PANEL_STATUS: SHORTFALL (ACTUAL_AGENT_COUNT=1)"*) ok ;; *) bad "a block-less reviewer comment must not satisfy Phase 4: $(p4 FULL 'security auth')" ;; esac
238
- # the 3C pending-agent count and the staging 6.75 delivery count carry the same block lookahead as Phase 4
239
- STAGING="$ROOT/commands/review-pr-staging.md"
238
+ # the 3C pending-agent count and the staging 6.75 delivery count (scripts/review-staging-post.sh) carry the same block lookahead as Phase 4
239
+ STAGING="$ROOT/scripts/review-staging-post.sh" # owner of the staging 6.75 delivery count (forge#4359)
240
240
  for spec in "$SPEC" "$STAGING"; do
241
241
  n=$(grep -F 'count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->' "$spec" | wc -l)
242
242
  want=2; [ "$spec" = "$STAGING" ] && want=1
@@ -37,20 +37,12 @@ for p in infra security scraper; do
37
37
  if [ "$reads" -ge 1 ] && [ "$reads" = "$decls" ]; then ok; else bad "$p.md: $reads fences read SLICE_FILE, $decls re-declare it"; fi
38
38
  done
39
39
 
40
- # Staging slice path: every placeholder must be bound, and an unresolved SHA must yield a slash-free component.
41
- STAGING_SPEC="$ROOT/commands/review-pr-staging.md"
42
- if grep -qE '^REVIEW_SHA_SHORT=.*tr -cd .*unresolved' "$STAGING_SPEC"; then ok; else bad "staging spec does not define a hex-sanitized REVIEW_SHA_SHORT with an unresolved fallback"; fi
43
- if grep -qF 'Phase -1 `REVIEW_SHA_SHORT`' "$STAGING_SPEC"; then ok; else bad "staging dispatch text does not bind [REVIEW_SHA_SHORT] to Phase -1 REVIEW_SHA_SHORT"; fi
44
- SHORT_LINE=$(grep -E '^REVIEW_SHA_SHORT=' "$STAGING_SPEC" | head -n 1)
45
- for full in "" "n/x" "0123456789abcdef0123456789abcdef01234567"; do
46
- got=$(REVIEW_SHA_FULL="$full"; eval "$SHORT_LINE"; printf '%s' "$REVIEW_SHA_SHORT")
47
- case "$full" in
48
- 0123*) want="0123456" ;;
49
- *) want="unresolved" ;;
50
- esac
51
- if [ "$got" = "$want" ]; then ok; else bad "REVIEW_SHA_SHORT for '$full' = '$got' (want '$want')"; fi
52
- case "$got" in */*) bad "REVIEW_SHA_SHORT contains a slash for '$full'" ;; *) ok ;; esac
53
- done
40
+ # Staging slice path (#4359): `forgedock review-staging` renders [REVIEW_SHA_SHORT] into the briefs. It derives it only from a validated
41
+ # full head SHA and stops before any brief when none resolves, so the slice path can never carry a / or an unresolved sentinel.
42
+ STAGING_CLI="$ROOT/bin/review-staging.mjs"
43
+ if grep -qF 'const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;' "$STAGING_CLI" && grep -qF 'if (!SHA_RE.test(head)) throw new Stop(1' "$STAGING_CLI"; then ok; else bad "staging CLI does not refuse an unvalidated head SHA"; fi
44
+ if grep -qF 'const sha7 = head.slice(0, 7);' "$STAGING_CLI" && grep -qF 'REVIEW_SHA_SHORT: sha7' "$STAGING_CLI"; then ok; else bad "staging CLI does not bind [REVIEW_SHA_SHORT] to the validated head's first 7 hex"; fi
45
+ if grep -q 'REVIEW_SHA_SHORT' "$ROOT/commands/review-pr-staging.md"; then bad "staging spec still binds [REVIEW_SHA_SHORT] by hand (the CLI renders the briefs)"; else ok; fi
54
46
 
55
47
  echo "review-pr-gate2.test.sh: passed=$PASS failed=$FAILN"
56
48
  [ "$FAILN" -eq 0 ]
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # review-pr-staging-finding-filter.test.sh — fixtures for the anchored open-finding filter (forge#3800).
3
- # The spec's `# FINDING_PR_FILTER` line is extracted and run against issue shapes: the gate must keep a
3
+ # The staging gate's `# FINDING_PR_FILTER` line (scripts/review-staging-pre.sh since #4359) is extracted and run against issue shapes: the gate must keep a
4
4
  # hit only when its title suffix or `**Source**:` line names exactly PR #N; hidden markers never count.
5
5
  # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
6
6
  # SPDX-License-Identifier: AGPL-3.0-or-later
@@ -12,10 +12,10 @@ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
12
12
  command -v jq >/dev/null 2>&1 || { echo "review-pr-staging-finding-filter.test.sh: jq missing, skipped"; exit 0; }
13
13
 
14
14
  extract() { grep -E '# FINDING_PR_FILTER$' "$1" | sed 's/[[:space:]]*# FINDING_PR_FILTER$//; s/^[[:space:]]*//'; }
15
- SPEC="$ROOT/commands/review-pr-staging.md"
15
+ SPEC="$ROOT/scripts/review-staging-pre.sh" # owner of the staging 0A gate (forge#4359)
16
16
  DEF="$(extract "$SPEC" | sort -u)"
17
- [ "$(printf '%s\n' "$DEF" | grep -c .)" -eq 1 ] && ok || bad "review-pr-staging.md must carry one identical FINDING_PR_FILTER definition"
18
- [ "$(extract "$ROOT/commands/deploy-info.md" | sort -u)" = "$DEF" ] && ok || bad "deploy-info.md filter differs from review-pr-staging.md"
17
+ [ "$(printf '%s\n' "$DEF" | grep -c .)" -eq 1 ] && ok || bad "review-staging-pre.sh must carry one identical FINDING_PR_FILTER definition"
18
+ [ "$(extract "$ROOT/commands/deploy-info.md" | sort -u)" = "$DEF" ] && ok || bad "deploy-info.md filter differs from review-staging-pre.sh"
19
19
  [ "$(grep -c -F 'FINDING_PR_FILTER' "$SPEC")" -ge 3 ] && ok || bad "filter must be referenced by all three queries"
20
20
  grep -q -F 'OVERRIDE: shipping with open findings' "$SPEC" && ok || bad "OVERRIDE text missing"
21
21
  eval "$DEF"