diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index af7bd6f173..c02bf9692e 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -253,6 +253,7 @@ export { parseMinerGoalSpecContent, discoverMinerGoalSpecPath, MINER_GOAL_SPEC_FILENAMES, + type MinerFeasibilityGatePolicy, type MinerGoalSpec, type MinerIssueDiscoveryPolicy, type ParsedMinerGoalSpec, diff --git a/packages/gittensory-engine/src/miner-goal-spec.ts b/packages/gittensory-engine/src/miner-goal-spec.ts index c76477dc6a..24a0a7fb4c 100644 --- a/packages/gittensory-engine/src/miner-goal-spec.ts +++ b/packages/gittensory-engine/src/miner-goal-spec.ts @@ -10,6 +10,21 @@ import { parse as parseYaml } from "yaml"; /** How strongly opening discovery issues is encouraged for this repo. Mirrors the review-side policy vocabulary. */ export type MinerIssueDiscoveryPolicy = "encouraged" | "neutral" | "discouraged"; +/** + * Per-repo tuning of the analyze-phase feasibility gate (`buildFeasibilityVerdict`, feasibility.ts). This config + * block only carries the maintainer's *intent*; enforcing it against the composer is the gate consumer's job + * (#4270), not this parser's. Scoped to reason suppression: a repo can opt out of specific avoid/raise reason + * codes (e.g. a repo that genuinely wants attempts even on `duplicate_cluster_medium`), leaving every other reason + * in force. Reason codes are the strings `buildFeasibilityVerdict` emits (e.g. `claim_status_claimed`, + * `issue_quality_uncertain`); unknown codes are kept verbatim and simply never match. + */ +export type MinerFeasibilityGatePolicy = { + /** Avoid-reason codes this repo opts out of; the gate consumer downgrades/ignores them. Default: []. */ + suppressAvoidReasons: readonly string[]; + /** Raise-reason codes this repo opts out of; the gate consumer downgrades/ignores them. Default: []. */ + suppressRaiseReasons: readonly string[]; +}; + /** Per-repo miner configuration parsed from `.gittensory-miner.yml`. See {@link DEFAULT_MINER_GOAL_SPEC}. */ export type MinerGoalSpec = { /** @@ -49,6 +64,11 @@ export type MinerGoalSpec = { * Default: neutral. */ issueDiscoveryPolicy: MinerIssueDiscoveryPolicy; + /** + * Per-repo tuning of the analyze-phase feasibility gate — which avoid/raise reason codes this repo opts out of. + * Carries intent only; the gate consumer (#4270) enforces it. Default: empty policy (no reasons suppressed). + */ + feasibilityGate: MinerFeasibilityGatePolicy; }; /** The tolerant parser result for `.gittensory-miner.yml`: the normalized spec plus parse warnings and whether the @@ -77,6 +97,10 @@ export const DEFAULT_MINER_GOAL_SPEC: Readonly = Object.freeze({ blockedLabels: Object.freeze([]), maxConcurrentClaims: 1, issueDiscoveryPolicy: "neutral", + feasibilityGate: Object.freeze({ + suppressAvoidReasons: Object.freeze([]), + suppressRaiseReasons: Object.freeze([]), + }), }); const MAX_MINER_GOAL_SPEC_BYTES = 32_768; @@ -90,6 +114,10 @@ function cloneDefaultMinerGoalSpec(): MinerGoalSpec { blockedPaths: [...DEFAULT_MINER_GOAL_SPEC.blockedPaths], preferredLabels: [...DEFAULT_MINER_GOAL_SPEC.preferredLabels], blockedLabels: [...DEFAULT_MINER_GOAL_SPEC.blockedLabels], + feasibilityGate: { + suppressAvoidReasons: [...DEFAULT_MINER_GOAL_SPEC.feasibilityGate.suppressAvoidReasons], + suppressRaiseReasons: [...DEFAULT_MINER_GOAL_SPEC.feasibilityGate.suppressRaiseReasons], + }, }; } @@ -149,6 +177,23 @@ function normalizeIssueDiscoveryPolicy( return fallback; } +function emptyFeasibilityGatePolicy(): MinerFeasibilityGatePolicy { + return { suppressAvoidReasons: [], suppressRaiseReasons: [] }; +} + +function normalizeFeasibilityGatePolicy(value: unknown, field: string, warnings: string[]): MinerFeasibilityGatePolicy { + if (value === undefined || value === null) return emptyFeasibilityGatePolicy(); + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push(`MinerGoalSpec field "${field}" must be a mapping; ignoring a ${typeof value} value.`); + return emptyFeasibilityGatePolicy(); + } + const record = value as Record; + return { + suppressAvoidReasons: normalizeStringList(record.suppressAvoidReasons, `${field}.suppressAvoidReasons`, warnings), + suppressRaiseReasons: normalizeStringList(record.suppressRaiseReasons, `${field}.suppressRaiseReasons`, warnings), + }; +} + function normalizePositiveInteger(value: unknown, field: string, fallback: number, warnings: string[]): number { if (value === undefined || value === null) return fallback; if (typeof value !== "number" || !Number.isFinite(value)) { @@ -181,7 +226,9 @@ function hasConfiguredGoalFields(spec: MinerGoalSpec): boolean { spec.preferredLabels.length > 0 || spec.blockedLabels.length > 0 || spec.maxConcurrentClaims !== DEFAULT_MINER_GOAL_SPEC.maxConcurrentClaims || - spec.issueDiscoveryPolicy !== DEFAULT_MINER_GOAL_SPEC.issueDiscoveryPolicy + spec.issueDiscoveryPolicy !== DEFAULT_MINER_GOAL_SPEC.issueDiscoveryPolicy || + spec.feasibilityGate.suppressAvoidReasons.length > 0 || + spec.feasibilityGate.suppressRaiseReasons.length > 0 ); } @@ -222,6 +269,7 @@ export function parseMinerGoalSpec(raw: unknown): ParsedMinerGoalSpec { DEFAULT_MINER_GOAL_SPEC.issueDiscoveryPolicy, warnings, ), + feasibilityGate: normalizeFeasibilityGatePolicy(record.feasibilityGate, "feasibilityGate", warnings), }; if (!hasConfiguredGoalFields(spec)) { warnings.push("MinerGoalSpec contained no recognized non-default goal fields; falling back to safe defaults."); diff --git a/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts b/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts index 502ef8b92f..bbb484780d 100644 --- a/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts +++ b/packages/gittensory-engine/test/miner-goal-spec-parser.test.ts @@ -45,6 +45,7 @@ test("parseMinerGoalSpec: valid raw config normalizes every field and keeps non- blockedLabels: ["duplicate"], maxConcurrentClaims: 2, issueDiscoveryPolicy: "encouraged", + feasibilityGate: { suppressAvoidReasons: [], suppressRaiseReasons: [] }, }); assert.deepEqual(parsed.warnings, []); }); @@ -130,6 +131,7 @@ test("parseMinerGoalSpec: malformed fields fall back independently with targeted blockedLabels: ["wontfix"], maxConcurrentClaims: 1, issueDiscoveryPolicy: "neutral", + feasibilityGate: { suppressAvoidReasons: [], suppressRaiseReasons: [] }, }); const warningText = parsed.warnings.join(" "); assert.match(warningText, /minerEnabled/i); diff --git a/packages/gittensory-engine/test/miner-goal-spec.test.ts b/packages/gittensory-engine/test/miner-goal-spec.test.ts index cd85337f02..92e3b55023 100644 --- a/packages/gittensory-engine/test/miner-goal-spec.test.ts +++ b/packages/gittensory-engine/test/miner-goal-spec.test.ts @@ -19,6 +19,7 @@ test("DEFAULT_MINER_GOAL_SPEC carries the documented safe defaults", () => { blockedLabels: [], maxConcurrentClaims: 1, issueDiscoveryPolicy: "neutral", + feasibilityGate: { suppressAvoidReasons: [], suppressRaiseReasons: [] }, }); }); @@ -28,12 +29,16 @@ test("DEFAULT_MINER_GOAL_SPEC is deep-frozen so the shared singleton can't be mu assert.ok(Object.isFrozen(DEFAULT_MINER_GOAL_SPEC.blockedPaths)); assert.ok(Object.isFrozen(DEFAULT_MINER_GOAL_SPEC.preferredLabels)); assert.ok(Object.isFrozen(DEFAULT_MINER_GOAL_SPEC.blockedLabels)); + assert.ok(Object.isFrozen(DEFAULT_MINER_GOAL_SPEC.feasibilityGate)); + assert.ok(Object.isFrozen(DEFAULT_MINER_GOAL_SPEC.feasibilityGate.suppressAvoidReasons)); + assert.ok(Object.isFrozen(DEFAULT_MINER_GOAL_SPEC.feasibilityGate.suppressRaiseReasons)); }); test("DEFAULT_MINER_GOAL_SPEC exposes exactly the specified field surface", () => { assert.deepEqual(Object.keys(DEFAULT_MINER_GOAL_SPEC).sort(), [ "blockedLabels", "blockedPaths", + "feasibilityGate", "issueDiscoveryPolicy", "maxConcurrentClaims", "minerEnabled", diff --git a/packages/gittensory-miner/docs/miner-goal-spec.md b/packages/gittensory-miner/docs/miner-goal-spec.md index 777721d721..1ab08e56ee 100644 --- a/packages/gittensory-miner/docs/miner-goal-spec.md +++ b/packages/gittensory-miner/docs/miner-goal-spec.md @@ -49,3 +49,23 @@ Maximum issues one miner may hold claimed on this repo at once. ### `issueDiscoveryPolicy` (`encouraged` | `neutral` | `discouraged`, default: `neutral`) How strongly this repo encourages a miner to open discovery issues. + +### `feasibilityGate` (mapping, default: `{}`) + +Per-repo tuning of the analyze-phase feasibility gate (`buildFeasibilityVerdict`). This block carries the +maintainer's intent only; the gate consumer enforces it. It is scoped to reason suppression — a repo can opt out +of specific avoid/raise reason codes while leaving every other reason in force. + +- **`suppressAvoidReasons`** — avoid-reason codes this repo opts out of. String list. Default: `[]`. +- **`suppressRaiseReasons`** — raise-reason codes this repo opts out of. String list. Default: `[]`. + +Reason codes are the strings `buildFeasibilityVerdict` emits (e.g. `duplicate_cluster_medium`, +`claim_status_claimed`, `issue_quality_uncertain`); an unknown code is kept verbatim and simply never matches. + +```yaml +feasibilityGate: + suppressAvoidReasons: + - duplicate_cluster_high + suppressRaiseReasons: + - claim_status_claimed +``` diff --git a/packages/gittensory-miner/schema/miner-goal-spec.schema.json b/packages/gittensory-miner/schema/miner-goal-spec.schema.json index 14340a3e27..b47d7f5f07 100644 --- a/packages/gittensory-miner/schema/miner-goal-spec.schema.json +++ b/packages/gittensory-miner/schema/miner-goal-spec.schema.json @@ -46,6 +46,25 @@ "enum": ["encouraged", "neutral", "discouraged"], "default": "neutral", "description": "How strongly opening discovery issues is encouraged. Default: neutral." + }, + "feasibilityGate": { + "type": "object", + "additionalProperties": true, + "properties": { + "suppressAvoidReasons": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "default": [], + "description": "Avoid-reason codes this repo opts out of. Default: []." + }, + "suppressRaiseReasons": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "default": [], + "description": "Raise-reason codes this repo opts out of. Default: []." + } + }, + "description": "Per-repo tuning of the analyze-phase feasibility gate: reason codes to suppress. Default: {}." } } } diff --git a/test/unit/miner-goal-spec-parser.test.ts b/test/unit/miner-goal-spec-parser.test.ts index 67684ffd93..5e01ba7472 100644 --- a/test/unit/miner-goal-spec-parser.test.ts +++ b/test/unit/miner-goal-spec-parser.test.ts @@ -53,6 +53,7 @@ describe("MinerGoalSpec parser (#2301)", () => { blockedLabels: ["duplicate"], maxConcurrentClaims: 2, issueDiscoveryPolicy: "encouraged", + feasibilityGate: { suppressAvoidReasons: [], suppressRaiseReasons: [] }, }, warnings: ['MinerGoalSpec field "blockedPaths" truncated an over-long entry.'], }); @@ -143,6 +144,7 @@ describe("MinerGoalSpec parser (#2301)", () => { blockedLabels: ["wontfix"], maxConcurrentClaims: 1, issueDiscoveryPolicy: "neutral", + feasibilityGate: { suppressAvoidReasons: [], suppressRaiseReasons: [] }, }, warnings: expect.arrayContaining([ expect.stringMatching(/minerEnabled/i), @@ -280,4 +282,39 @@ describe("MinerGoalSpec parser (#2301)", () => { warnings: ["MinerGoalSpec content exceeded 32768 bytes; ignoring it and falling back to safe defaults."], }); }); + + describe("feasibilityGate policy (#4275)", () => { + it("parses suppress lists, deduping and skipping invalid entries", () => { + const parsed = parseMinerGoalSpec({ + feasibilityGate: { + suppressAvoidReasons: ["duplicate_cluster_high", "duplicate_cluster_high", " ", 5], + suppressRaiseReasons: ["claim_status_claimed"], + }, + }); + expect(parsed.present).toBe(true); + expect(parsed.spec.feasibilityGate).toEqual({ + suppressAvoidReasons: ["duplicate_cluster_high"], + suppressRaiseReasons: ["claim_status_claimed"], + }); + expect(parsed.warnings.join(" ")).toMatch(/feasibilityGate\.suppressAvoidReasons.*skipped a non-string/i); + }); + + it("treats a feasibilityGate-only config as present", () => { + const parsed = parseMinerGoalSpec({ feasibilityGate: { suppressRaiseReasons: ["issue_quality_uncertain"] } }); + expect(parsed.present).toBe(true); + expect(parsed.spec.feasibilityGate.suppressRaiseReasons).toEqual(["issue_quality_uncertain"]); + expect(parsed.spec.feasibilityGate.suppressAvoidReasons).toEqual([]); + }); + + it("falls a non-mapping feasibilityGate back to an empty policy with a warning", () => { + const parsed = parseMinerGoalSpec({ minerEnabled: false, feasibilityGate: "nope" }); + expect(parsed.spec.feasibilityGate).toEqual({ suppressAvoidReasons: [], suppressRaiseReasons: [] }); + expect(parsed.warnings.join(" ")).toMatch(/"feasibilityGate" must be a mapping/i); + }); + + it("leaves feasibilityGate at the empty-policy default when absent", () => { + const parsed = parseMinerGoalSpec({ wantedPaths: ["src/**"] }); + expect(parsed.spec.feasibilityGate).toEqual({ suppressAvoidReasons: [], suppressRaiseReasons: [] }); + }); + }); }); diff --git a/test/unit/opportunity-branch-internals.test.ts b/test/unit/opportunity-branch-internals.test.ts index 208d2c1c60..3425414780 100644 --- a/test/unit/opportunity-branch-internals.test.ts +++ b/test/unit/opportunity-branch-internals.test.ts @@ -73,6 +73,7 @@ describe("opportunity branch internals", () => { blockedLabels: [], maxConcurrentClaims: 1, issueDiscoveryPolicy: "encouraged", + feasibilityGate: { suppressAvoidReasons: [], suppressRaiseReasons: [] }, }, }, }).preferredLabels, diff --git a/test/unit/opportunity-metadata-signals.test.ts b/test/unit/opportunity-metadata-signals.test.ts index b2d5dafa5b..3a31b3ac7e 100644 --- a/test/unit/opportunity-metadata-signals.test.ts +++ b/test/unit/opportunity-metadata-signals.test.ts @@ -66,6 +66,7 @@ describe("opportunity metadata signals", () => { blockedLabels: [], maxConcurrentClaims: 1, issueDiscoveryPolicy: "encouraged", + feasibilityGate: { suppressAvoidReasons: [], suppressRaiseReasons: [] }, }, }, },