forgedock 1.31.0 → 1.32.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.
package/docs/CONFIG.md CHANGED
@@ -563,11 +563,11 @@ engine:
563
563
  | `lane_limits` | map | No | Cap each core lane's whole process tree (#4115), under `pipeline:` or `engine:`: `memory_max` (for example `3G`) and `cpu_quota` (for example `150%`). When set and `systemd-run --user --scope` works (probed once), the builder CLI is spawned as `systemd-run --user --scope -q -p MemoryMax=... -p CPUQuota=... -- claude ...`; elsewhere the caps are ignored. Write it nested or flat (`lane_limits_memory_max`, `lane_limits_cpu_quota`). While a builder runs the engine polls host load and memory every 15 s against the `admission` thresholds: over them it SIGSTOPs the lane's process group, under them SIGCONT, and never pauses longer than 10 minutes. |
564
564
  | `admission` | map | No | Host-resource admission for the core work-on (#4105), checked before the lease, worktree or any GitHub call. When a check fails the run ends `deferred` (code `host.busy`, exit 75) with a last line such as `issue #N → deferred (host busy: mem 1.2G < 2G; retry in 60s)`; no lease is taken and nothing is posted. Keys: `enabled` (default `true`; `false` disables admission), `min_free_mem_mb` (default 2048; `MemAvailable` from `/proc/meminfo`, else `os.freemem()`), `min_free_tmp_mb` (default 1024; free space of the run TMPDIR filesystem, `~/.forge/tmp`, and of `/tmp`, via `statfs`), `max_load_per_core` (default 4; 1-minute load average divided by cores). Write it nested (`engine:` then `admission:` with the keys indented) or flat as `admission_enabled`, `admission_min_free_mem_mb`, `admission_min_free_tmp_mb`, `admission_max_load_per_core`; the parser reads one nested level into `engine`. `/orchestrate` requeues a host-busy lane and lowers K by 1 for 5 minutes. |
565
565
  | `merge_wait` | string | No | Core work-on merge gate. `required` (default): wait only for the status checks the base branch requires: the union of classic branch protection (`gh api repos/{o}/{r}/branches/{base}/protection/required_status_checks`, contexts and checks) and repository rulesets (`gh api repos/{o}/{r}/rules/branches/{base}`, `required_status_checks` rules); with neither (404 or empty) merge right after review PASS. A merge rejected by base branch policy (PR `MERGEABLE` but `BLOCKED`) is waited on and retried within the CI budget, then left `ci-pending`; it is never base-synced and never `base.conflict`. A failing required check is `needs-human`; a pending one is polled with backoff and never `needs-human`. `all`: wait for every check (`scripts/wait-ci-green.sh`). `none`: merge right after review. Always `--match-head-commit` at the reviewed head. The policy used is recorded in the `FORGE:WORK-ON` comment. |
566
- | `context_pack` | string | No | Context pack (#4275). Before the plan call the core compiles the issue's GitHub/git neighbourhood into a small, budgeted pack: linked issues and PRs (parent, `Depends on`, body links, same-repo cross-references, siblings), prior attempts, merged PRs on the issue's files that were reverted or whose decisions/findings name a symbol from the issue, structured `FORGE:FACTS` records of earlier lanes on the issue's files (rejected approaches with why, hot spots, decisions, conventions, baselines; #4401), and repo-fact file pointers (found in the lane worktree or, for untracked caches, the main checkout; #4400). `shadow` (default): the pack is compiled, posted on the issue as a collapsed `FORGE:CONTEXT` comment and logged as a `CONTEXT_PACK` event; the builder prompt is unchanged. `on`: it is also appended to the plan prompt as hints. `off`: nothing is gathered, posted or logged. The pack never enters the text contract quotes are checked against. Values read from linked issues' `FORGE:STATE` are kept only when they are outcome codes. Compare with `forgedock stats --context-ab`. Unless `off`, a resumed or recovery lane also gets a bounded resume digest of its last run in the builder prompt (end reason, validate results per command, deliver requirements, files changed; logged as `RESUME_DIGEST`; #4400), whatever the mode. |
566
+ | `context_pack` | string | No | Context pack (#4275). Before the plan call the core compiles the issue's GitHub/git neighbourhood into a small, budgeted pack: linked issues and PRs (parent, `Depends on`, body links, same-repo cross-references, siblings), prior attempts, merged PRs on the issue's files that were reverted or whose decisions/findings name a symbol from the issue, structured `FORGE:FACTS` records of earlier lanes on the issue's files (rejected approaches with why, hot spots, decisions, conventions, baselines; #4401), and repo-fact file pointers (found in the lane worktree or, for untracked caches, the main checkout; #4400). `shadow` (default): the pack is compiled, posted on the issue as a collapsed `FORGE:CONTEXT` comment and logged as a `CONTEXT_PACK` event; the builder prompt is unchanged. `on`: it is also appended to the plan prompt as hints. `off`: nothing is gathered, posted or logged. `ab` (#4444): the A/B experiment. Each issue gets a stable arm, `on` or `shadow`, from a hash of `repo#issue`, logged as that mode with `context_ab: true`; the default stays `shadow`. The pack never enters the text contract quotes are checked against. Values read from linked issues' `FORGE:STATE` are kept only when they are outcome codes. Compare with `forgedock stats --context-ab` (per lane type and mode: first-pass yield, turns and cache-read per merge, rework rate, and recovery lanes' implement attempt against the first run on the same issue). Unless `off`, a resumed or recovery lane also gets a bounded resume digest of its last run in the builder prompt (end reason, validate results per command, deliver requirements, files changed; logged as `RESUME_DIGEST`; #4400), whatever the mode. |
567
567
  | `context_pack_sources` | string | No | Comma list of the pack sources to use: `graph`, `attempts`, `pitfalls`, `ledger`, `facts` (earlier lanes' `FORGE:FACTS` on the issue's files plus repo-fact file pointers). Default: all five. |
568
568
  | `context_pack_bytes` | integer | No | Hard byte cap of the pack; items that do not fit are dropped whole and counted. Default: 6144. |
569
569
  | `context_pack_days` | integer | No | Window of the merged-PR ledger, in days. Default: 30. |
570
- | `lane_instructions` | string | No | Lane profile (#4395). Core lane sessions always run with Claude Code auto-memory off (`CLAUDE_CODE_DISABLE_AUTO_MEMORY=1`). Set this to a repo-relative file, for example `devdocs/lane-instructions.md`, that holds only the hard rules plus pointers into docs. It is read from the lane worktree, so it is versioned with the repo. Lanes then also skip CLAUDE.md discovery (`CLAUDE_CODE_DISABLE_CLAUDE_MDS=1`), and the file text is appended to the system prompt of every fresh builder, reviewer and verifier session. An absolute path, a `..` path, or a missing, empty or over-16 KB file is a trajectory anomaly; lanes then keep CLAUDE.md. Settings and hooks always load (no `--setting-sources`). `FORGEDOCK_FULL_SESSION=1` keeps a full session. Each `phase_attempt` telemetry record carries `lane_profile` (`full`, `memory-off` or `lane`) and `baseline_tokens`, the first turn's context of a fresh session, so baseline growth is visible. |
570
+ | `lane_instructions` | string | No | Lane profile (#4395). Core lane sessions always run with Claude Code auto-memory off (`CLAUDE_CODE_DISABLE_AUTO_MEMORY=1`). Set this to a repo-relative file, for example `devdocs/lane-instructions.md`, that holds only the hard rules plus pointers into docs. It is read from the lane worktree, so it is versioned with the repo. Lanes then also skip CLAUDE.md discovery (`CLAUDE_CODE_DISABLE_CLAUDE_MDS=1`), and the file text is appended to the system prompt of every fresh builder, reviewer and verifier session. An absolute path, a `..` path, or a missing, empty or over-16 KB file is a trajectory anomaly; lanes then keep CLAUDE.md. Settings and hooks always load (no `--setting-sources`). `FORGEDOCK_FULL_SESSION=1` keeps a full session. Each `phase_attempt` telemetry record carries `lane_profile` (`full`, `memory-off` or `lane`) and `baseline_tokens`, the first turn's context of a fresh session, so baseline growth is visible. `forgedock stats --lane-profile [--issues <list>]` compares `baseline_tokens` per phase and the conformance rate per profile from local run logs, for the before/after check on the same issue set. |
571
571
  | `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. |
572
572
 
573
573
  **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.
@@ -613,7 +613,7 @@ lean:
613
613
  | `fix_model` | string | No | Model of fresh fix-round sessions (#4363; `engine.lean_fix_model` is also read). Default: the reviewer model (`engine.lean_reviewer_model`, else Sonnet). Fix rounds for precheck, CI, docs, structure, enumerating-test and sequence-number failures, and conformance or review FAILs whose failures all cite a `file:line`, run in a fresh session (no resume of the builder's transcript) whose prompt carries only the round's failure output, the capped `git diff <base>...HEAD`, the frozen contract and the issue title and summary (under 60K characters). A fresh session that makes no commit gets one resume of the original builder session in the same round. Other rounds (block or contract corrections, CI unavailable, base-sync conflicts, failures without a file:line) resume the builder as before; round limits (`verify.max_rounds`, the one review retry) are unchanged. The run log's `PHASE_ATTEMPT_USAGE` records `mode: "fresh"\|"resume"` on each fix-round attempt. Plan and build sessions keep the builder model. |
614
614
  | `builder_routing` | string | No | `on` (default) or `off` (#4394). After the contract is frozen, the lane is classed from its deliverable paths only: `risk` (any path with a built-in risk word such as auth, billing, payments, session, secret or migration, or matching a `review.risk_paths` glob), `docs-config-test` (every path is a test, doc or config file; lockfiles count as code), `small` (at most 2 non-test deliverables) or `large` (more, or none). `docs-config-test` and `small` build on the mid tier (`builder_mid_model`); `risk` and `large` build on the top model (`--model`, else the CLI default). The plan and contract-correction calls always run on the top model. A failed validate round on the mid tier (`precheck`, `ci_red`, `merge_ci`) escalates the lane once: that fix round and every later builder call, fresh fix sessions included, run on the top model. Escalation adds no round. Conformance or review fix rounds do not escalate and keep `fix_model`. `off` keeps every builder call on the top model, as before, and still records the class. The run log gets `BUILDER_ROUTE {mode, class, tier, model, risk}` and `BUILDER_ESCALATED {from, to, trigger}`, and `RUN_TERMINAL` gets `builder_tier`, `builder_class`, `builder_escalated` and `builder_model`. `forgedock stats --tiers` reports merge, conformance and post-merge-rework rates, first-pass yield and cost per tier (`mid`, `mid->top`, `top`) and class, from local run logs. Target-repo risk areas (for example detection code) belong in `review.risk_paths`. |
615
615
  | `builder_mid_model` | string | No | Mid-tier builder model for `builder_routing` (#4394; `engine.lean_builder_mid_model` is also read). Default `claude-sonnet-5-5`. An invalid id is ignored. |
616
- | `light.mode` | string | No | `off`, `shadow` (default) or `on` (#4448). `bin/lean/light.mjs` decides whether an issue qualifies for the light lane: a repro, steps to reproduce, expected or actual behaviour or a code block in the body; 1 to `max_files` predicted non-test files; no `size:M`/`size:L` label; no predicted path with a built-in risk word or matching `review.risk_paths`; not an `investigate(` issue, a post-deploy issue (`post-deploy`/`awaiting-promotion` label, or only post-deploy checklist items) or a decision issue (`decision`, `needs-decision`, `product-decision`, `rfc` or `adr` label, or a `decision(`/`rfc(`/`adr(` title). A missing repro or file prediction is `ambiguous`, left to a cheap classifier. `off` is never eligible; `shadow` reports the verdict without changing the lane; `on` lets an eligible issue run light. The lane wiring is #4450: until it lands, no lane changes shape in any mode. The flat form `lean.light_mode` also works and wins. |
616
+ | `light.mode` | string | No | `off`, `shadow` (default) or `on` (#4448). `bin/lean/light.mjs` decides whether an issue qualifies for the light lane: a repro, steps to reproduce, expected or actual behaviour or a code block in the body; 1 to `max_files` predicted non-test files; no `size:M`/`size:L` label; no predicted path with a built-in risk word or matching `review.risk_paths`; not an `investigate(` issue, a post-deploy issue (`post-deploy`/`awaiting-promotion` label, or only post-deploy checklist items) or a decision issue (`decision`, `needs-decision`, `product-decision`, `rfc` or `adr` label, or a `decision(`/`rfc(`/`adr(` title). A missing repro or file prediction is `ambiguous`, left to a cheap classifier. `off` is never eligible; `shadow` reports the verdict without changing the lane; `on` lets an eligible issue run light. The lane wiring is #4450. A light lane posts a `FORGE:LITE` receipt on the issue (outcome light or upgraded, diff stat, red/green repro, gates, true cost from the #4407 per-session deltas, eligibility reason, upgrade reasons), and the batch efficiency report ends with a `lanes:` line: the light/upgraded/full split with cost and the upgrade rate, upgraded ÷ (light + upgraded) (#4452). The flat form `lean.light_mode` also works and wins. |
617
617
  | `light.max_lines` | number | No | Upgrade trigger (#4448): a light lane whose non-test diff (added + deleted lines) exceeds this moves to the full lane. Default 40. The other triggers: more than `max_files` non-test changed files, a repro that is not red on the base, two failed test runs, a touched risk path, or a public-API-shape change (#4449). Flat `lean.light_max_lines` wins. |
618
618
  | `light.max_files` | number | No | Max predicted non-test files for eligibility and max changed non-test files before an upgrade (#4448). Default 2. Flat `lean.light_max_files` wins. |
619
619
  | `sequence.regenerate` | string | No | Command run in the lane worktree (`sh -c`) after the engine renumbers a schema-changing sequence-numbered migration (#4386), to regenerate its snapshot, e.g. `pnpm --filter @x/db db:generate`. The flat form `lean.sequence_regenerate` also works and wins. When a lane-added numbered file (`NNNN_*.sql`, a drizzle `meta/_journal.json` + `meta/NNNN_snapshot.json`, a numbered ADR) shares its number with the base, the engine renumbers it without a builder session: `git mv` to max(base)+1…, the document's own number in its heading, references to the old name repo-wide, the base journal plus the lane's entries, and a data-only migration's snapshot cloned from the base tip with a new `id`/`prevId`. A schema-changing migration without this command, or a migration in another language (`.py`, `.ts`), falls back to the builder renumber, and the trajectory records why. A base-sync whose conflicts are only in such files is resolved the same way, with no re-review. At the merge gate a lane with numbered files holds a host sequence lease per (repo, lane branch, directory) from its last renumber check through the merge, so only one numbered-file PR is in the renumber→CI→merge window at a time. |
package/docs/STATS.md CHANGED
@@ -78,6 +78,19 @@ Limits: logs written before #4362 carry no trigger, so implement attempts beyond
78
78
  `unknown`. Logs written before #4407 sum running session totals. Lanes run outside a batch are in no window. `gh` (for
79
79
  merged issues only) is the one network call; when it fails, quality and acceptance print `n/a` with the reason.
80
80
 
81
+ ## Explorer A/B (`--explore-ab`, forge#4418)
82
+
83
+ Compares lanes run with forge.yaml `lean.explore: on` against `off` (#4391), from the local run logs only (no GitHub
84
+ crawl). Every RUN_START slice of each log counts, so re-running an issue with the other mode adds a second lane.
85
+
86
+ - Rows per size bucket (`S`, `M`, `L` from the lane's turn record, else `unsized`) and mode: n, mean builder (plan +
87
+ build) turns, mean builder `cache_read_input_tokens`, merge rate (merged / finished) and conformance rate (lanes that
88
+ reached review with no conformance/review fix round and no shadow would-block, over reviewed; same as `--tiers`).
89
+ - A paired table restricted to issues with a finished lane in both modes.
90
+ - Finished lanes without an `explore` tag (before #4391) are counted and left out.
91
+
92
+ `--json` prints the same data as `query.explore_ab`.
93
+
81
94
  ## Sections (per version and repo)
82
95
 
83
96
  - **Cost**: cache-read tokens per issue (mean, median), per phase (mean over all runs, so phases add up to the per-issue
@@ -0,0 +1,55 @@
1
+ {
2
+ "version": "lightlane-v1",
3
+ "description": "Three-way light-lane benchmark (#4453, parent #4442): three more-itertools bugs reported after the model cutoff, graded by the upstream fix tests. Instructions: docs/eval/lightlane-bench.md.",
4
+ "upstream": "more-itertools/more-itertools",
5
+ "license": "MIT",
6
+ "clone_url": "https://github.com/more-itertools/more-itertools.git",
7
+ "base": "670b715b307adfe5d2fca08a87ca562cb8bfea2c",
8
+ "suite_command": "python3 -m unittest discover -q -s tests -t .",
9
+ "control_prompt": "Fix this issue in the repository in the current directory. Write a failing regression test first, then the fix, then run the targeted tests. Do not use web tools.\n\nIssue: {title}\n\n{body}",
10
+ "issues": [
11
+ {
12
+ "id": "lightlane-1293",
13
+ "upstream_issue": 1293,
14
+ "fix_pr": 1294,
15
+ "fix_sha": "1dd480a08bc2a4c399c84bf21cb1558f8b5fc838",
16
+ "title": "Repeated zero-cache seekable.peek() retains nested chain iterators",
17
+ "body": "On current master (`b5e3886`), the fix for #1268 preserves values but each `seekable(..., maxlen=0).peek()` prepends a new `chain` around the previous source. Repeated peeking without advancing retains an iterator chain proportional to the number of calls, despite having no history cache.\n\n```python\nimport tracemalloc\nfrom more_itertools import seekable\n\ns = seekable(range(3), maxlen=0)\ntracemalloc.start()\ns.peek()\nbaseline = tracemalloc.get_traced_memory()[0]\nfor _ in range(10000):\n assert s.peek() == 0\nprint(tracemalloc.get_traced_memory()[0] - baseline)\nprint(list(s.elements()), list(s))\n```\n\nOn CPython 3.12.13/macOS this retains 2,427,672 additional bytes; output remains `[] [0, 1, 2]`. A single lookahead slot should be sufficient regardless of the number of repeated `peek()`/`bool()` calls. I am checking a fix that reuses the existing `peekable` helper only in the zero-history case, with unchanged bounded/unbounded history behavior.\n\nInvestigation performed with AI assistance.\n",
18
+ "oracle": {
19
+ "files": [
20
+ "tests/test_more.py"
21
+ ],
22
+ "command": "python3 -m unittest -q tests.test_more.SeekableTest.test_repeated_zero_cache_peek_has_bounded_memory",
23
+ "note": "670b715 is the merge parent of the fix, but the fix branch also carries the #1268/#1269 seekable maxlen=0 value fix; on the base the oracle errors with StopIteration, so an arm passes only by bounding memory and keeping the peeked values."
24
+ }
25
+ },
26
+ {
27
+ "id": "lightlane-1304",
28
+ "upstream_issue": 1304,
29
+ "fix_pr": 1305,
30
+ "fix_sha": "1ea82a711c69f590054987b5cb194157f8ce8ac4",
31
+ "title": "ichunked(..., 0) raises islice's ValueError on a nonempty iterable",
32
+ "body": "`ichunked` is documented as being like `chunked`, but with lazy chunks. `chunked` treats `n=0` as \"yield nothing\". `ichunked` only does that when the source is already empty; with any remaining items it calls `islice(iterator, n - 1)` and blows up.\n\nI hit this switching a streaming pipeline from `chunked` to `ichunked` so batches would not be materialized as lists. Chunk size comes from the same config in both paths; `0` means \"this stage is disabled, emit nothing.\"\n\n```python\nfrom more_itertools import chunked, ichunked\n\nrows = [\"a\", \"b\", \"c\"] # e.g. leftover rows from a DB cursor\nchunk_size = 0 # config: skip this sink\n\nprint(list(chunked(rows, chunk_size)))\n# []\n\nprint([list(c) for c in ichunked(rows, chunk_size)])\n# ValueError: Stop argument for islice() must be None or an integer: 0 <= x <= sys.maxsize.\n```\n\n`IchunkedTests.test_zero` only uses `iterable = []`, which never enters the `for first in iterator` loop, so the `islice(..., n - 1)` path is untested.\n\nNegative `n` has the same leak: `chunked` raises `ValueError: n must be at least 0`, `ichunked` raises islice's \"Indices for islice() must be None or an integer...\".\n\nHappy to send a PR.",
33
+ "oracle": {
34
+ "files": [
35
+ "tests/test_more.py"
36
+ ],
37
+ "command": "python3 -m unittest -q tests.test_more.IchunkedTests.test_zero_nonempty tests.test_more.IchunkedTests.test_negative"
38
+ }
39
+ },
40
+ {
41
+ "id": "lightlane-1308",
42
+ "upstream_issue": 1308,
43
+ "fix_pr": 1309,
44
+ "fix_sha": "67fcf640477c78605580f5ee703a54c73c1e7a8a",
45
+ "title": "bucket ignores a supplied validator when the callable is falsey",
46
+ "body": "`bucket` replaces a supplied validator with its default when the callable has a false truth value. An empty callable set is one example:\n\n```python\nfrom more_itertools import bucket\n\nclass AllowedKeys(set):\n def __call__(self, key):\n return key in self\n\nallowed = AllowedKeys()\nsource = iter([1, 2, 3])\nbuckets = bucket(source, key=lambda x: x, validator=allowed)\nprint(list(buckets[1])) # [1]; expected []\n```\n\nThe validator rejects every key, so selecting a bucket should return an empty iterator without consuming `source`. Instead, `validator or (lambda x: True)` discards it. Membership checks and iteration over bucket keys also accept keys the validator rejects. On an infinite input, a lookup for an absent, rejected key can keep scanning rather than return immediately.\n\nI reproduced this on current main (11.1.0). Changing the default selection to `(lambda x: True) if validator is None else validator` preserves the supplied callable. My regression checks rejected selection, membership, source consumption, and key iteration; the full unittest suite passes with that change.\n\nI can send the small fix and regression test if this behavior is unintended.\n",
47
+ "oracle": {
48
+ "files": [
49
+ "tests/test_more.py"
50
+ ],
51
+ "command": "python3 -m unittest -q tests.test_more.BucketTests.test_falsey_validator_is_honoured"
52
+ }
53
+ }
54
+ ]
55
+ }
@@ -0,0 +1,91 @@
1
+ # Light-lane three-way benchmark (#4453)
2
+
3
+ This benchmark compares the light lane (#4450) with the full lean lane and with a plain single-session `claude -p` control. All three arms run on the same issue set, and each is graded by the **upstream oracle tests**: the test files of the real upstream fix, which fail on the base and pass on the fix. It reproduces the #4442 benchmark of 2026-10-22 and checks its acceptance targets.
4
+
5
+ - Harness: `scripts/bench-lightlane.mjs`
6
+ - Corpus: `docs/eval/corpus/lightlane-v1.json`. This is more-itertools @ `670b715` with upstream #1293, #1304 and #1308. Their fixes are PRs #1294, #1305 and #1309, and they were reported after the model's training cutoff.
7
+
8
+ ## Prerequisites
9
+
10
+ - `python3`, `git`, `gh` (authenticated, with access to the sandbox repos), `claude` and `forgedock` on PATH. Use the ForgeDock release under test.
11
+ - Two **private sandbox repos**, one for the light arm and one for the full arm. Each needs the upstream history up to the base, so the base SHA exists in it:
12
+ ```sh
13
+ git clone https://github.com/more-itertools/more-itertools.git mi && cd mi
14
+ git push git@github.com:<you>/mi-light.git 670b715b307adfe5d2fca08a87ca562cb8bfea2c:refs/heads/main
15
+ git push git@github.com:<you>/mi-full.git 670b715b307adfe5d2fca08a87ca562cb8bfea2c:refs/heads/main
16
+ ```
17
+ If a sandbox is a squashed snapshot instead, set `arms.<arm>.base` in the run config to its base commit.
18
+ - A `forge.yaml` in each sandbox. The light arm sets `lean:` → `light:` → `mode: on`, and the full arm sets `mode: off`. Commit it on `main`; the harness diffs from the base, so this commit doesn't count as an arm change.
19
+ - The corpus issues filed in each sandbox, with the title and body taken verbatim from the corpus. Labels follow the #4442 setup: `bug`.
20
+ ```sh
21
+ jq -c '.issues[]' docs/eval/corpus/lightlane-v1.json | while read -r i; do
22
+ gh issue create -R <you>/mi-light --title "$(jq -r .title <<<"$i")" --body "$(jq -r .body <<<"$i")" --label bug
23
+ done
24
+ ```
25
+
26
+ ## Run config (`runcfg.json`, not committed)
27
+
28
+ ```json
29
+ {
30
+ "arms": {
31
+ "light": { "repo": "<you>/mi-light", "issues": { "lightlane-1293": 1, "lightlane-1304": 2, "lightlane-1308": 3 } },
32
+ "full": { "repo": "<you>/mi-full", "issues": { "lightlane-1293": 1, "lightlane-1304": 2, "lightlane-1308": 3 } },
33
+ "control": { "model": "claude-opus-5-5" }
34
+ }
35
+ }
36
+ ```
37
+
38
+ Optional per arm:
39
+ - `command`: argv prefix. The default is `["forgedock","run-issue"]`, or `["claude"]` for the control.
40
+ - `args`: extra arguments, for example `["--model","claude-opus-5-5"]` for a lane, or the permission flags your `claude` needs for the control.
41
+ - `clone_url` and `base` for the lane arms.
42
+
43
+ Use the same model in every arm, and no web tools.
44
+
45
+ ## Steps
46
+
47
+ 1. **Calibrate** the oracles. Every issue must report `red_on_base` and `green_on_fix`, otherwise the command exits 2:
48
+ ```sh
49
+ node scripts/bench-lightlane.mjs calibrate --corpus docs/eval/corpus/lightlane-v1.json
50
+ ```
51
+ 2. **Run** the arms. This takes about 10 minutes per arm per issue, runs sequentially, and appends one JSON row per arm and issue:
52
+ ```sh
53
+ node scripts/bench-lightlane.mjs run --corpus docs/eval/corpus/lightlane-v1.json --config runcfg.json --out rows.jsonl
54
+ ```
55
+ Use `--arms light,control` or `--issues lightlane-1304` to rerun part of the set. Use `--keep` to keep the scratch clones.
56
+ 3. **Aggregate** the rows. This prints the metrics and targets, and exits 2 when a target fails:
57
+ ```sh
58
+ node scripts/bench-lightlane.mjs aggregate rows.jsonl
59
+ ```
60
+ 4. Post the aggregate JSON and the target table, plus `rows.jsonl`, on the measurement tracking issue. Note the ForgeDock version, the model and the date.
61
+
62
+ ## What each metric means
63
+
64
+ | Field | Meaning |
65
+ |---|---|
66
+ | `cost_usd` | **True** cost. For the lanes, it is the run result's `cost_usd`: the sum of the #4407 per-session deltas, not the naive sum of running totals. For the control, it is `total_cost_usd`. |
67
+ | `wall_ms` | Lane wall time inside the engine (`wall_ms`, #4451). For the control, it is `duration_ms`. `harness_wall_ms` adds CLI start-up. |
68
+ | `model_ms` | Summed session time (`model_ms`, #4451). For the control, it is `duration_ms`, since the control is one session. |
69
+ | `oracle_pass` | The oracle command passes after the fix's test files are overlaid on the arm's head (`git checkout <fix_sha> -- tests/test_more.py`). |
70
+ | `suite_pass` | The repo's own suite (`suite_command`) passes on the arm's head, before the overlay. |
71
+ | `claimed_fix` | The arm reported success and its diff is non-empty. For the lanes, success is a `merged` or `ci-pending` terminal reason; for the control, it is exit 0 with `is_error: false`. |
72
+ | false fix | `claimed_fix` and not `oracle_pass`. |
73
+ | `api_shape` | `bin/lean/apishape.mjs` from the base to the arm's head (signature, sync↔async, generator↔function, removed or renamed public symbol). |
74
+ | undetected API shape | A light-arm row that finished light (`light: true`, no upgrade) although its diff changed the public API shape. |
75
+
76
+ ## Targets (#4442)
77
+
78
+ | Target | Check |
79
+ |---|---|
80
+ | Light true cost ≤ 1.2× control | The cost sum of eligible light rows, divided by the control's cost on the same issues. |
81
+ | Light wall ≤ 1.3× model time | The `wall_ms` sum of eligible light rows, divided by their `model_ms` sum. |
82
+ | Light oracle pass rate ≥ control | Over all light rows. |
83
+ | Light false-fix rate ≤ control | Over all light rows. |
84
+ | 0 undetected public-API-shape changes | Over all light rows. |
85
+
86
+ "Eligible" means the row's `light_eligibility` (the run result's light-lane verdict) is `eligible`. A target is `null` when an arm it needs has no rows.
87
+
88
+ ## Corpus notes
89
+
90
+ - Oracle commands are scoped to the test ids each fix PR added or changed. Class-wide commands would include tests that fail on the base for reasons unrelated to the issue.
91
+ - `lightlane-1293`: `670b715` is the merge parent of the fix, but the fix branch also carries the #1268/#1269 seekable `maxlen=0` value fix. On the base the oracle errors with `StopIteration`, so an arm passes only if it bounds memory and also keeps the peeked values.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.31.0",
3
+ "version": "1.32.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"
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+ /**
5
+ * scripts/bench-lightlane.mjs — three-way benchmark: light lane vs full lane vs a plain single-session control (#4453)
6
+ *
7
+ * Runs the same issue set through three arms and grades every arm with the upstream oracle tests (the test files of
8
+ * the real upstream fix, overlaid on the arm's head). Zero dependencies; every process call goes through an
9
+ * injectable `run`, so tests use fakes. Instructions: docs/eval/lightlane-bench.md. Corpus: docs/eval/corpus/.
10
+ *
11
+ * Usage:
12
+ * node scripts/bench-lightlane.mjs calibrate --corpus <corpus.json>
13
+ * node scripts/bench-lightlane.mjs run --corpus <corpus.json> --config <runcfg.json> --out <rows.jsonl> [--arms light,full,control] [--issues id,...] [--keep]
14
+ * node scripts/bench-lightlane.mjs aggregate <rows.jsonl|->
15
+ *
16
+ * Arms:
17
+ * light, full: `forgedock run-issue <n> --repo <sandbox> --result-file <f>` on a sandbox repo whose forge.yaml sets
18
+ * lean.light.mode on (light) or off (full). Cost is the run result's cost_usd (the sum of the #4407 per-session
19
+ * deltas); model time is model_ms (summed session time, #4451).
20
+ * control: `claude -p <prompt> --output-format json --model <model>` in a fresh clone at the base; cost is
21
+ * total_cost_usd, wall and model time are duration_ms (one session).
22
+ *
23
+ * The harness never writes to GitHub itself; the only GitHub writes are those of the light/full arms' own run-issue.
24
+ *
25
+ * Exit codes: 0 ok | 1 invalid input or a failed run step | 2 a calibration or a target failed (output still emitted)
26
+ */
27
+
28
+ import { spawn } from "node:child_process";
29
+ import { appendFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
30
+ import { tmpdir } from "node:os";
31
+ import { join } from "node:path";
32
+ import { pathToFileURL } from "node:url";
33
+ import { detectApiShape } from "../bin/lean/apishape.mjs";
34
+
35
+ export const ARMS = ["light", "full", "control"];
36
+ export const TARGETS = Object.freeze({ cost_vs_control: 1.2, wall_vs_model: 1.3 });
37
+ const SHA_RE = /^[0-9a-f]{40}$/;
38
+ const CLAIMED_REASONS = new Set(["merged", "ci-pending"]);
39
+ const OUT_CAP = 1 << 20;
40
+
41
+ /** Spawn `cmd args` and collect its output (the last 1 MiB of each stream); never throws. */
42
+ export function benchDefaultRun(cmd, args, { cwd, env } = {}) {
43
+ return new Promise((resolve) => {
44
+ let stdout = "", stderr = "";
45
+ const cap = (s) => (s.length > OUT_CAP ? s.slice(-OUT_CAP) : s);
46
+ let p;
47
+ try { p = spawn(cmd, args, { cwd, env: env ?? process.env, stdio: ["ignore", "pipe", "pipe"] }); } catch (e) { resolve({ code: 127, stdout: "", stderr: String(e?.message ?? e) }); return; }
48
+ p.stdout.on("data", (d) => { stdout = cap(stdout + d); });
49
+ p.stderr.on("data", (d) => { stderr = cap(stderr + d); });
50
+ p.on("error", (e) => resolve({ code: e?.code === "ENOENT" ? 127 : 1, stdout, stderr: stderr + String(e?.message ?? e) }));
51
+ p.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
52
+ });
53
+ }
54
+
55
+ /** A parsed corpus, or a thrown Error naming the first bad field. */
56
+ export function validateCorpus(c) {
57
+ if (!c || typeof c !== "object") throw new Error("corpus: not an object");
58
+ for (const k of ["clone_url", "suite_command", "control_prompt"]) if (typeof c[k] !== "string" || !c[k].trim()) throw new Error(`corpus: missing ${k}`);
59
+ if (!SHA_RE.test(String(c.base))) throw new Error("corpus: base must be a full 40-hex sha");
60
+ if (!Array.isArray(c.issues) || !c.issues.length) throw new Error("corpus: issues must be a non-empty array");
61
+ const ids = new Set();
62
+ for (const i of c.issues) {
63
+ if (!i?.id || ids.has(i.id)) throw new Error(`corpus: missing or duplicate issue id ${i?.id}`);
64
+ ids.add(i.id);
65
+ if (!SHA_RE.test(String(i.fix_sha))) throw new Error(`corpus: ${i.id}: fix_sha must be a full 40-hex sha`);
66
+ if (typeof i.title !== "string" || typeof i.body !== "string") throw new Error(`corpus: ${i.id}: title and body are required`);
67
+ if (!Array.isArray(i.oracle?.files) || !i.oracle.files.length || typeof i.oracle.command !== "string") throw new Error(`corpus: ${i.id}: oracle {files, command} is required`);
68
+ }
69
+ return c;
70
+ }
71
+
72
+ /** The run config for `arms`, or a thrown Error: light/full need {repo, issues: {<corpus id>: n}}, control needs {model}. */
73
+ export function validateRunConfig(cfg, corpus, arms) {
74
+ const a = cfg?.arms;
75
+ if (!a || typeof a !== "object") throw new Error("config: missing arms");
76
+ for (const arm of arms) {
77
+ const x = a[arm];
78
+ if (!x) throw new Error(`config: missing arms.${arm}`);
79
+ if (arm === "control") { if (typeof x.model !== "string" || !x.model) throw new Error("config: arms.control.model is required"); continue; }
80
+ if (typeof x.repo !== "string" || !/^[\w.-]+\/[\w.-]+$/.test(x.repo)) throw new Error(`config: arms.${arm}.repo must be owner/name`);
81
+ for (const i of corpus.issues) if (!Number.isSafeInteger(x.issues?.[i.id]) || x.issues[i.id] <= 0) throw new Error(`config: arms.${arm}.issues.${i.id} must be the sandbox issue number`);
82
+ }
83
+ return cfg;
84
+ }
85
+
86
+ /** The control prompt for an issue: `{title}` and `{body}` replaced. */
87
+ export const fillPrompt = (tpl, issue) => String(tpl).replaceAll("{title}", issue.title).replaceAll("{body}", issue.body);
88
+
89
+ const sh = (run, command, cwd) => run("sh", ["-c", command], { cwd });
90
+ async function git(run, args, cwd) {
91
+ const r = await run("git", args, { cwd });
92
+ if (r.code !== 0) throw new Error(`git ${args.join(" ")} failed (${r.code}): ${String(r.stderr).trim().slice(0, 300)}`);
93
+ return String(r.stdout).trim();
94
+ }
95
+
96
+ /** Overlay the oracle test files from the fix commit onto the checkout in `dir` and run the oracle; true when it passes. */
97
+ async function runOracle(run, dir, issue) {
98
+ await git(run, ["checkout", "-q", issue.fix_sha, "--", ...issue.oracle.files], dir);
99
+ return (await sh(run, issue.oracle.command, dir)).code === 0;
100
+ }
101
+
102
+ /** For each corpus issue: the oracle must fail on the base and pass on the fix. -> [{id, red_on_base, green_on_fix}] */
103
+ export async function calibrate({ corpus, run = benchDefaultRun, tmp }) {
104
+ const dir = join(tmp, "upstream");
105
+ await git(run, ["clone", "-q", corpus.clone_url, dir], tmp);
106
+ const out = [];
107
+ for (const issue of corpus.issues) {
108
+ await git(run, ["checkout", "-q", "-f", corpus.base], dir);
109
+ const red = !(await runOracle(run, dir, issue));
110
+ await git(run, ["checkout", "-q", "-f", issue.fix_sha], dir);
111
+ const green = (await sh(run, issue.oracle.command, dir)).code === 0;
112
+ out.push({ id: issue.id, red_on_base: red, green_on_fix: green });
113
+ }
114
+ return out;
115
+ }
116
+
117
+ const num = (x) => (Number.isFinite(Number(x)) && x !== null && x !== "" ? Number(x) : null);
118
+ const readJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
119
+
120
+ /** Grade `head` from the repo in `src`: own suite, then the oracle overlay, then the API shape from `base`. */
121
+ async function grade({ run, src, head, base, corpus, issue, tmp, tag, apiShape }) {
122
+ await git(run, ["branch", "-f", "bench-head", head], src);
123
+ const dir = join(tmp, `${tag}-grade`);
124
+ await git(run, ["clone", "-q", "-b", "bench-head", src, dir], tmp);
125
+ await git(run, ["fetch", "-q", corpus.clone_url, issue.fix_sha], dir);
126
+ const changed = (await run("git", ["diff", "--quiet", base, "HEAD"], { cwd: dir })).code === 1;
127
+ let shape;
128
+ try { const r = await apiShape({ cwd: dir, base, head: "HEAD" }); shape = { status: r?.status ?? "unknown", changes: Array.isArray(r?.changes) ? r.changes : [] }; }
129
+ catch (e) { shape = { status: "error", changes: [], error: String(e?.message ?? e).slice(0, 200) }; }
130
+ const suite = (await sh(run, corpus.suite_command, dir)).code === 0;
131
+ const oracle = await runOracle(run, dir, issue);
132
+ return { changed, suite_pass: suite, oracle_pass: oracle, api_shape: shape };
133
+ }
134
+
135
+ /** Run one arm on one corpus issue and grade it -> one row (see aggregate). `now` and `apiShape` are injectable. */
136
+ export async function runArm({ arm, issue, corpus, cfg, run = benchDefaultRun, tmp, now = () => Date.now(), apiShape = detectApiShape }) {
137
+ const tag = `${arm}-${issue.id}`, a = cfg.arms[arm];
138
+ const row = { arm, id: issue.id, cost_usd: null, wall_ms: null, model_ms: null, harness_wall_ms: null, claimed_fix: false, oracle_pass: false, suite_pass: null,
139
+ api_shape: null, light: null, light_eligibility: null, light_upgrade: null, terminal: null };
140
+ const t0 = now();
141
+ let src = null, head = null, base = corpus.base, claimed = false;
142
+ if (arm === "control") {
143
+ src = join(tmp, `${tag}-work`);
144
+ await git(run, ["clone", "-q", corpus.clone_url, src], tmp);
145
+ await git(run, ["checkout", "-q", "-b", "bench-control", corpus.base], src);
146
+ const [bin, ...pre] = Array.isArray(a.command) && a.command.length ? a.command : ["claude"];
147
+ const r = await run(bin, [...pre, "-p", fillPrompt(corpus.control_prompt, issue), "--output-format", "json", "--model", a.model, ...(a.args ?? [])], { cwd: src });
148
+ row.harness_wall_ms = now() - t0;
149
+ let j = null;
150
+ try { j = JSON.parse(String(r.stdout).trim().split("\n").pop()); } catch { /* reported as terminal below */ }
151
+ row.cost_usd = num(j?.total_cost_usd); row.wall_ms = num(j?.duration_ms); row.model_ms = num(j?.duration_ms);
152
+ row.terminal = r.code !== 0 ? `exit ${r.code}` : !j ? "no-json" : j.is_error ? "error" : "ok";
153
+ claimed = row.terminal === "ok";
154
+ await git(run, ["add", "-A"], src);
155
+ await git(run, ["-c", "user.name=bench", "-c", "user.email=bench@localhost", "commit", "-q", "--allow-empty", "--no-verify", "-m", `control ${issue.id}`], src);
156
+ head = await git(run, ["rev-parse", "HEAD"], src);
157
+ } else {
158
+ const n = a.issues[issue.id], rf = join(tmp, `${tag}-result.json`);
159
+ const [bin, ...pre] = Array.isArray(a.command) && a.command.length ? a.command : ["forgedock", "run-issue"];
160
+ const r = await run(bin, [...pre, String(n), "--repo", a.repo, "--result-file", rf, ...(a.args ?? [])], {});
161
+ row.harness_wall_ms = now() - t0;
162
+ const res = readJson(rf);
163
+ row.terminal = res?.terminalReason ?? `exit ${r.code}`;
164
+ row.cost_usd = num(res?.cost_usd); row.wall_ms = num(res?.wall_ms); row.model_ms = num(res?.model_ms);
165
+ row.light = typeof res?.light === "boolean" ? res.light : null;
166
+ row.light_eligibility = res?.light_eligibility ?? null;
167
+ row.light_upgrade = Array.isArray(res?.light_upgrade) ? res.light_upgrade : null;
168
+ claimed = CLAIMED_REASONS.has(row.terminal);
169
+ base = a.base ?? corpus.base;
170
+ src = join(tmp, `${tag}-src`);
171
+ await git(run, ["clone", "-q", a.clone_url ?? `https://github.com/${a.repo}.git`, src], tmp);
172
+ if (res?.pr) {
173
+ const pr = String(res.pr).match(/(\d+)\s*$/)?.[1];
174
+ const v = await run("gh", ["pr", "view", pr, "-R", a.repo, "--json", "headRefOid", "--jq", ".headRefOid"], {});
175
+ await git(run, ["fetch", "-q", "origin", `refs/pull/${pr}/head`], src);
176
+ head = SHA_RE.test(String(v.stdout).trim()) ? String(v.stdout).trim() : await git(run, ["rev-parse", "FETCH_HEAD"], src);
177
+ } else if (res?.branch) {
178
+ await git(run, ["fetch", "-q", "origin", res.branch], src);
179
+ head = await git(run, ["rev-parse", "FETCH_HEAD"], src);
180
+ }
181
+ }
182
+ if (!head) return row; // no PR and no branch: nothing to grade; oracle_pass stays false
183
+ const g = await grade({ run, src, head, base, corpus, issue, tmp, tag, apiShape });
184
+ return { ...row, claimed_fix: claimed && g.changed, oracle_pass: g.oracle_pass, suite_pass: g.suite_pass, api_shape: g.api_shape };
185
+ }
186
+
187
+ const sum = (xs) => xs.reduce((s, x) => s + x, 0);
188
+ const nums = (rows, k) => rows.map((r) => r[k]).filter((x) => Number.isFinite(x));
189
+ const rate = (rows, f) => (rows.length ? rows.filter(f).length / rows.length : null);
190
+ const r4 = (x) => (x == null ? null : Math.round(x * 10000) / 10000);
191
+
192
+ /** Per-arm metrics over rows (pure). */
193
+ export function armMetrics(rows) {
194
+ const cost = nums(rows, "cost_usd"), wall = nums(rows, "wall_ms"), model = nums(rows, "model_ms");
195
+ return {
196
+ n: rows.length,
197
+ cost_usd: { sum: r4(sum(cost)), mean: cost.length ? r4(sum(cost) / cost.length) : null },
198
+ wall_ms: sum(wall), model_ms: sum(model), wall_over_model: sum(model) > 0 ? r4(sum(wall) / sum(model)) : null,
199
+ oracle_pass_rate: r4(rate(rows, (r) => r.oracle_pass === true)),
200
+ false_fix_rate: r4(rate(rows, (r) => r.claimed_fix === true && r.oracle_pass !== true)),
201
+ suite_pass_rate: r4(rate(rows, (r) => r.suite_pass === true)),
202
+ api_shape_changed: rows.filter((r) => r.api_shape?.status === "changed").length,
203
+ undetected_api_shape: rows.filter((r) => r.arm === "light" && r.light === true && r.api_shape?.status === "changed").length,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Rows -> {arms, light_eligible, targets} (pure). Cost and wall are judged on the light rows whose eligibility verdict is
209
+ * `eligible` (cost against the control rows of the same issues); oracle, false fix and undetected API shape on all light
210
+ * rows. A target is {pass: true|false|null, value, limit}; null when an arm it needs has no rows.
211
+ */
212
+ export function aggregate(rows) {
213
+ const by = Object.fromEntries(ARMS.map((a) => [a, rows.filter((r) => r.arm === a)]));
214
+ const arms = Object.fromEntries(ARMS.map((a) => [a, armMetrics(by[a])]));
215
+ const elig = by.light.filter((r) => r.light_eligibility === "eligible");
216
+ const eligIds = new Set(elig.map((r) => r.id));
217
+ const ctlElig = by.control.filter((r) => eligIds.has(r.id));
218
+ const le = armMetrics(elig), ce = armMetrics(ctlElig), L = arms.light, C = arms.control;
219
+ const t = (ok, value, limit) => ({ pass: ok == null ? null : ok, value, limit });
220
+ const costRatio = elig.length && ctlElig.length && ce.cost_usd.sum > 0 ? r4(le.cost_usd.sum / ce.cost_usd.sum) : null;
221
+ const targets = {
222
+ cost_le_1_2x_control: t(costRatio == null ? null : costRatio <= TARGETS.cost_vs_control, costRatio, TARGETS.cost_vs_control),
223
+ wall_le_1_3x_model: t(le.wall_over_model == null ? null : le.wall_over_model <= TARGETS.wall_vs_model, le.wall_over_model, TARGETS.wall_vs_model),
224
+ oracle_ge_control: t(L.n && C.n ? L.oracle_pass_rate >= C.oracle_pass_rate : null, L.oracle_pass_rate, C.oracle_pass_rate),
225
+ false_fix_le_control: t(L.n && C.n ? L.false_fix_rate <= C.false_fix_rate : null, L.false_fix_rate, C.false_fix_rate),
226
+ zero_undetected_api_shape: t(L.n ? L.undetected_api_shape === 0 : null, L.undetected_api_shape, 0),
227
+ };
228
+ return { arms, light_eligible: { ...le, control_cost_usd: ce.cost_usd.sum, ids: [...eligIds].sort() }, targets };
229
+ }
230
+
231
+ /** JSONL (or a JSON array) -> rows. */
232
+ export function parseRows(text) {
233
+ const s = String(text).trim();
234
+ if (!s) return [];
235
+ if (s.startsWith("[")) return JSON.parse(s);
236
+ return s.split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l));
237
+ }
238
+
239
+ const flag = (argv, name) => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : null; };
240
+ const list = (v, all) => (v ? v.split(",").map((s) => s.trim()).filter(Boolean) : all);
241
+
242
+ export async function main(argv = process.argv.slice(2), { run = benchDefaultRun, out = process.stdout, err = process.stderr } = {}) {
243
+ const cmd = argv[0];
244
+ try {
245
+ if (cmd === "aggregate") {
246
+ const src = argv[1];
247
+ const text = !src || src === "-" ? readFileSync(0, "utf8") : readFileSync(src, "utf8");
248
+ const agg = aggregate(parseRows(text));
249
+ out.write(JSON.stringify(agg, null, 2) + "\n");
250
+ return Object.values(agg.targets).some((x) => x.pass === false) ? 2 : 0;
251
+ }
252
+ if (cmd !== "calibrate" && cmd !== "run") { err.write("usage: bench-lightlane.mjs calibrate|run|aggregate (see the header)\n"); return 1; }
253
+ const cp = flag(argv, "--corpus");
254
+ if (!cp) throw new Error("--corpus is required");
255
+ const corpus = validateCorpus(JSON.parse(readFileSync(cp, "utf8")));
256
+ const tmp = mkdtempSync(join(tmpdir(), "bench-lightlane-"));
257
+ try {
258
+ if (cmd === "calibrate") {
259
+ const res = await calibrate({ corpus, run, tmp });
260
+ out.write(JSON.stringify(res, null, 2) + "\n");
261
+ return res.every((r) => r.red_on_base && r.green_on_fix) ? 0 : 2;
262
+ }
263
+ const arms = list(flag(argv, "--arms"), ARMS), rowsOut = flag(argv, "--out"), cfgPath = flag(argv, "--config");
264
+ if (!rowsOut || !cfgPath) throw new Error("--config and --out are required");
265
+ const bad = arms.filter((a) => !ARMS.includes(a));
266
+ if (bad.length) throw new Error(`unknown arm(s): ${bad.join(", ")}`);
267
+ const cfg = validateRunConfig(JSON.parse(readFileSync(cfgPath, "utf8")), corpus, arms);
268
+ const ids = list(flag(argv, "--issues"), corpus.issues.map((i) => i.id));
269
+ for (const issue of corpus.issues.filter((i) => ids.includes(i.id))) {
270
+ for (const arm of arms) {
271
+ const row = await runArm({ arm, issue, corpus, cfg, run, tmp });
272
+ appendFileSync(rowsOut, JSON.stringify(row) + "\n");
273
+ err.write(`${arm} ${issue.id}: oracle ${row.oracle_pass ? "pass" : "fail"}, $${row.cost_usd ?? "?"}, ${row.terminal}\n`);
274
+ }
275
+ }
276
+ return 0;
277
+ } finally {
278
+ if (!argv.includes("--keep")) rmSync(tmp, { recursive: true, force: true });
279
+ }
280
+ } catch (e) {
281
+ err.write(`ERROR: ${e?.message ?? e}\n`);
282
+ return 1;
283
+ }
284
+ }
285
+
286
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) process.exitCode = await main();
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # Fixtures for scripts/refresh-bot-token.sh (#4507): hosts.yml is replaced by rename, never rewritten in place.
6
+ # Run: bash scripts/refresh-bot-token.test.sh (bash 3.2 compatible). Stubs `node` and `gh`; no network.
7
+
8
+ set -u
9
+ DIR=$(cd "$(dirname "$0")" && pwd)
10
+ S="$DIR/refresh-bot-token.sh"
11
+ T=$(mktemp -d "${TMPDIR:-/tmp}/refresh-bot-token-test.XXXXXX")
12
+ trap 'rm -rf "$T"' EXIT
13
+ pass=0; fail=0
14
+ ok() { pass=$((pass+1)); }
15
+ no() { fail=$((fail+1)); echo "FAIL: $1"; }
16
+
17
+ mkdir -p "$T/bin" "$T/cfg"
18
+ echo key > "$T/key.pem"
19
+ cat > "$T/bin/node" <<'STUB'
20
+ #!/usr/bin/env bash
21
+ printf 'tok-new'
22
+ STUB
23
+ cat > "$T/bin/gh" <<'STUB'
24
+ #!/usr/bin/env bash
25
+ echo "$GH_CONFIG_DIR $*" >> "$GH_LOG"
26
+ case "$1 $2" in
27
+ "auth login") [ -n "${GH_FAIL:-}" ] && exit 1; cat > /dev/null; printf 'github.com:\n oauth_token: tok-new\n' > "$GH_CONFIG_DIR/hosts.yml" ;;
28
+ "auth status") echo "Logged in to github.com account rapiercraft-forgedock[bot]" ;;
29
+ esac
30
+ exit 0
31
+ STUB
32
+ chmod +x "$T/bin/node" "$T/bin/gh"
33
+ inode() { ls -i "$1" | awk '{print $1}'; }
34
+
35
+ # (a) success: gh writes in a staging dir; hosts.yml is renamed into place
36
+ printf 'github.com:\n oauth_token: tok-old\n' > "$T/cfg/hosts.yml"
37
+ before=$(inode "$T/cfg/hosts.yml")
38
+ OUT=$(PATH="$T/bin:$PATH" GH_CONFIG_DIR="$T/cfg" GH_LOG="$T/log" bash "$S" --pem "$T/key.pem" 2>&1); RC=$?
39
+ [ "$RC" = 0 ] && ok || no "(a) exit $RC: $OUT"
40
+ grep -q 'tok-new' "$T/cfg/hosts.yml" && ok || no "(a) hosts.yml not updated"
41
+ [ "$(inode "$T/cfg/hosts.yml")" != "$before" ] && ok || no "(a) hosts.yml rewritten in place (same inode)"
42
+ if awk '{print $1}' "$T/log" | grep -qx "$T/cfg"; then no "(a) gh ran against the live config dir"; else ok; fi
43
+ grep -q "auth login" "$T/log" && ok || no "(a) gh auth login not run"
44
+ ls -a "$T/cfg" | grep -q '^\.hosts-stage\.' && no "(a) staging dir left behind" || ok
45
+ case "$OUT" in *"token refreshed"*) ok ;; *) no "(a) success message missing: $OUT" ;; esac
46
+
47
+ # (b) failed login: the live hosts.yml stays byte-identical, exit != 0
48
+ printf 'github.com:\n oauth_token: tok-keep\n' > "$T/cfg/hosts.yml"
49
+ cp "$T/cfg/hosts.yml" "$T/expect"
50
+ PATH="$T/bin:$PATH" GH_CONFIG_DIR="$T/cfg" GH_LOG="$T/log" GH_FAIL=1 bash "$S" --pem "$T/key.pem" >/dev/null 2>&1; RC=$?
51
+ [ "$RC" != 0 ] && ok || no "(b) failed login exited 0"
52
+ cmp -s "$T/expect" "$T/cfg/hosts.yml" && ok || no "(b) hosts.yml changed after a failed login"
53
+ ls -a "$T/cfg" | grep -q '^\.hosts-stage\.' && no "(b) staging dir left behind" || ok
54
+
55
+ echo "refresh-bot-token: $pass passed, $fail failed"
56
+ [ "$fail" = 0 ]