forgedock 1.28.0 → 1.29.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 (53) hide show
  1. package/bin/engine/outcome-codes.mjs +29 -0
  2. package/bin/engine-cli.mjs +71 -16
  3. package/bin/lean/batch.test.mjs +2 -2
  4. package/bin/lean/conformance.mjs +47 -2
  5. package/bin/lean/conformance.test.mjs +53 -1
  6. package/bin/lean/context.mjs +333 -0
  7. package/bin/lean/context.test.mjs +279 -0
  8. package/bin/lean/effects.mjs +19 -1
  9. package/bin/lean/lean.mjs +171 -35
  10. package/bin/lean/lean.test.mjs +413 -7
  11. package/bin/lean/orchestrate.mjs +80 -3
  12. package/bin/lean/prefilter.mjs +2 -2
  13. package/bin/lean/prefilter.test.mjs +3 -0
  14. package/bin/lean/prompts.mjs +22 -7
  15. package/bin/lean/recovery.test.mjs +230 -0
  16. package/bin/lean/render.mjs +25 -5
  17. package/bin/lean/testmap.mjs +84 -0
  18. package/bin/lean/testmap.test.mjs +58 -0
  19. package/bin/lean/verify.mjs +48 -3
  20. package/bin/lean/verify.test.mjs +81 -3
  21. package/bin/stats/index.mjs +12 -2
  22. package/bin/stats/local-source.mjs +13 -0
  23. package/bin/stats/query.mjs +27 -0
  24. package/bin/stats/stats.test.mjs +41 -2
  25. package/commands/orchestrate.md +52 -6
  26. package/docs/CONFIG.md +15 -0
  27. package/docs/OUTCOME-CODES.md +14 -0
  28. package/docs/RELEASE-NOTES.md +9 -0
  29. package/package.json +1 -1
  30. package/scripts/check-spec-markers.sh +1 -0
  31. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
  32. package/scripts/fixtures/test-map/go/Makefile +5 -0
  33. package/scripts/fixtures/test-map/go/README.md +1 -0
  34. package/scripts/fixtures/test-map/go/go.mod +3 -0
  35. package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
  36. package/scripts/fixtures/test-map/go/main.go +3 -0
  37. package/scripts/fixtures/test-map/node/package.json +10 -0
  38. package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
  39. package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
  40. package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
  41. package/scripts/fixtures/test-map/node/src/a.ts +1 -0
  42. package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
  43. package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
  44. package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
  45. package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
  46. package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
  47. package/scripts/fixtures/test-map/python/uv.lock +1 -0
  48. package/scripts/repo-facts.sh +146 -0
  49. package/scripts/repo-facts.test.sh +106 -0
  50. package/scripts/test-map.sh +211 -0
  51. package/scripts/test-map.test.sh +165 -0
  52. package/scripts/wait-ci-green.sh +10 -4
  53. package/scripts/wait-ci-green.test.sh +9 -0
package/docs/CONFIG.md CHANGED
@@ -402,6 +402,12 @@ orchestration:
402
402
  # paused P3s wait for bounded P3 batches. P1/P2 are never affected, and
403
403
  # `policy: all` / unlimited overrides do not disable it. "on" (default) | "off".
404
404
  amplification_breaker: on
405
+
406
+ # In-batch recovery (#4267): recovery lanes `forgedock orchestrate` launches
407
+ # per issue for a mechanical needs-human (ci.red, base.conflict, ...).
408
+ # Default 1; 0 disables in-batch recovery.
409
+ recovery:
410
+ max_attempts: 1
405
411
  ```
406
412
 
407
413
  | Field | Type | Required | Description |
@@ -410,6 +416,7 @@ orchestration:
410
416
  | `breaker.errors` / `breaker.window_min` | integer | No | `forgedock orchestrate` circuit breaker (#4121): after `errors` engine-errors within `window_min` minutes the batch stops admitting lanes, lets running lanes finish, and keeps the rest pending for `--resume`. `errors: 0` turns it off. Default: 3 in 30 |
411
417
  | `max_deferrals` | integer | No | `forgedock orchestrate`: requeues of one issue after a non-host `deferred` (usage limit, merge/push transient, CLI idle, lease held, gh unavailable) before its row is final. `host.busy` deferrals are not capped. Default: 3 |
412
418
  | `global_parallel` | integer | No | `forgedock orchestrate` (#4191): host-wide cap on running lanes across every batch and repo on the host, enforced through the host lane registry (`~/.forge/sched/`, or `FORGEDOCK_SCHED_DIR`). A freed slot goes to the highest-priority queued issue (`priority:P<n>` / `P<n>` labels, unlabelled P2); within a priority, slots are shared across repos by weight (#4193: `forgedock sched weight <owner/repo> N`, `--clear` resets; default equal, FIFO inside a repo), and priority always dominates weights; running lanes are never preempted and `--parallel` K still bounds each batch. `forgedock sched cap N` overrides it live for the whole host (`--clear` reverts); `forgedock sched show` lists the weights, slots and the queue. Default: unset (no global cap) |
419
+ | `recovery.max_attempts` | integer | No | `forgedock orchestrate` in-batch recovery (#4267): how many recovery lanes the batch launches per issue for a `needs-human` with a mechanical code (`ci.red`, `base.conflict`, `ci-pending` on a conflicting PR, `contract.invalid` after a mid-run issue edit, `builder.block_untyped`). A recovery lane (`run-issue --recover <code>`) removes `needs-human`, posts a `FORGE:DECISION`, resumes the open PR and re-queues the issue with its dependents. If it ends anything but `merged`, the issue is labelled `needs-human` again with a recovery-evidence `FORGE:NEEDS_HUMAN` comment. Human codes are never recovered. Flat form: `recovery_max_attempts`. Default: 1; `0` disables in-batch recovery (a mechanical `needs-human` is final, as before). |
413
420
  | `cascade.policy` | string | No | `all` \| `balanced` \| `conservative`. Default: `balanced` |
414
421
  | `cascade.max_generation` | integer or `"unlimited"` | No | Max cascade generation admitted at Phase 1 resolve time. Default: 1 |
415
422
  | `cascade.batch_max_generation` | integer | No | Finite maximum member generation that an automated P3 batch may aggregate. Default: 2 |
@@ -422,6 +429,8 @@ orchestration:
422
429
  | `cascade.convergence_window` | positive integer | No | Consecutive ratio observations at or above 1.0 before warning. Default: 3 |
423
430
  | `cascade.amplification_breaker` | `on` \| `off` | No | Pause P3-and-below cascade admission while the findings-spawned / merged-units ratio stays at or above 1.0 for `convergence_window` units. Counts all cascade findings (not only same-lineage); P1/P2 unaffected; independent of `cascade.policy`, so `policy: all` does not disable it. Paused P3s route to bounded P3 batches. Default: `on`. Opt out with `off` (boolean `false` is accepted as `off`, `true` as `on`). |
424
431
 
432
+ **Upgrade note — in-batch recovery is on by default** (1.29.0): without `orchestration.recovery.max_attempts`, `forgedock orchestrate` launches one recovery lane per issue for a mechanical `needs-human` before reporting it. To keep the previous behavior (every `needs-human` final), set `orchestration.recovery.max_attempts: 0`.
433
+
425
434
  **Upgrade note — `cascade.amplification_breaker` is on by default**: existing configs that do not set this key now get the breaker automatically. During a batch, once the findings-spawned / merged-units ratio stays at or above 1.0 for `convergence_window` units (it can trip at exactly 1.0 over 3 units), P3-and-below cascade admission pauses; paused P3s are routed to bounded P3 batches rather than dispatched individually. P1/P2 are unaffected. To keep the previous behavior, set `orchestration.cascade.amplification_breaker: off` (or `false`). See [RELEASE-NOTES.md](RELEASE-NOTES.md) for the release that introduced this default.
426
435
 
427
436
  **Hard invariant — not configurable**: safety exclusions (findings whose `## Problem` section indicates security/billing/anti-bot/auth concerns) are never batched and never auto-admitted by any `cascade.policy`, including `all`. That exclusion is enforced upstream of this section (the P3 batching eligibility check) and has no corresponding key here by design.
@@ -552,6 +561,10 @@ engine:
552
561
  | `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. |
553
562
  | `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. |
554
563
  | `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. |
564
+ | `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, and repo-fact file pointers. `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`. |
565
+ | `context_pack_sources` | string | No | Comma list of the pack sources to use: `graph`, `attempts`, `ledger`, `facts`. Default: all four. |
566
+ | `context_pack_bytes` | integer | No | Hard byte cap of the pack; items that do not fit are dropped whole and counted. Default: 6144. |
567
+ | `context_pack_days` | integer | No | Window of the merged-PR ledger, in days. Default: 30. |
555
568
  | `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. |
556
569
 
557
570
  **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.
@@ -595,6 +608,7 @@ verify:
595
608
  skip_paths: "*.md,docs/**" # optional, comma-separated; default *.md,*.mdx,*.rst,*.txt,docs/**,devdocs/**
596
609
  max_rounds: 2 # optional CI fix rounds before needs-human (default 2)
597
610
  precheck_timeout_seconds: 300 # optional per-command timeout of the declared pre-PR checks (default 300)
611
+ check_run: true # optional; post the forgedock/verify check run (default false)
598
612
  ```
599
613
 
600
614
  | Key | Type | Required | Description |
@@ -605,6 +619,7 @@ verify:
605
619
  | `skip_paths` | string | No | Comma-separated globs. A glob without `/` matches the basename anywhere; `**` spans directories. When every changed file matches, the lane skips CI verification (a docs-only lane runs no CI matrix). |
606
620
  | `max_rounds` | integer | No | Builder fix rounds for red CI (0–10, default 2). Also bounds the merge-time fix rounds and the declared-check fix rounds below, in every mode. |
607
621
  | `precheck_timeout_seconds` | integer | No | Timeout of each declared check command (1–3600, default 300); a timed-out command counts as failed (exit 124). |
622
+ | `check_run` | boolean | No | Default `false` (no Checks API call, behaviour unchanged). `true`: after each push of a reviewed head (PR open, base-sync, merge-time fix round) the engine posts one completed `forgedock/verify` check run on that head (forge#4270), `success` iff every deterministic gate the lane already ran passed: declared check commands, undeclared package edges, docs freshness, contract scope, validate ids, requirement status, hotspot growth, amendments. The output carries each gate's id, pass/fail and evidence as JSON; the run log records a `VERIFY` event. Report only: ForgeDock never makes it required and never waits on it (it is ignored by its own CI waits); requiring it is the repository's branch-protection choice. Creating check runs needs a token that may (a GitHub App with `checks: write`); a Checks API failure is logged as an anomaly and never fails the lane. |
608
623
 
609
624
  **Flow** (`ci`/`hybrid`, `bin/lean/verify.mjs` `runCiVerify`):
610
625
 
@@ -87,6 +87,20 @@ An absent or unknown code is recorded as `unclassified`, with the raw reason kep
87
87
  | `awaiting_merge` | Clean result held for a merge decision | remediate | Terminal reason `awaiting-merge` |
88
88
  | `unclassified` | Fallback for an absent or unknown code | engine | The raw reason is kept alongside |
89
89
 
90
+ ## Needs-human classes (forge#4267)
91
+
92
+ `classifyNeedsHuman(row)` in `bin/engine/outcome-codes.mjs` sorts a lane result into one of three classes, so
93
+ `forgedock orchestrate` recovers mechanical outcomes inside the batch:
94
+
95
+ - **recoverable**:
96
+ - needs-human with `ci.red`, `base.conflict` or `builder.block_untyped`;
97
+ - `contract.invalid` when the issue was edited mid-run (`issueEdited`);
98
+ - `ci-pending` on a conflicting PR, recovered as `base.conflict`.
99
+
100
+ The runner relaunches the issue once per `orchestration.recovery.max_attempts` (default 1) as a recovery lane (`run-issue --recover <code>`), under the normal admission rules. That lane removes the `needs-human` label, posts `FORGE:DECISION`, and resumes the open PR: base-sync, then CI fix rounds from the failed-check logs. Dependents stay queued. A merged recovery is reported as `recovered`. A failed one is final, keeps the recovery evidence on the row, and skips its dependents. A failed recovery stays `needs-human`: the lane re-adds the label (with a `FORGE:NEEDS_HUMAN` comment carrying the evidence) only for a final `needs-human` or `ci-pending`; the batch runner re-adds it, once, for a final `engine-error` or `deferred` after the recovery pass (those may be requeued, so the lane cannot know). `invalid`, `decomposed`, `dependency-*` and `merged` never re-label.
101
+ - **human**: `builder.blocked`, which is only reached by a typed human block: credentials, spend, promotion, customer or product-decision. It is never recovered, it skips dependents, and it is the only class in the report's escalation list.
102
+ - **terminal**: every other code. No recovery runs, dependents are skipped, and the row is reported as `unrecovered`.
103
+
90
104
  ## Known limits
91
105
 
92
106
  - `build.acceptance_failed` covers malformed specs too until the gate sentinel distinguishes them.
@@ -2,6 +2,15 @@
2
2
 
3
3
  Operator-facing notes for behavior changes that need action or awareness. Newest first.
4
4
 
5
+ ## v1.29.0: in-batch recovery and context pack on by default
6
+
7
+ - **In-batch recovery** (`orchestration.recovery.max_attempts`, default 1): `forgedock orchestrate` now launches one recovery lane per issue for a mechanical `needs-human` (`ci.red`, `base.conflict`, `ci-pending` on a conflicting PR, `contract.invalid` after a mid-run issue edit, `builder.block_untyped`). The lane removes `needs-human`, resumes the PR and re-queues the issue. A recovery that ends anything but `merged` re-applies `needs-human` with a recovery-evidence `FORGE:NEEDS_HUMAN` comment.
8
+ - **Context pack** (`engine.context_pack`, default `shadow`): each core run compiles a budgeted pack of the issue's links, prior attempts and related merged PRs, posts it as a collapsed `FORGE:CONTEXT` comment and logs `CONTEXT_PACK`; the builder prompt is unchanged in shadow. `FORGE:CONTEXT` and other engine-posted `<!-- FORGE:` comments are never quotable by contract requirements, and only outcome codes are read from linked issues' `FORGE:STATE`.
9
+ - **Pre-PR test map**: `.forge/test-map.json` is read from the base ref (else the main checkout), never the lane worktree. `scripts/test-map.sh` no longer maps deploy/publish/release/push/secret steps, and a `- run: |` block ends at sibling step keys (`env:`, `shell:`). Regenerate the map.
10
+ - **`forgedock/verify` check**: only gates that ran count; with none, the check is `neutral` ("no gates ran") instead of `success`.
11
+
12
+ Action: set `orchestration.recovery.max_attempts: 0` to keep every `needs-human` final, and `engine.context_pack: off` to stop the `FORGE:CONTEXT` comment. Rerun `scripts/test-map.sh` if you keep a test map. See docs/CONFIG.md.
13
+
5
14
  ## Core work-on: per-phase comments (GitHub as the knowledge graph)
6
15
 
7
16
  The core builder now writes three markdown stage notes with fixed heading templates (`.forge-lean/investigation.md`, `plan.md`, `implementation.md`) next to the machine-readable `result.json`. The engine posts each as its own comment, live while the builder session runs (the CLI call is now asynchronous; a file is posted once its mtime is stable for 2 s) and the rest in order right after the session, before review: `FORGE:INVESTIGATION` (`## Investigation Report`, also carries the `FORGE:VALIDATE` alias marker), `FORGE:PLAN` (`## Implementation Plan`), `FORGE:IMPLEMENTATION` (`## Implementation Complete`, with branch and files changed computed by the engine), then `FORGE:REVIEW` (`## Review`: fixes the issue yes/no with evidence, regression check with what was checked, notes). A missing or blank file is rendered from the `result.json` fields ("not reported" for gaps); builder-written `<!-- FORGE:` markers are stripped and each comment is capped at 60k characters. At the end (merged, invalid or needs-human) one `FORGE:TRAJECTORY` comment (`## Pipeline Trajectory`, also carrying the `FORGE:WORK-ON` alias marker) replaces the old register comment: phase table, decisions, anomalies, cost, tokens, wall time. The PR body shows the root cause, the deliverables table and the verification. No extra model sessions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.28.0",
3
+ "version": "1.29.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"
@@ -107,6 +107,7 @@ MARKER_REGISTRY="
107
107
  CONTRACT
108
108
  COORD_ISSUE
109
109
  CRITIQUE
110
+ DECISION
110
111
  DECISION_RECORD
111
112
  BOOTSTRAP_PLAN
112
113
  DECOMPOSED
@@ -0,0 +1,23 @@
1
+ name: ci
2
+ on:
3
+ pull_request:
4
+ paths:
5
+ - '**.go'
6
+ - 'go.mod'
7
+ jobs:
8
+ lint:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - run: golangci-lint run ./...
13
+ test:
14
+ runs-on: ubuntu-latest
15
+ timeout-minutes: 30
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - name: test
19
+ run: |
20
+ go mod download
21
+ go test ./...
22
+ - name: docs
23
+ run: ./scripts/check-docs
@@ -0,0 +1,5 @@
1
+ build:
2
+ go build ./...
3
+
4
+ test:
5
+ go test ./...
@@ -0,0 +1 @@
1
+ fixture
@@ -0,0 +1,3 @@
1
+ module example.com/fixture
2
+
3
+ go 1.22
@@ -0,0 +1,3 @@
1
+ package util
2
+
3
+ func Double(n int) int { return n * 2 }
@@ -0,0 +1,3 @@
1
+ package main
2
+
3
+ func main() {}
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "fixture-node",
3
+ "private": true,
4
+ "workspaces": ["packages/*"],
5
+ "scripts": {
6
+ "build": "tsc -p .",
7
+ "test": "vitest run",
8
+ "lint": "eslint ."
9
+ }
10
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "@fixture/ui",
3
+ "scripts": { "test": "vitest run", "typecheck": "tsc --noEmit" }
4
+ }
@@ -0,0 +1 @@
1
+ export const Button = () => null;
@@ -0,0 +1 @@
1
+ lockfileVersion: '9.0'
@@ -0,0 +1 @@
1
+ export const a = 1;
@@ -0,0 +1 @@
1
+ export default {};
@@ -0,0 +1,2 @@
1
+ def add(a, b):
2
+ return a + b
@@ -0,0 +1,9 @@
1
+ [project]
2
+ name = "fixture-python"
3
+ version = "0.0.0"
4
+
5
+ [tool.pytest.ini_options]
6
+ testpaths = ["tests"]
7
+
8
+ [tool.ruff]
9
+ line-length = 100
@@ -0,0 +1,5 @@
1
+ from pkg.mod import add
2
+
3
+
4
+ def test_add():
5
+ assert add(1, 2) == 3
@@ -0,0 +1 @@
1
+ version = 1
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # repo-facts.sh — deterministic repo facts the builder reads instead of re-learning them each lane (forge#4272). No LLM,
6
+ # no network, runs none of the repo's commands: git + jq + awk + node. Codebase-agnostic.
7
+ #
8
+ # Usage: repo-facts.sh [--repo-path DIR] [--output FILE | --stdout]
9
+ # default output <repo>/.forge/repo-facts.json, rewritten on every run (an optional cache; the builder prompt cites
10
+ # it by path when present, deleting it restores today's prompts).
11
+ # Output: {"version":1,
12
+ # "toolchain": bin/lean/toolchain.mjs detectPins(repo) {pm, node, notes} (forge#4163),
13
+ # "slow_suites": [{command, source, why}]: test-map entries (.forge/test-map.json when valid, else test-map.sh) whose
14
+ # command names e2e|playwright|cypress|integration|docker, or whose CI job sets `services:` or timeout-minutes >= 20,
15
+ # "layer_rules": {packages:[{name,dir}], edges:[{from,to,kind,manifest}], boundary_configs:[path]}: the workspace
16
+ # package graph at HEAD, same workspace and edge definitions as scripts/structure-delta.sh (forge#4159); boundary
17
+ # configs are eslint/dependency-cruiser/Nx files naming boundary rules,
18
+ # "hotspots": scripts/hotspots.sh over the tracked files, over-threshold and not excluded, by hotspot desc, max 20
19
+ # (forge#4195)}
20
+ # Exit: 0 ok | 2 usage or bad repo path. bash 3.2 portable.
21
+ set -uo pipefail
22
+
23
+ die() { echo "repo-facts: $*" >&2; exit 2; }
24
+ command -v jq >/dev/null 2>&1 || die "jq is required"
25
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
26
+ REPO="."; OUT=""; STDOUT=0
27
+ while [ $# -gt 0 ]; do
28
+ case "$1" in
29
+ --repo-path) [ $# -ge 2 ] && [ -n "$2" ] || die "usage: repo-facts.sh [--repo-path DIR] [--output FILE | --stdout]"; REPO="$2"; shift 2 ;;
30
+ --output) [ $# -ge 2 ] && [ -n "$2" ] || die "usage: repo-facts.sh [--repo-path DIR] [--output FILE | --stdout]"; OUT="$2"; shift 2 ;;
31
+ --stdout) STDOUT=1; shift ;;
32
+ *) die "unknown option: $1" ;;
33
+ esac
34
+ done
35
+ [ -d "$REPO" ] || die "repo path not found: $REPO"
36
+ case "$OUT" in ""|/*) ;; *) OUT="$PWD/$OUT" ;; esac
37
+ cd "$REPO" || die "cannot enter $REPO"
38
+
39
+ T=$(mktemp -d "${TMPDIR:-/tmp}/repo-facts.XXXXXX") || die "mktemp failed"
40
+ trap 'rm -rf "$T"' EXIT
41
+ HAS_HEAD=0; git rev-parse --verify -q "HEAD^{commit}" >/dev/null 2>&1 && HAS_HEAD=1
42
+ # -z and quotepath=off (#4298): non-ASCII and spaced paths come out verbatim, never C-quoted
43
+ if [ "$HAS_HEAD" = 1 ]; then git -c core.quotepath=off ls-tree -r -z --name-only HEAD | tr '\0' '\n' > "$T/files"; else : > "$T/files"; fi
44
+ # file content at HEAD
45
+ at_head() { git show "HEAD:$1" 2>/dev/null; }
46
+
47
+ # ---- toolchain (reuses #4163 detection) ----
48
+ TOOLCHAIN=$(node --input-type=module -e '
49
+ const { detectPins } = await import(process.argv[1]);
50
+ console.log(JSON.stringify(await detectPins(process.argv[2])));
51
+ ' "$HERE/../bin/lean/toolchain.mjs" "$PWD" 2>/dev/null) || TOOLCHAIN=""
52
+ printf '%s' "$TOOLCHAIN" | jq -e 'type == "object"' >/dev/null 2>&1 || TOOLCHAIN='{"pm":null,"node":null,"notes":["toolchain detection failed"]}'
53
+
54
+ # ---- slow suites ----
55
+ MAP=""
56
+ [ -f .forge/test-map.json ] && MAP=$(jq -c 'select(.version == 1 and (.entries | type == "array"))' .forge/test-map.json 2>/dev/null)
57
+ [ -n "$MAP" ] || MAP=$(bash "$HERE/test-map.sh" --stdout 2>/dev/null)
58
+ [ -n "$MAP" ] || MAP='{"version":1,"entries":[]}'
59
+ # CI jobs: "workflow#jobs.<job> TAB services(0|1) TAB timeout-minutes(0 when unset)"
60
+ : > "$T/jobs"
61
+ grep -E '^\.github/workflows/[^/]+\.(yml|yaml)$' "$T/files" | while IFS= read -r wf; do
62
+ [ -f "$wf" ] || continue
63
+ awk -v wf="$wf" '
64
+ function indent(s) { match(s, /^ */); return RLENGTH }
65
+ function flush() { if (job != "") print wf "#jobs." job "\t" svc "\t" tmo }
66
+ /^jobs:[ \t]*$/ { on = 1; next }
67
+ on && /^[^ \t#]/ { flush(); job = ""; on = 0; next }
68
+ on && /^ +[A-Za-z0-9_-]+:[ \t]*$/ && (jind == 0 || indent($0) == jind) { flush(); jind = indent($0); job = $0; sub(/^ +/, "", job); sub(/:.*$/, "", job); svc = 0; tmo = 0; next }
69
+ on && job != "" && indent($0) == jind + 2 && /^ *services:/ { svc = 1 }
70
+ on && job != "" && indent($0) == jind + 2 && /^ *timeout-minutes:[ \t]*[0-9]+/ { t = $0; sub(/^ *timeout-minutes:[ \t]*/, "", t); tmo = t + 0 }
71
+ END { flush() }
72
+ ' "$wf" >> "$T/jobs"
73
+ done
74
+ JOBS=$(jq -R -s -c '[split("\n")[] | select(length > 0) | split("\t") | {key: .[0], value: {services: (.[1] == "1"), timeout: (.[2] | tonumber)}}] | from_entries' < "$T/jobs")
75
+ SLOW=$(printf '%s' "$MAP" | jq -c --argjson jobs "$JOBS" '
76
+ [.entries[] | . as $e
77
+ | ($e.command | ascii_downcase | [scan("e2e|playwright|cypress|integration|docker")] | first) as $w
78
+ | ($jobs[$e.source] // null) as $j
79
+ | (if $w then "command names \($w)"
80
+ elif $j and $j.services then "CI job \($e.source) uses services"
81
+ elif $j and $j.timeout >= 20 then "CI job \($e.source) sets timeout-minutes \($j.timeout)"
82
+ else null end) as $why
83
+ | select($why) | {command: $e.command, source: $e.source, why: $why}]')
84
+
85
+ # ---- layer rules: workspace packages and their edges at HEAD (structure-delta.sh definitions) ----
86
+ {
87
+ at_head package.json | jq -r '(.workspaces // empty) | if type == "array" then .[] elif type == "object" then (.packages // [])[] else empty end | strings' 2>/dev/null
88
+ at_head pnpm-workspace.yaml | awk '
89
+ /^packages:/ { on = 1; next }
90
+ on && /^[^ \t#-]/ { on = 0 }
91
+ on && /^[ \t]*-[ \t]*/ { sub(/^[ \t]*-[ \t]*/, ""); sub(/[ \t]+#.*$/, ""); gsub(/["\047]/, ""); sub(/[ \t]+$/, ""); if ($0 != "") print }'
92
+ } | sed 's#^\./##; s#/$##' | sort -u > "$T/globs"
93
+ to_re() { printf '%s' "$1" | sed 's/[.+^$()|{}[]/\\&/g; s/\*\*/%%/g; s/\*/[^\/]*/g; s/%%/.*/g; s/?/[^\/]/g'; }
94
+ : > "$T/inc"; : > "$T/exc"; : > "$T/names"; : > "$T/deps"
95
+ while IFS= read -r g; do
96
+ case "$g" in \!*) echo "^$(to_re "${g#!}")\$" >> "$T/exc" ;; *) echo "^$(to_re "$g")\$" >> "$T/inc" ;; esac
97
+ done < "$T/globs"
98
+ if [ -s "$T/inc" ]; then
99
+ grep -E '/package\.json$' "$T/files" | grep -v -E '(^|/)node_modules/' | while IFS= read -r m; do
100
+ d=${m%/package.json}
101
+ grep -qE -f "$T/inc" <<<"$d" || continue
102
+ if [ -s "$T/exc" ] && grep -qE -f "$T/exc" <<<"$d"; then continue; fi
103
+ at_head "$m" > "$T/pj" || continue
104
+ n=$(jq -r '.name // empty | strings' "$T/pj" 2>/dev/null)
105
+ [ -n "$n" ] || continue
106
+ printf '%s\t%s\t%s\n' "$d" "$m" "$n" >> "$T/names"
107
+ jq -r --arg m "$m" '["dependencies","devDependencies","peerDependencies"][] as $k | (.[$k] // {}) | objects | keys[] | [$m, $k, .] | @tsv' "$T/pj" 2>/dev/null >> "$T/deps"
108
+ done
109
+ fi
110
+ PACKAGES=$(jq -R -s -c '[split("\n")[] | select(length > 0) | split("\t") | {name: .[2], dir: .[0]}] | sort_by(.name)' < "$T/names")
111
+ EDGES=$(awk -F'\t' '
112
+ FILENAME == ARGV[1] { name[$2] = $3; ws[$3] = 1; next }
113
+ ($3 in ws) && ($1 in name) && name[$1] != $3 { print name[$1] "\t" $3 "\t" $2 "\t" $1 }
114
+ ' "$T/names" "$T/deps" | sort -u | jq -R -s -c '[split("\n")[] | select(length > 0) | split("\t") | {from: .[0], to: .[1], kind: .[2], manifest: .[3]}]')
115
+ : > "$T/bfiles"
116
+ while IFS= read -r f; do
117
+ case "${f##*/}" in
118
+ .eslintrc|.eslintrc.*|eslint.config.*|.dependency-cruiser.*|nx.json|project.json)
119
+ # here-string, not `at_head | grep -q`: under pipefail an early grep exit SIGPIPEs at_head and reads as no match (#4298)
120
+ c=$(at_head "$f"); grep -qE 'boundaries|forbidden|depConstraints|DependOnLibsWithTags|no-restricted-paths' <<<"$c" && printf '%s\n' "$f" >> "$T/bfiles" ;;
121
+ esac
122
+ done < "$T/files"
123
+ BOUNDARY=$(jq -R -s -c 'split("\n") | map(select(length > 0))' < "$T/bfiles")
124
+
125
+ # ---- hotspots (reuses #4195 facts) ----
126
+ # hotspots.sh with no paths reports its config; only files over file_lines can be hotspots, so only those are passed
127
+ HOT='[]'
128
+ FL=$(bash "$HERE/hotspots.sh" 2>/dev/null | jq -r 'select(.enabled) | .file_lines' 2>/dev/null)
129
+ if [ -s "$T/files" ] && [ -n "$FL" ]; then
130
+ while IFS= read -r f; do
131
+ [ -f "$f" ] || continue
132
+ n=$(wc -l < "$f" | tr -d ' '); [ "$n" -gt "$FL" ] && printf '%s\n' "$f"
133
+ done < "$T/files" > "$T/big"
134
+ H=""; [ -s "$T/big" ] && { H=$(tr '\n' '\0' < "$T/big" | xargs -0 -n 200 bash "$HERE/hotspots.sh" -- 2>/dev/null) || H=""; }
135
+ HOT=$(printf '%s' "$H" | jq -s -c '[.[].files[]? | select(.over and (.excluded | not)) | {path, lines, commits_90d, hotspot}] | sort_by(-.hotspot, .path) | .[:20]' 2>/dev/null) || HOT='[]'
136
+ [ -n "$HOT" ] || HOT='[]'
137
+ fi
138
+
139
+ FACTS=$(jq -n -c --argjson tc "$TOOLCHAIN" --argjson slow "$SLOW" --argjson pk "$PACKAGES" --argjson ed "$EDGES" --argjson bc "$BOUNDARY" --argjson hot "$HOT" \
140
+ '{version: 1, toolchain: $tc, slow_suites: $slow, layer_rules: {packages: $pk, edges: $ed, boundary_configs: $bc}, hotspots: $hot}') || die "jq failed"
141
+
142
+ if [ "$STDOUT" = 1 ]; then printf '%s\n' "$FACTS"; exit 0; fi
143
+ [ -n "$OUT" ] || OUT=".forge/repo-facts.json"
144
+ mkdir -p "$(dirname "$OUT")" || die "cannot create $(dirname "$OUT")"
145
+ printf '%s\n' "$FACTS" > "$OUT.tmp.$$" && mv "$OUT.tmp.$$" "$OUT" || die "cannot write $OUT"
146
+ echo "$OUT"
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env bash
2
+ # repo-facts.test.sh — cases for scripts/repo-facts.sh (forge#4272).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ set -uo pipefail
6
+ S="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/repo-facts.sh"
7
+ PASS=0; FAILN=0
8
+ TMP="$(mktemp -d "${TMPDIR:-/tmp}/repo-facts-test.XXXXXX")"; trap 'rm -rf "$TMP"' EXIT
9
+ ok() { PASS=$((PASS+1)); }
10
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
11
+ eq() { if [ "$2" = "$3" ]; then ok; else bad "$1 (got: $2 want: $3)"; fi }
12
+ q() { printf '%s' "$OUT" | jq -c "$1"; }
13
+
14
+ new_repo() {
15
+ rm -rf "$TMP/$1"; mkdir -p "$TMP/$1"; cd "$TMP/$1" || exit 1
16
+ git init -q -b main; git config user.email t@example.com; git config user.name t; git config commit.gpgsign false
17
+ }
18
+ commit() { git add -A; git commit -qm "$1"; }
19
+ lines() { i=0; : > "$1"; while [ $i -lt "$2" ]; do echo "l$i" >> "$1"; i=$((i+1)); done; }
20
+
21
+ # ---- 1. pins, slow suites, package edge, boundary config, hotspot ----
22
+ new_repo full
23
+ mkdir -p packages/a packages/b .github/workflows src
24
+ cat > package.json <<'EOF'
25
+ {"name":"root","private":true,"packageManager":"pnpm@9.1.0","workspaces":["packages/*"],
26
+ "scripts":{"test":"vitest run","lint":"eslint .","test:e2e":"playwright test"}}
27
+ EOF
28
+ echo "20.11.1" > .nvmrc
29
+ echo '{"name":"@x/a","dependencies":{"@x/b":"workspace:*","left-pad":"1.0.0"}}' > packages/a/package.json
30
+ echo '{"name":"@x/b","scripts":{"test":"vitest run"}}' > packages/b/package.json
31
+ echo "module.exports = { rules: { 'boundaries/element-types': ['error'] } };" > .eslintrc.js
32
+ echo "module.exports = { rules: { semi: 'error' } };" > packages/b/.eslintrc.js
33
+ cat > .github/workflows/ci.yml <<'EOF'
34
+ on: push
35
+ jobs:
36
+ db:
37
+ runs-on: ubuntu-latest
38
+ services:
39
+ postgres:
40
+ image: postgres
41
+ steps:
42
+ - run: pnpm run test
43
+ long:
44
+ runs-on: ubuntu-latest
45
+ timeout-minutes: 45
46
+ steps:
47
+ - run: make lint-all
48
+ quick:
49
+ runs-on: ubuntu-latest
50
+ timeout-minutes: 5
51
+ steps:
52
+ - run: make fmt
53
+ EOF
54
+ lines src/big.ts 2100; commit c1
55
+ echo x >> src/big.ts; lines src/small.ts 10; commit c2
56
+ OUT="$(bash "$S" --stdout)"; rc=$?
57
+ eq "rc" "$rc" 0
58
+ eq "version" "$(q '.version')" 1
59
+ eq "pm pin" "$(q '.toolchain.pm')" '{"name":"pnpm","version":"9.1.0","source":"package.json packageManager"}'
60
+ eq "node pin" "$(q '.toolchain.node')" '{"spec":"20.11.1","source":".nvmrc"}'
61
+ eq "packages" "$(q '.layer_rules.packages')" '[{"name":"@x/a","dir":"packages/a"},{"name":"@x/b","dir":"packages/b"}]'
62
+ eq "edge" "$(q '.layer_rules.edges')" '[{"from":"@x/a","to":"@x/b","kind":"dependencies","manifest":"packages/a/package.json"}]'
63
+ eq "boundary configs" "$(q '.layer_rules.boundary_configs')" '[".eslintrc.js"]'
64
+ eq "services job" "$(q '[.slow_suites[] | select(.command == "pnpm run test") | .why]')" '["CI job .github/workflows/ci.yml#jobs.db uses services"]'
65
+ eq "timeout job" "$(q '[.slow_suites[] | select(.command == "make lint-all") | .why]')" '["CI job .github/workflows/ci.yml#jobs.long sets timeout-minutes 45"]'
66
+ eq "quick job not slow" "$(q '[.slow_suites[] | select(.command == "make fmt")] | length')" 0
67
+ eq "hotspot" "$(q '.hotspots')" '[{"path":"src/big.ts","lines":2101,"commits_90d":2,"hotspot":4202}]'
68
+
69
+ # ---- 2. a cached test map is read: its e2e command is a slow suite ----
70
+ mkdir -p .forge
71
+ echo '{"version":1,"entries":[{"globs":["e2e/**"],"command":"pnpm exec playwright test","cwd":".","source":"package.json#scripts.test:e2e"}]}' > .forge/test-map.json
72
+ OUT="$(bash "$S" --stdout)"
73
+ eq "map e2e" "$(q '.slow_suites')" '[{"command":"pnpm exec playwright test","source":"package.json#scripts.test:e2e","why":"command names playwright"}]'
74
+
75
+ # ---- 3. default output file, rerun stable ----
76
+ P="$(bash "$S")"; eq "output path" "$P" ".forge/repo-facts.json"
77
+ cp .forge/repo-facts.json "$TMP/first.json"; bash "$S" >/dev/null
78
+ if cmp -s .forge/repo-facts.json "$TMP/first.json"; then ok; else bad "rerun not identical"; fi
79
+
80
+ # ---- 4. a repo with none of these: empty sections, exit 0 ----
81
+ new_repo bare
82
+ echo hi > README.md; commit init
83
+ OUT="$(bash "$S" --stdout)"; rc=$?
84
+ eq "bare rc" "$rc" 0
85
+ eq "bare" "$(q '[.toolchain.pm, .toolchain.node, .slow_suites, .layer_rules, .hotspots]')" '[null,null,[],{"packages":[],"edges":[],"boundary_configs":[]},[]]'
86
+ new_repo empty
87
+ OUT="$(bash "$S" --stdout)"; eq "no commits rc" "$?" 0
88
+ eq "no commits" "$(q '.hotspots')" '[]'
89
+
90
+ # ---- 4b. #4298: a big boundary config matching early (pipefail + grep -q SIGPIPE) and non-ASCII / spaced paths ----
91
+ new_repo paths
92
+ mkdir -p "packages/é b" "apps/ü"
93
+ echo '{"name":"root","private":true,"workspaces":["packages/*"]}' > package.json
94
+ echo '{"name":"@x/c"}' > "packages/é b/package.json"
95
+ { echo "export default [{ rules: { 'boundaries/element-types': ['error'] } }];"; i=0; while [ $i -lt 20000 ]; do echo "// padding line $i to overflow the pipe buffer ......................................"; i=$((i+1)); done; } > "apps/ü/eslint.config.js"
96
+ commit init
97
+ OUT="$(bash "$S" --stdout)"; eq "paths rc" "$?" 0
98
+ eq "big boundary config in a non-ASCII dir" "$(q '.layer_rules.boundary_configs')" '["apps/ü/eslint.config.js"]'
99
+ eq "spaced non-ASCII package dir" "$(q '.layer_rules.packages')" '[{"name":"@x/c","dir":"packages/é b"}]'
100
+
101
+ # ---- 5. usage ----
102
+ bash "$S" --repo-path "$TMP/missing" >/dev/null 2>&1; eq "bad repo path" "$?" 2
103
+ bash "$S" --bogus >/dev/null 2>&1; eq "unknown option" "$?" 2
104
+
105
+ echo "repo-facts.test.sh: $PASS passed, $FAILN failed"
106
+ [ "$FAILN" -eq 0 ]