diff --git a/packages/loopover-engine/src/miner-goal-spec.ts b/packages/loopover-engine/src/miner-goal-spec.ts index c6ee75a57b..04f8ec9991 100644 --- a/packages/loopover-engine/src/miner-goal-spec.ts +++ b/packages/loopover-engine/src/miner-goal-spec.ts @@ -259,10 +259,16 @@ function normalizeSelfPlagiarismPolicy( const record = value as Record; if ( record.similarityThreshold !== undefined && - typeof record.similarityThreshold !== "number" + (typeof record.similarityThreshold !== "number" || + !Number.isFinite(record.similarityThreshold) || + record.similarityThreshold < 0 || + record.similarityThreshold > 1) ) { + // An out-of-range or non-finite value previously fell through to self-plagiarism.ts's Math.min(1, Math.max(0, ...)) + // and was silently clamped with no warning, marked "present" -- unlike every sibling numeric normalizer here. + // Warn and fall back to the default instead (#8862). warnings.push( - `MinerGoalSpec field "${field}.similarityThreshold" must be a number; falling back to ${fallback.similarityThreshold}.`, + `MinerGoalSpec field "${field}.similarityThreshold" must be a number in [0, 1]; falling back to ${fallback.similarityThreshold}.`, ); return fallback; } diff --git a/test/unit/miner-goal-spec-parser.test.ts b/test/unit/miner-goal-spec-parser.test.ts index efb1a0c152..d90b7dc5bc 100644 --- a/test/unit/miner-goal-spec-parser.test.ts +++ b/test/unit/miner-goal-spec-parser.test.ts @@ -224,6 +224,25 @@ describe("MinerGoalSpec parser (#2301)", () => { expect(arrayValue.warnings.join(" ")).toMatch(/selfPlagiarism.*must be a mapping/i); }); + it("warns and falls back for an out-of-range or non-finite similarityThreshold instead of silently clamping (#8862)", () => { + // A numeric value outside [0, 1] used to fall through to self-plagiarism.ts's Math.min(1, Math.max(0, value)), + // silently clamping (e.g. 5 -> 1) with zero warnings and the field marked "present" -- unlike every sibling + // numeric normalizer. Each of these must now warn AND fall back to the documented default of 0.85. + for (const badThreshold of [5, -1, Number.POSITIVE_INFINITY, Number.NaN]) { + const parsed = parseMinerGoalSpec({ + wantedPaths: ["src/**"], + selfPlagiarism: { similarityThreshold: badThreshold }, + }); + expect(parsed.spec.selfPlagiarism).toEqual({ similarityThreshold: 0.85 }); + expect(parsed.warnings.join(" ")).toMatch(/selfPlagiarism\.similarityThreshold.*must be a number in \[0, 1\]/i); + } + + // An in-range value is still accepted verbatim with no warning. + const valid = parseMinerGoalSpec({ selfPlagiarism: { similarityThreshold: 0.42 } }); + expect(valid.spec.selfPlagiarism).toEqual({ similarityThreshold: 0.42 }); + expect(valid.warnings.join(" ")).not.toMatch(/similarityThreshold/i); + }); + it("a killSwitch policy alone (all other fields default) marks the spec present", () => { const parsed = parseMinerGoalSpec({ killSwitch: { paused: true } }); expect(parsed.present).toBe(true);