Skip to content

Commit abff879

Browse files
[feat](guide,i18n): role-based "Start here" + a bilingual-purity CI guard
Two findings drove this, both from auditing rather than assuming. 1. HELP WAS LOPSIDED ACROSS SECTORS. The Advisor tab carries three layers of help (the Copilot tour, the Chat Advisor, the Guide); Home and Insights carry one — the Guide, a ten-section document that someone who is already confused will not read. Nothing anywhere answered the question people actually arrive with: "which parts of this apply to me?" Adds section 1 of the Guide, "Start here — what to do, by your role", placed first on purpose: every later section explains HOW the tool works, which is no use to a reader who does not yet know which parts concern them. Six roles — newcomer/student, Software Architect, CTO or Engineering Manager, Tech Lead, QA or SRE, and teaching/studying — each given a concrete path and what to walk away with, not an invitation to explore. The advice is specific enough to be wrong if it were wrong: the architect is sent to sensitivity because the margin is the real output; the CTO is told the ADR export is the deliverable and to quote the close-call margin rather than the winner; QA is sent to the anti-pattern catalogue and past the ranking entirely. Sections 2-10 renumbered accordingly (no test pinned the numbering). 2. LANGUAGE MIXING WAS UNVERIFIED, NOT VERIFIED-CLEAN. Every user-facing string is an EN/ID pair, and a half-translated sentence is invisible to tsc, eslint and every existing test. Audited all 1162 pairs across 52 files: zero mixed strings. One apparent hit — "Log append-only" — is a false positive; append-only is a single technical term, so hyphenated compounds are now stripped before matching. Since "checked once" decays, the audit is now a committed guard: scripts/check-language-purity.mjs, wired into CI and `npm run lang:check`. It keys on FUNCTION words ("the", "with", "yang", "dengan"), never on technical nouns — "microservices" and "cache" are legitimately English inside Indonesian prose, and flagging them would make the guard noise instead of signal. It covers both shapes in the codebase: { en, id } objects and the Guide's inline L()/p(). Proven to bite rather than assumed to: inserting "with the panel" into an Indonesian string exits 1 naming the file, line and words. Bundle 281→284kB with the reason recorded inline per that guard's convention: prose only, in the already-lazy ManualBook chunk. Initial JS unchanged at 120.8/121, so first paint is unaffected; NFR ceiling is 300. Gates: tsc clean, lint 0 warnings, 239/239 unit, 14/14 E2E, 7/7 guards. Guide verified rendering in both languages at 390px with 0 axe violations (WCAG 2.0/2.1 A+AA), TOC 10 entries, all six role cards present. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 31392a7 commit abff879

5 files changed

Lines changed: 157 additions & 28 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ jobs:
4646
run: node scripts/check-app-config.mjs
4747
- name: Validate content (schema + primary sources + related_advisor ↔ frozen model)
4848
run: npm run content:validate
49+
- name: Check bilingual purity (no half-translated EN/ID strings)
50+
run: npm run lang:check
4951
- name: Lint
5052
run: npm run lint
5153
- name: Unit tests (scoring engine + exporters)

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"size": "node scripts/check-bundle-size.mjs",
2222
"audit:prod": "npm audit --omit=dev --audit-level=high",
2323
"content:validate": "node scripts/check-content.mjs",
24+
"lang:check": "node scripts/check-language-purity.mjs",
2425
"test:e2e": "playwright test"
2526
},
2627
"dependencies": {

scripts/check-bundle-size.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { gzipSync } from 'node:zlib';
1717
// shared async chunk can't silently be mis-counted. Headroom catches a real regression; raise the
1818
// budgets deliberately (with a note) if the app grows.
1919
const JS_INITIAL_BUDGET_KB = 121; // 120→121 for Phase 3 (chat + copilot) 2026-07-19: both features are LAZY chunks; the only initial delta is their dynamic-import stubs + a few refs of wiring (~0.3kB gzip, negligible FCP). NFR ceiling is far higher.
20-
const JS_TOTAL_BUDGET_KB = 281; // raised 200→260 (Insights bilingualisation 2026-07-15); 260→268 for the Phase 3 Chat Advisor 2026-07-19 (adapter + hook + panel, all in a LAZY chunk — the FAB is lazy too, so the initial budget is untouched); 268→278 for the 2026-07-23 scenario-coverage expansion (cost/ops, risk catalog, sensitivity, migration, dimension/factor/QA lookups, app-usage FAQ — all pure data-driven text in the same lazy chunk; NFR cap is 300); 278→281 for the 2026-07-25 Chat Advisor capability browser + "How to use this" pane (bilingual help copy, same lazy chunk — initial JS unchanged at 120.8/121, so first paint is unaffected)
20+
const JS_TOTAL_BUDGET_KB = 284; // raised 200→260 (Insights bilingualisation 2026-07-15); 260→268 for the Phase 3 Chat Advisor 2026-07-19 (adapter + hook + panel, all in a LAZY chunk — the FAB is lazy too, so the initial budget is untouched); 268→278 for the 2026-07-23 scenario-coverage expansion (cost/ops, risk catalog, sensitivity, migration, dimension/factor/QA lookups, app-usage FAQ — all pure data-driven text in the same lazy chunk; NFR cap is 300); 278→281 for the 2026-07-25 Chat Advisor capability browser + "How to use this" pane (bilingual help copy, same lazy chunk — initial JS unchanged at 120.8/121, so first paint is unaffected); 281→284 for the 2026-07-25 role-based "Start here" section in the Guide (six bilingual role paths — prose only, in the already-lazy ManualBook chunk that loads on demand; initial JS still 120.8/121, NFR cap 300)
2121
const CSS_BUDGET_KB = 29; // 25→27 (Fase 2g polish 2026-07-18); 27→29 for Phase 3 2026-07-19 (chat panel + copilot overlay/launcher/Dos-Don'ts cards); still under the ~30kB NFR ceiling
2222

2323
const dir = 'dist/assets';

scripts/check-language-purity.mjs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Architecture Advisor — bilingual purity guard.
4+
*
5+
* Every user-facing string exists as an EN/ID pair. The failure this catches is a half-translated
6+
* sentence: an `id` string that still carries English prose (or the reverse), which reads as broken
7+
* to a monolingual user and is invisible to typechecking, linting and every existing test.
8+
*
9+
* What counts as evidence. Technical nouns are legitimately English inside Indonesian prose
10+
* ("microservices", "cache", "deploy", "event sourcing"), so their presence proves nothing and
11+
* flagging them would make this guard noise. FUNCTION words are the signal — "the", "with", "your",
12+
* "which" appear only in an untranslated English sentence, never as borrowed terminology. Hyphenated
13+
* compounds are stripped first, because "append-only" and "read-only" are single technical terms
14+
* whose tail would otherwise look like the English word "only".
15+
*
16+
* Covers both shapes the codebase uses: `{ en: '…', id: '…' }` objects (dict, config, chat topics)
17+
* and the Manual's inline `L('…','…')` / `p('…','…')` helpers.
18+
*
19+
* Run: node scripts/check-language-purity.mjs (exit 0 = no mixed-language strings)
20+
*/
21+
import { readFileSync } from 'node:fs';
22+
import { execSync } from 'node:child_process';
23+
24+
const EN_FN = ['the', 'and', 'with', 'your', 'for', 'this', 'that', 'from', 'when', 'what', 'how', 'are', 'is', 'you', 'will', 'can', 'not', 'all', 'any', 'more', 'than', 'into', 'about', 'which', 'while', 'they', 'their', 'has', 'have', 'was', 'were', 'been', 'only', 'also', 'just', 'each', 'every', 'some', 'most', 'other', 'both', 'over', 'under', 'after', 'before', 'without', 'between', 'because', 'however', 'therefore', 'them', 'then', 'there', 'here', 'who', 'why', 'where', 'does', 'need', 'needs', 'want', 'uses', 'used', 'instead', 'rather', 'own'];
25+
const ID_FN = ['yang', 'dan', 'dengan', 'untuk', 'ini', 'itu', 'dari', 'pada', 'adalah', 'bisa', 'tidak', 'akan', 'atau', 'juga', 'tapi', 'tetapi', 'sudah', 'belum', 'masih', 'saja', 'agar', 'supaya', 'karena', 'sehingga', 'setiap', 'semua', 'lebih', 'paling', 'anda', 'kami', 'kita', 'mereka', 'ada', 'tak', 'bukan', 'saat', 'ketika', 'kalau', 'jika', 'maka', 'oleh', 'tanpa', 'antara', 'namun', 'sebuah', 'dalam', 'cuma', 'punya'];
26+
27+
const Q = "(?:'((?:[^'\\\\]|\\\\.)*)'|\"((?:[^\"\\\\]|\\\\.)*)\")";
28+
const RX_OBJ = new RegExp(`en:\\s*${Q}\\s*,\\s*id:\\s*${Q}`, 'g');
29+
const RX_FN = new RegExp(`\\b[Lp]\\(\\s*${Q}\\s*,\\s*${Q}\\s*[,)]`, 'gs');
30+
31+
/** Strip hyphenated compounds first — they are single technical terms, not prose. */
32+
const words = (s) => (s.toLowerCase().replace(/[a-z]+-[a-z-]+/g, ' ').match(/[a-z]+/g) || []);
33+
34+
const files = execSync('grep -rl "en:\\|L(\'\\|p(\'" src/ content/ --include=*.ts --include=*.tsx --include=*.json 2>/dev/null || true', { encoding: 'utf8' })
35+
.trim().split('\n').filter(Boolean).filter((f) => !f.includes('.test.'));
36+
37+
const problems = [];
38+
let pairs = 0;
39+
40+
for (const file of files) {
41+
const src = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8');
42+
for (const rx of [RX_OBJ, RX_FN]) {
43+
rx.lastIndex = 0;
44+
let m;
45+
while ((m = rx.exec(src))) {
46+
const en = m[1] ?? m[2] ?? '';
47+
const id = m[3] ?? m[4] ?? '';
48+
if (!en || !id) continue;
49+
pairs++;
50+
const line = src.slice(0, m.index).split('\n').length;
51+
const enInId = [...new Set(words(id).filter((w) => EN_FN.includes(w)))];
52+
const idInEn = [...new Set(words(en).filter((w) => ID_FN.includes(w)))];
53+
if (enInId.length) problems.push(`${file}:${line} — ID string carries English prose (${enInId.join(', ')}): "${id.slice(0, 120)}"`);
54+
if (idInEn.length) problems.push(`${file}:${line} — EN string carries Indonesian prose (${idInEn.join(', ')}): "${en.slice(0, 120)}"`);
55+
}
56+
}
57+
}
58+
59+
console.log(`Language purity — ${pairs} EN/ID pairs across ${files.length} files`);
60+
if (problems.length) {
61+
for (const p of problems) console.log(` ✗ ${p}`);
62+
console.error(`\n${problems.length} mixed-language string(s). Translate the sentence, or if the flagged word is genuinely a technical term, add it to this script's exclusions with a reason.`);
63+
process.exit(1);
64+
}
65+
console.log(' no mixed-language strings ✓');

0 commit comments

Comments
 (0)