storebound/cli/lib/jargon.ts

79 lines
5.2 KiB
TypeScript

// Internal jargon in release notes: words players do not use. The one list of them; `whatsNew.allowWords` in the
// config keeps a word the owner wants (a product name that collides, a game term). Pure.
export type JargonKind = 'vendor' | 'dev' | 'vcs' | 'path' | 'issue' | 'identifier';
export interface JargonFinding { word: string; kind: JargonKind; plain?: string }
const DROP_NAME = 'drop the name and say what changed for the player';
/** SDK, service and tool names players never see. */
export const JARGON_VENDORS: readonly string[] = [
'Umami', 'Sentry', 'Firebase', 'Crashlytics', 'Capacitor', 'Tauri', 'Electron', 'Jenkins', 'Forgejo', 'GitHub', 'GitLab',
'Gitea', 'Gradle', 'Xcode', 'CocoaPods', 'Webpack', 'Vite', 'Docker', 'Supabase', 'RevenueCat', 'Amplitude', 'Mixpanel',
'Keeper OS', 'TestFlight', 'Fastlane',
];
/** Developer words, with a plain alternative where one is obvious (undefined: just drop it). */
export const JARGON_DEV: Readonly<Record<string, string | undefined>> = {
telemetry: 'drop it, or "anonymous usage stats"', analytics: 'drop it, or "anonymous usage stats"',
refactor: '"faster" / "more stable", or drop it', refactored: '"faster" / "more stable", or drop it', refactoring: '"faster" / "more stable", or drop it',
endpoint: '"server"', endpoints: '"servers"', payload: '"data"', api: '"connection" / "online features"', apis: '"online features"',
ci: undefined, pr: undefined, prs: undefined, fingerprint: 'say what the player notices', ota: '"in-app update"',
bump: '"update"', bumped: '"updated"', deps: '"under-the-hood updates"', dependency: '"under-the-hood update"', dependencies: '"under-the-hood updates"',
sdk: 'say what changed for the player', sdks: 'say what changed for the player', backend: '"server"', frontend: undefined,
config: '"settings"', regression: '"a bug"', lint: undefined, linter: undefined, typecheck: undefined, nullable: undefined,
async: undefined, websocket: '"live connection"', json: undefined, schema: undefined, webhook: undefined, stacktrace: '"crash"',
'stack trace': '"crash"', env: undefined, changelog: undefined, codebase: undefined, repo: undefined,
};
/** Version-control words ("reads like a commit log"). */
export const JARGON_VCS: Readonly<Record<string, string | undefined>> = {
commit: undefined, commits: undefined, merge: undefined, merged: undefined, rebase: undefined, 'pull request': undefined,
'merge request': undefined, revert: '"undo"', reverted: '"undid"', squash: undefined, git: undefined, 'cherry-pick': undefined,
};
/** Product names that look like code identifiers. */
const NOT_IDENTIFIERS = /^(iPhone|iPad|iPod|iOS|iCloud|iMessage|macOS|watchOS|tvOS|visionOS|eBook|eSIM|eSports|YouTube|iTunes|AirPods|AirPlay)s?$/;
const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const wordRe = (w: string): RegExp => new RegExp(`(?<![\\p{L}\\p{N}_-])${esc(w).replace(/ /g, '\\s+')}(?![\\p{L}\\p{N}_])`, 'iu');
/** Internal words in a notes text, each once, in text order. `allow`: words the owner keeps (case-insensitive). */
export function jargonFindings(text: string, allow: readonly string[] = []): JargonFinding[] {
const allowed = new Set(allow.map(w => w.toLowerCase()));
const hits: { at: number; f: JargonFinding }[] = [];
const seen = new Set<string>();
const add = (at: number, f: JargonFinding): void => {
const k = f.word.toLowerCase();
if (seen.has(k) || allowed.has(k)) return;
seen.add(k);
hits.push({ at, f });
};
// links are fine (a support page); look at the text without them
const t = text.replace(/https?:\/\/\S+/g, m => ' '.repeat(m.length));
const scan = (words: Iterable<[string, string | undefined]>, kind: JargonKind): void => {
for (const [w, plain] of words) { const m = wordRe(w).exec(t); if (m) add(m.index, { word: kind === 'vendor' ? w : m[0], kind, plain }); }
};
scan(JARGON_VENDORS.map(v => [v, DROP_NAME] as [string, string]), 'vendor');
scan(Object.entries(JARGON_VCS), 'vcs');
scan(Object.entries(JARGON_DEV), 'dev');
for (const m of t.matchAll(/(?<![\w/.])(?:\.{0,2}\/)?(?:[\w-]+\/)+[\w.-]*[A-Za-z][\w.-]*|(?<![\w/.@])[\w-]+\.(?:ts|tsx|js|jsx|mjs|kt|java|swift|mm|gd|cs|cpp|py|rb|go|rs|json|ya?ml|plist|gradle|xml|md|lock|toml)\b/g)) {
if (/^(and|or|and\/or)$/i.test(m[0])) continue;
add(m.index, { word: m[0], kind: 'path', plain: 'drop the file name' });
}
for (const m of t.matchAll(/(?<![\w&])(?:#\d+|![1-9]\d*|[A-Z][A-Z0-9]{1,9}-\d+)\b/g)) add(m.index, { word: m[0], kind: 'issue', plain: 'drop the ticket number' });
for (const m of t.matchAll(/(?<![\p{L}\p{N}_])(?:[a-z][a-z0-9]*[A-Z][A-Za-z0-9]*|[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+)(?![\p{L}\p{N}_])/gu)) {
if (NOT_IDENTIFIERS.test(m[0])) continue;
add(m.index, { word: m[0], kind: 'identifier', plain: 'describe the feature in player words' });
}
return hits.sort((a, b) => a.at - b.at).map(h => h.f);
}
const LABEL: Record<JargonKind, string> = {
vendor: 'SDK / vendor name', dev: 'developer word', vcs: 'reads like a commit log', path: 'file path', issue: 'issue id', identifier: 'code identifier',
};
/** One warning line for a finding. */
export function jargonMessage(f: JargonFinding): string {
return `internal jargon (${LABEL[f.kind]}): "${f.word}"${f.plain ? `; instead: ${f.plain}` : '; drop it'}`;
}