prpm-dbp 0.16.0 → 0.18.1

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,83 @@ 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` 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.
34
+
35
+ ## Kuatkuasa automatik
36
+
37
+ ```bash
38
+ npx prpm-dbp install --hook
39
+ ```
40
+
41
+ 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.
42
+
43
+ Untuk menampal sendiri: `npx prpm-dbp hook-json`.
44
+
45
+ Panaskan cache **sekali** semasa pemasangan, bukan semasa gate berjalan:
46
+
47
+ ```bash
48
+ npx prpm-dbp warm kosa-kata-projek.txt
49
+ ```
50
+
51
+ Gate atas cache panas mengambil 0.19 saat bagi 406 perkataan. Itu cukup pantas untuk setiap penyuntingan. Gate yang perlahan akan dimatikan orang.
52
+
53
+ ## Tiga keadaan gate, bukan dua
54
+
55
+ | Medan | Maksud | Kesan |
56
+ |---|---|---|
57
+ | `lulusDbp` | ada dalam PRPM, ejaan padan | — |
58
+ | `ralat` | ada dalam PRPM dengan ejaan lain, entri rujukan silang `®`, atau tiada dalam PRPM mahupun dataset | **exit 1** |
59
+ | `tidakDapatDisahkan` | tiada dalam cache, nama khas, akronim, atau tiada entri PRPM tetapi wujud dalam dataset | dilapor; exit 1 jika melebihi `--ambang` |
60
+
61
+ `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.
62
+
63
+ 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**.
64
+
65
+ ## Prosedur wajib
66
+
67
+ Ikut ini setiap kali, jangan langkau langkah.
68
+
69
+ **Menulis Jawi daripada Rumi:**
70
+
71
+ 1. `npx prpm-dbp jawi <fail> --luar-talian --homograf --json`
72
+ 2. Baca `belumDisahkan` dan `namaKhas`. Jangan reka ejaan untuknya. Sahkan dengan `lookup`, atau tanya pengguna, atau tandakan dalam hasil akhir.
73
+ 3. Baca `mencurigakan`. Setiap satu bermakna PRPM sendiri mungkin tersilap. Semak di pautan yang diberi sebelum menggunakannya.
74
+ 4. Baca `homograf`. Beritahu pengguna perkataan mana yang akan taksa apabila dibaca.
75
+ 5. Laporkan `semuanyaDbp`. Jika `false`, nyatakan bahagian mana bukan daripada sumber rasmi.
76
+
77
+ **Merumikan daripada Jawi:**
78
+
79
+ 1. `npx prpm-dbp rumi <fail> --pilih --konteks --json`
80
+ 2. Untuk **setiap** item dalam `kabur`: baca ayat yang mengandunginya, baca makna setiap calon, tentukan yang mana sesuai.
81
+ 3. Jika `dipilih` salah bagi ayat itu, **gantikan** dalam hasil akhir. Kekerapan kalah kepada konteks.
82
+ 4. Nyatakan kepada pengguna berapa banyak kekaburan wujud dan mana yang anda ubah.
83
+ 5. `tidakDikenali` bermakna perkataan itu tiada dalam cache. Jalankan `warm` di luar talian, bukan tinggalkan bertanda.
84
+
85
+ **Berhenti dan tanya pengguna apabila:** ejaan `mencurigakan` mengubah makna, nama khas tiada rujukan bertulis, atau dua calon sama-sama munasabah dalam ayat itu.
86
+
87
+ Semua perintah menyokong `--json`, jadi langkah di atas boleh dibuat secara berulang tanpa menghurai teks.
88
+
12
89
  ## Peraturan
13
90
 
14
91
  0. **Semak ejaan ikut DBP, bukan ikut ingatan.** Ini peraturan pertama kerana ia yang paling kerap dilanggar. Perkataan yang anda "tahu" ejaannya masih perlu disemak. Contoh sebenar daripada penggunaan: `sunah` ialah `سنة` tetapi `sunat` ialah `سونت`; `bena` langsung tiada ejaan Jawi dalam PRPM walaupun ia perkataan yang sah; `salat` hanyalah rujukan silang kepada `solat`. Tiada satu pun daripada perbezaan ini boleh diteka daripada bentuk Rumi. Jalankan `lookup` atau `jawi`, jangan tulis daripada ingatan.
@@ -160,6 +237,83 @@ Ejaan yang disimpan begini ditandakan datang daripada pengguna, bukan daripada D
160
237
 
161
238
  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 `کوالا لومڤور`.
162
239
 
240
+ ## Sebelum menghantar apa-apa
241
+
242
+ Jalankan `npx prpm-dbp gate <fail> --luar-talian` ke atas **keseluruhan teks**. Bukan sebahagian. Bukan perkataan yang anda rasa berisiko.
243
+
244
+ Ejaan yang salah ialah tepat ejaan yang anda **tidak** syak. Kalau anda syak, anda sudah menyemaknya.
245
+
246
+ 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.
247
+
248
+ **Apa yang berlaku apabila langkah ini dilangkau:**
249
+
250
+ 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**.
251
+
252
+ ```
253
+ pusingan 1 sunnah x206, hadith x16, fardhu x2 -> masuk produksi
254
+ pusingan 2 tayammum, dhuha, qadha, redha, iddah x90 -> masuk produksi
255
+ ```
256
+
257
+ 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`.
258
+
259
+ 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.
260
+
261
+ `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.
262
+
263
+ ## Kuatkuasa automatik
264
+
265
+ ```bash
266
+ npx prpm-dbp install --hook
267
+ ```
268
+
269
+ 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.
270
+
271
+ Untuk menampal sendiri: `npx prpm-dbp hook-json`.
272
+
273
+ Panaskan cache **sekali** semasa pemasangan, bukan semasa gate berjalan:
274
+
275
+ ```bash
276
+ npx prpm-dbp warm kosa-kata-projek.txt
277
+ ```
278
+
279
+ Gate atas cache panas mengambil 0.19 saat bagi 406 perkataan. Itu cukup pantas untuk setiap penyuntingan. Gate yang perlahan akan dimatikan orang.
280
+
281
+ ## Tiga keadaan gate, bukan dua
282
+
283
+ | Medan | Maksud | Kesan |
284
+ |---|---|---|
285
+ | `lulusDbp` | ada dalam PRPM, ejaan padan | — |
286
+ | `ralat` | ada dalam PRPM dengan ejaan lain, entri rujukan silang `®`, atau tiada dalam PRPM mahupun dataset | **exit 1** |
287
+ | `tidakDapatDisahkan` | tiada dalam cache, nama khas, akronim, atau tiada entri PRPM tetapi wujud dalam dataset | dilapor; exit 1 jika melebihi `--ambang` |
288
+
289
+ `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.
290
+
291
+ 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**.
292
+
293
+ ## Prosedur wajib
294
+
295
+ Ikut ini setiap kali, jangan langkau langkah.
296
+
297
+ **Menulis Jawi daripada Rumi:**
298
+
299
+ 1. `npx prpm-dbp jawi <fail> --luar-talian --homograf --json`
300
+ 2. Baca `belumDisahkan` dan `namaKhas`. Jangan reka ejaan untuknya. Sahkan dengan `lookup`, atau tanya pengguna, atau tandakan dalam hasil akhir.
301
+ 3. Baca `mencurigakan`. Setiap satu bermakna PRPM sendiri mungkin tersilap. Semak di pautan yang diberi sebelum menggunakannya.
302
+ 4. Baca `homograf`. Beritahu pengguna perkataan mana yang akan taksa apabila dibaca.
303
+ 5. Laporkan `semuanyaDbp`. Jika `false`, nyatakan bahagian mana bukan daripada sumber rasmi.
304
+
305
+ **Merumikan daripada Jawi:**
306
+
307
+ 1. `npx prpm-dbp rumi <fail> --pilih --konteks --json`
308
+ 2. Untuk **setiap** item dalam `kabur`: baca ayat yang mengandunginya, baca makna setiap calon, tentukan yang mana sesuai.
309
+ 3. Jika `dipilih` salah bagi ayat itu, **gantikan** dalam hasil akhir. Kekerapan kalah kepada konteks.
310
+ 4. Nyatakan kepada pengguna berapa banyak kekaburan wujud dan mana yang anda ubah.
311
+ 5. `tidakDikenali` bermakna perkataan itu tiada dalam cache. Jalankan `warm` di luar talian, bukan tinggalkan bertanda.
312
+
313
+ **Berhenti dan tanya pengguna apabila:** ejaan `mencurigakan` mengubah makna, nama khas tiada rujukan bertulis, atau dua calon sama-sama munasabah dalam ayat itu.
314
+
315
+ Semua perintah menyokong `--json`, jadi langkah di atas boleh dibuat secara berulang tanpa menghurai teks.
316
+
163
317
  ## Peraturan Pedoman DBP
164
318
 
165
319
  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.
@@ -271,6 +425,83 @@ Dua puluh dua kes kabur yang tinggal itu sah: `قلقله` boleh dibaca `qalqala
271
425
 
272
426
  **Sifar perkataan hilang atau rosak.**
273
427
 
428
+ ## Sebelum menghantar apa-apa
429
+
430
+ Jalankan `npx prpm-dbp gate <fail> --luar-talian` ke atas **keseluruhan teks**. Bukan sebahagian. Bukan perkataan yang anda rasa berisiko.
431
+
432
+ Ejaan yang salah ialah tepat ejaan yang anda **tidak** syak. Kalau anda syak, anda sudah menyemaknya.
433
+
434
+ 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.
435
+
436
+ **Apa yang berlaku apabila langkah ini dilangkau:**
437
+
438
+ 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**.
439
+
440
+ ```
441
+ pusingan 1 sunnah x206, hadith x16, fardhu x2 -> masuk produksi
442
+ pusingan 2 tayammum, dhuha, qadha, redha, iddah x90 -> masuk produksi
443
+ ```
444
+
445
+ 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`.
446
+
447
+ 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.
448
+
449
+ `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.
450
+
451
+ ## Kuatkuasa automatik
452
+
453
+ ```bash
454
+ npx prpm-dbp install --hook
455
+ ```
456
+
457
+ 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.
458
+
459
+ Untuk menampal sendiri: `npx prpm-dbp hook-json`.
460
+
461
+ Panaskan cache **sekali** semasa pemasangan, bukan semasa gate berjalan:
462
+
463
+ ```bash
464
+ npx prpm-dbp warm kosa-kata-projek.txt
465
+ ```
466
+
467
+ Gate atas cache panas mengambil 0.19 saat bagi 406 perkataan. Itu cukup pantas untuk setiap penyuntingan. Gate yang perlahan akan dimatikan orang.
468
+
469
+ ## Tiga keadaan gate, bukan dua
470
+
471
+ | Medan | Maksud | Kesan |
472
+ |---|---|---|
473
+ | `lulusDbp` | ada dalam PRPM, ejaan padan | — |
474
+ | `ralat` | ada dalam PRPM dengan ejaan lain, entri rujukan silang `®`, atau tiada dalam PRPM mahupun dataset | **exit 1** |
475
+ | `tidakDapatDisahkan` | tiada dalam cache, nama khas, akronim, atau tiada entri PRPM tetapi wujud dalam dataset | dilapor; exit 1 jika melebihi `--ambang` |
476
+
477
+ `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.
478
+
479
+ 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**.
480
+
481
+ ## Prosedur wajib
482
+
483
+ Ikut ini setiap kali, jangan langkau langkah.
484
+
485
+ **Menulis Jawi daripada Rumi:**
486
+
487
+ 1. `npx prpm-dbp jawi <fail> --luar-talian --homograf --json`
488
+ 2. Baca `belumDisahkan` dan `namaKhas`. Jangan reka ejaan untuknya. Sahkan dengan `lookup`, atau tanya pengguna, atau tandakan dalam hasil akhir.
489
+ 3. Baca `mencurigakan`. Setiap satu bermakna PRPM sendiri mungkin tersilap. Semak di pautan yang diberi sebelum menggunakannya.
490
+ 4. Baca `homograf`. Beritahu pengguna perkataan mana yang akan taksa apabila dibaca.
491
+ 5. Laporkan `semuanyaDbp`. Jika `false`, nyatakan bahagian mana bukan daripada sumber rasmi.
492
+
493
+ **Merumikan daripada Jawi:**
494
+
495
+ 1. `npx prpm-dbp rumi <fail> --pilih --konteks --json`
496
+ 2. Untuk **setiap** item dalam `kabur`: baca ayat yang mengandunginya, baca makna setiap calon, tentukan yang mana sesuai.
497
+ 3. Jika `dipilih` salah bagi ayat itu, **gantikan** dalam hasil akhir. Kekerapan kalah kepada konteks.
498
+ 4. Nyatakan kepada pengguna berapa banyak kekaburan wujud dan mana yang anda ubah.
499
+ 5. `tidakDikenali` bermakna perkataan itu tiada dalam cache. Jalankan `warm` di luar talian, bukan tinggalkan bertanda.
500
+
501
+ **Berhenti dan tanya pengguna apabila:** ejaan `mencurigakan` mengubah makna, nama khas tiada rujukan bertulis, atau dua calon sama-sama munasabah dalam ayat itu.
502
+
503
+ Semua perintah menyokong `--json`, jadi langkah di atas boleh dibuat secara berulang tanpa menghurai teks.
504
+
274
505
  ## Peraturan yang sengaja TIDAK dilaksanakan
275
506
 
276
507
  | Seksyen | Sebab |
@@ -357,6 +588,61 @@ Empat kelas berasingan, kerana menggabungkannya menenggelamkan yang penting:
357
588
 
358
589
  Penambahan huruf vokal **tidak** dikira ejaan lama. Kalau kehadiran `ي` diabaikan, `بينا` (bina) dan `بنا` (bena) akan kelihatan sama, sedangkan itu dua perkataan berlainan.
359
590
 
591
+ ## Ejaan bukan piawai DBP
592
+
593
+ Dokumen kurikulum Malaysia kerap menggunakan ortografi Jawi yang berbeza daripada piawai DBP. Pasangan yang disahkan daripada DSKP Pendidikan Islam KPM sebenar:
594
+
595
+ | Rumi | DBP | DSKP | Beza |
596
+ |---|---|---|---|
597
+ | surah | `سورة` | `سوره` | ta marbutah jadi ha |
598
+ | iktibar | `اعتبار` | `اعتبر` | alif digugurkan |
599
+ | bersifat | `برصيفت` | `برصفة` | ya digugurkan, ta marbutah |
600
+ | dalil | `دليل` | `داليل` | alif ditambah |
601
+ | syirik | `شيريک` | `شرك` | ya digugurkan, kaf Arab |
602
+
603
+ Kesemuanya berbeza hanya pada huruf vokal dan bentuk huruf, bukan pada rangka konsonan. `--rangka` memadan rangka itu:
604
+
605
+ ```
606
+ $ npx prpm-dbp rumi dskp.txt --luar-talian --rangka
607
+ ejaan bukan piawai DBP (209):
608
+ اعتبر -> iktibar [DBP eja: اعتبار]
609
+ سوره -> surah [DBP eja: سورة]
610
+ کتب -> kitab|katib|kutub (rangka sama, PILIH ikut konteks)
611
+ ```
612
+
613
+ Diukur pada 168 baris DSKP sebenar: token yang langsung tidak dikenali turun daripada 100 kepada 47.
614
+
615
+ **Ini lapisan terakhir dan ia LOSSY.** 29% rangka dikongsi lebih daripada satu perkataan, purata 4.1 calon. Ia hanya digunakan selepas padanan tepat gagal, hasilnya sentiasa berlabel `ejaanBukanPiawai` berserta ejaan DBP untuk perbandingan, dan ia **tidak pernah memilih sendiri** apabila calon berbilang.
616
+
617
+ 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 `«...»`.
618
+
619
+ ## Kekaburan dalam teks domain
620
+
621
+ `--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.
622
+
623
+ Diukur pada DSKP KKQ/PQS: salah untuk 5 daripada 16 pasangan taksa, dan kelima-limanya ialah inti pelajaran.
624
+
625
+ ```
626
+ اسم -> asam patut isim "hamzah wasal pada kalimah asam dan faal"
627
+ فعل -> faal patut fiil
628
+ رسم -> rasam patut Rasm (Uthmani)
629
+ سنة -> sanat patut sunah
630
+ ```
631
+
632
+ Gunakan glosari domain. Ia **mengatasi segalanya**, termasuk lapisan 1:
633
+
634
+ ```bash
635
+ npx prpm-dbp rumi dskp.txt --pilih --glosari istilah-kkq.json
636
+ ```
637
+
638
+ ```json
639
+ { "اسم": "isim", "فعل": "fiil", "رسم": "rasm", "سنة": "sunah" }
640
+ ```
641
+
642
+ 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.
643
+
644
+ Kekerapan kekal sebagai pemecah seri sahaja, dan setiap keputusan tetap dilapor dalam `kabur` untuk semakan.
645
+
360
646
  ## Rantai kepercayaan
361
647
 
362
648
  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,15 +30,23 @@ 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)
38
43
  --luar-talian jangan sentuh rangkaian langsung (wajib untuk laluan permintaan app)
39
44
  --homograf tandakan perkataan yang ejaan Jawinya dikongsi perkataan lain
40
45
  --konteks papar ayat dan makna setiap calon bagi kekaburan (rumi)
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
41
50
  --pilih pilih calon paling lazim supaya teks boleh terus dibaca;
42
51
  setiap pilihan disenaraikan untuk disemak ikut konteks
43
52
 
@@ -52,12 +61,17 @@ function huraiArgs(argv) {
52
61
  if (a === '--refresh') pilihan.refresh = true
53
62
  else if (a === '--json') pilihan.json = true
54
63
  else if (a === '--link') pilihan.link = true
64
+ else if (a === '--hook') pilihan.hook = true
55
65
  else if (a === '--cepat') pilihan.cepat = true
56
66
  else if (a === '--pedoman') pilihan.pedoman = true
57
67
  else if (a === '--luar-talian') pilihan.luarTalian = true
58
68
  else if (a === '--homograf') pilihan.homograf = true
59
69
  else if (a === '--konteks') pilihan.konteks = true
60
70
  else if (a === '--pilih') pilihan.pilih = true
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]
61
75
  else if (a === '--jenis') pilihan.jenis = argv[++i]
62
76
  else if (a === '--had') pilihan.had = Number(argv[++i])
63
77
  else if (a === '--delay') pilihan.delay = Number(argv[++i])
@@ -83,10 +97,20 @@ async function main() {
83
97
  }
84
98
 
85
99
  if (perintah === 'install') {
100
+ if (pilihan.hook) {
101
+ keluar(await pasangHook())
102
+ return
103
+ }
86
104
  keluar(await pasangSkill({ link: pilihan.link }))
87
105
  return
88
106
  }
89
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
+
90
114
  const cache = await loadCache()
91
115
  const opts = {
92
116
  refresh: pilihan.refresh,
@@ -226,6 +250,8 @@ async function main() {
226
250
  const h = keRumi(teks, cache, {
227
251
  dataset: await muatDataset(),
228
252
  pilihLazim: pilihan.pilih,
253
+ padanRangka: pilihan.rangka,
254
+ glosari: pilihan.glosari ? JSON.parse(await readFile(pilihan.glosari, 'utf8')) : null,
229
255
  })
230
256
  if (pilihan.json) { keluar(h); break }
231
257
  console.log(h.teks)
@@ -255,6 +281,17 @@ async function main() {
255
281
  }
256
282
  }
257
283
  }
284
+ if (h.ejaanBukanPiawai?.length) {
285
+ const tunggal = h.ejaanBukanPiawai.filter((x) => x.calon.length === 1)
286
+ const banyak = h.ejaanBukanPiawai.filter((x) => x.calon.length > 1)
287
+ console.error(`\nejaan bukan piawai DBP (${h.ejaanBukanPiawai.length}):`)
288
+ for (const x of tunggal.slice(0, 20)) {
289
+ console.error(` ${x.jawi} -> ${x.calon[0].rumi} [DBP eja: ${x.calon[0].jawiDbp}]`)
290
+ }
291
+ for (const x of banyak.slice(0, 10)) {
292
+ console.error(` ${x.jawi} -> ${x.calon.map((c) => c.rumi).join('|')} (rangka sama, PILIH ikut konteks)`)
293
+ }
294
+ }
258
295
  if (h.arab.length) console.error(`teks Arab dibiarkan (${h.arab.length}): ${h.arab.join(' ')}`)
259
296
  for (const k of h.kabur) {
260
297
  console.error(
@@ -266,6 +303,52 @@ async function main() {
266
303
  break
267
304
  }
268
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
+ luarTalian: pilihan.luarTalian !== false,
322
+ abai,
323
+ })
324
+ gabungan.jumlahKataUnik += laporan.jumlahKataUnik
325
+ gabungan.lulusDbp += laporan.lulusDbp
326
+ gabungan.ralat.push(...laporan.ralat.map((r) => ({ ...r, fail })))
327
+ gabungan.tidakDapatDisahkan.push(...laporan.tidakDapatDisahkan.map((r) => ({ ...r, fail })))
328
+ }
329
+ gabungan.peratusTidakDapatDisahkan = gabungan.jumlahKataUnik
330
+ ? Math.round((gabungan.tidakDapatDisahkan.length / gabungan.jumlahKataUnik) * 100)
331
+ : 0
332
+
333
+ const kod = kodKeluar(gabungan, pilihan.ambang)
334
+ if (pilihan.json) keluar({ ...gabungan, kodKeluar: kod })
335
+ else {
336
+ console.error(
337
+ `gate: ${gabungan.lulusDbp}/${gabungan.jumlahKataUnik} disahkan DBP, ` +
338
+ `${gabungan.ralat.length} ralat, ` +
339
+ `${gabungan.tidakDapatDisahkan.length} tidak dapat disahkan (${gabungan.peratusTidakDapatDisahkan}%)`,
340
+ )
341
+ for (const r of gabungan.ralat.slice(0, 30)) {
342
+ console.error(
343
+ ` RALAT ${r.kata}: ${r.sebab}` + (r.sepatutnya ? ` -> guna "${r.sepatutnya}"` : ''),
344
+ )
345
+ }
346
+ if (gabungan.ralat.length > 30) console.error(` ... ${gabungan.ralat.length - 30} lagi`)
347
+ }
348
+ process.exitCode = kod
349
+ break
350
+ }
351
+
269
352
  case 'nama': {
270
353
  if (baki.length >= 2) {
271
354
  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.16.0",
3
+ "version": "0.18.1",
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,138 @@
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
+
19
+ import { lookup } from './prpm.js'
20
+ import { mungkinAkronim, mungkinNamaKhas } from './nama.js'
21
+ import { cariDataset } from './dataset.js'
22
+
23
+ const TOKEN_RUMI = /([A-Za-zÀ-ÿ][A-Za-zÀ-ÿ'-]*)/g
24
+
25
+ /**
26
+ * Semak teks Rumi. Setiap perkataan jatuh ke dalam SATU daripada tiga baldi.
27
+ */
28
+ export async function gate(teks, cache, opts = {}) {
29
+ // Dataset boleh disuntik supaya ujian tidak bergantung pada fail yang
30
+ // diimport ke mesin tertentu. Ujian yang bergantung pada keadaan mesin
31
+ // lulus di sini dan gagal di CI, atau lebih teruk, sebaliknya.
32
+ const { luarTalian = true, abai = new Set(), cariDalamDataset = cariDataset } = opts
33
+
34
+ const lulusDbp = []
35
+ const ralat = []
36
+ const tidakDapatDisahkan = []
37
+ const dilihat = new Set()
38
+
39
+ const bahagian = teks.split(TOKEN_RUMI)
40
+ for (const [i, cebis] of bahagian.entries()) {
41
+ if (i % 2 === 0) continue
42
+ const kata = cebis.toLowerCase()
43
+ if (kata.length < 2 || dilihat.has(kata)) continue
44
+ dilihat.add(kata)
45
+
46
+ if (abai.has(kata)) continue
47
+
48
+ // Akronim dan nama khas tidak boleh disahkan oleh kamus. Ia BUKAN lulus;
49
+ // ia keadaan ketiga, supaya pengguna tahu apa yang tidak disemak.
50
+ if (mungkinAkronim(cebis) || mungkinNamaKhas(cebis, bahagian[i - 1])) {
51
+ tidakDapatDisahkan.push({ kata: cebis, sebab: 'nama khas atau akronim' })
52
+ continue
53
+ }
54
+
55
+ const rec = await lookup(kata, cache, { ...opts, luarTalian })
56
+
57
+ if (rec?.ralat) {
58
+ tidakDapatDisahkan.push({ kata, sebab: `gagal semak: ${rec.ralat}` })
59
+ continue
60
+ }
61
+
62
+ if (!rec?.dijumpai) {
63
+ // Dalam mod luar talian, "tiada dalam cache" berbeza daripada "tiada
64
+ // dalam PRPM". Yang pertama tidak dapat disahkan; yang kedua ralat.
65
+ if (luarTalian && /luar talian/.test(rec?.nota ?? '')) {
66
+ tidakDapatDisahkan.push({ kata, sebab: 'tiada dalam cache' })
67
+ continue
68
+ }
69
+
70
+ // PRPM menyatakan sendiri perkataan ini tiada dalam kamus TERKINI
71
+ // tetapi ada dalam tesaurus. Itu bukan ejaan salah; ia perkataan sah
72
+ // yang bahagian kamusnya tidak diliputi. Menandakannya ralat
73
+ // menghasilkan negatif palsu pada perkataan paling biasa.
74
+ if (rec?.adaTesaurus) {
75
+ tidakDapatDisahkan.push({
76
+ kata,
77
+ sebab: 'tiada dalam kamus terkini PRPM, tetapi ada dalam tesaurus',
78
+ })
79
+ continue
80
+ }
81
+
82
+ // PRPM tiada entri untuk kata tugas paling asas: dan, di, ke, yang,
83
+ // atau. Menandakannya sebagai ralat menjadikan gate mustahil dipakai.
84
+ // Pembezanya bersumber, bukan senarai putih dari ingatan: kata tugas
85
+ // itu SEMUA wujud dalam dataset, manakala hadith, fardhu, tayammum,
86
+ // dhuha, qadha, redha dan iddah TIADA satu pun.
87
+ const ds = await cariDalamDataset(kata)
88
+ if (ds?.jawi) {
89
+ tidakDapatDisahkan.push({
90
+ kata,
91
+ sebab: 'tiada entri PRPM, tetapi wujud dalam dataset',
92
+ })
93
+ continue
94
+ }
95
+
96
+ ralat.push({ kata, sebab: 'tiada entri dalam PRPM', url: rec?.url })
97
+ continue
98
+ }
99
+
100
+ // Entri rujukan silang bermakna DBP mengalihkan bentuk ini ke bentuk lain.
101
+ // sunnah -> ®sunah. Menggunakannya adalah ejaan yang salah.
102
+ const def = (rec.entri?.[0]?.definisi ?? '').trim()
103
+ const silang = /^®\s*(.+?)\.?$/.exec(def)
104
+ if (silang) {
105
+ ralat.push({
106
+ kata,
107
+ sebab: 'rujukan silang, DBP mengalihkan ke bentuk lain',
108
+ sepatutnya: silang[1].trim(),
109
+ url: rec.url,
110
+ })
111
+ continue
112
+ }
113
+
114
+ lulusDbp.push(kata)
115
+ }
116
+
117
+ const jumlah = lulusDbp.length + ralat.length + tidakDapatDisahkan.length
118
+ const peratusTidakSah = jumlah ? Math.round((tidakDapatDisahkan.length / jumlah) * 100) : 0
119
+
120
+ return {
121
+ jumlahKataUnik: jumlah,
122
+ lulusDbp: lulusDbp.length,
123
+ ralat,
124
+ tidakDapatDisahkan,
125
+ peratusTidakDapatDisahkan: peratusTidakSah,
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Tentukan kod keluar. Ralat sentiasa menggagalkan. `tidakDapatDisahkan`
131
+ * menggagalkan apabila melebihi ambang, kerana cache sejuk bermakna gate
132
+ * tidak menyemak apa-apa.
133
+ */
134
+ export function kodKeluar(laporan, ambang = null) {
135
+ if (laporan.ralat.length) return 1
136
+ if (ambang != null && laporan.peratusTidakDapatDisahkan > ambang) return 1
137
+ return 0
138
+ }
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
 
@@ -127,10 +143,43 @@ export async function loadCache() {
127
143
  }
128
144
  }
129
145
 
146
+ /**
147
+ * Simpan cache dengan MENGGABUNGKAN keadaan di cakera terlebih dahulu.
148
+ *
149
+ * Menulis ganti secara membuta memusnahkan kerja proses lain. Dua sesi yang
150
+ * berjalan serentak akan memuat keadaan yang sama, masing-masing menambah
151
+ * perkataannya, dan yang menyimpan kemudian memadam tambahan yang awal.
152
+ * Ia diperhatikan secara langsung: entri yang baru dipanaskan hilang selepas
153
+ * sesi lain menyimpan.
154
+ *
155
+ * Rekod di cakera menang bagi kunci yang sama, kerana ia mungkin lebih baharu.
156
+ * Rekod benih (`ringkas`) tidak pernah menewaskan rekod penuh.
157
+ */
130
158
  export async function saveCache(cache) {
131
159
  await mkdir(dirname(CACHE_PATH), { recursive: true })
160
+
161
+ let diCakera = {}
162
+ try {
163
+ diCakera = JSON.parse(await readFile(CACHE_PATH, 'utf8'))
164
+ } catch {
165
+ diCakera = {}
166
+ }
167
+
168
+ const gabung = { ...cache }
169
+ for (const [kata, rec] of Object.entries(diCakera)) {
170
+ const sedia = gabung[kata]
171
+ // Rekod penuh mengatasi rekod ringkas; selain itu cakera menang.
172
+ if (sedia && !sedia.ringkas && rec.ringkas) continue
173
+ gabung[kata] = rec
174
+ }
175
+
176
+ // Rekod benih tidak ditulis ke cache pengguna: ia sudah ada dalam pakej.
177
+ for (const [kata, rec] of Object.entries(gabung)) {
178
+ if (rec?.benih) delete gabung[kata]
179
+ }
180
+
132
181
  const tmp = `${CACHE_PATH}.tmp`
133
- await writeFile(tmp, JSON.stringify(cache, null, 1), 'utf8')
182
+ await writeFile(tmp, JSON.stringify(gabung, null, 1), 'utf8')
134
183
  await rename(tmp, CACHE_PATH)
135
184
  }
136
185
 
package/src/semak.js CHANGED
@@ -102,3 +102,53 @@ export function ejaanLamaSahaja(a, b) {
102
102
  if (!a || !b || a === b) return false
103
103
  return samakanLama(a) === samakanLama(b)
104
104
  }
105
+
106
+ /**
107
+ * Rangka konsonan: buang huruf vokal pilihan dan samakan varian bentuk.
108
+ *
109
+ * Dokumen kurikulum Malaysia kerap menggunakan ejaan Jawi yang berbeza
110
+ * daripada piawai DBP. Corak yang disahkan pada pasangan sebenar DSKP:
111
+ *
112
+ * surah DBP سورة DSKP سوره ta marbutah jadi ha
113
+ * iktibar DBP اعتبار DSKP اعتبر alif digugurkan
114
+ * bersifat DBP برصيفت DSKP برصفة ya digugurkan, ta marbutah
115
+ * dalil DBP دليل DSKP داليل alif ditambah
116
+ * syirik DBP شيريک DSKP شرك ya digugurkan, kaf Arab
117
+ *
118
+ * Kesemuanya berbeza hanya pada huruf vokal dan bentuk huruf, bukan pada
119
+ * rangka konsonan. Membuang vokal menjadikan kedua-dua ejaan sepadan.
120
+ *
121
+ * INI LOSSY dan MESTI kekal di bawah lapisan 1 dan 2. Banyak perkataan
122
+ * berkongsi rangka yang sama, jadi padanan begini adalah inferens, bukan
123
+ * rekod DBP, dan hasilnya mesti berlabel.
124
+ */
125
+ const VOKAL_PILIHAN = /[اويى]/g
126
+
127
+ const SAMA_BENTUK = new Map([
128
+ ['ة', 'ه'],
129
+ ['ك', 'ک'],
130
+ ['ڬ', 'ݢ'],
131
+ ['ف', 'ڤ'],
132
+ ['ج', 'چ'],
133
+ ['أ', 'ا'],
134
+ ['إ', 'ا'],
135
+ ['ء', ''],
136
+ ])
137
+
138
+ // Pedoman §6.4: ta marbutah melambangkan [t] ATAU [h] sebagai kelainan
139
+ // sebutan pada kata serapan Arab (akibat/akibah, hidayat/hidayah). Ketiga-tiga
140
+ // huruf itu boleh bertukar di HUJUNG kata sahaja. Di tengah kata ia huruf
141
+ // berlainan dan tidak boleh dilipat.
142
+ const HUJUNG_SETARA = new Set(['ة', 'ه', 'ت'])
143
+
144
+ export function rangkaKonsonan(jawi) {
145
+ if (!jawi) return ''
146
+ let keluar = ''
147
+ for (const huruf of String(jawi).normalize('NFC')) {
148
+ const ganti = SAMA_BENTUK.has(huruf) ? SAMA_BENTUK.get(huruf) : huruf
149
+ keluar += ganti
150
+ }
151
+ keluar = keluar.replace(VOKAL_PILIHAN, '').replace(/\s+/g, '')
152
+ if (keluar && HUJUNG_SETARA.has(keluar.slice(-1))) keluar = keluar.slice(0, -1) + 'ه'
153
+ return keluar
154
+ }
package/src/tukar.js CHANGED
@@ -12,7 +12,7 @@ import { lookup } from './prpm.js'
12
12
  import { normalisasiJawi } from './kamus.js'
13
13
  import { cariDataset, muatDataset } from './dataset.js'
14
14
  import { cariNama, mungkinAkronim, mungkinNamaKhas } from './nama.js'
15
- import { semakHurufAkhir } from './semak.js'
15
+ import { rangkaKonsonan, semakHurufAkhir } from './semak.js'
16
16
  import {
17
17
  imbuhAkhiranAn,
18
18
  imbuhAkhiranKan,
@@ -162,6 +162,63 @@ 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
+ 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
+
195
+ /**
196
+ * Indeks rangka konsonan, untuk memadan ejaan bukan piawai.
197
+ *
198
+ * Dokumen kurikulum Malaysia kerap menggunakan ortografi yang berbeza
199
+ * daripada DBP: vokal digugur atau ditambah, ta marbutah bertukar ha, bentuk
200
+ * Arab menggantikan bentuk Jawi Melayu. Indeks ini memadan rangka konsonan
201
+ * supaya `سوره` masih dikenali sebagai `surah`.
202
+ *
203
+ * LOSSY: 29% rangka dikongsi lebih daripada satu perkataan, purata 4.1 calon.
204
+ * Kerana itu ia hanya digunakan selepas padanan tepat gagal, hasilnya sentiasa
205
+ * berlabel, dan ia TIDAK PERNAH memilih sendiri apabila calon berbilang.
206
+ */
207
+ export function petaRangka(cache, dataset = null) {
208
+ const peta = new Map()
209
+ const tambah = (jawi, rumi) => {
210
+ const kunci = rangkaKonsonan(jawi)
211
+ if (!kunci) return
212
+ if (!peta.has(kunci)) peta.set(kunci, new Map())
213
+ if (!peta.get(kunci).has(rumi)) peta.get(kunci).set(rumi, jawi)
214
+ }
215
+ for (const rec of Object.values(cache)) if (rec?.jawi) tambah(rec.jawi, rec.kata)
216
+ for (const [rumi, ejaan] of Object.entries(dataset?.peta ?? {})) {
217
+ for (const j of ejaan) tambah(j, rumi)
218
+ }
219
+ return peta
220
+ }
221
+
165
222
  export function petaSongsang(cache, dataset = null) {
166
223
  const peta = new Map()
167
224
  let maxKata = 1
@@ -428,19 +485,42 @@ export async function keJawi(teks, cache, opts = {}) {
428
485
  export function keRumi(
429
486
  teksJawi,
430
487
  cache,
431
- { penanda = ['«', '»'], dataset = null, pilihLazim = false } = {},
488
+ {
489
+ penanda = ['«', '»'],
490
+ dataset = null,
491
+ pilihLazim = false,
492
+ padanRangka = false,
493
+ glosari = null,
494
+ } = {},
432
495
  ) {
433
496
  const peta = petaSongsang(cache, dataset)
434
497
  const tidakDikenali = []
435
498
  const kabur = []
436
499
  const arab = []
437
500
  const sendiDipisah = []
501
+ const bukanPiawai = []
502
+ const dariGlosari = []
503
+ const rangka = padanRangka ? petaRangka(cache, dataset) : null
438
504
  const bahagian = teksJawi.split(PECAH_JAWI)
439
505
  const keluar = []
440
506
 
441
507
  for (let i = 0; i < bahagian.length; i++) {
442
508
  if (i % 2 === 0) { keluar.push(bahagian[i]); continue }
443
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
+
444
524
  // Teks Arab bertanda baris dibiarkan seadanya. Cuba menukarnya akan
445
525
  // merosakkan petikan al-Quran dan hadis dalam dokumen Melayu.
446
526
  if (HARAKAT.test(bahagian[i])) {
@@ -491,6 +571,43 @@ export function keRumi(
491
571
  }
492
572
  if (dipisah) { keluar.push(dipisah); sendiDipisah.push(bahagian[i]); continue }
493
573
 
574
+ // Lapisan terakhir: ejaan bukan piawai. Berlabel, di bawah lapisan
575
+ // 1 dan 2, dan tidak pernah memilih sendiri apabila calon berbilang.
576
+ if (rangka) {
577
+ const padananRangka = rangka.get(rangkaKonsonan(bahagian[i]))
578
+ if (padananRangka?.size) {
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
+
595
+ if (senarai.length === 1) {
596
+ keluar.push(senarai[0].rumi)
597
+ continue
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
+ }
606
+ keluar.push(penanda[0] + senarai.map((x) => x.rumi).join('|') + penanda[1])
607
+ continue
608
+ }
609
+ }
610
+
494
611
  tidakDikenali.push(bahagian[i])
495
612
  keluar.push(penanda[0] + bahagian[i] + penanda[1])
496
613
  continue
@@ -521,6 +638,8 @@ export function keRumi(
521
638
  kabur,
522
639
  arab: [...new Set(arab)],
523
640
  sendiDipisah,
641
+ ejaanBukanPiawai: bukanPiawai,
642
+ dariGlosari,
524
643
  saizPeta: peta.size,
525
644
  lengkap: tidakDikenali.length === 0,
526
645
  }