forgedock 1.30.0 → 1.32.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 +3 -2
- package/bin/engine/runlog.mjs +82 -12
- package/bin/engine/telemetry-seam.mjs +11 -4
- package/bin/engine/telemetry-seam.test.mjs +18 -0
- package/bin/engine-cli.mjs +409 -38
- package/bin/engine.mjs +47 -37
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/hooks/reread-nudge.mjs +65 -0
- package/bin/labels.json +5 -0
- package/bin/lean/apishape.mjs +170 -0
- package/bin/lean/apishape.test.mjs +187 -0
- package/bin/lean/batch.mjs +32 -0
- package/bin/lean/batch.test.mjs +338 -2
- package/bin/lean/budget.mjs +89 -0
- package/bin/lean/budget.test.mjs +106 -0
- package/bin/lean/checks.mjs +32 -9
- package/bin/lean/checks.test.mjs +78 -1
- package/bin/lean/conformance.e2e.test.mjs +27 -5
- package/bin/lean/conformance.mjs +150 -15
- package/bin/lean/conformance.test.mjs +175 -3
- package/bin/lean/context.mjs +128 -44
- package/bin/lean/context.test.mjs +157 -5
- package/bin/lean/decompose.mjs +90 -0
- package/bin/lean/decompose.test.mjs +105 -0
- package/bin/lean/depcache.mjs +11 -7
- package/bin/lean/depcache.test.mjs +81 -0
- package/bin/lean/e2e.mjs +102 -0
- package/bin/lean/e2e.test.mjs +86 -0
- package/bin/lean/effects.mjs +48 -15
- package/bin/lean/explore.mjs +47 -0
- package/bin/lean/explore.test.mjs +61 -0
- package/bin/lean/facts.mjs +84 -0
- package/bin/lean/facts.test.mjs +66 -0
- package/bin/lean/fixround.mjs +90 -0
- package/bin/lean/fixround.test.mjs +117 -0
- package/bin/lean/heavy.mjs +35 -2
- package/bin/lean/heavy.test.mjs +32 -2
- package/bin/lean/lean.mjs +712 -134
- package/bin/lean/lean.test.mjs +1626 -37
- package/bin/lean/ledger.mjs +5 -3
- package/bin/lean/ledger.test.mjs +18 -0
- package/bin/lean/light.mjs +135 -0
- package/bin/lean/light.test.mjs +173 -0
- package/bin/lean/lightlane.mjs +219 -0
- package/bin/lean/lightlane.test.mjs +237 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +303 -41
- package/bin/lean/pitfalls.mjs +136 -0
- package/bin/lean/pitfalls.test.mjs +117 -0
- package/bin/lean/postmerge.mjs +85 -0
- package/bin/lean/postmerge.test.mjs +79 -0
- package/bin/lean/precheck.mjs +138 -13
- package/bin/lean/precheck.test.mjs +118 -1
- package/bin/lean/prefilter.mjs +220 -5
- package/bin/lean/prefilter.test.mjs +260 -4
- package/bin/lean/preflight.mjs +108 -3
- package/bin/lean/preflight.test.mjs +48 -1
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +104 -13
- package/bin/lean/render.mjs +7 -3
- package/bin/lean/resume.mjs +67 -0
- package/bin/lean/resume.test.mjs +84 -0
- package/bin/lean/review-trigger.mjs +12 -0
- package/bin/lean/review-trigger.test.mjs +17 -1
- package/bin/lean/routing.mjs +74 -0
- package/bin/lean/routing.test.mjs +67 -0
- package/bin/lean/sched.mjs +16 -1
- package/bin/lean/sched.test.mjs +175 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +170 -3
- package/bin/lean/selfreview.mjs +37 -0
- package/bin/lean/selfreview.test.mjs +51 -0
- package/bin/lean/seqlease.mjs +40 -0
- package/bin/lean/seqlease.test.mjs +101 -0
- package/bin/lean/sequence.mjs +296 -13
- package/bin/lean/sequence.test.mjs +263 -2
- package/bin/lean/toolchain.mjs +60 -15
- package/bin/lean/toolchain.test.mjs +120 -3
- package/bin/lean/toolstats.mjs +72 -0
- package/bin/lean/toolstats.test.mjs +47 -0
- package/bin/lean/verifier.mjs +232 -0
- package/bin/lean/verifier.test.mjs +163 -0
- package/bin/lean/verify.mjs +72 -10
- package/bin/lean/verify.test.mjs +68 -3
- package/bin/review-staging.mjs +533 -0
- package/bin/runner.mjs +82 -13
- package/bin/stats/aggregate.mjs +87 -2
- package/bin/stats/efficiency.mjs +335 -0
- package/bin/stats/efficiency.test.mjs +266 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +91 -6
- package/bin/stats/local-source.mjs +145 -8
- package/bin/stats/query.mjs +181 -3
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +403 -8
- package/commands/orchestrate.md +3 -1
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-staging.md +84 -831
- package/commands/review-pr.md +1 -1
- package/commands/work-on/build/implement.md +1 -0
- package/commands/work-on/build/validate.md +11 -1
- package/docs/CONFIG.md +80 -5
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/STATS.md +66 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/eval/corpus/lightlane-v1.json +55 -0
- package/docs/eval/lightlane-bench.md +91 -0
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +286 -0
- package/scripts/ci-local/lib.mjs +20 -0
- package/scripts/ci-local/plan.mjs +2 -12
- package/scripts/ci-local/scoping.mjs +10 -1
- package/scripts/ci-local.test.sh +2 -0
- package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
- package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
- package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
- package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
- package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
- package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
- package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
- package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
- package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
- package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
- package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
- package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
- package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
- package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
- package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
- package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
- package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
- package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
- package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
- package/scripts/forge-root.test.sh +2 -2
- package/scripts/publish-staging-sync.test.sh +62 -4
- package/scripts/review-finalize.sh +17 -6
- package/scripts/review-finalize.test.sh +47 -0
- package/scripts/review-pr-arguments.test.sh +13 -1
- package/scripts/review-pr-delta-wiring.test.sh +2 -2
- package/scripts/review-pr-gate2.test.sh +6 -14
- package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
- package/scripts/review-pr-staging-scope.test.sh +39 -33
- package/scripts/review-prepare.sh +73 -6
- package/scripts/review-prepare.test.sh +85 -0
- package/scripts/review-staging-post.sh +293 -0
- package/scripts/review-staging-post.test.sh +143 -0
- package/scripts/review-staging-pre.sh +401 -0
- package/scripts/review-staging-pre.test.sh +150 -0
- package/scripts/symbol-index.mjs +26 -7
- package/scripts/symbol-index.py +55 -9
- package/scripts/trusted-comments.test.sh +1 -1
package/bin/lean/prompts.mjs
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
6
|
import { reviewSkeleton } from "./conformance.mjs";
|
|
7
|
+
import { seqNumber } from "./sequence.mjs";
|
|
8
|
+
import { capText, FIX_CAPS, FIX_PROMPT_MAX } from "./fixround.mjs";
|
|
9
|
+
import { SPLIT_MIN_FILES, SPLIT_MIN_LINES } from "./decompose.mjs";
|
|
7
10
|
|
|
8
11
|
export const approxTokens = (s) => Math.ceil(String(s).length / 4);
|
|
9
12
|
export const LEAN_DIR = ".forge-lean";
|
|
@@ -15,8 +18,14 @@ export const CONTRACT_FILE = `${LEAN_DIR}/contract.json`;
|
|
|
15
18
|
|
|
16
19
|
/** Change-scoped checks with shared incremental caches (#4218): the builder runs the scoped commands this CLI prints. */
|
|
17
20
|
export const CHECKS_CLI = fileURLToPath(new URL("./checks.mjs", import.meta.url));
|
|
21
|
+
/** #4364: `forgedock check`, the engine's validate and pre-PR checks run in-session before the result is written. */
|
|
22
|
+
export const FORGEDOCK_CLI = fileURLToPath(new URL("../forgedock.mjs", import.meta.url));
|
|
18
23
|
|
|
19
|
-
const HOST_RULES = `Host rules (many lanes share this machine): run only targeted tests (specific
|
|
24
|
+
const HOST_RULES = `Host rules (many lanes share this machine): run only targeted tests (specific files or -k filters), never the full suite. Never create extra checkouts, copies or base clones of the repo; compare with git show or git diff origin/<base>. Never install browsers. Keep scratch files under $TMPDIR. Cap verification workers at $FORGE_HOST_PARALLELISM when set: turbo --concurrency, vitest/jest --maxWorkers, playwright --workers, node --test --test-concurrency (eslint via turbo). For any typecheck, lint or test you run, first run \`node ${CHECKS_CLI} --base origin/<base>\` after your edits and run the scoped commands it prints instead of the full tsc/eslint/test scripts (it widens itself when impact is unknown: lockfile, tsconfig, CI config); once they pass, rerun it with --publish to share the incremental caches. Lint changed packages only, never dependents (turbo ...[base], nx affected). node_modules/.venv may be hardlinked read-only from a shared cache: after changing a lockfile, or when an install fails with EACCES, delete that dir first (rm -rf is safe), then install; never chmod or edit files inside it.
|
|
25
|
+
Quiet output (tool results are re-read every later turn): run tests quietly (-q or a dot/summary reporter); on failure read \`tail -40\` plus the failing test ids (grep FAILED / not ok / FAIL), never the whole log. Send output over 8 KB to a $TMPDIR file; tail or grep it. Issue independent read-only calls (reads, greps, git log/blame, gh) in the same turn or one shell call. Read each file's ranges once (sed -n a,bp or Read offset/limit, never a whole large file) and keep notes; don't re-read it.`;
|
|
26
|
+
|
|
27
|
+
/** #4450: the host-rules block of the builder prompts (toolchain, sparse and repo-facts lines included), reused by the light-lane prompt. */
|
|
28
|
+
export const hostRules = ({ toolchain = null, sparse = null, facts = null } = {}) => `${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}`;
|
|
20
29
|
|
|
21
30
|
/** Verify-by-CI (#4167): in ci/hybrid mode the engine runs the repo's CI on GitHub runners; the host runs only light checks. */
|
|
22
31
|
const LIGHT = "run only formatters and linters on the files you changed";
|
|
@@ -42,6 +51,60 @@ export function precheckFailurePrompt(failures, mode = "local") {
|
|
|
42
51
|
return `The repo's declared check commands failed in this worktree, before the PR is opened:\n\n${list}\n\nFix the cause in the code (or record a typed amendment with a why in ${RESULT_FILE}). Re-run each failing command, ${mode === "ci" ? `${LIGHT} otherwise, ` : ""}commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
|
|
43
52
|
}
|
|
44
53
|
|
|
54
|
+
/** #4406: the tree after merging the base fails the post-merge check commands (verify.post_merge or the scoped typecheck). */
|
|
55
|
+
export function postMergeFailurePrompt(failures, mode = "local") {
|
|
56
|
+
const list = failures.slice(0, 10).map((f) => `#### \`${f.command}\` (exit ${f.rc})\n${f.output ? `\`\`\`\n${f.output}\n\`\`\`` : "(no output)"}`).join("\n\n");
|
|
57
|
+
return `After merging the base into this branch, the merged tree fails these check commands (sibling changes merged without a text conflict, e.g. duplicate keys or same-named symbols):\n\n${list}\n\nFix the cause keeping both this fix's intent and the base's changes (rename rather than drop either side). ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** #4406: names this change newly exports that another module of the same app or package already declares. */
|
|
61
|
+
export function duplicateExportsPrompt(findings, mode = "local") {
|
|
62
|
+
const rows = findings.slice(0, 30).map((d) => `- ${d.symbol}: ${d.path} (also exported by ${(d.others ?? []).join(", ")})`).join("\n");
|
|
63
|
+
return `This change exports names that another module of the same app or package already exports:\n${rows}\n\nRename the new export or reuse the existing one, and update every reference and any registry, map or test keyed by the name (same-named entries collide or leave one unprobed). ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** #4390: byte cap of the diff hunks a context-ceiling restart carries (the stat is always kept). */
|
|
67
|
+
export const RESTART_DIFF_MAX_BYTES = 40 * 1024;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* #4390: appended to the restart prompt when the engine stopped a builder session at the context ceiling: the fresh
|
|
71
|
+
* session continues from the frozen contract, the branch (`git diff --stat` + bounded hunks) and the last validate result.
|
|
72
|
+
*/
|
|
73
|
+
export function contextRestartPrompt({ stat = "", diff = "", base = "", check = "", ceiling = 0 } = {}) {
|
|
74
|
+
const d = boundDiff(String(diff ?? ""), { stat, base, maxBytes: RESTART_DIFF_MAX_BYTES }).trim();
|
|
75
|
+
const c = String(check ?? "").trim();
|
|
76
|
+
return `\n\nContext restart: the previous session on this lane reached the ${ceiling}-token context ceiling and the engine stopped it. Its history is gone, its work is not: ${CONTRACT_FILE} is still the frozen authority, and the branch holds the committed work (uncommitted edits were saved in a \`wip(forge): checkpoint\` commit). Continue from this state; do not start over, and do not re-read what the summary below already shows.
|
|
77
|
+
|
|
78
|
+
<diff_stat base="${base}">
|
|
79
|
+
${String(stat ?? "").trim() || "(no changes yet)"}
|
|
80
|
+
</diff_stat>
|
|
81
|
+
|
|
82
|
+
<diff>
|
|
83
|
+
${d || "(no changes yet)"}
|
|
84
|
+
</diff>
|
|
85
|
+
|
|
86
|
+
Last validate result (${LEAN_DIR}/self-check.json, from \`forgedock check\`): ${c ? `\n${c.length > 4000 ? `${c.slice(0, 4000)}\n[truncated]` : c}` : "none recorded; run the validate commands."}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** #4392: the implement turn budget is spent; one bounded fresh session either finishes or stops with a typed digest. */
|
|
90
|
+
export function turnBudgetWrapupPrompt({ size = "?", budget = 0, used = 0, grant = 5, stat = "", diff = "", base = "", check = "" } = {}) {
|
|
91
|
+
const d = boundDiff(String(diff ?? ""), { stat, base, maxBytes: RESTART_DIFF_MAX_BYTES }).trim();
|
|
92
|
+
const c = String(check ?? "").trim();
|
|
93
|
+
return `\n\nTurn budget spent: this lane is size ${size} with an implement budget of ${budget} turns, and ${used} are used. The engine stopped the previous session and saved its work in a \`wip(forge): checkpoint\` commit. You have at most ${grant} turns. Pick one, do not grind:
|
|
94
|
+
- Finish: if the work is complete or nearly so, commit what works, run the validate commands, and write ${RESULT_FILE} with status "fixed" as usual.
|
|
95
|
+
- Stop: otherwise commit nothing new and write ${RESULT_FILE} as {"status":"budget-exceeded","summary":"<one line>","digest":"<what is done, what remains, why it is bigger than planned, and your recommendation: retry, split or re-scope>"}.
|
|
96
|
+
|
|
97
|
+
<diff_stat base="${base}">
|
|
98
|
+
${String(stat ?? "").trim() || "(no changes yet)"}
|
|
99
|
+
</diff_stat>
|
|
100
|
+
|
|
101
|
+
<diff>
|
|
102
|
+
${d || "(no changes yet)"}
|
|
103
|
+
</diff>
|
|
104
|
+
|
|
105
|
+
Last validate result (${LEAN_DIR}/self-check.json): ${c ? `\n${c.length > 4000 ? `${c.slice(0, 4000)}\n[truncated]` : c}` : "none recorded."}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
45
108
|
/** CI could not run (no workflow run, no checks, dispatch refused): the local fallback (#4167). */
|
|
46
109
|
export function ciUnavailablePrompt() {
|
|
47
110
|
return `CI verification is unavailable for this branch, so verification falls back to this host. Run every validate command locally (targeted, as the host rules say), fix any failure, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE} with each command's real exit code. Do not push or touch GitHub.`;
|
|
@@ -79,18 +142,28 @@ ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}
|
|
|
79
142
|
If the issue is already fixed or invalid, write ${RESULT_FILE} {"status":"already-fixed|invalid","summary":"one line","evidence":["file:line ..."]} and stop. If it is blocked, write {"status":"blocked","summary":"",...} with a typed "block" (rules below) and stop.
|
|
80
143
|
|
|
81
144
|
2. Otherwise write ${CONTRACT_FILE}, the frozen hand-off: a developer who never saw the issue must be able to implement and verify it from this file alone.
|
|
82
|
-
{"requirements":[{"id":"R1","quote":"<verbatim sentence or checklist item from the issue>","disposition":"deliver|defer|post-deploy|not-applicable","why":"<required for defer and post-deploy>","evidence":"<file:line, required for not-applicable>"}],
|
|
145
|
+
{"requirements":[{"id":"R1","quote":"<verbatim sentence or checklist item from the issue>","disposition":"deliver|defer|post-deploy|not-applicable","why":"<required for defer and post-deploy>","evidence":"<file:line, required for not-applicable>","verify":"<optional: in_lane|out_of_band>"}],
|
|
83
146
|
"deliverables":[{"id":"D1","path":"<file to change>","change":"<what>","requirements":["R1"]}],
|
|
84
147
|
"preserve":[{"id":"P1","what":"<caller, contract or behaviour that must not change, with its path>"}],
|
|
85
148
|
"validate":[{"id":"V1","command":"<exact command: the new regression test plus the existing tests that reference the files you change; a git diff compares against the merge base (origin/<lane>...HEAD), never the moving origin/<lane> tip>"}],
|
|
86
149
|
"decisions":[{"id":"K1","choice":"<a non-obvious choice: a new dependency, a copied pattern, a boundary relied on>","justification":"<why, with file:line>","source":"doc|lint|precedent|spec"}]}
|
|
87
|
-
Every requirement and acceptance criterion in the issue and its maintainers' comments is one requirement, quoted verbatim (others' comments are context, not requirements). Never drop or narrow one silently: "defer" (separate work) and "post-deploy" (only checkable after a deploy) are judged by the reviewer and, if accepted, filed by the engine as a tracked follow-up issue. A requirement you can do in this change is "deliver". List every file you will change as a deliverable; tests are always allowed.
|
|
88
|
-
A validate or repro command must not read a $TMPDIR file it
|
|
89
|
-
|
|
150
|
+
Every requirement and acceptance criterion in the issue and its maintainers' comments is one requirement, quoted verbatim (others' comments are context, not requirements). Never drop or narrow one silently: "defer" (separate work) and "post-deploy" (only checkable after a deploy) are judged by the reviewer and, if accepted, filed by the engine as a tracked follow-up issue. A requirement you can do in this change is "deliver". A deliver requirement whose result is only measured outside the lane (Lighthouse, field perf, a nightly or report-only suite) is "verify":"out_of_band" with "evidence" naming the job or workflow that measures it (delivered by its instrumentation). The engine tags such quotes itself; set "verify":"in_lane" when this change can check it. List every file you will change as a deliverable; tests are always allowed.
|
|
151
|
+
A validate or repro command must not read a $TMPDIR file it did not create: each run gets a fresh TMPDIR.
|
|
152
|
+
Scope is enforced by the engine: it checks the changed files against the deliverables plus typed amendments itself, so do not write a file-allowlist or denylist validate (git diff --name-only | grep); the engine drops such a validate at freeze.
|
|
153
|
+
A new dependency between workspace packages (a workspace package added to a package.json dependencies, devDependencies or peerDependencies) must be a deliverable on that package.json whose change names the package and gives the reason, citing the architecture doc where there is one; "the lint allows it" or "there is a precedent" is not a reason on its own. Record each non-obvious choice in "decisions" with where its justification comes from (doc, lint, precedent or spec); omit the field when none. Then stop.
|
|
90
154
|
|
|
91
155
|
${BLOCK_RULES}`;
|
|
92
156
|
}
|
|
93
157
|
|
|
158
|
+
/**
|
|
159
|
+
* #4374: the conformance rubric, one definition shared verbatim by `reviewerPrompt` and the builder's self-review in
|
|
160
|
+
* `builderImplementPrompt`, so the two cannot drift. Each question is answered yes (OK) or no; codebase-agnostic.
|
|
161
|
+
*/
|
|
162
|
+
export const REVIEW_RUBRIC = `1. Fixed: does the diff resolve the issue as written, delivering every requirement and acceptance criterion of the issue and every contract requirement with disposition deliver? An out-of-band requirement is delivered by its instrumentation (budget test or bench committed, job configured), not its measured result.
|
|
163
|
+
2. No regression: is the diff free of breakage of an existing caller, test or behaviour (a concrete path:line failure)?
|
|
164
|
+
3. Contract: is every deliverable changed as described, every preserve item intact and every validate command passing?
|
|
165
|
+
4. Scope: does the diff change only deliverable paths and tests?`;
|
|
166
|
+
|
|
94
167
|
/** Builder call 2, same session: implement the frozen contract. */
|
|
95
168
|
export function builderImplementPrompt(mode = "local", { toolchain = null, sparse = null, facts = null } = {}) {
|
|
96
169
|
const remote = mode === "ci" || mode === "hybrid";
|
|
@@ -103,8 +176,10 @@ The contract changes only through typed amendments in ${RESULT_FILE}, each with
|
|
|
103
176
|
- {"kind":"add-requirement","requirement":{"id":"R9","quote":"<verbatim from the issue>","disposition":"deliver|defer|post-deploy"},"why":""}: an issue requirement the contract missed.
|
|
104
177
|
- {"kind":"defer|post-deploy","requirement":"R#","why":""}: a contracted requirement that becomes separate work or a post-deploy check. The engine files it as a tracked follow-up.
|
|
105
178
|
Never report a deliver requirement as "deferred" or "follow-up" without an amendment.
|
|
106
|
-
A new dependency between workspace packages in a package.json that no deliverable names
|
|
107
|
-
If the repository has scripts/check-devdocs-freshness.sh,
|
|
179
|
+
A new dependency between workspace packages in a package.json that no deliverable names is blocked by the engine: add it through add-deliverable with a why naming the package, or do not add it.
|
|
180
|
+
If the repository has scripts/check-devdocs-freshness.sh, a behaviour change under the paths it checks must update the matching dossier (devdocs/modules/*.md, per devdocs/index.yaml) in the same change. Run it against the base before writing the result; the engine runs it too.
|
|
181
|
+
|
|
182
|
+
Before writing ${RESULT_FILE}, after your last edit, run \`node ${FORGEDOCK_CLI} check\` in the worktree and fix until it passes; if it cannot pass, report the problem as blocked.
|
|
108
183
|
|
|
109
184
|
When done and committed, write ${IMPLEMENTATION_FILE} (markdown, no HTML comments):
|
|
110
185
|
### Approach
|
|
@@ -113,7 +188,13 @@ When done and committed, write ${IMPLEMENTATION_FILE} (markdown, no HTML comment
|
|
|
113
188
|
### Verification (each validate command, its result, and whether the new test fails without the fix)
|
|
114
189
|
### Notes (behavior changes, env vars)
|
|
115
190
|
|
|
116
|
-
|
|
191
|
+
Last step before writing ${RESULT_FILE}: self-review against the reviewer's rubric. Answer each question below yes or no against \`git diff <base>...HEAD\` (base: the lane the contract was frozen against), with evidence for each (path:line, or a command and its result); fix any "no" in this session and commit before you answer again:
|
|
192
|
+
<rubric>
|
|
193
|
+
${REVIEW_RUBRIC}
|
|
194
|
+
</rubric>
|
|
195
|
+
|
|
196
|
+
Finally write ${RESULT_FILE}: {"status":"fixed","summary":"one line","requirements":[{"id":"R1","status":"implemented|partial|missing","evidence":"file:line","instrumented":"<true if out_of_band bench+job committed>"}],"validation":[{"id":"V1","command":"","exit":0}],"tests":[{"command":"","result":"pass|fail: ...","fails_without_fix":true|false|"unknown"}],"self_review":[{"question":"1","answer":"yes|no","evidence":""}],"amendments":[]}
|
|
197
|
+
Optional "facts":{"decisions":[],"rejected":[{"approach":"","why":""}],"hotspots":[{"at":"file:line","why":""}],"conventions":[]}; rejected: approaches you dropped, with why.
|
|
117
198
|
Docker: start containers with docker run --rm; pass --label for each entry of $FORGE_DOCKER_LABELS on every docker run, volume create, network create and build (compose: on services, volumes, networks); tear down via trap/finally.
|
|
118
199
|
If you started Docker containers or volumes that are still there, list their names in ${RESULT_FILE} as "resources":{"containers":[],"volumes":[]} so the engine removes them when the lane ends.
|
|
119
200
|
If you find you are blocked, write "status":"blocked" with a typed "block" instead.`;
|
|
@@ -167,7 +248,7 @@ export function missingEntriesPrompt(missing, mode = "local") {
|
|
|
167
248
|
return `These tests enumerate the siblings of symbols or files this change adds, but do not name them (CI will fail on the stale list):\n${missing.slice(0, 30).map((m) => `- ${m.symbol} (${m.module}) -> ${m.test}:${m.line}`).join("\n")}\n\nFor each, add the symbol to that test's list, ${NOT_MEMBER}. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
|
|
168
249
|
}
|
|
169
250
|
|
|
170
|
-
const RENUMBER_RULE = "Renumber the lane's files, never the base's: rename each with git mv to its new number, keep the SQL (or other migration source) byte-identical, regenerate the snapshot and journal with the repo's own migration generator (never hand- or text-merge the journal), keep journal entries strictly ordered, and update every reference to the old name.";
|
|
251
|
+
const RENUMBER_RULE = "Renumber the lane's files, never the base's: rename each with git mv to its new number, keep the SQL (or other migration source) byte-identical, change a numbered document (e.g. an ADR) only in its own number (its title), regenerate the snapshot and journal with the repo's own migration generator (never hand- or text-merge the journal), keep journal entries strictly ordered, and update every reference to the old name, including the series index (e.g. docs/adr/README.md) so it lists both the base's entry and yours.";
|
|
171
252
|
|
|
172
253
|
/** #4322: lane-added sequence-numbered files whose number the base already uses, at hand-off or after a base-sync. */
|
|
173
254
|
export function sequencePrompt(collisions, lane, mode = "local") {
|
|
@@ -177,7 +258,7 @@ export function sequencePrompt(collisions, lane, mode = "local") {
|
|
|
177
258
|
|
|
178
259
|
/** #4322: the base-sync conflict addition when a conflicted file is a numbered migration or a journal; "" otherwise. */
|
|
179
260
|
export function sequenceSyncNote(files) {
|
|
180
|
-
const hit = (files ?? []).some((f) =>
|
|
261
|
+
const hit = (files ?? []).some((f) => seqNumber(f) || /(?:^|\/)_journal\.json$/.test(String(f)));
|
|
181
262
|
return hit ? ` A numbered migration or its journal conflicts: do not text-merge it. ${RENUMBER_RULE}` : "";
|
|
182
263
|
}
|
|
183
264
|
|
|
@@ -203,12 +284,22 @@ export function docsPrompt(output, mode = "local") {
|
|
|
203
284
|
return `The repository's docs freshness check (scripts/check-devdocs-freshness.sh) fails on this change:\n${String(output ?? "").slice(0, 4000)}\n\nUpdate the module dossier it names (devdocs/modules/*.md; devdocs/index.yaml maps files to dossiers) to describe the behaviour you changed, in the dossier's existing style and in a few lines. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
|
|
204
285
|
}
|
|
205
286
|
|
|
287
|
+
/**
|
|
288
|
+
* #4363: a fix round in a fresh session (no transcript). It carries the site's task prompt (which holds the capped
|
|
289
|
+
* failure output), the lane diff, the frozen contract and the issue title and summary, each capped; under FIX_PROMPT_MAX.
|
|
290
|
+
*/
|
|
291
|
+
export function fixSessionPrompt({ task, diff = "", stat = "", base = "", contract = null, title = "", summary = "" }) {
|
|
292
|
+
const contractText = capText(typeof contract === "string" ? contract : JSON.stringify(contract ?? {}, null, 1), FIX_CAPS.contract);
|
|
293
|
+
const p = `${HOST_RULES}\n\nYou are fixing one failure on an existing branch in this worktree. You have no earlier session: the issue, the frozen contract and the branch's diff against ${base || "the lane base"} are below; read any file you need.\n\n## Issue: ${String(title).slice(0, 300)}\n${capText(summary, FIX_CAPS.summary)}\n\n## Frozen contract (${CONTRACT_FILE})\n\`\`\`json\n${contractText}\n\`\`\`\n\n## Diff (\`git diff ${base || "<base>"}...HEAD\`)\n\`\`\`diff\n${boundDiff(diff, { stat, base, maxBytes: FIX_CAPS.diff })}\n\`\`\`\n\n## The failure to fix\n${capText(task, FIX_CAPS.task)}`;
|
|
294
|
+
return capText(p, FIX_PROMPT_MAX - 100);
|
|
295
|
+
}
|
|
296
|
+
|
|
206
297
|
/** Human-only block reasons (#4124). Everything else agents resolve: a dependency, a split, prod data monitoring can read. */
|
|
207
298
|
export const HUMAN_BLOCK_REASONS = Object.freeze(["credentials", "spend", "promotion", "customer", "product-decision"]);
|
|
208
299
|
|
|
209
300
|
const BLOCK_RULES = `"blocked" needs a typed "block" in ${RESULT_FILE}, one of:
|
|
210
301
|
- {"kind":"dependency","issues":[N]}: an open upstream issue must land first. The engine requeues this issue behind it.
|
|
211
|
-
- {"kind":"split-required","subissues":[{"title":"","body":"","depends_on":[0]}]}: too big for one change. The engine files the sub-issues (depends_on = indexes of earlier sub-issues).
|
|
302
|
+
- {"kind":"split-required","subissues":[{"title":"","body":"","depends_on":[0],"files":["<path>"]}],"size":{"files":N,"lines":N}}: too big for one change. The engine files the sub-issues (depends_on = indexes of earlier sub-issues; files = the files or dirs each will edit, also accepted as "paths") only when every sub-issue's files are disjoint from the others' or the whole change's size estimate exceeds ${SPLIT_MIN_FILES} files or ${SPLIT_MIN_LINES} lines; otherwise keep one lane: an ordered checklist in the contract with a validate per item. In an admitted split, when siblings would edit the same hub (a barrel or index export list, a union type, a shared test fixture, an index doc), pre-stage its slots in the earliest sub-issue and make the others depend on it, or give each sibling its own per-feature file; the engine serialises sub-issues that still share a file.
|
|
212
303
|
- {"kind":"human","why":"${HUMAN_BLOCK_REASONS.join("|")}"}: only an external action agents cannot take, or a product decision too critical for agents.
|
|
213
304
|
"An upstream issue must land first", "needs a split" and "needs prod data that read-only monitoring can read" are never "human".`;
|
|
214
305
|
|
|
@@ -241,7 +332,7 @@ export const REVIEWER_SYSTEM = `You are an independent conformance reviewer of o
|
|
|
241
332
|
|
|
242
333
|
You receive the GitHub issue with its comments, the frozen contract (requirements quoted from the issue, deliverables, preserve constraints, validate commands) with typed amendments, the builder's result with validation output, the changed files, the diff, and a row skeleton with one row per contract id and changed file. The engine computes the verdict from your rows; a reply without rows is rejected, so fill the skeleton rather than composing rows: fill EVERY row (set each empty status, evidence and validated_by, and each null deferral_ok; keep the ids and files as given):
|
|
243
334
|
- issue_coverage: one row per requirement or acceptance criterion in the issue, quoted verbatim, saying whether the contract covers it, narrows it, or misses it. The skeleton lists the contract's quotes; add a row for any issue requirement the contract does not quote.
|
|
244
|
-
- requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with evidence as a backtick-quoted repo-relative path:line copied verbatim from the changed files, including (group), [slug] and [...slug] folders (e.g. \`apps/web/app/(console)/layout.tsx:12\`, not route.ts:12) and the validate id or test that proves it (validated_by). For a defer or post-deploy requirement add deferral_ok: true only if it is genuinely separate work or only checkable after a deploy, false if it belongs in this change.
|
|
335
|
+
- requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with evidence as a backtick-quoted repo-relative path:line copied verbatim from the changed files, including (group), [slug] and [...slug] folders (e.g. \`apps/web/app/(console)/layout.tsx:12\`, not route.ts:12) and the validate id or test that proves it (validated_by). For a defer or post-deploy requirement add deferral_ok: true only if it is genuinely separate work or only checkable after a deploy, false if it belongs in this change. For a verify out_of_band row (measured by the named job, not in this lane) set instrumented: true only when its budget test or bench is committed and that job is configured, with that path:line as evidence; false otherwise. Do not judge the measured result.
|
|
245
336
|
- scope: one row per changed file, pre-filled with the engine's authorization: the deliverable id that authorizes it (deliverables may be directories or globs), "test", "incidental" (changelog, lockfile), or "none" with what it changes. Change it only if the diff shows the file does something its deliverable does not cover.
|
|
246
337
|
- preserve: one row per preserve id: intact|violated|unverified, with path:line. The skeleton starts each at "unverified"; set intact only after checking it.
|
|
247
338
|
- regressions: concrete breakages only, each with path:line.
|
|
@@ -283,7 +374,7 @@ export function boundDiff(diff, { stat = "", base = "", maxBytes = REVIEW_DIFF_M
|
|
|
283
374
|
export function reviewerPrompt({ issue, comments = [], contract, result, files = [], diff, stat = "", base = "", structure = "", ignore = [`${LEAN_DIR}/`], skeleton = null }) {
|
|
284
375
|
const notes = comments.map((c) => String(c?.body ?? "")).filter((b) => !/^<!-- FORGE:/.test(b.trim()));
|
|
285
376
|
const sk = JSON.stringify(skeleton ?? reviewSkeleton(contract ?? {}, files, ignore), null, 1);
|
|
286
|
-
return `Issue #${issue.number}: ${issue.title}\n\n<issue_body>\n${cap(issue.body ?? "", 20000)}\n</issue_body>\n\n<issue_comments>\n${cap(notes.join("\n---\n"), 8000)}\n</issue_comments>\n\n<contract>\n${cap(JSON.stringify(contract ?? {}, null, 1), 12000)}\n</contract>\n\n<builder_result>\n${cap(JSON.stringify(result ?? {}, null, 1), 6000)}\n</builder_result>\n\n<changed_files>\n${files.join("\n")}\n</changed_files>\n\n<row_skeleton>\n${cap(sk, 30000)}\n</row_skeleton>\n\n<diff>\n${boundDiff(diff, { stat, base })}\n</diff>${structure ? `\n<structure>${String(structure).replace(/[\r\n]+/g, " ")}</structure>` : ""}`;
|
|
377
|
+
return `Issue #${issue.number}: ${issue.title}\n\n<issue_body>\n${cap(issue.body ?? "", 20000)}\n</issue_body>\n\n<issue_comments>\n${cap(notes.join("\n---\n"), 8000)}\n</issue_comments>\n\n<contract>\n${cap(JSON.stringify(contract ?? {}, null, 1), 12000)}\n</contract>\n\n<builder_result>\n${cap(JSON.stringify(result ?? {}, null, 1), 6000)}\n</builder_result>\n\n<changed_files>\n${files.join("\n")}\n</changed_files>\n\n<rubric>\n${REVIEW_RUBRIC}\n</rubric>\n\n<row_skeleton>\n${cap(sk, 30000)}\n</row_skeleton>\n\n<diff>\n${boundDiff(diff, { stat, base })}\n</diff>${structure ? `\n<structure>${String(structure).replace(/[\r\n]+/g, " ")}</structure>` : ""}`;
|
|
287
378
|
}
|
|
288
379
|
|
|
289
380
|
/**
|
package/bin/lean/render.mjs
CHANGED
|
@@ -125,7 +125,7 @@ const CONTRACT_BLOCK_RE = /<summary>contract\.json \(sha256 ([0-9a-f]{12})(?:, i
|
|
|
125
125
|
export function renderContract(c, hash, { compact = false, scope = null } = {}) {
|
|
126
126
|
const json = `<details><summary>contract.json (sha256 ${hash}${scope ? `, issue ${scope}` : ""})</summary>\n\n\`\`\`json\n${contractJson(c)}\n\`\`\`\n</details>`;
|
|
127
127
|
if (compact) return `## Builder Contract (frozen \`${hash}\`)\n\n${c.requirements.length} requirement(s), ${(c.deliverables ?? []).length} deliverable(s); the tables are omitted because the contract is large.\n\n${json}`;
|
|
128
|
-
const disp = (r) => `${r.disposition}${r.tracked ? ` → ${r.tracked}` : ["defer", "post-deploy"].includes(r.disposition) ? " (filed after review accepts it)" : ""}${r.why ? ` (${escl(r.why)})` : ""}`;
|
|
128
|
+
const disp = (r) => `${r.disposition}${r.verify === "out_of_band" ? `, verified out of band${r.evidence ? ` (${escl(r.evidence)})` : ""}` : ""}${r.tracked ? ` → ${r.tracked}` : ["defer", "post-deploy"].includes(r.disposition) ? " (filed after review accepts it)" : ""}${r.why ? ` (${escl(r.why)})` : ""}`;
|
|
129
129
|
const req = c.requirements.map((r) => `| ${esc(r.id)} | ${esc(r.quote)} | ${esc(disp(r))} |`).join("\n");
|
|
130
130
|
const del = (c.deliverables ?? []).map((d) => `| ${esc(d.id)} | \`${esc(d.path)}\` | ${esc(d.change)} | ${esc((d.requirements ?? []).join(", "))} |`).join("\n");
|
|
131
131
|
const dec = Array.isArray(c.decisions) && c.decisions.length // forge#4165: decision provenance
|
|
@@ -148,7 +148,8 @@ export function renderAmendments(applied, filed = {}) {
|
|
|
148
148
|
export function renderReview(rv, contract) {
|
|
149
149
|
const rows = contract.requirements.map((r) => {
|
|
150
150
|
const x = (rv.requirements ?? []).find((y) => y.id === r.id) ?? {};
|
|
151
|
-
|
|
151
|
+
const oob = r.verify === "out_of_band" ? ` (out of band${r.evidence ? `: ${r.evidence}` : ""})` : "";
|
|
152
|
+
return `| ${esc(r.id)} | ${oob ? esc(`${x.status ?? ""}${oob}`) : esc(x.status)}${r.tracked ? ` (tracked ${r.tracked})` : ""} | ${esc(x.evidence)} | ${esc(x.validated_by)} |`;
|
|
152
153
|
}).join("\n");
|
|
153
154
|
const cov = rv.issue_coverage ?? [];
|
|
154
155
|
return `## Conformance Review\n**Verdict:** CONFORMS (computed by the engine from the rows below)\n\n| Requirement | Status | Evidence | Validated by |\n| --- | --- | --- | --- |\n${rows}\n\n**Issue coverage:** ${cov.filter((c) => c.status === "covered").length}/${cov.length} issue requirement(s) covered by the contract\n**Scope:** ${(rv.scope ?? []).map((s) => `\`${escl(s.file)}\` ← ${escl(s.authorized_by)}`).join(", ") || NR}\n**Preserve:** ${(rv.preserve ?? []).map((p) => `${escl(p.id)} ${escl(p.status)}`).join(", ") || "none"}\n**Regressions:** none\n\n**Notes:** ${escl(rv.summary)}`;
|
|
@@ -171,7 +172,10 @@ export function renderPrBody({ issue, investigation, contract, implementation, r
|
|
|
171
172
|
: planFallback(result, gitFiles).split("### Deliverables\n")[1].split("\n\n### ")[0];
|
|
172
173
|
const verify = section(implementation, "Verification") || verification(result);
|
|
173
174
|
const remaining = tracked.length ? `\n\n## Remaining work tracked\n${tracked.map((t) => `- ${t}`).join("\n")}` : "";
|
|
174
|
-
|
|
175
|
+
// #4404: requirements measured outside the lane; the change delivers their instrumentation, the named job the result
|
|
176
|
+
const oob = (contract?.requirements ?? []).filter((r) => r?.verify === "out_of_band");
|
|
177
|
+
const outOfBand = oob.length ? `\n\n## Verified out of band\n${oob.map((r) => `- ${escl(r.id)}: ${escl(r.quote)} (job ${r.evidence ? `\`${escl(r.evidence).replace(/`/g, "'")}\`` : "not named"})`).join("\n")}` : "";
|
|
178
|
+
return `## Root cause\n${root}\n\n## Deliverables\n${deliver}\n\n## Verification\n${verify}${remaining}${outOfBand}\n\nCloses #${issue}`;
|
|
175
179
|
}
|
|
176
180
|
|
|
177
181
|
// ---- trajectory ----
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* #4400: the resume digest. A resumed or recovery lane reuses the branch, but the engine-posted records of the last run
|
|
5
|
+
* never reach the builder (they are excluded from the quotable issue text, #4297), so it re-derived state it had already
|
|
6
|
+
* posted. This compiles a bounded digest from structured records only: the branch's changed files, the newest trusted
|
|
7
|
+
* FORGE:FACTS baselines (validate command + exit), the RUN_START FORGE:STATE end reason/code/failure classes, the first
|
|
8
|
+
* line of the newest trusted FORGE:NEEDS_HUMAN or FORGE:FIX_CI, and the frozen contract's deliver requirements.
|
|
9
|
+
* Pure: no I/O, never throws. It goes into builder prompts only, never into the text contract quotes are checked against.
|
|
10
|
+
*/
|
|
11
|
+
import { noComments } from "./render.mjs";
|
|
12
|
+
import { parseFactsComment } from "./facts.mjs";
|
|
13
|
+
import { isTrustedIssueComment as trusted } from "./prefilter.mjs";
|
|
14
|
+
|
|
15
|
+
export const RESUME_DIGEST_BYTES = 2048;
|
|
16
|
+
const LINE_MAX = 200;
|
|
17
|
+
const LIST_MAX = 12;
|
|
18
|
+
const CODE_RE = /^[a-z][a-z0-9_.-]{0,63}$/;
|
|
19
|
+
|
|
20
|
+
/** One line, comments stripped and `<` escaped, so builder text can never open a comment or a FORGE marker. */
|
|
21
|
+
const one = (s, max = LINE_MAX) => {
|
|
22
|
+
const t = noComments(String(s ?? ""), " ").replace(/\s+/g, " ").trim().replace(/</g, "<");
|
|
23
|
+
return t.length > max ? `${t.slice(0, max - 1)}…` : t;
|
|
24
|
+
};
|
|
25
|
+
const code = (v) => (typeof v === "string" && CODE_RE.test(v) ? v : null);
|
|
26
|
+
const markerOf = (body) => /^\s*<!--\s*FORGE:([A-Z_]+)\s*-->/.exec(String(body ?? ""))?.[1] ?? null;
|
|
27
|
+
/** First line of a record that is not the marker or a heading. */
|
|
28
|
+
const recordLine = (body) => one(noComments(String(body ?? "")).split("\n").map((l) => l.trim()).find((l) => l && !/^#+\s/.test(l)) ?? "");
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* {text, files, validates, reason}: the digest text ("" when nothing is known) and its counts for the RESUME_DIGEST event. `state` is the FORGE:STATE read at RUN_START (before this run overwrites
|
|
32
|
+
* it), `comments` the issue comments (only trusted ones are read), `contract` the frozen contract or null, `files` the
|
|
33
|
+
* branch's changed files, `lane` the base branch. The text never exceeds `maxBytes` (UTF-8): whole lines are dropped from
|
|
34
|
+
* the end, so the end reason and validate results (first) survive longest.
|
|
35
|
+
*/
|
|
36
|
+
export function buildResumeDigest({ state = null, comments = [], contract = null, files = [], lane = "", maxBytes = RESUME_DIGEST_BYTES } = {}) {
|
|
37
|
+
try {
|
|
38
|
+
const tc = (Array.isArray(comments) ? comments : []).filter(trusted);
|
|
39
|
+
const facts = [...tc].reverse().map((c) => parseFactsComment(c?.body)).find(Boolean) ?? null;
|
|
40
|
+
const last = [...tc].reverse().find((c) => ["NEEDS_HUMAN", "FIX_CI"].includes(markerOf(c?.body)));
|
|
41
|
+
const validate = Array.isArray(contract?.validate) ? contract.validate : [];
|
|
42
|
+
const idOf = (cmd) => validate.find((v) => one(v?.command) === cmd)?.id ?? null;
|
|
43
|
+
|
|
44
|
+
const sections = [];
|
|
45
|
+
const reason = code(state?.terminalReason), tcode = code(state?.terminalCode);
|
|
46
|
+
const fails = Array.isArray(state?.failureClasses) ? [...new Set(state.failureClasses.map(code).filter(Boolean))].sort().slice(0, 5) : [];
|
|
47
|
+
const why = last ? `FORGE:${markerOf(last.body)}: ${recordLine(last.body)}` : "";
|
|
48
|
+
if (reason || why) sections.push(["How the last run ended:", ...(reason ? [`- ${reason}${tcode && tcode !== "unclassified" ? ` (${tcode})` : ""}${fails.length ? `; failures ${fails.join(", ")}` : ""}`] : []), ...(why ? [`- ${why}`] : [])]);
|
|
49
|
+
const base = facts?.baselines ?? [];
|
|
50
|
+
if (base.length) sections.push(["Validate results of the last run (command: exit):", ...base.map((b) => { const id = idOf(b.command); return `- ${id ? `${one(id, 20)} ` : ""}\`${b.command.replace(/`/g, "'")}\`: exit ${b.exit}${b.exit === 0 ? "" : " (failing)"}`; })]);
|
|
51
|
+
const reqs = (Array.isArray(contract?.requirements) ? contract.requirements : []).filter((r) => r?.disposition === "deliver");
|
|
52
|
+
if (reqs.length) sections.push(["Requirements to deliver (frozen contract); verify each against the branch and finish what is missing:", ...reqs.map((r) => `- ${one(r.id, 20)}: ${one(r.quote, 160)}`)]);
|
|
53
|
+
const changed = (Array.isArray(files) ? files : []).map((f) => one(f)).filter(Boolean);
|
|
54
|
+
if (changed.length) sections.push([`Files changed on the branch (vs ${one(lane) || "base"}):`, ...changed.slice(0, LIST_MAX).map((f) => `- ${f}`), ...(changed.length > LIST_MAX ? [`- … ${changed.length - LIST_MAX} more`] : [])]);
|
|
55
|
+
const none = { text: "", files: 0, validates: 0, reason: null };
|
|
56
|
+
if (!sections.length) return none;
|
|
57
|
+
|
|
58
|
+
const head = "\n\nResume digest of the last run on this branch (engine facts, not issue text):";
|
|
59
|
+
const tail = "Start from this state: do not re-derive it. Fix what failed, finish what is missing, then re-run the validate commands.";
|
|
60
|
+
const size = (lines) => Buffer.byteLength([head, ...lines, tail].join("\n"), "utf8");
|
|
61
|
+
const lines = sections.flatMap((s, i) => (i ? ["", ...s] : s));
|
|
62
|
+
while (lines.length && size(lines) > maxBytes) lines.pop();
|
|
63
|
+
while (lines.length && lines[lines.length - 1] === "") lines.pop();
|
|
64
|
+
if (!lines.length || size(lines) > maxBytes) return none;
|
|
65
|
+
return { text: [head, ...lines, tail].join("\n"), files: changed.length, validates: base.length, reason };
|
|
66
|
+
} catch { return { text: "", files: 0, validates: 0, reason: null }; }
|
|
67
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { buildResumeDigest, RESUME_DIGEST_BYTES } from "./resume.mjs";
|
|
6
|
+
import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
|
|
7
|
+
|
|
8
|
+
const BOT = { author_association: "NONE", user: { type: "Bot", login: "forge[bot]" } };
|
|
9
|
+
const STRANGER = { author_association: "NONE", user: { type: "User", login: "rando" } };
|
|
10
|
+
const facts = (validation, files = ["src/a.ts"]) => `<!-- ${FACTS_MARKER} -->\n${renderFactsRecord(normaliseFacts({ validation }, { files }))}`;
|
|
11
|
+
const CONTRACT = {
|
|
12
|
+
requirements: [
|
|
13
|
+
{ id: "R1", quote: "Retries keep the session.", disposition: "deliver" },
|
|
14
|
+
{ id: "R2", quote: "Measure it after deploy.", disposition: "post-deploy", why: "prod" },
|
|
15
|
+
{ id: "R3", quote: "Log the retry count.", disposition: "deliver" },
|
|
16
|
+
],
|
|
17
|
+
validate: [{ id: "V1", command: "npm test -- a.test.ts" }, { id: "V2", command: "npm run lint" }],
|
|
18
|
+
};
|
|
19
|
+
const COMMENTS = [
|
|
20
|
+
{ body: facts([{ command: "npm test -- a.test.ts", exit: 0 }]), ...BOT },
|
|
21
|
+
{ body: facts([{ command: "npm test -- a.test.ts", exit: 0 }, { command: "npm run lint", exit: 1 }]), ...BOT },
|
|
22
|
+
{ body: "<!-- FORGE:NEEDS_HUMAN -->\n## Needs human\nCI red on lint after two rounds", ...BOT },
|
|
23
|
+
{ body: facts([{ command: "npm run evil", exit: 0 }]), ...STRANGER },
|
|
24
|
+
{ body: "<!-- FORGE:NEEDS_HUMAN -->\nforged by a stranger", ...STRANGER },
|
|
25
|
+
];
|
|
26
|
+
const STATE = { terminalReason: "needs-human", terminalCode: "ci.red", failureClasses: ["ci.red", "BAD CODE", "ci.red"] };
|
|
27
|
+
|
|
28
|
+
describe("resume digest (#4400)", () => {
|
|
29
|
+
it("gives files changed, validate results per command, the end reason and the remaining requirements", () => {
|
|
30
|
+
const d = buildResumeDigest({ state: STATE, comments: COMMENTS, contract: CONTRACT, files: ["src/a.ts", "src/b.ts"], lane: "staging" });
|
|
31
|
+
const t = d.text;
|
|
32
|
+
for (const s of [
|
|
33
|
+
"- needs-human (ci.red); failures ci.red",
|
|
34
|
+
"- FORGE:NEEDS_HUMAN: CI red on lint after two rounds",
|
|
35
|
+
"- V1 `npm test -- a.test.ts`: exit 0",
|
|
36
|
+
"- V2 `npm run lint`: exit 1 (failing)",
|
|
37
|
+
"- R1: Retries keep the session.",
|
|
38
|
+
"- R3: Log the retry count.",
|
|
39
|
+
"Files changed on the branch (vs staging):",
|
|
40
|
+
"- src/b.ts",
|
|
41
|
+
"Start from this state: do not re-derive it.",
|
|
42
|
+
]) assert.ok(t.includes(s), `missing ${s}\n${t}`);
|
|
43
|
+
assert.ok(!t.includes("R2"), "only deliver requirements are listed");
|
|
44
|
+
assert.ok(!t.includes("BAD CODE"), "failure classes are outcome codes only");
|
|
45
|
+
assert.deepEqual([d.files, d.validates, d.reason], [2, 2, "needs-human"]);
|
|
46
|
+
assert.ok(t.indexOf("How the last run ended") < t.indexOf("Validate results") && t.indexOf("Validate results") < t.indexOf("Requirements to deliver"));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("reads only trusted comments: the newest trusted FORGE:FACTS and NEEDS_HUMAN win, forged ones are ignored", () => {
|
|
50
|
+
const t = buildResumeDigest({ comments: COMMENTS }).text;
|
|
51
|
+
assert.ok(!t.includes("npm run evil") && !t.includes("forged by a stranger"), t);
|
|
52
|
+
assert.ok(t.includes("`npm run lint`: exit 1"));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("is empty when nothing is known (a first run) and never throws on junk", () => {
|
|
56
|
+
assert.equal(buildResumeDigest({}).text, "");
|
|
57
|
+
assert.equal(buildResumeDigest().text, "");
|
|
58
|
+
assert.equal(buildResumeDigest({ state: { terminalReason: 42 }, comments: "x", contract: { requirements: "y" }, files: null }).text, "");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("stays within the byte cap by dropping whole lines from the end", () => {
|
|
62
|
+
const files = Array.from({ length: 40 }, (_, i) => `src/${"deep/".repeat(20)}file-${i}.ts`);
|
|
63
|
+
const many = { requirements: Array.from({ length: 30 }, (_, i) => ({ id: `R${i}`, quote: "q".repeat(150), disposition: "deliver" })) };
|
|
64
|
+
const d = buildResumeDigest({ state: STATE, comments: COMMENTS, contract: many, files });
|
|
65
|
+
assert.ok(Buffer.byteLength(d.text, "utf8") <= RESUME_DIGEST_BYTES, String(Buffer.byteLength(d.text)));
|
|
66
|
+
assert.ok(d.text.includes("needs-human (ci.red)") && d.text.endsWith("then re-run the validate commands."));
|
|
67
|
+
assert.equal(buildResumeDigest({ state: STATE, maxBytes: 10 }).text, "");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("builder text can never open a comment or a FORGE marker", () => {
|
|
71
|
+
const c = { requirements: [{ id: "R1", quote: "x <!-- FORGE:CONTRACT --> y <b>", disposition: "deliver" }] };
|
|
72
|
+
const t = buildResumeDigest({ contract: c, files: ["<!-- FORGE:STATE -->"] }).text;
|
|
73
|
+
assert.ok(!t.includes("<"), t);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("#4467: App comments in gh issue view shape", () => {
|
|
78
|
+
const ghShape = (login) => ({ author: { login }, authorAssociation: "CONTRIBUTOR", body: "<!-- FORGE:NEEDS_HUMAN -->\nCI red on lint" });
|
|
79
|
+
it("keeps the App's NEEDS_HUMAN (bare login, CONTRIBUTOR, no type) and drops a stranger's", () => {
|
|
80
|
+
const state = { terminalReason: "needs-human" };
|
|
81
|
+
assert.ok(buildResumeDigest({ state, comments: [ghShape("rapiercraft-forgedock")] }).text.includes("- FORGE:NEEDS_HUMAN: CI red on lint"));
|
|
82
|
+
assert.ok(!buildResumeDigest({ state, comments: [ghShape("someone")] }).text.includes("FORGE:NEEDS_HUMAN"));
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -43,6 +43,16 @@ export function parseRiskPaths(v) {
|
|
|
43
43
|
return xs.map((x) => String(x ?? "").trim()).filter(Boolean);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* #4398: the changed files matching a declared forge.yaml review.risk_paths glob (sorted, unique). Only declared globs
|
|
48
|
+
* count, never the built-in risk words: a hit makes the conformance gate enforce for that lane. [] without globs.
|
|
49
|
+
*/
|
|
50
|
+
export function riskPathHits(files = [], riskPaths = []) {
|
|
51
|
+
const globs = parseRiskPaths(riskPaths);
|
|
52
|
+
if (!globs.length) return [];
|
|
53
|
+
return [...new Set((files ?? []).map(String))].filter((f) => globs.some((g) => matchesGlob(f, g))).sort();
|
|
54
|
+
}
|
|
55
|
+
|
|
46
56
|
/**
|
|
47
57
|
* {would_skip, reasons:[{code, detail}]} from facts only. Codes: risk_path, package_edge, hotspot, gate.failed,
|
|
48
58
|
* gate.ambiguous. `hotspots` null/undefined means the facts were unavailable (ambiguous); {enabled:false} is an answer.
|
|
@@ -69,6 +79,8 @@ export function computeTrigger({ files = [], riskPaths = [], structure = null, h
|
|
|
69
79
|
if (gates.ciFellBack) add("gate.failed", "CI verification unavailable (local fallback)");
|
|
70
80
|
const tracked = (contract?.requirements ?? []).filter((r) => r?.disposition === "defer" || r?.disposition === "post-deploy").map((r) => r.id);
|
|
71
81
|
if (tracked.length) add("gate.ambiguous", `contract defers ${tracked.sort().join(", ")} (only the reviewer judges deferrals)`);
|
|
82
|
+
const oob = (contract?.requirements ?? []).filter((r) => r?.verify === "out_of_band").map((r) => r.id);
|
|
83
|
+
if (oob.length) add("gate.ambiguous", `contract verifies ${oob.sort().join(", ")} out of band (only the reviewer judges instrumentation)`);
|
|
72
84
|
reasons.sort((a, b) => a.code.localeCompare(b.code) || a.detail.localeCompare(b.detail));
|
|
73
85
|
return { would_skip: reasons.length === 0, reasons };
|
|
74
86
|
}
|
|
@@ -7,12 +7,21 @@ import { join } from "node:path";
|
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
10
|
-
import { computeTrigger, parseRiskPaths, riskClassOf, triggerReport, readRunLogs, formatReport } from "./review-trigger.mjs";
|
|
10
|
+
import { computeTrigger, riskPathHits, parseRiskPaths, riskClassOf, triggerReport, readRunLogs, formatReport } from "./review-trigger.mjs";
|
|
11
11
|
|
|
12
12
|
const HOT = { enabled: true, file_lines: 2000, growth_lines: 150, files: [] };
|
|
13
13
|
const clean = (patch = {}) => computeTrigger({ files: ["src/foo.js", "test/foo.test.js"], hotspots: HOT, ...patch });
|
|
14
14
|
const codes = (t) => t.reasons.map((r) => r.code);
|
|
15
15
|
|
|
16
|
+
describe("riskPathHits (#4398)", () => {
|
|
17
|
+
it("only declared review.risk_paths globs count, sorted and unique; built-in risk words never do", () => {
|
|
18
|
+
assert.deepEqual(riskPathHits(["src/auth/login.ts", "src/billing/b.ts", "src/billing/a.ts", "src/billing/a.ts"], ["src/billing/**"]), ["src/billing/a.ts", "src/billing/b.ts"]);
|
|
19
|
+
assert.deepEqual(riskPathHits(["src/auth/login.ts"], []), [], "no declared globs: nothing enforces");
|
|
20
|
+
assert.deepEqual(riskPathHits(["db/x.sql", "lib/a.js"], "db/**, *.sql"), ["db/x.sql"]);
|
|
21
|
+
assert.deepEqual(riskPathHits(undefined, ["**"]), []);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
16
25
|
describe("computeTrigger (#4273)", () => {
|
|
17
26
|
it("a clean diff with passing gates would skip, with no reasons", () => {
|
|
18
27
|
assert.deepEqual(clean(), { would_skip: true, reasons: [] });
|
|
@@ -61,6 +70,13 @@ describe("computeTrigger (#4273)", () => {
|
|
|
61
70
|
assert.deepEqual(a, b);
|
|
62
71
|
assert.deepEqual(codes(a), ["gate.ambiguous", "gate.failed", "risk_path", "risk_path"]);
|
|
63
72
|
});
|
|
73
|
+
it("#4404 an out-of-band requirement forces the reviewer (only it judges instrumentation)", () => {
|
|
74
|
+
const t = clean({ contract: { requirements: [{ id: "R2", disposition: "deliver", verify: "out_of_band" }, { id: "R1", disposition: "deliver" }] } });
|
|
75
|
+
assert.equal(t.would_skip, false);
|
|
76
|
+
assert.deepEqual(t.reasons, [{ code: "gate.ambiguous", detail: "contract verifies R2 out of band (only the reviewer judges instrumentation)" }]);
|
|
77
|
+
assert.equal(clean({ contract: { requirements: [{ id: "R1", disposition: "deliver", verify: "in_lane" }] } }).would_skip, true);
|
|
78
|
+
});
|
|
79
|
+
|
|
64
80
|
it("parseRiskPaths takes a comma string or a list", () => {
|
|
65
81
|
assert.deepEqual(parseRiskPaths(" a/** , ,*.sql"), ["a/**", "*.sql"]);
|
|
66
82
|
assert.deepEqual(parseRiskPaths(["x", " ", "y "]), ["x", "y"]);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Builder model routing (#4394). The build runs on a mid-tier model when the frozen contract is small or touches only
|
|
5
|
+
* docs, config and tests, and on the top model (the configured builder model, else the CLI default) when it is large or
|
|
6
|
+
* hits a risk path (the built-in risk words or forge.yaml review.risk_paths). The route is computed once from the
|
|
7
|
+
* contract's deliverable paths; a failed validate round on the mid tier escalates the lane to the top model.
|
|
8
|
+
* Pure: no I/O, no model call. Repo-agnostic: paths are classified by extension and directory names only.
|
|
9
|
+
*/
|
|
10
|
+
import { riskClassOf, riskPathHits } from "./review-trigger.mjs";
|
|
11
|
+
|
|
12
|
+
export const ROUTING_MODES = Object.freeze(["on", "off"]);
|
|
13
|
+
export const DEFAULT_MID_MODEL = "claude-sonnet-5-5";
|
|
14
|
+
/** Deliverables of at most this many non-test files make a small change. */
|
|
15
|
+
export const SMALL_MAX_FILES = 2;
|
|
16
|
+
/** Fix-round triggers that are validate rounds: a failure on the mid tier escalates the lane to the top model. */
|
|
17
|
+
export const ESCALATE_TRIGGERS = Object.freeze(["precheck", "ci_red", "merge_ci"]);
|
|
18
|
+
|
|
19
|
+
const MODEL_RE = /^[A-Za-z0-9._:[\]-]+$/;
|
|
20
|
+
|
|
21
|
+
/** {mode, midModel}: an unknown mode is on, an invalid model id falls back to the default mid model. */
|
|
22
|
+
export function parseBuilderRouting({ mode, midModel } = {}) {
|
|
23
|
+
const m = String(mode ?? "").trim().toLowerCase();
|
|
24
|
+
const id = String(midModel ?? "").trim();
|
|
25
|
+
return { mode: ROUTING_MODES.includes(m) ? m : "on", midModel: MODEL_RE.test(id) ? id : DEFAULT_MID_MODEL };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const LOCKFILE_RE = /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|Cargo\.lock|poetry\.lock|uv\.lock|Pipfile\.lock|Gemfile\.lock|composer\.lock|go\.sum|[^/]*\.lock)$/i;
|
|
29
|
+
const TEST_FILE_RE = /(\.(test|spec)\.[A-Za-z0-9]+$)|(_test\.(go|py|rb|exs?)$)|((^|\/)test_[^/]+\.py$)|(_spec\.rb$)|(Tests?\.(java|kt|cs|swift)$)/;
|
|
30
|
+
const TEST_DIR_RE = /(^|\/)(tests?|__tests__|spec|specs|fixtures|__snapshots__)\//i;
|
|
31
|
+
const DOCS_EXT_RE = /\.(md|mdx|markdown|rst|txt|adoc)$/i;
|
|
32
|
+
const DOCS_DIR_RE = /(^|\/)(docs?|devdocs)\//i;
|
|
33
|
+
const CONFIG_EXT_RE = /\.(json|jsonc|ya?ml|toml|ini|cfg|conf|properties|editorconfig)$/i;
|
|
34
|
+
const DOTFILE_RE = /(^|\/)\.[^/]+$/;
|
|
35
|
+
const ENV_EXAMPLE_RE = /(^|\/)\.env\.(example|sample|template)$/i;
|
|
36
|
+
|
|
37
|
+
/** 'test' | 'docs' | 'config' | 'code' for one repo-relative path. Lockfiles are code (they change what runs). */
|
|
38
|
+
export function classifyPath(p) {
|
|
39
|
+
const s = String(p ?? "").trim().replace(/^\.\//, "");
|
|
40
|
+
if (LOCKFILE_RE.test(s)) return "code";
|
|
41
|
+
if (TEST_FILE_RE.test(s) || TEST_DIR_RE.test(s)) return "test";
|
|
42
|
+
if (DOCS_EXT_RE.test(s) || DOCS_DIR_RE.test(s)) return "docs";
|
|
43
|
+
if (ENV_EXAMPLE_RE.test(s) || CONFIG_EXT_RE.test(s) || DOTFILE_RE.test(s)) return "config";
|
|
44
|
+
return "code";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Deliverable paths of a frozen contract (trimmed, unique, in order). */
|
|
48
|
+
export function deliverablePaths(contract) {
|
|
49
|
+
const ds = Array.isArray(contract?.deliverables) ? contract.deliverables : [];
|
|
50
|
+
return [...new Set(ds.map((d) => String(d?.path ?? "").trim()).filter(Boolean))];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The route of one lane: {class, tier, model, risk}. class: risk | docs-config-test | small | large; tier: mid | top.
|
|
55
|
+
* `topModel` null means the CLI default. Mode off keeps every lane on the top model but still reports its class.
|
|
56
|
+
*/
|
|
57
|
+
export function routeBuilder({ contract = null, riskPaths = [], cfg = {}, topModel = null } = {}) {
|
|
58
|
+
const c = parseBuilderRouting(cfg);
|
|
59
|
+
const paths = deliverablePaths(contract);
|
|
60
|
+
const declared = new Set(riskPathHits(paths, riskPaths));
|
|
61
|
+
const risk = paths.filter((p) => riskClassOf(p) || declared.has(p));
|
|
62
|
+
const kinds = paths.map(classifyPath);
|
|
63
|
+
const cls = !paths.length ? "large"
|
|
64
|
+
: risk.length ? "risk"
|
|
65
|
+
: kinds.every((k) => k !== "code") ? "docs-config-test"
|
|
66
|
+
: kinds.filter((k) => k !== "test").length <= SMALL_MAX_FILES ? "small" : "large";
|
|
67
|
+
const tier = c.mode === "on" && (cls === "docs-config-test" || cls === "small") ? "mid" : "top";
|
|
68
|
+
return { mode: c.mode, class: cls, tier, model: tier === "mid" ? c.midModel : topModel, risk };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True when a failed `trigger` round on the mid tier escalates the lane (once). */
|
|
72
|
+
export function shouldEscalate(route, escalated, trigger) {
|
|
73
|
+
return route?.tier === "mid" && !escalated && ESCALATE_TRIGGERS.includes(trigger);
|
|
74
|
+
}
|