forgedock 1.30.0 → 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 +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 +362 -33
- package/bin/engine.mjs +47 -37
- 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 +294 -1
- 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 +116 -43
- package/bin/lean/context.test.mjs +139 -4
- 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 +37 -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 +650 -124
- package/bin/lean/lean.test.mjs +1450 -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 +261 -38
- 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 +113 -1
- package/bin/lean/prefilter.mjs +159 -5
- package/bin/lean/prefilter.test.mjs +152 -3
- 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 +101 -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.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 +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 +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 +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 +53 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- 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
|
@@ -2,7 +2,7 @@
|
|
|
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 { validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, missingTool, repoTracks, tmpdirInputs } from "./conformance.mjs";
|
|
5
|
+
import { validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, VERIFY_MODES, OUT_OF_BAND_RE } from "./conformance.mjs";
|
|
6
6
|
|
|
7
7
|
const ISSUE = "## Requirements\n1. The scrape route must release the spend reservation on 429.\n2. The search route must release the spend reservation on 429.\n\n- [ ] Billing pricing is unchanged.";
|
|
8
8
|
const Q1 = "The scrape route must release the spend reservation on 429.";
|
|
@@ -62,7 +62,7 @@ describe("amendments", () => {
|
|
|
62
62
|
|
|
63
63
|
describe("precheck (deterministic, no reviewer)", () => {
|
|
64
64
|
const res = { requirements: [{ id: "R1", status: "implemented" }, { id: "R2", status: "implemented" }], validation: [{ id: "V1", exit: 0 }] };
|
|
65
|
-
it("passes a complete, validated, in-scope change", () => assert.deepEqual(precheck({ contract: C, result: res, changedFiles: files }), { ok: true, failures: [] }));
|
|
65
|
+
it("passes a complete, validated, in-scope change", () => assert.deepEqual(precheck({ contract: C, result: res, changedFiles: files }), { ok: true, failures: [], superseded: [] }));
|
|
66
66
|
it("unauthorized file -> scope_creep; tests and ignored paths are always allowed", () => {
|
|
67
67
|
assert.deepEqual(codes(precheck({ contract: C, result: res, changedFiles: [...files, "src/billing.js", ".forge-lean/x", "tests/test_x.py", "pkg/foo_test.go"], ignore: [".forge-lean/"] })), ["conformance.scope_creep"]);
|
|
68
68
|
for (const t of ["test/a.js", "src/a.test.ts", "tests/test_a.py", "a_test.go", "spec/a.spec.js"]) assert.ok(isTestPath(t), t);
|
|
@@ -309,10 +309,44 @@ describe("close plan and follow-up items", () => {
|
|
|
309
309
|
const k = { requirements: [{ id: "R1", disposition: "deliver" }, { id: "R2", disposition: "defer", tracked: "#9" }, { id: "R3", disposition: "post-deploy", tracked: "#9" }] };
|
|
310
310
|
assert.deepEqual(closePlan(k).tracked, ["#9"]);
|
|
311
311
|
});
|
|
312
|
+
it("#4398 isInvestigation reads the investigate( / investigate: title type only", () => {
|
|
313
|
+
for (const t of ["investigate(stealth): which signal", "Investigate: why", " investigate(x)"]) assert.equal(isInvestigation(t), true, t);
|
|
314
|
+
for (const t of ["fix(investigate): x", "feat: investigate later", "investigation(x)", "", null]) assert.equal(isInvestigation(t), false, String(t));
|
|
315
|
+
});
|
|
316
|
+
it("#4398 an investigation closes partial when a requirement is deferred or its answer is not implemented; otherwise completed", () => {
|
|
317
|
+
const title = "investigate(stealth): which device signal is rejected";
|
|
318
|
+
const k = { requirements: [{ id: "R1", disposition: "deliver" }, { id: "R2", disposition: "defer", tracked: "#9" }, { id: "R3", disposition: "not-applicable" }] };
|
|
319
|
+
const p = closePlan(k, { title });
|
|
320
|
+
assert.equal(p.reason, "partial"); assert.deepEqual(p.open.map((r) => r.id), ["R2"]); assert.deepEqual(p.tracked, ["#9"]);
|
|
321
|
+
assert.deepEqual(closePlan(k, { title: "fix: foo" }), { reason: "completed", tracked: ["#9"] }, "only investigations change");
|
|
322
|
+
const d = { requirements: [{ id: "R1", disposition: "deliver" }, { id: "R2", disposition: "deliver" }] };
|
|
323
|
+
const rows = (s1, s2) => ({ requirements: [{ id: "R1", status: s1 }, { id: "R2", status: s2 }] });
|
|
324
|
+
assert.deepEqual(closePlan(d, { title, review: rows("implemented", "implemented") }), { reason: "completed", tracked: [] });
|
|
325
|
+
assert.deepEqual(closePlan(d, { title, review: rows("implemented", "deferred") }).open.map((r) => r.id), ["R2"]);
|
|
326
|
+
assert.deepEqual(closePlan(d, { title, review: { requirements: [{ id: "R1", status: "implemented" }] } }).open.map((r) => r.id), ["R2"], "a missing row is not an answer");
|
|
327
|
+
assert.equal(closePlan(d, { title, review: null }).reason, "completed", "a skipped review judges dispositions only");
|
|
328
|
+
});
|
|
329
|
+
it("#4398 formatShadowSummary: shadow would-block and false-block shares, risk-enforced lanes; null without conformance rows", () => {
|
|
330
|
+
assert.equal(formatShadowSummary([{ issue: 1 }]), null); assert.equal(formatShadowSummary(undefined), null);
|
|
331
|
+
const rows = [{ conformance: { mode: "shadow", would: "conforms", risk: false } }, { conformance: { mode: "shadow", would: "nonconforming", risk: false } },
|
|
332
|
+
{ conformance: { mode: "shadow", would: "review.incomplete", risk: false } }, { conformance: { mode: "shadow", would: null, risk: false } },
|
|
333
|
+
{ conformance: { mode: "enforce", would: null, risk: true } }, { issue: 9 }];
|
|
334
|
+
assert.equal(formatShadowSummary(rows), "conformance: 4 shadow lanes, would block 2 (50%), false-block 1 (25%: would-block on an incomplete/inconsistent/unparseable review); risk-path enforced: 1");
|
|
335
|
+
assert.match(formatShadowSummary([{ conformance: { mode: "enforce", would: null, risk: false } }]), /0 shadow lanes, would block 0 \(n\/a\)/);
|
|
336
|
+
});
|
|
312
337
|
it("follow-up keys are single-line, tab-free and stable", () => {
|
|
313
338
|
const [i] = followupItems([{ id: "R2", quote: "a\tb\n`c`", disposition: "defer", why: "x\ny" }]);
|
|
314
339
|
assert.equal(i.key, "R2: a b c"); assert.equal(i.why, "defer: x y"); assert.doesNotMatch(i.key + i.why, /[\t\n`]/);
|
|
315
340
|
});
|
|
341
|
+
it("a follow-up key whose 240-char cut ends on a space has no trailing whitespace (#4388)", () => {
|
|
342
|
+
const quote = `${"x".repeat(234)} tail of the quote past the cut`; // "R12: " + 234 chars puts the space at index 239
|
|
343
|
+
assert.equal(`R12: ${quote}`[239], " ");
|
|
344
|
+
const [i] = followupItems([{ id: "R12", quote, disposition: "defer", why: "w" }]);
|
|
345
|
+
assert.equal(i.key, i.key.trim()); assert.equal(i.key.length, 239); assert.doesNotMatch(i.key, /\s$/);
|
|
346
|
+
});
|
|
347
|
+
it("followupMap resolves untrimmed item keys from the script's trimmed echo (#4388)", () => {
|
|
348
|
+
assert.deepEqual(followupMap("R1: a\t42\nnoise\nR2: b\tx\n", [{ key: "R1: a " }, { key: "R2: b" }]), { "R1: a ": 42 });
|
|
349
|
+
});
|
|
316
350
|
});
|
|
317
351
|
|
|
318
352
|
describe("hotspot growth precheck (forge#4195)", () => {
|
|
@@ -364,7 +398,7 @@ describe("repro_test on bug lanes (#4271)", () => {
|
|
|
364
398
|
});
|
|
365
399
|
it("red on base and green on head passes; anything else fails the precheck as conformance.unimplemented", () => {
|
|
366
400
|
const k = { ...C, repro_test: RT };
|
|
367
|
-
assert.deepEqual(precheck({ contract: k, result: res, changedFiles: files, repro: { base: "FAILED", head: "RAN" } }), { ok: true, failures: [] });
|
|
401
|
+
assert.deepEqual(precheck({ contract: k, result: res, changedFiles: files, repro: { base: "FAILED", head: "RAN" } }), { ok: true, failures: [], superseded: [] });
|
|
368
402
|
for (const repro of [{ base: "RAN", head: "RAN" }, { base: "SKIPPED", head: "FAILED" }, { base: "UNRUNNABLE", head: "RAN" }, { base: "SKIPPED", head: "RAN_NOTHING" }]) {
|
|
369
403
|
const pc = precheck({ contract: k, result: res, changedFiles: files, repro });
|
|
370
404
|
assert.deepEqual(pc.failures.map((f) => [f.code, f.kind]), [["conformance.unimplemented", "repro_red_green"]], JSON.stringify(repro));
|
|
@@ -425,3 +459,141 @@ describe("#4265 issue scope and failure explanations", () => {
|
|
|
425
459
|
assert.equal(r.failures[0].detail, "validate V1 (`npm test`) failed (exit 2)");
|
|
426
460
|
});
|
|
427
461
|
});
|
|
462
|
+
|
|
463
|
+
describe("#4350 the engine's scope gate is the authority over a builder's scope-check validate", () => {
|
|
464
|
+
const ALLOW = 'test -z "$(git diff --name-only origin/main...HEAD | grep -v -x -e src/tool.js -e test/tool.test.js)"';
|
|
465
|
+
const frozen = {
|
|
466
|
+
requirements: [{ id: "R1", quote: Q1, disposition: "deliver" }],
|
|
467
|
+
deliverables: [{ id: "D1", path: "src/tool.js" }],
|
|
468
|
+
validate: [{ id: "V1", command: "node --test test/tool.test.js" }, { id: "V2", command: ALLOW }],
|
|
469
|
+
};
|
|
470
|
+
const amendments = [{ kind: "add-deliverable", path: "docs/tool.md", why: "the docs-freshness rule needs the module doc" }];
|
|
471
|
+
const effective = applyAmendments(frozen, amendments).contract;
|
|
472
|
+
const changed = ["src/tool.js", "test/tool.test.js", "docs/tool.md", ".forge-lean/result.json"];
|
|
473
|
+
const result = { requirements: [{ id: "R1", status: "implemented" }], validation: [{ id: "V1", exit: 0 }, { id: "V2", exit: 1 }], amendments };
|
|
474
|
+
const run = (o = {}) => precheck({ contract: effective, result, changedFiles: changed, ignore: [".forge-lean/"], validateRuns: { V2: { rc: 1, output: "" } }, frozen, ...o });
|
|
475
|
+
it("#4350 a scope-allowlist validate that fails only for an amended deliverable is superseded, not conformance.unimplemented", () => {
|
|
476
|
+
const pc = run();
|
|
477
|
+
assert.deepEqual(pc.failures, []);
|
|
478
|
+
assert.equal(pc.ok, true, "the lane is not ended by the stale check");
|
|
479
|
+
assert.deepEqual(pc.superseded, [{ id: "V2", files: ["docs/tool.md"] }]);
|
|
480
|
+
});
|
|
481
|
+
it("#4350 a real out-of-scope edit is still scope creep, and the stale validate failure stands", () => {
|
|
482
|
+
const pc = run({ changedFiles: [...changed, "src/other.js"] });
|
|
483
|
+
assert.deepEqual(codes(pc), ["conformance.scope_creep", "conformance.unimplemented"]);
|
|
484
|
+
assert.match(pc.failures[0].detail, /src\/other\.js/);
|
|
485
|
+
assert.deepEqual(pc.superseded, []);
|
|
486
|
+
});
|
|
487
|
+
it("#4350 a failing non-scope validate, a scope check failing with no amended file, and callers without frozen are unchanged", () => {
|
|
488
|
+
const testFail = { ...result, validation: [{ id: "V1", exit: 1 }, { id: "V2", exit: 0 }] };
|
|
489
|
+
assert.deepEqual(codes(run({ result: testFail, validateRuns: { V1: { rc: 1, output: "AssertionError" } } })), ["conformance.unimplemented"]);
|
|
490
|
+
assert.deepEqual(codes(run({ changedFiles: ["src/tool.js", "test/tool.test.js"] })), ["conformance.unimplemented"], "nothing amended in the diff: the validate failed for its own reason");
|
|
491
|
+
const legacy = run({ frozen: undefined });
|
|
492
|
+
assert.deepEqual(codes(legacy), ["conformance.unimplemented"]);
|
|
493
|
+
assert.deepEqual(legacy.superseded, []);
|
|
494
|
+
});
|
|
495
|
+
it("#4350 isScopeCheck recognises file-listing git commands only", () => {
|
|
496
|
+
for (const c of [ALLOW, "git diff --name-status origin/x...HEAD | grep -c .", "git -C pkg diff --stat $(git merge-base HEAD origin/x)", "git log --name-only -1", "test -z \"$(git status --porcelain)\""]) assert.ok(isScopeCheck(c), c);
|
|
497
|
+
for (const c of ["npm test", "git diff --check origin/x...HEAD", "pytest -k name_only", "git diff --exit-code -- src/a.js"]) assert.ok(!isScopeCheck(c), c);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
describe("#4353 the engine's scope gate is the only scope authority: pure scope validates are dropped at freeze", () => {
|
|
502
|
+
const ALLOW = 'test -z "$(git diff --name-only origin/main...HEAD | grep -v -x -e src/tool.js -e test/tool.test.js)"';
|
|
503
|
+
it("#4353 isPureScopeCheck: an allowlist or denylist made only of git listing and filter commands; anything that also runs code is kept", () => {
|
|
504
|
+
for (const c of [ALLOW, "git diff --name-status origin/x...HEAD | grep -c .", "git -C pkg diff --stat $(git merge-base HEAD origin/x)", "! git diff --name-only origin/x...HEAD | grep -qv '^src/'", "test -z \"$(git status --porcelain)\"", "[ \"$(git diff --name-only origin/x...HEAD | wc -l)\" -le 3 ]"]) assert.ok(isPureScopeCheck(c), c);
|
|
505
|
+
for (const c of ["npm test", "node --test x && git diff --stat origin/x...HEAD", "git diff --name-only origin/x...HEAD | xargs node --check", "pytest -k name_only", "git diff --exit-code -- src/a.js", "git diff --name-only HEAD~1 | sh"]) assert.ok(!isPureScopeCheck(c), c);
|
|
506
|
+
});
|
|
507
|
+
it("#4353 dropScopeValidates removes only pure scope validates and reports them; other contracts are unchanged", () => {
|
|
508
|
+
const c = { requirements: [{ id: "R1", quote: Q1, disposition: "deliver" }], deliverables: [{ id: "D1", path: "src/tool.js" }],
|
|
509
|
+
validate: [{ id: "V1", command: "node --test test/tool.test.js" }, { id: "V2", command: ALLOW }, { id: "V3", command: "node --test x && git diff --stat" }] };
|
|
510
|
+
const d = dropScopeValidates(c);
|
|
511
|
+
assert.deepEqual(d.dropped, [{ id: "V2", command: ALLOW }]);
|
|
512
|
+
assert.deepEqual(d.contract.validate.map((v) => v.id), ["V1", "V3"]);
|
|
513
|
+
assert.equal(c.validate.length, 3, "the input is not mutated");
|
|
514
|
+
assert.equal(validateContract(d.contract, `${Q1}`).ok, true, "the dropped contract still freezes");
|
|
515
|
+
const plain = { ...c, validate: [c.validate[0]] };
|
|
516
|
+
assert.equal(dropScopeValidates(plain).contract, plain); assert.deepEqual(dropScopeValidates(plain).dropped, []);
|
|
517
|
+
for (const x of [null, [], "x", { requirements: [] }]) assert.deepEqual(dropScopeValidates(x), { contract: x, dropped: [] });
|
|
518
|
+
});
|
|
519
|
+
it("#4353 a legacy frozen contract that still carries the allowlist keeps the #4350 superseded path", () => {
|
|
520
|
+
const frozen = { requirements: [{ id: "R1", quote: Q1, disposition: "deliver" }], deliverables: [{ id: "D1", path: "src/tool.js" }], validate: [{ id: "V1", command: "node --test test/tool.test.js" }, { id: "V2", command: ALLOW }] };
|
|
521
|
+
const amendments = [{ kind: "add-deliverable", path: "docs/tool.md", why: "module doc" }];
|
|
522
|
+
const pc = precheck({ contract: applyAmendments(frozen, amendments).contract, frozen, changedFiles: ["src/tool.js", "docs/tool.md"], validateRuns: { V2: { rc: 1, output: "" } },
|
|
523
|
+
result: { requirements: [{ id: "R1", status: "implemented" }], validation: [{ id: "V1", exit: 0 }, { id: "V2", exit: 1 }], amendments } });
|
|
524
|
+
assert.equal(pc.ok, true); assert.deepEqual(pc.superseded, [{ id: "V2", files: ["docs/tool.md"] }]);
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
describe("#4404 verified out of band: a requirement measured outside the lane is judged by its instrumentation", () => {
|
|
529
|
+
const LH = "Lighthouse performance score >= 95 on the landing page.";
|
|
530
|
+
const TTI = "Interactive < 1 s on a throttled mid-range Android profile.";
|
|
531
|
+
const NIGHTLY = "The nightly perf suite reports the 1,000-event render time.";
|
|
532
|
+
const OOB_ISSUE = `${ISSUE}\n- [ ] ${LH}\n- [ ] ${TTI}\n- [ ] ${NIGHTLY}\n- [ ] Field perf stays within budget.`;
|
|
533
|
+
const k = { ...C, requirements: [...C.requirements, { id: "R3", quote: LH, disposition: "deliver", evidence: "lighthouse-nightly" }] };
|
|
534
|
+
const tagged = tagOutOfBand(k).contract;
|
|
535
|
+
const oobRow = { id: "R3", status: "partial", evidence: "web/perf/budget.test.js:4", validated_by: "V1", verify: "out_of_band", job: "lighthouse-nightly", instrumented: true };
|
|
536
|
+
const oobCtx = { ...ctx, contract: tagged, changedFiles: [...files, "web/perf/budget.test.js"], issueText: OOB_ISSUE };
|
|
537
|
+
const withRow = (r) => ({ ...good, requirements: [...good.requirements, r], issue_coverage: [...good.issue_coverage, { quote: LH, status: "covered" }] });
|
|
538
|
+
|
|
539
|
+
it("a contract requirement mentioning Lighthouse or field perf is tagged out_of_band at plan time", () => {
|
|
540
|
+
const c = { ...C, requirements: [...C.requirements,
|
|
541
|
+
{ id: "R3", quote: LH, disposition: "deliver" }, { id: "R4", quote: "Field perf stays within budget.", disposition: "deliver" },
|
|
542
|
+
{ id: "R5", quote: TTI, disposition: "deliver" }, { id: "R6", quote: NIGHTLY, disposition: "deliver" },
|
|
543
|
+
{ id: "R7", quote: LH, disposition: "deliver", verify: "in_lane" }, { id: "R8", quote: LH, disposition: "post-deploy", why: "w" }] };
|
|
544
|
+
const t = tagOutOfBand(c);
|
|
545
|
+
assert.deepEqual(t.tagged, ["R3", "R4", "R5", "R6"]);
|
|
546
|
+
assert.deepEqual(t.contract.requirements.filter(isOutOfBand).map((r) => r.id), ["R3", "R4", "R5", "R6"]);
|
|
547
|
+
assert.equal(t.contract.requirements.find((r) => r.id === "R7").verify, "in_lane"); // an explicit verify wins
|
|
548
|
+
assert.equal(t.contract.requirements.find((r) => r.id === "R8").verify, undefined); // only deliver requirements
|
|
549
|
+
assert.equal(c.requirements[2].verify, undefined); // the input contract is not mutated
|
|
550
|
+
assert.equal(tagOutOfBand(C).contract, C); assert.deepEqual(tagOutOfBand(C).tagged, []);
|
|
551
|
+
for (const x of [null, [], "x", {}]) assert.deepEqual(tagOutOfBand(x), { contract: x, tagged: [] });
|
|
552
|
+
assert.deepEqual(VERIFY_MODES, ["in_lane", "out_of_band"]);
|
|
553
|
+
for (const q of [LH, TTI, NIGHTLY, "Field performance holds.", "Web vitals pass.", "A report-only bench workflow tracks it."]) assert.match(q, OUT_OF_BAND_RE, q);
|
|
554
|
+
for (const q of ["The nightly build. Then a suite.", "Add a unit test for the parser.", "Run the job locally."]) assert.doesNotMatch(q, OUT_OF_BAND_RE, q);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("an add-requirement amendment is tagged the same way", () => {
|
|
558
|
+
const a = applyAmendments(C, [{ kind: "add-requirement", requirement: { id: "R9", quote: LH }, why: "missed" }], OOB_ISSUE);
|
|
559
|
+
assert.equal(a.contract.requirements.find((r) => r.id === "R9").verify, "out_of_band");
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("validateContract: an out-of-band requirement names its job; verify takes only known modes and deliver", () => {
|
|
563
|
+
assert.deepEqual(validateContract(tagged, OOB_ISSUE), { ok: true, problems: [] });
|
|
564
|
+
const noJob = tagOutOfBand({ ...C, requirements: [...C.requirements, { id: "R3", quote: LH, disposition: "deliver" }] }).contract;
|
|
565
|
+
assert.match(validateContract(noJob, OOB_ISSUE).problems.join("\n"), /R3: verify out_of_band needs evidence naming the job/);
|
|
566
|
+
const bad = { ...C, requirements: [{ ...C.requirements[0], verify: "later" }, { ...C.requirements[1], disposition: "defer", why: "w", verify: "out_of_band", evidence: "j" }] };
|
|
567
|
+
const p = validateContract(bad, OOB_ISSUE).problems.join("\n");
|
|
568
|
+
assert.match(p, /R1: verify must be one of in_lane\|out_of_band/); assert.match(p, /R2: verify out_of_band applies only to a deliver requirement/);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it("with the instrumentation present the reviewer passes the requirement; without it it is still unimplemented", () => {
|
|
572
|
+
assert.equal(decide(withRow(oobRow), oobCtx).state, "conforms");
|
|
573
|
+
const missing = decide(withRow({ ...oobRow, status: "missing", instrumented: false, evidence: "" }), oobCtx);
|
|
574
|
+
assert.equal(missing.state, "nonconforming"); assert.deepEqual(codes(missing), ["conformance.unimplemented"]);
|
|
575
|
+
assert.match(missing.failures[0].detail, /R3 verified out of band by lighthouse-nightly: instrumentation missing/);
|
|
576
|
+
// instrumented is required on an out-of-band row, and instrumented true needs path:line evidence
|
|
577
|
+
assert.deepEqual(checkCompleteness(withRow({ ...oobRow, instrumented: null }), oobCtx).missing, ["R3"]);
|
|
578
|
+
assert.deepEqual(checkCompleteness(withRow({ ...oobRow, evidence: "" }), oobCtx).missing, ["R3"]);
|
|
579
|
+
// the same partial row on an in-lane requirement still fails (unchanged behaviour)
|
|
580
|
+
const inLane = { ...oobCtx, contract: { ...tagged, requirements: tagged.requirements.map((r) => (r.id === "R3" ? { ...r, verify: "in_lane" } : r)) } };
|
|
581
|
+
assert.deepEqual(codes(decide(withRow(oobRow), inLane)), ["conformance.unimplemented"]);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("precheck: the builder's instrumented out-of-band row is not unimplemented; a bare partial one is", () => {
|
|
585
|
+
const res = { requirements: [{ id: "R1", status: "implemented" }, { id: "R2", status: "implemented" }], validation: [{ id: "V1", exit: 0 }] };
|
|
586
|
+
const ok = { requirements: [...res.requirements, { id: "R3", status: "partial", instrumented: true }], validation: res.validation };
|
|
587
|
+
assert.equal(precheck({ contract: tagged, result: ok, changedFiles: files }).ok, true);
|
|
588
|
+
const no = { requirements: [...res.requirements, { id: "R3", status: "partial" }], validation: res.validation };
|
|
589
|
+
const f = precheck({ contract: tagged, result: no, changedFiles: files }).failures;
|
|
590
|
+
assert.deepEqual(f.map((x) => x.kind), ["requirement_unimplemented"]); assert.match(f[0].detail, /R3 is verified out of band by lighthouse-nightly/);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("the skeleton row carries the job and an empty instrumented; outOfBandRequirements lists the job", () => {
|
|
594
|
+
const sk = reviewSkeleton(tagged, files);
|
|
595
|
+
assert.deepEqual(sk.requirements.find((r) => r.id === "R3"), { id: "R3", status: "", evidence: "", validated_by: "", verify: "out_of_band", job: "lighthouse-nightly", instrumented: null, note: "" });
|
|
596
|
+
assert.equal("instrumented" in sk.requirements[0], false);
|
|
597
|
+
assert.deepEqual(outOfBandRequirements(tagged), [{ id: "R3", quote: LH, job: "lighthouse-nightly" }]);
|
|
598
|
+
});
|
|
599
|
+
});
|
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,14 +108,15 @@ 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
121
|
/** Max referenced issues whose state is read for the cache stamp (#4318: one GraphQL call for all of them). */
|
|
115
122
|
export const LINK_STAMP_MAX = 100;
|
|
@@ -156,10 +163,6 @@ const outcomeOf = (st) => {
|
|
|
156
163
|
return codeOf(c) && c !== "unclassified" ? c : null;
|
|
157
164
|
};
|
|
158
165
|
const failuresOf = (st) => (Array.isArray(st?.failureClasses) ? [...new Set(st.failureClasses.map(codeOf).filter(Boolean))].sort().slice(0, 5) : []);
|
|
159
|
-
const trusted = (c) => {
|
|
160
|
-
const login = String(c?.author?.login ?? c?.user?.login ?? "");
|
|
161
|
-
return isTrustedComment({ author_association: c?.authorAssociation ?? c?.author_association, user: { login, type: c?.user?.type ?? (/\[bot\]$|^app\//.test(login) ? "Bot" : "User") } });
|
|
162
|
-
};
|
|
163
166
|
/** First line of a FORGE record that is not a marker or a heading. */
|
|
164
167
|
const recordLine = (body) => oneLine(noComments(body).split("\n").map((l) => l.trim()).find((l) => l && !/^#+\s/.test(l)) ?? "");
|
|
165
168
|
const markerOf = (body) => /^\s*<!--\s*FORGE:([A-Z_]+)\s*-->/.exec(String(body ?? ""))?.[1] ?? null;
|
|
@@ -174,10 +177,11 @@ async function readComments(gh, repo, n) {
|
|
|
174
177
|
|
|
175
178
|
/**
|
|
176
179
|
* Collect the facts for one issue's pack. `io`: {gh, git, exists}; git runs in `cwd` (the lane worktree), base is the
|
|
177
|
-
* 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.
|
|
178
182
|
*/
|
|
179
|
-
export async function gatherContextFacts({ io, repo, issue, iss, comments = [], cwd, base, sources = CONTEXT_SOURCES, days = DEFAULT_LEDGER_DAYS, now = Date.now() }) {
|
|
180
|
-
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: [] };
|
|
181
185
|
const body = String(iss?.body ?? "");
|
|
182
186
|
const on = new Set(sources);
|
|
183
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)}`); } };
|
|
@@ -230,30 +234,54 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
|
|
|
230
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)` }); }
|
|
231
235
|
});
|
|
232
236
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
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 [];
|
|
237
250
|
const since = new Date(now - days * 86400000).toISOString().slice(0, 10);
|
|
238
251
|
const out = String(await git(["log", "--first-parent", `--since=${since}`, "--format=%x1e%H%x1f%s%x1f%b%x1f", "--name-only", base, "--", ...paths]));
|
|
239
|
-
|
|
252
|
+
return out.split("\x1e").filter((x) => x.trim()).map((rec) => {
|
|
240
253
|
const [sha, subject = "", msg = "", files = ""] = rec.split("\x1f");
|
|
241
254
|
const pr = Number(/Merge pull request #(\d+)/.exec(subject)?.[1] ?? /\(#(\d+)\)\s*$/.exec(subject)?.[1]) || null;
|
|
242
255
|
return { sha: sha.trim(), subject: subject.trim(), msg, pr, files: files.split("\n").map((f) => f.trim()).filter(Boolean),
|
|
243
256
|
revert: /^Revert\b/.test(subject.trim()) || /\/revert-\d+-/.test(subject) || /^This reverts commit [0-9a-f]{7,40}/m.test(msg) };
|
|
244
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;
|
|
245
275
|
const reverts = commits.filter((c) => c.revert);
|
|
246
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))));
|
|
247
|
-
for (const c of
|
|
277
|
+
for (const c of await ledgerPrs()) {
|
|
248
278
|
let title = c.subject, notes = [];
|
|
249
279
|
const reverted = c.revert ? null : revertedBy(c);
|
|
250
280
|
if (c.pr) {
|
|
251
281
|
try {
|
|
252
|
-
const pr =
|
|
282
|
+
const pr = await prInfo(c.pr);
|
|
253
283
|
title = oneLine(pr.title ?? title, 120);
|
|
254
|
-
|
|
255
|
-
const recs = [...(await readComments(io.gh, repo, c.pr)), ...(linked ? await readComments(io.gh, repo, linked) : [])].filter(trusted);
|
|
256
|
-
for (const r of recs) {
|
|
284
|
+
for (const r of pr.recs) {
|
|
257
285
|
const m = markerOf(r.body);
|
|
258
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}`));
|
|
259
287
|
if (m === "CONTRACT") for (const d of parseContractComment(r.body)?.contract?.decisions ?? []) notes.push(oneLine(`decision ${d?.id ?? ""}: ${d?.choice ?? ""}`, 160));
|
|
@@ -269,7 +297,32 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
|
|
|
269
297
|
});
|
|
270
298
|
|
|
271
299
|
await step("facts", async () => {
|
|
272
|
-
|
|
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
|
+
}
|
|
273
326
|
});
|
|
274
327
|
return facts;
|
|
275
328
|
}
|
|
@@ -279,25 +332,33 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
|
|
|
279
332
|
// forge#4297: codes are re-checked here too, so facts read back from the cache get the same whitelist
|
|
280
333
|
const linkLine = (l) => { const fails = (Array.isArray(l.failures) ? l.failures : []).map(codeOf).filter(Boolean), res = codeOf(l.result);
|
|
281
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}×`;
|
|
282
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("; ")}` : ""}`;
|
|
283
337
|
|
|
284
338
|
/**
|
|
285
339
|
* The pack text for `facts`, ranked: explicit links (parent > Depends on > body links > cross-references > siblings),
|
|
286
|
-
* 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
|
|
287
341
|
* carries its full body when it fits. Items that do not fit `budgetBytes` are dropped whole and counted in `truncated`.
|
|
288
|
-
*
|
|
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[]}}
|
|
289
344
|
*/
|
|
290
345
|
export function compileContextPack(facts, { budgetBytes = DEFAULT_CONTEXT_BYTES, sources = CONTEXT_SOURCES } = {}) {
|
|
291
346
|
const on = new Set(sources);
|
|
292
347
|
const cand = [];
|
|
293
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 });
|
|
294
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 } });
|
|
295
351
|
if (on.has("ledger")) for (const p of facts?.ledger ?? []) cand.push({ src: "ledger", line: ledgerLine(p) });
|
|
296
|
-
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
|
+
}
|
|
297
358
|
const found = Object.fromEntries(CONTEXT_SOURCES.map((s) => [s, cand.filter((c) => c.src === s).length]));
|
|
298
|
-
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: [] };
|
|
299
360
|
if (!cand.length) return empty;
|
|
300
|
-
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" };
|
|
301
362
|
const head = `Context pack for #${facts.issue}`;
|
|
302
363
|
const size = (s) => Buffer.byteLength(s, "utf8");
|
|
303
364
|
const kept = [];
|
|
@@ -306,7 +367,7 @@ export function compileContextPack(facts, { budgetBytes = DEFAULT_CONTEXT_BYTES,
|
|
|
306
367
|
const secCost = kept.some((k) => k.src === c.src) ? 0 : size(`\n${titles[c.src]}:`) + 1;
|
|
307
368
|
const pick = [c.full, c.line].find((t) => t && used + secCost + size(t) + 1 <= budgetBytes);
|
|
308
369
|
if (!pick) { truncated++; continue; }
|
|
309
|
-
kept.push({ src: c.src, text: pick });
|
|
370
|
+
kept.push({ src: c.src, text: pick, pit: c.pit });
|
|
310
371
|
used += secCost + size(pick) + 1;
|
|
311
372
|
}
|
|
312
373
|
if (!kept.length) return { ...empty, truncated };
|
|
@@ -316,13 +377,25 @@ export function compileContextPack(facts, { budgetBytes = DEFAULT_CONTEXT_BYTES,
|
|
|
316
377
|
if (rows.length) parts.push(`\n${titles[s]}:`, ...rows.map((r) => r.text));
|
|
317
378
|
}
|
|
318
379
|
const text = parts.join("\n");
|
|
319
|
-
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();
|
|
320
393
|
}
|
|
321
394
|
|
|
322
|
-
/** 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). */
|
|
323
396
|
export function renderContextRecord(pack, { mode, laneType: lt }) {
|
|
324
|
-
const src = CONTEXT_SOURCES.map((s) => `${s} ${pack.sources[s]}/${pack.found[s]}`).join(", ");
|
|
325
|
-
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>`;
|
|
326
399
|
}
|
|
327
400
|
|
|
328
401
|
/**
|
|
@@ -331,19 +404,19 @@ export function renderContextRecord(pack, { mode, laneType: lt }) {
|
|
|
331
404
|
* reported source errors is not cached (#4298), the write goes through a temp file and a rename, and the issue's other
|
|
332
405
|
* cached packs are pruned. A cache miss or any write/rename/prune failure is not an error.
|
|
333
406
|
*/
|
|
334
|
-
export async function contextPackFor({ cfg, issue, headSha, cacheDir, fs, gather, stamp = {} }) {
|
|
335
|
-
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;
|
|
336
409
|
const file = name ? join(cacheDir, name) : null;
|
|
337
410
|
if (file && fs?.read) {
|
|
338
411
|
try { const c = JSON.parse(await fs.read(file)); if (c?.pack && typeof c.pack.text === "string") return { ...c, cached: true }; } catch { /* miss */ }
|
|
339
412
|
}
|
|
340
413
|
const facts = await gather();
|
|
341
414
|
const out = { pack: compileContextPack(facts, cfg), errors: facts?.errors ?? [] };
|
|
342
|
-
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));
|
|
343
416
|
return { ...out, cached: false };
|
|
344
417
|
}
|
|
345
418
|
|
|
346
|
-
async function cachePack(fs, cacheDir, file, name,
|
|
419
|
+
async function cachePack(fs, cacheDir, file, name, key, text) {
|
|
347
420
|
try {
|
|
348
421
|
if (typeof fs.rename === "function") {
|
|
349
422
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
@@ -353,7 +426,7 @@ async function cachePack(fs, cacheDir, file, name, issue, text) {
|
|
|
353
426
|
} catch { return; /* cache is optional */ }
|
|
354
427
|
if (typeof fs.list !== "function" || typeof fs.rm !== "function") return;
|
|
355
428
|
try {
|
|
356
|
-
const prefix = `context-${
|
|
429
|
+
const prefix = `context-${key}-`;
|
|
357
430
|
for (const f of await fs.list(cacheDir)) {
|
|
358
431
|
if (f !== name && f.startsWith(prefix) && f.endsWith(".json")) { try { await fs.rm(join(cacheDir, f)); } catch { /* best effort */ } }
|
|
359
432
|
}
|