Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 201 additions & 0 deletions packages/inflekt/__tests__/four-properties.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/**
* The four properties every singular/plural pair must satisfy, because callers
* hand us words in both numbers and ask for either one:
*
* singularize(singular) === singular
* pluralize(singular) === plural
* pluralize(plural) === plural
* singularize(plural) === singular
*
* The corpora here are the hand-picked ones — identifiers a schema actually
* contains, and the English shapes that break naive suffix rules. The
* exhaustive check is scripts/audit.ts, which runs these same four properties
* over every singular/plural pair in the system dictionary.
*/
import { pluralize, singularize } from '../src/pluralize';

/** Identifiers, as a developer writes them in a schema. */
const IDENTIFIERS: Array<[string, string]> = [
// coined acronyms and abbreviations: never in a dictionary, and the reason
// "drop the s" has to be the fallback for -is/-us
['api', 'apis'], ['uri', 'uris'], ['url', 'urls'], ['uuid', 'uuids'],
['guid', 'guids'], ['id', 'ids'], ['ip', 'ips'], ['cpu', 'cpus'],
['gpu', 'gpus'], ['ram', 'rams'], ['acl', 'acls'], ['sdk', 'sdks'],
['cli', 'clis'], ['ui', 'uis'], ['ux', 'uxes'], ['dag', 'dags'],
['pid', 'pids'], ['fd', 'fds'], ['csr', 'csrs'], ['sha', 'shas'],
['jwt', 'jwts'], ['saml', 'samls'], ['totp', 'totps'], ['otp', 'otps'],
['mfa', 'mfas'], ['orm', 'orms'], ['crud', 'cruds'],
// formats and languages
['json', 'jsons'], ['yaml', 'yamls'], ['toml', 'tomls'], ['csv', 'csvs'],
['sql', 'sqls'], ['html', 'htmls'], ['css', 'csses'],
// -x words, where Latin and English disagree
['vertex', 'vertices'], ['matrix', 'matrices'], ['mutex', 'mutexes'],
['regex', 'regexes'],
// -a words the inflection library mistakes for Latin plurals
['schema', 'schemas'], ['lambda', 'lambdas'], ['delta', 'deltas'],
['beta', 'betas'], ['alpha', 'alphas'], ['quota', 'quotas'],
['replica', 'replicas'], ['persona', 'personas'], ['formula', 'formulas'],
['antenna', 'antennas'],
// -us/-is words that are singular, not plural
['status', 'statuses'], ['radius', 'radiuses'], ['focus', 'focuses'],
['syllabus', 'syllabuses'], ['bus', 'buses'], ['alias', 'aliases'],
['bias', 'biases'], ['axis', 'axes'], ['analysis', 'analyses'],
['basis', 'bases'],
// genuine Latin, where singularize and pluralize must agree with each other
['datum', 'data'], ['metadatum', 'metadata'], ['criterion', 'criteria'],
['phenomenon', 'phenomena'], ['medium', 'media'], ['stratum', 'strata'],
['corpus', 'corpora'], ['forum', 'forums'],
// -o words: "s", not "es"
['repo', 'repos'], ['proto', 'protos'], ['photo', 'photos'],
['video', 'videos'], ['macro', 'macros'], ['micro', 'micros'],
// GraphQL and database vocabulary
['mutation', 'mutations'], ['subscription', 'subscriptions'],
['resolver', 'resolvers'], ['directive', 'directives'],
['fragment', 'fragments'], ['migration', 'migrations'],
['snapshot', 'snapshots'], ['cursor', 'cursors'], ['policy', 'policies'],
['query', 'queries'], ['tenant', 'tenants'], ['grant', 'grants'],
['role', 'roles'], ['scope', 'scopes'], ['claim', 'claims'],
['session', 'sessions'], ['token', 'tokens'], ['webhook', 'webhooks'],
['endpoint', 'endpoints'], ['payload', 'payloads'], ['directory', 'directories'],
['proxy', 'proxies'], ['gateway', 'gateways'], ['queue', 'queues'],
['cache', 'caches'], ['class', 'classes'], ['process', 'processes'],
['hash', 'hashes'], ['branch', 'branches'], ['patch', 'patches'],
['batch', 'batches'], ['match', 'matches'], ['checksum', 'checksums'],
['digest', 'digests'], ['bucket', 'buckets'], ['volume', 'volumes'],
['partition', 'partitions'], ['shard', 'shards'], ['cluster', 'clusters'],
['node', 'nodes'], ['pod', 'pods'], ['disk', 'disks'], ['edge', 'edges'],
['graph', 'graphs'], ['heap', 'heaps'], ['stack', 'stacks'],
['kernel', 'kernels'], ['daemon', 'daemons'], ['socket', 'sockets'],
['thread', 'threads'], ['codec', 'codecs'], ['cron', 'crons'],
['config', 'configs'], ['env', 'envs'], ['plugin', 'plugins'],
['addon', 'addons'], ['secret', 'secrets'], ['certificate', 'certificates'],
['tuple', 'tuples'], ['struct', 'structs'], ['union', 'unions'],
['enum', 'enums'], ['bool', 'bools'], ['byte', 'bytes'], ['bit', 'bits'],
['blob', 'blobs'], ['file', 'files'], ['key', 'keys'], ['value', 'values'],
['object', 'objects'], ['array', 'arrays'], ['vector', 'vectors'],
['tensor', 'tensors'], ['embedding', 'embeddings'], ['mock', 'mocks'],
['stub', 'stubs'], ['offset', 'offsets'],
// snake_case compounds: the last word decides
['api_key', 'api_keys'], ['access_token', 'access_tokens'],
['user_profile', 'user_profiles'], ['schema_migration', 'schema_migrations'],
['oauth_client', 'oauth_clients'], ['ssh_key', 'ssh_keys'],
['index_stat', 'index_stats'], ['full_text_search', 'full_text_searches'],
['api_status', 'api_statuses'], ['user_analysis', 'user_analyses'],
];

/** The English shapes that break naive suffix rules. */
const ENGLISH: Array<[string, string]> = [
// irregulars
['person', 'people'], ['child', 'children'], ['man', 'men'],
['woman', 'women'], ['foot', 'feet'], ['tooth', 'teeth'],
['goose', 'geese'], ['mouse', 'mice'], ['louse', 'lice'], ['ox', 'oxen'],
// -f/-fe: only the f-stem nouns take -ves
['knife', 'knives'], ['life', 'lives'], ['wife', 'wives'],
['shelf', 'shelves'], ['leaf', 'leaves'], ['thief', 'thieves'],
['bookshelf', 'bookshelves'], ['cafe', 'cafes'], ['safe', 'safes'],
['roof', 'roofs'], ['belief', 'beliefs'], ['chief', 'chiefs'],
// -ves that is not an f-stem plural at all
['olive', 'olives'], ['drive', 'drives'], ['archive', 'archives'],
// -is/-us singulars, and the Greek/Latin -sis family
['iris', 'irises'], ['chassis', 'chassis'], ['atlas', 'atlases'],
['virus', 'viruses'], ['census', 'censuses'], ['genius', 'geniuses'],
['campus', 'campuses'], ['cactus', 'cactuses'], ['thesis', 'theses'],
['crisis', 'crises'], ['hypothesis', 'hypotheses'], ['diagnosis', 'diagnoses'],
['parenthesis', 'parentheses'], ['synopsis', 'synopses'], ['oasis', 'oases'],
// -ss and -s stems
['class', 'classes'], ['address', 'addresses'], ['lens', 'lenses'],
['princess', 'princesses'], ['business', 'businesses'],
// -y and -ie
['city', 'cities'], ['company', 'companies'], ['cookie', 'cookies'],
['movie', 'movies'], ['day', 'days'], ['key', 'keys'],
// -o
['potato', 'potatoes'], ['tomato', 'tomatoes'], ['hero', 'heroes'],
['piano', 'pianos'], ['zero', 'zeros'], ['echo', 'echoes'],
// -ch/-sh/-x/-z
['church', 'churches'], ['dish', 'dishes'], ['box', 'boxes'],
['quiz', 'quizzes'], ['buzz', 'buzzes'], ['waltz', 'waltzes'],
// Latin and Greek. -ex/-ix takes the Latin plural wherever the dictionary
// attests one, which is also what PostGraphile's inflector (the `pluralize`
// package) does: indices, appendices, vertices, matrices. Elsewhere the
// English plural wins wherever the dictionary attests one (alumnuses,
// aquariums) — deliberately unlike `pluralize`, which coins "radii" and
// "cacti" for identifiers — and the Latin plural is kept only where English
// has none (curricula, memoranda, genera).
['appendix', 'appendices'], ['index', 'indices'], ['alumnus', 'alumnuses'],
['curriculum', 'curricula'], ['memorandum', 'memoranda'],
['aquarium', 'aquariums'], ['genus', 'genera'],
// -o: "es" for the nouns that take it, "s" for the rest
['veto', 'vetoes'],
// -ice, which the inflection library reads as -ouse
['police', 'polices'], ['service', 'services'], ['chalice', 'chalices'],
// compounds where the last word is irregular
['salesperson', 'salespeople'], ['grandchild', 'grandchildren'],
];

/** Words with no distinct plural: both numbers are the same word. */
const INVARIANT = [
'sheep', 'series', 'species', 'aircraft', 'news', 'equipment',
'information', 'software', 'chassis', 'deer', 'fish', 'moose', 'salmon',
];

describe.each([
['identifiers', IDENTIFIERS],
['english', ENGLISH],
])('%s', (_name, corpus) => {
it.each(corpus)('%s <-> %s', (singular, plural) => {
expect(singularize(singular)).toBe(singular);
expect(pluralize(singular)).toBe(plural);
expect(pluralize(plural)).toBe(plural);
expect(singularize(plural)).toBe(singular);
});
});

describe('invariant words', () => {
it.each(INVARIANT)('%s', (word) => {
expect(pluralize(word)).toBe(word);
expect(singularize(word)).toBe(word);
});
});

describe('casing is preserved in both directions', () => {
it.each([
['ApiSchema', 'ApiSchemas'],
['UserStatus', 'UserStatuses'],
['AnalysisResult', 'AnalysisResults'],
['PersonAddress', 'PersonAddresses'],
['API_KEY', 'API_KEYS'],
['USER_STATUS', 'USER_STATUSES'],
])('%s <-> %s', (singular, plural) => {
expect(singularize(singular)).toBe(singular);
expect(pluralize(singular)).toBe(plural);
expect(pluralize(plural)).toBe(plural);
expect(singularize(plural)).toBe(singular);
});
});

/**
* Words no algorithm can get right without knowing what the author meant.
* Pinned so a change of behaviour is a deliberate decision, not a surprise.
*/
describe('inherently ambiguous', () => {
it('reads -ies as the dictionary does, not as an identifier', () => {
// "tries" is the plural of "try" in every dictionary; that a codebase also
// spells the plural of "trie" that way is invisible here.
expect(singularize('tries')).toBe('try');
// Same shape, opposite direction: "tty" is not a word, so the -y rule for
// words (city -> cities) applies to it.
expect(pluralize('tty')).toBe('tties');
});

it('treats a consonant-only acronym as a word, since nothing marks it', () => {
// "dns" has the shape of a plural (and "cds", "urls" and "ids" really are
// plurals), so it loses the "s" like any other unknown -s word.
expect(singularize('dns')).toBe('dn');
});

it('reads a word that is also a plural as the word', () => {
// wamerican lists "cons" as a word in its own right (with the plural
// "conses"), so it is left alone rather than read as the plural of "con".
expect(singularize('cons')).toBe('cons');
});
});
2 changes: 2 additions & 0 deletions packages/inflekt/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"build": "makage build",
"lint": "eslint . --fix",
"test": "jest",
"audit": "ts-node scripts/audit.ts",
"generate": "ts-node scripts/generate-exceptions.ts",
"test:watch": "jest --watch"
},
"repository": {
Expand Down
110 changes: 110 additions & 0 deletions packages/inflekt/scripts/audit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* Scratch audit (not shipped): measure the four inflection properties against
* the dictionary-attested singular/plural pairs.
*
* npx ts-node scripts/audit.ts [--sample N]
*
* A singular may have several attested plurals (apexes and apices, cactuses and
* cacti): any of them counts as correct, since which one to prefer is a style
* choice, not a correctness one.
*/
import { readFileSync, writeFileSync } from 'fs';

import { pluralize, singularize } from '../src/pluralize';

const words = readFileSync('/usr/share/dict/words', 'utf8')
.split('\n')
.map((w) => w.trim())
.filter((w) => /^[a-z]{3,}$/.test(w));
const dictionary = new Set(words);

/** Words that look like plurals but are not (or have no singular). */
const NOT_PLURALS = new Set([
'acoustics', 'aerobics', 'athletics', 'crossroads', 'economics', 'ethics',
'genetics', 'gymnastics', 'linguistics', 'logistics', 'mathematics',
'mechanics', 'news', 'obstetrics', 'optics', 'physics', 'politics', 'series',
'species', 'statistics', 'summons', 'hes', 'hises', 'ses', 'shes', 'sis',
'sises', 'these', 'shucks', 'biceps', 'triceps', 'forceps',
]);

function candidates(word: string): string[] {
const out: string[] = [];
if (word.endsWith('s')) out.push(word.slice(0, -1));
if (word.endsWith('es')) out.push(word.slice(0, -2), word.slice(0, -2) + 'is');
if (word.endsWith('ies')) out.push(word.slice(0, -3) + 'y');
if (word.endsWith('ses')) out.push(word.slice(0, -2));
if (word.endsWith('ves')) out.push(word.slice(0, -3) + 'f', word.slice(0, -3) + 'fe');
if (word.endsWith('ices')) out.push(word.slice(0, -4) + 'ex', word.slice(0, -4) + 'ix');
return out;
}

// singular -> every attested plural of it
const attested = new Map<string, Set<string>>();
for (const word of words) {
if (!word.endsWith('s') || word.endsWith('ss') || NOT_PLURALS.has(word)) continue;
const valid = [...new Set(candidates(word))].filter(
(c) => c.length > 1 && dictionary.has(c) && c !== word
);
if (valid.length !== 1) continue;
const plurals = attested.get(valid[0]) ?? new Set<string>();
plurals.add(word);
attested.set(valid[0], plurals);
}

const buckets: Record<string, string[]> = {
'singularize(singular) != singular': [],
'pluralize(singular) not attested': [],
'pluralize(plural) != plural': [],
'singularize(plural) != singular': [],
};

let pairCount = 0;
for (const [singular, plurals] of attested) {
pairCount += plurals.size;
const want = [...plurals].join('/');

if (singularize(singular) !== singular) {
buckets['singularize(singular) != singular'].push(
`${singular} -> ${singularize(singular)}`
);
}
const got = pluralize(singular);
if (!plurals.has(got)) {
buckets['pluralize(singular) not attested'].push(
`${singular} -> ${got} (attested ${want})`
);
}
for (const plural of plurals) {
if (pluralize(plural) !== plural) {
buckets['pluralize(plural) != plural'].push(`${plural} -> ${pluralize(plural)}`);
}
if (singularize(plural) !== singular) {
buckets['singularize(plural) != singular'].push(
`${plural} -> ${singularize(plural)} (want ${singular})`
);
}
}
}

const sampleFlag = process.argv.indexOf('--sample');
const sample = sampleFlag === -1 ? 15 : Number(process.argv[sampleFlag + 1]);

let report = `singulars: ${attested.size}, attested plurals: ${pairCount}\n`;
for (const [name, failures] of Object.entries(buckets)) {
const rate = ((failures.length / attested.size) * 100).toFixed(2);
report += `\n${name}: ${failures.length} (${rate}%)\n`;
report += failures.map((f) => ` ${f}`).join('\n') + '\n';
}
writeFileSync('/tmp/audit.txt', report);
console.log(
report
.split('\n')
.filter((line) => !line.startsWith(' '))
.join('\n')
);
for (const [name, failures] of Object.entries(buckets)) {
if (!failures.length) continue;
console.log(`\n${name} (first ${sample}):`);
console.log(failures.slice(0, sample).map((f) => ` ${f}`).join('\n'));
}
console.log('\nfull: /tmp/audit.txt');
Loading
Loading