diff --git a/packages/core/src/loop/acceptance/acceptance-spec.ts b/packages/core/src/loop/acceptance/acceptance-spec.ts index a6be9c9c..7768cc2f 100644 --- a/packages/core/src/loop/acceptance/acceptance-spec.ts +++ b/packages/core/src/loop/acceptance/acceptance-spec.ts @@ -225,22 +225,29 @@ function buildEntityAcceptance( }; } +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + /** * Check if a field is mentioned (by exact name or humanized form) in a constraint. + * Matches on WORD BOUNDARIES, not raw substring: a short field name like `id`/`age` must not + * match a longer word that merely contains it (`valid`, `manage`), which would wrongly + * associate the field with an unrelated constraint and fabricate a spurious negative test. */ -function fieldIsMentioned( +export function fieldIsMentioned( field: IAcceptField, constraintLower: string ): boolean { - const exactMatch = constraintLower.includes(field.name.toLowerCase()); + const mentions = (needle: string): boolean => + needle.length > 0 && + new RegExp(`\\b${escapeRegExp(needle)}\\b`, "u").test(constraintLower); const humanized = field.name .replace(/([A-Z])/g, " $1") .toLowerCase() .trim(); - const humanizedMatch = - humanized.length > 0 && constraintLower.includes(humanized); - return exactMatch || humanizedMatch; + return mentions(field.name.toLowerCase()) || mentions(humanized); } /** diff --git a/packages/core/tests/acceptance-spec.test.ts b/packages/core/tests/acceptance-spec.test.ts index b8bdfad4..91505f6a 100644 --- a/packages/core/tests/acceptance-spec.test.ts +++ b/packages/core/tests/acceptance-spec.test.ts @@ -3,7 +3,9 @@ import type { IProductPlan } from "../src/loop/planning/plan-types"; import { planToAcceptanceSpec, testIdsFor, + fieldIsMentioned, } from "../src/loop/acceptance/acceptance-spec"; +import type { IAcceptField } from "../src/loop/acceptance/acceptance.types"; const plan: IProductPlan = { product: "CRM", @@ -452,3 +454,32 @@ test("FIX 7: mustNotHappen does not duplicate negatives when field already has o // Should NOT have duplicates — only ONE negative for name="" expect(nameEmptyNegatives.length).toBe(1); }); + +function acceptField(name: string): IAcceptField { + return { name, type: "string", optional: false, valid: "x", invalid: [] }; +} + +test("fieldIsMentioned matches on WORD BOUNDARIES, not raw substring", () => { + // Positive: the field name (or its humanized form) appears as a whole word. + expect( + fieldIsMentioned(acceptField("email"), "a valid email is required") + ).toBe(true); + expect(fieldIsMentioned(acceptField("name"), "name must be unique")).toBe( + true + ); + // Humanized: camelCase field, spaced constraint. + expect( + fieldIsMentioned(acceptField("firstName"), "the first name is required") + ).toBe(true); + + // Negative (the bug): a short field name must NOT match a longer word that contains it — + // `id` inside `valid`, `age` inside `manage` — which would fabricate a spurious negative. + expect(fieldIsMentioned(acceptField("id"), "must be a valid email")).toBe( + false + ); + expect(fieldIsMentioned(acceptField("age"), "manage the records")).toBe( + false + ); + // And it isn't tripped by an unrelated word either. + expect(fieldIsMentioned(acceptField("name"), "the total amount")).toBe(false); +});