From 4aaed0e0e155c91307a4f8ed2c07302f7721ed6e Mon Sep 17 00:00:00 2001 From: bhushan4work Date: Wed, 4 Nov 2026 09:27:15 +0530 Subject: [PATCH 1/2] fix(json-formatter): singularize sibilant and ambiguous -ses plurals in XML and TypeScript (#462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit singularize() handled -ies and generic -ses but mis-singularized sibilant plurals (matches → matche, boxes → boxe, quizzes → quizz) and ambiguous -ses words (cases → cas). Add rules for -ches, -shes, -xes, -zes and doubled-consonant -zzes, and make the -ses case strip "es" only when the stem is long enough to be a real singular ending in s (addresses → address, statuses → status), otherwise strip just "s" (cases → case). Preserves categories → category, users → user, boss → bossItem, root → rootItem. --- lib/__tests__/json-utils.test.ts | 65 ++++++++++++++++++++++++++++++++ lib/json-utils.ts | 13 ++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/lib/__tests__/json-utils.test.ts b/lib/__tests__/json-utils.test.ts index 73e0c1e..3ce5ca6 100644 --- a/lib/__tests__/json-utils.test.ts +++ b/lib/__tests__/json-utils.test.ts @@ -292,6 +292,45 @@ describe("toXML", () => { expect(result.value).toContain(" x"); }); + it("singularizes sibilant plurals (-ches, -shes, -xes, -zes)", () => { + const result = toXML( + '{"matches":["a"],"branches":["b"],"hashes":["c"],"boxes":["d"],"fixes":["e"],"quizzes":["f"]}', + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value).toContain("a"); + expect(result.value).toContain("b"); + expect(result.value).toContain("c"); + expect(result.value).toContain("d"); + expect(result.value).toContain("e"); + expect(result.value).toContain("f"); + }); + + it("singularizes ambiguous -ses cases correctly", () => { + const result = toXML('{"cases":["x"],"addresses":["y"],"statuses":["z"]}'); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value).toContain("x"); + expect(result.value).toContain("
y
"); + expect(result.value).toContain("z"); + }); + + it("preserves existing singularization for -ies, simple -s, and non-plural keys", () => { + const result = toXML( + '{"categories":["a"],"users":["b"],"boss":["c"],"root":["d"],"tags":["e"]}', + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value).toContain("a"); + expect(result.value).toContain("b"); + expect(result.value).toContain("c"); + expect(result.value).toContain("d"); + expect(result.value).toContain("e"); + }); + it("wraps a top-level array of arrays, each sub-array in its own element", () => { const result = toXML("[[1,2],[3]]"); expect(result.ok).toBe(true); @@ -765,6 +804,32 @@ describe("toTypeScript array items", () => { expect(transpile.diagnostics ?? []).toHaveLength(0); } }); + + it("singularizes sibilant plural array names in interfaces", () => { + const result = toTypeScript('{"matches":[{"x":1}],"branches":[{"y":2}],"boxes":[{"z":3}]}'); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value).toContain("export interface Match {"); + expect(result.value).toContain("matches: Match[];"); + expect(result.value).toContain("export interface Branch {"); + expect(result.value).toContain("branches: Branch[];"); + expect(result.value).toContain("export interface Box {"); + expect(result.value).toContain("boxes: Box[];"); + }); + + it("singularizes ambiguous -ses array names in interfaces", () => { + const result = toTypeScript('{"cases":[{"a":1}],"addresses":[{"b":2}],"statuses":[{"c":3}]}'); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value).toContain("export interface Case {"); + expect(result.value).toContain("cases: Case[];"); + expect(result.value).toContain("export interface Address {"); + expect(result.value).toContain("addresses: Address[];"); + expect(result.value).toContain("export interface Status {"); + expect(result.value).toContain("statuses: Status[];"); + }); }); /* ----------------------------------------------------------------------- diff --git a/lib/json-utils.ts b/lib/json-utils.ts index b34bafc..8cfce2f 100644 --- a/lib/json-utils.ts +++ b/lib/json-utils.ts @@ -239,7 +239,18 @@ function pascalCase(s: string): string { function singularize(name: string): string { if (/ies$/.test(name)) return name.replace(/ies$/, "y"); - if (/ses$/.test(name)) return name.replace(/es$/, ""); + // Sibilant plurals with a doubled consonant: quizzes → quiz + if (/zzes$/.test(name)) return name.replace(/zes$/, ""); + // Sibilant plurals: matches → match, branches → branch, hashes → hash, boxes → box, fixes → fix + if (/(ches|shes|xes|zes)$/.test(name)) return name.replace(/es$/, ""); + // Ambiguous -ses: addresses → address and statuses → status (strip "es"; the stem ends + // in "s" and is long enough to be a real singular), but cases → case (strip just "s"; + // "cas" is too short). + if (/ses$/.test(name)) { + const stripped = name.replace(/es$/, ""); + if (stripped.length >= 5 && /s$/.test(stripped)) return stripped; + return name.replace(/s$/, ""); + } if (/s$/.test(name) && !/ss$/.test(name)) return name.replace(/s$/, ""); return name + "Item"; } From 480057d42fc53d4e32cdb635df2f31ebfade0de6 Mon Sep 17 00:00:00 2001 From: bhushan4work Date: Wed, 4 Nov 2026 11:12:55 +0530 Subject: [PATCH 2/2] fix(json-formatter): refine singularization for -zes and ambiguous -ses plurals (#462) - Only strip "es" on -zzes (e.g. quizzes -> quiz), keeping sizes -> size, prizes -> prize, mazes -> maze intact - For ambiguous -ses, remove "es" only when the resulting stem ends in ss, tus, rus, nus, pus, or bus; otherwise strip only trailing "s" - Keep -ches, -shes, and -xes rules (caches -> cach) - Add regression tests covering direct singularize(), toXML(), and toTypeScript() conversions --- lib/__tests__/json-utils.test.ts | 159 +++++++++++++++++++++++++++++++ lib/json-utils.ts | 25 +++-- 2 files changed, 171 insertions(+), 13 deletions(-) diff --git a/lib/__tests__/json-utils.test.ts b/lib/__tests__/json-utils.test.ts index 3ce5ca6..1f889c4 100644 --- a/lib/__tests__/json-utils.test.ts +++ b/lib/__tests__/json-utils.test.ts @@ -12,6 +12,7 @@ import { parseJson5Compat, diffLines, validate, + singularize, SAMPLE_JSON, } from "../json-utils"; @@ -317,6 +318,51 @@ describe("toXML", () => { expect(result.value).toContain("z"); }); + it("singularizes ambiguous -ses words based on stem endings", () => { + const stems = toXML( + '{"addresses":["a"],"classes":["b"],"statuses":["c"],"viruses":["d"],"bonuses":["e"],"campuses":["f"],"buses":["g"]}', + ); + expect(stems.ok).toBe(true); + if (stems.ok) { + expect(stems.value).toContain("
a
"); + expect(stems.value).toContain("b"); + expect(stems.value).toContain("c"); + expect(stems.value).toContain("d"); + expect(stems.value).toContain("e"); + expect(stems.value).toContain("f"); + expect(stems.value).toContain("g"); + } + + const nonStems = toXML( + '{"cases":["a"],"causes":["b"],"houses":["c"],"responses":["d"],"courses":["e"],"releases":["f"],"licenses":["g"],"databases":["h"]}', + ); + expect(nonStems.ok).toBe(true); + if (nonStems.ok) { + expect(nonStems.value).toContain("a"); + expect(nonStems.value).toContain("b"); + expect(nonStems.value).toContain("c"); + expect(nonStems.value).toContain("d"); + expect(nonStems.value).toContain("e"); + expect(nonStems.value).toContain("f"); + expect(nonStems.value).toContain("g"); + expect(nonStems.value).toContain("h"); + } + }); + + it("handles -zes plurals and caches appropriately in XML", () => { + const result = toXML( + '{"sizes":["s"],"prizes":["p"],"mazes":["m"],"quizzes":["q"],"caches":["c"]}', + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value).toContain("s"); + expect(result.value).toContain("p"); + expect(result.value).toContain("m"); + expect(result.value).toContain("q"); + expect(result.value).toContain("c"); + }); + it("preserves existing singularization for -ies, simple -s, and non-plural keys", () => { const result = toXML( '{"categories":["a"],"users":["b"],"boss":["c"],"root":["d"],"tags":["e"]}', @@ -830,6 +876,119 @@ describe("toTypeScript array items", () => { expect(result.value).toContain("export interface Status {"); expect(result.value).toContain("statuses: Status[];"); }); + + it("singularizes -ses and -zes array names in interfaces", () => { + const result = toTypeScript( + JSON.stringify({ + classes: [{ id: 1 }], + viruses: [{ id: 2 }], + bonuses: [{ id: 3 }], + campuses: [{ id: 4 }], + buses: [{ id: 5 }], + causes: [{ id: 6 }], + houses: [{ id: 7 }], + responses: [{ id: 8 }], + courses: [{ id: 9 }], + releases: [{ id: 10 }], + licenses: [{ id: 11 }], + databases: [{ id: 12 }], + sizes: [{ id: 13 }], + prizes: [{ id: 14 }], + mazes: [{ id: 15 }], + quizzes: [{ id: 16 }], + }), + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.value).toContain("export interface Class {"); + expect(result.value).toContain("classes: Class[];"); + expect(result.value).toContain("export interface Virus {"); + expect(result.value).toContain("viruses: Virus[];"); + expect(result.value).toContain("export interface Bonus {"); + expect(result.value).toContain("bonuses: Bonus[];"); + expect(result.value).toContain("export interface Campus {"); + expect(result.value).toContain("campuses: Campus[];"); + expect(result.value).toContain("export interface Bus {"); + expect(result.value).toContain("buses: Bus[];"); + expect(result.value).toContain("export interface Cause {"); + expect(result.value).toContain("causes: Cause[];"); + expect(result.value).toContain("export interface House {"); + expect(result.value).toContain("houses: House[];"); + expect(result.value).toContain("export interface Response {"); + expect(result.value).toContain("responses: Response[];"); + expect(result.value).toContain("export interface Course {"); + expect(result.value).toContain("courses: Course[];"); + expect(result.value).toContain("export interface Release {"); + expect(result.value).toContain("releases: Release[];"); + expect(result.value).toContain("export interface License {"); + expect(result.value).toContain("licenses: License[];"); + expect(result.value).toContain("export interface Database {"); + expect(result.value).toContain("databases: Database[];"); + expect(result.value).toContain("export interface Size {"); + expect(result.value).toContain("sizes: Size[];"); + expect(result.value).toContain("export interface Prize {"); + expect(result.value).toContain("prizes: Prize[];"); + expect(result.value).toContain("export interface Maze {"); + expect(result.value).toContain("mazes: Maze[];"); + expect(result.value).toContain("export interface Quiz {"); + expect(result.value).toContain("quizzes: Quiz[];"); + }); +}); + +describe("singularize", () => { + it("singularizes all required maintainer corrections and preserves existing rules", () => { + // Sibilant plurals: -ches, -shes, -xes + expect(singularize("matches")).toBe("match"); + expect(singularize("branches")).toBe("branch"); + expect(singularize("hashes")).toBe("hash"); + expect(singularize("boxes")).toBe("box"); + expect(singularize("fixes")).toBe("fix"); + expect(singularize("caches")).toBe("cach"); + + // -zes handling: -zzes vs non-doubled + expect(singularize("quizzes")).toBe("quiz"); + expect(singularize("sizes")).toBe("size"); + expect(singularize("prizes")).toBe("prize"); + expect(singularize("mazes")).toBe("maze"); + + // Ambiguous -ses: stem ends in ss, tus, rus, nus, pus, bus -> remove es + expect(singularize("addresses")).toBe("address"); + expect(singularize("classes")).toBe("class"); + expect(singularize("statuses")).toBe("status"); + expect(singularize("viruses")).toBe("virus"); + expect(singularize("bonuses")).toBe("bonus"); + expect(singularize("campuses")).toBe("campus"); + expect(singularize("buses")).toBe("bus"); + + // Ambiguous -ses: stem does not end in those endings -> remove trailing s + expect(singularize("cases")).toBe("case"); + expect(singularize("causes")).toBe("cause"); + expect(singularize("houses")).toBe("house"); + expect(singularize("responses")).toBe("response"); + expect(singularize("courses")).toBe("course"); + expect(singularize("releases")).toBe("release"); + expect(singularize("licenses")).toBe("license"); + expect(singularize("databases")).toBe("database"); + + // Preserved rules + expect(singularize("categories")).toBe("category"); + expect(singularize("users")).toBe("user"); + expect(singularize("boss")).toBe("bossItem"); + expect(singularize("root")).toBe("rootItem"); + + // PascalCase support for TypeScript interface naming + expect(singularize("Addresses")).toBe("Address"); + expect(singularize("Classes")).toBe("Class"); + expect(singularize("Statuses")).toBe("Status"); + expect(singularize("Viruses")).toBe("Virus"); + expect(singularize("Bonuses")).toBe("Bonus"); + expect(singularize("Campuses")).toBe("Campus"); + expect(singularize("Buses")).toBe("Bus"); + expect(singularize("Cases")).toBe("Case"); + expect(singularize("Sizes")).toBe("Size"); + expect(singularize("Quizzes")).toBe("Quiz"); + }); }); /* ----------------------------------------------------------------------- diff --git a/lib/json-utils.ts b/lib/json-utils.ts index 8cfce2f..83ffa3f 100644 --- a/lib/json-utils.ts +++ b/lib/json-utils.ts @@ -237,21 +237,20 @@ function pascalCase(s: string): string { return /^[0-9]/.test(result) ? `_${result}` : result; } -function singularize(name: string): string { - if (/ies$/.test(name)) return name.replace(/ies$/, "y"); +export function singularize(name: string): string { + if (/ies$/i.test(name)) return name.replace(/ies$/i, "y"); // Sibilant plurals with a doubled consonant: quizzes → quiz - if (/zzes$/.test(name)) return name.replace(/zes$/, ""); + if (/zzes$/i.test(name)) return name.replace(/zes$/i, ""); // Sibilant plurals: matches → match, branches → branch, hashes → hash, boxes → box, fixes → fix - if (/(ches|shes|xes|zes)$/.test(name)) return name.replace(/es$/, ""); - // Ambiguous -ses: addresses → address and statuses → status (strip "es"; the stem ends - // in "s" and is long enough to be a real singular), but cases → case (strip just "s"; - // "cas" is too short). - if (/ses$/.test(name)) { - const stripped = name.replace(/es$/, ""); - if (stripped.length >= 5 && /s$/.test(stripped)) return stripped; - return name.replace(/s$/, ""); - } - if (/s$/.test(name) && !/ss$/.test(name)) return name.replace(/s$/, ""); + if (/(ches|shes|xes)$/i.test(name)) return name.replace(/es$/i, ""); + // Ambiguous -ses: remove "es" only when the stem ends in ss, tus, rus, nus, pus, or bus; + // otherwise remove only the trailing "s". + if (/ses$/i.test(name)) { + const stem = name.slice(0, -2); + if (/(ss|tus|rus|nus|pus|bus)$/i.test(stem)) return stem; + return name.slice(0, -1); + } + if (/s$/i.test(name) && !/ss$/i.test(name)) return name.replace(/s$/i, ""); return name + "Item"; }