prpm-dbp 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -9,6 +9,74 @@ Sumber kebenaran tunggal untuk ejaan BM dan Jawi ialah PRPM DBP, bukan ingatan m
9
9
 
10
10
  Model boleh hasilkan Jawi yang nampak betul tetapi salah, dan pembaca tidak dapat kesan. Skill ini tukar tekaan jadi semakan.
11
11
 
12
+ ## Sebelum menghantar apa-apa
13
+
14
+ Jalankan `npx prpm-dbp gate <fail> --luar-talian` ke atas **keseluruhan teks**. Bukan sebahagian. Bukan perkataan yang anda rasa berisiko.
15
+
16
+ Ejaan yang salah ialah tepat ejaan yang anda **tidak** syak. Kalau anda syak, anda sudah menyemaknya.
17
+
18
+ Ini termasuk ejaan yang anda tulis dalam **kod, ujian dan dokumentasi**, bukan hanya dalam teks untuk pengguna. Semasa membina pakej ini sendiri, satu nilai Jawi ditulis ke dalam ujian daripada ingatan tanpa disemak. Nilainya kebetulan betul. Kebetulan bukan proses.
19
+
20
+ **Apa yang berlaku apabila langkah ini dilangkau:**
21
+
22
+ Satu agen menggunakan skill ini sepanjang sesi penuh untuk menukar Jawi ke Rumi, kemudian menghantar 549 kad RPH Pendidikan Islam ke pangkalan data produksi dengan **273 ejaan yang salah**.
23
+
24
+ ```
25
+ pusingan 1 sunnah x206, hadith x16, fardhu x2 -> masuk produksi
26
+ pusingan 2 tayammum, dhuha, qadha, redha, iddah x90 -> masuk produksi
27
+ ```
28
+
29
+ Semuanya kata lazim dalam bahan Pendidikan Islam. Setiap satu ada jawapan tegas dalam PRPM: `sunnah` ialah rujukan silang ke `sunah`, `hadith` sepatutnya `hadis`, `fardhu` sepatutnya `fardu`, `tayammum` sepatutnya `tayamum`, `dhuha` sepatutnya `duha`, `qadha` sepatutnya `qada`.
30
+
31
+ Alat untuk menangkap kesemuanya ada di tangan agen itu sepanjang masa. Ia tidak pernah dijalankan ke atas teks Rumi keluarannya sendiri. Kedua-dua pusingan ditemui kerana **pengguna bertanya**, bukan kerana proses menangkapnya.
32
+
33
+ `gate` menyemak **kedua-dua tulisan dalam panggilan yang sama**. Versi terdahulu menyemak Rumi sahaja, jadi dokumen Jawi penuh melepasinya tanpa satu aduan pun — tepat jenis dokumen yang alat ini paling diperlukan.
34
+
35
+ Empat perkara yang ditangkap dalam teks Jawi:
36
+
37
+ | Keadaan | Contoh | Keputusan |
38
+ |---|---|---|
39
+ | ruang tersasar di dalam perkataan | `ݢول وڠن` | **ralat**, cadang `ݢولوڠن` |
40
+ | ejaan tiada dalam DBP | `منجاوهكن` | **ralat**, cadang `منجاءوهکن` |
41
+ | homograf tulen | `سنة` | tidak dapat disahkan, dua bacaan |
42
+ | petikan Arab | `الشِّمال` | tidak dapat disahkan, §11.1 |
43
+
44
+ Ruang tersasar itu kes paling berbahaya. Separuh perkataan boleh **berjaya secara palsu**: `ݢول` benar-benar memetakan kepada dua perkataan DBP tulen, `gol` dan `gul`. Penukar yang memilih calon paling kerap akan mengeluarkan "gol" dengan yakin, dan separuh perkataan hilang tanpa sesiapa perasan. Sebab itu percantuman dicuba apabila **salah satu** jiran belum selesai, bukan kedua-duanya.
45
+
46
+ Nama khas Arab tanpa kata sandang `ال` (`أصحاب`, nama tokoh, nama tempat) tidak dapat disahkan kamus Melayu. Isytiharkannya melalui `npx prpm-dbp nama <rumi> <jawi> --jenis arab`; selepas itu ia berhenti menjadi ralat, tetapi tetap **bukan** lulus DBP.
47
+
48
+ `gate` keluar dengan kod 1 apabila ada ralat, jadi ia boleh dipasang sebagai hook atau langkah CI. Peraturan yang bergantung pada agen ingat sendiri akan gagal, dan gagal secara senyap.
49
+
50
+ ## Kuatkuasa automatik
51
+
52
+ ```bash
53
+ npx prpm-dbp install --hook
54
+ ```
55
+
56
+ Memasang hook PostToolUse yang menjalankan `gate --luar-talian` ke atas setiap fail `.md`, `.txt` atau `.json` yang ditulis atau disunting. Ia **melaporkan** ralat kepada agen tanpa menyekat tulisan: menyekat atas kamus yang tidak lengkap akan menghalang kerja yang sah, manakala melaporkan sudah memadai kerana agen membaca output hook.
57
+
58
+ Untuk menampal sendiri: `npx prpm-dbp hook-json`.
59
+
60
+ Panaskan cache **sekali** semasa pemasangan, bukan semasa gate berjalan:
61
+
62
+ ```bash
63
+ npx prpm-dbp warm kosa-kata-projek.txt
64
+ ```
65
+
66
+ Gate atas cache panas mengambil 0.19 saat bagi 406 perkataan. Itu cukup pantas untuk setiap penyuntingan. Gate yang perlahan akan dimatikan orang.
67
+
68
+ ## Tiga keadaan gate, bukan dua
69
+
70
+ | Medan | Maksud | Kesan |
71
+ |---|---|---|
72
+ | `lulusDbp` | ada dalam PRPM, ejaan padan | — |
73
+ | `ralat` | ada dalam PRPM dengan ejaan lain, entri rujukan silang `®`, atau tiada dalam PRPM mahupun dataset | **exit 1** |
74
+ | `tidakDapatDisahkan` | tiada dalam cache, nama khas, akronim, atau tiada entri PRPM tetapi wujud dalam dataset | dilapor; exit 1 jika melebihi `--ambang` |
75
+
76
+ `tidakDapatDisahkan` **tidak pernah lulus senyap**. Gate yang lulus atas cache sejuk memberi jaminan palsu, dan itu lebih bahaya daripada tiada gate. Gunakan `--ambang 10` supaya cache sejuk menggagalkan gate.
77
+
78
+ PRPM tiada entri untuk kata tugas paling asas: `dan`, `di`, `ke`, `yang`, `atau`. Menandakannya sebagai ralat menjadikan gate mustahil dipakai. Pembezanya bersumber, bukan senarai putih: kata tugas itu **semua** wujud dalam dataset, manakala `hadith`, `fardhu`, `tayammum`, `dhuha`, `qadha`, `redha` dan `iddah` **tiada satu pun**.
79
+
12
80
  ## Prosedur wajib
13
81
 
14
82
  Ikut ini setiap kali, jangan langkau langkah.
@@ -184,30 +252,6 @@ Ejaan yang disimpan begini ditandakan datang daripada pengguna, bukan daripada D
184
252
 
185
253
  Bagi nama berasal Arab, ejaan Jawinya memang ejaan Arab asal, jadi ia nampak seperti dikekalkan. Bagi nama bukan Arab ia ditransliterasi penuh: `Kuala Lumpur` menjadi `کوالا لومڤور`.
186
254
 
187
- ## Prosedur wajib
188
-
189
- Ikut ini setiap kali, jangan langkau langkah.
190
-
191
- **Menulis Jawi daripada Rumi:**
192
-
193
- 1. `npx prpm-dbp jawi <fail> --luar-talian --homograf --json`
194
- 2. Baca `belumDisahkan` dan `namaKhas`. Jangan reka ejaan untuknya. Sahkan dengan `lookup`, atau tanya pengguna, atau tandakan dalam hasil akhir.
195
- 3. Baca `mencurigakan`. Setiap satu bermakna PRPM sendiri mungkin tersilap. Semak di pautan yang diberi sebelum menggunakannya.
196
- 4. Baca `homograf`. Beritahu pengguna perkataan mana yang akan taksa apabila dibaca.
197
- 5. Laporkan `semuanyaDbp`. Jika `false`, nyatakan bahagian mana bukan daripada sumber rasmi.
198
-
199
- **Merumikan daripada Jawi:**
200
-
201
- 1. `npx prpm-dbp rumi <fail> --pilih --konteks --json`
202
- 2. Untuk **setiap** item dalam `kabur`: baca ayat yang mengandunginya, baca makna setiap calon, tentukan yang mana sesuai.
203
- 3. Jika `dipilih` salah bagi ayat itu, **gantikan** dalam hasil akhir. Kekerapan kalah kepada konteks.
204
- 4. Nyatakan kepada pengguna berapa banyak kekaburan wujud dan mana yang anda ubah.
205
- 5. `tidakDikenali` bermakna perkataan itu tiada dalam cache. Jalankan `warm` di luar talian, bukan tinggalkan bertanda.
206
-
207
- **Berhenti dan tanya pengguna apabila:** ejaan `mencurigakan` mengubah makna, nama khas tiada rujukan bertulis, atau dua calon sama-sama munasabah dalam ayat itu.
208
-
209
- Semua perintah menyokong `--json`, jadi langkah di atas boleh dibuat secara berulang tanpa menghurai teks.
210
-
211
255
  ## Peraturan Pedoman DBP
212
256
 
213
257
  Satu fail sahaja dalam pakej ini **menjana** ejaan dan bukan mencarinya: `src/pedoman.js`. Ia dibenarkan kerana setiap peraturan di dalamnya datang daripada *Pedoman Umum Ejaan Jawi Bahasa Melayu* terbitan DBP, dan dirujuk pada seksyennya.
@@ -319,30 +363,6 @@ Dua puluh dua kes kabur yang tinggal itu sah: `قلقله` boleh dibaca `qalqala
319
363
 
320
364
  **Sifar perkataan hilang atau rosak.**
321
365
 
322
- ## Prosedur wajib
323
-
324
- Ikut ini setiap kali, jangan langkau langkah.
325
-
326
- **Menulis Jawi daripada Rumi:**
327
-
328
- 1. `npx prpm-dbp jawi <fail> --luar-talian --homograf --json`
329
- 2. Baca `belumDisahkan` dan `namaKhas`. Jangan reka ejaan untuknya. Sahkan dengan `lookup`, atau tanya pengguna, atau tandakan dalam hasil akhir.
330
- 3. Baca `mencurigakan`. Setiap satu bermakna PRPM sendiri mungkin tersilap. Semak di pautan yang diberi sebelum menggunakannya.
331
- 4. Baca `homograf`. Beritahu pengguna perkataan mana yang akan taksa apabila dibaca.
332
- 5. Laporkan `semuanyaDbp`. Jika `false`, nyatakan bahagian mana bukan daripada sumber rasmi.
333
-
334
- **Merumikan daripada Jawi:**
335
-
336
- 1. `npx prpm-dbp rumi <fail> --pilih --konteks --json`
337
- 2. Untuk **setiap** item dalam `kabur`: baca ayat yang mengandunginya, baca makna setiap calon, tentukan yang mana sesuai.
338
- 3. Jika `dipilih` salah bagi ayat itu, **gantikan** dalam hasil akhir. Kekerapan kalah kepada konteks.
339
- 4. Nyatakan kepada pengguna berapa banyak kekaburan wujud dan mana yang anda ubah.
340
- 5. `tidakDikenali` bermakna perkataan itu tiada dalam cache. Jalankan `warm` di luar talian, bukan tinggalkan bertanda.
341
-
342
- **Berhenti dan tanya pengguna apabila:** ejaan `mencurigakan` mengubah makna, nama khas tiada rujukan bertulis, atau dua calon sama-sama munasabah dalam ayat itu.
343
-
344
- Semua perintah menyokong `--json`, jadi langkah di atas boleh dibuat secara berulang tanpa menghurai teks.
345
-
346
366
  ## Peraturan yang sengaja TIDAK dilaksanakan
347
367
 
348
368
  | Seksyen | Sebab |
@@ -457,6 +477,33 @@ Diukur pada 168 baris DSKP sebenar: token yang langsung tidak dikenali turun dar
457
477
 
458
478
  Kegunaannya bukan menjadikan alat lebih longgar. Ia membezakan **"ini surah, cuma dieja lain"** daripada **"saya tidak kenal benda ini"** — dua keadaan yang dahulu keluar sama sebagai `«...»`.
459
479
 
480
+ ## Kekaburan dalam teks domain
481
+
482
+ `--pilih` menggunakan kekerapan korpus. Dalam teks am ia betul hampir sentiasa. Dalam **teks domain ia gagal tepat di tempat paling penting**: setiap istilah khusus kalah kepada kata lazim yang berkongsi rangka konsonan.
483
+
484
+ Diukur pada DSKP KKQ/PQS: salah untuk 5 daripada 16 pasangan taksa, dan kelima-limanya ialah inti pelajaran.
485
+
486
+ ```
487
+ اسم -> asam patut isim "hamzah wasal pada kalimah asam dan faal"
488
+ فعل -> faal patut fiil
489
+ رسم -> rasam patut Rasm (Uthmani)
490
+ سنة -> sanat patut sunah
491
+ ```
492
+
493
+ Gunakan glosari domain. Ia **mengatasi segalanya**, termasuk lapisan 1:
494
+
495
+ ```bash
496
+ npx prpm-dbp rumi dskp.txt --pilih --glosari istilah-kkq.json
497
+ ```
498
+
499
+ ```json
500
+ { "اسم": "isim", "فعل": "fiil", "رسم": "rasm", "سنة": "sunah" }
501
+ ```
502
+
503
+ Setiap penggunaan direkod dalam medan `dariGlosari`, jadi ia boleh diaudit. Bina glosari itu sekali untuk domain anda; ia lebih murah daripada membetulkan output berulang kali, dan ia menjadi rekod: siapa membenarkan istilah apa.
504
+
505
+ Kekerapan kekal sebagai pemecah seri sahaja, dan setiap keputusan tetap dilapor dalam `kabur` untuk semakan.
506
+
460
507
  ## Rantai kepercayaan
461
508
 
462
509
  Setiap ejaan Jawi yang dikeluarkan membawa asal-usulnya. Jangan runtuhkan lapisan ini.
package/bin/cli.js CHANGED
@@ -2,11 +2,12 @@
2
2
  import { readFile } from 'node:fs/promises'
3
3
  import process from 'node:process'
4
4
 
5
- import { pasangSkill } from '../src/install.js'
5
+ import { HOOK, pasangHook, pasangSkill } from '../src/install.js'
6
6
  import { banding, cariKamus } from '../src/kamus.js'
7
7
  import { keJawi, keRumi, semakJawi } from '../src/tukar.js'
8
8
  import { huraiTsv, muatDataset, simpanDataset } from '../src/dataset.js'
9
9
  import { NAMA_PATH, muatNama, tambahNama } from '../src/nama.js'
10
+ import { gate, kodKeluar } from '../src/gate.js'
10
11
  import {
11
12
  CACHE_PATH,
12
13
  auditJawi,
@@ -29,9 +30,13 @@ const BANTUAN = `prpm-dbp — semak Bahasa Melayu dan Jawi terhadap PRPM DBP
29
30
  npx prpm-dbp import-tsv <fail.tsv> import dataset rumi<TAB>jawi (dilabel bukan DBP)
30
31
  npx prpm-dbp nama <rumi> <jawi> sahkan satu nama khas (tokoh, surah, tempat)
31
32
  npx prpm-dbp nama senaraikan nama khas yang disahkan
33
+ npx prpm-dbp gate <fail...> semakan boleh GAGAL untuk hook dan CI
34
+ exit 0 bersih, exit 1 ada ralat
32
35
  npx prpm-dbp banding <kata...> silang semak PRPM lawan KamusDBP
33
36
  npx prpm-dbp cache [--buang <kata>] status cache
34
37
  npx prpm-dbp install [--link] pasang sebagai Claude Code skill
38
+ npx prpm-dbp install --hook pasang hook PostToolUse yang jalankan gate
39
+ npx prpm-dbp hook-json papar blok settings.json untuk ditampal sendiri
35
40
 
36
41
  Pilihan: --refresh --had <n> --delay <ms> --json --cepat
37
42
  --pedoman jana imbuhan ikut peraturan DBP (88-98% tepat, hasil ditanda)
@@ -39,6 +44,9 @@ Pilihan: --refresh --had <n> --delay <ms> --json --cepat
39
44
  --homograf tandakan perkataan yang ejaan Jawinya dikongsi perkataan lain
40
45
  --konteks papar ayat dan makna setiap calon bagi kekaburan (rumi)
41
46
  --rangka padan ejaan bukan piawai melalui rangka konsonan (berlabel)
47
+ --glosari <fail> rumi: glosari domain {jawi: rumi} yang mengatasi segalanya
48
+ --ambang <n> gate: exit 1 jika tidakDapatDisahkan melebihi n peratus
49
+ --abai <fail> gate: senarai nama khas/istilah yang sudah diisytihar
42
50
  --pilih pilih calon paling lazim supaya teks boleh terus dibaca;
43
51
  setiap pilihan disenaraikan untuk disemak ikut konteks
44
52
 
@@ -53,6 +61,7 @@ function huraiArgs(argv) {
53
61
  if (a === '--refresh') pilihan.refresh = true
54
62
  else if (a === '--json') pilihan.json = true
55
63
  else if (a === '--link') pilihan.link = true
64
+ else if (a === '--hook') pilihan.hook = true
56
65
  else if (a === '--cepat') pilihan.cepat = true
57
66
  else if (a === '--pedoman') pilihan.pedoman = true
58
67
  else if (a === '--luar-talian') pilihan.luarTalian = true
@@ -60,6 +69,9 @@ function huraiArgs(argv) {
60
69
  else if (a === '--konteks') pilihan.konteks = true
61
70
  else if (a === '--pilih') pilihan.pilih = true
62
71
  else if (a === '--rangka') pilihan.rangka = true
72
+ else if (a === '--ambang') pilihan.ambang = Number(argv[++i])
73
+ else if (a === '--abai') pilihan.abai = argv[++i]
74
+ else if (a === '--glosari') pilihan.glosari = argv[++i]
63
75
  else if (a === '--jenis') pilihan.jenis = argv[++i]
64
76
  else if (a === '--had') pilihan.had = Number(argv[++i])
65
77
  else if (a === '--delay') pilihan.delay = Number(argv[++i])
@@ -85,10 +97,20 @@ async function main() {
85
97
  }
86
98
 
87
99
  if (perintah === 'install') {
100
+ if (pilihan.hook) {
101
+ keluar(await pasangHook())
102
+ return
103
+ }
88
104
  keluar(await pasangSkill({ link: pilihan.link }))
89
105
  return
90
106
  }
91
107
 
108
+ if (perintah === 'hook-json') {
109
+ // Blok siap salin untuk pengguna yang mahu menampal sendiri.
110
+ keluar({ hooks: { PostToolUse: [HOOK] } })
111
+ return
112
+ }
113
+
92
114
  const cache = await loadCache()
93
115
  const opts = {
94
116
  refresh: pilihan.refresh,
@@ -229,6 +251,7 @@ async function main() {
229
251
  dataset: await muatDataset(),
230
252
  pilihLazim: pilihan.pilih,
231
253
  padanRangka: pilihan.rangka,
254
+ glosari: pilihan.glosari ? JSON.parse(await readFile(pilihan.glosari, 'utf8')) : null,
232
255
  })
233
256
  if (pilihan.json) { keluar(h); break }
234
257
  console.log(h.teks)
@@ -280,6 +303,64 @@ async function main() {
280
303
  break
281
304
  }
282
305
 
306
+ case 'gate': {
307
+ if (!baki.length) throw new Error('perlu sekurang-kurangnya satu fail')
308
+ const abai = new Set()
309
+ if (pilihan.abai) {
310
+ const senarai = await readFile(pilihan.abai, 'utf8')
311
+ for (const b of senarai.split('\n')) {
312
+ const k = b.trim().toLowerCase()
313
+ if (k && !k.startsWith('#')) abai.add(k)
314
+ }
315
+ }
316
+
317
+ const gabungan = { jumlahKataUnik: 0, lulusDbp: 0, ralat: [], tidakDapatDisahkan: [] }
318
+ for (const fail of baki) {
319
+ const laporan = await gate(await readFile(fail, 'utf8'), cache, {
320
+ ...opts,
321
+ // Dataset MESTI dimuatkan. Tanpanya peta songsang menyusut daripada
322
+ // 64k ejaan kepada saiz cache sahaja, dan kata tugas paling asas
323
+ // seperti دان dilaporkan sebagai ejaan Jawi yang salah.
324
+ dataset: await muatDataset(),
325
+ // Nama khas Arab (nama surah, tokoh, tempat) tidak boleh disahkan
326
+ // kamus Melayu. Yang sudah diisytihar melalui `nama` diambil kira
327
+ // supaya pengisytiharan itu benar-benar bermakna.
328
+ namaJawi: new Set(
329
+ Object.values(await muatNama())
330
+ .map((n) => n?.jawi)
331
+ .filter(Boolean),
332
+ ),
333
+ luarTalian: pilihan.luarTalian !== false,
334
+ abai,
335
+ })
336
+ gabungan.jumlahKataUnik += laporan.jumlahKataUnik
337
+ gabungan.lulusDbp += laporan.lulusDbp
338
+ gabungan.ralat.push(...laporan.ralat.map((r) => ({ ...r, fail })))
339
+ gabungan.tidakDapatDisahkan.push(...laporan.tidakDapatDisahkan.map((r) => ({ ...r, fail })))
340
+ }
341
+ gabungan.peratusTidakDapatDisahkan = gabungan.jumlahKataUnik
342
+ ? Math.round((gabungan.tidakDapatDisahkan.length / gabungan.jumlahKataUnik) * 100)
343
+ : 0
344
+
345
+ const kod = kodKeluar(gabungan, pilihan.ambang)
346
+ if (pilihan.json) keluar({ ...gabungan, kodKeluar: kod })
347
+ else {
348
+ console.error(
349
+ `gate: ${gabungan.lulusDbp}/${gabungan.jumlahKataUnik} disahkan DBP, ` +
350
+ `${gabungan.ralat.length} ralat, ` +
351
+ `${gabungan.tidakDapatDisahkan.length} tidak dapat disahkan (${gabungan.peratusTidakDapatDisahkan}%)`,
352
+ )
353
+ for (const r of gabungan.ralat.slice(0, 30)) {
354
+ console.error(
355
+ ` RALAT ${r.kata}: ${r.sebab}` + (r.sepatutnya ? ` -> guna "${r.sepatutnya}"` : ''),
356
+ )
357
+ }
358
+ if (gabungan.ralat.length > 30) console.error(` ... ${gabungan.ralat.length - 30} lagi`)
359
+ }
360
+ process.exitCode = kod
361
+ break
362
+ }
363
+
283
364
  case 'nama': {
284
365
  if (baki.length >= 2) {
285
366
  const rec = await tambahNama(baki[0], baki[1], { jenis: pilihan.jenis ?? null })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prpm-dbp",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Semak ejaan Bahasa Melayu dan Jawi terhadap PRPM Dewan Bahasa dan Pustaka. CLI dan skill untuk AI agent.",
5
5
  "keywords": [
6
6
  "bahasa-melayu",
package/src/gate.js ADDED
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Gate: semakan yang boleh GAGAL, untuk hook dan CI.
3
+ *
4
+ * Dibina selepas satu agen menggunakan skill ini sepanjang sesi penuh dan
5
+ * tetap menghantar 273 ejaan salah ke pangkalan data produksi. Alat untuk
6
+ * menangkapnya ada di tangannya sepanjang masa; ia tidak pernah dijalankan.
7
+ * Kedua-dua pusingan ditemui kerana pengguna bertanya, bukan kerana proses
8
+ * menangkapnya.
9
+ *
10
+ * Kesimpulannya: skill yang bergantung pada agen ingat sendiri akan gagal,
11
+ * dan gagal secara senyap. Gate wujud supaya kepatuhan tidak bergantung
12
+ * pada ingatan.
13
+ *
14
+ * TIGA keadaan, bukan dua. `tidakDapatDisahkan` TIDAK PERNAH lulus senyap:
15
+ * gate yang lulus atas cache sejuk memberi jaminan palsu, dan itu lebih
16
+ * bahaya daripada tiada gate.
17
+ *
18
+ * Versi pertama menyemak Rumi SAHAJA. Regexnya `[A-Za-zÀ-ÿ]` bermakna
19
+ * dokumen Jawi penuh melepasi hook tanpa satu aduan pun — tepat jenis
20
+ * dokumen yang skill ini paling diperlukan. Sebaris teks pelajaran dengan
21
+ * `ݢول وڠن` (golongan dengan ruang tersasar di tengahnya) dan `منجاوهكن`
22
+ * (menjauhkan tanpa hamzah) lulus sepenuhnya. Gate kini menyemak
23
+ * kedua-dua tulisan.
24
+ */
25
+
26
+ import { lookup } from './prpm.js'
27
+ import { mungkinAkronim, mungkinNamaKhas } from './nama.js'
28
+ import { cariDataset } from './dataset.js'
29
+ import { normalisasiJawi } from './kamus.js'
30
+ import { jarakHuruf, petaSongsang } from './tukar.js'
31
+ import { pisahUlangJawi } from './pedoman.js'
32
+ import { rangkaKonsonan } from './semak.js'
33
+
34
+ const TOKEN_RUMI = /([A-Za-zÀ-ÿ][A-Za-zÀ-ÿ'-]*)/g
35
+ const TOKEN_JAWI = /([؀-ۿݐ-ݿﭐ-﷿ﹰ-]+)/g
36
+
37
+ // Baris tanda vokal Arab. Jawi Melayu tidak menggunakannya; petikan al-Quran
38
+ // dan hadis dalam dokumen Melayu menggunakannya. Kehadirannya bermakna
39
+ // potongan itu bahasa Arab, yang Pedoman §11.1 kata kekalkan seadanya.
40
+ const HARAKAT = /[ً-ْٰٓ-ٕ]/
41
+
42
+ /**
43
+ * Semak token Jawi terhadap peta songsang DBP.
44
+ *
45
+ * Yang paling penting di sini ialah pengesanan RUANG TERSASAR. Sebuah
46
+ * perkataan yang terbelah dua boleh gagal dengan LANTANG pada satu separuh
47
+ * dan BERJAYA SECARA PALSU pada separuh yang lain: `ݢولوڠن` (golongan)
48
+ * yang terbelah menjadi `ݢول` + `وڠن` meninggalkan `ݢول`, yang benar-benar
49
+ * memetakan kepada dua perkataan DBP tulen, gol dan gul. Penukar yang
50
+ * memilih calon paling kerap akan mengeluarkan "gol" dengan penuh yakin,
51
+ * dan separuh perkataan hilang tanpa sesiapa perasan.
52
+ *
53
+ * Sebab itu percantuman dicuba apabila SALAH SATU jiran tidak dapat
54
+ * diselesaikan. Menuntut kedua-duanya gagal akan terlepas kes ini justeru
55
+ * kerana separuh pertama "berjaya".
56
+ */
57
+ export function gateJawi(teks, cache, opts = {}) {
58
+ const { dataset = null, abai = new Set(), namaJawi = new Set() } = opts
59
+ const peta = petaSongsang(cache, dataset)
60
+
61
+ // Indeks rangka konsonan, dibina sekali. Ejaan yang tersalah biasanya
62
+ // berbeza hanya pada vokal atau hamzah — منجاوهكن lawan منجاءوهکن — jadi
63
+ // rangkanya kekal sama dan ejaan DBP yang betul boleh dinamakan terus.
64
+ // Melaporkan "tiada dalam DBP" sahaja menyerahkan kerja mencari kepada
65
+ // pembaca; melaporkan ejaan sebenar menutup jurang itu.
66
+ const rangka = new Map()
67
+ for (const [kunci, slot] of peta) {
68
+ if (!slot.layak.length) continue
69
+ const r = rangkaKonsonan(kunci)
70
+ if (!r) continue
71
+ if (!rangka.has(r)) rangka.set(r, [])
72
+ rangka.get(r).push({ jawi: kunci, rumi: slot.layak[0] })
73
+ }
74
+ const cadang = (t) => {
75
+ const senarai = rangka.get(rangkaKonsonan(t)) ?? []
76
+ if (!senarai.length) return null
77
+ const terbaik = senarai
78
+ .map((c) => ({ ...c, jarak: jarakHuruf(normalisasiJawi(t), c.jawi) }))
79
+ .sort((a, b) => a.jarak - b.jarak)[0]
80
+ // Jarak besar bermakna ia perkataan lain sepenuhnya yang kebetulan
81
+ // berkongsi rangka, bukan salah eja. Cadangan begitu mengelirukan.
82
+ return terbaik.jarak <= 3 ? terbaik : null
83
+ }
84
+
85
+ const lulusDbp = []
86
+ const ralat = []
87
+ const tidakDapatDisahkan = []
88
+ const dilihat = new Set()
89
+
90
+ const bahagian = teks.split(TOKEN_JAWI)
91
+ const token = []
92
+ for (const [i, cebis] of bahagian.entries()) {
93
+ if (i % 2 === 1) token.push({ teks: cebis, i })
94
+ }
95
+
96
+ // Pemisah antara dua token mesti ruang tulen sebelum percantuman dicuba.
97
+ // Tanda baca, petikan atau baris baru antara mereka bermakna ia memang
98
+ // dua perkataan berasingan, bukan satu yang terbelah.
99
+ const ruangSahaja = (a, b) => /^[ \t ‌​]*$/.test(bahagian.slice(a + 1, b).join(''))
100
+
101
+ // §17.1: kata ulang penuh ialah kata dasar + angka dua Arab. `اوسها٢`
102
+ // ialah usaha-usaha, dan kata dasarnya yang ada dalam kamus, bukan bentuk
103
+ // berangka itu. Tanpa langkah ini setiap kata ulang dalam dokumen jadi
104
+ // ralat palsu.
105
+ const cari = (t) => {
106
+ const langsung = peta.get(normalisasiJawi(t))
107
+ if (langsung) return langsung
108
+ const dasar = pisahUlangJawi(t)
109
+ return dasar ? (peta.get(normalisasiJawi(dasar)) ?? null) : null
110
+ }
111
+ const selesai = (t) => {
112
+ const m = cari(t)
113
+ return m ? m.layak.length === 1 : false
114
+ }
115
+
116
+ for (const [n, tok] of token.entries()) {
117
+ const t = tok.teks
118
+ if (abai.has(t) || abai.has(normalisasiJawi(t))) continue
119
+
120
+ // Petikan Arab tidak selalu bertanda baris. Kata sandang `ال` ialah
121
+ // penanda paling boleh dipercayai yang tinggal: ia membuka hampir setiap
122
+ // frasa Arab yang dipetik (اليمين, المقربون, الشمال) dan pada dasarnya
123
+ // tidak pernah membuka kata Melayu Jawi. Nama khas Arab tanpa kata
124
+ // sandang (أصحاب) tetap perlu diisytihar melalui simpanan `nama`;
125
+ // kamus Melayu memang tidak boleh mengesahkannya, dan berpura-pura
126
+ // boleh adalah jaminan palsu.
127
+ if (HARAKAT.test(t) || (/^ال/.test(t) && !cari(t))) {
128
+ // Pedoman §11.1: petikan Arab kekalkan ejaan asal. Ia bukan lulus DBP
129
+ // kerana kamus Melayu tidak boleh mengesahkannya; ia keadaan ketiga.
130
+ if (!dilihat.has(t)) { dilihat.add(t); tidakDapatDisahkan.push({ kata: t, sebab: 'teks Arab, dikekalkan (Pedoman §11.1)' }) }
131
+ continue
132
+ }
133
+
134
+ const m = cari(t)
135
+
136
+ // Cuba cantum dengan jiran SEBELUM melaporkan apa-apa, kerana ruang
137
+ // tersasar mencemarkan kedua-dua separuh sekali gus.
138
+ const seterusnya = token[n + 1]
139
+ if (seterusnya && ruangSahaja(tok.i, seterusnya.i)) {
140
+ const belumSelesai = !selesai(t) || !selesai(seterusnya.teks)
141
+ if (belumSelesai) {
142
+ const gabung = cari(t + seterusnya.teks)
143
+ if (gabung?.layak.length) {
144
+ const kunci = t + ' ' + seterusnya.teks
145
+ if (!dilihat.has(kunci)) {
146
+ dilihat.add(kunci)
147
+ ralat.push({
148
+ kata: kunci,
149
+ sebab: 'ruang tersasar di dalam perkataan',
150
+ sepatutnya: t + seterusnya.teks,
151
+ bacaan: gabung.layak[0],
152
+ })
153
+ }
154
+ dilihat.add(t)
155
+ dilihat.add(seterusnya.teks)
156
+ continue
157
+ }
158
+ }
159
+ }
160
+
161
+ if (dilihat.has(t)) continue
162
+ dilihat.add(t)
163
+
164
+ if (!m) {
165
+ // Nama khas yang SUDAH diisytihar pengguna bukan ralat. Ia tetap bukan
166
+ // lulus DBP — kamus Melayu tidak mengesahkannya — jadi ia keadaan
167
+ // ketiga, dan pembaca nampak apa yang tidak disemak.
168
+ if (namaJawi.has(t) || namaJawi.has(normalisasiJawi(t))) {
169
+ tidakDapatDisahkan.push({ kata: t, sebab: 'nama khas diisytihar pengguna' })
170
+ continue
171
+ }
172
+ const c = cadang(t)
173
+ ralat.push({
174
+ kata: t,
175
+ sebab: 'ejaan Jawi tiada dalam DBP mahupun dataset',
176
+ ...(c ? { sepatutnya: c.jawi, bacaan: c.rumi } : {}),
177
+ })
178
+ continue
179
+ }
180
+ if (m.layak.length === 1) {
181
+ lulusDbp.push(t)
182
+ continue
183
+ }
184
+ if (m.layak.length > 1) {
185
+ // Homograf tulen. Ejaannya SAH; hanya bacaannya perlu konteks. Ini
186
+ // bukan ralat, dan menandakannya begitu akan menggagalkan setiap
187
+ // dokumen yang mengandungi سنة.
188
+ tidakDapatDisahkan.push({ kata: t, sebab: `homograf, ${m.layak.length} bacaan DBP`, calon: m.layak })
189
+ continue
190
+ }
191
+ if (m.entah.length) {
192
+ tidakDapatDisahkan.push({ kata: t, sebab: 'hanya dalam dataset, bukan DBP', calon: m.entah })
193
+ continue
194
+ }
195
+ ralat.push({ kata: t, sebab: 'hanya padan bentuk rujukan silang DBP', calon: m.tolak })
196
+ }
197
+
198
+ return { lulusDbp, ralat, tidakDapatDisahkan }
199
+ }
200
+
201
+ /**
202
+ * Semak teks Rumi. Setiap perkataan jatuh ke dalam SATU daripada tiga baldi.
203
+ */
204
+ export async function gate(teks, cache, opts = {}) {
205
+ // Dataset boleh disuntik supaya ujian tidak bergantung pada fail yang
206
+ // diimport ke mesin tertentu. Ujian yang bergantung pada keadaan mesin
207
+ // lulus di sini dan gagal di CI, atau lebih teruk, sebaliknya.
208
+ const { luarTalian = true, abai = new Set(), cariDalamDataset = cariDataset } = opts
209
+
210
+ const lulusDbp = []
211
+ const ralat = []
212
+ const tidakDapatDisahkan = []
213
+ const dilihat = new Set()
214
+
215
+ const bahagian = teks.split(TOKEN_RUMI)
216
+ for (const [i, cebis] of bahagian.entries()) {
217
+ if (i % 2 === 0) continue
218
+ const kata = cebis.toLowerCase()
219
+ if (kata.length < 2 || dilihat.has(kata)) continue
220
+ dilihat.add(kata)
221
+
222
+ if (abai.has(kata)) continue
223
+
224
+ // Akronim dan nama khas tidak boleh disahkan oleh kamus. Ia BUKAN lulus;
225
+ // ia keadaan ketiga, supaya pengguna tahu apa yang tidak disemak.
226
+ if (mungkinAkronim(cebis) || mungkinNamaKhas(cebis, bahagian[i - 1])) {
227
+ tidakDapatDisahkan.push({ kata: cebis, sebab: 'nama khas atau akronim' })
228
+ continue
229
+ }
230
+
231
+ const rec = await lookup(kata, cache, { ...opts, luarTalian })
232
+
233
+ if (rec?.ralat) {
234
+ tidakDapatDisahkan.push({ kata, sebab: `gagal semak: ${rec.ralat}` })
235
+ continue
236
+ }
237
+
238
+ if (!rec?.dijumpai) {
239
+ // Dalam mod luar talian, "tiada dalam cache" berbeza daripada "tiada
240
+ // dalam PRPM". Yang pertama tidak dapat disahkan; yang kedua ralat.
241
+ if (luarTalian && /luar talian/.test(rec?.nota ?? '')) {
242
+ tidakDapatDisahkan.push({ kata, sebab: 'tiada dalam cache' })
243
+ continue
244
+ }
245
+
246
+ // PRPM menyatakan sendiri perkataan ini tiada dalam kamus TERKINI
247
+ // tetapi ada dalam tesaurus. Itu bukan ejaan salah; ia perkataan sah
248
+ // yang bahagian kamusnya tidak diliputi. Menandakannya ralat
249
+ // menghasilkan negatif palsu pada perkataan paling biasa.
250
+ if (rec?.adaTesaurus) {
251
+ tidakDapatDisahkan.push({
252
+ kata,
253
+ sebab: 'tiada dalam kamus terkini PRPM, tetapi ada dalam tesaurus',
254
+ })
255
+ continue
256
+ }
257
+
258
+ // PRPM tiada entri untuk kata tugas paling asas: dan, di, ke, yang,
259
+ // atau. Menandakannya sebagai ralat menjadikan gate mustahil dipakai.
260
+ // Pembezanya bersumber, bukan senarai putih dari ingatan: kata tugas
261
+ // itu SEMUA wujud dalam dataset, manakala hadith, fardhu, tayammum,
262
+ // dhuha, qadha, redha dan iddah TIADA satu pun.
263
+ const ds = await cariDalamDataset(kata)
264
+ if (ds?.jawi) {
265
+ tidakDapatDisahkan.push({
266
+ kata,
267
+ sebab: 'tiada entri PRPM, tetapi wujud dalam dataset',
268
+ })
269
+ continue
270
+ }
271
+
272
+ ralat.push({ kata, sebab: 'tiada entri dalam PRPM', url: rec?.url })
273
+ continue
274
+ }
275
+
276
+ // Entri rujukan silang bermakna DBP mengalihkan bentuk ini ke bentuk lain.
277
+ // sunnah -> ®sunah. Menggunakannya adalah ejaan yang salah.
278
+ const def = (rec.entri?.[0]?.definisi ?? '').trim()
279
+ const silang = /^®\s*(.+?)\.?$/.exec(def)
280
+ if (silang) {
281
+ ralat.push({
282
+ kata,
283
+ sebab: 'rujukan silang, DBP mengalihkan ke bentuk lain',
284
+ sepatutnya: silang[1].trim(),
285
+ url: rec.url,
286
+ })
287
+ continue
288
+ }
289
+
290
+ lulusDbp.push(kata)
291
+ }
292
+
293
+ // Jawi disemak dalam panggilan yang SAMA. Kalau ia perintah berasingan,
294
+ // ia jadi langkah yang boleh dilupakan, dan itu punca kegagalan asal.
295
+ const j = gateJawi(teks, cache, opts)
296
+ lulusDbp.push(...j.lulusDbp)
297
+ ralat.push(...j.ralat.map((r) => ({ ...r, tulisan: 'jawi' })))
298
+ tidakDapatDisahkan.push(...j.tidakDapatDisahkan.map((r) => ({ ...r, tulisan: 'jawi' })))
299
+
300
+ const jumlah = lulusDbp.length + ralat.length + tidakDapatDisahkan.length
301
+ const peratusTidakSah = jumlah ? Math.round((tidakDapatDisahkan.length / jumlah) * 100) : 0
302
+
303
+ return {
304
+ jumlahKataUnik: jumlah,
305
+ lulusDbp: lulusDbp.length,
306
+ ralat,
307
+ tidakDapatDisahkan,
308
+ peratusTidakDapatDisahkan: peratusTidakSah,
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Tentukan kod keluar. Ralat sentiasa menggagalkan. `tidakDapatDisahkan`
314
+ * menggagalkan apabila melebihi ambang, kerana cache sejuk bermakna gate
315
+ * tidak menyemak apa-apa.
316
+ */
317
+ export function kodKeluar(laporan, ambang = null) {
318
+ if (laporan.ralat.length) return 1
319
+ if (ambang != null && laporan.peratusTidakDapatDisahkan > ambang) return 1
320
+ return 0
321
+ }
package/src/install.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * Mod salin (default) untuk pengguna npx, mod --link untuk pembangun.
4
4
  */
5
5
 
6
- import { cp, mkdir, rm, symlink } from 'node:fs/promises'
6
+ import { cp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'
7
7
  import { homedir } from 'node:os'
8
8
  import { dirname, join, resolve } from 'node:path'
9
9
  import { fileURLToPath } from 'node:url'
@@ -26,3 +26,50 @@ export async function pasangSkill({ link = false } = {}) {
26
26
  }
27
27
  return { dipasang: SASARAN, mod: 'salin', sumber: AKAR }
28
28
  }
29
+
30
+ /**
31
+ * Pasang hook PostToolUse Claude Code yang menjalankan gate ke atas fail
32
+ * yang baru ditulis atau disunting.
33
+ *
34
+ * Ini satu-satunya lapisan yang benar-benar memaksa. Peraturan dalam
35
+ * SKILL.md bergantung pada agen memilih untuk patuh, dan satu agen sudah
36
+ * membuktikan itu tidak mencukupi: 273 ejaan salah masuk produksi walaupun
37
+ * alat menangkapnya ada di tangan sepanjang masa.
38
+ *
39
+ * Hook gagal LEMBUT: ia melaporkan ralat kepada agen tanpa menyekat tulisan.
40
+ * Menyekat tulisan atas kamus yang tidak lengkap akan menghalang kerja yang
41
+ * sah; melaporkan sudah memadai kerana agen membaca output hook.
42
+ */
43
+ export const HOOK = {
44
+ matcher: 'Write|Edit',
45
+ hooks: [
46
+ {
47
+ type: 'command',
48
+ command:
49
+ 'f=$(echo "$CLAUDE_TOOL_INPUT" | grep -oE \'"file_path"[^,]*\' | cut -d\'"\' -f4); ' +
50
+ 'case "$f" in *.md|*.txt|*.json) npx -y prpm-dbp gate "$f" --luar-talian 2>&1 | head -20 ;; esac; exit 0',
51
+ },
52
+ ],
53
+ }
54
+
55
+ export async function pasangHook({ fail = join(homedir(), '.claude', 'settings.json') } = {}) {
56
+ let tetapan = {}
57
+ try {
58
+ tetapan = JSON.parse(await readFile(fail, 'utf8'))
59
+ } catch {
60
+ tetapan = {}
61
+ }
62
+
63
+ tetapan.hooks ??= {}
64
+ tetapan.hooks.PostToolUse ??= []
65
+
66
+ const sedia = tetapan.hooks.PostToolUse.some((h) =>
67
+ JSON.stringify(h).includes('prpm-dbp gate'),
68
+ )
69
+ if (sedia) return { fail, status: 'sudah dipasang' }
70
+
71
+ tetapan.hooks.PostToolUse.push(HOOK)
72
+ await mkdir(dirname(fail), { recursive: true })
73
+ await writeFile(fail, JSON.stringify(tetapan, null, 2), 'utf8')
74
+ return { fail, status: 'dipasang', matcher: HOOK.matcher }
75
+ }
package/src/prpm.js CHANGED
@@ -34,6 +34,17 @@ const DEFINISI = /<b>Definisi\s*:\s*<\/b>([\s\S]*?)(?=<b>\(|$)/
34
34
  const SUMBER = /<b>\(([^)]+)\)<\/b>/
35
35
  const TIADA_TESAURUS = /Tiada maklumat tesaurus untuk kata/
36
36
 
37
+ // PRPM mengisytiharkan keadaan ketiga dengan ayatnya sendiri. Perkataan
38
+ // seperti orang, itu dan sumber memulangkan halaman 35-42KB tanpa satu pun
39
+ // div.tab-pane, tetapi ia BUKAN "tiada entri": PRPM menyatakan perkataan itu
40
+ // tiada dalam kamus TERKINI, dan memaparkan Tesaurus sebagai gantinya.
41
+ //
42
+ // Menyamakan dua keadaan ini menghasilkan negatif palsu pada perkataan yang
43
+ // paling biasa, dan negatif palsu di situ merosakkan kepercayaan kepada
44
+ // seluruh laporan.
45
+ const BUKAN_KAMUS_TERKINI = /Carian kata tiada di dalam kamus terkini/
46
+ const ADA_TESAURUS = /Bersinonim dengan/
47
+
37
48
  // Panel "Juga ditemukan dalam" menyenaraikan bilangan rujukan perkataan itu
38
49
  // dalam setiap korpus DBP. Jumlahnya ialah isyarat kekerapan yang BERSUMBER,
39
50
  // bukan tekaan: mengikut 2439 rujukan berbanding mengekot 6.
@@ -89,11 +100,16 @@ export function parse(kata, page) {
89
100
  jawiVarian: unik.slice(1),
90
101
  entri,
91
102
  url: `${BASE_URL}?keyword=${encodeURIComponent(kata)}`,
103
+ // Tiga keadaan berbeza, bukan dua.
104
+ dalamKamusTerkini: entri.length > 0 ? true : !BUKAN_KAMUS_TERKINI.test(page),
105
+ adaTesaurus: ADA_TESAURUS.test(page),
92
106
  nota: entri.length
93
107
  ? null
94
- : TIADA_TESAURUS.test(page)
95
- ? 'tiada entri dalam PRPM'
96
- : 'halaman tidak dikenali, semak manual',
108
+ : BUKAN_KAMUS_TERKINI.test(page)
109
+ ? 'tiada dalam kamus terkini PRPM; ada dalam tesaurus'
110
+ : TIADA_TESAURUS.test(page)
111
+ ? 'tiada entri dalam PRPM'
112
+ : 'halaman tidak dikenali, semak manual',
97
113
  }
98
114
  }
99
115
 
package/src/tukar.js CHANGED
@@ -162,6 +162,36 @@ export async function cariDasar(kata, cache, opts = {}) {
162
162
  * bilangan perkataan terpanjang supaya pembacaan songsang boleh memadan
163
163
  * jujukan, bukan token tunggal sahaja.
164
164
  */
165
+ /**
166
+ * Jarak suntingan antara dua ejaan.
167
+ *
168
+ * MESTI menghormati susunan huruf. Ukuran berasaskan set huruf gagal dengan
169
+ * teruk: سروه (seruh) ialah anagram sempurna bagi سوره, jadi jaraknya sifar
170
+ * dan ia mengalahkan سورة (surah) yang sebenarnya betul. Susunan huruf itulah
171
+ * maklumat yang membezakannya.
172
+ */
173
+ export function jarakHuruf(a, b) {
174
+ const m = a.length
175
+ const n = b.length
176
+ if (!m) return n
177
+ if (!n) return m
178
+ let baris = Array.from({ length: n + 1 }, (_, i) => i)
179
+ for (let i = 1; i <= m; i++) {
180
+ let sebelum = baris[0]
181
+ baris[0] = i
182
+ for (let j = 1; j <= n; j++) {
183
+ const simpan = baris[j]
184
+ baris[j] = Math.min(
185
+ baris[j] + 1,
186
+ baris[j - 1] + 1,
187
+ sebelum + (a[i - 1] === b[j - 1] ? 0 : 1),
188
+ )
189
+ sebelum = simpan
190
+ }
191
+ }
192
+ return baris[n]
193
+ }
194
+
165
195
  /**
166
196
  * Indeks rangka konsonan, untuk memadan ejaan bukan piawai.
167
197
  *
@@ -455,7 +485,13 @@ export async function keJawi(teks, cache, opts = {}) {
455
485
  export function keRumi(
456
486
  teksJawi,
457
487
  cache,
458
- { penanda = ['«', '»'], dataset = null, pilihLazim = false, padanRangka = false } = {},
488
+ {
489
+ penanda = ['«', '»'],
490
+ dataset = null,
491
+ pilihLazim = false,
492
+ padanRangka = false,
493
+ glosari = null,
494
+ } = {},
459
495
  ) {
460
496
  const peta = petaSongsang(cache, dataset)
461
497
  const tidakDikenali = []
@@ -463,6 +499,7 @@ export function keRumi(
463
499
  const arab = []
464
500
  const sendiDipisah = []
465
501
  const bukanPiawai = []
502
+ const dariGlosari = []
466
503
  const rangka = padanRangka ? petaRangka(cache, dataset) : null
467
504
  const bahagian = teksJawi.split(PECAH_JAWI)
468
505
  const keluar = []
@@ -470,6 +507,20 @@ export function keRumi(
470
507
  for (let i = 0; i < bahagian.length; i++) {
471
508
  if (i % 2 === 0) { keluar.push(bahagian[i]); continue }
472
509
 
510
+ // Glosari domain yang dibekalkan pemanggil MENGATASI segalanya.
511
+ // Kekerapan global gagal tepat di tempat paling penting: dalam teks
512
+ // domain, setiap istilah khusus kalah kepada kata lazim yang berkongsi
513
+ // rangka. اسم menjadi asam, فعل menjadi faal, رسم menjadi rasam.
514
+ // Hanya pengetahuan domain boleh membetulkannya, dan pemanggil ada.
515
+ if (glosari) {
516
+ const paksa = glosari[bahagian[i]] ?? glosari[normalisasiJawi(bahagian[i])]
517
+ if (paksa) {
518
+ dariGlosari.push({ jawi: bahagian[i], rumi: paksa })
519
+ keluar.push(paksa)
520
+ continue
521
+ }
522
+ }
523
+
473
524
  // Teks Arab bertanda baris dibiarkan seadanya. Cuba menukarnya akan
474
525
  // merosakkan petikan al-Quran dan hadis dalam dokumen Melayu.
475
526
  if (HARAKAT.test(bahagian[i])) {
@@ -525,12 +576,33 @@ export function keRumi(
525
576
  if (rangka) {
526
577
  const padananRangka = rangka.get(rangkaKonsonan(bahagian[i]))
527
578
  if (padananRangka?.size) {
528
- const senarai = [...padananRangka.entries()].map(([rumi, jawiDbp]) => ({ rumi, jawiDbp }))
529
- bukanPiawai.push({ jawi: bahagian[i], calon: senarai })
579
+ // Rangka pendek berlanggar teruk: رواية memberi 20+ calon. Isih
580
+ // ikut kedekatan huruf sebenar dahulu, kemudian kekerapan korpus,
581
+ // dan hadkan senarai supaya teks kekal boleh dibaca.
582
+ const asal = bahagian[i]
583
+ const senarai = [...padananRangka.entries()]
584
+ .map(([rumi, jawiDbp]) => ({
585
+ rumi,
586
+ jawiDbp,
587
+ jarak: jarakHuruf(asal, jawiDbp),
588
+ kerap: cache[rumi]?.rujukanKorpus ?? 0,
589
+ }))
590
+ .sort((a, b) => a.jarak - b.jarak || b.kerap - a.kerap)
591
+ .slice(0, 6)
592
+
593
+ bukanPiawai.push({ jawi: asal, calon: senarai })
594
+
530
595
  if (senarai.length === 1) {
531
596
  keluar.push(senarai[0].rumi)
532
597
  continue
533
598
  }
599
+ // --pilih mesti meleraikan ini juga, jika tidak seluruh senarai
600
+ // calon dibuang ke dalam teks dan ayat menjadi tidak boleh dibaca.
601
+ if (pilihLazim) {
602
+ bukanPiawai[bukanPiawai.length - 1].dipilih = senarai[0].rumi
603
+ keluar.push(senarai[0].rumi)
604
+ continue
605
+ }
534
606
  keluar.push(penanda[0] + senarai.map((x) => x.rumi).join('|') + penanda[1])
535
607
  continue
536
608
  }
@@ -567,6 +639,7 @@ export function keRumi(
567
639
  arab: [...new Set(arab)],
568
640
  sendiDipisah,
569
641
  ejaanBukanPiawai: bukanPiawai,
642
+ dariGlosari,
570
643
  saizPeta: peta.size,
571
644
  lengkap: tidakDikenali.length === 0,
572
645
  }