forgedock 1.30.0 → 1.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +3 -2
- package/bin/engine/runlog.mjs +82 -12
- package/bin/engine/telemetry-seam.mjs +11 -4
- package/bin/engine/telemetry-seam.test.mjs +18 -0
- package/bin/engine-cli.mjs +409 -38
- package/bin/engine.mjs +47 -37
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/hooks/reread-nudge.mjs +65 -0
- package/bin/labels.json +5 -0
- package/bin/lean/apishape.mjs +170 -0
- package/bin/lean/apishape.test.mjs +187 -0
- package/bin/lean/batch.mjs +32 -0
- package/bin/lean/batch.test.mjs +338 -2
- package/bin/lean/budget.mjs +89 -0
- package/bin/lean/budget.test.mjs +106 -0
- package/bin/lean/checks.mjs +32 -9
- package/bin/lean/checks.test.mjs +78 -1
- package/bin/lean/conformance.e2e.test.mjs +27 -5
- package/bin/lean/conformance.mjs +150 -15
- package/bin/lean/conformance.test.mjs +175 -3
- package/bin/lean/context.mjs +128 -44
- package/bin/lean/context.test.mjs +157 -5
- package/bin/lean/decompose.mjs +90 -0
- package/bin/lean/decompose.test.mjs +105 -0
- package/bin/lean/depcache.mjs +11 -7
- package/bin/lean/depcache.test.mjs +81 -0
- package/bin/lean/e2e.mjs +102 -0
- package/bin/lean/e2e.test.mjs +86 -0
- package/bin/lean/effects.mjs +48 -15
- package/bin/lean/explore.mjs +47 -0
- package/bin/lean/explore.test.mjs +61 -0
- package/bin/lean/facts.mjs +84 -0
- package/bin/lean/facts.test.mjs +66 -0
- package/bin/lean/fixround.mjs +90 -0
- package/bin/lean/fixround.test.mjs +117 -0
- package/bin/lean/heavy.mjs +35 -2
- package/bin/lean/heavy.test.mjs +32 -2
- package/bin/lean/lean.mjs +712 -134
- package/bin/lean/lean.test.mjs +1626 -37
- package/bin/lean/ledger.mjs +5 -3
- package/bin/lean/ledger.test.mjs +18 -0
- package/bin/lean/light.mjs +135 -0
- package/bin/lean/light.test.mjs +173 -0
- package/bin/lean/lightlane.mjs +219 -0
- package/bin/lean/lightlane.test.mjs +237 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +303 -41
- package/bin/lean/pitfalls.mjs +136 -0
- package/bin/lean/pitfalls.test.mjs +117 -0
- package/bin/lean/postmerge.mjs +85 -0
- package/bin/lean/postmerge.test.mjs +79 -0
- package/bin/lean/precheck.mjs +138 -13
- package/bin/lean/precheck.test.mjs +118 -1
- package/bin/lean/prefilter.mjs +220 -5
- package/bin/lean/prefilter.test.mjs +260 -4
- package/bin/lean/preflight.mjs +108 -3
- package/bin/lean/preflight.test.mjs +48 -1
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +104 -13
- package/bin/lean/render.mjs +7 -3
- package/bin/lean/resume.mjs +67 -0
- package/bin/lean/resume.test.mjs +84 -0
- package/bin/lean/review-trigger.mjs +12 -0
- package/bin/lean/review-trigger.test.mjs +17 -1
- package/bin/lean/routing.mjs +74 -0
- package/bin/lean/routing.test.mjs +67 -0
- package/bin/lean/sched.mjs +16 -1
- package/bin/lean/sched.test.mjs +175 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +170 -3
- package/bin/lean/selfreview.mjs +37 -0
- package/bin/lean/selfreview.test.mjs +51 -0
- package/bin/lean/seqlease.mjs +40 -0
- package/bin/lean/seqlease.test.mjs +101 -0
- package/bin/lean/sequence.mjs +296 -13
- package/bin/lean/sequence.test.mjs +263 -2
- package/bin/lean/toolchain.mjs +60 -15
- package/bin/lean/toolchain.test.mjs +120 -3
- package/bin/lean/toolstats.mjs +72 -0
- package/bin/lean/toolstats.test.mjs +47 -0
- package/bin/lean/verifier.mjs +232 -0
- package/bin/lean/verifier.test.mjs +163 -0
- package/bin/lean/verify.mjs +72 -10
- package/bin/lean/verify.test.mjs +68 -3
- package/bin/review-staging.mjs +533 -0
- package/bin/runner.mjs +82 -13
- package/bin/stats/aggregate.mjs +87 -2
- package/bin/stats/efficiency.mjs +335 -0
- package/bin/stats/efficiency.test.mjs +266 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +91 -6
- package/bin/stats/local-source.mjs +145 -8
- package/bin/stats/query.mjs +181 -3
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +403 -8
- package/commands/orchestrate.md +3 -1
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-staging.md +84 -831
- package/commands/review-pr.md +1 -1
- package/commands/work-on/build/implement.md +1 -0
- package/commands/work-on/build/validate.md +11 -1
- package/docs/CONFIG.md +80 -5
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/STATS.md +66 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/eval/corpus/lightlane-v1.json +55 -0
- package/docs/eval/lightlane-bench.md +91 -0
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +286 -0
- package/scripts/ci-local/lib.mjs +20 -0
- package/scripts/ci-local/plan.mjs +2 -12
- package/scripts/ci-local/scoping.mjs +10 -1
- package/scripts/ci-local.test.sh +2 -0
- package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
- package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
- package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
- package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
- package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
- package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
- package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
- package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
- package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
- package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
- package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
- package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
- package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
- package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
- package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
- package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
- package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
- package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
- package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
- package/scripts/forge-root.test.sh +2 -2
- package/scripts/publish-staging-sync.test.sh +62 -4
- package/scripts/review-finalize.sh +17 -6
- package/scripts/review-finalize.test.sh +47 -0
- package/scripts/review-pr-arguments.test.sh +13 -1
- package/scripts/review-pr-delta-wiring.test.sh +2 -2
- package/scripts/review-pr-gate2.test.sh +6 -14
- package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
- package/scripts/review-pr-staging-scope.test.sh +39 -33
- package/scripts/review-prepare.sh +73 -6
- package/scripts/review-prepare.test.sh +85 -0
- package/scripts/review-staging-post.sh +293 -0
- package/scripts/review-staging-post.test.sh +143 -0
- package/scripts/review-staging-pre.sh +401 -0
- package/scripts/review-staging-pre.test.sh +150 -0
- package/scripts/symbol-index.mjs +26 -7
- package/scripts/symbol-index.py +55 -9
- package/scripts/trusted-comments.test.sh +1 -1
|
@@ -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,29 @@
|
|
|
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
|
-
|
|
23
|
-
export const
|
|
24
|
+
/** #4444: `ab` is the experiment mode: each issue gets a stable arm, `on` or `shadow` (contextArm). */
|
|
25
|
+
export const CONTEXT_MODES = Object.freeze(["shadow", "on", "off", "ab"]);
|
|
26
|
+
export const CONTEXT_SOURCES = Object.freeze(["graph", "attempts", "pitfalls", "ledger", "facts"]);
|
|
24
27
|
export const DEFAULT_CONTEXT_BYTES = 6144;
|
|
25
28
|
export const DEFAULT_LEDGER_DAYS = 30;
|
|
29
|
+
/** #4375: a pitfall not seen in this many lanes of the repo ages out (forge.yaml engine.context_pack_pitfall_lanes). */
|
|
30
|
+
export const DEFAULT_PITFALL_LANES = 50;
|
|
26
31
|
/** Bounds on GitHub reads per pack: linked issues read, ledger PRs inspected. */
|
|
27
32
|
export const MAX_LINKS = 20;
|
|
28
33
|
export const MAX_LEDGER_PRS = 8;
|
|
34
|
+
/** #4401: FORGE:FACTS records (earlier lanes) read into one pack: this issue's own attempts first, then the file-path ledger. */
|
|
35
|
+
export const MAX_FACT_LANES = 6;
|
|
29
36
|
export const REPO_FACT_FILES = Object.freeze([REPO_FACTS_FILE, TEST_MAP_FILE]);
|
|
30
37
|
const ATTEMPT_MARKERS = ["NEEDS_HUMAN", "BASE_SYNC", "FIX_CI"];
|
|
31
38
|
const LINE_MAX = 200;
|
|
@@ -38,13 +45,23 @@ const oneLine = (s, max = LINE_MAX) => {
|
|
|
38
45
|
const stripComments = (s) => noComments(s).replace(/\r\n/g, "\n").trim();
|
|
39
46
|
const byNum = (a, b) => a - b;
|
|
40
47
|
|
|
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 } = {}) {
|
|
48
|
+
/** forge.yaml engine.context_pack (+ _sources, _bytes, _days, _pitfall_lanes) normalised; invalid values fall back to the defaults. */
|
|
49
|
+
export function parseContextPackConfig({ mode, sources, bytes, days, pitfallLanes } = {}) {
|
|
43
50
|
const m = String(mode ?? "").trim().toLowerCase();
|
|
44
51
|
const list = Array.isArray(sources) ? sources : typeof sources === "string" && sources.trim() ? sources.split(/[\s,]+/) : null;
|
|
45
52
|
const picked = list ? CONTEXT_SOURCES.filter((s) => list.map((x) => String(x).trim().toLowerCase()).includes(s)) : [...CONTEXT_SOURCES];
|
|
46
53
|
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) };
|
|
54
|
+
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) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* #4444: the effective mode of one lane. In `ab` the arm comes from a stable hash of `repo#issue`, so every rerun of an
|
|
59
|
+
* issue stays in its arm and the arms are random, matched cohorts per lane type; any other mode passes through.
|
|
60
|
+
*/
|
|
61
|
+
export function contextArm({ mode, repo = "", issue } = {}) {
|
|
62
|
+
if (mode !== "ab") return { mode, ab: false };
|
|
63
|
+
const byte = createHash("sha256").update(`${String(repo).toLowerCase()}#${issue}`).digest()[0];
|
|
64
|
+
return { mode: byte % 2 === 0 ? "on" : "shadow", ab: true };
|
|
48
65
|
}
|
|
49
66
|
|
|
50
67
|
/** `**Parent**: #N` (decomposition sub-issue body line); null when absent. */
|
|
@@ -102,14 +119,15 @@ export function priorAttempts(body, comments = []) {
|
|
|
102
119
|
}
|
|
103
120
|
|
|
104
121
|
/**
|
|
105
|
-
* Cache key for a compiled pack: issue + base head SHA, plus the config that shapes it and (#4298, #4304) the hash of the
|
|
122
|
+
* 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
123
|
* issue's human scope text (`scope`) and prior-attempt count, so an edit or a new attempt on the same base compiles a
|
|
107
124
|
* fresh pack. Not `updatedAt`: the lane's own FORGE:STATE/CONTEXT posts and label edits bump it, so the cache never hit.
|
|
125
|
+
* #4375: `stamp.pitfalls` (the selected pitfall signatures) joins the key, so a newly learned pitfall is never served stale.
|
|
108
126
|
*/
|
|
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,
|
|
127
|
+
export const contextPackCacheKey = (issue, headSha, cfg = {}, stamp = {}, repo = null) =>
|
|
128
|
+
`context-${runLogKey(issue, repo)}-${String(headSha ?? "").trim().slice(0, 40) || "nohead"}-${hash12(JSON.stringify([cfg.sources ?? [], cfg.budgetBytes ?? null, cfg.days ?? null,
|
|
111
129
|
...(stamp?.scope != null || stamp?.attempts != null ? [String(stamp.scope ?? ""), Number(stamp.attempts) || 0] : []),
|
|
112
|
-
...(stamp?.links ? [`links:${stamp.links}`] : [])]))}.json`;
|
|
130
|
+
...(stamp?.links ? [`links:${stamp.links}`] : []), ...(stamp?.pitfalls?.length ? [`pitfalls:${stamp.pitfalls.join(",")}`] : [])]))}.json`;
|
|
113
131
|
|
|
114
132
|
/** Max referenced issues whose state is read for the cache stamp (#4318: one GraphQL call for all of them). */
|
|
115
133
|
export const LINK_STAMP_MAX = 100;
|
|
@@ -156,10 +174,6 @@ const outcomeOf = (st) => {
|
|
|
156
174
|
return codeOf(c) && c !== "unclassified" ? c : null;
|
|
157
175
|
};
|
|
158
176
|
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
177
|
/** First line of a FORGE record that is not a marker or a heading. */
|
|
164
178
|
const recordLine = (body) => oneLine(noComments(body).split("\n").map((l) => l.trim()).find((l) => l && !/^#+\s/.test(l)) ?? "");
|
|
165
179
|
const markerOf = (body) => /^\s*<!--\s*FORGE:([A-Z_]+)\s*-->/.exec(String(body ?? ""))?.[1] ?? null;
|
|
@@ -174,10 +188,11 @@ async function readComments(gh, repo, n) {
|
|
|
174
188
|
|
|
175
189
|
/**
|
|
176
190
|
* 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
|
|
191
|
+
* base ref; `checkout` (the main checkout, optional) is where repo-fact files absent from the worktree are looked up.
|
|
192
|
+
* Every source is independent and best effort: a failing source is listed in `errors` and contributes nothing.
|
|
178
193
|
*/
|
|
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: [] };
|
|
194
|
+
export async function gatherContextFacts({ io, repo, issue, iss, comments = [], cwd, checkout = null, base, sources = CONTEXT_SOURCES, days = DEFAULT_LEDGER_DAYS, now = Date.now(), pitfalls = [] }) {
|
|
195
|
+
const facts = { issue: Number(issue), links: [], attempts: [], pitfalls: [], ledger: [], facts: [], lanes: [], errors: [] };
|
|
181
196
|
const body = String(iss?.body ?? "");
|
|
182
197
|
const on = new Set(sources);
|
|
183
198
|
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 +245,54 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
|
|
|
230
245
|
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
246
|
});
|
|
232
247
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
248
|
+
// #4375: already read and selected by the engine from the local ledger (pitfalls.mjs selectPitfalls); no I/O here
|
|
249
|
+
await step("pitfalls", async () => {
|
|
250
|
+
for (const p of Array.isArray(pitfalls) ? pitfalls : []) {
|
|
251
|
+
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" });
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// The file-path ledger walk (merged PRs on the issue's named paths) and each PR's trusted records, shared by the ledger
|
|
256
|
+
// and facts sources (#4401): run once per gather, and only when a source that needs them is on.
|
|
257
|
+
const paths = namedPaths(body);
|
|
258
|
+
let walked = null;
|
|
259
|
+
const walk = () => (walked ??= (async () => {
|
|
260
|
+
if (!paths.length || !base) return [];
|
|
237
261
|
const since = new Date(now - days * 86400000).toISOString().slice(0, 10);
|
|
238
262
|
const out = String(await git(["log", "--first-parent", `--since=${since}`, "--format=%x1e%H%x1f%s%x1f%b%x1f", "--name-only", base, "--", ...paths]));
|
|
239
|
-
|
|
263
|
+
return out.split("\x1e").filter((x) => x.trim()).map((rec) => {
|
|
240
264
|
const [sha, subject = "", msg = "", files = ""] = rec.split("\x1f");
|
|
241
265
|
const pr = Number(/Merge pull request #(\d+)/.exec(subject)?.[1] ?? /\(#(\d+)\)\s*$/.exec(subject)?.[1]) || null;
|
|
242
266
|
return { sha: sha.trim(), subject: subject.trim(), msg, pr, files: files.split("\n").map((f) => f.trim()).filter(Boolean),
|
|
243
267
|
revert: /^Revert\b/.test(subject.trim()) || /\/revert-\d+-/.test(subject) || /^This reverts commit [0-9a-f]{7,40}/m.test(msg) };
|
|
244
268
|
});
|
|
269
|
+
})());
|
|
270
|
+
const prReads = new Map();
|
|
271
|
+
const prInfo = (n) => {
|
|
272
|
+
if (!prReads.has(n)) prReads.set(n, (async () => {
|
|
273
|
+
const pr = JSON.parse(String(await io.gh(["api", `repos/${repo}/pulls/${n}`, "--jq", "{title, body, head: .head.ref}"])));
|
|
274
|
+
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;
|
|
275
|
+
const recs = [...(await readComments(io.gh, repo, n)), ...(linked ? await readComments(io.gh, repo, linked) : [])].filter(trusted);
|
|
276
|
+
return { title: pr.title, linked, recs };
|
|
277
|
+
})());
|
|
278
|
+
return prReads.get(n);
|
|
279
|
+
};
|
|
280
|
+
const ledgerPrs = async () => (await walk()).filter((x) => x.pr || x.revert).slice(0, MAX_LEDGER_PRS);
|
|
281
|
+
|
|
282
|
+
await step("ledger", async () => {
|
|
283
|
+
const symbols = issueSymbols(body);
|
|
284
|
+
const commits = await walk();
|
|
285
|
+
if (!commits.length) return;
|
|
245
286
|
const reverts = commits.filter((c) => c.revert);
|
|
246
287
|
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
|
|
288
|
+
for (const c of await ledgerPrs()) {
|
|
248
289
|
let title = c.subject, notes = [];
|
|
249
290
|
const reverted = c.revert ? null : revertedBy(c);
|
|
250
291
|
if (c.pr) {
|
|
251
292
|
try {
|
|
252
|
-
const pr =
|
|
293
|
+
const pr = await prInfo(c.pr);
|
|
253
294
|
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) {
|
|
295
|
+
for (const r of pr.recs) {
|
|
257
296
|
const m = markerOf(r.body);
|
|
258
297
|
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
298
|
if (m === "CONTRACT") for (const d of parseContractComment(r.body)?.contract?.decisions ?? []) notes.push(oneLine(`decision ${d?.id ?? ""}: ${d?.choice ?? ""}`, 160));
|
|
@@ -269,7 +308,32 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
|
|
|
269
308
|
});
|
|
270
309
|
|
|
271
310
|
await step("facts", async () => {
|
|
272
|
-
|
|
311
|
+
// #4401: FORGE:FACTS of earlier lanes: this issue's own attempts (newest first), then merged PRs on the issue's files
|
|
312
|
+
// whose record names an overlapping file. Only trusted comments; a malformed record contributes nothing.
|
|
313
|
+
const seen = new Set();
|
|
314
|
+
const add = (rec, ref, n, pr) => {
|
|
315
|
+
const key = JSON.stringify(rec);
|
|
316
|
+
if (!rec || seen.has(key) || facts.lanes.length >= MAX_FACT_LANES) return;
|
|
317
|
+
seen.add(key);
|
|
318
|
+
facts.lanes.push({ ref, issue: n, pr, ...rec });
|
|
319
|
+
};
|
|
320
|
+
for (const c of [...comments].reverse()) if (trusted(c)) add(parseFactsComment(c?.body), `#${issue} earlier attempt`, Number(issue), null);
|
|
321
|
+
const overlaps = (files) => files.some((f) => paths.some((p) => f === p || f.startsWith(`${p.replace(/\/+$/, "")}/`) || p.startsWith(`${f}/`)));
|
|
322
|
+
let prs = [];
|
|
323
|
+
try { prs = await ledgerPrs(); } catch (e) { facts.errors.push(`facts: ledger ${oneLine(e?.message ?? e, 80)}`); }
|
|
324
|
+
for (const c of prs) {
|
|
325
|
+
if (!c.pr || facts.lanes.length >= MAX_FACT_LANES) continue;
|
|
326
|
+
let pr;
|
|
327
|
+
try { pr = await prInfo(c.pr); } catch (e) { facts.errors.push(`facts: PR #${c.pr} ${oneLine(e?.message ?? e, 80)}`); continue; }
|
|
328
|
+
for (const r of [...pr.recs].reverse()) {
|
|
329
|
+
const rec = parseFactsComment(r.body);
|
|
330
|
+
if (rec && overlaps(rec.files)) add(rec, `${pr.linked ? `#${pr.linked}, ` : ""}PR #${c.pr}`, pr.linked, c.pr);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// #4400: the .forge/ caches are untracked, so a fresh lane worktree lacks them: the main checkout is the fallback
|
|
334
|
+
for (const p of REPO_FACT_FILES) {
|
|
335
|
+
for (const d of [...new Set([cwd, checkout].filter(Boolean))]) if (await io.exists(`${d}/${p}`)) { facts.facts.push(p); break; }
|
|
336
|
+
}
|
|
273
337
|
});
|
|
274
338
|
return facts;
|
|
275
339
|
}
|
|
@@ -279,25 +343,33 @@ export async function gatherContextFacts({ io, repo, issue, iss, comments = [],
|
|
|
279
343
|
// forge#4297: codes are re-checked here too, so facts read back from the cache get the same whitelist
|
|
280
344
|
const linkLine = (l) => { const fails = (Array.isArray(l.failures) ? l.failures : []).map(codeOf).filter(Boolean), res = codeOf(l.result);
|
|
281
345
|
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(", ")}` : ""}`; };
|
|
346
|
+
const pitfallLine = (p) => `- [${p.trigger}] ${oneLine(p.lesson, 160)}${p.globs?.length ? ` (${p.globs.join(", ")})` : ""}; seen ${p.count}×`;
|
|
282
347
|
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
348
|
|
|
284
349
|
/**
|
|
285
350
|
* 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
|
|
351
|
+
* 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
352
|
* carries its full body when it fits. Items that do not fit `budgetBytes` are dropped whole and counted in `truncated`.
|
|
288
|
-
*
|
|
353
|
+
* `pitfalls` lists the kept pitfalls ({sig, match}) for the run log (#4375).
|
|
354
|
+
* @returns {{text:string, bytes:number, items:number, truncated:number, sources:object, found:object, hash:string|null, pitfalls:object[]}}
|
|
289
355
|
*/
|
|
290
356
|
export function compileContextPack(facts, { budgetBytes = DEFAULT_CONTEXT_BYTES, sources = CONTEXT_SOURCES } = {}) {
|
|
291
357
|
const on = new Set(sources);
|
|
292
358
|
const cand = [];
|
|
293
359
|
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
360
|
if (on.has("attempts")) for (const a of facts?.attempts ?? []) cand.push({ src: "attempts", line: `- ${a.ref}: ${oneLine(a.line)}` });
|
|
361
|
+
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
362
|
if (on.has("ledger")) for (const p of facts?.ledger ?? []) cand.push({ src: "ledger", line: ledgerLine(p) });
|
|
296
|
-
if (on.has("facts"))
|
|
363
|
+
if (on.has("facts")) {
|
|
364
|
+
// #4401: earlier lanes' facts by kind (rejected approaches first, across lanes), then the repo-fact file pointers
|
|
365
|
+
const lanes = (facts?.lanes ?? []).map((l) => factsLines(l, l.ref));
|
|
366
|
+
for (const k of FACTS_RANK) for (const l of lanes) for (const line of l[k]) cand.push({ src: "facts", line: oneLine(line) });
|
|
367
|
+
for (const f of facts?.facts ?? []) cand.push({ src: "facts", line: `- ${f} (repo facts file; read it if relevant)` });
|
|
368
|
+
}
|
|
297
369
|
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 };
|
|
370
|
+
const empty = { text: "", bytes: 0, items: 0, truncated: 0, sources: Object.fromEntries(CONTEXT_SOURCES.map((s) => [s, 0])), found, hash: null, pitfalls: [] };
|
|
299
371
|
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: "
|
|
372
|
+
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
373
|
const head = `Context pack for #${facts.issue}`;
|
|
302
374
|
const size = (s) => Buffer.byteLength(s, "utf8");
|
|
303
375
|
const kept = [];
|
|
@@ -306,7 +378,7 @@ export function compileContextPack(facts, { budgetBytes = DEFAULT_CONTEXT_BYTES,
|
|
|
306
378
|
const secCost = kept.some((k) => k.src === c.src) ? 0 : size(`\n${titles[c.src]}:`) + 1;
|
|
307
379
|
const pick = [c.full, c.line].find((t) => t && used + secCost + size(t) + 1 <= budgetBytes);
|
|
308
380
|
if (!pick) { truncated++; continue; }
|
|
309
|
-
kept.push({ src: c.src, text: pick });
|
|
381
|
+
kept.push({ src: c.src, text: pick, pit: c.pit });
|
|
310
382
|
used += secCost + size(pick) + 1;
|
|
311
383
|
}
|
|
312
384
|
if (!kept.length) return { ...empty, truncated };
|
|
@@ -316,13 +388,25 @@ export function compileContextPack(facts, { budgetBytes = DEFAULT_CONTEXT_BYTES,
|
|
|
316
388
|
if (rows.length) parts.push(`\n${titles[s]}:`, ...rows.map((r) => r.text));
|
|
317
389
|
}
|
|
318
390
|
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) };
|
|
391
|
+
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) };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** The pack text without its pitfalls section (#4375: pitfalls are local only and never posted). */
|
|
395
|
+
export function publicPackText(text) {
|
|
396
|
+
const out = [];
|
|
397
|
+
let skip = false;
|
|
398
|
+
for (const l of String(text ?? "").split("\n")) {
|
|
399
|
+
if (/^[^\s-].*:$/.test(l)) skip = l === "Recurring pitfalls in this repo (past rework):";
|
|
400
|
+
else if (skip && l === "") skip = false;
|
|
401
|
+
if (!skip) out.push(l);
|
|
402
|
+
}
|
|
403
|
+
return out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
320
404
|
}
|
|
321
405
|
|
|
322
|
-
/** The collapsed FORGE:CONTEXT issue record body (without the marker). */
|
|
406
|
+
/** The collapsed FORGE:CONTEXT issue record body (without the marker); pitfalls show as a count only (#4375). */
|
|
323
407
|
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>`;
|
|
408
|
+
const src = CONTEXT_SOURCES.map((s) => `${s} ${pack.sources?.[s] ?? 0}/${pack.found?.[s] ?? 0}`).join(", ");
|
|
409
|
+
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
410
|
}
|
|
327
411
|
|
|
328
412
|
/**
|
|
@@ -331,19 +415,19 @@ export function renderContextRecord(pack, { mode, laneType: lt }) {
|
|
|
331
415
|
* reported source errors is not cached (#4298), the write goes through a temp file and a rename, and the issue's other
|
|
332
416
|
* cached packs are pruned. A cache miss or any write/rename/prune failure is not an error.
|
|
333
417
|
*/
|
|
334
|
-
export async function contextPackFor({ cfg, issue, headSha, cacheDir, fs, gather, stamp = {} }) {
|
|
335
|
-
const name = cacheDir && headSha ? contextPackCacheKey(issue, headSha, cfg, stamp) : null;
|
|
418
|
+
export async function contextPackFor({ cfg, issue, repo = null, headSha, cacheDir, fs, gather, stamp = {} }) {
|
|
419
|
+
const name = cacheDir && headSha ? contextPackCacheKey(issue, headSha, cfg, stamp, repo) : null;
|
|
336
420
|
const file = name ? join(cacheDir, name) : null;
|
|
337
421
|
if (file && fs?.read) {
|
|
338
422
|
try { const c = JSON.parse(await fs.read(file)); if (c?.pack && typeof c.pack.text === "string") return { ...c, cached: true }; } catch { /* miss */ }
|
|
339
423
|
}
|
|
340
424
|
const facts = await gather();
|
|
341
425
|
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));
|
|
426
|
+
if (file && fs?.write && !out.errors.length) await cachePack(fs, cacheDir, file, name, runLogKey(issue, repo), JSON.stringify(out));
|
|
343
427
|
return { ...out, cached: false };
|
|
344
428
|
}
|
|
345
429
|
|
|
346
|
-
async function cachePack(fs, cacheDir, file, name,
|
|
430
|
+
async function cachePack(fs, cacheDir, file, name, key, text) {
|
|
347
431
|
try {
|
|
348
432
|
if (typeof fs.rename === "function") {
|
|
349
433
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
@@ -353,7 +437,7 @@ async function cachePack(fs, cacheDir, file, name, issue, text) {
|
|
|
353
437
|
} catch { return; /* cache is optional */ }
|
|
354
438
|
if (typeof fs.list !== "function" || typeof fs.rm !== "function") return;
|
|
355
439
|
try {
|
|
356
|
-
const prefix = `context-${
|
|
440
|
+
const prefix = `context-${key}-`;
|
|
357
441
|
for (const f of await fs.list(cacheDir)) {
|
|
358
442
|
if (f !== name && f.startsWith(prefix) && f.endsWith(".json")) { try { await fs.rm(join(cacheDir, f)); } catch { /* best effort */ } }
|
|
359
443
|
}
|