Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ export {
discoverMinerGoalSpecPath,
MINER_GOAL_SPEC_FILENAMES,
type MinerGoalSpec,
type MinerFeasibilityGatePolicy,
type MinerIssueDiscoveryPolicy,
type ParsedMinerGoalSpec,
} from "./miner-goal-spec.js";
Expand Down
30 changes: 29 additions & 1 deletion packages/gittensory-engine/src/miner-goal-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -77,6 +91,7 @@ export const DEFAULT_MINER_GOAL_SPEC: Readonly<MinerGoalSpec> = Object.freeze({
blockedLabels: Object.freeze([]),
maxConcurrentClaims: 1,
issueDiscoveryPolicy: "neutral",
feasibilityGate: Object.freeze({ suppressReasons: Object.freeze([]) }),
});

const MAX_MINER_GOAL_SPEC_BYTES = 32_768;
Expand All @@ -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] },
};
}

Expand Down Expand Up @@ -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<string, unknown>;
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)) {
Expand Down Expand Up @@ -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
);
}

Expand Down Expand Up @@ -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.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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, []);
});
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions packages/gittensory-engine/test/miner-goal-spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ test("DEFAULT_MINER_GOAL_SPEC carries the documented safe defaults", () => {
blockedLabels: [],
maxConcurrentClaims: 1,
issueDiscoveryPolicy: "neutral",
feasibilityGate: { suppressReasons: [] },
});
});

Expand All @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions packages/gittensory-miner/docs/miner-goal-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
14 changes: 14 additions & 0 deletions packages/gittensory-miner/schema/miner-goal-spec.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
}
}
}
1 change: 1 addition & 0 deletions test/unit/miner-goal-spec-doc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const SPEC_FIELDS = [
"blockedLabels",
"maxConcurrentClaims",
"issueDiscoveryPolicy",
"feasibilityGate",
] as const;

describe("miner goal spec docs (#2300)", () => {
Expand Down
40 changes: 40 additions & 0 deletions test/unit/miner-goal-spec-feasibility-gate.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
1 change: 1 addition & 0 deletions test/unit/opportunity-branch-internals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ describe("opportunity branch internals", () => {
blockedLabels: [],
maxConcurrentClaims: 1,
issueDiscoveryPolicy: "encouraged",
feasibilityGate: { suppressReasons: [] },
},
},
}).preferredLabels,
Expand Down
1 change: 1 addition & 0 deletions test/unit/opportunity-metadata-signals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ describe("opportunity metadata signals", () => {
blockedLabels: [],
maxConcurrentClaims: 1,
issueDiscoveryPolicy: "encouraged",
feasibilityGate: { suppressReasons: [] },
},
},
},
Expand Down
Loading