From 2efbff3f4b1e95007b5eb5f1a8f578a594645b0f Mon Sep 17 00:00:00 2001 From: davion-knight <298846663+davion-knight@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:53:10 -0500 Subject: [PATCH] feat(miner-config): parse a feasibilityGate policy block from .gittensory-miner.yml (#4275) Add a feasibilityGate field to MinerGoalSpec (packages/gittensory-engine/src/ miner-goal-spec.ts) so a maintainer can tune the miner feasibility gate per repo. It is a policy BLOCK (an object, so it can grow), today carrying suppressReasons - feasibility-verdict avoid/raise reason codes the gate should ignore for this repo. Default: an empty block (suppress nothing). Wired through the canonical tolerant parser exactly like the existing six fields: a normalizeFeasibilityGate helper (reusing normalizeStringList for suppressReasons, degrading a non-object/list value to the empty block with a warning, never throwing), the DEFAULT_MINER_GOAL_SPEC entry + deep clone, and hasConfiguredGoalFields (so a spec that sets ONLY feasibilityGate is still detected as present). The non-exported miner-goal-spec-parse.ts sibling is deliberately left untouched (no real caller). Updates the JSON Schema mirror + docs to match. The gate's actual consumer is separate, maintainer-owned wiring (#4270); this defines the config surface only. Closes #4275 --- packages/gittensory-engine/src/index.ts | 1 + .../gittensory-engine/src/miner-goal-spec.ts | 30 +++++++++++++- .../test/miner-goal-spec-parser.test.ts | 2 + .../test/miner-goal-spec.test.ts | 2 + .../gittensory-miner/docs/miner-goal-spec.md | 17 ++++++++ .../schema/miner-goal-spec.schema.json | 14 +++++++ test/unit/miner-goal-spec-doc.test.ts | 1 + .../miner-goal-spec-feasibility-gate.test.ts | 40 +++++++++++++++++++ .../unit/opportunity-branch-internals.test.ts | 1 + .../unit/opportunity-metadata-signals.test.ts | 1 + 10 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 test/unit/miner-goal-spec-feasibility-gate.test.ts diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 75a4feae84..03db2568e6 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -235,6 +235,7 @@ export { discoverMinerGoalSpecPath, MINER_GOAL_SPEC_FILENAMES, type MinerGoalSpec, + type MinerFeasibilityGatePolicy, type MinerIssueDiscoveryPolicy, type ParsedMinerGoalSpec, } from "./miner-goal-spec.js"; diff --git a/packages/gittensory-engine/src/miner-goal-spec.ts b/packages/gittensory-engine/src/miner-goal-spec.ts index c76477dc6a..741c682cb1 100644 --- a/packages/gittensory-engine/src/miner-goal-spec.ts +++ b/packages/gittensory-engine/src/miner-goal-spec.ts @@ -49,6 +49,20 @@ export type MinerGoalSpec = { * Default: neutral. */ issueDiscoveryPolicy: MinerIssueDiscoveryPolicy; + /** + * Per-repo tuning for the miner feasibility gate (#4275). A policy block (not a scalar, so it can grow); today it + * carries the maintainer's "suppress" list — feasibility-verdict avoid/raise reason codes the gate should IGNORE + * for this repo (a candidate is not avoided for a listed reason). Default: an empty block (suppress nothing). The + * gate's actual consumer is separate, maintainer-owned wiring (#4270); this config surface is defined here only. + */ + feasibilityGate: MinerFeasibilityGatePolicy; +}; + +/** The `feasibilityGate` policy block of {@link MinerGoalSpec}. Deliberately an object (extensible) rather than a + * scalar. `suppressReasons` is a de-duplicated, bounded list of feasibility-verdict reason codes the gate should + * ignore for this repo; an empty list (the default) suppresses nothing. */ +export type MinerFeasibilityGatePolicy = { + suppressReasons: readonly string[]; }; /** The tolerant parser result for `.gittensory-miner.yml`: the normalized spec plus parse warnings and whether the @@ -77,6 +91,7 @@ export const DEFAULT_MINER_GOAL_SPEC: Readonly = Object.freeze({ blockedLabels: Object.freeze([]), maxConcurrentClaims: 1, issueDiscoveryPolicy: "neutral", + feasibilityGate: Object.freeze({ suppressReasons: Object.freeze([]) }), }); const MAX_MINER_GOAL_SPEC_BYTES = 32_768; @@ -90,6 +105,7 @@ function cloneDefaultMinerGoalSpec(): MinerGoalSpec { blockedPaths: [...DEFAULT_MINER_GOAL_SPEC.blockedPaths], preferredLabels: [...DEFAULT_MINER_GOAL_SPEC.preferredLabels], blockedLabels: [...DEFAULT_MINER_GOAL_SPEC.blockedLabels], + feasibilityGate: { suppressReasons: [...DEFAULT_MINER_GOAL_SPEC.feasibilityGate.suppressReasons] }, }; } @@ -149,6 +165,16 @@ function normalizeIssueDiscoveryPolicy( return fallback; } +function normalizeFeasibilityGate(value: unknown, field: string, warnings: string[]): MinerFeasibilityGatePolicy { + if (value === undefined || value === null) return { suppressReasons: [] }; + if (typeof value !== "object" || Array.isArray(value)) { + warnings.push(`MinerGoalSpec field "${field}" must be a mapping; ignoring a ${Array.isArray(value) ? "list" : typeof value} value.`); + return { suppressReasons: [] }; + } + const record = value as Record; + return { suppressReasons: normalizeStringList(record.suppressReasons, `${field}.suppressReasons`, 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 +207,8 @@ 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.suppressReasons.length > 0 ); } @@ -222,6 +249,7 @@ export function parseMinerGoalSpec(raw: unknown): ParsedMinerGoalSpec { DEFAULT_MINER_GOAL_SPEC.issueDiscoveryPolicy, warnings, ), + feasibilityGate: normalizeFeasibilityGate(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..6998fb1f7b 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: { suppressReasons: [] }, }); assert.deepEqual(parsed.warnings, []); }); @@ -130,6 +131,7 @@ test("parseMinerGoalSpec: malformed fields fall back independently with targeted blockedLabels: ["wontfix"], maxConcurrentClaims: 1, issueDiscoveryPolicy: "neutral", + feasibilityGate: { suppressReasons: [] }, }); 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..cda8c0213f 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: { suppressReasons: [] }, }); }); @@ -34,6 +35,7 @@ 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..1138f66027 100644 --- a/packages/gittensory-miner/docs/miner-goal-spec.md +++ b/packages/gittensory-miner/docs/miner-goal-spec.md @@ -49,3 +49,20 @@ 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` (object, default: `{}`) + +Per-repo tuning for the miner feasibility gate. A policy block (an object, so it can grow), today carrying a single +field: + +- `suppressReasons` (string list, default: `[]`) — feasibility-verdict avoid/raise reason codes the gate should + ignore for this repo. A candidate is not avoided for a listed reason. An empty list suppresses nothing. + +The gate's actual consumer is separate, maintainer-owned wiring; this block only defines the per-repo config surface. + +```yaml +feasibilityGate: + suppressReasons: + - duplicate_cluster_risk + - low_confidence +``` diff --git a/packages/gittensory-miner/schema/miner-goal-spec.schema.json b/packages/gittensory-miner/schema/miner-goal-spec.schema.json index 14340a3e27..b4a60eb1f6 100644 --- a/packages/gittensory-miner/schema/miner-goal-spec.schema.json +++ b/packages/gittensory-miner/schema/miner-goal-spec.schema.json @@ -46,6 +46,20 @@ "enum": ["encouraged", "neutral", "discouraged"], "default": "neutral", "description": "How strongly opening discovery issues is encouraged. Default: neutral." + }, + "feasibilityGate": { + "type": "object", + "additionalProperties": true, + "properties": { + "suppressReasons": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "default": [], + "description": "Feasibility-verdict avoid/raise reason codes the gate should ignore for this repo. Default: []." + } + }, + "default": {}, + "description": "Per-repo tuning for the miner feasibility gate. Default: an empty block (suppress nothing)." } } } diff --git a/test/unit/miner-goal-spec-doc.test.ts b/test/unit/miner-goal-spec-doc.test.ts index a151eefe6b..e80490f636 100644 --- a/test/unit/miner-goal-spec-doc.test.ts +++ b/test/unit/miner-goal-spec-doc.test.ts @@ -17,6 +17,7 @@ const SPEC_FIELDS = [ "blockedLabels", "maxConcurrentClaims", "issueDiscoveryPolicy", + "feasibilityGate", ] as const; describe("miner goal spec docs (#2300)", () => { diff --git a/test/unit/miner-goal-spec-feasibility-gate.test.ts b/test/unit/miner-goal-spec-feasibility-gate.test.ts new file mode 100644 index 0000000000..ee58d33320 --- /dev/null +++ b/test/unit/miner-goal-spec-feasibility-gate.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_MINER_GOAL_SPEC, parseMinerGoalSpec } from "../../packages/gittensory-engine/src/miner-goal-spec"; + +// Parser coverage for the `feasibilityGate` policy block (#4275), mirroring the per-field present/absent/malformed +// style the other MinerGoalSpec fields are held to. The block reuses normalizeStringList for suppressReasons, so +// these focus on the block wrapper's own arms (absent, non-object, array, and a real object). +describe("parseMinerGoalSpec feasibilityGate (#4275)", () => { + it("defaults to an empty suppress block when absent, without marking a bare spec present", () => { + const parsed = parseMinerGoalSpec({}); + expect(parsed.present).toBe(false); + expect(parsed.spec.feasibilityGate).toEqual({ suppressReasons: [] }); + // the shared default is not mutated + expect(DEFAULT_MINER_GOAL_SPEC.feasibilityGate.suppressReasons).toEqual([]); + }); + + it("parses suppressReasons and marks the spec present when only feasibilityGate is set", () => { + const parsed = parseMinerGoalSpec({ feasibilityGate: { suppressReasons: ["duplicate_cluster_risk", "low_confidence"] } }); + expect(parsed.present).toBe(true); + expect(parsed.warnings).toEqual([]); + expect(parsed.spec.feasibilityGate.suppressReasons).toEqual(["duplicate_cluster_risk", "low_confidence"]); + }); + + it("de-duplicates and skips non-string suppressReasons entries (via the shared list normalizer)", () => { + const parsed = parseMinerGoalSpec({ feasibilityGate: { suppressReasons: ["a", "a", 7, " b "] } }); + expect(parsed.spec.feasibilityGate.suppressReasons).toEqual(["a", "b"]); + expect(parsed.warnings.some((w) => w.includes("feasibilityGate.suppressReasons"))).toBe(true); + }); + + it("degrades a non-object feasibilityGate to the empty block with a warning", () => { + const parsed = parseMinerGoalSpec({ feasibilityGate: "nope" }); + expect(parsed.spec.feasibilityGate).toEqual({ suppressReasons: [] }); + expect(parsed.warnings.some((w) => w.includes('field "feasibilityGate" must be a mapping'))).toBe(true); + }); + + it("degrades a list-valued feasibilityGate to the empty block, naming the value kind as 'list'", () => { + const parsed = parseMinerGoalSpec({ feasibilityGate: ["a"] }); + expect(parsed.spec.feasibilityGate).toEqual({ suppressReasons: [] }); + expect(parsed.warnings.some((w) => w.includes("ignoring a list value"))).toBe(true); + }); +}); diff --git a/test/unit/opportunity-branch-internals.test.ts b/test/unit/opportunity-branch-internals.test.ts index 208d2c1c60..e0b2ca9a92 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: { suppressReasons: [] }, }, }, }).preferredLabels, diff --git a/test/unit/opportunity-metadata-signals.test.ts b/test/unit/opportunity-metadata-signals.test.ts index b2d5dafa5b..89ed6f4073 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: { suppressReasons: [] }, }, }, },