diff --git a/packages/gittensory-engine/README.md b/packages/gittensory-engine/README.md index 380ba0f111..43582fc2a2 100644 --- a/packages/gittensory-engine/README.md +++ b/packages/gittensory-engine/README.md @@ -63,3 +63,9 @@ input order. `scanAiPolicyText` and `resolveAiPolicyVerdict` provide the deterministic policy gate used by miner discovery. They only deny on small, explicit AI-contribution ban phrases in `AI-USAGE.md` or `CONTRIBUTING.md`; ambiguous, missing, or empty policy text stays allowed so discovery does not invent a ban. + +## MinerGoalSpec + +`MinerGoalSpec` is the type surface for a repo's `.gittensory-miner.yml` (miner-side analogue of `.gittensory.yml`). +`DEFAULT_MINER_GOAL_SPEC` is the safe default a repo with no file behaves as — minable (`minerEnabled: true`, an +explicit opt-out), no path/label preferences, one concurrent claim, `neutral` discovery. Parsing is a separate module. diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 031283c159..eb9cdaba6e 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -17,3 +17,8 @@ export { type AiPolicySource, type AiPolicyVerdict, } from "./ai-policy-map.js"; +export { + DEFAULT_MINER_GOAL_SPEC, + type MinerGoalSpec, + type MinerIssueDiscoveryPolicy, +} 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 new file mode 100644 index 0000000000..973bdae566 --- /dev/null +++ b/packages/gittensory-engine/src/miner-goal-spec.ts @@ -0,0 +1,66 @@ +// MinerGoalSpec (#2293). The type surface for `.gittensory-miner.yml` — the per-repo config a maintainer/repo-owner +// drops in to tell an autonomous miner what to look for and how to behave when targeting their repo. This is the +// MINER-side analogue of the review-side `.gittensory.yml` focus manifest (see `src/signals/focus-manifest.ts`'s +// `FocusManifest`): a small typed config object paired with an explicit safe-defaults constant. +// +// This module is TYPES ONLY — no parsing, no IO. The parser (validation + safe-default coercion of raw YAML) is a +// separate follow-up issue; keeping the shape small here is deliberate, because it is easy to add a field later and +// painful to remove one contributors already rely on. Field names/semantics that overlap the review side are +// carried over verbatim from `.gittensory.yml` so the two manifests stay obviously paired. + +/** How strongly opening discovery issues is encouraged for this repo. Mirrors the review-side policy vocabulary. */ +export type MinerIssueDiscoveryPolicy = "encouraged" | "neutral" | "discouraged"; + +/** Per-repo miner configuration parsed from `.gittensory-miner.yml`. See {@link DEFAULT_MINER_GOAL_SPEC}. */ +export type MinerGoalSpec = { + /** + * Whether this repo permits autonomous miners at all. Explicit OPT-OUT, not opt-in: a public repo with no + * `.gittensory-miner.yml` is still minable, mirroring `.gittensory.yml`'s "safe by default" stance. Set `false` + * to halt all miner targeting of this repo. Default: true. + */ + minerEnabled: boolean; + /** + * Work areas the maintainer wants a miner to focus on; a candidate touching these is preferred. Glob list. + * Default: [] (no preference). + */ + wantedPaths: readonly string[]; + /** + * Paths off-limits to a miner. A candidate touching one of these should be skipped. Glob list. + * Default: [] (nothing blocked). + */ + blockedPaths: readonly string[]; + /** + * Issue/PR labels the maintainer prefers a miner to target; a candidate carrying one is favored. String list. + * Default: [] (no preference). + */ + preferredLabels: readonly string[]; + /** + * Maximum number of issues a single miner may hold claimed on this repo at once, so one miner cannot monopolize + * a repo's queue. A positive integer (`>= 1`); the parser is expected to floor a non-integer toward zero + * (`Math.floor`) and reject any value below 1. Default: 1. + */ + maxConcurrentClaims: number; + /** + * How strongly this repo encourages a miner to open discovery issues. Values: encouraged | neutral | discouraged. + * Default: neutral. + */ + issueDiscoveryPolicy: MinerIssueDiscoveryPolicy; +}; + +/** + * The safe defaults applied when a field is absent from `.gittensory-miner.yml` (or the file itself is missing). + * Every value here matches the "Default: X" documented on its field above. Analogous to the defaults constant that + * accompanies `FocusManifest` in `src/signals/focus-manifest.ts` — a repo with no file behaves as if it declared + * this: minable, with no path/label preferences, one concurrent claim, and neutral discovery. + * + * Deep-frozen: this is a shared singleton, so runtime code can read it freely but must not mutate it — clone before + * layering repo-specific overrides on top. + */ +export const DEFAULT_MINER_GOAL_SPEC: Readonly = Object.freeze({ + minerEnabled: true, + wantedPaths: Object.freeze([]), + blockedPaths: Object.freeze([]), + preferredLabels: Object.freeze([]), + maxConcurrentClaims: 1, + issueDiscoveryPolicy: "neutral", +}); diff --git a/packages/gittensory-engine/test/miner-goal-spec.test.ts b/packages/gittensory-engine/test/miner-goal-spec.test.ts new file mode 100644 index 0000000000..329b4a933f --- /dev/null +++ b/packages/gittensory-engine/test/miner-goal-spec.test.ts @@ -0,0 +1,56 @@ +// Tests for the MinerGoalSpec type contract (#2293). Types-only module, so these assert (a) the safe-defaults +// constant's runtime values, (b) that it satisfies the MinerGoalSpec type, and (c) a lightweight "every field is +// documented with a Default:" lint over the source — not parser behavior (that lands in a separate issue). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { DEFAULT_MINER_GOAL_SPEC, type MinerGoalSpec } from "../dist/index.js"; + +// Compile-time contract: the exported default must satisfy MinerGoalSpec (fails `tsc` if the shape drifts). +const _contract: MinerGoalSpec = DEFAULT_MINER_GOAL_SPEC; +void _contract; + +test("DEFAULT_MINER_GOAL_SPEC carries the documented safe defaults", () => { + assert.deepEqual(DEFAULT_MINER_GOAL_SPEC, { + minerEnabled: true, // opt-out, not opt-in: a repo with no file is still minable + wantedPaths: [], + blockedPaths: [], + preferredLabels: [], + maxConcurrentClaims: 1, + issueDiscoveryPolicy: "neutral", + }); +}); + +test("DEFAULT_MINER_GOAL_SPEC is deep-frozen so the shared singleton can't be mutated", () => { + assert.ok(Object.isFrozen(DEFAULT_MINER_GOAL_SPEC)); + assert.ok(Object.isFrozen(DEFAULT_MINER_GOAL_SPEC.wantedPaths)); + assert.ok(Object.isFrozen(DEFAULT_MINER_GOAL_SPEC.blockedPaths)); + assert.ok(Object.isFrozen(DEFAULT_MINER_GOAL_SPEC.preferredLabels)); +}); + +test("DEFAULT_MINER_GOAL_SPEC exposes exactly the specified field surface", () => { + assert.deepEqual(Object.keys(DEFAULT_MINER_GOAL_SPEC).sort(), [ + "blockedPaths", + "issueDiscoveryPolicy", + "maxConcurrentClaims", + "minerEnabled", + "preferredLabels", + "wantedPaths", + ]); +}); + +test("every MinerGoalSpec field is documented with a JSDoc 'Default:' in the source", () => { + // The suite compiles to dist-test/ (tsconfig.test.json outDir), a sibling of src/, so ../src/ from this file's + // runtime location resolves to the TypeScript source. readFileSync throws loudly if that layout ever changes, so + // this test can't silently pass on a bad path. + const source = readFileSync(new URL("../src/miner-goal-spec.ts", import.meta.url), "utf8"); + const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + for (const field of Object.keys(DEFAULT_MINER_GOAL_SPEC)) { + // Grab the JSDoc block immediately preceding the field declaration inside the type. Field names are escaped + // before interpolation so the pattern stays safe if it is ever reused for a less controlled field surface. + const doc = source.match(new RegExp(`(/\\*\\*[\\s\\S]*?\\*/)\\s*\\n\\s*${escapeRe(field)}:`)); + const jsdoc = doc?.[1]; + assert.ok(jsdoc, `field '${field}' should have a JSDoc block`); + assert.match(jsdoc, /Default:/, `field '${field}' JSDoc should state its Default:`); + } +});