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 @@ -253,6 +253,7 @@ export {
parseMinerGoalSpecContent,
discoverMinerGoalSpecPath,
MINER_GOAL_SPEC_FILENAMES,
type MinerFeasibilityGatePolicy,
type MinerGoalSpec,
type MinerIssueDiscoveryPolicy,
type ParsedMinerGoalSpec,
Expand Down
50 changes: 49 additions & 1 deletion packages/gittensory-engine/src/miner-goal-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -77,6 +97,10 @@ export const DEFAULT_MINER_GOAL_SPEC: Readonly<MinerGoalSpec> = 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;
Expand All @@ -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],
},
};
}

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

Expand Down Expand Up @@ -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.");
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: { suppressAvoidReasons: [], suppressRaiseReasons: [] },
});
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: { suppressAvoidReasons: [], suppressRaiseReasons: [] },
});
const warningText = parsed.warnings.join(" ");
assert.match(warningText, /minerEnabled/i);
Expand Down
5 changes: 5 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: { suppressAvoidReasons: [], suppressRaiseReasons: [] },
});
});

Expand All @@ -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",
Expand Down
20 changes: 20 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,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
```
19 changes: 19 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,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: {}."
}
}
}
37 changes: 37 additions & 0 deletions test/unit/miner-goal-spec-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'],
});
Expand Down Expand Up @@ -143,6 +144,7 @@ describe("MinerGoalSpec parser (#2301)", () => {
blockedLabels: ["wontfix"],
maxConcurrentClaims: 1,
issueDiscoveryPolicy: "neutral",
feasibilityGate: { suppressAvoidReasons: [], suppressRaiseReasons: [] },
},
warnings: expect.arrayContaining([
expect.stringMatching(/minerEnabled/i),
Expand Down Expand Up @@ -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: [] });
});
});
});
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: { suppressAvoidReasons: [], suppressRaiseReasons: [] },
},
},
}).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: { suppressAvoidReasons: [], suppressRaiseReasons: [] },
},
},
},
Expand Down