/** * lib/string-utils.ts — String transformations for dev CLIs. * Ported from SmartStack.cli/src/mcp/lib/string-utils.ts. * * Centralizes case conversions and naming helpers used by scaffolders, * validators and analyzers that reason about SmartStack naming conventions. */ /** * Convert PascalCase/camelCase to kebab-case. Handles consecutive uppercase letters. * "HumanResources" → "human-resources" * "APIClient" → "api-client" * "HRDepartment" → "hr-department" */ export function toKebabCase(segment: string): string { return segment .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') .replace(/([a-z0-9])([A-Z])/g, '$1-$2') .toLowerCase(); } /** * Convert kebab-case (or already camelCase) to camelCase. * "human-resources" → "humanResources" * "employees" → "employees" */ export function toCamelCase(segment: string): string { return segment.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase()); } /** * Convert any case to PascalCase. * "human-resources" → "HumanResources" * "humanResources" → "HumanResources" */ export function toPascalCase(segment: string): string { const camel = toCamelCase(segment); return camel.charAt(0).toUpperCase() + camel.slice(1); } /** Capitalize the first letter. */ export function capitalize(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1); } /** * Simple English singularization. Handles common plural forms. * "employees" → "employee" * "companies" → "company" * "processes" → "process" * "addresses" → "address" * "status" → "status" */ export function singularize(word: string): string { if (word.endsWith('ies')) return word.slice(0, -3) + 'y'; if (word.endsWith('sses')) return word.slice(0, -2); if ( word.endsWith('shes') || word.endsWith('ches') || word.endsWith('xes') || word.endsWith('zes') ) { return word.slice(0, -2); } if (word.endsWith('ses')) return word.slice(0, -1); if (word.endsWith('s') && !word.endsWith('ss')) return word.slice(0, -1); return word; } /** * Very rough English pluralization (inverse of singularize). * "employee" → "employees" * "company" → "companies" * "address" → "addresses" */ export function pluralize(word: string): string { if (word.endsWith('y') && !/[aeiou]y$/.test(word)) return word.slice(0, -1) + 'ies'; if (word.endsWith('s') || word.endsWith('x') || word.endsWith('z')) return word + 'es'; if (word.endsWith('sh') || word.endsWith('ch')) return word + 'es'; return word + 's'; } /** * Convert a NavRoute dot-path to a URL path with kebab-case segments. * "humanResources.employees" → "human-resources/employees" */ export function navRouteToUrlPath(navRoute: string): string { return navRoute.split('.').map(toKebabCase).join('/'); } const FRENCH_DETERMINERS = new Set([ 'le', 'la', 'les', 'l', 'un', 'une', 'des', 'de', 'du', 'd', 'a', 'au', 'aux', 'et', 'ou', ]); /** * Slugify a human-readable role label into a functional kebab-case code. * Used by Phase 0 Core Foundation Seed to derive Role.code from actor.label, * avoiding propagation of BA analysis codes (e.g. "BA-002-AC-008") that have * no business meaning at the domain level. * * "Gestionnaire de budget" → "gestionnaire-budget" * "Contrôleur de gestion" → "controleur-gestion" * "L'agent des comptes clients" → "agent-comptes-clients" * * Pipeline: NFD-strip accents (Unicode combining marks U+0300..U+036F) → * lowercase → tokenise on non-alphanumeric → * drop French determiners → join with '-'. If every token is a determiner * (pathological case like label="Le"), keep the original tokens so we never * return an empty string for a non-empty input. */ export function slugifyRoleCode(label: string): string { if (!label || !label.trim()) return ''; const ascii = label.normalize('NFD').replace(/[̀-ͯ]/g, ''); const tokens = ascii.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); if (tokens.length === 0) return ''; const meaningful = tokens.filter((t) => !FRENCH_DETERMINERS.has(t)); const finalTokens = meaningful.length > 0 ? meaningful : tokens; return finalTokens.join('-'); } /** * Disambiguate a list of slugs by appending `-2`, `-3`, ... to duplicates so * that every output is unique. Order is preserved — the first occurrence keeps * its plain slug, subsequent collisions get numeric suffixes. * * ["manager", "manager", "controleur", "manager"] * → ["manager", "manager-2", "controleur", "manager-3"] */ export function disambiguateRoleCodes(slugs: string[]): string[] { const seen = new Map(); return slugs.map((s) => { const count = (seen.get(s) ?? 0) + 1; seen.set(s, count); return count === 1 ? s : `${s}-${count}`; }); }