forgedock 1.24.4 → 1.25.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.
@@ -43,12 +43,19 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
43
43
  | `infra.disk_quota` | Disk full or quota exhausted (EDQUOT/ENOSPC, errno -28/-122); retryable infra failure, never `needs-human` | engine | Runner error text |
44
44
  | `infra.cli_spawn_transient` | CLI spawn failed transiently before the child ran (ETIMEDOUT, EAGAIN, ENOMEM, EMFILE); retried with backoff on a separate bounded counter that does not consume a phase attempt | engine | `err.code` `CLI_SPAWN_TRANSIENT` |
45
45
  | `cli.timeout` | CLI child ran and was killed at the wall-clock limit; a normal attempt (consumes one of `maxAttempts`), usage recorded when the CLI emitted any | engine | `err.code` `CLI_TIMEOUT` |
46
+ | `cli.spawn_failed` | The CLI could not be started for a reason that repeats on every rerun (`E2BIG` argv too large, `ENOENT`/`EACCES`/`ENOEXEC` binary missing or not executable, `ENAMETOOLONG`). Core work-on ends `needs-human` with this code instead of `engine-error`, so `forgedock orchestrate` does not spend its relaunch on it | lean, engine | `err.code` `CLI_SPAWN_FAILED` (E2BIG) or `err.spawnDeterministic` (other errnos keep their `err.code`); `err.spawnErrno` holds the errno |
46
47
  | `lease.held` | Another live agent holds the issue lease | engine | Foreign live lease on acquire |
47
- | `dependency.open` | A trusted `**Depends on**: #N` dependency is still open, so the run was refused before any state write | engine | Trusted issue body `**Depends on**` line + the dependency issue's open state |
48
- | `dependency.unreadable` | The issue body or a declared dependency could not be read, so the run was refused (fail closed) | engine | Failed or unparsable `gh api` read |
48
+ | `dependency.open` | A dependency is still open: a trusted `**Depends on**: #N` line refused the run before any state write, or the core builder reported `block.kind: "dependency"` naming an issue that is open. Carries `dependencies`; never `needs-human`. `forgedock orchestrate` adds the edge and requeues the issue behind it, without skipping dependents | engine, lean | Trusted `**Depends on**` line, or `result.json` `block.issues`, + the dependency issue's live open state |
49
+ | `dependency.unreadable` | The issue body or a declared dependency could not be read, so the run was refused (fail closed) | engine, lean | Failed or unparsable `gh api` / `gh issue view` read |
50
+ | `dependency.blocked` | Core work-on: the builder's dependency is open and itself labelled `needs-human`. Terminal reason `dependency-blocked` (blocked by #X); this issue is not labelled `needs-human` | lean | `result.json` `block.kind: "dependency"` + the dependency's `needs-human` label |
51
+ | `lane.mismatch` | Core work-on: `--lane` differs from the lane worked out from the issue (milestone → `branches.feature_pattern`, else `branches.staging`); refused before any lease, comment or worktree. `--lane-override` forces it and is recorded in the trajectory. A resumed PR whose base is not the lane is refused the same way before merge | lean | Issue milestone + forge.yaml `branches`, or the open PR's `baseRefName` |
52
+ | `lane.default_branch` | Core work-on: the requested or resolved lane is the default (production) branch (forge.yaml `branches.default` or the GitHub default branch); never a lane, refused at every entry point and before PR create and merge | lean, cli | forge.yaml `branches.default`, `gh repo view` `defaultBranchRef` |
53
+ | `lane.branch_missing` | Core work-on: the lane branch (e.g. `milestone/<slug>`) does not exist on origin; fail closed | lean | `git ls-remote --heads origin` |
54
+ | `lane.unresolved` | Core work-on: the lane could not be worked out (milestone or default branch unreadable, empty milestone slug, bad `feature_pattern`); fail closed | lean | Failed `gh` read or config |
49
55
  | `invalid` | Issue is invalid or in a divergent closed state | investigate, engine | `INVESTIGATION:INVALID` marker, or live snapshot |
50
- | `decomposed` | Issue was decomposed into sub-issues | investigate, decompose | `FORGE:DECOMPOSED` or the decomposition assessment |
51
- | `builder.blocked` | Core work-on: the builder reported it cannot proceed without a human decision (design, credentials, infra) | lean | `result.json` status `blocked` |
56
+ | `decomposed` | Issue was decomposed into sub-issues. In core work-on, the builder reported `block.kind: "split-required"`; the engine files the sub-issues (`**Parent**` and `**Depends on**` lines) and returns them as `subissues` for `forgedock orchestrate` to run | investigate, decompose, lean | `FORGE:DECOMPOSED` or the decomposition assessment; `result.json` `block.subissues` |
57
+ | `builder.blocked` | Core work-on: the builder reported an external action agents cannot take, or a product decision too critical for agents: `block.kind: "human"` with `why` one of `credentials`, `spend`, `promotion`, `customer`, `product-decision`. An upstream issue, a needed split, or prod data that monitoring access can read never count | lean | `result.json` status `blocked` + typed `block` |
58
+ | `builder.block_untyped` | Core work-on: the builder stayed `blocked` without a valid typed reason (missing `block`, a non-human `why` such as prod data, or a dependency that is not open) after one corrective resume | lean | `result.json` status `blocked`, twice |
52
59
  | `builder.no_evidence` | Core work-on: the builder reported invalid/already-fixed without file:line evidence, so the issue was not closed | lean | `result.json` with empty `evidence` |
53
60
  | `builder.no_commit` | Core work-on: the builder made no commit for a fix | lean | No changed files after the builder session |
54
61
  | `review.failed` | Core work-on: the fix/regression review failed twice (or could not resume the builder) | lean | Reviewer verdict FAIL |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.24.4",
3
+ "version": "1.25.0",
4
4
  "description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"
@@ -154,6 +154,29 @@ fi
154
154
  echo "INFO: Building code index for HEAD ${HEAD_SHA:0:8}..."
155
155
  mkdir -p "$CACHE_DIR"
156
156
 
157
+ # ---------------------------------------------------------------------------
158
+ # Step 0: File list (forge#4126)
159
+ # ---------------------------------------------------------------------------
160
+ # Enumerate files ONCE, from the git index when available: a filesystem walk also descends into nested
161
+ # worktrees (.forge/worktrees, .claude/worktrees), virtualenvs and build output inside the checkout —
162
+ # ~30x the tracked tree on a real repo. Outside git, fall back to a find that prunes those directories.
163
+ # Every step below reads this list (paths relative to REPO_PATH); none walks the tree itself.
164
+ FILE_LIST="${CACHE_DIR}/.files"
165
+ if command -v git >/dev/null 2>&1 && git -C "$REPO_PATH" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
166
+ git -C "$REPO_PATH" -c core.quotepath=off ls-files > "$FILE_LIST" 2>/dev/null || true
167
+ else
168
+ (cd "$REPO_PATH" && find . \( -name .git -o -name node_modules -o -name __pycache__ -o -name .forge \
169
+ -o -name .claude -o -name .venv -o -name venv \) -prune -o -type f -print 2>/dev/null | sed 's|^\./||') \
170
+ > "$FILE_LIST" || true
171
+ fi
172
+
173
+ # src_files <ext>... — absolute paths of listed files with one of the given extensions.
174
+ src_files() {
175
+ local re
176
+ re="\\.($(IFS='|'; echo "$*"))\$"
177
+ grep -E "$re" "$FILE_LIST" 2>/dev/null | awk -v p="$REPO_PATH" '{print p "/" $0}' || true
178
+ }
179
+
157
180
  # ---------------------------------------------------------------------------
158
181
  # Step 1: Symbol extraction
159
182
  # ---------------------------------------------------------------------------
@@ -163,12 +186,13 @@ SYMBOLS_FILE="${CACHE_DIR}/symbols.tsv"
163
186
  echo "INFO: Extracting symbols..."
164
187
 
165
188
  if command -v ctags >/dev/null 2>&1; then
166
- # Use ctags for precise symbol extraction
167
189
  # --fields=+ne adds line number and end-of-scope — portable across universal-ctags
168
- ctags -R --output-format=json \
190
+ # Use ctags for precise symbol extraction over the listed files only (-L, never -R: forge#4126).
191
+ # Paths are relative to REPO_PATH, matching the grep fallback and the caller graph.
192
+ (cd "$REPO_PATH" && ctags --output-format=json \
169
193
  --fields='nkz' \
170
194
  --languages=Python,JavaScript,TypeScript,Go,Rust,Java,C,C++ \
171
- -f - "$REPO_PATH" 2>/dev/null \
195
+ -L "$FILE_LIST" -f - 2>/dev/null) \
172
196
  | grep -v '^!' \
173
197
  | while IFS= read -r line; do
174
198
  # Parse JSON output from universal-ctags. Portable (non-PCRE) extraction —
@@ -205,8 +229,7 @@ if [[ ! -s "$SYMBOLS_FILE" ]] || ! command -v ctags >/dev/null 2>&1; then
205
229
  echo "INFO: Falling back to grep-based symbol extraction"
206
230
 
207
231
  # Python: functions and classes
208
- find "$REPO_PATH" -name "*.py" -not -path "*/.git/*" -not -path "*/node_modules/*" \
209
- -not -path "*/__pycache__/*" 2>/dev/null | while read -r f; do
232
+ src_files py | while read -r f; do
210
233
  grep -n "^def \|^ def \|^class " "$f" 2>/dev/null | while IFS=: read -r lnum content; do
211
234
  # Portable (non-PCRE) extraction — grep -oP is not supported by Git Bash's
212
235
  # grep build on Windows, so use a bash regex instead of a lookbehind.
@@ -222,8 +245,7 @@ if [[ ! -s "$SYMBOLS_FILE" ]] || ! command -v ctags >/dev/null 2>&1; then
222
245
  done >> "$SYMBOLS_FILE" 2>/dev/null || true
223
246
 
224
247
  # JavaScript/TypeScript: functions and classes
225
- find "$REPO_PATH" \( -name "*.js" -o -name "*.ts" -o -name "*.tsx" -o -name "*.jsx" \) \
226
- -not -path "*/.git/*" -not -path "*/node_modules/*" 2>/dev/null | while read -r f; do
248
+ src_files js ts tsx jsx | while read -r f; do
227
249
  grep -n "^export function \|^function \|^export class \|^class \|^const .* = \(function\|async function\|() =>\)" \
228
250
  "$f" 2>/dev/null | while IFS=: read -r lnum content; do
229
251
  # Portable (non-PCRE) extraction — grep -oP is not supported by Git Bash's
@@ -250,8 +272,7 @@ IMPORTS_FILE="${CACHE_DIR}/imports.tsv"
250
272
  echo "INFO: Building import graph..."
251
273
 
252
274
  # Python imports
253
- find "$REPO_PATH" -name "*.py" -not -path "*/.git/*" -not -path "*/__pycache__/*" \
254
- 2>/dev/null | while read -r f; do
275
+ src_files py | while read -r f; do
255
276
  rel_path="${f#$REPO_PATH/}"
256
277
  grep -n "^import \|^from " "$f" 2>/dev/null | while IFS=: read -r lnum content; do
257
278
  # Portable (non-PCRE) extraction — grep -oP is not supported by Git Bash's
@@ -271,8 +292,7 @@ find "$REPO_PATH" -name "*.py" -not -path "*/.git/*" -not -path "*/__pycache__/*
271
292
  done >> "$IMPORTS_FILE" 2>/dev/null || true
272
293
 
273
294
  # JavaScript/TypeScript imports
274
- find "$REPO_PATH" \( -name "*.js" -o -name "*.ts" -o -name "*.tsx" -o -name "*.jsx" \) \
275
- -not -path "*/.git/*" -not -path "*/node_modules/*" 2>/dev/null | while read -r f; do
295
+ src_files js ts tsx jsx | while read -r f; do
276
296
  rel_path="${f#$REPO_PATH/}"
277
297
  grep -n "^import \|^} from \|require(" "$f" 2>/dev/null | while IFS=: read -r lnum content; do
278
298
  # Portable (non-PCRE) extraction — grep -oP is not supported by Git Bash's
@@ -289,7 +309,7 @@ find "$REPO_PATH" \( -name "*.js" -o -name "*.ts" -o -name "*.tsx" -o -name "*.j
289
309
  done >> "$IMPORTS_FILE" 2>/dev/null || true
290
310
 
291
311
  # Go imports
292
- find "$REPO_PATH" -name "*.go" -not -path "*/.git/*" 2>/dev/null | while read -r f; do
312
+ src_files go | while read -r f; do
293
313
  rel_path="${f#$REPO_PATH/}"
294
314
  grep -n '"[^"]*"' "$f" 2>/dev/null | grep -v "//\|fmt.Print\|errors.New" | while IFS=: read -r lnum content; do
295
315
  # Portable (non-PCRE) extraction — grep -oP is not supported by Git Bash's
@@ -311,28 +331,32 @@ CALLERS_FILE="${CACHE_DIR}/callers.tsv"
311
331
 
312
332
  echo "INFO: Building caller graph..."
313
333
 
314
- # For each symbol, find files that call it (grep for usage)
334
+ # One pass (forge#4126): collect every `name(` occurrence across the listed sources with a single grep, then
335
+ # join it in awk against the first 500 symbols. The old loop ran one full find|grep tree walk per symbol.
336
+ # One row per (symbol, caller file) at the first call line; files defining the symbol are skipped.
315
337
  if [[ -s "$SYMBOLS_FILE" ]]; then
316
- # Process a sample of top symbols (cap at 500 to avoid timeout)
317
- head -500 "$SYMBOLS_FILE" | while IFS=$'\t' read -r sym_name sym_file sym_line sym_kind; do
318
- # Skip very short or generic names to avoid false positives
319
- if [[ ${#sym_name} -lt 3 ]]; then continue; fi
320
- # Search for callers (function calls, not definitions)
321
- find "$REPO_PATH" -type f \( -name "*.py" -o -name "*.js" -o -name "*.ts" \
322
- -o -name "*.go" -o -name "*.rs" -o -name "*.java" \) \
323
- -not -path "*/.git/*" -not -path "*/node_modules/*" 2>/dev/null | \
324
- xargs grep -l "\b${sym_name}(" 2>/dev/null | while read -r caller_file; do
325
- rel_caller="${caller_file#$REPO_PATH/}"
326
- # Skip the file where the symbol is defined
327
- if [[ "$rel_caller" == "$sym_file" ]]; then continue; fi
328
- caller_line=$(grep -n "\b${sym_name}(" "$caller_file" 2>/dev/null | head -1 | cut -d: -f1 || echo "0")
329
- echo -e "${sym_name}\t${rel_caller}\t${caller_line}"
330
- done || true
331
- # ^ `xargs grep -l` returning 1 (zero callers found for this symbol) propagates
332
- # through `pipefail` into this `while` compound statement, which is subject to
333
- # `set -e` — guard it here so one symbol with no callers doesn't abort the
334
- # remaining symbols in the outer loop.
335
- done >> "$CALLERS_FILE" 2>/dev/null || true
338
+ CALLEE_FILE="${CACHE_DIR}/.callees"
339
+ head -500 "$SYMBOLS_FILE" > "$CALLEE_FILE"
340
+ (cd "$REPO_PATH" && grep -E '\.(py|js|ts|go|rs|java)$' "$FILE_LIST" | tr '\n' '\0' \
341
+ | xargs -0 grep -n -o -H -E '[A-Za-z_][A-Za-z0-9_]*\(' -- 2>/dev/null) \
342
+ | awk -v syms="$CALLEE_FILE" '
343
+ BEGIN {
344
+ while ((getline l < syms) > 0) {
345
+ split(l, s, "\t")
346
+ # Skip very short or generic names to avoid false positives
347
+ if (length(s[1]) >= 3) { want[s[1]] = 1; def[s[1] SUBSEP s[2]] = 1 }
348
+ }
349
+ }
350
+ {
351
+ # file:line:name( — split from the right so a ":" in the file name survives.
352
+ n = split($0, a, ":"); id = a[n]; sub(/\($/, "", id); ln = a[n-1]
353
+ f = substr($0, 1, length($0) - length(a[n]) - length(ln) - 2)
354
+ k = id SUBSEP f
355
+ if (!(id in want) || (k in def) || (k in seen)) next
356
+ seen[k] = 1
357
+ print id "\t" f "\t" ln
358
+ }' >> "$CALLERS_FILE" 2>/dev/null || true
359
+ # ^ grep exits 1 when nothing matches; guard so `set -e`/`pipefail` never aborts the build here.
336
360
  fi
337
361
 
338
362
  # ---------------------------------------------------------------------------
@@ -375,9 +399,8 @@ if [[ -f "$FORGE_YAML" ]]; then
375
399
  fi
376
400
 
377
401
  # Map each file to its domain
378
- find "$REPO_PATH" -type f -not -path "*/.git/*" -not -path "*/node_modules/*" \
379
- -not -path "*/__pycache__/*" -not -path "*/.forge/*" 2>/dev/null | while read -r f; do
380
- rel_path="${f#$REPO_PATH/}"
402
+ while IFS= read -r rel_path; do
403
+ case "$rel_path" in .forge/*|*/node_modules/*|node_modules/*|*/__pycache__/*) continue ;; esac
381
404
  domain="unclassified"
382
405
  for pattern in "${!DOMAIN_PATTERNS[@]}"; do
383
406
  if [[ "$rel_path" == $pattern* ]]; then
@@ -386,7 +409,7 @@ find "$REPO_PATH" -type f -not -path "*/.git/*" -not -path "*/node_modules/*" \
386
409
  fi
387
410
  done
388
411
  echo -e "${rel_path}\t${domain}"
389
- done >> "$DOMAIN_FILE" 2>/dev/null || true
412
+ done < "$FILE_LIST" >> "$DOMAIN_FILE" 2>/dev/null || true
390
413
 
391
414
  # ---------------------------------------------------------------------------
392
415
  # Step 5: Write index manifest
@@ -401,9 +424,7 @@ FILE_COUNT=$(wc -l < "$DOMAIN_FILE" 2>/dev/null || echo 0)
401
424
  # found, but extraction came back empty, a loop was truncated somewhere above
402
425
  # (or a tool like grep -oP is unavailable/incompatible on this platform) — flag
403
426
  # it instead of letting "Index built successfully" silently paper over data loss.
404
- SRC_FILE_COUNT=$(find "$REPO_PATH" \( -name "*.py" -o -name "*.js" -o -name "*.ts" \
405
- -o -name "*.tsx" -o -name "*.jsx" -o -name "*.go" \) -not -path "*/.git/*" \
406
- -not -path "*/node_modules/*" -not -path "*/__pycache__/*" 2>/dev/null | wc -l | tr -d ' ')
427
+ SRC_FILE_COUNT=$(src_files py js ts tsx jsx go | wc -l | tr -d ' ')
407
428
  INDEX_TRUNCATED=0
408
429
  if [[ "$SRC_FILE_COUNT" -gt 0 && "$SYMBOL_COUNT" -eq 0 ]]; then
409
430
  INDEX_TRUNCATED=1
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # Tests for scripts/code-index.sh: the index covers the tracked tree only — nested worktrees (.forge/worktrees,
6
+ # .claude/worktrees) and virtualenvs inside the checkout are never walked (forge#4126) — and the one-pass caller
7
+ # graph still maps callees to their callers. Run: bash scripts/code-index.test.sh (bash 3.2 compatible)
8
+
9
+ set -u
10
+ DIR=$(cd "$(dirname "$0")" && pwd)
11
+ ROOT=$(cd "$DIR/.." && pwd)
12
+ SCRIPT="${CODE_INDEX_UNDER_TEST:-$ROOT/scripts/code-index.sh}" # override to run the suite against another copy
13
+ pass=0; fail=0
14
+ ok() { pass=$((pass+1)); }
15
+ bad() { fail=$((fail+1)); echo "FAIL: $1"; }
16
+ WORK=$(mktemp -d "${TMPDIR:-/tmp}/code-index-test.XXXXXX")
17
+ trap 'rm -rf "$WORK"' EXIT
18
+ export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t
19
+
20
+ # mksrc <dir> — a small Python + JS source tree; helper_fn is defined in app.py and called from use.py.
21
+ mksrc() {
22
+ mkdir -p "$1/src" "$1/web"
23
+ printf 'def helper_fn():\n return helper_fn()\n\nclass Widget:\n pass\n' > "$1/src/app.py"
24
+ printf 'from src.app import helper_fn\n\n\ndef run_it():\n return helper_fn()\n' > "$1/src/use.py"
25
+ printf "import x from './y'\nexport function renderPage() {}\n" > "$1/web/page.js"
26
+ }
27
+
28
+ # --- 1. git checkout with nested worktrees and a venv inside it ---
29
+ REPO="$WORK/repo"; mksrc "$REPO"
30
+ git -C "$REPO" init -q -b main
31
+ git -C "$REPO" add -A; git -C "$REPO" commit -qm init
32
+ git -C "$REPO" worktree add -q -b wt1 "$REPO/.claude/worktrees/one" 2>/dev/null
33
+ git -C "$REPO" worktree add -q -b wt2 "$REPO/.forge/worktrees/two" 2>/dev/null
34
+ printf 'def worktree_only_fn():\n helper_fn()\n' > "$REPO/.claude/worktrees/one/src/wt_only.py"
35
+ printf 'def forge_wt_only_fn():\n helper_fn()\n' > "$REPO/.forge/worktrees/two/src/fwt_only.py"
36
+ mkdir -p "$REPO/.venv/lib"; printf 'def venv_only_fn():\n helper_fn()\n' > "$REPO/.venv/lib/site.py"
37
+
38
+ OUT="$WORK/out"
39
+ bash "$SCRIPT" --repo-path "$REPO" --output "$OUT" > "$WORK/log" 2>&1; rc=$?
40
+ if [ "$rc" -eq 0 ]; then ok; else bad "build exit $rc: $(head -c 400 "$WORK/log")"; fi
41
+ C=$(ls -d "$OUT"/*/ 2>/dev/null | head -n 1)
42
+ for f in symbols imports callers file_domain; do
43
+ if [ -s "$C/$f.tsv" ]; then ok; else bad "$f.tsv is empty"; fi
44
+ if grep -qE '\.claude/|\.forge/|\.venv/|worktree_only_fn|forge_wt_only_fn|venv_only_fn' "$C/$f.tsv" 2>/dev/null; then
45
+ bad "$f.tsv indexed a nested worktree or venv: $(grep -E '\.claude/|\.forge/|\.venv/|_only_fn' "$C/$f.tsv" | head -n 3 | tr '\n' ' ')"
46
+ else ok; fi
47
+ done
48
+ if grep -q '^helper_fn src/app.py 1 ' "$C/symbols.tsv"; then ok; else bad "helper_fn symbol missing or path not relative: $(cat "$C/symbols.tsv")"; fi
49
+ if grep -q '"files": 3' "$C/index.json"; then ok; else bad "file count should be the 3 tracked files: $(grep '"files":' "$C/index.json" | head -n 1)"; fi
50
+
51
+ # --- 2. one-pass caller graph ---
52
+ if grep -q '^helper_fn src/use.py 5$' "$C/callers.tsv"; then ok; else bad "helper_fn caller src/use.py:5 missing: $(cat "$C/callers.tsv")"; fi
53
+ if grep -q '^helper_fn src/app.py ' "$C/callers.tsv"; then bad "the defining file must not be listed as a caller"; else ok; fi
54
+ n=$(grep -c '^helper_fn src/use.py ' "$C/callers.tsv")
55
+ if [ "$n" -eq 1 ]; then ok; else bad "one caller row per (symbol, file), got $n"; fi
56
+
57
+ # --- 3. cache hit on unchanged HEAD ---
58
+ bash "$SCRIPT" --repo-path "$REPO" --output "$OUT" > "$WORK/log2" 2>&1
59
+ if grep -q "Cache hit" "$WORK/log2"; then ok; else bad "second build should be a cache hit"; fi
60
+
61
+ # --- 4. outside git: pruned find fallback ---
62
+ PLAIN="$WORK/plain"; mksrc "$PLAIN"
63
+ mkdir -p "$PLAIN/.venv/lib" "$PLAIN/.claude/worktrees/x/src"
64
+ printf 'def venv_only_fn():\n pass\n' > "$PLAIN/.venv/lib/site.py"
65
+ printf 'def worktree_only_fn():\n pass\n' > "$PLAIN/.claude/worktrees/x/src/w.py"
66
+ bash "$SCRIPT" --repo-path "$PLAIN" --output "$WORK/out2" > "$WORK/log3" 2>&1; rc=$?
67
+ if [ "$rc" -eq 0 ]; then ok; else bad "non-git build exit $rc"; fi
68
+ C2=$(ls -d "$WORK"/out2/*/ 2>/dev/null | head -n 1)
69
+ if grep -q '^helper_fn src/app.py ' "$C2/symbols.tsv" 2>/dev/null; then ok; else bad "non-git build missed helper_fn"; fi
70
+ if grep -qE 'venv_only_fn|worktree_only_fn' "$C2/symbols.tsv" 2>/dev/null; then bad "non-git build walked .venv/.claude"; else ok; fi
71
+
72
+ echo "code-index tests: $pass passed, $fail failed"
73
+ [ "$fail" -eq 0 ]
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  # review-pr-staging-scope.test.sh — cases for the review-pr-staging scoped-review gates (forge#3451):
3
3
  # (1) the 7B out-of-scope rule may only demote MEDIUM/LOW/INFO, never HIGH/CRITICAL or unknown severity;
4
- # (2) the 0B.1 "reviewed" test needs the full-length Reviewed-SHA line, not a 7-char substring.
4
+ # (2) the 0B.1 "reviewed" test needs the full-length Reviewed-SHA line, not a 7-char substring;
5
+ # (11) issue numbers cited in commit subjects never make 0B.1 fall open (forge#4127).
5
6
  # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
6
7
  # SPDX-License-Identifier: AGPL-3.0-or-later
7
8
  set -uo pipefail
@@ -264,6 +265,40 @@ vh10 blocking "PASS"
264
265
  case "$VH10_OUT" in *rc=0*) [ "$VH10_V" = "PASS" ] && ok || bad "a valid persisted PASS must stay PASS" ;; *) bad "a valid persisted PASS must not block" ;; esac
265
266
  vh10 blocking "SKIP"
266
267
  case "$VH10_OUT" in *rc=0*) [ "$VH10_V" = "SKIP" ] && ok || bad "a valid persisted SKIP must stay SKIP" ;; *) bad "a valid persisted SKIP must not block" ;; esac
268
+ # (11) bundle PR set vs issue numbers (forge#4127): conventional subjects cite issues ("(#28668)"); an issue is skipped, never a reason to fall open.
269
+ # Stub gh: PRs 5 (reviewed at head) and 6; 28668 is an issue; 77 fails every read.
270
+ H11=0123456789abcdef0123456789abcdef01234567
271
+ gh11() {
272
+ case "$*" in
273
+ "pr view 5 "*|"pr view 6 "*) jq -n --arg h "$H11" '{headRefOid:$h,mergeCommit:{oid:"m"},commits:[{oid:"c"}],state:"MERGED",baseRefName:"staging",labels:[]}' ;;
274
+ "pr view "*) return 1 ;;
275
+ "api repos/o/r/issues/5 "*|"api repos/o/r/issues/6 "*) echo pr ;;
276
+ "api repos/o/r/issues/28668 "*) echo issue ;;
277
+ "api --paginate repos/o/r/issues/5/comments") printf '%s\n' "$(cm9 "<!-- FORGE:REVIEW_ROUTE mode=full spec=review-pr.md sha=${H11:0:7} -->")" "$(cm9 "$(agent9 security "$H11")")" | jq -s . ;;
278
+ "api --paginate repos/o/r/issues/"*/comments) echo '[]' ;;
279
+ "pr diff "*) echo "f$3.txt" ;;
280
+ *) return 1 ;;
281
+ esac
282
+ }
283
+ [ "$(grep -c '^# BEGIN bundle-pr-kind$' "$SPEC")" -eq 2 ] && [ "$(awk '/^# BEGIN bundle-pr-kind$/{f=1;n++} f{print > ("'"$TMP"'/kind" n ".sh")} /^# END bundle-pr-kind$/{f=0}' "$SPEC"; cmp -s "$TMP/kind1.sh" "$TMP/kind2.sh" && echo same)" = same ] && ok || bad "0A and 0B.1 must carry identical bundle_pr_kind blocks"
284
+ awk '/^# BEGIN bundle-pr-set$/{f=1;next} /^# END bundle-pr-set$/{f=0} f' "$SPEC" | sed 's/{GH_REPO}/o\/r/g' > "$TMP/prset.sh"
285
+ [ -s "$TMP/prset.sh" ] && ok || bad "bundle-pr-set block not found in Phase 0A"
286
+ OUT11=$( gh() { gh11 "$@"; }; BUNDLE_PRS="5 6 28668 77 90"; MERGE_PRS="90"; . "$TMP/prset.sh"; echo $ALL_PR_NUMBERS "|$SKIPPED_ISSUES" )
287
+ [ "$OUT11" = "5 6 77 90 | 28668" ] && ok || bad "0A must drop the confirmed issue and keep PRs, merge PRs and unclassifiable numbers (got '$OUT11')"
288
+ awk '/^# BEGIN bundle-scope-loop$/{f=1;next} /^# END bundle-scope-loop$/{f=0} f' "$SPEC" > "$TMP/loop.sh"
289
+ [ -s "$TMP/loop.sh" ] && ok || bad "bundle-scope-loop block not found in 0B.1"
290
+ scope11() { # $1 = ALL_PR_NUMBERS; prints "<SCOPE_MODE> <REVIEWED_PR_COUNT>" then the loop output
291
+ ( gh() { gh11 "$@"; }; bundle_delta_credit() { DELTA_COMMITS=""; DELTA_FULL=0; }; . "$TMP/kind1.sh"
292
+ SCOPE_MODE=full; REVIEWED_COMMITS=""; REVIEWED_MERGES=""; REVIEWED_PR_COUNT=0; _PF="$TMP/pf11"; : > "$_PF"
293
+ TRUSTED_SCRIPT="$TC" ALL_PR_NUMBERS="$1" PR_NUMBER=9 GH_REPO=o/r _SD="$TMP" REVIEW_DELTA_SCRIPT=""
294
+ LOG11=$(. "$TMP/loop.sh"; echo "@@$SCOPE_MODE $REVIEWED_PR_COUNT"); echo "${LOG11##*@@}"; echo "${LOG11%@@*}" )
295
+ }
296
+ OUT11=$(scope11 "28668 5 6")
297
+ [ "$(printf '%s\n' "$OUT11" | head -1)" = "scoped 1" ] && ok || bad "an issue number in the bundle list must be skipped, not fall open (got '$OUT11')"
298
+ case "$OUT11" in *"#28668 is an issue"*) ok ;; *) bad "0B.1 must announce the skipped issue (got '$OUT11')" ;; esac
299
+ OUT11=$(scope11 "5 77")
300
+ [ "$(printf '%s\n' "$OUT11" | head -1 | cut -d' ' -f1)" = "full" ] && ok || bad "a genuine read failure must still fall open (got '$OUT11')"
301
+ case "$OUT11" in *"fell open at bundle PR #77"*"not a confirmed issue"*) ok ;; *) bad "a fall-open must print the PR and reason (got '$OUT11')" ;; esac
267
302
  else echo "review-pr-staging-scope.test.sh: git missing, delta section skipped"; fi
268
303
  echo "review-pr-staging-scope.test.sh: passed=$PASS failed=$FAILN"
269
304
  [ "$FAILN" -eq 0 ]
@@ -23,7 +23,9 @@
23
23
  # --no-delta skip the FULL/DELTA/BASE_SYNC_ONLY resolver: the review mode is always FULL (a staging bundle review always reviews the
24
24
  # whole bundle), without widening the roster to the full union the way --thorough does
25
25
  # Env: FORGE_CONFIG (forge.yaml path; default: the main checkout's forge.yaml, then the git top-level one),
26
- # REVIEW_PREPARE_SLEEP (seconds between mergeability retries, default 5).
26
+ # REVIEW_PREPARE_SLEEP (seconds between mergeability retries, default 5),
27
+ # REVIEW_PREPARE_INDEX_TIMEOUT (seconds the code-index build may take, default 120; on timeout or failure the slices say
28
+ # "Code index not available" and agents grep instead — forge#4126).
27
29
  # Exit: 0 context emitted (status OK) | 3 context emitted with status BLOCKED (the caller must stop, never skip) | 2 usage error
28
30
  #
29
31
  # Moved from the spec and still in the spec (the caller keeps running them): Phase 2 lint/typecheck/test/build EXECUTION (long-running,
@@ -463,10 +465,21 @@ if [ "$REVIEW_MODE" != "BASE_SYNC_ONLY" ]; then
463
465
  gh pr comment "$PR_NUMBER" -R "$REPO" --body "$_msg" >/dev/null 2>&1 || true # allowlist:check-command-side-effects
464
466
  [ -z "$MERGE_ISSUE" ] || gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label needs-human >/dev/null 2>&1 || true # allowlist:check-command-side-effects
465
467
  fi
466
- if [ -n "$CODE_INDEX_SCRIPT" ]; then bash "$CODE_INDEX_SCRIPT" --repo-path "$REPO_PATH" >/dev/null 2>&1 || true; fi
468
+ # forge#4126: the index build is bounded and visible. A hung build used to block review prep silently (output was discarded).
469
+ INDEX_OK=0
470
+ if [ -n "$CODE_INDEX_SCRIPT" ]; then
471
+ _ito="${REVIEW_PREPARE_INDEX_TIMEOUT:-120}"; case "$_ito" in ''|*[!0-9]*|0) _ito=120 ;; esac
472
+ _tbin=$(command -v timeout || command -v gtimeout || true)
473
+ echo "review-prepare: building code index (limit ${_ito}s)" >&2
474
+ if [ -n "$_tbin" ]; then "$_tbin" "$_ito" bash "$CODE_INDEX_SCRIPT" --repo-path "$REPO_PATH" >/dev/null 2>&1; _irc=$?
475
+ else bash "$CODE_INDEX_SCRIPT" --repo-path "$REPO_PATH" >/dev/null 2>&1; _irc=$?; fi
476
+ if [ "$_irc" -eq 0 ]; then INDEX_OK=1
477
+ elif [ "$_irc" -eq 124 ]; then echo "review-prepare: code index timed out after ${_ito}s — skipping index slices (agents grep instead)" >&2
478
+ else echo "review-prepare: code index failed (exit $_irc) — skipping index slices (agents grep instead)" >&2; fi
479
+ fi
467
480
  build_index_slice() {
468
481
  local domain="${1}" files
469
- if [ -n "$CODE_INDEX_SCRIPT" ]; then
482
+ if [ "$INDEX_OK" -eq 1 ]; then
470
483
  files=$(bash "$CODE_INDEX_SCRIPT" query --domain "$domain" --repo-path "$REPO_PATH" 2>/dev/null | awk -F'\t' '{print $1}' | head -30 | tr '\n' ' ')
471
484
  echo "Domain files (${domain}): ${files:-none}"
472
485
  else echo "Code index not available — agent will use grep exploration"; fi
@@ -300,5 +300,21 @@ setup feat/x staging "README.md" "$DIFF_PLAIN"
300
300
  CTX=$(cd "$WORK/hostile" && FORGEDOCK_HOME="$ROOT" FORGE_CONFIG="$CFG" bash "$ROOT/scripts/review-prepare.sh" --pr 7 --repo o/r --out-dir "$WORK/out" 2>/dev/null)
301
301
  eq "a hostile cwd roster script is never used" '.roster.agents | index("evil") != null' false
302
302
 
303
+ # --- code index build is bounded and visible (forge#4126) ---
304
+ setup feat/x staging "services/api/auth.py" "$DIFF_AUTH_BILL"
305
+ prep "$ROOT"
306
+ eq "real index fills the auth slice" '.index_slices.auth | startswith("Domain files (auth):")' true
307
+ if grep -q "review-prepare: building code index" "$WORK/stderr"; then ok; else bad "index build must print a progress line on stderr"; fi
308
+ if command -v timeout >/dev/null 2>&1 || command -v gtimeout >/dev/null 2>&1; then
309
+ mkroot "$WORK/root-slowindex" "" "" templates
310
+ printf '#!/usr/bin/env bash\nsleep 30\n' > "$WORK/root-slowindex/scripts/code-index.sh"
311
+ t0=$(date +%s)
312
+ REVIEW_PREPARE_INDEX_TIMEOUT=1 prep "$WORK/root-slowindex"; t1=$(date +%s)
313
+ rc_is "hung index build" 0
314
+ if [ $((t1 - t0)) -lt 20 ]; then ok; else bad "hung index build was not bounded ($((t1 - t0))s)"; fi
315
+ eq "timed-out index falls back to grep slices" .index_slices.auth "Code index not available — agent will use grep exploration"
316
+ if grep -q "code index timed out after 1s" "$WORK/stderr"; then ok; else bad "index timeout must be reported on stderr"; fi
317
+ fi
318
+
303
319
  echo "review-prepare tests: $pass passed, $fail failed"
304
320
  [ "$fail" -eq 0 ]