From bdbe11d16abe1250e8b8a422fd48b60e10866611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CSebastian?= <64795732+slegarraga@users.noreply.github.com> Date: Thu, 6 Aug 2026 05:14:24 -0400 Subject: [PATCH] fix: make exact-match whitespace collapsing opt-out --- README.md | 2 +- src/scorers/exact-match.ts | 6 ++++-- src/scorers/util.ts | 7 +++++-- tests/scorers.test.ts | 22 ++++++++++++++++++++++ 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b66ca8d..01406f3 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ score is the weighted mean of the scorer scores. | Scorer | Passes when | Key options | | --- | --- | --- | -| `exact-match` | output equals `expected` | `expected`, `caseSensitive`, `trim` | +| `exact-match` | output equals `expected` | `expected`, `caseSensitive`, `trim`, `collapseWhitespace` | | `regex` | output matches a pattern | `pattern`, `flags`, `expectMatch` | | `contains` | all substrings present (partial credit) | `value` / `values`, `caseSensitive` | | `not-contains` | no banned substring present | `value` / `values`, `caseSensitive` | diff --git a/src/scorers/exact-match.ts b/src/scorers/exact-match.ts index 09a757e..5c80aa8 100644 --- a/src/scorers/exact-match.ts +++ b/src/scorers/exact-match.ts @@ -9,6 +9,7 @@ import { normalize, result } from "./util.js"; * - `expected`: the value to match (falls back to `case.expected`) * - `caseSensitive`: default false * - `trim`: default true + * - `collapseWhitespace`: default true */ export const exactMatchScorer: Scorer = { type: "exact-match", @@ -23,8 +24,9 @@ export const exactMatchScorer: Scorer = { } const caseSensitive = spec.caseSensitive === true; const trim = spec.trim !== false; - const a = normalize(ctx.output, { caseSensitive, trim }); - const b = normalize(expected, { caseSensitive, trim }); + const collapseWhitespace = spec.collapseWhitespace !== false; + const a = normalize(ctx.output, { caseSensitive, trim, collapseWhitespace }); + const b = normalize(expected, { caseSensitive, trim, collapseWhitespace }); const passed = a === b; return result(spec, { score: passed ? 1 : 0, diff --git a/src/scorers/util.ts b/src/scorers/util.ts index 8f8aa26..bf570a4 100644 --- a/src/scorers/util.ts +++ b/src/scorers/util.ts @@ -29,9 +29,12 @@ export function result( } /** Normalize text for lenient comparisons (trim + collapse whitespace). */ -export function normalize(text: string, opts: { caseSensitive?: boolean; trim?: boolean } = {}): string { +export function normalize( + text: string, + opts: { caseSensitive?: boolean; trim?: boolean; collapseWhitespace?: boolean } = {}, +): string { let out = text; if (opts.trim !== false) out = out.trim(); if (!opts.caseSensitive) out = out.toLowerCase(); - return out.replace(/\s+/g, " "); + return opts.collapseWhitespace === false ? out : out.replace(/\s+/g, " "); } diff --git a/tests/scorers.test.ts b/tests/scorers.test.ts index b53c6a2..7aa813d 100644 --- a/tests/scorers.test.ts +++ b/tests/scorers.test.ts @@ -49,6 +49,28 @@ describe("exact-match", () => { const r = await run(exactMatchScorer, { type: "exact-match" }, ctx("yes", {}, { expected: "yes" })); expect(r.passed).toBe(true); }); + it("collapses internal whitespace by default", async () => { + const r = await run( + exactMatchScorer, + { type: "exact-match", expected: "hello world" }, + ctx("hello\n\nworld"), + ); + expect(r.passed).toBe(true); + }); + it("keeps internal whitespace when collapseWhitespace is false", async () => { + const r = await run( + exactMatchScorer, + { + type: "exact-match", + expected: "hello\nworld", + caseSensitive: true, + trim: false, + collapseWhitespace: false, + }, + ctx("hello world"), + ); + expect(r.passed).toBe(false); + }); }); describe("regex", () => {