diff --git a/src/signals/focus-manifest.ts b/src/signals/focus-manifest.ts index 60ad0a6d..b0f64d60 100644 --- a/src/signals/focus-manifest.ts +++ b/src/signals/focus-manifest.ts @@ -361,6 +361,130 @@ function summarize(manifest: FocusManifest, blocked: string[], wanted: string[]) return "Maintainer focus manifest applied with no path-specific verdict."; } +// ─── Contribution Lanes ────────────────────────────────────────────────────── + +export type ContributionLanePreference = "preferred" | "neutral" | "discouraged"; + +/** + * Standalone contribution lane summary derived from a focus manifest. + * Does not require a specific change set — describes the general contribution + * policy the maintainer has declared so contributors and repo owners can plan + * their work before touching any files. + */ +export type ContributionLanes = { + present: boolean; + source: FocusManifestSource; + directPrLane: ContributionLanePreference; + issueDiscoveryLane: ContributionLanePreference; + preferredEntryPaths: string[]; + discouragedEntryPaths: string[]; + validationExpectations: string[]; + issueEntryGuidance: string[]; + prEntryGuidance: string[]; + warnings: string[]; + summary: string; +}; + +/** + * Derive contribution lanes from a focus manifest without requiring a specific + * change set. The result is deterministic, explainable, and public-safe: no + * maintainer-private notes, scoreability, reviewability, reward/risk, wallet, + * hotkey, or raw trust context appears in any output field. + */ +export function deriveContributionLanes(manifest: FocusManifest): ContributionLanes { + if (!manifest.present) { + return { + present: false, + source: manifest.source, + directPrLane: "neutral", + issueDiscoveryLane: "neutral", + preferredEntryPaths: [], + discouragedEntryPaths: [], + validationExpectations: [], + issueEntryGuidance: [], + prEntryGuidance: [], + warnings: manifest.warnings, + summary: "No maintainer focus manifest; contribution lanes are not constrained.", + }; + } + + const directPrLane = deriveDirectPrLane(manifest); + const issueDiscoveryLane = deriveIssueDiscoveryLane(manifest); + const validationExpectations = deriveValidationExpectations(manifest); + const issueEntryGuidance = deriveIssueEntryGuidance(manifest); + const prEntryGuidance = derivePrEntryGuidance(manifest); + + return { + present: true, + source: manifest.source, + directPrLane, + issueDiscoveryLane, + preferredEntryPaths: manifest.wantedPaths.filter(isFocusManifestPublicSafe), + discouragedEntryPaths: manifest.blockedPaths.filter(isFocusManifestPublicSafe), + validationExpectations, + issueEntryGuidance, + prEntryGuidance, + warnings: manifest.warnings, + summary: lanesSummary(manifest, directPrLane, issueDiscoveryLane), + }; +} + +function deriveDirectPrLane(manifest: FocusManifest): ContributionLanePreference { + if (manifest.issueDiscoveryPolicy === "encouraged") return "discouraged"; + if (manifest.wantedPaths.length > 0) return "preferred"; + return "neutral"; +} + +function deriveIssueDiscoveryLane(manifest: FocusManifest): ContributionLanePreference { + if (manifest.issueDiscoveryPolicy === "encouraged") return "preferred"; + if (manifest.issueDiscoveryPolicy === "discouraged") return "discouraged"; + return "neutral"; +} + +function deriveValidationExpectations(manifest: FocusManifest): string[] { + const expectations: string[] = []; + if (manifest.linkedIssuePolicy === "required") expectations.push("Link a tracked issue before opening a PR."); + else if (manifest.linkedIssuePolicy === "preferred") expectations.push("Link a tracked issue if one exists."); + for (const expectation of manifest.testExpectations) { + if (isFocusManifestPublicSafe(expectation)) expectations.push(expectation); + } + return expectations; +} + +function deriveIssueEntryGuidance(manifest: FocusManifest): string[] { + const guidance: string[] = []; + if (manifest.issueDiscoveryPolicy === "encouraged") guidance.push("Issue discovery reports are welcomed; search for gaps before opening a PR."); + else if (manifest.issueDiscoveryPolicy === "discouraged") guidance.push("Prefer direct fixes over new issue reports; this repo discourages issue-discovery submissions."); + if (manifest.linkedIssuePolicy === "required") guidance.push("Issues must be linked to a PR before it is opened."); + else if (manifest.linkedIssuePolicy === "preferred") guidance.push("Link an existing issue to your PR when one is available."); + return guidance; +} + +function derivePrEntryGuidance(manifest: FocusManifest): string[] { + const guidance: string[] = []; + if (manifest.wantedPaths.length > 0) { + guidance.push(`Focus changes on maintainer-wanted areas: ${manifest.wantedPaths.slice(0, 5).join(", ")}.`); + } + if (manifest.blockedPaths.length > 0) { + guidance.push(`Avoid maintainer-blocked areas: ${manifest.blockedPaths.slice(0, 5).join(", ")}.`); + } + if (manifest.preferredLabels.length > 0) { + guidance.push(`Apply a maintainer-preferred label to your PR: ${manifest.preferredLabels.slice(0, 3).join(", ")}.`); + } + for (const note of manifest.publicNotes) { + if (isFocusManifestPublicSafe(note)) guidance.push(note); + } + return [...new Set(guidance)].filter(isFocusManifestPublicSafe); +} + +function lanesSummary(manifest: FocusManifest, directPrLane: ContributionLanePreference, issueDiscoveryLane: ContributionLanePreference): string { + if (issueDiscoveryLane === "preferred" && directPrLane === "discouraged") return "Issue-discovery is the preferred contribution mode for this repo."; + if (issueDiscoveryLane === "discouraged" && manifest.wantedPaths.length > 0) return "Direct PRs focused on the wanted areas are the preferred contribution mode."; + if (directPrLane === "preferred") return "Direct PRs on the maintainer-wanted areas are preferred."; + if (issueDiscoveryLane === "discouraged") return "Direct PRs are preferred; issue-discovery submissions are discouraged."; + return "Contribution lanes are guided by the maintainer focus manifest."; +} + // ─── Focus Manifest Policy Schema ──────────────────────────────────────────── /** Preference signal for a contribution lane derived from the focus manifest. */ diff --git a/test/unit/focus-manifest.test.ts b/test/unit/focus-manifest.test.ts index 804c2e6b..01dd4700 100644 --- a/test/unit/focus-manifest.test.ts +++ b/test/unit/focus-manifest.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildFocusManifestGuidance, compileFocusManifestPolicy, + deriveContributionLanes, isFocusManifestPublicSafe, matchesManifestPath, parseFocusManifest, @@ -430,6 +431,161 @@ describe("compileFocusManifestPolicy", () => { }); }); +describe("deriveContributionLanes", () => { + it("returns neutral lanes with no constraints when no manifest is present", () => { + const lanes = deriveContributionLanes(parseFocusManifest(null)); + expect(lanes.present).toBe(false); + expect(lanes.directPrLane).toBe("neutral"); + expect(lanes.issueDiscoveryLane).toBe("neutral"); + expect(lanes.preferredEntryPaths).toEqual([]); + expect(lanes.discouragedEntryPaths).toEqual([]); + expect(lanes.validationExpectations).toEqual([]); + expect(lanes.issueEntryGuidance).toEqual([]); + expect(lanes.prEntryGuidance).toEqual([]); + expect(lanes.summary).toMatch(/not constrained/i); + }); + + it("marks direct-PR as preferred when wanted paths are declared", () => { + const lanes = deriveContributionLanes(parseFocusManifest({ wantedPaths: ["src/", "lib/"] })); + expect(lanes.present).toBe(true); + expect(lanes.directPrLane).toBe("preferred"); + expect(lanes.issueDiscoveryLane).toBe("neutral"); + expect(lanes.preferredEntryPaths).toEqual(["src/", "lib/"]); + expect(lanes.prEntryGuidance.join(" ")).toMatch(/src\//); + expect(lanes.summary).toMatch(/wanted areas are preferred/i); + }); + + it("marks issue-discovery as preferred and direct-PR as discouraged when issueDiscoveryPolicy is encouraged", () => { + const lanes = deriveContributionLanes(parseFocusManifest({ issueDiscoveryPolicy: "encouraged" })); + expect(lanes.directPrLane).toBe("discouraged"); + expect(lanes.issueDiscoveryLane).toBe("preferred"); + expect(lanes.issueEntryGuidance.join(" ")).toMatch(/welcomed|search for gaps/i); + expect(lanes.summary).toMatch(/issue.discovery is the preferred/i); + }); + + it("marks issue-discovery as discouraged when issueDiscoveryPolicy is discouraged", () => { + const lanes = deriveContributionLanes(parseFocusManifest({ wantedPaths: ["src/"], issueDiscoveryPolicy: "discouraged" })); + expect(lanes.issueDiscoveryLane).toBe("discouraged"); + expect(lanes.directPrLane).toBe("preferred"); + expect(lanes.issueEntryGuidance.join(" ")).toMatch(/prefer direct fixes|discourages/i); + expect(lanes.summary).toMatch(/wanted areas are the preferred/i); + }); + + it("surfaces validation expectations from testExpectations and linkedIssuePolicy", () => { + const lanes = deriveContributionLanes( + parseFocusManifest({ wantedPaths: ["src/"], linkedIssuePolicy: "required", testExpectations: ["unit tests for new branches", "npm run test:ci"] }), + ); + expect(lanes.validationExpectations).toContain("Link a tracked issue before opening a PR."); + expect(lanes.validationExpectations).toContain("unit tests for new branches"); + expect(lanes.validationExpectations).toContain("npm run test:ci"); + }); + + it("produces preferred validation hint for linkedIssuePolicy:preferred", () => { + const lanes = deriveContributionLanes(parseFocusManifest({ wantedPaths: ["src/"], linkedIssuePolicy: "preferred" })); + expect(lanes.validationExpectations).toContain("Link a tracked issue if one exists."); + expect(lanes.issueEntryGuidance).toContain("Link an existing issue to your PR when one is available."); + }); + + it("includes required link requirement in both validation expectations and issue entry guidance", () => { + const lanes = deriveContributionLanes(parseFocusManifest({ wantedPaths: ["src/"], linkedIssuePolicy: "required" })); + expect(lanes.validationExpectations).toContain("Link a tracked issue before opening a PR."); + expect(lanes.issueEntryGuidance).toContain("Issues must be linked to a PR before it is opened."); + }); + + it("includes blocked paths in discouragedEntryPaths and PR entry guidance", () => { + const lanes = deriveContributionLanes(parseFocusManifest({ wantedPaths: ["src/"], blockedPaths: ["migrations/", "infra/secrets.tf"] })); + expect(lanes.discouragedEntryPaths).toEqual(["migrations/", "infra/secrets.tf"]); + expect(lanes.prEntryGuidance.join(" ")).toMatch(/migrations\/.*infra\/secrets\.tf|infra\/secrets\.tf.*migrations\//); + }); + + it("includes preferred labels in PR entry guidance", () => { + const lanes = deriveContributionLanes(parseFocusManifest({ wantedPaths: ["src/"], preferredLabels: ["bug", "good first issue"] })); + expect(lanes.prEntryGuidance.join(" ")).toMatch(/bug|good first issue/); + }); + + it("includes maintainer public notes in PR entry guidance", () => { + const lanes = deriveContributionLanes(parseFocusManifest({ wantedPaths: ["src/"], publicNotes: ["Prefer small, focused PRs."] })); + expect(lanes.prEntryGuidance).toContain("Prefer small, focused PRs."); + }); + + it("excludes maintainerNotes from all output fields", () => { + const lanes = deriveContributionLanes( + parseFocusManifest({ wantedPaths: ["src/"], maintainerNotes: ["Internal: ping @owner before touching the queue processor."] }), + ); + const serialized = JSON.stringify(lanes); + expect(serialized).not.toMatch(/ping @owner/); + expect(serialized).not.toMatch(/Internal:/); + }); + + it("filters public notes containing forbidden language before including them in prEntryGuidance", () => { + const lanes = deriveContributionLanes( + parseFocusManifest({ wantedPaths: ["src/"], publicNotes: ["Maximize your reward payout", "Keep PRs focused."] }), + ); + expect(lanes.prEntryGuidance).not.toContain("Maximize your reward payout"); + expect(lanes.prEntryGuidance).toContain("Keep PRs focused."); + }); + + it("filters testExpectations containing forbidden language before including them in validationExpectations", () => { + const lanes = deriveContributionLanes( + parseFocusManifest({ wantedPaths: ["src/"], testExpectations: ["Submit your wallet seed phrase", "npm run test:ci"] }), + ); + expect(lanes.validationExpectations).not.toContain("Submit your wallet seed phrase"); + expect(lanes.validationExpectations).toContain("npm run test:ci"); + }); + + it("preserves source from the manifest", () => { + const lanes = deriveContributionLanes(parseFocusManifest({ wantedPaths: ["src/"] }, "repo_file")); + expect(lanes.source).toBe("repo_file"); + }); + + it("passes a comprehensive manifest fixture end-to-end with all fields populated", () => { + const manifest = parseFocusManifest({ + source: "repo_file", + wantedPaths: ["src/", "packages/*/lib"], + blockedPaths: ["migrations/"], + preferredLabels: ["bug", "good first issue"], + linkedIssuePolicy: "required", + testExpectations: ["unit tests for new branches"], + issueDiscoveryPolicy: "discouraged", + maintainerNotes: ["Internal: ping @owner"], + publicNotes: ["Prefer small, focused PRs."], + }); + const lanes = deriveContributionLanes(manifest); + + expect(lanes.present).toBe(true); + expect(lanes.source).toBe("repo_file"); + expect(lanes.directPrLane).toBe("preferred"); + expect(lanes.issueDiscoveryLane).toBe("discouraged"); + expect(lanes.preferredEntryPaths).toContain("src/"); + expect(lanes.discouragedEntryPaths).toContain("migrations/"); + expect(lanes.validationExpectations).toContain("Link a tracked issue before opening a PR."); + expect(lanes.validationExpectations).toContain("unit tests for new branches"); + expect(lanes.issueEntryGuidance.join(" ")).toMatch(/discourages/i); + expect(lanes.prEntryGuidance.join(" ")).toMatch(/bug|good first issue/i); + expect(lanes.prEntryGuidance).toContain("Prefer small, focused PRs."); + expect(lanes.summary).toMatch(/wanted areas/i); + + const serialized = JSON.stringify(lanes); + expect(serialized).not.toMatch(/ping @owner/); + expect(serialized).not.toMatch(/\b(wallet|hotkey|coldkey|raw trust|trust score|payout|reward|farming|private reviewability)\b/i); + }); + + it("keeps both lanes neutral with a default summary when a present manifest declares no wanted paths or policies", () => { + const lanes = deriveContributionLanes(parseFocusManifest({ preferredLabels: ["bug"] })); + expect(lanes.present).toBe(true); + expect(lanes.directPrLane).toBe("neutral"); + expect(lanes.issueDiscoveryLane).toBe("neutral"); + expect(lanes.summary).toMatch(/guided by the maintainer focus manifest/i); + }); + + it("recommends direct PRs when issue-discovery is discouraged without any wanted paths", () => { + const lanes = deriveContributionLanes(parseFocusManifest({ issueDiscoveryPolicy: "discouraged", preferredLabels: ["bug"] })); + expect(lanes.directPrLane).toBe("neutral"); + expect(lanes.issueDiscoveryLane).toBe("discouraged"); + expect(lanes.summary).toMatch(/direct prs are preferred; issue-discovery submissions are discouraged/i); + }); +}); + describe("public-safe invariant", () => { it("rejects forbidden compensation/secret language", () => { expect(isFocusManifestPublicSafe("Keep PRs focused")).toBe(true);