forgedock 1.29.0 → 1.29.1

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.
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
- import { parseTestMap, readTestMap, selectCommands, factsPath, TEST_MAP_FILE, REPO_FACTS_FILE } from "./testmap.mjs";
5
+ import { parseTestMap, readTestMap, selectCommands, mergeChecks, factsPath, TEST_MAP_FILE, REPO_FACTS_FILE } from "./testmap.mjs";
6
6
 
7
7
  const MAP = {
8
8
  version: 1,
@@ -56,3 +56,18 @@ describe("test map (#4272)", () => {
56
56
  assert.equal(await factsPath(["/wt"], async () => { throw new Error("EACCES"); }), null);
57
57
  });
58
58
  });
59
+
60
+ describe("mergeChecks (#4315): the map can only add checks", () => {
61
+ it("a partial map keeps every learned command and adds the ones it selects that learned lacks", () => {
62
+ assert.deepEqual(mergeChecks(["npm test", "cd pkg && npm test"], ["make lint", "npm test"]),
63
+ { commands: ["make lint", "npm test", "cd pkg && npm test"], added: 1, source: "test-map+learned" });
64
+ });
65
+ it("duplicates, whitespace variants included, appear once", () => {
66
+ assert.deepEqual(mergeChecks(["npm run lint", " npm run lint", "npm test"], ["npm run lint ", "npm run lint"]).commands, ["npm run lint", "npm test"]);
67
+ });
68
+ it("an empty selection keeps the learned commands; no learned commands keep the map's selection", () => {
69
+ assert.deepEqual(mergeChecks([], ["make lint"]), { commands: ["make lint"], added: 0, source: "learned" });
70
+ assert.deepEqual(mergeChecks(["npm test"], []), { commands: ["npm test"], added: 1, source: "test-map" });
71
+ assert.deepEqual(mergeChecks(undefined, undefined), { commands: [], added: 0, source: "learned" });
72
+ });
73
+ });
@@ -357,8 +357,9 @@ else
357
357
  # Check current CI status
358
358
  # Fail closed: valid fields only (`status`/`conclusion` do not exist on gh pr checks --json; the
359
359
  # old query errored and defaulted to 0 = "no failures"). An unreadable result counts as failing.
360
+ # forgedock/verify (the lean engine's report-only gate summary) is not a code failure: ignored, as wait-ci-green.sh does.
360
361
  CURRENT_CI_STATUS=$(gh pr checks "$PR_NUMBER" $GH_FLAG --json name,bucket \
361
- --jq '[.[] | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
362
+ --jq '[.[] | select(.name != "forgedock/verify") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
362
363
 
363
364
  if [ "$CURRENT_CI_STATUS" -eq 0 ]; then
364
365
  echo "CI gate: PASSED (no failing checks)"
@@ -378,9 +379,9 @@ else
378
379
 
379
380
  CI_FIXES=$((CI_FIXES + 1))
380
381
 
381
- # Re-check CI status after fix attempt
382
- FAILING_AFTER=$(gh pr checks "$PR_NUMBER" $GH_FLAG --json name,status,conclusion \
383
- --jq '[.[] | select(.conclusion == "failure")] | length' 2>/dev/null || echo "0")
382
+ # Re-check CI status after fix attempt (same fail-closed query as above)
383
+ FAILING_AFTER=$(gh pr checks "$PR_NUMBER" $GH_FLAG --json name,bucket \
384
+ --jq '[.[] | select(.name != "forgedock/verify") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
384
385
 
385
386
  if [ "$FAILING_AFTER" -eq 0 ]; then
386
387
  echo "CI gate: PASSED after $CI_ITER iteration(s)"
@@ -38,7 +38,8 @@ Do this yourself from issue text and the code. Spawn no sub-agents here.
38
38
 
39
39
  It closes, with the evidence on the issue and no lane, every issue that a merged PR already fixed (one
40
40
  short read-only validation on a small model) or an auto-filed CI failure whose workflow is green again
41
- (the green run link). It checks K issues at a time. Its last line is `lanes: #A #B ...`: continue with only
41
+ (the green run link), unless it is flaky (wording or `flaky` label), was reopened after a pre-filter close, or
42
+ the workflow failed and passed at the same SHA. It checks K issues at a time. Its last line is `lanes: #A #B ...`: continue with only
42
43
  those issues. This is the only pre-filter in this command: lanes run `forgedock run-issue`, which does not
43
44
  pre-filter again. Keep its
44
45
  `pre-filter:` line for the report.
@@ -144,12 +145,12 @@ When a lane finishes (agent lanes; the batch runner applies the same rules itsel
144
145
  These are mechanical. **Do not ask for confirmation on them.** The batch runner recovers each one itself
145
146
  (`orchestration.recovery.max_attempts`, default 1 per issue). It launches one recovery lane under the same lease, lane
146
147
  and admission rules as any lane, with `run-issue --recover <code> [--recover-attempt n]` (core path only:
147
- `--path full` rejects `--recover`). That lane:
148
+ `--path full` rejects `--recover`; recovery is off when `engine.path: full`). That lane:
148
149
  - removes `needs-human`;
149
150
  - resumes the open PR: merges the base, fixes the failed required checks from their logs, and pushes new commits only;
150
151
  - re-queues the issue, keeping its dependents queued in the same batch.
151
152
 
152
- Report what was recovered afterwards. A failed recovery stays `needs-human` with its evidence, exactly once (failed = `needs-human`, `ci-pending`, or a final `engine-error`/`deferred`; `invalid`, `decomposed` and `dependency-*` are not failures and never re-label), and there
153
+ Report what was recovered afterwards. A failed recovery stays `needs-human` with its evidence, re-labelled exactly once per issue, by the final attempt only (failed = `needs-human`, `ci-pending`, or a final `engine-error`/`deferred`; `invalid`, `decomposed` and `dependency-*` are not failures and never re-label). With `max_attempts > 1` a non-final attempt adds no label or comment: the runner either launches the next attempt or, when none follows, re-labels it once itself, so the label does not flap between attempts. There
153
154
  is no second attempt beyond `max_attempts`.
154
155
  - `needs-human` with a **human code** (`builder.blocked`: product-decision, spend, credentials, promotion, customer): an
155
156
  external action agents cannot take, or a product decision too critical for agents. It is never auto-recovered. Read
@@ -660,8 +660,13 @@ git diff origin/$DEFAULT_BRANCH...origin/$STAGING_BRANCH | grep -iE "(api[_-]?ke
660
660
  type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
661
661
  . "$(forge_resolve lib/fence-bootstrap.sh)" || { echo "BLOCKED: ForgeDock helper lib/fence-bootstrap.sh not resolvable"; exit 1; } # FENCE_BOOTSTRAP
662
662
  gh pr checks "$PR_NUMBER" -R "$GH_REPO" 2>&1
663
+ # forgedock/verify (the lean engine's report-only gate summary) is not a code failure: ignored, as wait-ci-green.sh does.
664
+ # Fail closed: an unreadable result counts as failing.
665
+ CI_FAILING=$(gh pr checks "$PR_NUMBER" -R "$GH_REPO" --json name,bucket \
666
+ --jq '[.[] | select(.name != "forgedock/verify") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
667
+ echo "CI_FAILING: $CI_FAILING"
663
668
  ```
664
- Any CI failure → BLOCK DEPLOY (unless autofixed in Phase 1F).
669
+ `CI_FAILING` > 0 → BLOCK DEPLOY (unless autofixed in Phase 1F). A red `forgedock/verify` alone does not block.
665
670
 
666
671
  ### Phase 1F: CI Autofix
667
672
 
@@ -97,7 +97,7 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
97
97
  - `contract.invalid` when the issue was edited mid-run (`issueEdited`);
98
98
  - `ci-pending` on a conflicting PR, recovered as `base.conflict`.
99
99
 
100
- The runner relaunches the issue once per `orchestration.recovery.max_attempts` (default 1) as a recovery lane (`run-issue --recover <code>`), under the normal admission rules. That lane removes the `needs-human` label, posts `FORGE:DECISION`, and resumes the open PR: base-sync, then CI fix rounds from the failed-check logs. Dependents stay queued. A merged recovery is reported as `recovered`. A failed one is final, keeps the recovery evidence on the row, and skips its dependents. A failed recovery stays `needs-human`: the lane re-adds the label (with a `FORGE:NEEDS_HUMAN` comment carrying the evidence) only for a final `needs-human` or `ci-pending`; the batch runner re-adds it, once, for a final `engine-error` or `deferred` after the recovery pass (those may be requeued, so the lane cannot know). `invalid`, `decomposed`, `dependency-*` and `merged` never re-label.
100
+ The runner relaunches the issue once per `orchestration.recovery.max_attempts` (default 1) as a recovery lane (`run-issue --recover <code>`), under the normal admission rules. That lane removes the `needs-human` label, posts `FORGE:DECISION`, and resumes the open PR: base-sync, then CI fix rounds from the failed-check logs. Dependents stay queued. A merged recovery is reported as `recovered`. A failed one is final, keeps the recovery evidence on the row, and skips its dependents. A failed recovery stays `needs-human`: the lane re-adds the label (with a `FORGE:NEEDS_HUMAN` comment carrying the evidence) only for a final `needs-human` or `ci-pending`, and only when it is the last attempt (`attempt == max_attempts`); a non-final attempt that ends `needs-human` or `ci-pending` without another attempt following is re-labelled once by the batch runner; the batch runner re-adds it, once, for a final `engine-error` or `deferred` after the recovery pass (those may be requeued, so the lane cannot know). `invalid`, `decomposed`, `dependency-*` and `merged` never re-label.
101
101
  - **human**: `builder.blocked`, which is only reached by a typed human block: credentials, spend, promotion, customer or product-decision. It is never recovered, it skips dependents, and it is the only class in the report's escalation list.
102
102
  - **terminal**: every other code. No recovery runs, dependents are skipped, and the row is reported as `unrecovered`.
103
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.29.0",
3
+ "version": "1.29.1",
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"
@@ -24,7 +24,7 @@
24
24
  # Import graph: language-specific grep patterns (Python, JS/TS, Go, Rust, shell)
25
25
  #
26
26
  # Cache key: HEAD SHA — cache is a hit on unchanged HEAD, miss on new commit.
27
- # Works on Windows (Git Bash / WSL) and Linux. No bash-only hot-path tooling.
27
+ # Works on Windows (Git Bash / WSL), Linux and macOS. Bash 3.2 portable: no associative arrays, mapfile, grep -P.
28
28
  #
29
29
  # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
30
30
  # SPDX-License-Identifier: AGPL-3.0-or-later
@@ -181,7 +181,7 @@ src_files() {
181
181
  # Step 1: Symbol extraction
182
182
  # ---------------------------------------------------------------------------
183
183
  SYMBOLS_FILE="${CACHE_DIR}/symbols.tsv"
184
- > "$SYMBOLS_FILE"
184
+ : > "$SYMBOLS_FILE"
185
185
 
186
186
  echo "INFO: Extracting symbols..."
187
187
 
@@ -239,7 +239,7 @@ if [[ ! -s "$SYMBOLS_FILE" ]] || ! command -v ctags >/dev/null 2>&1; then
239
239
  name=""
240
240
  fi
241
241
  kind=$(echo "$content" | grep -q "^def \| def " && echo "function" || echo "class")
242
- rel_path="${f#$REPO_PATH/}"
242
+ rel_path="${f#"$REPO_PATH"/}"
243
243
  [[ -n "$name" ]] && echo -e "${name}\t${rel_path}\t${lnum}\t${kind}"
244
244
  done || true
245
245
  done >> "$SYMBOLS_FILE" 2>/dev/null || true
@@ -257,7 +257,7 @@ if [[ ! -s "$SYMBOLS_FILE" ]] || ! command -v ctags >/dev/null 2>&1; then
257
257
  else
258
258
  name=""
259
259
  fi
260
- rel_path="${f#$REPO_PATH/}"
260
+ rel_path="${f#"$REPO_PATH"/}"
261
261
  [[ -n "$name" ]] && echo -e "${name}\t${rel_path}\t${lnum}\tfunction"
262
262
  done || true
263
263
  done >> "$SYMBOLS_FILE" 2>/dev/null || true
@@ -267,13 +267,13 @@ fi
267
267
  # Step 2: Import graph extraction
268
268
  # ---------------------------------------------------------------------------
269
269
  IMPORTS_FILE="${CACHE_DIR}/imports.tsv"
270
- > "$IMPORTS_FILE"
270
+ : > "$IMPORTS_FILE"
271
271
 
272
272
  echo "INFO: Building import graph..."
273
273
 
274
274
  # Python imports
275
275
  src_files py | while read -r f; do
276
- rel_path="${f#$REPO_PATH/}"
276
+ rel_path="${f#"$REPO_PATH"/}"
277
277
  grep -n "^import \|^from " "$f" 2>/dev/null | while IFS=: read -r lnum content; do
278
278
  # Portable (non-PCRE) extraction — grep -oP is not supported by Git Bash's
279
279
  # grep build on Windows, so use a bash regex instead of lookbehind.
@@ -293,7 +293,7 @@ done >> "$IMPORTS_FILE" 2>/dev/null || true
293
293
 
294
294
  # JavaScript/TypeScript imports
295
295
  src_files js ts tsx jsx | while read -r f; do
296
- rel_path="${f#$REPO_PATH/}"
296
+ rel_path="${f#"$REPO_PATH"/}"
297
297
  grep -n "^import \|^} from \|require(" "$f" 2>/dev/null | while IFS=: read -r lnum content; do
298
298
  # Portable (non-PCRE) extraction — grep -oP is not supported by Git Bash's
299
299
  # grep build on Windows, so use a bash regex instead of lookbehind/lookahead.
@@ -310,7 +310,7 @@ done >> "$IMPORTS_FILE" 2>/dev/null || true
310
310
 
311
311
  # Go imports
312
312
  src_files go | while read -r f; do
313
- rel_path="${f#$REPO_PATH/}"
313
+ rel_path="${f#"$REPO_PATH"/}"
314
314
  grep -n '"[^"]*"' "$f" 2>/dev/null | grep -v "//\|fmt.Print\|errors.New" | while IFS=: read -r lnum content; do
315
315
  # Portable (non-PCRE) extraction — grep -oP is not supported by Git Bash's
316
316
  # grep build on Windows, so use a bash regex instead of lookbehind/lookahead.
@@ -327,7 +327,7 @@ done >> "$IMPORTS_FILE" 2>/dev/null || true
327
327
  # Step 3: Caller graph (callee → callers mapping)
328
328
  # ---------------------------------------------------------------------------
329
329
  CALLERS_FILE="${CACHE_DIR}/callers.tsv"
330
- > "$CALLERS_FILE"
330
+ : > "$CALLERS_FILE"
331
331
 
332
332
  echo "INFO: Building caller graph..."
333
333
 
@@ -363,50 +363,50 @@ fi
363
363
  # Step 4: File-to-domain mapping
364
364
  # ---------------------------------------------------------------------------
365
365
  DOMAIN_FILE="${CACHE_DIR}/file_domain.tsv"
366
- > "$DOMAIN_FILE"
366
+ : > "$DOMAIN_FILE"
367
367
 
368
368
  echo "INFO: Building file-domain map..."
369
369
 
370
370
  # Read forge.yaml domain hints if available
371
371
  FORGE_YAML="${REPO_PATH}/forge.yaml"
372
- declare -A DOMAIN_PATTERNS
373
- DOMAIN_PATTERNS=(
374
- ["commands"]="pipeline-commands"
375
- ["scripts"]="pipeline-scripts"
376
- ["services/api"]="api"
377
- ["services/worker"]="worker"
378
- ["web/src/app/billing"]="billing"
379
- ["web/src/app/auth"]="auth"
380
- ["services/api/auth"]="auth"
381
- ["services/api/billing"]="billing"
382
- ["services/api/db"]="database"
383
- ["migrations"]="database"
384
- [".github/workflows"]="ci-cd"
385
- ["infra"]="infrastructure"
386
- ["web/src"]="frontend"
372
+ # Ordered prefix -> domain table, most specific first; the first matching prefix wins (forge#4235).
373
+ # Parallel indexed arrays, not an associative array: bash 3.2 (stock macOS) has no `declare -A`,
374
+ # and hash iteration order made services/api vs services/api/auth unspecified.
375
+ DOMAIN_PREFIXES=(
376
+ "web/src/app/billing" "web/src/app/auth" "services/api/auth" "services/api/billing" "services/api/db"
377
+ "services/api" "services/worker" "migrations" ".github/workflows" "infra" "web/src" "commands" "scripts"
378
+ )
379
+ DOMAIN_LABELS=(
380
+ "billing" "auth" "auth" "billing" "database"
381
+ "api" "worker" "database" "ci-cd" "infrastructure" "frontend" "pipeline-commands" "pipeline-scripts"
387
382
  )
388
383
 
389
- # Read key_paths from forge.yaml if present (override defaults)
384
+ # Read key_paths from forge.yaml if present: prepended so they override the defaults
390
385
  if [[ -f "$FORGE_YAML" ]]; then
391
386
  # Simple key_paths extraction — reads "domain: [patterns]" blocks under review.key_paths
392
387
  while IFS=: read -r key val; do
393
388
  key=$(echo "$key" | tr -d ' ')
394
389
  val=$(echo "$val" | tr -d ' "')
395
390
  if [[ -n "$key" && -n "$val" ]]; then
396
- DOMAIN_PATTERNS["$val"]="$key"
391
+ DOMAIN_PREFIXES=("$val" "${DOMAIN_PREFIXES[@]}")
392
+ DOMAIN_LABELS=("$key" "${DOMAIN_LABELS[@]}")
397
393
  fi
398
394
  done < <(grep -A2 'key_paths:' "$FORGE_YAML" 2>/dev/null | grep '^\s*\w\+:' | head -20 || true)
399
395
  fi
396
+ DOMAIN_COUNT=${#DOMAIN_PREFIXES[@]}
400
397
 
401
398
  # Map each file to its domain
402
399
  while IFS= read -r rel_path; do
403
400
  case "$rel_path" in .forge/*|*/node_modules/*|node_modules/*|*/__pycache__/*) continue ;; esac
404
401
  domain="unclassified"
405
- for pattern in "${!DOMAIN_PATTERNS[@]}"; do
402
+ i=0
403
+ while [[ "$i" -lt "$DOMAIN_COUNT" ]]; do
404
+ pattern="${DOMAIN_PREFIXES[$i]}"
406
405
  if [[ "$rel_path" == $pattern* ]]; then
407
- domain="${DOMAIN_PATTERNS[$pattern]}"
406
+ domain="${DOMAIN_LABELS[$i]}"
408
407
  break
409
408
  fi
409
+ i=$((i + 1))
410
410
  done
411
411
  echo -e "${rel_path}\t${domain}"
412
412
  done < "$FILE_LIST" >> "$DOMAIN_FILE" 2>/dev/null || true
@@ -415,10 +415,10 @@ done < "$FILE_LIST" >> "$DOMAIN_FILE" 2>/dev/null || true
415
415
  # Step 5: Write index manifest
416
416
  # ---------------------------------------------------------------------------
417
417
  TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date +"%Y-%m-%dT%H:%M:%SZ")
418
- SYMBOL_COUNT=$(wc -l < "$SYMBOLS_FILE" 2>/dev/null || echo 0)
419
- IMPORT_COUNT=$(wc -l < "$IMPORTS_FILE" 2>/dev/null || echo 0)
420
- CALLER_COUNT=$(wc -l < "$CALLERS_FILE" 2>/dev/null || echo 0)
421
- FILE_COUNT=$(wc -l < "$DOMAIN_FILE" 2>/dev/null || echo 0)
418
+ SYMBOL_COUNT=$( (wc -l < "$SYMBOLS_FILE" 2>/dev/null || echo 0) | tr -d " ")
419
+ IMPORT_COUNT=$( (wc -l < "$IMPORTS_FILE" 2>/dev/null || echo 0) | tr -d " ")
420
+ CALLER_COUNT=$( (wc -l < "$CALLERS_FILE" 2>/dev/null || echo 0) | tr -d " ")
421
+ FILE_COUNT=$( (wc -l < "$DOMAIN_FILE" 2>/dev/null || echo 0) | tr -d " ")
422
422
 
423
423
  # Sanity check: if source files that should have produced symbols/imports were
424
424
  # found, but extraction came back empty, a loop was truncated somewhere above
@@ -38,7 +38,7 @@ mkdir -p "$REPO/.venv/lib"; printf 'def venv_only_fn():\n helper_fn()\n' > "$
38
38
  OUT="$WORK/out"
39
39
  bash "$SCRIPT" --repo-path "$REPO" --output "$OUT" > "$WORK/log" 2>&1; rc=$?
40
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)
41
+ C=$(find "$OUT" -mindepth 1 -maxdepth 1 -type d | head -n 1)
42
42
  for f in symbols imports callers file_domain; do
43
43
  if [ -s "$C/$f.tsv" ]; then ok; else bad "$f.tsv is empty"; fi
44
44
  if grep -qE '\.claude/|\.forge/|\.venv/|worktree_only_fn|forge_wt_only_fn|venv_only_fn' "$C/$f.tsv" 2>/dev/null; then
@@ -65,9 +65,28 @@ printf 'def venv_only_fn():\n pass\n' > "$PLAIN/.venv/lib/site.py"
65
65
  printf 'def worktree_only_fn():\n pass\n' > "$PLAIN/.claude/worktrees/x/src/w.py"
66
66
  bash "$SCRIPT" --repo-path "$PLAIN" --output "$WORK/out2" > "$WORK/log3" 2>&1; rc=$?
67
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)
68
+ C2=$(find "$WORK/out2" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -n 1)
69
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
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
71
 
72
+ # --- 5. bash 3.2 portable (forge#4235): stock macOS bash has no associative arrays or mapfile ---
73
+ if grep -vE '^[[:space:]]*#' "$SCRIPT" | grep -qE '(declare|local|typeset) -[a-zA-Z]*A|mapfile|readarray|grep -[a-zA-Z]*P'; then
74
+ bad "code-index.sh stays bash 3.2 portable (no associative arrays / mapfile): $(grep -nE '(declare|local|typeset) -[a-zA-Z]*A|mapfile|readarray' "$SCRIPT" | head -n 3 | tr '\n' ' ')"
75
+ else ok; fi
76
+
77
+ # --- 6. file-domain map: the most specific prefix wins, deterministically ---
78
+ DOM="$WORK/dom"; mkdir -p "$DOM/services/api/auth" "$DOM/web/src/app/billing" "$DOM/scripts"
79
+ printf 'x = 1\n' > "$DOM/services/api/auth/login.py"; printf 'y = 1\n' > "$DOM/services/api/routes.py"
80
+ printf 'export const p = 1\n' > "$DOM/web/src/app/billing/page.tsx"; printf 'echo hi\n' > "$DOM/scripts/x.sh"
81
+ git -C "$DOM" init -q -b main; git -C "$DOM" add -A; git -C "$DOM" commit -qm init
82
+ bash "$SCRIPT" --repo-path "$DOM" --output "$WORK/out3" > "$WORK/log4" 2>&1; rc=$?
83
+ if [ "$rc" -eq 0 ]; then ok; else bad "domain fixture build exit $rc: $(head -c 400 "$WORK/log4")"; fi
84
+ C3=$(find "$WORK/out3" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -n 1)
85
+ for want in "services/api/auth/login.py auth" "services/api/routes.py api" "web/src/app/billing/page.tsx billing" "scripts/x.sh pipeline-scripts"; do
86
+ if grep -qxF "$want" "$C3/file_domain.tsv" 2>/dev/null; then ok; else bad "file_domain.tsv missing '$want': $(tr '\n' ' ' < "$C3/file_domain.tsv" 2>/dev/null)"; fi
87
+ done
88
+ q=$(bash "$SCRIPT" query --domain auth --repo-path "$DOM" --output "$WORK/out3" 2>/dev/null)
89
+ if [ "$q" = "services/api/auth/login.py auth" ]; then ok; else bad "query --domain auth returned '$q'"; fi
90
+
72
91
  echo "code-index tests: $pass passed, $fail failed"
73
92
  [ "$fail" -eq 0 ]
@@ -21,3 +21,5 @@ jobs:
21
21
  go test ./...
22
22
  - name: docs
23
23
  run: ./scripts/check-docs
24
+ - name: vet
25
+ run: make vet
@@ -12,7 +12,7 @@ eq() { if [ "$2" = "$3" ]; then ok; else bad "$1 (got: $2 want: $3)"; fi }
12
12
  q() { printf '%s' "$OUT" | jq -c "$1"; }
13
13
 
14
14
  new_repo() {
15
- rm -rf "$TMP/$1"; mkdir -p "$TMP/$1"; cd "$TMP/$1" || exit 1
15
+ rm -rf "${TMP:?}/$1"; mkdir -p "$TMP/$1"; cd "$TMP/$1" || exit 1
16
16
  git init -q -b main; git config user.email t@example.com; git config user.name t; git config commit.gpgsign false
17
17
  }
18
18
  commit() { git add -A; git commit -qm "$1"; }
@@ -44,12 +44,12 @@ jobs:
44
44
  runs-on: ubuntu-latest
45
45
  timeout-minutes: 45
46
46
  steps:
47
- - run: make lint-all
47
+ - run: pytest tests/slow
48
48
  quick:
49
49
  runs-on: ubuntu-latest
50
50
  timeout-minutes: 5
51
51
  steps:
52
- - run: make fmt
52
+ - run: ruff check .
53
53
  EOF
54
54
  lines src/big.ts 2100; commit c1
55
55
  echo x >> src/big.ts; lines src/small.ts 10; commit c2
@@ -62,8 +62,8 @@ eq "packages" "$(q '.layer_rules.packages')" '[{"name":"@x/a","dir":"packages/a"
62
62
  eq "edge" "$(q '.layer_rules.edges')" '[{"from":"@x/a","to":"@x/b","kind":"dependencies","manifest":"packages/a/package.json"}]'
63
63
  eq "boundary configs" "$(q '.layer_rules.boundary_configs')" '[".eslintrc.js"]'
64
64
  eq "services job" "$(q '[.slow_suites[] | select(.command == "pnpm run test") | .why]')" '["CI job .github/workflows/ci.yml#jobs.db uses services"]'
65
- eq "timeout job" "$(q '[.slow_suites[] | select(.command == "make lint-all") | .why]')" '["CI job .github/workflows/ci.yml#jobs.long sets timeout-minutes 45"]'
66
- eq "quick job not slow" "$(q '[.slow_suites[] | select(.command == "make fmt")] | length')" 0
65
+ eq "timeout job" "$(q '[.slow_suites[] | select(.command == "pytest tests/slow") | .why]')" '["CI job .github/workflows/ci.yml#jobs.long sets timeout-minutes 45"]'
66
+ eq "quick job not slow" "$(q '[.slow_suites[] | select(.command == "ruff check .")] | length')" 0
67
67
  eq "hotspot" "$(q '.hotspots')" '[{"path":"src/big.ts","lines":2101,"commits_90d":2,"hotspot":4202}]'
68
68
 
69
69
  # ---- 2. a cached test map is read: its e2e command is a slow suite ----
@@ -87,15 +87,23 @@ case "$MERGE_ISSUE" in *[!0-9]*) MERGE_ISSUE="" ;; esac
87
87
  [ "$THOROUGH" = "true" ] || THOROUGH="false"
88
88
 
89
89
  # --- trusted helper resolution (install roots only; never cwd) ---
90
+ # shellcheck source=/dev/null # the resolver lib is found at runtime in the trusted install root
90
91
  type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; _SELF_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd -P)"; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" "${CLAUDE_PLUGIN_ROOT:-}" "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/* "$_SELF_ROOT"; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
91
92
 
92
93
  ROSTER_SCRIPT=$(forge_resolve review-roster.sh)
93
94
  TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
95
+ # shellcheck disable=SC2034 # read by lib/review-delta-mode.sh (sourced below)
94
96
  REVIEW_DELTA_SCRIPT=$(forge_resolve review-delta.sh)
95
97
  CODE_INDEX_SCRIPT=$(forge_resolve code-index.sh)
96
- _l=$(forge_resolve lib/pr-issue-number.sh); if [ -n "$_l" ]; then . "$_l"; else pr_issue_number() { return 0; }; fi
97
- _l=$(forge_resolve lib/review-template-resolve.sh); if [ -n "$_l" ]; then . "$_l"; else review_template_resolve() { TEMPLATE_BASE=""; TEMPLATE_SOURCE="none"; MONOLITHIC_CATALOG=""; }; fi
98
- _l=$(forge_resolve lib/review-delta-mode.sh); if [ -n "$_l" ]; then . "$_l"; else review_delta_mode() { echo "FULL - 0"; }; carry_verified_from() { :; }; fi
98
+ _l=$(forge_resolve lib/pr-issue-number.sh)
99
+ # shellcheck source=/dev/null # resolved at runtime from the trusted install root
100
+ if [ -n "$_l" ]; then . "$_l"; else pr_issue_number() { return 0; }; fi
101
+ _l=$(forge_resolve lib/review-template-resolve.sh)
102
+ # shellcheck source=/dev/null # resolved at runtime from the trusted install root
103
+ if [ -n "$_l" ]; then . "$_l"; else review_template_resolve() { TEMPLATE_BASE=""; TEMPLATE_SOURCE="none"; MONOLITHIC_CATALOG=""; }; fi
104
+ _l=$(forge_resolve lib/review-delta-mode.sh)
105
+ # shellcheck source=/dev/null disable=SC2329 # resolved at runtime; the fallback stubs mirror the lib's API
106
+ if [ -n "$_l" ]; then . "$_l"; else review_delta_mode() { echo "FULL - 0"; }; carry_verified_from() { :; }; fi
99
107
 
100
108
  # --- state, scratch and the ref-restore guard ---
101
109
  BLOCKERS=""
@@ -105,12 +113,14 @@ mkdir -p "$OUT_DIR" 2>/dev/null || { echo "review-prepare: cannot create $OUT_DI
105
113
  TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/review-prepare.XXXXXX") || exit 2
106
114
  ORIG_REF=""; CHECKED_OUT=""
107
115
  # restore_ref: put the checkout back exactly where it was. -f so a dirty review checkout can never wedge the restore.
116
+ # shellcheck disable=SC2329 # called from cleanup (EXIT trap) and the checkout paths
108
117
  restore_ref() {
109
118
  if [ -n "$CHECKED_OUT" ] && [ -n "$ORIG_REF" ]; then
110
119
  git checkout -f "$ORIG_REF" >/dev/null 2>&1 || echo "review-prepare: WARNING could not restore $ORIG_REF" >&2
111
120
  fi
112
121
  CHECKED_OUT=""
113
122
  }
123
+ # shellcheck disable=SC2329 # invoked by the EXIT trap
114
124
  cleanup() { restore_ref; rm -rf "$TMP_DIR"; }
115
125
  trap cleanup EXIT
116
126
 
@@ -144,6 +154,7 @@ if [ -z "$BLOCKERS" ]; then
144
154
  # Pinned (forge#3935): same-repo PRs only (a fork's branch name resolves to origin's own branch), and the fetched tip must equal REVIEW_SHA; the diff is taken against REVIEW_SHA, never the branch name.
145
155
  if { [ "$NO_DELTA" = "true" ] || [ "$IS_MILESTONE_TO_STAGING" = "true" ]; } && [ -n "$BASE" ] && [ -n "$HEAD" ] && [ "$(printf '%s' "$PR_INFO" | jq -r .isCrossRepository 2>/dev/null)" = "false" ] && printf '%s' "$REVIEW_SHA" | grep -qE '^[0-9a-f]{40}$' \
146
156
  && git fetch -q origin "$BASE" "$HEAD" 2>/dev/null && [ "$(git rev-parse -q --verify "refs/remotes/origin/${HEAD}^{commit}" 2>/dev/null)" = "$REVIEW_SHA" ]; then
157
+ # shellcheck disable=SC2015 # either assignment failing resets both, by design
147
158
  FILES=$(git diff --name-only "origin/${BASE}...${REVIEW_SHA}" 2>/dev/null) && DIFF=$(git diff "origin/${BASE}...${REVIEW_SHA}" 2>/dev/null) || { FILES=""; DIFF=""; }
148
159
  fi
149
160
  [ -n "$FILES" ] || block "gh pr diff failed"
@@ -420,7 +431,9 @@ canonical_roster() { local out="" a d; for a in $1; do d=$(agent_domain "$a"); c
420
431
  SELECTED_AGENTS=$(canonical_roster "$SELECTED_AGENTS")
421
432
 
422
433
  # --- Phase 3C: review mode (FULL | DELTA | BASE_SYNC_ONLY) ---
434
+ # shellcheck disable=SC2034 # both read by lib/review-delta-mode.sh
423
435
  BASE_BRANCH="$BASE"
436
+ # shellcheck disable=SC2034
424
437
  COMMENTS_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null || echo "")
425
438
  delta_roster() {
426
439
  local rounds="${1}" full="${2}" files="${3}" out="security" root
@@ -473,11 +486,20 @@ if [ "$REVIEW_MODE" != "BASE_SYNC_ONLY" ]; then
473
486
  _ito="${REVIEW_PREPARE_INDEX_TIMEOUT:-120}"; case "$_ito" in ''|*[!0-9]*|0) _ito=120 ;; esac
474
487
  _tbin=$(command -v timeout || command -v gtimeout || true)
475
488
  echo "review-prepare: building code index (limit ${_ito}s)" >&2
476
- if [ -n "$_tbin" ]; then "$_tbin" "$_ito" bash "$CODE_INDEX_SCRIPT" --repo-path "$REPO_PATH" >/dev/null 2>&1; _irc=$?
477
- else bash "$CODE_INDEX_SCRIPT" --repo-path "$REPO_PATH" >/dev/null 2>&1; _irc=$?; fi
489
+ # forge#4235: keep the build's stderr so a failure is diagnosable (its tail is echoed below); stdout stays discarded.
490
+ _ierr=$(mktemp "$OUT_DIR/.code-index-err-XXXXXX" 2>/dev/null) || _ierr=/dev/null
491
+ if [ -n "$_tbin" ]; then "$_tbin" "$_ito" bash "$CODE_INDEX_SCRIPT" --repo-path "$REPO_PATH" >/dev/null 2>"$_ierr"; _irc=$?
492
+ else bash "$CODE_INDEX_SCRIPT" --repo-path "$REPO_PATH" >/dev/null 2>"$_ierr"; _irc=$?; fi
478
493
  if [ "$_irc" -eq 0 ]; then INDEX_OK=1
479
494
  elif [ "$_irc" -eq 124 ]; then echo "review-prepare: code index timed out after ${_ito}s — skipping index slices (agents grep instead)" >&2
480
- else echo "review-prepare: code index failed (exit $_irc) — skipping index slices (agents grep instead)" >&2; fi
495
+ else
496
+ echo "review-prepare: code index failed (exit $_irc) — skipping index slices (agents grep instead)" >&2
497
+ # forge#4316: the tail can name repo files; show the repo root as <repo>, strip control chars, cap each line
498
+ [ -s "$_ierr" ] && tail -n 20 "$_ierr" | tr -d '\000-\010\013-\037\177' \
499
+ | awk -v r="$REPO_PATH" '{ l = $0; if (r != "") { o = ""; while ((i = index(l, r)) > 0) { o = o substr(l, 1, i - 1) "<repo>"; l = substr(l, i + length(r)) } l = o l } print substr(l, 1, 200) }' \
500
+ | sed 's/^/review-prepare: code-index: /' >&2
501
+ fi
502
+ [ "$_ierr" = /dev/null ] || rm -f "$_ierr"
481
503
  fi
482
504
  build_index_slice() {
483
505
  local domain="${1}" files
@@ -109,7 +109,9 @@ setup feat/x staging "README.md" "$DIFF_PLAIN"; printf 'CONFLICTING|DIRTY\n' > "
109
109
  prep "$ROOT"; eq "conflicting" .merge_health.mergeable CONFLICTING; eq "conflicting state" .merge_health.state DIRTY; eq "no retry on CONFLICTING" .merge_health.retries 0
110
110
  setup feat/x staging "README.md" "$DIFF_PLAIN"; printf 'UNKNOWN|UNKNOWN\n' > "$FIX/merge.seq"
111
111
  prep "$ROOT"; eq "unknown stays unknown after 3 retries" .merge_health.retries 3; eq "still UNKNOWN" .merge_health.mergeable UNKNOWN
112
- setup feat/x staging "README.md" "$DIFF_PLAIN"; printf 'garbage|$(rm -rf)\n' > "$FIX/merge.seq"
112
+ setup feat/x staging "README.md" "$DIFF_PLAIN"
113
+ # shellcheck disable=SC2016 # the literal $(rm -rf) is the hostile payload under test
114
+ printf 'garbage|$(rm -rf)\n' > "$FIX/merge.seq"
113
115
  prep "$ROOT"; eq "garbage state normalized" .merge_health.state UNKNOWN
114
116
  setup feat/x staging "README.md" "$DIFF_PLAIN"; echo 0 > "$FIX/prior"
115
117
  prep "$ROOT"; eq "first-time contributor" .pr.first_time_contributor true
@@ -234,6 +236,7 @@ CFG_OVERRIDE="$WORK/emptyroot.yaml" prep "$WORK/root-notpl"
234
236
  rc_is "no template source" 3; eq "templates none" .templates.source none
235
237
 
236
238
  # --- 8. issue number extraction and trusted contract ---
239
+ # shellcheck source=/dev/null
237
240
  . "$ROOT/scripts/lib/pr-issue-number.sh"
238
241
  chk() { local got; got=$(pr_issue_number_of "$2"); if [ "$got" = "$3" ]; then ok; else bad "$1: got '$got', want '$3'"; fi; }
239
242
  chk "Closes" "Closes #12" 12
@@ -271,6 +274,7 @@ setup feat/x staging "README.md" "$DIFF_PLAIN" "00000000000000000000000000000000
271
274
  prep "$ROOT" --checkout-pr
272
275
  rc_is "unknown sha cannot be checked out" 3
273
276
  if [ "$(git -C "$REPO_DIR" symbolic-ref --short HEAD 2>/dev/null)" = "main" ]; then ok; else bad "failed checkout moved HEAD"; fi
277
+ # shellcheck disable=SC2016 # matches the literal $ORIG_REF in the script source
274
278
  if grep -q 'checkout -f "\$ORIG_REF"' "$ROOT/scripts/review-prepare.sh"; then ok; else bad "restore_ref must use git checkout -f"; fi
275
279
 
276
280
  # --- 10. fail closed on gh and bad arguments ---
@@ -317,8 +321,24 @@ eq "a hostile cwd roster script is never used" '.roster.agents | index("evil") !
317
321
  # --- code index build is bounded and visible (forge#4126) ---
318
322
  setup feat/x staging "services/api/auth.py" "$DIFF_AUTH_BILL"
319
323
  prep "$ROOT"
320
- eq "real index fills the auth slice" '.index_slices.auth | startswith("Domain files (auth):")' true
324
+ _f0=$fail; eq "real index fills the auth slice" '.index_slices.auth | startswith("Domain files (auth):")' true
325
+ # forge#4235: on failure show review-prepare's stderr (it carries the index build's exit code and stderr tail)
326
+ [ "$fail" -eq "$_f0" ] || sed 's/^/ stderr: /' "$WORK/stderr"
321
327
  if grep -q "review-prepare: building code index" "$WORK/stderr"; then ok; else bad "index build must print a progress line on stderr"; fi
328
+ # a failing build reports its exit code and stderr tail, and the slices fall back to grep
329
+ mkroot "$WORK/root-failindex" "" "" templates
330
+ # forge#4316: a line naming a repo file, carrying an ESC and over 200 chars is echoed sanitised and capped
331
+ # shellcheck disable=SC2016 # $2 is the fake index script's own argument
332
+ printf '#!/usr/bin/env bash\necho "index-boom-marker" >&2\nprintf "parse error in %%s/secret/file.py \\033[31m%%0300d\\n" "$2" 0 >&2\nexit 3\n' > "$WORK/root-failindex/scripts/code-index.sh"
333
+ prep "$WORK/root-failindex"
334
+ rc_is "failing index build" 0
335
+ if grep -q "code index failed (exit 3)" "$WORK/stderr"; then ok; else bad "index failure must report the build's exit code on stderr"; fi
336
+ if grep -q "review-prepare: code-index: index-boom-marker" "$WORK/stderr"; then ok; else bad "index failure must echo the build's stderr tail: $(head -c 300 "$WORK/stderr")"; fi
337
+ _ix=$(grep "review-prepare: code-index: parse error" "$WORK/stderr")
338
+ case "$_ix" in "review-prepare: code-index: parse error in <repo>/secret/file.py [31m0"*) ok ;; *) bad "index stderr must show the repo root as <repo> without control chars: $(printf '%s' "$_ix" | head -c 120 | od -c | head -3)" ;; esac
339
+ if [ "${#_ix}" -le $((28 + 200)) ]; then ok; else bad "index stderr line must be capped at 200 chars, got ${#_ix}"; fi
340
+ eq "failed index falls back to grep slices" .index_slices.auth "Code index not available — agent will use grep exploration"
341
+ if ls "$WORK/out"/.code-index-err-* >/dev/null 2>&1; then bad "index stderr temp file must be removed"; else ok; fi
322
342
  if command -v timeout >/dev/null 2>&1 || command -v gtimeout >/dev/null 2>&1; then
323
343
  mkroot "$WORK/root-slowindex" "" "" templates
324
344
  printf '#!/usr/bin/env bash\nsleep 30\n' > "$WORK/root-slowindex/scripts/code-index.sh"