forgedock 1.29.1 → 1.31.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 +5 -2
- package/bin/engine/phases.mjs +20 -3
- 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 +453 -66
- package/bin/engine.mjs +50 -38
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- 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 +356 -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 +278 -22
- package/bin/lean/conformance.test.mjs +251 -5
- package/bin/lean/context.mjs +133 -55
- package/bin/lean/context.test.mjs +175 -9
- 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 +55 -19
- package/bin/lean/enumerating.mjs +189 -0
- package/bin/lean/enumerating.test.mjs +97 -0
- 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 +799 -127
- package/bin/lean/lean.test.mjs +1749 -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/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +341 -99
- 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 +180 -0
- package/bin/lean/precheck.test.mjs +205 -0
- package/bin/lean/prefilter.mjs +180 -9
- package/bin/lean/prefilter.test.mjs +175 -3
- package/bin/lean/preflight.mjs +238 -0
- package/bin/lean/preflight.test.mjs +108 -0
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +134 -12
- package/bin/lean/recovery.test.mjs +75 -0
- 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.test.mjs +127 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +149 -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 +385 -0
- package/bin/lean/sequence.test.mjs +373 -0
- package/bin/lean/testmap.mjs +43 -6
- package/bin/lean/testmap.test.mjs +38 -1
- 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 +77 -10
- package/bin/lean/verify.test.mjs +74 -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 +319 -0
- package/bin/stats/efficiency.test.mjs +233 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +69 -6
- package/bin/stats/local-source.mjs +86 -3
- package/bin/stats/query.mjs +48 -0
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +224 -5
- package/commands/orchestrate.md +6 -2
- 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 +88 -6
- package/docs/OUTCOME-CODES.md +7 -4
- package/docs/RELEASE-NOTES.md +10 -0
- package/docs/STATS.md +53 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/acceptance-red-green.sh +13 -4
- package/scripts/acceptance-red-green.test.sh +14 -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/test-map.sh +63 -17
- package/scripts/test-map.test.sh +55 -0
- package/scripts/trusted-comments.test.sh +1 -1
package/bin/lean/context.mjs
CHANGED
|
@@ -10,22 +10,28 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { createHash } from "node:crypto";
|
|
12
12
|
import { join } from "node:path";
|
|
13
|
-
import {
|
|
13
|
+
import { parseDependsOn } from "../engine/phases.mjs";
|
|
14
14
|
import { codeForTerminalReason } from "../engine/outcome-codes.mjs";
|
|
15
|
-
import {
|
|
15
|
+
import { runLogKey } from "../engine/runlog.mjs";
|
|
16
|
+
import { namedPaths, isTrustedIssueComment as trusted } from "./prefilter.mjs";
|
|
16
17
|
import { parseContractComment, noComments } from "./render.mjs";
|
|
17
18
|
import { REPO_FACTS_FILE, TEST_MAP_FILE } from "./testmap.mjs";
|
|
19
|
+
import { parseFactsComment, factsLines, FACTS_RANK } from "./facts.mjs";
|
|
18
20
|
|
|
19
21
|
// noComments lives in render.mjs (#4298: one comment-strip helper for context, conformance and prefilter).
|
|
20
22
|
export { noComments };
|
|
21
23
|
|
|
22
24
|
export const CONTEXT_MODES = Object.freeze(["shadow", "on", "off"]);
|
|
23
|
-
export const CONTEXT_SOURCES = Object.freeze(["graph", "attempts", "ledger", "facts"]);
|
|
25
|
+
export const CONTEXT_SOURCES = Object.freeze(["graph", "attempts", "pitfalls", "ledger", "facts"]);
|
|
24
26
|
export const DEFAULT_CONTEXT_BYTES = 6144;
|
|
25
27
|
export const DEFAULT_LEDGER_DAYS = 30;
|
|
28
|
+
/** #4375: a pitfall not seen in this many lanes of the repo ages out (forge.yaml engine.context_pack_pitfall_lanes). */
|
|
29
|
+
export const DEFAULT_PITFALL_LANES = 50;
|
|
26
30
|
/** Bounds on GitHub reads per pack: linked issues read, ledger PRs inspected. */
|
|
27
31
|
export const MAX_LINKS = 20;
|
|
28
32
|
export const MAX_LEDGER_PRS = 8;
|
|
33
|
+
/** #4401: FORGE:FACTS records (earlier lanes) read into one pack: this issue's own attempts first, then the file-path ledger. */
|
|
34
|
+
export const MAX_FACT_LANES = 6;
|
|
29
35
|
export const REPO_FACT_FILES = Object.freeze([REPO_FACTS_FILE, TEST_MAP_FILE]);
|
|
30
36
|
const ATTEMPT_MARKERS = ["NEEDS_HUMAN", "BASE_SYNC", "FIX_CI"];
|
|
31
37
|
const LINE_MAX = 200;
|
|
@@ -38,13 +44,13 @@ const oneLine = (s, max = LINE_MAX) => {
|
|
|
38
44
|
const stripComments = (s) => noComments(s).replace(/\r\n/g, "\n").trim();
|
|
39
45
|
const byNum = (a, b) => a - b;
|
|
40
46
|
|
|
41
|
-
/** forge.yaml engine.context_pack (+ _sources, _bytes, _days) normalised; invalid values fall back to the defaults. */
|
|
42
|
-
export function parseContextPackConfig({ mode, sources, bytes, days } = {}) {
|
|
47
|
+
/** forge.yaml engine.context_pack (+ _sources, _bytes, _days, _pitfall_lanes) normalised; invalid values fall back to the defaults. */
|
|
48
|
+
export function parseContextPackConfig({ mode, sources, bytes, days, pitfallLanes } = {}) {
|
|
43
49
|
const m = String(mode ?? "").trim().toLowerCase();
|
|
44
50
|
const list = Array.isArray(sources) ? sources : typeof sources === "string" && sources.trim() ? sources.split(/[\s,]+/) : null;
|
|
45
51
|
const picked = list ? CONTEXT_SOURCES.filter((s) => list.map((x) => String(x).trim().toLowerCase()).includes(s)) : [...CONTEXT_SOURCES];
|
|
46
52
|
const pos = (v, d) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? Math.floor(n) : d; };
|
|
47
|
-
return { mode: CONTEXT_MODES.includes(m) ? m : "shadow", sources: picked, budgetBytes: pos(bytes, DEFAULT_CONTEXT_BYTES), days: pos(days, DEFAULT_LEDGER_DAYS) };
|
|
53
|
+
return { mode: CONTEXT_MODES.includes(m) ? m : "shadow", sources: picked, budgetBytes: pos(bytes, DEFAULT_CONTEXT_BYTES), days: pos(days, DEFAULT_LEDGER_DAYS), pitfallLanes: pos(pitfallLanes, DEFAULT_PITFALL_LANES) };
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
/** `**Parent**: #N` (decomposition sub-issue body line); null when absent. */
|
|
@@ -102,34 +108,40 @@ export function priorAttempts(body, comments = []) {
|
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
/**
|
|
105
|
-
* Cache key for a compiled pack: issue + base head SHA, plus the config that shapes it and (#4298, #4304) the hash of the
|
|
111
|
+
* Cache key for a compiled pack: issue (with `repo`, #4432: repo-scoped) + base head SHA, plus the config that shapes it and (#4298, #4304) the hash of the
|
|
106
112
|
* issue's human scope text (`scope`) and prior-attempt count, so an edit or a new attempt on the same base compiles a
|
|
107
113
|
* fresh pack. Not `updatedAt`: the lane's own FORGE:STATE/CONTEXT posts and label edits bump it, so the cache never hit.
|
|
114
|
+
* #4375: `stamp.pitfalls` (the selected pitfall signatures) joins the key, so a newly learned pitfall is never served stale.
|
|
108
115
|
*/
|
|
109
|
-
export const contextPackCacheKey = (issue, headSha, cfg = {}, stamp = {}) =>
|
|
110
|
-
`context-${issue}-${String(headSha ?? "").trim().slice(0, 40) || "nohead"}-${hash12(JSON.stringify([cfg.sources ?? [], cfg.budgetBytes ?? null, cfg.days ?? null,
|
|
116
|
+
export const contextPackCacheKey = (issue, headSha, cfg = {}, stamp = {}, repo = null) =>
|
|
117
|
+
`context-${runLogKey(issue, repo)}-${String(headSha ?? "").trim().slice(0, 40) || "nohead"}-${hash12(JSON.stringify([cfg.sources ?? [], cfg.budgetBytes ?? null, cfg.days ?? null,
|
|
111
118
|
...(stamp?.scope != null || stamp?.attempts != null ? [String(stamp.scope ?? ""), Number(stamp.attempts) || 0] : []),
|
|
112
|
-
...(stamp?.links ? [`links:${stamp.links}`] : [])]))}.json`;
|
|
119
|
+
...(stamp?.links ? [`links:${stamp.links}`] : []), ...(stamp?.pitfalls?.length ? [`pitfalls:${stamp.pitfalls.join(",")}`] : [])]))}.json`;
|
|
113
120
|
|
|
114
|
-
/** Max referenced issues whose state is read for the cache stamp (one
|
|
115
|
-
export const LINK_STAMP_MAX =
|
|
121
|
+
/** Max referenced issues whose state is read for the cache stamp (#4318: one GraphQL call for all of them). */
|
|
122
|
+
export const LINK_STAMP_MAX = 100;
|
|
116
123
|
/**
|
|
117
124
|
* #4316: the linked-issue part of the cache stamp: a hash of `n:STATE` for each issue the body references (parent, Depends
|
|
118
|
-
* on, `#N` links), so a pack never shows a linked issue as open after it closed on the same base.
|
|
119
|
-
*
|
|
125
|
+
* on, `#N` links), so a pack never shows a linked issue as open after it closed on the same base. #4318: one GraphQL call
|
|
126
|
+
* reads every state plus the issue's own cross-reference/connected timeline count (a new relation changes the stamp too);
|
|
127
|
+
* above LINK_STAMP_MAX the highest (most recent) numbers are kept. A failed call counts every number as `n:?`; never
|
|
128
|
+
* throws; '' (and no call) when the body references nothing.
|
|
120
129
|
*/
|
|
121
130
|
export async function linkStateStamp({ gh, repo, issue, body }) {
|
|
122
131
|
const parent = parentOf(body);
|
|
123
132
|
const nums = [...new Set([...(parent ? [parent] : []), ...parseDependsOn(stripComments(body), issue), ...bodyLinks(body, repo, issue)])]
|
|
124
|
-
.filter((n) => Number.isSafeInteger(n) && n > 0 && n !== Number(issue)).sort(byNum).slice(
|
|
133
|
+
.filter((n) => Number.isSafeInteger(n) && n > 0 && n !== Number(issue)).sort(byNum).slice(-LINK_STAMP_MAX);
|
|
125
134
|
if (!nums.length) return "";
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
135
|
+
const [owner, name] = String(repo ?? "").split("/");
|
|
136
|
+
const self = Number(issue);
|
|
137
|
+
const q = `query($o:String!,$n:String!){repository(owner:$o,name:$n){${Number.isSafeInteger(self) && self > 0
|
|
138
|
+
? `self:issueOrPullRequest(number:${self}){... on Issue{timelineItems(itemTypes:[CROSS_REFERENCED_EVENT,CONNECTED_EVENT,DISCONNECTED_EVENT]){totalCount}}}` : ""}${
|
|
139
|
+
nums.map((n) => `i${n}:issueOrPullRequest(number:${n}){... on Issue{state} ... on PullRequest{state}}`).join(" ")}}}`;
|
|
140
|
+
let r = null;
|
|
141
|
+
try { r = JSON.parse(String(await gh(["api", "graphql", "-f", `query=${q}`, "-f", `o=${owner ?? ""}`, "-f", `n=${name ?? ""}`])))?.data?.repository ?? null; } catch { r = null; }
|
|
142
|
+
const states = nums.map((n) => `${n}:${String(r?.[`i${n}`]?.state ?? "").toUpperCase() || "?"}`);
|
|
143
|
+
const rel = r?.self?.timelineItems?.totalCount;
|
|
144
|
+
return hash12([...states, `rel:${Number.isInteger(rel) ? rel : "?"}`].join(","));
|
|
133
145
|
}
|
|
134
146
|
|
|
135
147
|
// ---- gather (I/O) ----
|
|
@@ -151,10 +163,6 @@ const outcomeOf = (st) => {
|
|
|
151
163
|
return codeOf(c) && c !== "unclassified" ? c : null;
|
|
152
164
|
};
|
|
153
165
|
const failuresOf = (st) => (Array.isArray(st?.failureClasses) ? [...new Set(st.failureClasses.map(codeOf).filter(Boolean))].sort().slice(0, 5) : []);
|
|
154
|
-
const trusted = (c) => {
|
|
155
|
-
const login = String(c?.author?.login ?? c?.user?.login ?? "");
|
|
156
|
-
return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
|
|
157
|
-
};
|
|
158
166
|
/** First line of a FORGE record that is not a marker or a heading. */
|
|
159
167
|
const recordLine = (body) => oneLine(noComments(body).split("\n").map((l) => l.trim()).find((l) => l && !/^#+\s/.test(l)) ?? "");
|
|
160
168
|
const markerOf = (body) => /^\s*<!--\s*FORGE:([A-Z_]+)\s*-->/.exec(String(body ?? ""))?.[1] ?? null;
|
|
@@ -169,10 +177,11 @@ async function readComments(gh, repo, n) {
|
|
|
169
177
|
|
|
170
178
|
/**
|
|
171
179
|
* Collect the facts for one issue's pack. `io`: {gh, git, exists}; git runs in `cwd` (the lane worktree), base is the
|
|
172
|
-
* base ref
|
|
180
|
+
* base ref; `checkout` (the main checkout, optional) is where repo-fact files absent from the worktree are looked up.
|
|
181
|
+
* Every source is independent and best effort: a failing source is listed in `errors` and contributes nothing.
|
|
173
182
|
*/
|
|
174
|
-
export async function gatherContextFacts({ io, repo, issue, iss, comments = [], cwd, base, sources = CONTEXT_SOURCES, days = DEFAULT_LEDGER_DAYS, now = Date.now() }) {
|
|
175
|
-
const facts = { issue: Number(issue), links: [], attempts: [], ledger: [], facts: [], errors: [] };
|
|
183
|
+
export async function gatherContextFacts({ io, repo, issue, iss, comments = [], cwd, checkout = null, base, sources = CONTEXT_SOURCES, days = DEFAULT_LEDGER_DAYS, now = Date.now(), pitfalls = [] }) {
|
|
184
|
+
const facts = { issue: Number(issue), links: [], attempts: [], pitfalls: [], ledger: [], facts: [], lanes: [], errors: [] };
|
|
176
185
|
const body = String(iss?.body ?? "");
|
|
177
186
|
const on = new Set(sources);
|
|
178
187
|
const step = async (name, fn) => { if (!on.has(name)) return; try { await fn(); } catch (e) { facts.errors.push(`${name}: ${oneLine(e?.message ?? e, 120)}`); } };
|
|
@@ -225,30 +234,54 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
|
|
|
225
234
|
for (const l of refs) { const [ref, sha] = l.split(" "); facts.attempts.push({ kind: "salvage", ref, line: `salvaged uncommitted work at ${sha ?? "?"} (git diff HEAD ${ref} --stat)` }); }
|
|
226
235
|
});
|
|
227
236
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
237
|
+
// #4375: already read and selected by the engine from the local ledger (pitfalls.mjs selectPitfalls); no I/O here
|
|
238
|
+
await step("pitfalls", async () => {
|
|
239
|
+
for (const p of Array.isArray(pitfalls) ? pitfalls : []) {
|
|
240
|
+
if (p && typeof p.sig === "string") facts.pitfalls.push({ sig: p.sig, trigger: String(p.trigger ?? ""), lesson: oneLine(p.lesson ?? "", 160), globs: (p.globs ?? []).slice(0, 5), count: Number(p.count) || 1, match: p.match === "files" ? "files" : "repo" });
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// The file-path ledger walk (merged PRs on the issue's named paths) and each PR's trusted records, shared by the ledger
|
|
245
|
+
// and facts sources (#4401): run once per gather, and only when a source that needs them is on.
|
|
246
|
+
const paths = namedPaths(body);
|
|
247
|
+
let walked = null;
|
|
248
|
+
const walk = () => (walked ??= (async () => {
|
|
249
|
+
if (!paths.length || !base) return [];
|
|
232
250
|
const since = new Date(now - days * 86400000).toISOString().slice(0, 10);
|
|
233
251
|
const out = String(await git(["log", "--first-parent", `--since=${since}`, "--format=%x1e%H%x1f%s%x1f%b%x1f", "--name-only", base, "--", ...paths]));
|
|
234
|
-
|
|
252
|
+
return out.split("\x1e").filter((x) => x.trim()).map((rec) => {
|
|
235
253
|
const [sha, subject = "", msg = "", files = ""] = rec.split("\x1f");
|
|
236
254
|
const pr = Number(/Merge pull request #(\d+)/.exec(subject)?.[1] ?? /\(#(\d+)\)\s*$/.exec(subject)?.[1]) || null;
|
|
237
255
|
return { sha: sha.trim(), subject: subject.trim(), msg, pr, files: files.split("\n").map((f) => f.trim()).filter(Boolean),
|
|
238
256
|
revert: /^Revert\b/.test(subject.trim()) || /\/revert-\d+-/.test(subject) || /^This reverts commit [0-9a-f]{7,40}/m.test(msg) };
|
|
239
257
|
});
|
|
258
|
+
})());
|
|
259
|
+
const prReads = new Map();
|
|
260
|
+
const prInfo = (n) => {
|
|
261
|
+
if (!prReads.has(n)) prReads.set(n, (async () => {
|
|
262
|
+
const pr = JSON.parse(String(await io.gh(["api", `repos/${repo}/pulls/${n}`, "--jq", "{title, body, head: .head.ref}"])));
|
|
263
|
+
const linked = Number(/issue-(\d+)/.exec(String(pr.head ?? ""))?.[1] ?? /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/i.exec(String(pr.body ?? ""))?.[1]) || null;
|
|
264
|
+
const recs = [...(await readComments(io.gh, repo, n)), ...(linked ? await readComments(io.gh, repo, linked) : [])].filter(trusted);
|
|
265
|
+
return { title: pr.title, linked, recs };
|
|
266
|
+
})());
|
|
267
|
+
return prReads.get(n);
|
|
268
|
+
};
|
|
269
|
+
const ledgerPrs = async () => (await walk()).filter((x) => x.pr || x.revert).slice(0, MAX_LEDGER_PRS);
|
|
270
|
+
|
|
271
|
+
await step("ledger", async () => {
|
|
272
|
+
const symbols = issueSymbols(body);
|
|
273
|
+
const commits = await walk();
|
|
274
|
+
if (!commits.length) return;
|
|
240
275
|
const reverts = commits.filter((c) => c.revert);
|
|
241
276
|
const revertedBy = (c) => reverts.find((r) => r !== c && ((c.pr && (new RegExp(`#${c.pr}\\b`).test(`${r.subject}\n${r.msg}`) || new RegExp(`/revert-${c.pr}-`).test(r.subject))) || r.msg.includes(c.sha) || r.msg.includes(c.sha.slice(0, 12))));
|
|
242
|
-
for (const c of
|
|
277
|
+
for (const c of await ledgerPrs()) {
|
|
243
278
|
let title = c.subject, notes = [];
|
|
244
279
|
const reverted = c.revert ? null : revertedBy(c);
|
|
245
280
|
if (c.pr) {
|
|
246
281
|
try {
|
|
247
|
-
const pr =
|
|
282
|
+
const pr = await prInfo(c.pr);
|
|
248
283
|
title = oneLine(pr.title ?? title, 120);
|
|
249
|
-
|
|
250
|
-
const recs = [...(await readComments(io.gh, repo, c.pr)), ...(linked ? await readComments(io.gh, repo, linked) : [])].filter(trusted);
|
|
251
|
-
for (const r of recs) {
|
|
284
|
+
for (const r of pr.recs) {
|
|
252
285
|
const m = markerOf(r.body);
|
|
253
286
|
if (m === "DECISION") notes.push(...String(r.body).split("\n").slice(1).map((l) => oneLine(l, 160)).filter((l) => l && !l.startsWith("#")).map((l) => `decision: ${l}`));
|
|
254
287
|
if (m === "CONTRACT") for (const d of parseContractComment(r.body)?.contract?.decisions ?? []) notes.push(oneLine(`decision ${d?.id ?? ""}: ${d?.choice ?? ""}`, 160));
|
|
@@ -264,7 +297,32 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
|
|
|
264
297
|
});
|
|
265
298
|
|
|
266
299
|
await step("facts", async () => {
|
|
267
|
-
|
|
300
|
+
// #4401: FORGE:FACTS of earlier lanes: this issue's own attempts (newest first), then merged PRs on the issue's files
|
|
301
|
+
// whose record names an overlapping file. Only trusted comments; a malformed record contributes nothing.
|
|
302
|
+
const seen = new Set();
|
|
303
|
+
const add = (rec, ref, n, pr) => {
|
|
304
|
+
const key = JSON.stringify(rec);
|
|
305
|
+
if (!rec || seen.has(key) || facts.lanes.length >= MAX_FACT_LANES) return;
|
|
306
|
+
seen.add(key);
|
|
307
|
+
facts.lanes.push({ ref, issue: n, pr, ...rec });
|
|
308
|
+
};
|
|
309
|
+
for (const c of [...comments].reverse()) if (trusted(c)) add(parseFactsComment(c?.body), `#${issue} earlier attempt`, Number(issue), null);
|
|
310
|
+
const overlaps = (files) => files.some((f) => paths.some((p) => f === p || f.startsWith(`${p.replace(/\/+$/, "")}/`) || p.startsWith(`${f}/`)));
|
|
311
|
+
let prs = [];
|
|
312
|
+
try { prs = await ledgerPrs(); } catch (e) { facts.errors.push(`facts: ledger ${oneLine(e?.message ?? e, 80)}`); }
|
|
313
|
+
for (const c of prs) {
|
|
314
|
+
if (!c.pr || facts.lanes.length >= MAX_FACT_LANES) continue;
|
|
315
|
+
let pr;
|
|
316
|
+
try { pr = await prInfo(c.pr); } catch (e) { facts.errors.push(`facts: PR #${c.pr} ${oneLine(e?.message ?? e, 80)}`); continue; }
|
|
317
|
+
for (const r of [...pr.recs].reverse()) {
|
|
318
|
+
const rec = parseFactsComment(r.body);
|
|
319
|
+
if (rec && overlaps(rec.files)) add(rec, `${pr.linked ? `#${pr.linked}, ` : ""}PR #${c.pr}`, pr.linked, c.pr);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// #4400: the .forge/ caches are untracked, so a fresh lane worktree lacks them: the main checkout is the fallback
|
|
323
|
+
for (const p of REPO_FACT_FILES) {
|
|
324
|
+
for (const d of [...new Set([cwd, checkout].filter(Boolean))]) if (await io.exists(`${d}/${p}`)) { facts.facts.push(p); break; }
|
|
325
|
+
}
|
|
268
326
|
});
|
|
269
327
|
return facts;
|
|
270
328
|
}
|
|
@@ -274,25 +332,33 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
|
|
|
274
332
|
// forge#4297: codes are re-checked here too, so facts read back from the cache get the same whitelist
|
|
275
333
|
const linkLine = (l) => { const fails = (Array.isArray(l.failures) ? l.failures : []).map(codeOf).filter(Boolean), res = codeOf(l.result);
|
|
276
334
|
return `- #${l.number} [${l.rel}${l.kind === "pr" ? ", PR" : ""}] ${l.state ?? "?"}${l.title ? ` "${l.title}"` : ""}${codeOf(l.code) ? `; outcome ${l.code}` : ""}${res ? `; ${res}` : ""}${fails.length ? `; failures ${fails.join(", ")}` : ""}`; };
|
|
335
|
+
const pitfallLine = (p) => `- [${p.trigger}] ${oneLine(p.lesson, 160)}${p.globs?.length ? ` (${p.globs.join(", ")})` : ""}; seen ${p.count}×`;
|
|
277
336
|
const ledgerLine = (p) => `- ${p.pr ? `PR #${p.pr}` : "commit"} ${p.sha}${p.revert ? " [revert]" : ""}${p.reverted ? ` [reverted by ${p.reverted}]` : ""} "${p.title}"${p.files?.length ? ` (${p.files.join(", ")})` : ""}${p.notes?.length ? ` — ${p.notes.join("; ")}` : ""}`;
|
|
278
337
|
|
|
279
338
|
/**
|
|
280
339
|
* The pack text for `facts`, ranked: explicit links (parent > Depends on > body links > cross-references > siblings),
|
|
281
|
-
* prior attempts, the symbol ledger (newest first), repo-fact pointers. One line per item; a parent or dependency also
|
|
340
|
+
* prior attempts, recurring pitfalls of the repo (#4375, in the engine's selected order), the symbol ledger (newest first), repo-fact pointers. One line per item; a parent or dependency also
|
|
282
341
|
* carries its full body when it fits. Items that do not fit `budgetBytes` are dropped whole and counted in `truncated`.
|
|
283
|
-
*
|
|
342
|
+
* `pitfalls` lists the kept pitfalls ({sig, match}) for the run log (#4375).
|
|
343
|
+
* @returns {{text:string, bytes:number, items:number, truncated:number, sources:object, found:object, hash:string|null, pitfalls:object[]}}
|
|
284
344
|
*/
|
|
285
345
|
export function compileContextPack(facts, { budgetBytes = DEFAULT_CONTEXT_BYTES, sources = CONTEXT_SOURCES } = {}) {
|
|
286
346
|
const on = new Set(sources);
|
|
287
347
|
const cand = [];
|
|
288
348
|
if (on.has("graph")) for (const l of facts?.links ?? []) cand.push({ src: "graph", line: linkLine(l), full: l.body ? `${linkLine(l)}\n${l.body.split("\n").map((x) => ` > ${x}`).join("\n")}` : null });
|
|
289
349
|
if (on.has("attempts")) for (const a of facts?.attempts ?? []) cand.push({ src: "attempts", line: `- ${a.ref}: ${oneLine(a.line)}` });
|
|
350
|
+
if (on.has("pitfalls")) for (const p of facts?.pitfalls ?? []) cand.push({ src: "pitfalls", line: pitfallLine(p), pit: { sig: p.sig, match: p.match } });
|
|
290
351
|
if (on.has("ledger")) for (const p of facts?.ledger ?? []) cand.push({ src: "ledger", line: ledgerLine(p) });
|
|
291
|
-
if (on.has("facts"))
|
|
352
|
+
if (on.has("facts")) {
|
|
353
|
+
// #4401: earlier lanes' facts by kind (rejected approaches first, across lanes), then the repo-fact file pointers
|
|
354
|
+
const lanes = (facts?.lanes ?? []).map((l) => factsLines(l, l.ref));
|
|
355
|
+
for (const k of FACTS_RANK) for (const l of lanes) for (const line of l[k]) cand.push({ src: "facts", line: oneLine(line) });
|
|
356
|
+
for (const f of facts?.facts ?? []) cand.push({ src: "facts", line: `- ${f} (repo facts file; read it if relevant)` });
|
|
357
|
+
}
|
|
292
358
|
const found = Object.fromEntries(CONTEXT_SOURCES.map((s) => [s, cand.filter((c) => c.src === s).length]));
|
|
293
|
-
const empty = { text: "", bytes: 0, items: 0, truncated: 0, sources: Object.fromEntries(CONTEXT_SOURCES.map((s) => [s, 0])), found, hash: null };
|
|
359
|
+
const empty = { text: "", bytes: 0, items: 0, truncated: 0, sources: Object.fromEntries(CONTEXT_SOURCES.map((s) => [s, 0])), found, hash: null, pitfalls: [] };
|
|
294
360
|
if (!cand.length) return empty;
|
|
295
|
-
const titles = { graph: "Linked issues and PRs", attempts: "Prior attempts at this issue", ledger: "Merged PRs on the same symbols (reverts, decisions, findings)", facts: "
|
|
361
|
+
const titles = { graph: "Linked issues and PRs", attempts: "Prior attempts at this issue", pitfalls: "Recurring pitfalls in this repo (past rework)", ledger: "Merged PRs on the same symbols (reverts, decisions, findings)", facts: "Facts from earlier lanes and repo facts" };
|
|
296
362
|
const head = `Context pack for #${facts.issue}`;
|
|
297
363
|
const size = (s) => Buffer.byteLength(s, "utf8");
|
|
298
364
|
const kept = [];
|
|
@@ -301,7 +367,7 @@ export function compileContextPack(facts, { budgetBytes = DEFAULT_CONTEXT_BYTES,
|
|
|
301
367
|
const secCost = kept.some((k) => k.src === c.src) ? 0 : size(`\n${titles[c.src]}:`) + 1;
|
|
302
368
|
const pick = [c.full, c.line].find((t) => t && used + secCost + size(t) + 1 <= budgetBytes);
|
|
303
369
|
if (!pick) { truncated++; continue; }
|
|
304
|
-
kept.push({ src: c.src, text: pick });
|
|
370
|
+
kept.push({ src: c.src, text: pick, pit: c.pit });
|
|
305
371
|
used += secCost + size(pick) + 1;
|
|
306
372
|
}
|
|
307
373
|
if (!kept.length) return { ...empty, truncated };
|
|
@@ -311,13 +377,25 @@ export function compileContextPack(facts, { budgetBytes = DEFAULT_CONTEXT_BYTES,
|
|
|
311
377
|
if (rows.length) parts.push(`\n${titles[s]}:`, ...rows.map((r) => r.text));
|
|
312
378
|
}
|
|
313
379
|
const text = parts.join("\n");
|
|
314
|
-
return { text, bytes: size(text), items: kept.length, truncated, sources: Object.fromEntries(CONTEXT_SOURCES.map((s) => [s, kept.filter((k) => k.src === s).length])), found, hash: hash12(text) };
|
|
380
|
+
return { text, bytes: size(text), items: kept.length, truncated, sources: Object.fromEntries(CONTEXT_SOURCES.map((s) => [s, kept.filter((k) => k.src === s).length])), found, hash: hash12(text), pitfalls: kept.filter((k) => k.pit).map((k) => k.pit) };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** The pack text without its pitfalls section (#4375: pitfalls are local only and never posted). */
|
|
384
|
+
export function publicPackText(text) {
|
|
385
|
+
const out = [];
|
|
386
|
+
let skip = false;
|
|
387
|
+
for (const l of String(text ?? "").split("\n")) {
|
|
388
|
+
if (/^[^\s-].*:$/.test(l)) skip = l === "Recurring pitfalls in this repo (past rework):";
|
|
389
|
+
else if (skip && l === "") skip = false;
|
|
390
|
+
if (!skip) out.push(l);
|
|
391
|
+
}
|
|
392
|
+
return out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
315
393
|
}
|
|
316
394
|
|
|
317
|
-
/** The collapsed FORGE:CONTEXT issue record body (without the marker). */
|
|
395
|
+
/** The collapsed FORGE:CONTEXT issue record body (without the marker); pitfalls show as a count only (#4375). */
|
|
318
396
|
export function renderContextRecord(pack, { mode, laneType: lt }) {
|
|
319
|
-
const src = CONTEXT_SOURCES.map((s) => `${s} ${pack.sources[s]}/${pack.found[s]}`).join(", ");
|
|
320
|
-
return `<details><summary>Context pack: ${pack.items} item(s), ${pack.bytes} bytes, ${pack.truncated} dropped (mode ${mode}, lane ${lt}, hash ${pack.hash})</summary>\n\nSources (included/found): ${src}\n\n~~~~text\n${noComments(pack.text).replace(/~~~~/g, "~~~")}\n~~~~\n</details>`;
|
|
397
|
+
const src = CONTEXT_SOURCES.map((s) => `${s} ${pack.sources?.[s] ?? 0}/${pack.found?.[s] ?? 0}`).join(", ");
|
|
398
|
+
return `<details><summary>Context pack: ${pack.items} item(s), ${pack.bytes} bytes, ${pack.truncated} dropped (mode ${mode}, lane ${lt}, hash ${pack.hash})</summary>\n\nSources (included/found): ${src}${pack.sources?.pitfalls ? " (pitfalls are local only, not shown)" : ""}\n\n~~~~text\n${noComments(publicPackText(pack.text)).replace(/~~~~/g, "~~~")}\n~~~~\n</details>`;
|
|
321
399
|
}
|
|
322
400
|
|
|
323
401
|
/**
|
|
@@ -326,19 +404,19 @@ export function renderContextRecord(pack, { mode, laneType: lt }) {
|
|
|
326
404
|
* reported source errors is not cached (#4298), the write goes through a temp file and a rename, and the issue's other
|
|
327
405
|
* cached packs are pruned. A cache miss or any write/rename/prune failure is not an error.
|
|
328
406
|
*/
|
|
329
|
-
export async function contextPackFor({ cfg, issue, headSha, cacheDir, fs, gather, stamp = {} }) {
|
|
330
|
-
const name = cacheDir && headSha ? contextPackCacheKey(issue, headSha, cfg, stamp) : null;
|
|
407
|
+
export async function contextPackFor({ cfg, issue, repo = null, headSha, cacheDir, fs, gather, stamp = {} }) {
|
|
408
|
+
const name = cacheDir && headSha ? contextPackCacheKey(issue, headSha, cfg, stamp, repo) : null;
|
|
331
409
|
const file = name ? join(cacheDir, name) : null;
|
|
332
410
|
if (file && fs?.read) {
|
|
333
411
|
try { const c = JSON.parse(await fs.read(file)); if (c?.pack && typeof c.pack.text === "string") return { ...c, cached: true }; } catch { /* miss */ }
|
|
334
412
|
}
|
|
335
413
|
const facts = await gather();
|
|
336
414
|
const out = { pack: compileContextPack(facts, cfg), errors: facts?.errors ?? [] };
|
|
337
|
-
if (file && fs?.write && !out.errors.length) await cachePack(fs, cacheDir, file, name, issue, JSON.stringify(out));
|
|
415
|
+
if (file && fs?.write && !out.errors.length) await cachePack(fs, cacheDir, file, name, runLogKey(issue, repo), JSON.stringify(out));
|
|
338
416
|
return { ...out, cached: false };
|
|
339
417
|
}
|
|
340
418
|
|
|
341
|
-
async function cachePack(fs, cacheDir, file, name,
|
|
419
|
+
async function cachePack(fs, cacheDir, file, name, key, text) {
|
|
342
420
|
try {
|
|
343
421
|
if (typeof fs.rename === "function") {
|
|
344
422
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
@@ -348,7 +426,7 @@ async function cachePack(fs, cacheDir, file, name, issue, text) {
|
|
|
348
426
|
} catch { return; /* cache is optional */ }
|
|
349
427
|
if (typeof fs.list !== "function" || typeof fs.rm !== "function") return;
|
|
350
428
|
try {
|
|
351
|
-
const prefix = `context-${
|
|
429
|
+
const prefix = `context-${key}-`;
|
|
352
430
|
for (const f of await fs.list(cacheDir)) {
|
|
353
431
|
if (f !== name && f.startsWith(prefix) && f.endsWith(".json")) { try { await fs.rm(join(cacheDir, f)); } catch { /* best effort */ } }
|
|
354
432
|
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
|
-
import {
|
|
5
|
+
import { normaliseFacts, renderFactsRecord, FACTS_MARKER } from "./facts.mjs";
|
|
6
|
+
import { gatherContextFacts, compileContextPack, contextPackFor, contextPackCacheKey, linkStateStamp, LINK_STAMP_MAX, parseContextPackConfig, laneType, priorAttempts, issueSymbols, bodyLinks, parentOf, renderContextRecord, DEFAULT_CONTEXT_BYTES, noComments } from "./context.mjs";
|
|
6
7
|
|
|
7
8
|
const REPO = "o/r";
|
|
8
9
|
const BOT = { author_association: "NONE", user: { type: "Bot", login: "forge[bot]" } };
|
|
@@ -33,7 +34,7 @@ function fakes(o = {}) {
|
|
|
33
34
|
101: [{ id: 2, body: "<!-- FORGE:DECISION -->\nKeep `compileContextPack` pure; I/O lives in gather.", ...BOT }],
|
|
34
35
|
102: [],
|
|
35
36
|
103: [{ id: 3, body: "<!-- FORGE:DECISION -->\nUnrelated decision about logging.", ...BOT }],
|
|
36
|
-
33: [{ id: 4, body: "<!-- FORGE:CONTRACT -->\nx", ...BOT }],
|
|
37
|
+
33: [{ id: 4, body: "<!-- FORGE:CONTRACT -->\nx", ...BOT }, ...(o.facts33 ?? [])],
|
|
37
38
|
};
|
|
38
39
|
const prs = { 101: { title: "feat: ranking", body: "Closes #33", head: "feat/issue-33-lean" }, 102: { title: "feat: speedup", body: "", head: "feat/issue-34-lean" }, 103: { title: "chore: logs", body: "", head: "x" } };
|
|
39
40
|
const log = [
|
|
@@ -109,6 +110,21 @@ describe("context pack (forge#4275)", () => {
|
|
|
109
110
|
assert.notEqual(contextPackCacheKey(50, "abc", cfg), contextPackCacheKey(51, "abc", cfg));
|
|
110
111
|
});
|
|
111
112
|
|
|
113
|
+
it("#4432: the cache key and the prune prefix are repo-scoped, so another repo's pack for the same number survives", async () => {
|
|
114
|
+
const store = new Map();
|
|
115
|
+
const fs = { read: async (p) => { if (!store.has(p)) throw new Error("ENOENT"); return store.get(p); }, write: async (p, t) => { store.set(p, t); },
|
|
116
|
+
list: async () => [...store.keys()].map((p) => p.slice("/runs/".length)), rm: async (p) => { store.delete(p); } };
|
|
117
|
+
const cfg = parseContextPackConfig({});
|
|
118
|
+
const g = async () => gather(fakes());
|
|
119
|
+
assert.match(contextPackCacheKey(50, "abc", cfg, {}, "acme/one"), /^context-acme__one__50-abc-/);
|
|
120
|
+
assert.equal(contextPackCacheKey(50, "abc", cfg, {}), contextPackCacheKey(50, "abc", cfg), "no repo: today's key");
|
|
121
|
+
await contextPackFor({ cfg, issue: 50, repo: "acme/one", headSha: "abc", cacheDir: "/runs", fs, gather: g });
|
|
122
|
+
await contextPackFor({ cfg, issue: 50, repo: "acme/two", headSha: "def", cacheDir: "/runs", fs, gather: g });
|
|
123
|
+
assert.deepEqual([...store.keys()].map((k) => k.split("-").slice(0, 2).join("-")).sort(), ["/runs/context-acme__one__50", "/runs/context-acme__two__50"]);
|
|
124
|
+
const again = await contextPackFor({ cfg, issue: 50, repo: "acme/one", headSha: "abc", cacheDir: "/runs", fs, gather: g });
|
|
125
|
+
assert.equal(again.cached, true);
|
|
126
|
+
});
|
|
127
|
+
|
|
112
128
|
it("drops whole items past the byte cap, falls back to one line for a parent body, counts truncation", async () => {
|
|
113
129
|
const facts = await gather(fakes());
|
|
114
130
|
const p = compileContextPack(facts, { budgetBytes: 130 });
|
|
@@ -121,7 +137,10 @@ describe("context pack (forge#4275)", () => {
|
|
|
121
137
|
const f = fakes();
|
|
122
138
|
const facts = await gather(f, { sources: ["graph", "facts"] });
|
|
123
139
|
assert.equal(facts.ledger.length + facts.attempts.length, 0);
|
|
124
|
-
assert.ok(!f.calls.some((c) => c[1] === "
|
|
140
|
+
assert.ok(!f.calls.some((c) => c[1] === "for-each-ref"), "a disabled source makes no calls");
|
|
141
|
+
const g = fakes();
|
|
142
|
+
await gather(g, { sources: ["graph"] });
|
|
143
|
+
assert.ok(!g.calls.some((c) => c[1] === "log" || c[1] === "for-each-ref"), "no ledger walk without the ledger or facts source (#4401)");
|
|
125
144
|
const p = compileContextPack(facts, { sources: ["graph"] });
|
|
126
145
|
assert.ok(p.text.includes("#40") && !p.text.includes("test-map"));
|
|
127
146
|
assert.deepEqual(parseContextPackConfig({ sources: "ledger, bogus,graph" }).sources, ["graph", "ledger"]);
|
|
@@ -166,7 +185,9 @@ describe("context pack (forge#4275)", () => {
|
|
|
166
185
|
});
|
|
167
186
|
|
|
168
187
|
it("config, lane type and parsers", () => {
|
|
169
|
-
assert.deepEqual(parseContextPackConfig({}), { mode: "shadow", sources: ["graph", "attempts", "ledger", "facts"], budgetBytes: 6144, days: 30 });
|
|
188
|
+
assert.deepEqual(parseContextPackConfig({}), { mode: "shadow", sources: ["graph", "attempts", "pitfalls", "ledger", "facts"], budgetBytes: 6144, days: 30, pitfallLanes: 50 });
|
|
189
|
+
assert.equal(parseContextPackConfig({ pitfallLanes: 12 }).pitfallLanes, 12);
|
|
190
|
+
assert.equal(parseContextPackConfig({ pitfallLanes: -3 }).pitfallLanes, 50);
|
|
170
191
|
assert.equal(parseContextPackConfig({ mode: "ON" }).mode, "on");
|
|
171
192
|
assert.equal(parseContextPackConfig({ mode: "nope" }).mode, "shadow");
|
|
172
193
|
assert.equal(laneType({ body: "x\n**Parent**: #3" }), "child");
|
|
@@ -183,7 +204,7 @@ describe("context pack (forge#4275)", () => {
|
|
|
183
204
|
const p = compileContextPack(await gather(fakes()));
|
|
184
205
|
const r = renderContextRecord(p, { mode: "shadow", laneType: "rerun" });
|
|
185
206
|
assert.match(r, /^<details><summary>Context pack: \d+ item\(s\), \d+ bytes, 0 dropped \(mode shadow, lane rerun, hash [0-9a-f]{12}\)<\/summary>/);
|
|
186
|
-
assert.match(r, /Sources \(included\/found\): graph 5\/5, attempts 3\/3, ledger 3\/3, facts 1\/1/);
|
|
207
|
+
assert.match(r, /Sources \(included\/found\): graph 5\/5, attempts 3\/3, pitfalls 0\/0, ledger 3\/3, facts 1\/1/);
|
|
187
208
|
assert.ok(r.includes(p.text));
|
|
188
209
|
});
|
|
189
210
|
});
|
|
@@ -221,20 +242,51 @@ describe("#4298 context-pack cache and shared names", () => {
|
|
|
221
242
|
assert.notEqual(k({ scope: "aaa111", attempts: 1 }), k({ scope: "aaa111", attempts: 2 }));
|
|
222
243
|
});
|
|
223
244
|
|
|
245
|
+
it("#4318: above LINK_STAMP_MAX the highest numbers are kept, still in one call", async () => {
|
|
246
|
+
const calls = [];
|
|
247
|
+
const gh = async (args) => { calls.push(args); return JSON.stringify({ data: { repository: {} } }); };
|
|
248
|
+
const body = Array.from({ length: LINK_STAMP_MAX + 5 }, (_, i) => `#${i + 1}`).join(" ");
|
|
249
|
+
await linkStateStamp({ gh, repo: "o/r", issue: 9999, body });
|
|
250
|
+
assert.equal(calls.length, 1);
|
|
251
|
+
const nums = [...(calls[0].find((x) => x.startsWith("query=")) ?? "").matchAll(/i(\d+):/g)].map((m) => Number(m[1]));
|
|
252
|
+
assert.equal(nums.length, LINK_STAMP_MAX);
|
|
253
|
+
assert.equal(nums[0], 6, "the lowest numbers are dropped");
|
|
254
|
+
assert.equal(nums.at(-1), LINK_STAMP_MAX + 5);
|
|
255
|
+
});
|
|
256
|
+
|
|
224
257
|
it("#4316: linked-issue states are in the key; a state change recompiles; no links keeps the old key", async () => {
|
|
225
258
|
const states = { 7: "open", 8: "open", 9: "closed" };
|
|
259
|
+
let rel = 2;
|
|
226
260
|
const calls = [];
|
|
227
|
-
|
|
261
|
+
// #4318: one GraphQL call; an alias per referenced number, plus the issue's own timeline relation count
|
|
262
|
+
const gh = async (args) => {
|
|
263
|
+
calls.push(args);
|
|
264
|
+
if (states.fail) throw new Error("HTTP 502");
|
|
265
|
+
const q = args.find((a) => a.startsWith("query=")) ?? "";
|
|
266
|
+
const repository = { self: { timelineItems: { totalCount: rel } } };
|
|
267
|
+
for (const m of q.matchAll(/i(\d+):issueOrPullRequest/g)) repository[`i${m[1]}`] = states[m[1]] ? { state: states[m[1]].toUpperCase() } : null;
|
|
268
|
+
return JSON.stringify({ data: { repository } });
|
|
269
|
+
};
|
|
270
|
+
const aliases = (a) => [...(a.find((x) => x.startsWith("query=")) ?? "").matchAll(/i(\d+):/g)].map((m) => Number(m[1]));
|
|
228
271
|
const body = "**Parent**: #7\nDepends on #8\nSee #9 and #50 (self).";
|
|
229
272
|
const s1 = await linkStateStamp({ gh, repo: "o/r", issue: 50, body });
|
|
230
273
|
assert.match(s1, /^[0-9a-f]{12}$/);
|
|
231
|
-
assert.
|
|
274
|
+
assert.equal(calls.length, 1, "one call for every reference");
|
|
275
|
+
assert.deepEqual(calls[0].slice(0, 2), ["api", "graphql"]);
|
|
276
|
+
assert.deepEqual(aliases(calls[0]), [7, 8, 9], "parent, depends-on and links once each, never self");
|
|
277
|
+
assert.ok(calls[0].includes("o=o") && calls[0].includes("n=r"));
|
|
232
278
|
assert.equal(await linkStateStamp({ gh, repo: "o/r", issue: 50, body }), s1, "stable");
|
|
233
279
|
states[8] = "closed";
|
|
234
280
|
const s2 = await linkStateStamp({ gh, repo: "o/r", issue: 50, body });
|
|
235
281
|
assert.notEqual(s2, s1, "a linked issue closing changes the stamp");
|
|
236
|
-
|
|
237
|
-
assert.
|
|
282
|
+
rel = 3;
|
|
283
|
+
assert.notEqual(await linkStateStamp({ gh, repo: "o/r", issue: 50, body }), s2, "a new timeline relation changes the stamp");
|
|
284
|
+
rel = 2;
|
|
285
|
+
states.fail = true;
|
|
286
|
+
const sf = await linkStateStamp({ gh, repo: "o/r", issue: 50, body });
|
|
287
|
+
assert.match(sf, /^[0-9a-f]{12}$/, "a failed call never throws");
|
|
288
|
+
assert.notEqual(sf, s2, "a failed call is n:? for every number");
|
|
289
|
+
delete states.fail;
|
|
238
290
|
assert.equal(await linkStateStamp({ gh: async () => { throw new Error("x"); }, repo: "o/r", issue: 50, body: "no links here" }), "");
|
|
239
291
|
const k = (s) => contextPackCacheKey(50, "abc", cfg, s);
|
|
240
292
|
assert.equal(k({ scope: "s", attempts: 0, links: "" }), k({ scope: "s", attempts: 0 }), "no links: the pre-#4316 key");
|
|
@@ -303,5 +355,119 @@ describe("#4298 context-pack cache and shared names", () => {
|
|
|
303
355
|
writeFileSync(`${d}/forge.yaml`, "engine:\n context_pack: on\n");
|
|
304
356
|
assert.deepEqual(readContextPack(d, (m) => warns.push(m)), { mode: "on" });
|
|
305
357
|
assert.equal(warns.length, 1, "a valid mode does not warn");
|
|
358
|
+
writeFileSync(`${d}/forge.yaml`, "engine:\n context_pack_pitfall_lanes: 20\n");
|
|
359
|
+
assert.deepEqual(readContextPack(d, (m) => warns.push(m)), { pitfallLanes: 20 }, "#4375: the pitfall aging window");
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe("#4375 recurring pitfalls in the context pack", () => {
|
|
364
|
+
const PITS = [
|
|
365
|
+
{ sig: "precheck:aaaaaaaaaaaa", trigger: "precheck", lesson: "precheck: src/a.ts:12:5 error 'x' is defined but never used no-unused-vars", globs: ["src/*.ts"], count: 4, match: "files" },
|
|
366
|
+
{ sig: "docs:bbbbbbbbbbbb", trigger: "docs", lesson: "docs: devdocs/modules/lean.md is stale for bin/lean/lean.mjs", globs: ["devdocs/modules/*.md"], count: 2, match: "repo" },
|
|
367
|
+
];
|
|
368
|
+
const facts = (pitfalls) => gatherContextFacts({ io: { gh: async () => { throw new Error("no gh"); }, git: async () => "", exists: async () => false }, repo: REPO, issue: 50, iss: { number: 50, body: "plain" }, comments: [], cwd: "/wt", base: "origin/staging", sources: ["pitfalls"], pitfalls });
|
|
369
|
+
|
|
370
|
+
it("compiles the given pitfalls in order, inside the byte budget, and lists the kept signatures", async () => {
|
|
371
|
+
const p = compileContextPack(await facts(PITS));
|
|
372
|
+
assert.ok(p.text.includes("Recurring pitfalls in this repo (past rework):"), p.text);
|
|
373
|
+
const a = p.text.indexOf("[precheck]"), b = p.text.indexOf("[docs]");
|
|
374
|
+
assert.ok(a > 0 && b > a, p.text);
|
|
375
|
+
assert.ok(p.text.includes("(src/*.ts); seen 4×"));
|
|
376
|
+
assert.deepEqual(p.pitfalls, [{ sig: PITS[0].sig, match: "files" }, { sig: PITS[1].sig, match: "repo" }]);
|
|
377
|
+
assert.equal(p.sources.pitfalls, 2);
|
|
378
|
+
const small = compileContextPack(await facts(PITS), { budgetBytes: 200 });
|
|
379
|
+
assert.ok(small.bytes <= 200 && small.truncated >= 1, `${small.bytes} ${small.truncated}`);
|
|
380
|
+
assert.deepEqual(small.pitfalls, small.text.includes("[docs]") ? p.pitfalls : [p.pitfalls[0]]);
|
|
381
|
+
assert.equal(compileContextPack(await facts(PITS), { sources: ["graph"] }).pitfalls.length, 0, "source off: no pitfalls");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("the posted FORGE:CONTEXT record never carries pitfall text, only the count", async () => {
|
|
385
|
+
const f = await gather(fakes());
|
|
386
|
+
const p = compileContextPack({ ...f, pitfalls: (await facts(PITS)).pitfalls });
|
|
387
|
+
assert.ok(p.text.includes("no-unused-vars"));
|
|
388
|
+
const r = renderContextRecord(p, { mode: "on", laneType: "first" });
|
|
389
|
+
for (const x of ["no-unused-vars", "Recurring pitfalls", "src/*.ts", "devdocs/modules/lean.md is stale"]) assert.ok(!r.includes(x), x);
|
|
390
|
+
assert.match(r, /pitfalls 2\/2/);
|
|
391
|
+
assert.ok(r.includes("Prior attempts at this issue:") && r.includes("Merged PRs on the same symbols"), "the other sections stay");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("the cache key changes when the selected pitfall signatures change", () => {
|
|
395
|
+
const cfg = parseContextPackConfig({});
|
|
396
|
+
const k0 = contextPackCacheKey(50, "abc", cfg, { scope: "s", attempts: 0 });
|
|
397
|
+
assert.equal(contextPackCacheKey(50, "abc", cfg, { scope: "s", attempts: 0, pitfalls: [] }), k0, "no pitfalls: today's key");
|
|
398
|
+
const k1 = contextPackCacheKey(50, "abc", cfg, { scope: "s", attempts: 0, pitfalls: ["precheck:aaaaaaaaaaaa"] });
|
|
399
|
+
assert.notEqual(k1, k0);
|
|
400
|
+
assert.notEqual(contextPackCacheKey(50, "abc", cfg, { scope: "s", attempts: 0, pitfalls: ["precheck:aaaaaaaaaaaa", "docs:bbbbbbbbbbbb"] }), k1);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("without pitfalls the pack text and hash are unchanged", async () => {
|
|
404
|
+
const f = await gather(fakes());
|
|
405
|
+
const a = compileContextPack(f), b = compileContextPack({ ...f, pitfalls: [] });
|
|
406
|
+
assert.equal(a.text, b.text); assert.equal(a.hash, b.hash);
|
|
407
|
+
assert.ok(!a.text.includes("Recurring pitfalls"));
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
describe("FORGE:FACTS in the context pack (#4401)", () => {
|
|
412
|
+
const rec = (files, approach, why = "it only hid the symptom") => `<!-- ${FACTS_MARKER} -->\n${renderFactsRecord(normaliseFacts({ facts: { rejected: [{ approach, why }], hotspots: [{ at: `${files[0]}:7`, why: "ranking loop" }] } }, { files }))}`;
|
|
413
|
+
const STRANGER = { author_association: "NONE", user: { type: "User", login: "rando" } };
|
|
414
|
+
|
|
415
|
+
it("a later lane on overlapping files receives the prior rejected approaches; the facts source is non-zero", async () => {
|
|
416
|
+
const f = fakes({ facts33: [
|
|
417
|
+
{ id: 5, body: rec(["bin/lean/context.mjs"], "detection-only check"), ...BOT },
|
|
418
|
+
{ id: 6, body: rec(["docs/other.md"], "unrelated file approach"), ...BOT },
|
|
419
|
+
{ id: 7, body: rec(["bin/lean/context.mjs"], "forged by a stranger"), ...STRANGER },
|
|
420
|
+
] });
|
|
421
|
+
const own = { id: "IC_9", body: rec(["bin/lean/context.mjs"], "own earlier attempt approach"), author: { login: "forge[bot]" }, authorAssociation: "NONE" };
|
|
422
|
+
const facts = await gather(f, { comments: [...COMMENTS, own] });
|
|
423
|
+
const p = compileContextPack(facts);
|
|
424
|
+
const t = p.text;
|
|
425
|
+
const at = (s) => { const i = t.indexOf(s); assert.ok(i >= 0, `missing ${s}\n${t}`); return i; };
|
|
426
|
+
const ownAt = at("- rejected: own earlier attempt approach — it only hid the symptom (#50 earlier attempt)");
|
|
427
|
+
const prior = at("- rejected: detection-only check — it only hid the symptom (#33, PR #101)");
|
|
428
|
+
const hot = at("- hot spot bin/lean/context.mjs:7: ranking loop (#33, PR #101)");
|
|
429
|
+
const ptr = at("- .forge/test-map.json");
|
|
430
|
+
assert.ok(ownAt < prior && prior < hot && hot < ptr, t);
|
|
431
|
+
assert.ok(t.includes("Facts from earlier lanes and repo facts:"));
|
|
432
|
+
assert.ok(!t.includes("unrelated file approach"), "a record on non-overlapping files is left out");
|
|
433
|
+
assert.ok(!t.includes("forged by a stranger"), "untrusted records are never read");
|
|
434
|
+
assert.equal(p.sources.facts, 5);
|
|
435
|
+
assert.equal(f.calls.filter((c) => c[0] === "gh" && c.some((x) => String(x).includes("pulls/101"))).length, 1, "ledger and facts share one read per PR");
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("works with the ledger source off and contributes nothing when no record exists", async () => {
|
|
439
|
+
const f = fakes({ facts33: [{ id: 5, body: rec(["bin/lean/context.mjs"], "detection-only check"), ...BOT }] });
|
|
440
|
+
const facts = await gather(f, { sources: ["facts"] });
|
|
441
|
+
assert.equal(facts.ledger.length, 0);
|
|
442
|
+
assert.ok(compileContextPack(facts, { sources: ["facts"] }).text.includes("detection-only check"));
|
|
443
|
+
const none = await gather(fakes(), { sources: ["facts"] });
|
|
444
|
+
assert.deepEqual([none.lanes, none.facts], [[], [".forge/test-map.json"]]);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
describe("populated pack sources (#4400)", () => {
|
|
449
|
+
it("repo-fact files that exist only in the main checkout are listed; the worktree alone (the old lookup) lists none", async () => {
|
|
450
|
+
const io = (dir) => ({ ...fakes().io, exists: async (p) => p.startsWith(`${dir}/`) && (p.endsWith(".forge/repo-facts.json") || p.endsWith(".forge/test-map.json")) });
|
|
451
|
+
const inCheckout = await gather({ io: io("/main") }, { sources: ["facts"], checkout: "/main" });
|
|
452
|
+
assert.deepEqual(inCheckout.facts, [".forge/repo-facts.json", ".forge/test-map.json"]);
|
|
453
|
+
const worktreeOnly = await gather({ io: io("/main") }, { sources: ["facts"] });
|
|
454
|
+
assert.deepEqual(worktreeOnly.facts, []);
|
|
455
|
+
const both = await gather({ io: { ...fakes().io, exists: async (p) => p.endsWith(".forge/repo-facts.json") } }, { sources: ["facts"], checkout: "/main" });
|
|
456
|
+
assert.deepEqual(both.facts, [".forge/repo-facts.json"], "a file in both dirs is listed once");
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("an issue with a prior run, a ledger PR on its file and a FORGE:FACTS record has non-zero attempts, ledger and facts", async () => {
|
|
460
|
+
const rec = `<!-- ${FACTS_MARKER} -->\n${renderFactsRecord(normaliseFacts({ facts: { rejected: [{ approach: "retry only", why: "hid the race" }] } }, { files: ["bin/lean/context.mjs"] }))}`;
|
|
461
|
+
const f = fakes({ facts33: [{ id: 8, body: rec, ...BOT }] });
|
|
462
|
+
const p = compileContextPack(await gather(f, { checkout: "/main" }));
|
|
463
|
+
for (const s of ["attempts", "ledger", "facts"]) assert.ok(p.sources[s] > 0, `${s} is zero: ${JSON.stringify(p.sources)}`);
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
describe("#4467: priorAttempts reads App comments in gh issue view shape", () => {
|
|
468
|
+
const ghShape = (login) => ({ author: { login }, authorAssociation: "CONTRIBUTOR", body: "<!-- FORGE:NEEDS_HUMAN -->\nstuck" });
|
|
469
|
+
it("counts the App's NEEDS_HUMAN, not a stranger's", () => {
|
|
470
|
+
assert.equal(priorAttempts("", [ghShape("rapiercraft-forgedock")]), 1);
|
|
471
|
+
assert.equal(priorAttempts("", [ghShape("someone")]), 0);
|
|
306
472
|
});
|
|
307
473
|
});
|