From d9ebc84fc931e306c8b3270eae87ddd08256441a Mon Sep 17 00:00:00 2001 From: Adam Benhassen Date: Fri, 31 Oct 2025 14:23:07 +0100 Subject: [PATCH] feat(core): escape single quotes in all enum descriptions and deprecation reasons --- .changeset/large-walls-wash.md | 5 + packages/core/__tests__/diff/enum.test.ts | 101 +++++++++++++++++++ packages/core/__tests__/utils/string.test.ts | 17 +++- packages/core/src/diff/changes/enum.ts | 18 ++-- packages/core/src/utils/string.ts | 4 + 5 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 .changeset/large-walls-wash.md diff --git a/.changeset/large-walls-wash.md b/.changeset/large-walls-wash.md new file mode 100644 index 0000000000..f44de84e58 --- /dev/null +++ b/.changeset/large-walls-wash.md @@ -0,0 +1,5 @@ +--- +'@graphql-inspector/core': minor +--- + +Escape single quotes in all enum descriptions and deprecation reasons diff --git a/packages/core/__tests__/diff/enum.test.ts b/packages/core/__tests__/diff/enum.test.ts index a049332db4..c3d50056e5 100644 --- a/packages/core/__tests__/diff/enum.test.ts +++ b/packages/core/__tests__/diff/enum.test.ts @@ -274,4 +274,105 @@ describe('enum', () => { expect(change.criticality.reason).toBeDefined(); expect(change.message).toEqual(`Enum value 'C' was added to enum 'enumA'`); }); + + describe('string escaping', () => { + test('deprecation reason changed with escaped single quotes', async () => { + const a = buildSchema(/* GraphQL */ ` + type Query { + fieldA: String + } + + enum enumA { + A @deprecated(reason: "It's old") + B + } + `); + + const b = buildSchema(/* GraphQL */ ` + type Query { + fieldA: String + } + + enum enumA { + A @deprecated(reason: "It's new") + B + } + `); + + const changes = await diff(a, b); + const change = findFirstChangeByPath(changes, 'enumA.A'); + + expect(changes.length).toEqual(1); + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.message).toEqual( + `Enum value 'enumA.A' deprecation reason changed from 'It\\'s old' to 'It\\'s new'`, + ); + }); + + test('deprecation reason added with escaped single quotes', async () => { + const a = buildSchema(/* GraphQL */ ` + type Query { + fieldA: String + } + + enum enumA { + A + B + } + `); + + const b = buildSchema(/* GraphQL */ ` + type Query { + fieldA: String + } + + enum enumA { + A @deprecated(reason: "Don't use this") + B + } + `); + + const changes = await diff(a, b); + const change = findFirstChangeByPath(changes, 'enumA.A'); + + expect(changes.length).toEqual(2); + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.message).toEqual( + `Enum value 'enumA.A' was deprecated with reason 'Don\\'t use this'`, + ); + }); + + test('deprecation reason without single quotes is unchanged', async () => { + const a = buildSchema(/* GraphQL */ ` + type Query { + fieldA: String + } + + enum enumA { + A @deprecated(reason: "Old Reason") + B + } + `); + + const b = buildSchema(/* GraphQL */ ` + type Query { + fieldA: String + } + + enum enumA { + A @deprecated(reason: "New Reason") + B + } + `); + + const changes = await diff(a, b); + const change = findFirstChangeByPath(changes, 'enumA.A'); + + expect(changes.length).toEqual(1); + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.message).toEqual( + `Enum value 'enumA.A' deprecation reason changed from 'Old Reason' to 'New Reason'`, + ); + }); + }); }); diff --git a/packages/core/__tests__/utils/string.test.ts b/packages/core/__tests__/utils/string.test.ts index f1bedf15f5..0b32a2c3b2 100644 --- a/packages/core/__tests__/utils/string.test.ts +++ b/packages/core/__tests__/utils/string.test.ts @@ -1,4 +1,4 @@ -import { safeString } from '../../src/utils/string.js'; +import { fmt, safeString } from '../../src/utils/string.js'; test('scalars', () => { expect(safeString(0)).toBe('0'); @@ -33,3 +33,18 @@ test('array', () => { '[ { foo: 42 } ]', ); }); + +describe('fmt', () => { + test('escapes single quotes in strings', () => { + expect(fmt("It's a test")).toBe("It\\'s a test"); + expect(fmt("Don't do this")).toBe("Don\\'t do this"); + expect(fmt("'quoted'")).toBe("\\'quoted\\'"); + }); + + test('handles strings without single quotes', () => { + expect(fmt('test')).toBe('test'); + expect(fmt('Old Reason')).toBe('Old Reason'); + expect(fmt('enumA.B')).toBe('enumA.B'); + expect(fmt('')).toBe(''); + }); +}); diff --git a/packages/core/src/diff/changes/enum.ts b/packages/core/src/diff/changes/enum.ts index cf6c74dd39..25060bacd3 100644 --- a/packages/core/src/diff/changes/enum.ts +++ b/packages/core/src/diff/changes/enum.ts @@ -1,5 +1,6 @@ import { GraphQLEnumType, GraphQLEnumValue } from 'graphql'; import { isDeprecated } from '../../utils/is-deprecated.js'; +import { fmt } from '../../utils/string.js'; import { Change, ChangeType, @@ -80,13 +81,11 @@ export function enumValueAdded( } function buildEnumValueDescriptionChangedMessage(args: EnumValueDescriptionChangedChange['meta']) { + const oldDesc = fmt(args.oldEnumValueDescription ?? 'undefined'); + const newDesc = fmt(args.newEnumValueDescription ?? 'undefined'); return args.oldEnumValueDescription === null - ? `Description '${args.newEnumValueDescription ?? 'undefined'}' was added to enum value '${ - args.enumName - }.${args.enumValueName}'` - : `Description for enum value '${args.enumName}.${args.enumValueName}' changed from '${ - args.oldEnumValueDescription ?? 'undefined' - }' to '${args.newEnumValueDescription ?? 'undefined'}'`; + ? `Description '${newDesc}' was added to enum value '${args.enumName}.${args.enumValueName}'` + : `Description for enum value '${args.enumName}.${args.enumValueName}' changed from '${oldDesc}' to '${newDesc}'`; } export function enumValueDescriptionChangedFromMeta( @@ -122,7 +121,9 @@ export function enumValueDescriptionChanged( function buildEnumValueDeprecationChangedMessage( args: EnumValueDeprecationReasonChangedChange['meta'], ) { - return `Enum value '${args.enumName}.${args.enumValueName}' deprecation reason changed from '${args.oldEnumValueDeprecationReason}' to '${args.newEnumValueDeprecationReason}'`; + const oldReason = fmt(args.oldEnumValueDeprecationReason); + const newReason = fmt(args.newEnumValueDeprecationReason); + return `Enum value '${args.enumName}.${args.enumValueName}' deprecation reason changed from '${oldReason}' to '${newReason}'`; } export function enumValueDeprecationReasonChangedFromMeta( @@ -158,7 +159,8 @@ export function enumValueDeprecationReasonChanged( function buildEnumValueDeprecationReasonAddedMessage( args: EnumValueDeprecationReasonAddedChange['meta'], ) { - return `Enum value '${args.enumName}.${args.enumValueName}' was deprecated with reason '${args.addedValueDeprecationReason}'`; + const reason = fmt(args.addedValueDeprecationReason); + return `Enum value '${args.enumName}.${args.enumValueName}' was deprecated with reason '${reason}'`; } export function enumValueDeprecationReasonAddedFromMeta( diff --git a/packages/core/src/utils/string.ts b/packages/core/src/utils/string.ts index 62ac0ed2c8..0796f20da5 100644 --- a/packages/core/src/utils/string.ts +++ b/packages/core/src/utils/string.ts @@ -80,3 +80,7 @@ export function safeString(obj: unknown) { .replace(/\[Object: null prototype\] /g, '') .replace(/(^')|('$)/g, ''); } + +export function fmt(reason: string): string { + return reason.replace(/'/g, "\\'"); +}