From bab3f63227113ebffdbfb03ef4f6f7c5c5f05adb Mon Sep 17 00:00:00 2001 From: CRT Date: Wed, 14 Oct 2026 14:45:19 +0800 Subject: [PATCH] whats-new drafts from player-facing notes first (whatsNew.source, docs/RELEASE-.md, the CHANGELOG section of the version or Unreleased; --release, --from-git) and only then from the git log; internal jargon warnings (SDK / vendor names, developer and version-control words, file paths, issue ids, code identifiers) with plain alternatives in the draft and whats-new check, one list in cli/lib/jargon.ts, whatsNew.allowWords keeps a word; skill: write from what players feel, never from commit messages; tests --- CHANGELOG.md | 10 +++++ cli/data/flags.json | 6 ++- cli/lib/config.ts | 8 ++++ cli/lib/jargon.ts | 79 +++++++++++++++++++++++++++++++++ cli/lib/whats-new.ts | 66 ++++++++++++++++++++++++--- cli/whats-new.ts | 42 ++++++++++++------ skills/whats-new/SKILL.md | 20 ++++++++- tests/wave1.test.ts | 2 +- tests/whats-new-sources.test.ts | 75 +++++++++++++++++++++++++++++++ 9 files changed, 287 insertions(+), 21 deletions(-) create mode 100644 cli/lib/jargon.ts create mode 100644 tests/whats-new-sources.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index efa261f..dc9d2ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +- **`whats-new` drafts from player-facing notes before the git log.** It takes `whatsNew.source`, then + `docs/RELEASE-.md`, then the CHANGELOG.md section of the version (else Unreleased); version from + `--release`, else package.json. Only without one (or with `--from-git`) it drafts from commit messages, and says so. +- **Internal jargon warnings in release notes.** The draft and `whats-new check` name SDK / vendor names, developer + and version-control words, file paths, issue ids and code identifiers, each with a plain alternative; warnings never + block. One list in cli/lib/jargon.ts; `whatsNew.allowWords` keeps a word. The whats-new skill says to write from what + players feel, never from commit messages. + ## 0.8.3 · 2026-10-14 Docs: no private account names in the published package. `npx storebound` prompts work on Node again (the [Y/n] diff --git a/cli/data/flags.json b/cli/data/flags.json index f142fbd..6180aac 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -801,9 +801,11 @@ "all", "dir", "force", + "from-git", "hook", "lang", "languages", + "release", "since", "translate" ], @@ -883,6 +885,7 @@ "fresh", "fresh-profile", "from", + "from-git", "from-text", "godot", "group", @@ -968,6 +971,7 @@ "react-native", "reconvert", "region", + "release", "release-date", "remove", "render", @@ -1381,7 +1385,7 @@ "storebound web check [] [--json] read-only, no credentials: the live files on https://" ], "whats-new": [ - "storebound whats-new [--since v1.1.0] [--lang en-US] [--all] [--hook \"text\"] [--dry]", + "storebound whats-new [--since v1.1.0] [--release 1.2.0] [--from-git] [--lang en-US] [--all] [--hook \"text\"] [--dry]", "storebound whats-new --languages en-US,de-DE,tr [--dir store/whats-new] [--translate \"\"] [--force] [--dry] [--json]", "storebound whats-new check [--dir store/whats-new] [--json] limits per store (Play 500, App Store 4000), untranslated files", "storebound whats-new apply [--dir store/whats-new] [--dry] [--json] checked files -> both listing files" diff --git a/cli/lib/config.ts b/cli/lib/config.ts index 6b60573..7614e2c 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -689,6 +689,14 @@ export interface ShipConfig { /** Owner answers for console forms that have no API (`storebound console fill`); only what the rest of the config * cannot prove. Anything left out stays empty and highlighted in the page. */ console?: ConsoleConfig; + /** Release notes (`storebound whats-new`). */ + whatsNew?: { + /** Player-facing notes file the draft comes from before the git log (a CHANGELOG-like file gives the version's + * section), e.g. "docs/player-notes.md". Without it: docs/RELEASE-.md, then CHANGELOG.md. */ + source?: string; + /** Words `whats-new check` must not flag as internal jargon (a product or game term that collides), any case. */ + allowWords?: string[]; + }; /** Native builds (`apple build`, `play build`). */ build?: { /** Shell command run in the project root before the archive / bundle, e.g. "npm run build && npx cap sync" for a diff --git a/cli/lib/jargon.ts b/cli/lib/jargon.ts new file mode 100644 index 0000000..c779773 --- /dev/null +++ b/cli/lib/jargon.ts @@ -0,0 +1,79 @@ +// 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> = { + 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> = { + 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(`(? w.toLowerCase())); + const hits: { at: number; f: JargonFinding }[] = []; + const seen = new Set(); + 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(/(? a.at - b.at).map(h => h.f); +} + +const LABEL: Record = { + 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'}`; +} diff --git a/cli/lib/whats-new.ts b/cli/lib/whats-new.ts index fe42d3c..c1838bd 100644 --- a/cli/lib/whats-new.ts +++ b/cli/lib/whats-new.ts @@ -3,6 +3,7 @@ import type { Language } from './config'; import { playCodes } from './config'; import { len, PLAY_LIMITS } from './copy'; +import { jargonFindings, jargonMessage } from './jargon'; export const NOTES_LIMITS = { play: PLAY_LIMITS.whatsNew, apple: 4000 } as const; /** Roughly what both stores show before "more" on a phone. */ @@ -55,17 +56,72 @@ export function formatNotes(items: NoteItem[], o: { hook?: string; limit: number } /** Problems with a finished note text for one store. */ -export function checkNotes(text: string, store: 'play' | 'apple'): string[] { +export function checkNotes(text: string, store: 'play' | 'apple', allow: readonly string[] = []): string[] { const e: string[] = []; const n = len(text); if (n > NOTES_LIMITS[store]) e.push(`${n} > ${NOTES_LIMITS[store]} characters`); const firstBreak = text.indexOf('\n'); const hook = firstBreak < 0 ? text : text.slice(0, firstBreak); if (len(hook) > HOOK_CHARS) e.push(`first line is ${len(hook)} characters; the part before "more" is about ${HOOK_CHARS}`); - if (/\b(bump|refactor|merge|ci|lint)\b/i.test(text)) e.push('reads like a commit log (bump / refactor / merge ...)'); + for (const f of jargonFindings(text, allow)) e.push(jargonMessage(f)); return e; } +// ---- player-facing sources: a release doc or the changelog section beats the git log ---- + +/** Markdown of a release doc / changelog section -> plain notes text (bullets as •, no emphasis, link or code syntax). */ +export function notesFromMarkdown(md: string): string { + return md.replace(//g, '').split('\n') + .filter(l => !/^#\s/.test(l)) + .map(l => l.replace(/^#{2,6}\s+/, '').replace(/^\s*[-*+]\s+/, '• ').replace(/\*\*(.+?)\*\*/g, '$1').replace(/__(.+?)__/g, '$1') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').replace(/`([^`]+)`/g, '$1').trimEnd()) + .join('\n').replace(/\n{3,}/g, '\n\n').trim(); +} + +/** The body of a CHANGELOG section for `version` ("## 1.2.0", "## [1.2.0] - date", "## v1.2.0 · date"), else of + * "## Unreleased" when no version is given. */ +export function changelogSection(md: string, version?: string): string | undefined { + const want = (version ? version.replace(/^v/i, '') : 'unreleased').toLowerCase(); + for (const sec of md.split(/^(?=## )/m)) { + if (!sec.startsWith('## ')) continue; + const nl = sec.indexOf('\n'); + const id = sec.slice(3, nl < 0 ? undefined : nl).trim().replace(/^\[?v?/i, '').split(/[\]\s·]/)[0]!.toLowerCase(); + if (id === want) return nl < 0 ? undefined : sec.slice(nl + 1).trim() || undefined; + } + return undefined; +} + +/** Cuts a text to `limit` characters at a whole line. */ +export function cutNotes(text: string, limit: number): string { + let out = ''; + for (const l of text.split('\n')) { const next = out ? `${out}\n${l}` : l; if (len(next) > limit) break; out = next; } + return out.trim() || [...text].slice(0, limit).join(''); +} + +export interface NotesSource { kind: 'file' | 'release-doc' | 'changelog'; path: string; text: string } + +/** The player-facing source of the notes, first match wins: `whatsNew.source` (a file; a CHANGELOG-like file gives the + * version's section), docs/RELEASE-.md, CHANGELOG.md's section for the version (or Unreleased). undefined: + * fall back to the git log. `read` returns undefined for a missing file. */ +export function findNotesSource(o: { version?: string; source?: string; read: (rel: string) => string | undefined }): NotesSource | undefined { + const v = o.version?.replace(/^v/i, ''); + if (o.source) { + const md = o.read(o.source); + if (md === undefined) throw new Error(`whatsNew.source: ${o.source} does not exist`); + const sec = /^## /m.test(md) ? changelogSection(md, v) : undefined; + const text = notesFromMarkdown(sec ?? md); + if (text) return { kind: 'file', path: o.source, text }; + } + if (v) for (const rel of [`docs/RELEASE-${v}.md`, `docs/RELEASE-v${v}.md`, `RELEASE-${v}.md`]) { + const md = o.read(rel); + if (md !== undefined && notesFromMarkdown(md)) return { kind: 'release-doc', path: rel, text: notesFromMarkdown(md) }; + } + const cl = o.read('CHANGELOG.md'); + const sec = cl === undefined ? undefined : changelogSection(cl, v) ?? (v ? changelogSection(cl) : undefined); + if (sec && notesFromMarkdown(sec)) return { kind: 'changelog', path: 'CHANGELOG.md', text: notesFromMarkdown(sec) }; + return undefined; +} + const fence = (t: string) => '```\n' + t + '\n```'; /** Sets the "What's new" block of every `` section that contains `lang` (or of all, lang = '*'). */ @@ -134,12 +190,12 @@ export function resolveLanguages(langs: Language[], wanted: string[]): Language[ return out; } -export interface LangCheck { lang: string; chars: number; play?: { limit: number; ok: boolean }; apple?: { limit: number; ok: boolean }; problems: string[] } +export interface LangCheck { lang: string; chars: number; play?: { limit: number; ok: boolean }; apple?: { limit: number; ok: boolean }; problems: string[]; warnings: string[] } /** Limits and wording of one language's notes, for the stores that language is listed on. Pure. */ -export function checkLanguageNotes(l: Language, f: NotesFile, sourceText?: string): LangCheck { +export function checkLanguageNotes(l: Language, f: NotesFile, sourceText?: string, allow: readonly string[] = []): LangCheck { const chars = len(f.text); - const c: LangCheck = { lang: l.id, chars, problems: [] }; + const c: LangCheck = { lang: l.id, chars, problems: [], warnings: jargonFindings(f.text, allow).map(jargonMessage) }; if (playCodes(l).length) { c.play = { limit: NOTES_LIMITS.play, ok: chars <= NOTES_LIMITS.play }; if (!c.play.ok) c.problems.push(`Google Play: ${chars} > ${NOTES_LIMITS.play} characters`); } if (l.apple) { c.apple = { limit: NOTES_LIMITS.apple, ok: chars <= NOTES_LIMITS.apple }; if (!c.apple.ok) c.problems.push(`App Store: ${chars} > ${NOTES_LIMITS.apple} characters`); } if (!f.text) c.problems.push('empty'); diff --git a/cli/whats-new.ts b/cli/whats-new.ts index 941a9e8..02e9f32 100644 --- a/cli/whats-new.ts +++ b/cli/whats-new.ts @@ -1,7 +1,9 @@ // Draft user-facing release notes from the git log and write them into the listing files (text only; nothing is // sent to a store). A human (or an agent with the whats-new skill) rewrites and translates before `play release` / // `apple listing` push them. -// storebound whats-new [--since v1.1.0] [--lang en-US] [--all] [--hook "text"] [--dry] +// storebound whats-new [--since v1.1.0] [--release 1.2.0] [--from-git] [--lang en-US] [--all] [--hook "text"] [--dry] +// The text comes from a player-facing source when there is one (whatsNew.source, docs/RELEASE-.md, the +// CHANGELOG.md section of the version or Unreleased; version: --release, else package.json); only then the git log. // --since: default the latest tag (git describe --tags --abbrev=0); --lang: the Play code / App Store locale to write // (default: the Play default language and the first App Store locale); --all: write the same draft into every // language (to translate afterwards; `storebound check` warns while they are identical). @@ -23,7 +25,7 @@ import type { Ctx } from './lib/ctx'; import { done, log, printJson, warn } from './lib/log'; import { textTable } from './lib/table'; import { - applyLanguageNotes, checkLanguageNotes, checkNotes, formatNotes, type LangCheck, NOTES_LIMITS, type NotesFile, noteItems, notesFileText, parseNotesFile, + applyLanguageNotes, checkLanguageNotes, checkNotes, cutNotes, findNotesSource, formatNotes, type LangCheck, NOTES_LIMITS, type NoteItem, type NotesFile, noteItems, notesFileText, parseNotesFile, resolveLanguages, setAppleWhatsNew, setPlayWhatsNew, } from './lib/whats-new'; import * as rt from './lib/rt'; @@ -38,14 +40,16 @@ function readDir(dir: string, langs: Language[]): { l: Language; f: NotesFile }[ return langs.filter(l => names.has(l.id)).map(l => ({ l, f: parseNotesFile(readFileSync(join(dir, `${l.id}.md`), 'utf8'), l.id) })); } -function checkAll(files: { l: Language; f: NotesFile }[]): LangCheck[] { +function checkAll(files: { l: Language; f: NotesFile }[], allow: readonly string[]): LangCheck[] { const src = files.find(x => x.f.status === 'source'); - return files.map(x => checkLanguageNotes(x.l, x.f, src && src !== x ? src.f.text : undefined)); + return files.map(x => checkLanguageNotes(x.l, x.f, src && src !== x ? src.f.text : undefined, allow)); } function printChecks(checks: LangCheck[]): void { console.log(textTable(['language', 'chars', 'play (500)', 'app store (4000)', 'problems'], checks.map(c => [c.lang, c.chars, c.play ? (c.play.ok ? 'ok' : 'OVER') : '-', c.apple ? (c.apple.ok ? 'ok' : 'OVER') : '-', c.problems.join('; ')]))); + // jargon never blocks: players read these notes, so name each word and a plain alternative (whatsNew.allowWords keeps one) + for (const c of checks) for (const w of c.warnings) warn(TAG, `${c.lang}: ${w}`); } async function languagesFlow(ctx: Ctx, draft: { play: string; apple: string }): Promise { @@ -70,7 +74,7 @@ async function languagesFlow(ctx: Ctx, draft: { play: string; apple: string }): files.push({ l, f: { lang: l.id, source: src!.id, status: 'translated', text: out }, write: true, note: 'translated by the hook' }); } else files.push({ l, f: { lang: l.id, source: src!.id, status: 'translate', text }, write: true, note: hook ? 'would run the --translate hook' : 'to translate (your agent: whats-new skill)' }); } - const checks = checkAll(files.map(x => ({ l: x.l, f: x.f }))); + const checks = checkAll(files.map(x => ({ l: x.l, f: x.f })), config.whatsNew?.allowWords ?? []); if (a.has('json')) printJson({ dir, dryRun: a.has('dry'), files: files.map((x, i) => ({ lang: x.l.id, file: join(dir, `${x.l.id}.md`), status: x.f.status, write: x.write, note: x.note, check: checks[i] })) }); else { for (const x of files) log(TAG, `${join(dir, `${x.l.id}.md`)}: ${x.note}`); printChecks(checks); } if (a.has('dry')) { done(TAG, 'dry run: nothing written'); return; } @@ -92,7 +96,7 @@ export async function run(ctx: Ctx): Promise { const dir = resolve(a.get('dir') ?? 'store/whats-new'); const files = readDir(dir, config.languages); if (!files.length) throw new Error(`${dir}: no .md files`); - const checks = checkAll(files); + const checks = checkAll(files, config.whatsNew?.allowWords ?? []); const bad = checks.filter(c => c.problems.some(p => !p.startsWith('first line'))); if (a._[0] === 'check' || bad.length) { if (a.has('json')) printJson({ dir, checks, ok: !bad.length }); @@ -115,15 +119,27 @@ export async function run(ctx: Ctx): Promise { } const since = a.get('since') ?? (() => { try { return git(['describe', '--tags', '--abbrev=0'], root).trim(); } catch { return ''; } })(); const range = since ? `${since}..HEAD` : 'HEAD'; - const subjects = git(['log', range, '--no-merges', '--format=%s'], root).split('\n').filter(Boolean); - const items = noteItems(subjects); - log('whats-new', `${subjects.length} commits since ${since || 'the first commit'} -> ${items.length} user-facing items`); + const allow = config.whatsNew?.allowWords ?? []; + // a player-facing source (whatsNew.source, docs/RELEASE-.md, the CHANGELOG section) beats the git log + const version = a.get('release') ?? (() => { try { return (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version?: string }).version; } catch { return undefined; } })(); + const source = a.has('from-git') ? undefined : findNotesSource({ version, source: config.whatsNew?.source, read: rel => { const p = resolve(rel); return existsSync(p) ? readFileSync(p, 'utf8') : undefined; } }); const hook = a.get('hook'); - const play = formatNotes(items, { hook, limit: NOTES_LIMITS.play }); - const apple = formatNotes(items, { hook, limit: NOTES_LIMITS.apple }); - for (const [store, text] of [['play', play], ['apple', apple]] as const) for (const p of checkNotes(text, store)) warn('whats-new', `${store}: ${p}`); + let play: string, apple: string, subjects: string[] = [], items: NoteItem[] = []; + if (source) { + log(TAG, `notes from ${source.path} (${source.kind === 'changelog' ? 'CHANGELOG section' : source.kind === 'release-doc' ? 'release doc' : 'whatsNew.source'}), not the git log`); + const body = hook ? `${hook}\n${source.text}` : source.text; + play = cutNotes(body, NOTES_LIMITS.play); + apple = cutNotes(body, NOTES_LIMITS.apple); + } else { + subjects = git(['log', range, '--no-merges', '--format=%s'], root).split('\n').filter(Boolean); + items = noteItems(subjects); + log(TAG, `${subjects.length} commits since ${since || 'the first commit'} -> ${items.length} items, drafted from commit messages (no docs/RELEASE-.md, CHANGELOG section or whatsNew.source): rewrite them in player words`); + play = formatNotes(items, { hook, limit: NOTES_LIMITS.play }); + apple = formatNotes(items, { hook, limit: NOTES_LIMITS.apple }); + } + for (const [store, text] of [['play', play], ['apple', apple]] as const) for (const p of checkNotes(text, store, allow)) warn(TAG, `${store}: ${p}`); if (a.has('languages')) return languagesFlow(ctx, { play, apple }); - if (a.has('json')) printJson({ since: since || null, commits: subjects.length, items, play, apple, limits: NOTES_LIMITS }); + if (a.has('json')) printJson({ since: since || null, source: source ? { kind: source.kind, path: source.path } : null, commits: subjects.length, items, play, apple, limits: NOTES_LIMITS }); else console.log(`--- draft (Play, ${[...play].length}/500) ---\n${play}\n---`); if (a.has('dry')) return; diff --git a/skills/whats-new/SKILL.md b/skills/whats-new/SKILL.md index 99c44ae..09de76c 100644 --- a/skills/whats-new/SKILL.md +++ b/skills/whats-new/SKILL.md @@ -5,12 +5,30 @@ description: Write user-facing release notes ("What's new") for a mobile app or # What's new (release notes) -CLI: `storebound whats-new [--since v1.2.0] [--dry]`. It reads `git log ..HEAD`, drops housekeeping commits +CLI: `storebound whats-new [--since v1.2.0] [--release 1.2.0] [--dry]`. It takes a player-facing source when one exists (below); otherwise it reads `git log ..HEAD`, drops housekeeping commits (chore, ci, docs, refactor, test, merges, version bumps), turns `feat:` / `fix:` / `perf:` subjects into bullets and writes a draft into the default language of `store/listing.md` (Play "What's new") and `store/appstore.md` (`whatsNew`). Nothing is sent to a store. `--all` writes the draft into every language as a starting point for translation; `storebound check` warns while a translation is still identical to the source. +## Where the text comes from +Write from what players feel, never from commit messages. Commit subjects are written for developers ("Umami +telemetry", "refactor save endpoint", SDK and tool names); players care about what they see and feel ("Levels load +faster", "Your progress is saved more reliably"). So the draft comes from a player-facing source first, the first +that exists: +1. `whatsNew.source` in the config (a notes file; a CHANGELOG-like file gives the version's `## ` section), +2. `docs/RELEASE-.md`, +3. the `CHANGELOG.md` section of the version, else its `## Unreleased` section. +The version is `--release 1.2.0`, else `package.json`. Only without any of these (or with `--from-git`) the draft is +built from `git log`; treat that draft as raw material and rewrite every line. If a project keeps a developer +changelog, add a short player-facing `docs/RELEASE-.md` instead of reusing commit-speak. + +The draft and `whats-new check` warn about internal jargon (never blocking): SDK / vendor names (Umami, Sentry, +Firebase, Capacitor, Tauri, Jenkins, Forgejo, ...), developer words (telemetry, refactor, endpoint, payload, API, +CI, PR, OTA, fingerprint, bump, deps, ...), version-control words (commit, merge, revert), file paths, issue ids and +code identifiers (`saveSlot`, `max_lives`). Each warning names the word and a plain alternative. Rewrite the line; +when a flagged word is a real player-facing term (a character called "Sentry"), add it to `whatsNew.allowWords`. + ## Write it for your users 1. Run `storebound whats-new --dry`, read the draft with the owner. 2. Rewrite: diff --git a/tests/wave1.test.ts b/tests/wave1.test.ts index 2161e06..7ccda59 100644 --- a/tests/wave1.test.ts +++ b/tests/wave1.test.ts @@ -206,7 +206,7 @@ describe('whats-new', () => { expect([...formatNotes(many, { limit: 500 })].length).toBeLessThanOrEqual(500); expect(formatNotes([], { limit: 500 })).toBe('Bug fixes and performance improvements.'); expect(checkNotes('x'.repeat(501), 'play')[0]).toContain('501 > 500'); - expect(checkNotes('bump version', 'apple')[0]).toContain('commit log'); + expect(checkNotes('bump version', 'apple')[0]).toBe('internal jargon (developer word): "bump"; instead: "update"'); }); test('writes into both listing files, keeping them parseable', () => { const play = readFileSync(join(T, 'listing.md'), 'utf8'); diff --git a/tests/whats-new-sources.test.ts b/tests/whats-new-sources.test.ts new file mode 100644 index 0000000..bf75925 --- /dev/null +++ b/tests/whats-new-sources.test.ts @@ -0,0 +1,75 @@ +// Release notes in player language: a player-facing source beats the git log, and internal jargon is flagged. +import { afterEach, describe, expect, test } from 'bun:test'; +import { jargonFindings, jargonMessage } from '../cli/lib/jargon'; +import { changelogSection, checkLanguageNotes, cutNotes, findNotesSource, notesFromMarkdown } from '../cli/lib/whats-new'; +import { run as whatsNew } from '../cli/whats-new'; +import { BASE, capture, jsonOut, project, restore } from './helpers/store-mock'; + +afterEach(restore); +const words = (t: string, allow?: string[]) => jargonFindings(t, allow).map(f => `${f.kind}:${f.word}`); + +describe('internal jargon', () => { + test('vendors, dev and version-control words, paths, issue ids and code identifiers', () => { + expect(words('Added Umami telemetry and a Keeper OS fix')).toEqual(['vendor:Umami', 'dev:telemetry', 'vendor:Keeper OS']); + expect(words('Sentry, Firebase, Capacitor, Tauri, Jenkins and Forgejo')).toEqual(['vendor:Sentry', 'vendor:Firebase', 'vendor:Capacitor', 'vendor:Tauri', 'vendor:Jenkins', 'vendor:Forgejo']); + expect(words('refactor the endpoint payload via the API in CI, PR merged, commit bump of deps, OTA fingerprint')) + .toEqual(['dev:refactor', 'dev:endpoint', 'dev:payload', 'dev:API', 'dev:CI', 'dev:PR', 'vcs:merged', 'vcs:commit', 'dev:bump', 'dev:deps', 'dev:OTA', 'dev:fingerprint']); + expect(words('Fixed src/game/save.ts and Player.gd')).toEqual(['path:src/game/save.ts', 'path:Player.gd']); + expect(words('Fixes #123 and GAME-42')).toEqual(['issue:#123', 'issue:GAME-42']); + expect(words('saveSlot now works, as does max_lives')).toEqual(['identifier:saveSlot', 'identifier:max_lives']); + }); + test('player language passes: product names, links, dates, plain words', () => { + expect(words('New: 20 desert levels on iPhone and iPad! Faster loading. Help: https://example.com/help/api_v2 on 12/10.')).toEqual([]); + expect(words('Capital letters, a pricey item, a picnic and the kick-off')).toEqual([]); // no "api" / "ci" inside words + }); + test('allowWords keeps a word, any case; messages name the word and a plain alternative', () => { + expect(words('New Firebase level and saveSlot', ['firebase', 'SAVESLOT'])).toEqual([]); + expect(jargonMessage(jargonFindings('endpoint')[0]!)).toBe('internal jargon (developer word): "endpoint"; instead: "server"'); + expect(jargonMessage(jargonFindings('Umami')[0]!)).toBe('internal jargon (SDK / vendor name): "Umami"; instead: drop the name and say what changed for the player'); + expect(jargonMessage(jargonFindings('merge')[0]!)).toBe('internal jargon (reads like a commit log): "merge"; drop it'); + }); + test('language check: jargon is a warning, never a blocking problem', () => { + const c = checkLanguageNotes({ id: 'en', apple: 'en-US' }, { lang: 'en', source: 'en', status: 'source', text: 'Telemetry via Umami.' }); + expect(c.problems).toEqual([]); + expect(c.warnings).toHaveLength(2); + expect(checkLanguageNotes({ id: 'en', apple: 'en-US' }, { lang: 'en', source: 'en', status: 'source', text: 'Telemetry via Umami.' }, undefined, ['telemetry', 'umami']).warnings).toEqual([]); + }); +}); + +describe('player-facing sources', () => { + const CL = '# Changelog\n\n## Unreleased\n\n- **Night mode** for the map.\n\n## [1.2.0] - 2026-10-01\n\nBig update.\n\n- New `desert` world, see [the trailer](https://example.com).\n\n## 1.1.0\n\n- Old.\n'; + test('changelog sections, markdown to text, cuts', () => { + expect(changelogSection(CL, '1.2.0')).toContain('New `desert` world'); + expect(changelogSection(CL, 'v1.1.0')).toBe('- Old.'); + expect(changelogSection(CL)).toBe('- **Night mode** for the map.'); + expect(changelogSection(CL, '9.9.9')).toBeUndefined(); + expect(notesFromMarkdown(changelogSection(CL, '1.2.0')!)).toBe('Big update.\n\n• New desert world, see the trailer.'); + expect(cutNotes('aaa\nbbb\nccc', 7)).toBe('aaa\nbbb'); + }); + test('order: whatsNew.source, docs/RELEASE-.md, CHANGELOG (version, then Unreleased), else none', () => { + const files: Record = { 'CHANGELOG.md': CL, 'docs/RELEASE-1.2.0.md': '# 1.2.0\n\n- Desert world!\n', 'notes.md': 'Hand written.' }; + const read = (rel: string) => files[rel]; + expect(findNotesSource({ version: '1.2.0', source: 'notes.md', read })).toEqual({ kind: 'file', path: 'notes.md', text: 'Hand written.' }); + expect(findNotesSource({ version: '1.2.0', read })).toEqual({ kind: 'release-doc', path: 'docs/RELEASE-1.2.0.md', text: '• Desert world!' }); + expect(findNotesSource({ version: '1.1.0', read })?.text).toBe('• Old.'); + expect(findNotesSource({ version: '2.0.0', read })?.text).toBe('• Night mode for the map.'); + expect(findNotesSource({ read: () => undefined })).toBeUndefined(); + expect(() => findNotesSource({ source: 'missing.md', read })).toThrow('whatsNew.source: missing.md does not exist'); + }); + test('command: the release doc wins over commit messages; --from-git and jargon warnings', async () => { + const p = project({ ...BASE, whatsNew: { allowWords: ['sentry'] } }, { 'package.json': '{"version":"1.2.0"}', 'docs/RELEASE-1.2.0.md': '# 1.2.0\n\n- 20 desert levels.\n- Sentry the guard dog joins you.\n' }); + const git = (...a: string[]) => Bun.spawnSync(['git', ...a], { cwd: p.root, env: { ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@example.com', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@example.com' } }); + git('init', '-q'); git('add', '.'); git('commit', '-qm', 'chore: init'); git('tag', 'v1.1.0'); git('commit', '-q', '--allow-empty', '-m', 'feat: Umami telemetry endpoint'); + let r = await capture(() => whatsNew(p.ctx(['--dry', '--json']))); + let j = jsonOut(r.out); + expect(j.source).toEqual({ kind: 'release-doc', path: 'docs/RELEASE-1.2.0.md' }); + expect(j.play).toBe('• 20 desert levels.\n• Sentry the guard dog joins you.'); + expect(r.err).not.toContain('jargon'); // "Sentry" is allowed by whatsNew.allowWords + r = await capture(() => whatsNew(p.ctx(['--dry', '--json', '--from-git']))); + j = jsonOut(r.out); + expect(j.source).toBeNull(); + expect(j.play).toContain('Umami telemetry endpoint'); + expect(r.err).toContain('internal jargon (SDK / vendor name): "Umami"'); + expect(r.err).toContain('"endpoint"; instead: "server"'); + }); +});