diff --git a/packages/gittensory-engine/package.json b/packages/gittensory-engine/package.json index ea44365380..8a697dfc23 100644 --- a/packages/gittensory-engine/package.json +++ b/packages/gittensory-engine/package.json @@ -30,6 +30,10 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./miner-goal-spec-parse": { + "types": "./dist/miner-goal-spec-parse.d.ts", + "default": "./dist/miner-goal-spec-parse.js" } }, "files": [ diff --git a/packages/gittensory-engine/src/miner-goal-spec-parse.ts b/packages/gittensory-engine/src/miner-goal-spec-parse.ts new file mode 100644 index 0000000000..a2b177ab58 --- /dev/null +++ b/packages/gittensory-engine/src/miner-goal-spec-parse.ts @@ -0,0 +1,104 @@ +import { + DEFAULT_MINER_GOAL_SPEC, + type MinerGoalSpec, + type MinerIssueDiscoveryPolicy, +} from "./miner-goal-spec.js"; + +export type MinerGoalSpecParseResult = { + present: boolean; + spec: Readonly; + warnings: readonly string[]; +}; + +const MAX_LIST_ENTRIES = 200; +const MAX_STRING_LEN = 300; +const POLICIES = new Set(["encouraged", "neutral", "discouraged"]); + +function freezeSpec(spec: MinerGoalSpec): Readonly { + return Object.freeze({ + ...spec, + wantedPaths: Object.freeze([...spec.wantedPaths]), + blockedPaths: Object.freeze([...spec.blockedPaths]), + preferredLabels: Object.freeze([...spec.preferredLabels]), + }); +} + +function parseStringList(value: unknown, field: string, warnings: string[]): string[] { + if (value === undefined) return []; + if (!Array.isArray(value)) { + warnings.push(`MinerGoalSpec field "${field}" must be an array; ignoring.`); + return []; + } + const seen = new Set(); + const out: string[] = []; + for (const entry of value) { + if (typeof entry !== "string") { + warnings.push(`MinerGoalSpec field "${field}" entries must be strings; skipping non-string.`); + continue; + } + const trimmed = entry.trim(); + if (!trimmed || trimmed.length > MAX_STRING_LEN) continue; + if (seen.has(trimmed)) continue; + seen.add(trimmed); + out.push(trimmed); + if (out.length >= MAX_LIST_ENTRIES) break; + } + return out; +} + +function parseBoolean(value: unknown, field: string, fallback: boolean, warnings: string[]): boolean { + if (value === undefined) return fallback; + if (typeof value === "boolean") return value; + warnings.push(`MinerGoalSpec field "${field}" must be a boolean; using default.`); + return fallback; +} + +function parseClaims(value: unknown, warnings: string[]): number { + if (value === undefined) return DEFAULT_MINER_GOAL_SPEC.maxConcurrentClaims; + if (typeof value !== "number" || !Number.isFinite(value)) { + warnings.push(`MinerGoalSpec field "maxConcurrentClaims" must be a number; using default.`); + return DEFAULT_MINER_GOAL_SPEC.maxConcurrentClaims; + } + const floored = Math.floor(value); + if (floored < 1) { + warnings.push(`MinerGoalSpec field "maxConcurrentClaims" must be >= 1; using 1.`); + return 1; + } + return floored; +} + +function parsePolicy(value: unknown, warnings: string[]): MinerIssueDiscoveryPolicy { + if (value === undefined) return DEFAULT_MINER_GOAL_SPEC.issueDiscoveryPolicy; + if (typeof value !== "string") { + warnings.push(`MinerGoalSpec field "issueDiscoveryPolicy" must be a string; using neutral.`); + return "neutral"; + } + const normalized = value.trim().toLowerCase(); + if (POLICIES.has(normalized as MinerIssueDiscoveryPolicy)) return normalized as MinerIssueDiscoveryPolicy; + warnings.push( + `MinerGoalSpec field "issueDiscoveryPolicy" must be encouraged, neutral, or discouraged; using neutral.`, + ); + return "neutral"; +} + +/** Parse raw JSON/YAML-decoded config into a deep-frozen {@link MinerGoalSpec}. Pure — no IO. */ +export function parseMinerGoalSpec(raw: unknown): MinerGoalSpecParseResult { + if (raw === null || raw === undefined || typeof raw !== "object" || Array.isArray(raw)) { + return { present: false, spec: DEFAULT_MINER_GOAL_SPEC, warnings: [] }; + } + + const record = raw as Record; + const warnings: string[] = []; + const present = Object.keys(record).length > 0; + + const spec = freezeSpec({ + minerEnabled: parseBoolean(record.minerEnabled, "minerEnabled", DEFAULT_MINER_GOAL_SPEC.minerEnabled, warnings), + wantedPaths: parseStringList(record.wantedPaths, "wantedPaths", warnings), + blockedPaths: parseStringList(record.blockedPaths, "blockedPaths", warnings), + preferredLabels: parseStringList(record.preferredLabels, "preferredLabels", warnings), + maxConcurrentClaims: parseClaims(record.maxConcurrentClaims, warnings), + issueDiscoveryPolicy: parsePolicy(record.issueDiscoveryPolicy, warnings), + }); + + return { present, spec, warnings: Object.freeze(warnings) }; +} diff --git a/packages/gittensory-engine/test/miner-goal-spec-parse.test.ts b/packages/gittensory-engine/test/miner-goal-spec-parse.test.ts new file mode 100644 index 0000000000..b6c43406d0 --- /dev/null +++ b/packages/gittensory-engine/test/miner-goal-spec-parse.test.ts @@ -0,0 +1,63 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { DEFAULT_MINER_GOAL_SPEC } from "../dist/miner-goal-spec.js"; +import { parseMinerGoalSpec } from "../dist/miner-goal-spec-parse.js"; + +test("parseMinerGoalSpec returns defaults for missing or non-object input", () => { + for (const raw of [undefined, null, "nope", 42, []]) { + const result = parseMinerGoalSpec(raw); + assert.equal(result.present, false); + assert.equal(result.spec, DEFAULT_MINER_GOAL_SPEC); + assert.deepEqual(result.warnings, []); + } +}); + +test("parseMinerGoalSpec coerces a full valid object and marks present", () => { + const result = parseMinerGoalSpec({ + minerEnabled: false, + wantedPaths: [" src/** ", "src/**"], + blockedPaths: ["dist/**"], + preferredLabels: [" bug ", "feature"], + maxConcurrentClaims: 3, + issueDiscoveryPolicy: "ENCOURAGED", + }); + + assert.equal(result.present, true); + assert.deepEqual(result.spec, { + minerEnabled: false, + wantedPaths: ["src/**"], + blockedPaths: ["dist/**"], + preferredLabels: ["bug", "feature"], + maxConcurrentClaims: 3, + issueDiscoveryPolicy: "encouraged", + }); + assert.deepEqual(result.warnings, []); + assert.ok(Object.isFrozen(result.spec)); + assert.ok(Object.isFrozen(result.spec.wantedPaths)); +}); + +test("parseMinerGoalSpec floors claims and rejects values below 1", () => { + const floored = parseMinerGoalSpec({ maxConcurrentClaims: 2.9 }); + assert.equal(floored.spec.maxConcurrentClaims, 2); + + const zero = parseMinerGoalSpec({ maxConcurrentClaims: 0 }); + assert.equal(zero.spec.maxConcurrentClaims, 1); + assert.match(zero.warnings.join(" "), /maxConcurrentClaims/); +}); + +test("parseMinerGoalSpec warns and falls back on malformed fields", () => { + const result = parseMinerGoalSpec({ + minerEnabled: "yes", + wantedPaths: "src/**", + maxConcurrentClaims: "two", + issueDiscoveryPolicy: "aggressive", + }); + + assert.equal(result.present, true); + assert.equal(result.spec.minerEnabled, DEFAULT_MINER_GOAL_SPEC.minerEnabled); + assert.deepEqual(result.spec.wantedPaths, []); + assert.equal(result.spec.maxConcurrentClaims, DEFAULT_MINER_GOAL_SPEC.maxConcurrentClaims); + assert.equal(result.spec.issueDiscoveryPolicy, "neutral"); + assert.ok(result.warnings.length >= 4); +});