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.
- package/bin/engine/outcome-codes.mjs +29 -0
- package/bin/engine-cli.mjs +71 -16
- package/bin/lean/batch.test.mjs +2 -2
- package/bin/lean/conformance.mjs +47 -2
- package/bin/lean/conformance.test.mjs +53 -1
- package/bin/lean/context.mjs +333 -0
- package/bin/lean/context.test.mjs +279 -0
- package/bin/lean/effects.mjs +19 -1
- package/bin/lean/lean.mjs +171 -35
- package/bin/lean/lean.test.mjs +413 -7
- package/bin/lean/orchestrate.mjs +80 -3
- package/bin/lean/prefilter.mjs +2 -2
- package/bin/lean/prefilter.test.mjs +3 -0
- package/bin/lean/prompts.mjs +22 -7
- package/bin/lean/recovery.test.mjs +230 -0
- package/bin/lean/render.mjs +25 -5
- package/bin/lean/testmap.mjs +84 -0
- package/bin/lean/testmap.test.mjs +58 -0
- package/bin/lean/verify.mjs +48 -3
- package/bin/lean/verify.test.mjs +81 -3
- package/bin/stats/index.mjs +12 -2
- package/bin/stats/local-source.mjs +13 -0
- package/bin/stats/query.mjs +27 -0
- package/bin/stats/stats.test.mjs +41 -2
- package/commands/orchestrate.md +52 -6
- package/docs/CONFIG.md +15 -0
- package/docs/OUTCOME-CODES.md +14 -0
- package/docs/RELEASE-NOTES.md +9 -0
- package/package.json +1 -1
- package/scripts/check-spec-markers.sh +1 -0
- package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
- package/scripts/fixtures/test-map/go/Makefile +5 -0
- package/scripts/fixtures/test-map/go/README.md +1 -0
- package/scripts/fixtures/test-map/go/go.mod +3 -0
- package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
- package/scripts/fixtures/test-map/go/main.go +3 -0
- package/scripts/fixtures/test-map/node/package.json +10 -0
- package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
- package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
- package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
- package/scripts/fixtures/test-map/node/src/a.ts +1 -0
- package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
- package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
- package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
- package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
- package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
- package/scripts/fixtures/test-map/python/uv.lock +1 -0
- package/scripts/repo-facts.sh +146 -0
- package/scripts/repo-facts.test.sh +106 -0
- package/scripts/test-map.sh +211 -0
- package/scripts/test-map.test.sh +165 -0
- package/scripts/wait-ci-green.sh +10 -4
- 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
|
|
package/docs/OUTCOME-CODES.md
CHANGED
|
@@ -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.
|
package/docs/RELEASE-NOTES.md
CHANGED
|
@@ -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.
|
|
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"
|
|
@@ -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 @@
|
|
|
1
|
+
fixture
|
|
@@ -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 {};
|
|
File without changes
|
|
@@ -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 ]
|