forgedock 1.18.0 → 1.19.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 (116) hide show
  1. package/bin/engine/outcome-codes.mjs +13 -0
  2. package/bin/engine/phases.mjs +54 -4
  3. package/bin/engine/runlog.mjs +10 -2
  4. package/bin/engine/telemetry-seam.mjs +3 -0
  5. package/bin/engine/telemetry.test.mjs +3 -2
  6. package/bin/engine-cli.mjs +32 -2
  7. package/bin/engine.mjs +139 -7
  8. package/bin/runner.mjs +69 -9
  9. package/bin/stats/aggregate.mjs +7 -3
  10. package/bin/stats/cache.mjs +7 -3
  11. package/bin/stats/github-source.mjs +136 -28
  12. package/bin/stats/index.mjs +46 -9
  13. package/bin/stats/render.mjs +1 -1
  14. package/bin/stats/stats.test.mjs +201 -40
  15. package/commands/review-pr-staging.md +4 -4
  16. package/commands/review-pr.md +25 -15
  17. package/commands/work-on/build/architect.md +53 -0
  18. package/commands/work-on/build/implement.md +19 -0
  19. package/commands/work-on/build/validate.md +320 -28
  20. package/commands/work-on/build.md +66 -9
  21. package/commands/work-on/investigate.md +7 -0
  22. package/commands/work-on/remediate.md +32 -13
  23. package/commands/work-on/review.md +32 -19
  24. package/docs/CONFIG.md +89 -2
  25. package/docs/OUTCOME-CODES.md +5 -1
  26. package/docs/STATS.md +11 -4
  27. package/docs/TELEMETRY.md +5 -2
  28. package/docs/WORK-ON-RUNTIME.md +18 -0
  29. package/package.json +2 -2
  30. package/packages/protocol/src/types.js +1 -1
  31. package/scripts/acceptance-red-green.sh +90 -0
  32. package/scripts/acceptance-red-green.test.sh +74 -0
  33. package/scripts/cap-findings.test.sh +2 -0
  34. package/scripts/check-contract-scope.test.sh +2 -0
  35. package/scripts/check-edge-cases.sh +143 -0
  36. package/scripts/check-edge-cases.test.sh +175 -0
  37. package/scripts/check-spec-markers.sh +2 -0
  38. package/scripts/check-test-tmp-cleanup.sh +56 -0
  39. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  40. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  41. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  42. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  43. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  44. package/scripts/ci-local/adapters/common.mjs +115 -0
  45. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  46. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  47. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  48. package/scripts/ci-local/adapters/portability.mjs +104 -0
  49. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  50. package/scripts/ci-local/cli.mjs +50 -0
  51. package/scripts/ci-local/lib.mjs +371 -0
  52. package/scripts/ci-local/plan.mjs +186 -0
  53. package/scripts/ci-local/run.mjs +345 -0
  54. package/scripts/ci-local/scoping.mjs +508 -0
  55. package/scripts/ci-local.sh +97 -0
  56. package/scripts/ci-local.test.sh +919 -0
  57. package/scripts/classify-finding.sh +32 -6
  58. package/scripts/classify-finding.test.sh +23 -0
  59. package/scripts/classify-test-run.sh +69 -0
  60. package/scripts/classify-test-run.test.sh +90 -0
  61. package/scripts/close-parent-c3.test.sh +2 -0
  62. package/scripts/consumer-sweep.sh +569 -0
  63. package/scripts/consumer-sweep.test.sh +357 -0
  64. package/scripts/detect-append-registries.sh +106 -0
  65. package/scripts/detect-append-registries.test.sh +64 -0
  66. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  67. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  68. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  69. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  70. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  71. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  72. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  73. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  74. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  75. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  76. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  77. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  78. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  79. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  80. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  81. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  82. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  83. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  84. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  85. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  86. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  87. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  88. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  89. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  90. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  91. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  92. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  93. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  94. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  95. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  96. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  97. package/scripts/forge-root.test.sh +1 -1
  98. package/scripts/lib/telemetry.sh +1 -0
  99. package/scripts/remediate-approved-head.test.sh +2 -0
  100. package/scripts/remediate-basesync.test.sh +1 -1
  101. package/scripts/review-acceptance-gate.sh +2 -2
  102. package/scripts/review-acceptance-gate.test.sh +20 -0
  103. package/scripts/review-delta.sh +8 -1
  104. package/scripts/review-delta.test.sh +19 -0
  105. package/scripts/review-finalize.sh +193 -41
  106. package/scripts/review-finalize.test.sh +211 -4
  107. package/scripts/review-pr-delta-wiring.test.sh +13 -0
  108. package/scripts/review-prepare.sh +5 -3
  109. package/scripts/review-prepare.test.sh +18 -2
  110. package/scripts/run-shell-tests.sh +5 -0
  111. package/scripts/telemetry.test.sh +2 -0
  112. package/scripts/trusted-comments.test.sh +2 -1
  113. package/scripts/verify-phase-trail.test.sh +8 -0
  114. package/scripts/wait-ci-green.sh +61 -9
  115. package/scripts/wait-ci-green.test.sh +41 -3
  116. package/scripts/with-run-tmpdir.sh +20 -0
package/docs/CONFIG.md CHANGED
@@ -481,13 +481,17 @@ engine:
481
481
  | `<key>_model` | string | No | Model alias (`sonnet`, `opus`, `haiku`) or full model id passed as `--model`. Invalid values are ignored. |
482
482
  | `<key>_max_turns` | integer | No | Positive integer passed as `--max-turns`. Invalid values are ignored. |
483
483
 
484
+ | `ci_pending_max_minutes` | integer | No | Minutes the engine waits for CI when the review phase ends BLOCKED with "checks still pending", or the remediate phase holds at its M7 re-gate on "CI pending" (the merge gate's wait window ended while CI was still running). Default: 90. Invalid values are ignored. This key is flat like the others but is not part of the `<key>_model` / `<key>_max_turns` pattern. |
485
+
486
+ **CI still pending**: the engine polls `scripts/wait-ci-green.sh` (no model calls) one read per poll on a 60 s, 2 min, 4 min, 5 min (cap) backoff with +/-20% jitter, while the lease keeps renewing, then re-dispatches the phase that was waiting, review or remediate (idempotent at an unchanged head: review re-runs the merge gate and merges; remediate resumes at its M7 re-gate). Before each poll it reads `gh api rate_limit` (not itself rate-limited) and, below 10% of the REST budget, sleeps until the window resets instead of polling; a rate-limited or transient (5xx, network) poll is retried and never re-dispatches the phase. Red CI takes the normal ci-gate path. When the budget is exhausted the run ends `needs-human` with outcome code `review.ci_pending_timeout`; `--retry` resumes. Each wait emits a `ci_pending_wait` telemetry event (`docs/TELEMETRY.md`) with the minutes waited, polls made and rate-limit sleeps.
487
+
484
488
  **Precedence** (model): `engine.<key>_model`, then the run-level model the caller passed explicitly, then omitted. `max_turns` comes only from this section. No defaults ship.
485
489
 
486
490
  **Version gate**: the flags are added only when the installed `claude --version` supports them (`--max-turns` is hidden from `claude --help`, so help text is never scraped); an unparseable version omits both flags.
487
491
 
488
492
  **Turn exhaustion**: when `--max-turns` is exhausted the run ends `engine-error` (detail `max-turns`) and the phase is never committed, so `forgedock run-issue --retry` re-enters the same phase. Raise `<key>_max_turns` before retrying. Phase runlog events record `model` and `turns` next to `usage` so a limit can be sized from measured turn counts.
489
493
 
490
- **Per-run scratch dir** (`tmp_root`): every engine child runs with `TMPDIR` and `CLAUDE_CODE_TMPDIR` set to `<tmp_root>/<issue>` so venvs and Claude session data stay off a size-limited `/tmp`. `engine.tmp_root` is an absolute or `~`-prefixed path; default `~/.forge/tmp`. When `tmp_root` is unset and the operator already exported `TMPDIR` or `CLAUDE_CODE_TMPDIR`, that choice is respected and never cleaned. The dir is removed on a terminal outcome and kept on `engine-error` so `--retry` resumes. A disk-full/quota failure (EDQUOT, ENOSPC, errno -28/-122) ends a retryable `engine-error` (outcome code `infra.disk_quota`), never `needs-human`.
494
+ **Per-run scratch dir** (`tmp_root`): every engine child runs with `TMPDIR` and `CLAUDE_CODE_TMPDIR` set to `<tmp_root>/<repo-key>/<issue>` so venvs and Claude session data stay off a size-limited `/tmp`. `engine.tmp_root` is an absolute or `~`-prefixed path; default `~/.forge/tmp`. The `<repo-key>` is the checkout's directory name plus 8 hex characters of a hash of its real path, so the same issue number in two repos never shares a dir; the dir is created `0700` and an existing symlink or dir owned by another user is not used (the child keeps the inherited tmp). Old `<tmp_root>/<issue>` dirs from earlier versions are not migrated. When `tmp_root` is unset and the operator already exported `TMPDIR` or `CLAUDE_CODE_TMPDIR`, that choice is respected and never cleaned. The dir is removed on a terminal outcome and kept on `engine-error` so `--retry` resumes. A disk-full/quota failure (EDQUOT, ENOSPC, errno -28/-122) ends a retryable `engine-error` (outcome code `infra.disk_quota`), never `needs-human`.
491
495
 
492
496
  **Commands that use this section**: CLI backend (`runCommand` in `bin/runner.mjs`)
493
497
 
@@ -736,6 +740,10 @@ verification:
736
740
  - name: "web"
737
741
  container: "acme-web-blue"
738
742
  health_url: "http://localhost:3000"
743
+
744
+ consumer_sweep:
745
+ mode: advisory # advisory (default) | blocking
746
+ budget_seconds: 60 # wall-clock budget for the sweep
739
747
  ```
740
748
 
741
749
  | Field | Type | Required | Description |
@@ -746,8 +754,87 @@ verification:
746
754
  | `services[].name` | string | **Yes (per entry)** | Human-readable service name |
747
755
  | `services[].container` | string | No | Docker container name for `docker exec` verification |
748
756
  | `services[].health_url` | string | No | Local URL for container-level health checks |
757
+ | `ci_local` | object | No | Local CI gate settings (see below) |
758
+ | `consumer_sweep.mode` | string | No | `advisory` (default): the validate consumer sweep (`scripts/consumer-sweep.sh`, V4.6) records unaddressed referencing sites and never fails. `blocking`: unaddressed sites fail validate with `consumer_unaddressed`, build runs one repair round, and sites still unaddressed are recorded as `CONSUMERS: unresolved` and passed to review as known risk (never `needs-human`). Flip to `blocking` only after `bash scripts/consumer-sweep.sh --eval-history 20` shows a mean of at most 2 flagged sites per merge and none above 10 on your repo |
759
+ | `consumer_sweep.budget_seconds` | integer | No | Wall-clock budget for the sweep (default `60`). When it runs out the sweep stops with `BUDGET_EXCEEDED`, reports the partial result, and never fails, in either mode |
760
+
761
+ **Commands that use this section**: `quality-gate`, `validate` (V2 runs `verification.commands`; V4.8 runs the local CI gate; health checks and `consumer_sweep`), `work-on`
762
+
763
+ ### `verification.ci_local` (OPTIONAL)
764
+
765
+ Validate Phase V4.8 runs `scripts/ci-local.sh` before review: it derives the repo's pull-request checks from its own CI
766
+ definitions and runs them locally in the build worktree, so deterministic CI failures are fixed in build (at most 2 fix
767
+ iterations; a failure still unfixable after that is DEGRADED by default, see `mode`). It never contacts GitHub and never triggers a remote
768
+ workflow; remote CI still runs once, at merge. Everything works with no configuration.
769
+
770
+ ```yaml
771
+ verification:
772
+ ci_local:
773
+ mode: advisory
774
+ exclude_jobs: ["gha:ci:e2e-*"]
775
+ include_jobs: []
776
+ extra_commands:
777
+ - "make lint-docs"
778
+ services: auto
779
+ budget_minutes: 15
780
+ max_parallel: 4
781
+ ```
749
782
 
750
- **Commands that use this section**: `quality-gate`, `validate`
783
+ | Field | Type | Default | Description |
784
+ |-------|------|---------|-------------|
785
+ | `mode` | `advisory` \| `blocking` | `advisory` | `advisory`: a runnable job still failing after the 2 fix iterations is DEGRADED (`DEGRADED_CHECKS+=ci-local`), recorded in `FORGE:BUILDER` under `### Local CI unresolved` and surfaced to review; the build is not blocked. `blocking` (opt-in): the build is blocked with `ci-local: <jobs>`. Flip to `blocking` only once telemetry (`ci_local` events) shows a low false-fail rate on your repo |
786
+ | `exclude_jobs` | array of globs | `[]` | Job ids or names to drop from the plan (matched against `gha:<workflow>:<job>`, `gitlab:<job>`, and the job name) |
787
+ | `include_jobs` | array of globs | `[]` | When non-empty, only matching CI jobs run (an allowlist); `extra_commands` and the portability checks (when they apply) always run |
788
+ | `extra_commands` | array of strings or `{name, command}` | `[]` | Extra commands always added to the plan. `verification.commands` is not repeated here: V2 already runs it as the fast pre-step |
789
+ | `services` | `auto` \| `off` | `auto` | `auto` starts a job's service containers with docker (same image, env, ports), tearing them down afterwards; without docker, or with `off`, such jobs are deferred |
790
+ | `budget_minutes` | number | `15` | Overall wall-time budget; jobs not finished or started within it leave the run DEGRADED (never PASS, never FAIL). One invocation is capped at 9 minutes whatever this says (the Bash tool limit is 10): beyond that the run is a partial DEGRADED result and re-running resumes from the result cache (`FORGE_CI_LOCAL_MAX_INVOCATION_SECONDS`, default 540) |
791
+ | `max_parallel` | number | `4` | Jobs run in parallel by one run (also bounded machine-wide, see below) |
792
+
793
+ **Trusted config**: every field above is read from the main checkout's `forge.yaml` (for a plain checkout, the base ref's
794
+ copy), never from the PR's own tree, so a PR cannot exclude jobs from the gate that checks it.
795
+
796
+ **Where checks come from**: GitHub Actions (`.github/workflows`), GitLab CI, CircleCI, Azure Pipelines, Bitbucket Pipelines
797
+ and Buildkite definitions, read from the trusted base ref plus the PR's own edits (a PR can add a check but cannot delete
798
+ one). Only when the repo has NO CI definitions are its task runners used; when CI exists but its path filters leave no job
799
+ applicable the run executes nothing and records `no_applicable_jobs` (a docs-only change never triggers a full suite) (`package.json` scripts, Makefile, justfile,
800
+ Taskfile, tox/nox, pre-commit, pyproject tools, Cargo, Go, Maven, Gradle). Deterministic portability checks (Windows-invalid
801
+ filenames, case collisions, CRLF in scripts, bash 3.2 constructs and GNU-only flags on added lines, plus the repo's
802
+ `scripts/check-shell-portability.sh` when present) run only when the repo's CI definitions target macOS or Windows (a
803
+ `macos-*`/`windows-*` runner, matrix entry or executor); for a Linux-only repo they are skipped with a note, so correct
804
+ Linux-only code is never blocked on bash 3.2 rules.
805
+
806
+ **Deferred to CI** (listed in the `FORGE:BUILDER` comment under `### Deferred to CI`, never guessed): jobs that reference
807
+ `secrets.` or `environment:`, deploy/publish jobs, `pull_request_target`, schedule/dispatch-only workflows, unknown `uses:`
808
+ actions, orbs, plugins and templates, service containers without docker, macOS/Windows variants on another host, matrix
809
+ variants beyond the first host-compatible one, and jobs whose tools are not installed locally. A failed dependency install
810
+ (for example offline) defers the job instead of failing it, and so does a failure of a job whose pinned toolchain differs
811
+ from the host by a major version (`setup-node` 18 vs host 26: version drift, listed with its reason). A job timeout is
812
+ DEGRADED, never FAIL, and its clock starts when the job starts executing, not while it waits for a machine slot.
813
+
814
+ **Exact scoping** (runs only what the change can affect): job-level path filters from the CI definition (including
815
+ `dorny/paths-filter` outputs) gate whole jobs; per-file linters (shellcheck, eslint, prettier, ruff, black, flake8, ...) run
816
+ on the changed files only; tests use the ecosystem selector (`jest --findRelatedTests`, `vitest related`, `go` affected
817
+ packages with reverse dependencies, `cargo test -p` affected crates, Maven `-pl ... -amd`, `nx affected`, `turbo --filter`) or a
818
+ git-grep reference graph for file-list runners (pytest, mocha, `node --test`, bats, rspec, direct `*.test.*` invocations);
819
+ changed test files always run. **Fail-safe widening**: a lockfile, build or lint config, CI definition, unreferenced shared
820
+ fixture, an unknown file type, or an empty test selection for a changed source file widens the step to the full command (the
821
+ reason is reported, never silently narrowed). Short module names (`app/db.py`, `__init__.py`) are matched by their qualified
822
+ import forms. `${{ github.* }}` expressions in `run:` scripts are passed to the script as environment variables, never
823
+ substituted into the script text. CircleCI secondary containers run on a per-job docker network and publish only declared
824
+ (or well-known default) ports on 127.0.0.1, never `--network host`.
825
+ Passing results are cached by input hash under `~/.forge/ci-local/results` and reused across build iterations, lanes and
826
+ issues on the same base.
827
+
828
+ **Environment** (all optional): `FORGE_CI_LOCAL_SLOTS` (machine-wide concurrent jobs across all runs, default
829
+ `max(2, min(8, cpus/2))`; slots live under `~/.forge/ci-local.lock` and stale ones are reclaimed), `FORGE_CI_LOCAL_BUDGET_MINUTES`,
830
+ `FORGE_CI_LOCAL_MAX_PARALLEL`, `FORGE_CI_LOCAL_JOB_TIMEOUT` (seconds, default min(900, budget)), `FORGE_CI_LOCAL_SERVICES=off`,
831
+ `FORGE_CI_LOCAL_MAX_INVOCATION_SECONDS` (default 540), `FORGE_CI_LOCAL_HOME` (default `~/.forge`), `FORGE_CI_LOCAL_NO_CACHE=1`. Jobs run in a runner-like environment: an allowlist of
832
+ system and toolchain variables (PATH, HOME, locale, XDG_*, CARGO_*, GOPATH, JAVA_HOME, proxies, ...) plus `CI=true`; credentials and
833
+ ambient operator state (agent tokens, `FORGE_*`/`CLAUDE_*` settings, git hook variables) never reach them. Extra names can be
834
+ listed in `FORGE_CI_LOCAL_PASSTHROUGH` (comma separated). Requires `node`, `git`, and mikefarah `yq` v4 for YAML CI definitions (without `yq`
835
+ the gate is DEGRADED and reported in `verification_skipped`, never a silent pass).
836
+
837
+ **Commands that use this section**: `work-on` (validate V4.8)
751
838
 
752
839
  ---
753
840
 
@@ -20,7 +20,8 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
20
20
  | `review.rereview_skipped` | Re-review was skipped | reserved | No structured evidence source yet |
21
21
  | `review.blocking_issue` | Review left the PR open at an unchanged head with a filed blocking ISSUE and handed off to remediate | review | Trusted `FORGE:NOTE_DISPOSITION` on the PR with `findings_filed` > 0 and `reviewed_sha` equal to the live head |
22
22
  | `review.pr_open` | Review ended with the PR open and unmerged and no handoff signal | review | Live PR open, no `REVIEW_RESULT` remediate handoff, no `needs-human`/`workflow:remediating` label, no blocking disposition |
23
- | `build.acceptance_failed` | Newest acceptance gate marker is blocked | build | `FORGE:ACCEPTANCE_GATE:FAILED`/`:BLOCKED` or `FORGE:BUILD_BLOCKED` |
23
+ | `review.ci_pending_timeout` | CI was still pending at the merge gate after the engine's bounded wait (`engine.ci_pending_max_minutes`) ran out | engine | Review ended `REVIEW_RESULT` `status: BLOCKED` with a blocker containing `checks still pending` (or remediate held at its M7 re-gate on `CI pending`), and `scripts/wait-ci-green.sh` still reported pending when the budget expired |
24
+ | `build.acceptance_failed` | Newest acceptance gate marker is blocked | build | `FORGE:ACCEPTANCE_GATE:FAILED`/`:BLOCKED` or `FORGE:BUILD_BLOCKED` (`:DEGRADED` is a pass-class marker, not blocked) |
24
25
  | `build.acceptance_malformed` | Acceptance spec was malformed | reserved | Indistinguishable from `build.acceptance_failed` by sentinel alone |
25
26
  | `build.size_over_gate` | Retired (forge#3862): the old size gate routed a finished build to decompose; never emitted now, kept so historic runs still parse | reserved | A finished build is never routed to decompose |
26
27
  | `build.cohesive` | Retired (forge#3862): the old size gate judged an oversized diff cohesive | reserved | No size gate remains |
@@ -28,12 +29,15 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
28
29
  | `remediate.deferred_live_run` | Remediation deferred to a live earlier run | remediate, engine | Trusted `FORGE:REMEDIATION_DEFERRED` record |
29
30
  | `remediate.re_escalated` | Remediation re-escalated to a human | remediate | `**Re-gate outcome**: RE-ESCALATED` |
30
31
  | `remediate.unfixable` | Remediation judged the findings unfixable | remediate | `**Re-gate outcome**: UNFIXABLE` |
32
+ | `remediate.inpr_requested` | Re-gate approved but requested one further in-PR fix round; the run continues, never `needs-human` | remediate | `**Re-gate outcome**: INPR-FIX-REQUESTED` |
31
33
  | `ci.red` | CI failed | reserved | `REVIEW_RESULT` carries no CI field yet |
32
34
  | `ci.timeout` | CI timed out | reserved | `REVIEW_RESULT` carries no CI field yet |
33
35
  | `base.conflict` | Base-branch conflict blocked the PR | reserved | `REVIEW_RESULT` carries no conflict field yet |
34
36
  | `rate_limited` | Session or usage limit outlasted the pause budget | engine | `err.usageLimit` / `err.resetAtEpochMs` |
35
37
  | `engine.error` | Engine or tool failure (missing key/SDK, CLI crash, max turns, unresolved args, runner threw every attempt) | engine | `err.code` |
36
38
  | `infra.disk_quota` | Disk full or quota exhausted (EDQUOT/ENOSPC, errno -28/-122); retryable infra failure, never `needs-human` | engine | Runner error text |
39
+ | `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` |
40
+ | `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` |
37
41
  | `lease.held` | Another live agent holds the issue lease | engine | Foreign live lease on acquire |
38
42
  | `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 |
39
43
  | `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 |
package/docs/STATS.md CHANGED
@@ -14,6 +14,10 @@ npx forgedock stats [--since <version|date>] [--repo <owner/repo>] [--json] [--r
14
14
  versions at or above it; `--since 2026-10-01` keeps activity on or after the date. `--compact` prints one row per
15
15
  version (the table embedded in the staging review summary). `--json` is machine-readable.
16
16
 
17
+ `--since` also bounds the GitHub crawl: only issues and comments updated on or after the window start are read (a version
18
+ is resolved to its tag time minus one day; an unknown tag falls back to a full crawl and says so on stderr). Progress is
19
+ printed to stderr while crawling, so `--json` stdout stays parseable.
20
+
17
21
  ## Sources
18
22
 
19
23
  1. **GitHub (primary).** Issues, PRs and labels of the target repo, plus trusted `<!-- FORGE:TELEMETRY v1 -->` comments
@@ -21,8 +25,9 @@ version (the table embedded in the staging review summary). `--json` is machine-
21
25
  `scripts/trusted-comments.sh`); `part=N` comments are concatenated and records are deduped on `eid`. All `gh` calls
22
26
  are argv-based. Needs an authenticated `gh`.
23
27
  2. **Local cache (optional).** `~/.forge/stats-cache/<owner>_<repo>/cache.json` (`FORGE_STATS_CACHE_DIR`) holds what was
24
- fetched plus an `updated_at` watermark, so the next run only re-reads items updated since. It is safe to delete (the
25
- cost is one full re-fetch); a corrupt cache is treated as empty. `--refresh` ignores it, `--no-cache` never touches it.
28
+ fetched, an `updated_at` watermark (the next run only re-reads items updated since) and a `coveredFrom` floor (the
29
+ oldest window start crawled, so a later, wider `--since` crawls the uncovered gap instead of under-reporting). It is
30
+ safe to delete (the cost is one full re-fetch); a corrupt cache is treated as empty. `--refresh` ignores it, `--no-cache` never touches it.
26
31
  3. **Local run logs (optional).** `~/.forge/runs/*.jsonl` (`FORGE_RUNS_DIR`) for runs that predate the engine's
27
32
  `phase_attempt` / `run_terminal` records. A run already present on GitHub is not counted twice. Only runs whose
28
33
  `RUN_START.repo` equals the target repo are used. `--no-local` skips them.
@@ -59,5 +64,7 @@ instead of failing.
59
64
 
60
65
  ## Limits
61
66
 
62
- Issues and PRs are listed by `updated_at`, so the first run on a large repo makes one comment request per item that has
63
- comments. Telemetry comments are edited in place; if a cached item looks stale, run with `--refresh`.
67
+ Comments are read from the repo-wide `issues/comments?since=` endpoint (a few paginated calls); only issues with a trusted
68
+ telemetry comment in the window get their full comment list re-read (6 in parallel, backing off when the API rate limit is
69
+ low). A run with no `--since` and no cache still walks the whole history once. Telemetry comments are edited in place, which
70
+ the comment window catches; if a cached item still looks stale, run with `--refresh`.
package/docs/TELEMETRY.md CHANGED
@@ -33,9 +33,12 @@ is dropped. Payload keys are `[A-Za-z0-9_]+` and cannot shadow the shared keys;
33
33
  |-------|-----------|---------|
34
34
  | `diff_size` | `scripts/diff-size.sh`; `work-on/build.md` B5.5 (final result) | `lines`, `excluded`, `ws_lines`, `review_weight`, `modules`, `result`; the script emits the legacy `threshold` and `OK` \| `OVER` \| `COHESIVE`, the B5.5 call site emits the final `result` = `OK` \| `EXTENDED_REVIEW` \| `SCOPE_FLAGGED` \| `OVERRIDDEN` plus `estimate` (plan), `actual` (whitespace-insensitive lines), `scope` = `ok` \| `out_of_plan` \| `no_baseline` and `review_depth` = `STANDARD` \| `EXTENDED` (calibration inputs, forge#3862) |
35
35
  | `acceptance_gate` | `work-on/build.md` B6.5 (`phase=build`); `scripts/review-acceptance-gate.sh` (`phase=review`) | `phase`, `verified`, `failed`, `malformed`, `deferred` |
36
- | `finding_disposition` | `scripts/classify-finding.sh`, one per finding | `severity`, `confidence`, `agent`, `disposition` = `ISSUE` \| `NOTE` \| `INPR_FIX`, `reason` |
36
+ | `finding_disposition` | `scripts/classify-finding.sh`, one per finding | `severity`, `confidence`, `agent`, `scope` = `introduced` \| `pre-existing` \| `unknown` (from the diff; `unknown` when not supplied), `disposition` = `ISSUE` \| `NOTE` \| `INPR_FIX` (also MEDIUM LIKELY when `scope=introduced`), `reason` |
37
37
  | `review_panel` | `review-pr.md` Phase 4 | `agents_selected`, `agents_completed`, `mode` = `FULL` \| `DELTA` \| `BASE_SYNC_ONLY` |
38
- | `ci_gate` | `scripts/wait-ci-green.sh` | `result` = `PASS` \| `FAIL` \| `ERROR` \| `TIMEOUT`, `rc`, `checks` |
38
+ | `ci_gate` | `scripts/wait-ci-green.sh` | `result` = `PASS` \| `FAIL` \| `ERROR` \| `TIMEOUT` \| `RATE_LIMITED` (rc 4: GitHub throttled or briefly unavailable for the whole window; retryable, never a verdict), `rc`, `checks` |
39
+ | `ci_pending_wait` | the engine (`bin/engine/telemetry-seam.mjs`, one per finished CI-pending wait at the merge gate; `phase` = `review` \| `remediate`) | `pr`, `minutes_waited`, `result` = `green` \| `red` \| `error` \| `timeout`, `polls` (CI reads made), `rate_limit_sleeps` (polls that were rate-limited plus sleeps until the budget reset) |
40
+ | `ci_local` | `scripts/ci-local.sh run` (validate V4.8), one per run | `result` = `PASS` \| `FAIL` \| `DEGRADED`, `jobs_run`, `jobs_deferred`, `jobs_failed`, `duration_ms`, `fix_iterations` (0 first run, 1 and 2 after fixes), `checks`, `cache_hits`, `narrowed` (checks run on a subset or skipped for no impact), `widened` (checks widened to the full command), `selected` / `total` (summed scope) |
41
+ | `ci_local_check` | `scripts/ci-local.sh run`, one per scoped, widened or executed check (at most 20 per run) | `job`, `selector`, `selected`, `total`, `cache` = `hit` \| `miss` \| `na`, `duration_ms`, `widen` (reason or `none`). A remote-CI failure at merge on a check recorded here as narrowed is a selector bug |
39
42
  | `liveness` | `scripts/remediate-liveness.sh` | `decision` = `PROCEED` \| `DEFER` \| `EXHAUSTED`, `why` |
40
43
  | `phase_attempt` | the engine (`bin/engine/telemetry-seam.mjs`, one per phase attempt; `phase` = the phase id) | `attempt`, `model`, `turns`, `duration_ms`, `cost_usd`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens` (any value the runner did not report is omitted) |
41
44
  | `run_terminal` | the engine (`terminate()`, one per run segment) | `reason` (terminal reason), `code` (see `docs/OUTCOME-CODES.md`), `wall_ms` (this engine process, start to terminal) |
@@ -110,6 +110,10 @@ phase hosting `/review-pr` cannot wait on a notification. Therefore:
110
110
  `/orchestrate` Step 4B item 6.4 dispatches a worker that runs `work-on <pr> --remediate`: worker L1,
111
111
  remediate L2, reviewers L3, same as the table.
112
112
 
113
+ ### Pre-handoff base sync and union registries (forge#3969)
114
+
115
+ `work-on:review` merges `origin/<base>` into the branch before the first push, so a PR is never born stale and a conflict is resolved while the build context is warm. Hand-maintained append-only registries (test lists, cron targets) are the usual conflict source; `scripts/detect-append-registries.sh` ranks them from history and proves a `.gitattributes` `merge=union` entry safe (`--check-union`). Union is allowed only for line-independent files, never YAML/JSON maps or ordered lists (see `.gitattributes`). `scripts/review-delta.sh` still treats a hand-resolved merge as not `BASE_SYNC_ONLY`, but accepts a merge whose tree is exactly what a mechanical `git merge-tree` of the same parents produces (a clean or union merge), so union registries take the no-panel fast path.
116
+
113
117
  ## 5. Router routing
114
118
 
115
119
  | Phase result | Router action |
@@ -136,3 +140,17 @@ remediate L2, reviewers L3, same as the table.
136
140
  ## 8. Outcome codes
137
141
 
138
142
  Every `PHASE_FAILED`, blocked `PHASE_COMMIT` and `RUN_TERMINAL` run-log event carries a machine-readable `code` from a closed, versioned enum (`bin/engine/outcome-codes.mjs`), and `FORGE:STATE` records the causing code as `outcomeCode`. Codes come from structured evidence only, never from the prose `detail`; an unmapped outcome is `unclassified` with the raw reason kept. See `docs/OUTCOME-CODES.md`.
143
+
144
+ ## Remediation re-gate outcomes and spawn retries (forge#4021)
145
+
146
+ The `**Re-gate outcome**` token in a `FORGE:REMEDIATION:COMPLETE` trail is a closed vocabulary the engine parses:
147
+ `AUTO-LANDED`, `HELD-AWAITING-MERGE`, `RE-ESCALATED`, `UNFIXABLE`, `REREVIEW-REQUIRED` and `INPR-FIX-REQUESTED`.
148
+ `INPR-FIX-REQUESTED` (with `**INPR round**: N`) means the re-review approved but posted a `FORGE:INPR_FIX` work
149
+ order. The engine commits `remediate` with `outputs.remediation = "inpr-fix"`, re-arms it once, and re-reviews. The
150
+ `INPR_REMEDIATION` marker count bounds it: a second request escalates to `needs-human`.
151
+
152
+ A CLI spawn failure (`ETIMEDOUT`, `EAGAIN`, `ENOMEM`, `EMFILE`) carries `code = "CLI_SPAWN_TRANSIENT"`. The engine
153
+ retries it after a 10s/30s/60s backoff on a separate counter (max 3) that does not consume a phase attempt; once
154
+ spent, normal attempt accounting applies. `ETIMEDOUT` (a `FORGEDOCK_CLI_TIMEOUT_MS` kill) is in this class by design,
155
+ so a phase that times out every time can run up to `maxAttempts + 3` full-length CLI sessions; lower the timeout or
156
+ max attempts if that cost is unacceptable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.18.0",
3
+ "version": "1.19.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"
@@ -48,7 +48,7 @@
48
48
  "README.md"
49
49
  ],
50
50
  "scripts": {
51
- "test": "node --test \"bin/tests/**/*.test.mjs\" \"bin/engine/**/*.test.mjs\" \"bin/stats/**/*.test.mjs\" \"packages/protocol/test/**/*.test.mjs\"",
51
+ "test": "bash scripts/with-run-tmpdir.sh node --test \"bin/tests/**/*.test.mjs\" \"bin/engine/**/*.test.mjs\" \"bin/stats/**/*.test.mjs\" \"packages/protocol/test/**/*.test.mjs\"",
52
52
  "test:sh": "bash scripts/run-shell-tests.sh",
53
53
  "docs:dev": "vitepress dev docs/site",
54
54
  "docs:build": "vitepress build docs/site",
@@ -109,7 +109,7 @@ export const RESERVED_TYPES = {
109
109
  partialSentinel: null,
110
110
  inlineValue: false,
111
111
  requiredFields: [],
112
- reGateOutcomeValues: ['AUTO-LANDED', 'HELD-AWAITING-MERGE', 'RE-ESCALATED', 'UNFIXABLE'],
112
+ reGateOutcomeValues: ['AUTO-LANDED', 'HELD-AWAITING-MERGE', 'RE-ESCALATED', 'UNFIXABLE', 'INPR-FIX-REQUESTED', 'REREVIEW-REQUIRED'],
113
113
  },
114
114
  TRAJECTORY: {
115
115
  type: 'TRAJECTORY',
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env bash
2
+ # acceptance-red-green.sh — red-green proof for one acceptance test: it must FAIL on the base commit and PASS on head.
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # Usage: acceptance-red-green.sh --repo-path <worktree> --base-ref <ref> --test-cmd "<cmd>" [--timeout <sec>]
7
+ # Runs <cmd> in <worktree> (head) first. Only when head RAN does it create a detached base worktree at
8
+ # `git merge-base HEAD <base-ref>` (under mktemp -d, never inside the repo), overlay head's changed test files
9
+ # onto it, and run <cmd> there. Each run is classified with classify-test-run.sh.
10
+ # Prints exactly one line on stdout: BASE=<FAILED|RAN|RAN_NOTHING|UNRUNNABLE|SKIPPED> HEAD=<FAILED|RAN|RAN_NOTHING|UNRUNNABLE>
11
+ # FAILED the run executed and failed
12
+ # RAN the run executed and passed
13
+ # RAN_NOTHING the runner exercised nothing
14
+ # UNRUNNABLE could not execute (timeout 124, not executable 126/127, worktree/overlay/classifier failure)
15
+ # SKIPPED base was not run because head did not PASS
16
+ # Never prints a verdict on success of cleanup: the base worktree is removed on every exit path (trap + explicit remove).
17
+ # Callers map: HEAD=RAN BASE=FAILED -> proof; BASE=RAN -> passes on base (gate FAIL); anything else that is not a proof -> DEGRADED/FAIL.
18
+ # Exit: 0 line printed | 2 usage error (stdout empty so callers fail closed)
19
+ # Bash 3.2 portable.
20
+ set -uo pipefail
21
+
22
+ REPO=""; BASE_REF=""; CMD=""; TMO=300
23
+ while [ $# -gt 0 ]; do
24
+ case "$1" in
25
+ --repo-path) REPO="${2-}"; shift 2 ;;
26
+ --base-ref) BASE_REF="${2-}"; shift 2 ;;
27
+ --test-cmd) CMD="${2-}"; shift 2 ;;
28
+ --timeout) TMO="${2-}"; shift 2 ;;
29
+ *) echo "usage: acceptance-red-green.sh --repo-path <dir> --base-ref <ref> --test-cmd <cmd> [--timeout <sec>]" >&2; exit 2 ;;
30
+ esac
31
+ done
32
+ [ -n "$REPO" ] && [ -d "$REPO" ] && [ -n "$BASE_REF" ] && [ -n "$CMD" ] || { echo "usage: acceptance-red-green.sh --repo-path <dir> --base-ref <ref> --test-cmd <cmd> [--timeout <sec>]" >&2; exit 2; }
33
+ case "$TMO" in ''|*[!0-9]*) TMO=300 ;; esac
34
+ CLS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/classify-test-run.sh"
35
+
36
+ WORK=$(mktemp -d "${TMPDIR:-/tmp}/forge-redgreen.XXXXXX") || { echo "BASE=UNRUNNABLE HEAD=UNRUNNABLE"; exit 0; }
37
+ BASE_WT="$WORK/base"; ADDED=false
38
+ cleanup() {
39
+ [ "$ADDED" = "true" ] && git -C "$REPO" worktree remove --force "$BASE_WT" >/dev/null 2>&1
40
+ ADDED=false
41
+ rm -rf "$WORK"
42
+ git -C "$REPO" worktree prune >/dev/null 2>&1 || true
43
+ }
44
+ trap cleanup EXIT
45
+ trap 'exit 130' INT TERM
46
+
47
+ TIMEOUT_BIN=$(command -v timeout || command -v gtimeout || true)
48
+
49
+ # run_in <dir> -> prints the classification word
50
+ run_in() {
51
+ local dir="$1" out="$WORK/out.$$.txt" rc cls
52
+ if [ -n "$TIMEOUT_BIN" ]; then ( cd "$dir" && "$TIMEOUT_BIN" "$TMO" bash -c "$CMD" ) >"$out" 2>&1; rc=$?
53
+ else ( cd "$dir" && bash -c "$CMD" ) >"$out" 2>&1; rc=$?; fi
54
+ case "$rc" in 124|126|127) echo UNRUNNABLE; return ;; esac
55
+ cls=$(bash "$CLS" "$rc" "$out" 2>/dev/null)
56
+ case "$cls" in FAILED|RAN|RAN_NOTHING) echo "$cls" ;; *) echo UNRUNNABLE ;; esac
57
+ }
58
+
59
+ HEAD_RES=$(run_in "$REPO")
60
+ if [ "$HEAD_RES" != "RAN" ]; then echo "BASE=SKIPPED HEAD=$HEAD_RES"; exit 0; fi
61
+
62
+ BASE_SHA=$(git -C "$REPO" merge-base HEAD "$BASE_REF" 2>/dev/null)
63
+ [ -n "$BASE_SHA" ] && git -C "$REPO" worktree add --detach "$BASE_WT" "$BASE_SHA" >/dev/null 2>&1 && ADDED=true
64
+ if [ "$ADDED" != "true" ]; then echo "BASE=UNRUNNABLE HEAD=$HEAD_RES"; exit 0; fi
65
+
66
+ # Overlay: head's changed test files onto base (a new test file does not exist at base; running without it is a false red).
67
+ is_test_path() {
68
+ case "$1" in
69
+ /*|..|../*|*/../*|*/..) return 1 ;;
70
+ esac
71
+ case "$1" in
72
+ test/*|tests/*|spec/*|__tests__/*|*/test/*|*/tests/*|*/spec/*|*/__tests__/*|*.test.*|*_test.*|*.spec.*|*_spec.*|*/test_*|test_*) return 0 ;;
73
+ esac
74
+ return 1
75
+ }
76
+ OVERLAY_OK=true
77
+ CHANGED=$( { git -C "$REPO" diff --name-only "$BASE_SHA" 2>/dev/null; git -C "$REPO" ls-files --others --exclude-standard 2>/dev/null; } | sort -u )
78
+ while IFS= read -r f; do
79
+ [ -n "$f" ] || continue
80
+ is_test_path "$f" || continue
81
+ [ -f "$REPO/$f" ] && [ ! -L "$REPO/$f" ] || continue # deleted at head, or a symlink: never copied
82
+ mkdir -p "$BASE_WT/$(dirname "$f")" && cp -p "$REPO/$f" "$BASE_WT/$f" 2>/dev/null && [ -f "$BASE_WT/$f" ] || OVERLAY_OK=false
83
+ done <<< "$CHANGED"
84
+ # Untracked dependency tree (never in git): link it so a missing install is not mistaken for a red run.
85
+ [ -d "$REPO/node_modules" ] && [ ! -e "$BASE_WT/node_modules" ] && ln -s "$REPO/node_modules" "$BASE_WT/node_modules" 2>/dev/null
86
+
87
+ if [ "$OVERLAY_OK" != "true" ]; then BASE_RES=UNRUNNABLE; else BASE_RES=$(run_in "$BASE_WT"); fi
88
+ cleanup # explicit; the EXIT trap is the backstop
89
+ echo "BASE=$BASE_RES HEAD=$HEAD_RES"
90
+ exit 0
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env bash
2
+ # acceptance-red-green.test.sh — tests for scripts/acceptance-red-green.sh (real temp git repos, no network)
3
+ # Usage: bash scripts/acceptance-red-green.test.sh
4
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ set -uo pipefail
7
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ RG="$SCRIPT_DIR/acceptance-red-green.sh"
9
+ TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
10
+ export TMPDIR="$TMP/tmpdir"; mkdir -p "$TMPDIR"
11
+ PASS=0; FAILN=0
12
+ ok() { PASS=$((PASS+1)); echo "ok - $1"; }
13
+ bad() { FAILN=$((FAILN+1)); echo "FAIL - $1"; }
14
+
15
+ # make_repo <dir>: base commit on branch main with app.sh (defect: prints bad), then a feature branch is the caller's job
16
+ make_repo() {
17
+ git init -q -b main "$1" && git -C "$1" config user.email t@t && git -C "$1" config user.name t
18
+ printf 'echo bad\n' > "$1/app.sh"; git -C "$1" add -A; git -C "$1" commit -qm base
19
+ git -C "$1" update-ref refs/remotes/origin/main HEAD
20
+ }
21
+ fix_head() { printf 'echo good\n' > "$1/app.sh"; git -C "$1" add -A; git -C "$1" commit -qm fix; }
22
+ leaked() { git -C "$1" worktree list | wc -l | tr -d ' '; }
23
+ check() { # name want repo cmd [extra...]
24
+ local name="$1" want="$2" repo="$3" cmd="$4" got
25
+ got=$(bash "$RG" --repo-path "$repo" --base-ref origin/main --test-cmd "$cmd" 2>/dev/null)
26
+ if [ "$got" = "$want" ]; then ok "$name"; else bad "$name: want '$want' got '$got'"; fi
27
+ if [ "$(leaked "$repo")" = "1" ] && [ -z "$(ls "$TMPDIR")" ]; then ok "$name: base worktree and tmp dir cleaned"; else bad "$name: leaked worktree or tmp ($(leaked "$repo") worktrees)"; fi
28
+ }
29
+
30
+ # 1. real red-green proof: test greps the fixed output, new test file only exists at head (overlay)
31
+ R1="$TMP/r1"; make_repo "$R1"; fix_head "$R1"
32
+ mkdir -p "$R1/tests"; printf 'test "$(bash app.sh)" = good && echo "1 passed"\n' > "$R1/tests/t.sh"; git -C "$R1" add -A; git -C "$R1" commit -qm test
33
+ check "red on base, green on head (new test file overlaid)" "BASE=FAILED HEAD=RAN" "$R1" "bash tests/t.sh"
34
+
35
+ # 2. passes on base: test never touches the defect
36
+ R2="$TMP/r2"; make_repo "$R2"; fix_head "$R2"
37
+ mkdir -p "$R2/tests"; printf 'echo "1 passed"\n' > "$R2/tests/t.sh"; git -C "$R2" add -A; git -C "$R2" commit -qm test
38
+ check "passes on base" "BASE=RAN HEAD=RAN" "$R2" "bash tests/t.sh"
39
+
40
+ # 3. head fails: base is not run
41
+ check "head failure skips base" "BASE=SKIPPED HEAD=FAILED" "$R1" "bash app.sh; exit 1"
42
+
43
+ # 4. RAN_NOTHING on head
44
+ check "runner that exercises nothing on head" "BASE=SKIPPED HEAD=RAN_NOTHING" "$R1" "echo 'no tests ran'"
45
+
46
+ # 5. command not found (127) on head
47
+ check "rc 127 is UNRUNNABLE" "BASE=SKIPPED HEAD=UNRUNNABLE" "$R1" "definitely-not-a-command-xyz"
48
+
49
+ # 6. base cannot execute: the command only exists at head (non-test file), so base yields 127
50
+ R3="$TMP/r3"; make_repo "$R3"; fix_head "$R3"
51
+ printf 'echo "1 passed"\n' > "$R3/runner.sh"; chmod +x "$R3/runner.sh"; git -C "$R3" add -A; git -C "$R3" commit -qm runner
52
+ check "base rc 127 (runner absent at base) is UNRUNNABLE" "BASE=UNRUNNABLE HEAD=RAN" "$R3" "./runner.sh"
53
+
54
+ # 7. base ref unresolvable: worktree cannot be created
55
+ got=$(bash "$RG" --repo-path "$R1" --base-ref origin/does-not-exist --test-cmd "bash tests/t.sh" 2>/dev/null)
56
+ [ "$got" = "BASE=UNRUNNABLE HEAD=RAN" ] && ok "unresolvable base ref is UNRUNNABLE" || bad "unresolvable base ref: got '$got'"
57
+ [ "$(leaked "$R1")" = "1" ] && ok "no worktree after failed setup" || bad "worktree leaked after failed setup"
58
+
59
+ # 8. non-test files are never overlaid (a changed source file stays at base)
60
+ R4="$TMP/r4"; make_repo "$R4"; fix_head "$R4"
61
+ check "source file stays at base (no overlay of non-test files)" "BASE=FAILED HEAD=RAN" "$R4" 'test "$(bash app.sh)" = good && echo "1 passed"'
62
+
63
+ # 9. timeout is UNRUNNABLE when a timeout binary exists
64
+ if command -v timeout >/dev/null 2>&1 || command -v gtimeout >/dev/null 2>&1; then
65
+ got=$(bash "$RG" --repo-path "$R1" --base-ref origin/main --test-cmd "sleep 5" --timeout 1 2>/dev/null)
66
+ [ "$got" = "BASE=SKIPPED HEAD=UNRUNNABLE" ] && ok "timeout is UNRUNNABLE" || bad "timeout: got '$got'"
67
+ fi
68
+
69
+ # 10. usage errors print nothing and exit 2
70
+ out=$(bash "$RG" --repo-path "$R1" 2>/dev/null); rc=$?
71
+ { [ "$rc" -eq 2 ] && [ -z "$out" ]; } && ok "missing args exit 2, empty stdout" || bad "usage: rc=$rc out='$out'"
72
+
73
+ echo "$PASS passed, $FAILN failed"
74
+ [ "$FAILN" -eq 0 ]
@@ -6,6 +6,8 @@
6
6
  # Run: bash scripts/cap-findings.test.sh (bash 3.2 compatible)
7
7
 
8
8
  set -u
9
+ # Every mktemp below lands in one private dir that the EXIT trap removes (forge#4016).
10
+ _SUITE_TMP=$(mktemp -d); export TMPDIR="$_SUITE_TMP"; trap 'rm -rf "$_SUITE_TMP"' EXIT
9
11
  DIR=$(cd "$(dirname "$0")" && pwd)
10
12
  S="$DIR/cap-findings.sh"
11
13
  pass=0; fail=0
@@ -6,6 +6,8 @@
6
6
  # Run: bash scripts/check-contract-scope.test.sh (bash 3.2 compatible)
7
7
 
8
8
  set -u
9
+ # Every mktemp below lands in one private dir that the EXIT trap removes (forge#4016).
10
+ _SUITE_TMP=$(mktemp -d); export TMPDIR="$_SUITE_TMP"; trap 'rm -rf "$_SUITE_TMP"' EXIT
9
11
  DIR=$(cd "$(dirname "$0")" && pwd)
10
12
  S="$DIR/check-contract-scope.sh"
11
13
  pass=0; fail=0
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # check-edge-cases.sh — Enforce that every FORGE:ARCHITECT edge case maps to a test in the diff or an untestable reason.
6
+ #
7
+ # Usage: check-edge-cases.sh --architect <file> --builder <file> --base <ref> [--repo <path>]
8
+ #
9
+ # <architect> is a FORGE:ARCHITECT comment body (or the extracted block); only the text between
10
+ # <!-- FORGE:EDGE_CASES:BEGIN --> and <!-- FORGE:EDGE_CASES:END --> is parsed. One record per line:
11
+ # EDGE: id=e1 kind=error|edge|concurrency description=<free text>
12
+ # Only id= and kind= are parsed; the description is free text and is never interpreted. ids match ^e[0-9]+$.
13
+ # <builder> is a FORGE:BUILDER comment body (may be empty or absent content). Lines parsed:
14
+ # EDGE_TEST: id=e1 test=<path>::<name>
15
+ # EDGE_TEST: id=e1 untestable=<non-empty reason, spaces allowed>
16
+ #
17
+ # An id is covered when it has a non-empty untestable= reason, or a test=<path>::<name> where <name> (fixed-string)
18
+ # appears in a line ADDED to <path> by the change (merge-base(base,HEAD) to the working tree, plus untracked files).
19
+ # Pre-existing tests never satisfy a new edge case.
20
+ #
21
+ # Exit codes:
22
+ # 0 covered, or no edge-case block / empty block (prints "SKIP: ..."); on success prints "MAPPED_TESTS: <n>"
23
+ # 1 UNMAPPED: <id> | TEST_NOT_IN_DIFF: <id> <path>::<name> | EMPTY_REASON: <id> | DUPLICATE_ID: <id>
24
+ # 2 malformed block or EDGE_TEST line, bad arguments, unreadable input, unresolvable base, git failure —
25
+ # NEVER a pass; callers report DEGRADED
26
+ #
27
+ # Offline, values are data (never eval'd). Portable to bash 3.2: no mapfile, no associative arrays.
28
+
29
+ set -uo pipefail
30
+
31
+ ARCH=""; BUILDER=""; BASE=""; REPO="."
32
+ USAGE="usage: check-edge-cases.sh --architect <file> --builder <file> --base <ref> [--repo <path>]"
33
+ die2() { echo "check-edge-cases: $*" >&2; exit 2; }
34
+
35
+ while [ "$#" -gt 0 ]; do
36
+ case "$1" in
37
+ --architect) [ "$#" -ge 2 ] || die2 "--architect needs a value"; ARCH="$2"; shift 2 ;;
38
+ --builder) [ "$#" -ge 2 ] || die2 "--builder needs a value"; BUILDER="$2"; shift 2 ;;
39
+ --base) [ "$#" -ge 2 ] || die2 "--base needs a value"; BASE="$2"; shift 2 ;;
40
+ --repo) [ "$#" -ge 2 ] || die2 "--repo needs a value"; REPO="$2"; shift 2 ;;
41
+ *) die2 "$USAGE" ;;
42
+ esac
43
+ done
44
+ [ -n "$ARCH" ] && [ -n "$BUILDER" ] && [ -n "$BASE" ] || die2 "$USAGE"
45
+ [ -f "$ARCH" ] || die2 "architect file not found: $ARCH"
46
+ [ -f "$BUILDER" ] || die2 "builder file not found: $BUILDER"
47
+
48
+ TMP=$(mktemp -d "${TMPDIR:-/tmp}/check-edge-cases.XXXXXX") || die2 "mktemp failed"
49
+ trap 'rm -rf "$TMP"' EXIT
50
+
51
+ # ---- extract BEGIN..END regions of the architect body --------------------------------------------------
52
+ awk -v out="$TMP/region" '
53
+ index($0, "<!-- FORGE:EDGE_CASES:BEGIN -->") { if (inb) { bad = 1 } inb = 1; seen = 1; next }
54
+ index($0, "<!-- FORGE:EDGE_CASES:END -->") { if (!inb) { bad = 1 } inb = 0; next }
55
+ inb { print > out }
56
+ END { if (inb || bad) exit 3; if (!seen) exit 4 }
57
+ ' "$ARCH"
58
+ case $? in
59
+ 0) ;;
60
+ 4) echo "SKIP: no edge-case block"; exit 0 ;;
61
+ *) die2 "unbalanced FORGE:EDGE_CASES:BEGIN/END markers" ;;
62
+ esac
63
+
64
+ # ---- parse EDGE records ---------------------------------------------------------------------------------
65
+ : > "$TMP/ids"; : > "$TMP/dups"
66
+ EDGE_RE='^EDGE: id=(e[0-9]+) kind=(error|edge|concurrency)([[:space:]]|$)'
67
+ LN=0
68
+ if [ -f "$TMP/region" ]; then
69
+ while IFS= read -r line || [ -n "$line" ]; do
70
+ LN=$((LN+1)); line="${line%$'\r'}"
71
+ trimmed="${line#"${line%%[![:space:]]*}"}"
72
+ case "$trimmed" in ""|'```'*) continue ;; esac
73
+ [[ "$trimmed" =~ $EDGE_RE ]] || die2 "line $LN: malformed EDGE record: $trimmed"
74
+ id="${BASH_REMATCH[1]}"
75
+ if grep -Fxq -- "$id" "$TMP/ids"; then echo "$id" >> "$TMP/dups"; else echo "$id" >> "$TMP/ids"; fi
76
+ done < "$TMP/region"
77
+ fi
78
+ if [ ! -s "$TMP/ids" ]; then echo "SKIP: empty edge-case block"; exit 0; fi
79
+
80
+ # ---- parse EDGE_TEST lines of the builder body ----------------------------------------------------------
81
+ : > "$TMP/maps" # id<TAB>test|untestable<TAB>value
82
+ : > "$TMP/mapdups"
83
+ PRE='^(-[[:space:]]+)?EDGE_TEST:[[:space:]]+id=(e[0-9]+)[[:space:]]+(test|untestable)=(.*)$'
84
+ while IFS= read -r line || [ -n "$line" ]; do
85
+ line="${line%$'\r'}"
86
+ trimmed="${line#"${line%%[![:space:]]*}"}"
87
+ case "$trimmed" in *EDGE_TEST:*) ;; *) continue ;; esac
88
+ case "$trimmed" in EDGE_TEST:*|"- EDGE_TEST:"*) ;; *) continue ;; esac # only line-leading records, not prose mentions
89
+ [[ "$trimmed" =~ $PRE ]] || die2 "malformed EDGE_TEST line: $trimmed"
90
+ mid="${BASH_REMATCH[2]}"; mkind="${BASH_REMATCH[3]}"; mval="${BASH_REMATCH[4]}"
91
+ mval="${mval#"${mval%%[![:space:]]*}"}"; mval="${mval%"${mval##*[![:space:]]}"}"
92
+ if awk -F'\t' -v s="$mid" '$1==s {ok=1} END{exit ok?0:1}' "$TMP/maps"; then echo "$mid" >> "$TMP/mapdups"; continue; fi
93
+ printf '%s\t%s\t%s\n' "$mid" "$mkind" "$mval" >> "$TMP/maps"
94
+ done < "$BUILDER"
95
+
96
+ # ---- repo, base, merge-base -----------------------------------------------------------------------------
97
+ case "$BASE" in ""|-*) die2 "invalid --base: '$BASE'" ;; esac
98
+ git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1 || die2 "not a git work tree: $REPO"
99
+ git -C "$REPO" rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null 2>&1 || die2 "cannot resolve base ref '$BASE'"
100
+ MB=$(git -C "$REPO" merge-base "$BASE" HEAD 2>/dev/null) || die2 "no merge base between '$BASE' and HEAD"
101
+
102
+ # added_has <path> <name>: 0 when <name> (fixed string) occurs in a line added to <path>
103
+ added_has() {
104
+ local p="$1" n="$2" rc
105
+ if git -C "$REPO" ls-files --error-unmatch -- "$p" >/dev/null 2>&1; then
106
+ git -C "$REPO" diff -U0 --no-color "$MB" -- "$p" > "$TMP/d" 2>/dev/null || die2 "git diff failed for $p"
107
+ awk -v n="$n" '/^\+\+\+/ {next} /^\+/ { if (index($0, n) > 0) ok=1 } END{exit ok?0:1}' "$TMP/d"; return $?
108
+ fi
109
+ # untracked (new, not yet staged) file: every line is added
110
+ git -C "$REPO" ls-files --others --exclude-standard -- "$p" > "$TMP/u" 2>/dev/null || die2 "git ls-files failed"
111
+ [ -s "$TMP/u" ] || return 1
112
+ [ -f "$REPO/$p" ] || return 1
113
+ awk -v n="$n" 'index($0, n) > 0 {ok=1} END{exit ok?0:1}' "$REPO/$p"; rc=$?
114
+ return "$rc"
115
+ }
116
+
117
+ # ---- check ----------------------------------------------------------------------------------------------
118
+ RC=0; MAPPED=0
119
+ while IFS= read -r d; do [ -n "$d" ] && { echo "DUPLICATE_ID: $d"; RC=1; }; done < "$TMP/dups"
120
+ while IFS= read -r d; do [ -n "$d" ] && { echo "DUPLICATE_ID: $d (EDGE_TEST)"; RC=1; }; done < "$TMP/mapdups"
121
+ PATH_RE='^[A-Za-z0-9_./@+=:,-]+$'
122
+ NAME_RE='^[^[:space:]]{1,200}$'
123
+ while IFS= read -r id; do
124
+ row=$(awk -F'\t' -v s="$id" '$1==s {print; exit}' "$TMP/maps")
125
+ if [ -z "$row" ]; then echo "UNMAPPED: $id"; RC=1; continue; fi
126
+ mkind=$(printf '%s' "$row" | cut -f2); mval=$(printf '%s' "$row" | cut -f3-)
127
+ if [ "$mkind" = untestable ]; then
128
+ [ -n "$mval" ] || { echo "EMPTY_REASON: $id"; RC=1; }
129
+ continue
130
+ fi
131
+ case "$mval" in *::*) ;; *) die2 "$id: test= must be <path>::<name>: $mval" ;; esac
132
+ tpath="${mval%%::*}"; tname="${mval##*::}"
133
+ case "$tpath" in -*) die2 "$id: test path must not start with '-'" ;; esac
134
+ [[ "$tpath" =~ $PATH_RE ]] || die2 "$id: invalid test path: $tpath"
135
+ [[ "$tname" =~ $NAME_RE ]] || die2 "$id: invalid test name"
136
+ if added_has "$tpath" "$tname"; then MAPPED=$((MAPPED+1)); else echo "TEST_NOT_IN_DIFF: $id $tpath::$tname"; RC=1; fi
137
+ done < "$TMP/ids"
138
+
139
+ if [ "$RC" -eq 0 ]; then
140
+ echo "OK: edge cases covered ($(wc -l < "$TMP/ids" | tr -d ' ') id(s))"
141
+ echo "MAPPED_TESTS: $MAPPED"
142
+ fi
143
+ exit "$RC"