diff --git a/.changeset/brave-wasps-sing.md b/.changeset/brave-wasps-sing.md new file mode 100644 index 000000000..c007b3b86 --- /dev/null +++ b/.changeset/brave-wasps-sing.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 2564 +--- +**UAT checkpoint frames now cover 9 more languages** — `response_language` values of Dutch, Polish, Russian, Ukrainian, Turkish, Hindi, Arabic, Vietnamese, or Indonesian render a localized checkpoint banner/instruction instead of silently falling back to the English frame (#2530). diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 8766ab7f5..68dc4288d 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -186,7 +186,7 @@ project one is reported, since that is the file you are most likely able to fix. | `dynamic_routing.provider_escalation` | string[] | ordered model IDs | (none) | Opt-in fallback providers tried when a run dies on a quota / rate limit — see [provider escalation](#provider-escalation-on-quota-exceeded--added-in-v143). Added in v1.43 ([#2296](https://github.com/open-gsd/gsd-core/issues/2296)) | | `project_code` | string | any short string | (none) | Prefix for phase directory names (e.g., `"ABC"` produces `ABC-01-setup/`). Added in v1.31 | | `phase_id_convention` | enum | `"milestone-prefixed"`, `null` | `null` | Phase ID naming convention. `null` = legacy numeric IDs (`Phase 1`, `Phase 2`). `"milestone-prefixed"` = globally unique IDs that encode the enclosing milestone (`Phase 1-01`, `Phase 1-02`). Run `gsd-tools roadmap upgrade --convention milestone-prefixed` to migrate an existing ROADMAP.md. | -| `response_language` | string | language code | (none) | Language for agent responses (e.g., `"pt"`, `"ko"`, `"ja"`). Propagates to all spawned agents for cross-phase language consistency. Added in v1.32 | +| `response_language` | string | language code | (none) | Language for agent responses (e.g., `"pt"`, `"ko"`, `"ja"`). Propagates to all spawned agents for cross-phase language consistency. Added in v1.32. UAT checkpoint frames (`/gsd-verify-work`) render a localized banner/instruction for English, Spanish, French, German, Portuguese, Japanese, Chinese, Korean, Italian, Dutch, Polish, Russian, Ukrainian, Turkish, Hindi, Arabic, Vietnamese, and Indonesian (endonyms and ISO codes also accepted); any other value falls back to the English frame. | | `context_window` | number | any integer | `200000` | Context window size in tokens. Set `1000000` for 1M-context models (e.g., `claude-fable-5`). Values `>= 500000` enable adaptive context enrichment (full-body reads of prior SUMMARY.md, deeper anti-pattern reads). Configured via `/gsd-config --advanced`. | | `context_profile` | string | `dev`, `research`, `review` | (none) | Execution context preset that applies a pre-configured bundle of mode, model, and workflow settings for the current type of work. Added in v1.34 | | `claude_md_path` | string | any file path | `./.claude/CLAUDE.md` | Custom output path for the generated CLAUDE.md file. Useful for monorepos or projects that need CLAUDE.md in a non-root location. Defaults to `./.claude/CLAUDE.md` — a valid project-scoped memory location that keeps GSD-generated content from polluting a hand-crafted repo-root `CLAUDE.md` ([#1098](https://github.com/open-gsd/gsd-core/issues/1098)). An existing file without GSD markers is never overwritten unless `--force` is passed. Default changed from `./CLAUDE.md` in v1.5. Added in v1.36 | diff --git a/src/uat.cts b/src/uat.cts index 39c44c6a5..ee346e699 100644 --- a/src/uat.cts +++ b/src/uat.cts @@ -359,6 +359,7 @@ function parseExpectedFromTestBlock(block: string): string | null { interface CheckpointFrame { banner: string; instruction: string; + direction?: 'rtl'; } const CHECKPOINT_BOX_WIDTH = 64; // total column width of the ╔══...╗ border, borders stay byte-identical @@ -400,6 +401,43 @@ const CHECKPOINT_FRAMES: Record = { banner: 'PUNTO DI CONTROLLO: Verifica richiesta', instruction: 'Digita `pass` o descrivi cosa non va.', }, + dutch: { + banner: 'CONTROLEPUNT: Verificatie vereist', + instruction: 'Typ `pass` of beschrijf wat er mis is.', + }, + polish: { + banner: 'PUNKT KONTROLNY: Wymagana weryfikacja', + instruction: 'Wpisz `pass` lub opisz, co jest nie tak.', + }, + russian: { + banner: 'КОНТРОЛЬНАЯ ТОЧКА: требуется проверка', + instruction: 'Введите `pass` или опишите, что не так.', + }, + ukrainian: { + banner: 'КОНТРОЛЬНА ТОЧКА: потрібна перевірка', + instruction: 'Введіть `pass` або опишіть, що не так.', + }, + turkish: { + banner: 'KONTROL NOKTASI: Doğrulama gerekli', + instruction: '`pass` yazın veya sorunu açıklayın.', + }, + hindi: { + banner: 'चेकपॉइंट: सत्यापन आवश्यक', + instruction: '`pass` लिखें या बताएं कि क्या गलत है।', + }, + arabic: { + banner: 'نقطة تحقق: المراجعة مطلوبة', + instruction: 'اكتب `pass` أو صف المشكلة.', + direction: 'rtl', + }, + vietnamese: { + banner: 'ĐIỂM KIỂM TRA: Cần xác minh', + instruction: 'Nhập `pass` hoặc mô tả vấn đề.', + }, + indonesian: { + banner: 'TITIK PEMERIKSAAN: Verifikasi diperlukan', + instruction: 'Ketik `pass` atau jelaskan apa yang salah.', + }, }; // Free-form response_language aliases → canonical CHECKPOINT_FRAMES key. @@ -413,22 +451,30 @@ const CHECKPOINT_LANGUAGE_ALIASES: Record = { chinese: 'chinese', zh: 'chinese', 'zh-cn': 'chinese', 'zh-tw': 'chinese', mandarin: 'chinese', 'simplified chinese': 'chinese', 'traditional chinese': 'chinese', '中文': 'chinese', korean: 'korean', ko: 'korean', '한국어': 'korean', italian: 'italian', it: 'italian', italiano: 'italian', + dutch: 'dutch', nl: 'dutch', nederlands: 'dutch', flemish: 'dutch', vlaams: 'dutch', + polish: 'polish', pl: 'polish', polski: 'polish', + russian: 'russian', ru: 'russian', 'ru-ru': 'russian', 'русский': 'russian', + ukrainian: 'ukrainian', uk: 'ukrainian', ua: 'ukrainian', 'українська': 'ukrainian', + turkish: 'turkish', tr: 'turkish', 'türkçe': 'turkish', turkce: 'turkish', + hindi: 'hindi', hi: 'hindi', 'हिन्दी': 'hindi', 'हिंदी': 'hindi', + arabic: 'arabic', ar: 'arabic', 'العربية': 'arabic', + vietnamese: 'vietnamese', vi: 'vietnamese', 'tiếng việt': 'vietnamese', 'tieng viet': 'vietnamese', + indonesian: 'indonesian', id: 'indonesian', 'bahasa indonesia': 'indonesian', }; function resolveCheckpointFrame(responseLanguage: string | undefined): CheckpointFrame { if (!responseLanguage) return CHECKPOINT_FRAMES.english; - const key = CHECKPOINT_LANGUAGE_ALIASES[responseLanguage.trim().toLowerCase()]; + const key = CHECKPOINT_LANGUAGE_ALIASES[ + responseLanguage.trim().normalize('NFC').toLowerCase() + ]; return (key && CHECKPOINT_FRAMES[key]) || CHECKPOINT_FRAMES.english; } -// Approximate East Asian Width ranges (Unicode property values W and F) — the -// CJK scripts CHECKPOINT_FRAMES ships (Japanese/Chinese/Korean) render each -// matching code point at 2 terminal/display columns, not 1. Padding computed -// from `.length` (UTF-16 code units) undercounts these by one column per -// wide character, visually misaligning the box's right border (#2402 review -// medium finding). Latin-script frames (English/Spanish/French/German/ -// Portuguese/Italian) contain no wide code points, so displayWidth === length -// for them — no behavior change there. +// Approximate terminal-cell width. East Asian Width W/F code points occupy two +// cells, while Unicode combining marks occupy no additional cell beyond their +// base character. Counting only W/F ranges is insufficient for scripts such as +// Devanagari: Hindi vowel signs and viramas are combining marks, and treating +// each as a full cell visibly shifts the checkpoint box's right border. function isWideCodePoint(codePoint: number): boolean { return ( (codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo @@ -447,11 +493,17 @@ function isWideCodePoint(codePoint: number): boolean { ); } +// Non-spacing/enclosing marks and format controls occupy zero terminal cells. +// Spacing combining marks (General_Category=Mc), such as Devanagari vowel +// signs, still advance the cursor and must contribute one column. +const ZERO_WIDTH_MARK_RE = /\p{gc=Mn}|\p{gc=Me}|\p{gc=Cf}/u; + // Iterates by Unicode code point (not UTF-16 code unit) so astral characters // are measured once, not as two surrogate units. function displayWidth(text: string): number { let width = 0; for (const ch of text) { + if (ZERO_WIDTH_MARK_RE.test(ch)) continue; width += isWideCodePoint(ch.codePointAt(0) as number) ? 2 : 1; } return width; @@ -468,11 +520,22 @@ function checkpointBoxLine(text: string): string { return `║${padded}║`; } +const RTL_ISOLATE = '\u2067'; +const POP_DIRECTIONAL_ISOLATE = '\u2069'; + +function isolateCheckpointFrameText(text: string, frame: CheckpointFrame): string { + return frame.direction === 'rtl' + ? `${RTL_ISOLATE}${text}${POP_DIRECTIONAL_ISOLATE}` + : text; +} + function buildCheckpoint(currentTest: { number: number; name: string; expected: string }, responseLanguage?: string): string { const frame = resolveCheckpointFrame(responseLanguage); + const banner = isolateCheckpointFrameText(frame.banner, frame); + const instruction = isolateCheckpointFrameText(frame.instruction, frame); return [ '╔══════════════════════════════════════════════════════════════╗', - checkpointBoxLine(frame.banner), + checkpointBoxLine(banner), '╚══════════════════════════════════════════════════════════════╝', '', `**Test ${currentTest.number}: ${currentTest.name}**`, @@ -480,7 +543,7 @@ function buildCheckpoint(currentTest: { number: number; name: string; expected: currentTest.expected, '', '──────────────────────────────────────────────────────────────', - frame.instruction, + instruction, '──────────────────────────────────────────────────────────────', ].join('\n'); } @@ -953,5 +1016,9 @@ export = { cmdRenderCheckpoint, parseCurrentTest, buildCheckpoint, + CHECKPOINT_FRAMES, + CHECKPOINT_LANGUAGE_ALIASES, + resolveCheckpointFrame, + checkpointBoxLine, parseDeferredItems, }; diff --git a/tests/uat.test.cjs b/tests/uat.test.cjs index 65a55b8b4..9b0d7936f 100644 --- a/tests/uat.test.cjs +++ b/tests/uat.test.cjs @@ -8,8 +8,15 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); +const fc = require('./helpers/fast-check-setup.cjs'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); -const { buildCheckpoint } = require('../gsd-core/bin/lib/uat.cjs'); +const { + buildCheckpoint, + CHECKPOINT_FRAMES, + CHECKPOINT_LANGUAGE_ALIASES, + resolveCheckpointFrame, + checkpointBoxLine, +} = require('../gsd-core/bin/lib/uat.cjs'); describe('audit-uat command', () => { let tmpDir; @@ -861,54 +868,233 @@ describe('uat render-checkpoint', () => { assert.notStrictEqual(japanese, english); }); - // Regression: #2402 review medium finding — checkpointBoxLine() padded using - // JS string `.length` (UTF-16 code units), not display width. Japanese/ - // Chinese/Korean use full-width characters that render at 2 terminal - // columns each, so the padded line was JS-length-correct (64) but visually - // 8-15 columns too wide, misaligning the right `║` border relative to the - // box's single-width border lines. Independently recomputes display width - // (East Asian Width W/F ranges) rather than reading source, so this test - // fails if the fix regresses even if the banner copy itself later changes. - describe('CJK checkpoint banner padding uses display width, not UTF-16 length (#2402)', () => { - function isWideCodePoint(codePoint) { - return ( - (codePoint >= 0x1100 && codePoint <= 0x115f) || - codePoint === 0x2329 || codePoint === 0x232a || - (codePoint >= 0x2e80 && codePoint <= 0x303e) || - (codePoint >= 0x3041 && codePoint <= 0x33ff) || - (codePoint >= 0x3400 && codePoint <= 0x4dbf) || - (codePoint >= 0x4e00 && codePoint <= 0x9fff) || - (codePoint >= 0xa000 && codePoint <= 0xa4cf) || - (codePoint >= 0xac00 && codePoint <= 0xd7a3) || - (codePoint >= 0xf900 && codePoint <= 0xfaff) || - (codePoint >= 0xfe30 && codePoint <= 0xfe4f) || - (codePoint >= 0xff00 && codePoint <= 0xff60) || - (codePoint >= 0xffe0 && codePoint <= 0xffe6) || - (codePoint >= 0x20000 && codePoint <= 0x3fffd) + test('resolveCheckpointFrame: every extended-pack alias resolves its localized frame', () => { + // Exercise canonical names, ISO codes, endonyms, and transliterations so a + // typo or duplicate alias cannot silently route a supported language back + // to the English fallback. + const cases = [ + { + aliases: ['Dutch', 'nl', 'nederlands', 'flemish', 'vlaams'], + frame: { + banner: 'CONTROLEPUNT: Verificatie vereist', + instruction: 'Typ `pass` of beschrijf wat er mis is.', + }, + }, + { + aliases: ['Polish', 'pl', 'polski'], + frame: { + banner: 'PUNKT KONTROLNY: Wymagana weryfikacja', + instruction: 'Wpisz `pass` lub opisz, co jest nie tak.', + }, + }, + { + aliases: ['Russian', 'ru', 'ru-ru', 'русский'], + frame: { + banner: 'КОНТРОЛЬНАЯ ТОЧКА: требуется проверка', + instruction: 'Введите `pass` или опишите, что не так.', + }, + }, + { + aliases: ['Ukrainian', 'uk', 'ua', 'українська'], + frame: { + banner: 'КОНТРОЛЬНА ТОЧКА: потрібна перевірка', + instruction: 'Введіть `pass` або опишіть, що не так.', + }, + }, + { + aliases: ['Turkish', 'tr', 'türkçe', 'turkce'], + frame: { + banner: 'KONTROL NOKTASI: Doğrulama gerekli', + instruction: '`pass` yazın veya sorunu açıklayın.', + }, + }, + { + aliases: ['Hindi', 'hi', 'हिन्दी', 'हिंदी'], + frame: { + banner: 'चेकपॉइंट: सत्यापन आवश्यक', + instruction: '`pass` लिखें या बताएं कि क्या गलत है।', + }, + }, + { + aliases: ['Arabic', 'ar', 'العربية'], + frame: { + banner: 'نقطة تحقق: المراجعة مطلوبة', + instruction: 'اكتب `pass` أو صف المشكلة.', + direction: 'rtl', + }, + }, + { + aliases: ['Vietnamese', 'vi', 'tiếng việt', 'tieng viet'], + frame: { + banner: 'ĐIỂM KIỂM TRA: Cần xác minh', + instruction: 'Nhập `pass` hoặc mô tả vấn đề.', + }, + }, + { + aliases: ['Indonesian', 'id', 'bahasa indonesia'], + frame: { + banner: 'TITIK PEMERIKSAAN: Verifikasi diperlukan', + instruction: 'Ketik `pass` atau jelaskan apa yang salah.', + }, + }, + ]; + for (const { aliases, frame } of cases) { + for (const alias of aliases) { + assert.deepStrictEqual( + resolveCheckpointFrame(alias), + frame, + `${alias} resolved to the wrong checkpoint frame`, + ); + } + } + }); + + test('checkpoint frame and alias catalogs remain structurally complete', () => { + const english = CHECKPOINT_FRAMES.english; + assert.ok(english, 'English fallback frame must exist'); + + for (const [language, frame] of Object.entries(CHECKPOINT_FRAMES)) { + const expectedKeys = frame.direction + ? ['banner', 'direction', 'instruction'] + : ['banner', 'instruction']; + assert.deepStrictEqual( + Object.keys(frame).sort(), + expectedKeys, + `${language} has an unexpected checkpoint-frame shape`, ); - } - function displayWidth(text) { - let width = 0; - for (const ch of text) width += isWideCodePoint(ch.codePointAt(0)) ? 2 : 1; - return width; + assert.ok(frame.banner.trim(), `${language} banner must be non-empty`); + assert.ok(frame.instruction.trim(), `${language} instruction must be non-empty`); + if (frame.direction !== undefined) { + assert.strictEqual(frame.direction, 'rtl', `${language} has an unsupported direction`); + } + assert.strictEqual( + CHECKPOINT_LANGUAGE_ALIASES[language], + language, + `${language} must self-alias to its canonical frame`, + ); + if (language !== 'english') { + assert.notDeepStrictEqual(frame, english, `${language} must not duplicate the English frame`); + } } - for (const lang of ['Japanese', 'Chinese', 'Korean']) { - test(`${lang} checkpoint banner line renders at display-width 64, aligning the right border`, () => { - const currentTest = { number: 1, name: 'Sample', expected: 'Something happens.' }; - const output = buildCheckpoint(currentTest, lang); - const lines = output.split('\n'); - const topBorder = lines[0]; - const bannerLine = lines[1]; - const bottomBorder = lines[2]; - - assert.strictEqual(displayWidth(topBorder), 64, 'top border is the 64-column reference width'); - assert.strictEqual(displayWidth(bottomBorder), 64, 'bottom border is the 64-column reference width'); - assert.strictEqual(displayWidth(bannerLine), 64, - `${lang} banner line must render at the same 64-column display width as the borders — ` + - 'padding by UTF-16 .length under-pads full-width characters and overflows the box'); - }); + for (const [alias, language] of Object.entries(CHECKPOINT_LANGUAGE_ALIASES)) { + const frame = CHECKPOINT_FRAMES[language]; + assert.ok(frame, `${alias} targets missing checkpoint frame ${language}`); + assert.strictEqual( + resolveCheckpointFrame(alias), + frame, + `${alias} must resolve to its declared checkpoint frame`, + ); + if (language !== 'english') { + assert.notDeepStrictEqual( + frame, + english, + `${alias} must not resolve to the English fallback`, + ); + } } + }); + + // Two alias keys that differ only by case or Unicode normalization form are + // distinct object keys — every assertion above still passes. But resolution + // lowercases and NFC-normalizes before the lookup, so the two collapse to one + // lookup key at runtime and whichever was written first becomes unreachable: + // the losing language silently renders the English fallback. + // + // Both defects survive compilation and both are observable on the catalog + // itself, precisely because the keys stay distinct. The remaining case — two + // byte-identical keys, where the object genuinely no longer records what was + // written — is rejected by tsc as TS1117 before this suite can run, since the + // tests execute against `gsd-core/bin/lib/uat.cjs` built from this source. + test('checkpoint alias catalog declares no colliding or unreachable alias keys', () => { + const declared = Object.keys(CHECKPOINT_LANGUAGE_ALIASES); + + const seen = new Set(); + const collisions = declared.filter( + (alias) => seen.size === seen.add(alias.normalize('NFC').toLowerCase()).size, + ); + assert.deepStrictEqual( + collisions, + [], + `alias key(s) collapse onto an earlier alias once normalized for lookup, so one language silently loses its alias: ${collisions.join(', ')}`, + ); + + // An alias not already in lookup form is the mirror defect: it collides with + // nothing, and resolveCheckpointFrame() — which normalizes its argument + // before indexing — can never produce it, so the entry is simply dead. + const unreachable = declared.filter( + (alias) => alias !== alias.normalize('NFC').toLowerCase(), + ); + assert.deepStrictEqual( + unreachable, + [], + `alias key(s) are not in NFC-lowercase lookup form and can never resolve: ${unreachable.join(', ')}`, + ); + }); + + test('resolveCheckpointFrame: canonically equivalent aliases resolve after NFC normalization', () => { + assert.deepStrictEqual( + resolveCheckpointFrame('türkçe'.normalize('NFD')), + resolveCheckpointFrame('türkçe'), + ); + assert.deepStrictEqual( + resolveCheckpointFrame('tiếng việt'.normalize('NFD')), + resolveCheckpointFrame('tiếng việt'), + ); + }); + + // Regression: #2402 review medium finding — checkpointBoxLine() padded using + // JS string `.length` (UTF-16 code units), not display width. The property + // below supplies an independent, category-labelled cell-width oracle rather + // than copying the implementation's Unicode range logic. + describe('checkpoint banner padding uses terminal display width (#2402, #2530)', () => { + test('property: category-labelled strings are padded to a 62-cell interior', () => { + const oneCell = fc.constantFrom( + { text: 'a', width: 1 }, + { text: '7', width: 1 }, + { text: ' ', width: 1 }, + { text: '\u093e', width: 1 }, // Mc: DEVANAGARI VOWEL SIGN AA + { text: '\u093f', width: 1 }, // Mc: DEVANAGARI VOWEL SIGN I + { text: '\u0949', width: 1 }, // Mc: DEVANAGARI VOWEL SIGN CANDRA O + ); + const zeroCell = fc.constantFrom( + { text: '\u0301', width: 0 }, // Mn: COMBINING ACUTE ACCENT + { text: '\u093c', width: 0 }, // Mn: DEVANAGARI SIGN NUKTA + { text: '\u20dd', width: 0 }, // Me: COMBINING ENCLOSING CIRCLE + { text: '\u200d', width: 0 }, // Cf: ZERO WIDTH JOINER + { text: '\u2066', width: 0 }, // Cf: LEFT-TO-RIGHT ISOLATE + { text: '\u2069', width: 0 }, // Cf: POP DIRECTIONAL ISOLATE + ); + const twoCell = fc.constantFrom( + { text: '界', width: 2 }, + { text: '語', width: 2 }, + { text: '한', width: 2 }, + ); + + fc.assert(fc.property( + fc.array(fc.oneof(oneCell, zeroCell, twoCell), { maxLength: 35 }), + (cells) => { + const text = cells.map((cell) => cell.text).join(''); + const textWidth = cells.reduce((sum, cell) => sum + cell.width, 0); + const padding = ' '.repeat(Math.max(0, 60 - textWidth)); + assert.strictEqual( + checkpointBoxLine(text), + `║ ${text}${padding}║`, + ); + }, + )); + }); + + test('padding boundary: width limit-1, limit, and limit+1', () => { + for (const width of [59, 60, 61]) { + const text = 'a'.repeat(width); + assert.strictEqual( + checkpointBoxLine(text), + `║ ${text}${' '.repeat(Math.max(0, 60 - width))}║`, + `unexpected rendering at text width ${width}`, + ); + } + }); test('exact rendered banner lines for Japanese/Chinese/Korean (regression pin)', () => { const currentTest = { number: 1, name: 'Sample', expected: 'Something happens.' }; @@ -925,6 +1111,24 @@ describe('uat render-checkpoint', () => { '║ 체크포인트: 검증 필요 ║', ); }); + + test('exact rendered Hindi banner line ignores combining-mark cell width (regression pin)', () => { + const currentTest = { number: 1, name: 'Sample', expected: 'Something happens.' }; + assert.strictEqual( + buildCheckpoint(currentTest, 'Hindi').split('\n')[1], + `║ चेकपॉइंट: सत्यापन आवश्यक${' '.repeat(40)}║`, + ); + }); + + test('exact rendered Arabic frame is isolated inside the LTR checkpoint layout', () => { + const currentTest = { number: 1, name: 'Sample', expected: 'Something happens.' }; + const arabic = buildCheckpoint(currentTest, 'Arabic'); + assert.strictEqual( + arabic.split('\n')[1], + `║ \u2067نقطة تحقق: المراجعة مطلوبة\u2069${' '.repeat(34)}║`, + ); + assert.ok(arabic.includes('\u2067اكتب `pass` أو صف المشكلة.\u2069')); + }); }); test('renders the current checkpoint as raw output', () => {