From d1dd869ed233c31f8ea05912fa49e380942b1822 Mon Sep 17 00:00:00 2001 From: electrohyun Date: Tue, 8 Sep 2026 23:00:12 +0900 Subject: [PATCH 1/5] fix: escape normalized keys in no-unnormalized-keys autofix --- src/rules/no-unnormalized-keys.js | 20 ++-- tests/rules/no-unnormalized-keys.test.js | 126 +++++++++++++++++++++++ 2 files changed, 140 insertions(+), 6 deletions(-) diff --git a/src/rules/no-unnormalized-keys.js b/src/rules/no-unnormalized-keys.js index 920d216f..1db6c41b 100644 --- a/src/rules/no-unnormalized-keys.js +++ b/src/rules/no-unnormalized-keys.js @@ -85,12 +85,20 @@ export default /** @satisfies {NoUnnormalizedKeysRuleDefinition} */ ({ return null; } - return fixer.replaceTextRange( - name.type === "String" - ? [name.range[0] + 1, name.range[1] - 1] - : name.range, - normalizedKey, - ); + if (name.type === "String") { + const quote = + context.sourceCode.getText(name)[0]; + const escapedKey = normalizedKey + .replaceAll("\\", "\\\\") + .replaceAll(quote, `\\${quote}`); + + return fixer.replaceTextRange( + [name.range[0] + 1, name.range[1] - 1], + escapedKey, + ); + } + + return fixer.replaceText(name, normalizedKey); }, }); } diff --git a/tests/rules/no-unnormalized-keys.test.js b/tests/rules/no-unnormalized-keys.test.js index 6e12151b..3de29d92 100644 --- a/tests/rules/no-unnormalized-keys.test.js +++ b/tests/rules/no-unnormalized-keys.test.js @@ -260,6 +260,132 @@ ruleTester.run("no-unnormalized-keys", rule, { }, ], }, + { + code: `{"a"b\c'"\": 1}`, + output: `{"a\\"b\\\\c'\\"\\\\": 1}`, + options: [{ form: "NFKC" }], + errors: [ + { + messageId: "unnormalizedKey", + data: { key: "a"b\c'"\" }, + line: 1, + column: 2, + endLine: 1, + endColumn: 12, + }, + ], + }, + { + code: `{"a"b\c'"\": 1}`, + output: `{"a\\"b\\\\c'\\"\\\\": 1}`, + language: "json/jsonc", + options: [{ form: "NFKC" }], + errors: [ + { + messageId: "unnormalizedKey", + data: { key: "a"b\c'"\" }, + line: 1, + column: 2, + endLine: 1, + endColumn: 12, + }, + ], + }, + { + code: `{"a"b\c'"\": 1}`, + output: `{"a\\"b\\\\c'\\"\\\\": 1}`, + language: "json/json5", + options: [{ form: "NFKC" }], + errors: [ + { + messageId: "unnormalizedKey", + data: { key: "a"b\c'"\" }, + line: 1, + column: 2, + endLine: 1, + endColumn: 12, + }, + ], + }, + { + code: `{'a'b\c"'\': 1}`, + output: `{'a\\'b\\\\c"\\'\\\\': 1}`, + language: "json/json5", + options: [{ form: "NFKC" }], + errors: [ + { + messageId: "unnormalizedKey", + data: { key: "a'b\c"'\" }, + line: 1, + column: 2, + endLine: 1, + endColumn: 12, + }, + ], + }, + { + code: `{"a"b\c'"\": 1}`, + output: `{"a\\"b\\\\c'\\"\\\\": 1}`, + options: [{ form: "NFKD" }], + errors: [ + { + messageId: "unnormalizedKey", + data: { key: "a"b\c'"\" }, + line: 1, + column: 2, + endLine: 1, + endColumn: 12, + }, + ], + }, + { + code: `{"a"b\c'"\": 1}`, + output: `{"a\\"b\\\\c'\\"\\\\": 1}`, + language: "json/jsonc", + options: [{ form: "NFKD" }], + errors: [ + { + messageId: "unnormalizedKey", + data: { key: "a"b\c'"\" }, + line: 1, + column: 2, + endLine: 1, + endColumn: 12, + }, + ], + }, + { + code: `{"a"b\c'"\": 1}`, + output: `{"a\\"b\\\\c'\\"\\\\": 1}`, + language: "json/json5", + options: [{ form: "NFKD" }], + errors: [ + { + messageId: "unnormalizedKey", + data: { key: "a"b\c'"\" }, + line: 1, + column: 2, + endLine: 1, + endColumn: 12, + }, + ], + }, + { + code: `{'a'b\c"'\': 1}`, + output: `{'a\\'b\\\\c"\\'\\\\': 1}`, + language: "json/json5", + options: [{ form: "NFKD" }], + errors: [ + { + messageId: "unnormalizedKey", + data: { key: "a'b\c"'\" }, + line: 1, + column: 2, + endLine: 1, + endColumn: 12, + }, + ], + }, // escaped form { code: `{"${escapedNfcO}":"NFC"}`, From 585fb6de277c621129c559275f31b660775472e5 Mon Sep 17 00:00:00 2001 From: electrohyun Date: Wed, 9 Sep 2026 08:26:30 +0900 Subject: [PATCH 2/5] test: clarify normalized key escaping cases --- tests/rules/no-unnormalized-keys.test.js | 93 ++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 8 deletions(-) diff --git a/tests/rules/no-unnormalized-keys.test.js b/tests/rules/no-unnormalized-keys.test.js index 3de29d92..917900b9 100644 --- a/tests/rules/no-unnormalized-keys.test.js +++ b/tests/rules/no-unnormalized-keys.test.js @@ -260,9 +260,86 @@ ruleTester.run("no-unnormalized-keys", rule, { }, ], }, + { + code: `{"a"b": 1}`, + output: String.raw`{"a\"b": 1}`, + options: [{ form: "NFKC" }], + errors: [ + { + messageId: "unnormalizedKey", + data: { key: "a"b" }, + line: 1, + column: 2, + endLine: 1, + endColumn: 7, + }, + ], + }, + { + code: `{"a'b": 1}`, + output: String.raw`{"a'b": 1}`, + options: [{ form: "NFKC" }], + errors: [ + { + messageId: "unnormalizedKey", + data: { key: "a'b" }, + line: 1, + column: 2, + endLine: 1, + endColumn: 7, + }, + ], + }, + { + code: `{"a\b": 1}`, + output: String.raw`{"a\\b": 1}`, + options: [{ form: "NFKC" }], + errors: [ + { + messageId: "unnormalizedKey", + data: { key: "a\b" }, + line: 1, + column: 2, + endLine: 1, + endColumn: 7, + }, + ], + }, + { + code: `{'a'b': 1}`, + output: String.raw`{'a\'b': 1}`, + language: "json/json5", + options: [{ form: "NFKC" }], + errors: [ + { + messageId: "unnormalizedKey", + data: { key: "a'b" }, + line: 1, + column: 2, + endLine: 1, + endColumn: 7, + }, + ], + }, + { + code: `{'a"b': 1}`, + output: String.raw`{'a"b': 1}`, + language: "json/json5", + options: [{ form: "NFKC" }], + errors: [ + { + messageId: "unnormalizedKey", + data: { key: "a"b" }, + line: 1, + column: 2, + endLine: 1, + endColumn: 7, + }, + ], + }, { code: `{"a"b\c'"\": 1}`, - output: `{"a\\"b\\\\c'\\"\\\\": 1}`, + output: String.raw`{"a\"b\\c'\"\\": 1}`, options: [{ form: "NFKC" }], errors: [ { @@ -277,7 +354,7 @@ ruleTester.run("no-unnormalized-keys", rule, { }, { code: `{"a"b\c'"\": 1}`, - output: `{"a\\"b\\\\c'\\"\\\\": 1}`, + output: String.raw`{"a\"b\\c'\"\\": 1}`, language: "json/jsonc", options: [{ form: "NFKC" }], errors: [ @@ -293,7 +370,7 @@ ruleTester.run("no-unnormalized-keys", rule, { }, { code: `{"a"b\c'"\": 1}`, - output: `{"a\\"b\\\\c'\\"\\\\": 1}`, + output: String.raw`{"a\"b\\c'\"\\": 1}`, language: "json/json5", options: [{ form: "NFKC" }], errors: [ @@ -309,7 +386,7 @@ ruleTester.run("no-unnormalized-keys", rule, { }, { code: `{'a'b\c"'\': 1}`, - output: `{'a\\'b\\\\c"\\'\\\\': 1}`, + output: String.raw`{'a\'b\\c"\'\\': 1}`, language: "json/json5", options: [{ form: "NFKC" }], errors: [ @@ -325,7 +402,7 @@ ruleTester.run("no-unnormalized-keys", rule, { }, { code: `{"a"b\c'"\": 1}`, - output: `{"a\\"b\\\\c'\\"\\\\": 1}`, + output: String.raw`{"a\"b\\c'\"\\": 1}`, options: [{ form: "NFKD" }], errors: [ { @@ -340,7 +417,7 @@ ruleTester.run("no-unnormalized-keys", rule, { }, { code: `{"a"b\c'"\": 1}`, - output: `{"a\\"b\\\\c'\\"\\\\": 1}`, + output: String.raw`{"a\"b\\c'\"\\": 1}`, language: "json/jsonc", options: [{ form: "NFKD" }], errors: [ @@ -356,7 +433,7 @@ ruleTester.run("no-unnormalized-keys", rule, { }, { code: `{"a"b\c'"\": 1}`, - output: `{"a\\"b\\\\c'\\"\\\\": 1}`, + output: String.raw`{"a\"b\\c'\"\\": 1}`, language: "json/json5", options: [{ form: "NFKD" }], errors: [ @@ -372,7 +449,7 @@ ruleTester.run("no-unnormalized-keys", rule, { }, { code: `{'a'b\c"'\': 1}`, - output: `{'a\\'b\\\\c"\\'\\\\': 1}`, + output: String.raw`{'a\'b\\c"\'\\': 1}`, language: "json/json5", options: [{ form: "NFKD" }], errors: [ From 90d3342e919c33f21525ef753ca1ab20889d5854 Mon Sep 17 00:00:00 2001 From: electrohyun Date: Wed, 9 Sep 2026 08:47:54 +0900 Subject: [PATCH 3/5] refactor: extract normalized key escaping --- src/rules/no-unnormalized-keys.js | 40 +++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/src/rules/no-unnormalized-keys.js b/src/rules/no-unnormalized-keys.js index 1db6c41b..a3a90941 100644 --- a/src/rules/no-unnormalized-keys.js +++ b/src/rules/no-unnormalized-keys.js @@ -20,6 +20,25 @@ import { getKey, getRawKey } from "../util.js"; * @typedef {JSONRuleDefinition<{ RuleOptions: [NoUnnormalizedKeysOptions], MessageIds: NoUnnormalizedKeysMessageIds }>} NoUnnormalizedKeysRuleDefinition */ +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +/** + * Escapes a normalized string key and wraps it in its original quotes. + * @param {string} normalizedKey The normalized key to escape. + * @param {string} keyText The original key text, including quotes. + * @returns {string} The escaped and quoted key. + */ +function escapeKey(normalizedKey, keyText) { + const quote = keyText[0]; + const escapedKey = normalizedKey + .replaceAll("\\", "\\\\") + .replaceAll(quote, `\\${quote}`); + + return `${quote}${escapedKey}${quote}`; +} + //----------------------------------------------------------------------------- // Rule Definition //----------------------------------------------------------------------------- @@ -85,20 +104,15 @@ export default /** @satisfies {NoUnnormalizedKeysRuleDefinition} */ ({ return null; } - if (name.type === "String") { - const quote = - context.sourceCode.getText(name)[0]; - const escapedKey = normalizedKey - .replaceAll("\\", "\\\\") - .replaceAll(quote, `\\${quote}`); - - return fixer.replaceTextRange( - [name.range[0] + 1, name.range[1] - 1], - escapedKey, - ); - } + const fixedKey = + name.type === "String" + ? escapeKey( + normalizedKey, + context.sourceCode.getText(name), + ) + : normalizedKey; - return fixer.replaceText(name, normalizedKey); + return fixer.replaceText(name, fixedKey); }, }); } From f621ec2656c50a32582f3f7e9851156816d00d8c Mon Sep 17 00:00:00 2001 From: electrohyun Date: Tue, 15 Sep 2026 10:27:55 +0900 Subject: [PATCH 4/5] refactor: simplify normalized key escaping --- src/rules/no-unnormalized-keys.js | 10 ++--- tests/rules/no-unnormalized-keys.test.js | 52 ++++++++++++------------ 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/src/rules/no-unnormalized-keys.js b/src/rules/no-unnormalized-keys.js index a3a90941..c62ba673 100644 --- a/src/rules/no-unnormalized-keys.js +++ b/src/rules/no-unnormalized-keys.js @@ -27,11 +27,10 @@ import { getKey, getRawKey } from "../util.js"; /** * Escapes a normalized string key and wraps it in its original quotes. * @param {string} normalizedKey The normalized key to escape. - * @param {string} keyText The original key text, including quotes. + * @param {string} quote The quote character used in the original key. * @returns {string} The escaped and quoted key. */ -function escapeKey(normalizedKey, keyText) { - const quote = keyText[0]; +function escapeKey(normalizedKey, quote) { const escapedKey = normalizedKey .replaceAll("\\", "\\\\") .replaceAll(quote, `\\${quote}`); @@ -81,12 +80,13 @@ export default /** @satisfies {NoUnnormalizedKeysRuleDefinition} */ ({ }, create(context) { + const { sourceCode } = context; const [{ form }] = context.options; return /** @type {JSONRuleVisitor} */ ({ Member(node) { const key = getKey(node); - const rawKey = getRawKey(node, context.sourceCode); + const rawKey = getRawKey(node, sourceCode); const normalizedKey = key.normalize(form); if (normalizedKey !== key) { @@ -108,7 +108,7 @@ export default /** @satisfies {NoUnnormalizedKeysRuleDefinition} */ ({ name.type === "String" ? escapeKey( normalizedKey, - context.sourceCode.getText(name), + sourceCode.text[name.range[0]], ) : normalizedKey; diff --git a/tests/rules/no-unnormalized-keys.test.js b/tests/rules/no-unnormalized-keys.test.js index 917900b9..3d0d2713 100644 --- a/tests/rules/no-unnormalized-keys.test.js +++ b/tests/rules/no-unnormalized-keys.test.js @@ -261,13 +261,13 @@ ruleTester.run("no-unnormalized-keys", rule, { ], }, { - code: `{"a"b": 1}`, + code: `{"a\uff02b": 1}`, output: String.raw`{"a\"b": 1}`, options: [{ form: "NFKC" }], errors: [ { messageId: "unnormalizedKey", - data: { key: "a"b" }, + data: { key: "a\uff02b" }, line: 1, column: 2, endLine: 1, @@ -276,13 +276,13 @@ ruleTester.run("no-unnormalized-keys", rule, { ], }, { - code: `{"a'b": 1}`, + code: `{"a\uff07b": 1}`, output: String.raw`{"a'b": 1}`, options: [{ form: "NFKC" }], errors: [ { messageId: "unnormalizedKey", - data: { key: "a'b" }, + data: { key: "a\uff07b" }, line: 1, column: 2, endLine: 1, @@ -291,13 +291,13 @@ ruleTester.run("no-unnormalized-keys", rule, { ], }, { - code: `{"a\b": 1}`, + code: `{"a\uff3cb": 1}`, output: String.raw`{"a\\b": 1}`, options: [{ form: "NFKC" }], errors: [ { messageId: "unnormalizedKey", - data: { key: "a\b" }, + data: { key: "a\uff3cb" }, line: 1, column: 2, endLine: 1, @@ -306,14 +306,14 @@ ruleTester.run("no-unnormalized-keys", rule, { ], }, { - code: `{'a'b': 1}`, + code: `{'a\uff07b': 1}`, output: String.raw`{'a\'b': 1}`, language: "json/json5", options: [{ form: "NFKC" }], errors: [ { messageId: "unnormalizedKey", - data: { key: "a'b" }, + data: { key: "a\uff07b" }, line: 1, column: 2, endLine: 1, @@ -322,14 +322,14 @@ ruleTester.run("no-unnormalized-keys", rule, { ], }, { - code: `{'a"b': 1}`, + code: `{'a\uff02b': 1}`, output: String.raw`{'a"b': 1}`, language: "json/json5", options: [{ form: "NFKC" }], errors: [ { messageId: "unnormalizedKey", - data: { key: "a"b" }, + data: { key: "a\uff02b" }, line: 1, column: 2, endLine: 1, @@ -338,13 +338,13 @@ ruleTester.run("no-unnormalized-keys", rule, { ], }, { - code: `{"a"b\c'"\": 1}`, + code: `{"a\uff02b\uff3cc\uff07\uff02\uff3c": 1}`, output: String.raw`{"a\"b\\c'\"\\": 1}`, options: [{ form: "NFKC" }], errors: [ { messageId: "unnormalizedKey", - data: { key: "a"b\c'"\" }, + data: { key: "a\uff02b\uff3cc\uff07\uff02\uff3c" }, line: 1, column: 2, endLine: 1, @@ -353,14 +353,14 @@ ruleTester.run("no-unnormalized-keys", rule, { ], }, { - code: `{"a"b\c'"\": 1}`, + code: `{"a\uff02b\uff3cc\uff07\uff02\uff3c": 1}`, output: String.raw`{"a\"b\\c'\"\\": 1}`, language: "json/jsonc", options: [{ form: "NFKC" }], errors: [ { messageId: "unnormalizedKey", - data: { key: "a"b\c'"\" }, + data: { key: "a\uff02b\uff3cc\uff07\uff02\uff3c" }, line: 1, column: 2, endLine: 1, @@ -369,14 +369,14 @@ ruleTester.run("no-unnormalized-keys", rule, { ], }, { - code: `{"a"b\c'"\": 1}`, + code: `{"a\uff02b\uff3cc\uff07\uff02\uff3c": 1}`, output: String.raw`{"a\"b\\c'\"\\": 1}`, language: "json/json5", options: [{ form: "NFKC" }], errors: [ { messageId: "unnormalizedKey", - data: { key: "a"b\c'"\" }, + data: { key: "a\uff02b\uff3cc\uff07\uff02\uff3c" }, line: 1, column: 2, endLine: 1, @@ -385,14 +385,14 @@ ruleTester.run("no-unnormalized-keys", rule, { ], }, { - code: `{'a'b\c"'\': 1}`, + code: `{'a\uff07b\uff3cc\uff02\uff07\uff3c': 1}`, output: String.raw`{'a\'b\\c"\'\\': 1}`, language: "json/json5", options: [{ form: "NFKC" }], errors: [ { messageId: "unnormalizedKey", - data: { key: "a'b\c"'\" }, + data: { key: "a\uff07b\uff3cc\uff02\uff07\uff3c" }, line: 1, column: 2, endLine: 1, @@ -401,13 +401,13 @@ ruleTester.run("no-unnormalized-keys", rule, { ], }, { - code: `{"a"b\c'"\": 1}`, + code: `{"a\uff02b\uff3cc\uff07\uff02\uff3c": 1}`, output: String.raw`{"a\"b\\c'\"\\": 1}`, options: [{ form: "NFKD" }], errors: [ { messageId: "unnormalizedKey", - data: { key: "a"b\c'"\" }, + data: { key: "a\uff02b\uff3cc\uff07\uff02\uff3c" }, line: 1, column: 2, endLine: 1, @@ -416,14 +416,14 @@ ruleTester.run("no-unnormalized-keys", rule, { ], }, { - code: `{"a"b\c'"\": 1}`, + code: `{"a\uff02b\uff3cc\uff07\uff02\uff3c": 1}`, output: String.raw`{"a\"b\\c'\"\\": 1}`, language: "json/jsonc", options: [{ form: "NFKD" }], errors: [ { messageId: "unnormalizedKey", - data: { key: "a"b\c'"\" }, + data: { key: "a\uff02b\uff3cc\uff07\uff02\uff3c" }, line: 1, column: 2, endLine: 1, @@ -432,14 +432,14 @@ ruleTester.run("no-unnormalized-keys", rule, { ], }, { - code: `{"a"b\c'"\": 1}`, + code: `{"a\uff02b\uff3cc\uff07\uff02\uff3c": 1}`, output: String.raw`{"a\"b\\c'\"\\": 1}`, language: "json/json5", options: [{ form: "NFKD" }], errors: [ { messageId: "unnormalizedKey", - data: { key: "a"b\c'"\" }, + data: { key: "a\uff02b\uff3cc\uff07\uff02\uff3c" }, line: 1, column: 2, endLine: 1, @@ -448,14 +448,14 @@ ruleTester.run("no-unnormalized-keys", rule, { ], }, { - code: `{'a'b\c"'\': 1}`, + code: `{'a\uff07b\uff3cc\uff02\uff07\uff3c': 1}`, output: String.raw`{'a\'b\\c"\'\\': 1}`, language: "json/json5", options: [{ form: "NFKD" }], errors: [ { messageId: "unnormalizedKey", - data: { key: "a'b\c"'\" }, + data: { key: "a\uff07b\uff3cc\uff02\uff07\uff3c" }, line: 1, column: 2, endLine: 1, From 71c285631f2fd967e383fa972207675d9a6a1ed0 Mon Sep 17 00:00:00 2001 From: electrohyun Date: Sun, 20 Sep 2026 15:46:43 +0900 Subject: [PATCH 5/5] refactor: simplify normalized key replcement --- src/rules/no-unnormalized-keys.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/rules/no-unnormalized-keys.js b/src/rules/no-unnormalized-keys.js index c62ba673..eab9138e 100644 --- a/src/rules/no-unnormalized-keys.js +++ b/src/rules/no-unnormalized-keys.js @@ -25,17 +25,15 @@ import { getKey, getRawKey } from "../util.js"; //----------------------------------------------------------------------------- /** - * Escapes a normalized string key and wraps it in its original quotes. + * Escapes a normalized string key for use inside its original quotes. * @param {string} normalizedKey The normalized key to escape. * @param {string} quote The quote character used in the original key. - * @returns {string} The escaped and quoted key. + * @returns {string} The escaped key. */ function escapeKey(normalizedKey, quote) { - const escapedKey = normalizedKey + return normalizedKey .replaceAll("\\", "\\\\") .replaceAll(quote, `\\${quote}`); - - return `${quote}${escapedKey}${quote}`; } //----------------------------------------------------------------------------- @@ -90,10 +88,10 @@ export default /** @satisfies {NoUnnormalizedKeysRuleDefinition} */ ({ const normalizedKey = key.normalize(form); if (normalizedKey !== key) { - const { name } = node; + const { loc, range, type } = node.name; context.report({ - loc: name.loc, + loc, messageId: "unnormalizedKey", data: { key: rawKey, @@ -104,15 +102,17 @@ export default /** @satisfies {NoUnnormalizedKeysRuleDefinition} */ ({ return null; } - const fixedKey = - name.type === "String" + return fixer.replaceTextRange( + type === "String" + ? [range[0] + 1, range[1] - 1] + : range, + type === "String" ? escapeKey( normalizedKey, - sourceCode.text[name.range[0]], + sourceCode.text[range[0]], ) - : normalizedKey; - - return fixer.replaceText(name, fixedKey); + : normalizedKey, + ); }, }); }