reedach-server 0.3.2 → 0.4.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/README.md CHANGED
@@ -92,7 +92,9 @@ docker run -d -p 3000:3000 -e REEDACH_KEYS="длинный-ключ-сайта=h
92
92
  нажмёт на слово.
93
93
 
94
94
  У подсказок по стилю может быть поле `replace` — варианты замены, уже поставленные в форму
95
- найденного слова: «релевантных» → `["подходящих", "нужных", "уместных"]`.
95
+ найденного слова: «релевантных» → `["подходящих", "нужных", "уместных"]`, «оказываем
96
+ содействие» → `["помогаем"]`, «скриншоты» → `["снимки экрана"]`. Поле `edit` — готовая
97
+ правка шире фрагмента или «Убрать»; как её применить, написано в README пакета `reedach`.
96
98
 
97
99
  **`POST /v1/suggest`** — `{"words": ["превет"]}` → `{"suggestions": {"превет": ["привет", …]}}`,
98
100
  до 20 слов за раз.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reedach-server",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Сервер Reedach: орфография (hunspell), морфология (Az.js + OpenCorpora) и подсказки по стилю для русского текста — библиотекой или по HTTP",
5
5
  "license": "MIT",
6
6
  "author": "Dima Faller <dima@dimafaller.ru>",
@@ -41,10 +41,10 @@
41
41
  "az": "0.2.3",
42
42
  "dictionary-en": "4.0.0",
43
43
  "dictionary-ru": "3.0.0",
44
- "reedach": "0.3.2"
44
+ "reedach": "0.4.0"
45
45
  },
46
46
  "peerDependencies": {
47
- "reedach-widget": "^0.3.2"
47
+ "reedach-widget": "^0.4.0"
48
48
  },
49
49
  "peerDependenciesMeta": {
50
50
  "reedach-widget": {
package/src/check.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import {checkStyle,sourceBlocks,textBlocks,mergeIssues,RULES,BUILTIN,CATEGORIES} from 'reedach';
2
- import {morphCheck,MORPH_RULES,inflectReplacements} from './morph.mjs';
2
+ import {morphCheck,MORPH_RULES,inflectReplacements,formReplacements} from './morph.mjs';
3
3
  import {words,known,misspelled,Suggester} from './spell.mjs';
4
4
  // One check = spelling (hunspell) + morphology (Az.js) + dictionary style hints (reedach core), merged into one list.
5
5
  export const CHECKS=['spelling','morph','style'];
@@ -28,6 +28,7 @@ export async function check(input,{suggester:sg=suggester(),misspelled:ms=misspe
28
28
  checks.has('morph')?morphCheck(blocks).catch(()=>[]):[],
29
29
  ]);
30
30
  const local=checks.has('style')?await inflectReplacements(checkStyle(blocks),blocks).catch(()=>checkStyle(blocks)):[];
31
+ await formReplacements([...local,...morph],blocks).catch(()=>{});
31
32
  const issues=mergeIssues(blocks,{spelling,style:morph,local}).filter(it=>!disable.has(it.id)&&!disable.has(it.category));
32
33
  return {issues:blocksInput?issues.sort((a,b)=>a.block-b.block||a.start-b.start):issues.map(({block,...it})=>it)};
33
34
  }
package/src/forms.mjs ADDED
@@ -0,0 +1,146 @@
1
+ // The first variant of a hint in the found word's form (moved from the Yokey keyboard, forms.js): the core gives
2
+ // these hints as an advice — variants in the dictionary form («оказываем содействие» → «Сказать проще: помогать») or
3
+ // variants that are phrases («скриншоты» → «снимок экрана»). Here the variant takes the found word's form on the same
4
+ // morphology as inflectReplacements: «помогаем», «снимки экрана». It goes to `replace` of a hint that has none.
5
+ const L='A-Za-zА-Яа-яЁё';
6
+ const norm=s=>s.toLowerCase().replace(/ё/g,'е');
7
+ const FAMILY={INFN:'verb',VERB:'verb',ADJF:'adj',NOUN:'noun'};
8
+ // [rule, key, variants, flags]. The key as in the rules: words, * for any ending. Of the variants the first of the found
9
+ // word's part of speech (and aspect, for a verb) is taken; $2 is the second word found, as written. Flags: gent - a
10
+ // word in the genitive after the fragment depends on its noun and would need another case after the verb («производим
11
+ // оплату заказа» is not «оплачиваем заказа»): no variant then; w2 - the form is the second word's; fixed - the variant
12
+ // as it is, for the found form written in the key (words the dictionary does not know). A noun in the singular is
13
+ // replaced only by one of its gender: the predicate and the adjectives around agree with it («коллаба удалась» is not
14
+ // «совместный проект удалась»), so «коллаба», «коллаборация» stay an advice.
15
+ // The phrases of bureaucratese-turns come as weak-verb-noun too (the server's hint replaces the dictionary's there).
16
+ const FORMS=[
17
+ ['bureaucratese-turns','надлежащ*',['правильный']],
18
+ ['bureaucratese-turns','приобре*',['купить','покупать','покупка']],
19
+ ['bureaucratese-turns','реализ* продукци*',['продать','продавать'],'gent'],
20
+ ['bureaucratese-turns','реализац* товар*',['продажа $2']],
21
+ ['bureaucratese-turns','производ* оплат*',['оплатить','оплачивать'],'gent'],
22
+ ['bureaucratese-turns','производ* замер*',['замерить','замерять'],'gent'],
23
+ ['bureaucratese-turns','производ* расчет*',['рассчитать','рассчитывать'],'gent'],
24
+ ['bureaucratese-turns','оказыва* содействие',['помогать']],
25
+ ['bureaucratese-turns','оказыва* влияние',['влиять']],
26
+ ['bureaucratese-turns','предоставля* возможность',['позволять'],'gent'],
27
+ ['bureaucratese-turns','предоставля* скидк*',['давать $2']],
28
+ ['bureaucratese-turns','довод* до сведения',['сообщать'],'gent'],
29
+ ['bureaucratese-turns','информиру* вас',['сообщать']],
30
+ ['bureaucratese-turns','ввод* в эксплуатацию',['запуск','запустить','запускать']],
31
+ ['stamp-newspaper-idioms','финальн* аккорд*',['финал'],'w2'],
32
+ ['stamp-newspaper-idioms','тернист* пут*',['трудный путь'],'w2'],
33
+ ['bureaucratese-latinisms','синерги*',['совместная польза']],
34
+ ['borrowing-business','контент-план*',['план публикаций']],
35
+ ['borrowing-business','тимлид*',['руководитель группы']],
36
+ ['borrowing-business','роадмап*',['дорожная карта']],
37
+ ['borrowing-business','хард скилл*',['профессиональные навыки'],'w2'],
38
+ ['borrowing-business','софт скилл*',['гибкие навыки'],'w2'],
39
+ ['borrowing-business','багфикс*',['исправление ошибки']],
40
+ ['borrowing-business','скриншот*',['снимок экрана']],
41
+ ['borrowing-business','скрин',['снимок экрана']],
42
+ ['borrowing-business','дашборд*',['панель показателей']],
43
+ ['borrowing-business','вишлист*',['список желаний']],
44
+ ['borrowing-business','онбординг*',['вхождение в должность']],
45
+ ['borrowing-business','бэклог*',['список задач']],
46
+ ['borrowing-business','скоуп*',['объем работ']],
47
+ ['borrowing-business','стейкхолдер*',['заинтересованная сторона']],
48
+ ['borrowing-business','чекпоинт*',['контрольная точка']],
49
+ ['borrowing-business','хедхантинг*',['переманивание специалистов']],
50
+ ['borrowing-business','хедхантер*',['охотник за кадрами']],
51
+ ['borrowing-business','брейншторм*',['мозговой штурм']],
52
+ ['borrowing-business','брейнсторм*',['мозговой штурм']],
53
+ ['borrowing-business','лидген*',['привлечение клиентов']],
54
+ ['borrowing-business','рефералк*',['приглашение друга']],
55
+ ['borrowing-business','реферал*',['приглашенный пользователь']],
56
+ ['borrowing-business','экшен-план*',['план действий']],
57
+ ['borrowing-business','вейтлист*',['лист ожидания']],
58
+ ['slang-register','мастхэв*',['обязательная вещь']],
59
+ ['slang-register','коллаб',['совместный проект'],'fixed'],
60
+ ['slang-register','коллабом',['совместным проектом'],'fixed'],
61
+ ].map(([rule,key,variants,flag])=>({rule,key,variants,flag,re:new RegExp('^'+key.split(' ').map(t=>t.endsWith('*')?t.slice(0,-1)+'['+L+'-]*':t).join('\\s+')+'$')}));
62
+
63
+ const best=(Az,word)=>{try{return word.length<=40?Az.Morph(word).filter(p=>p.parser==='Dictionary'||p.parser==='PrefixKnown'):[];}catch{return [];}};
64
+ const want=(tag,keys)=>keys.map(k=>tag[k]).filter(Boolean);
65
+
66
+ // The variant [v] in the form of [p] (a parse of the found word): its first word of p's family is inflected, the
67
+ // adjectives before it (a noun's) agree with it, the rest stays. null: it does not fit or does not inflect.
68
+ function inflected(Az,p,v){
69
+ const fam=FAMILY[p.tag.POS],words=v.split(' ');
70
+ for(let h=0;h<words.length;h++){
71
+ // «покупать» is a perfective too («покупать детей»): the parse of the found word's aspect first
72
+ const ts=best(Az,words[h]).filter(x=>FAMILY[x.tag.POS]===fam),t=ts.find(x=>x.tag.ASpc===p.tag.ASpc)||ts[0];if(!t)continue;
73
+ if(fam==='verb'&&p.tag.ASpc&&t.tag.ASpc&&p.tag.ASpc!==t.tag.ASpc)return null;
74
+ let head,num;
75
+ if(p.tag.POS==='INFN')head=t.normalize()?.word;
76
+ else if(fam==='verb')head=t.inflect([...want(p.tag,['NMbr','TEns','PErs','GNdr','MOod','INvl']),'VERB'])?.word;
77
+ else if(fam==='adj')head=t.inflect(want(p.tag,['NMbr','CAse','GNdr','ANim']))?.word;
78
+ // num: the number the head got (the found word's, or its own when it has no such form)
79
+ else{const r=t.inflect(want(p.tag,['NMbr','CAse']));head=(r||t.inflect(want(p.tag,['CAse'])))?.word;num=r?p.tag.NMbr:t.tag.NMbr;}
80
+ if(fam==='noun'&&p.tag.NMbr!=='plur'&&p.tag.GNdr&&t.tag.GNdr&&p.tag.GNdr!==t.tag.GNdr)return null;
81
+ // the accusative of another animacy is another form («увидели стейкхолдеров» is not «заинтересованных сторон»)
82
+ if(fam==='noun'&&(p.tag.CAse==='accs'||p.tag.CAse==='gent')&&p.tag.ANim&&t.tag.ANim&&p.tag.ANim!==t.tag.ANim)return null;
83
+ if(!head)return null;
84
+ const out=words.slice();out[h]=head;
85
+ if(fam==='noun'){
86
+ for(let a=0;a<h;a++){
87
+ const at=best(Az,words[a]).find(x=>x.tag.POS==='ADJF');if(!at)return null;
88
+ const g=[num,p.tag.CAse];if(num!=='plur'&&t.tag.GNdr)g.push(t.tag.GNdr);if(p.tag.CAse==='accs'&&t.tag.ANim)g.push(t.tag.ANim);
89
+ const f=at.inflect(g.filter(Boolean))?.word;if(!f)return null;out[a]=f;
90
+ }
91
+ }
92
+ return out.join(' ');
93
+ }
94
+ return null;
95
+ }
96
+
97
+ const GENITIVE=new Set(['NOUN','ADJF','PRTF','NPRO','NUMR']);
98
+ const PARTICLES=new Set(['только','именно','лишь','даже','еще','уже','всего','ровно','почти','как','хотя','бы']);
99
+ // the first word after the fragment but particles and adverbs («только одного выбора», «именно этого заказа»)
100
+ function genitiveAfter(Az,text,end){
101
+ const re=new RegExp('^\\s+(['+L+']+)');
102
+ for(let rest=text.slice(end),m;(m=re.exec(rest));rest=rest.slice(m[0].length)){
103
+ const w=m[1].toLowerCase(),ps=best(Az,w);
104
+ if(PARTICLES.has(w)||ps.length&&ps.every(p=>p.tag.POS==='PRCL'||p.tag.POS==='ADVB'||p.tag.POS==='CONJ'))continue;
105
+ return ps.some(p=>GENITIVE.has(p.tag.POS)&&(p.tag.CAse==='gent'||p.tag.CAse==='gen2'));
106
+ }
107
+ return false;
108
+ }
109
+
110
+ // The gender of a noun the dictionary does not know, by its ending (borrowings: «дашборд», «рефералка»).
111
+ const guessed=w=>/[ая]$/.test(w)?'femn':/[оеи]$/.test(w)?'neut':'masc';
112
+ const headGender=(Az,v)=>{for(const w of v.split(' ')){const t=best(Az,w).find(x=>x.tag.POS==='NOUN');if(t)return t.tag.GNdr;}return null;};
113
+
114
+ // issues (block offsets) and their blocks; Az: Az.js initialized (morph.mjs) or the keyboard's AOT shim.
115
+ // as the core's ready edits: the fragment maps onto the source one to one, no Markdown or gap (a line break, code) inside
116
+ const plain=(b,s,e)=>(!b.map||b.map[e-1]-b.map[s]===e-1-s)&&!(b.gaps||[]).some(j=>j>=s&&j<e)&&!b.text.slice(s,e).includes('\n');
117
+ // w2: the parses of the noun that agree with the adjective before it («тернистые пути» is the plural, not «пути» of
118
+ // the singular), all of them when none does
119
+ function agreeing(Az,parses,adjective){
120
+ const adj=best(Az,adjective).filter(x=>x.tag.POS==='ADJF'),fit=parses.filter(p=>adj.some(a=>a.tag.CAse===p.tag.CAse&&a.tag.NMbr===p.tag.NMbr));
121
+ return fit.length?fit:parses;
122
+ }
123
+ export const formWanted=it=>!it.replace&&!it.edit&&(it.id==='weak-verb-noun'||FORMS.some(f=>f.rule===it.id));
124
+ export function formReplacements(issues,blocks,Az){
125
+ for(const it of issues.filter(formWanted)){
126
+ const b=blocks[it.block],text=b?.text;if(typeof text!=='string'||!plain(b,it.start,it.end))continue;
127
+ const found=text.slice(it.start,it.end),words=found.split(/\s+/),low=norm(found).split(/\s+/).join(' ');
128
+ const f=FORMS.find(x=>(x.rule===it.id||it.id==='weak-verb-noun'&&x.rule==='bureaucratese-turns')&&x.re.test(low));if(!f)continue;
129
+ if(f.flag==='gent'&&genitiveAfter(Az,text,it.end))continue;
130
+ const variants=f.variants.map(v=>v.replace(/\$(\d)/g,(_,n)=>words[n-1]||''));
131
+ if(f.flag==='fixed'){it.replace=[variants[0]];continue;}
132
+ const carrier=norm(words[f.flag==='w2'?1:0]||'');
133
+ let form=null;
134
+ const parses=f.flag==='w2'?agreeing(Az,best(Az,carrier),norm(words[0])):best(Az,carrier);
135
+ for(const p of parses){
136
+ if(!FAMILY[p.tag.POS])continue;
137
+ for(const v of variants)if((form=inflected(Az,p,v)))break;
138
+ if(form)break;
139
+ }
140
+ // a word the dictionary does not know, written as in the key: the variant as it is (its dictionary form)
141
+ if(!form&&!best(Az,carrier).length&&!/\s/.test(f.key)&&carrier===f.key.replace('*','')&&headGender(Az,variants[0])===guessed(carrier))
142
+ form=variants[0];
143
+ if(form)it.replace=[form];
144
+ }
145
+ return issues;
146
+ }
package/src/morph.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import {createRequire} from 'node:module';
2
2
  import path from 'node:path';
3
+ import {formReplacements as forms,formWanted} from './forms.mjs';
3
4
  // Style hints that need parts of speech: Az.js with the OpenCorpora dictionaries, loaded once on first use.
4
5
  const require=createRequire(import.meta.url),Az=require('az'),DICTS=path.join(path.dirname(require.resolve('az/package.json')),'dicts');
5
6
  let ready=null;
@@ -23,11 +24,53 @@ const RULES={
23
24
  'adjective-pile':{category:'adjective',label:'Прилагательное',message:'Несколько прилагательных подряд перегружают фразу.',fix:'Оставьте одно, самое точное, или замените фактом.'},
24
25
  'start-repeat':{category:'repetition',label:'Повтор',message:'Третье предложение подряд начинается с того же слова: текст звучит монотонно.',fix:'Начните одно из предложений иначе или объедините их.'},
25
26
  'lemma-repeat':{category:'repetition',label:'Повтор',message:'Одно и то же слово дважды в предложении.',fix:'Замените повтор местоимением или перестройте фразу.'},
27
+ 'ne-adjective':{category:'mistake',label:'Частая ошибка',message:'Не с прилагательным пишется слитно, если нет противопоставления.',fix:'Пишите слитно: несложные сайты. Раздельно — с противопоставлением: не сложные, а простые.'},
28
+ 'instrumental-case':{category:'mistake',label:'Частая ошибка',message:'После этого глагола нужен творительный падеж: заниматься чем?',fix:'Поставьте слово в творительный падеж: занимался настройкой сетей.'},
29
+ 'typo-real-word':{category:'mistake',label:'Частая ошибка',message:'Похоже на опечатку: слово есть в словаре, но здесь не к месту.',fix:'Проверьте слово: возможно, имелось в виду мой.'},
26
30
  };
27
31
  const WEAK=new Set(['осуществлять','осуществить','производить','произвести','проводить','провести','совершать','совершить','оказывать','оказать','выполнять','выполнить','обеспечивать','обеспечить','предоставлять','предоставить','принимать','принять','давать','дать']);
28
32
  const CHAIN_HEAD=new Set(['хотеть','мочь','начать','начинать','стать','пытаться','попытаться','собираться','решить','планировать']);
29
33
  const SKIP_REPEAT=new Set(['быть','мочь','это','этот','который','свой','весь','такой','тот','один','год','раз','так','как','что','когда','если','также','тоже','только','очень','уже','еще','ещё','друг','другой','какой','какой-то','каждый','самый','себя','чуть']);
30
34
  const NOMINAL=/(?:ние|тие|ство|ция|ка)$/;
35
+ // Verbs that take the instrumental: «занимался настройкой», not «занимался настройка».
36
+ const INSTRUMENTAL=new Set(['заниматься','увлекаться','интересоваться','пользоваться','руководить','управлять','владеть','гордиться','торговать','обладать','командовать','восхищаться','любоваться','дорожить']);
37
+ // Before these «не» negates the whole phrase or the predicate: «вовсе не сложные», «я не большой любитель».
38
+ const NE_KEEP=new Set(['еще','ещё','уже','пока','вовсе','отнюдь','далеко','совсем','ничуть','нисколько','ни','это','был','была','было','были','будет','будут','быть','является','являются','стал','стала','стали','оказался','оказалась','оказались']);
39
+ // Every parse of a word, for agreement checks; the best one alone often hides the right case.
40
+ const allCache=new Map();
41
+ function all(word){const key=word.toLowerCase();let hit=allCache.get(key);if(!hit){try{hit=key.length<=MAX_WORD?Az.Morph(key):[];}catch{hit=[];}if(allCache.size>50000)allCache.clear();allCache.set(key,hit);}return hit;}
42
+ // «не сложные сайты»: «не», a qualitative adjective and a noun it agrees with, no contrast around («а не сложные»).
43
+ function neAdjective(words,k,gap){
44
+ const [ne,adj,noun]=words.slice(k,k+3);
45
+ if(!noun||ne.w.toLowerCase()!=='не'||!/^\s+$/.test(gap(ne,adj))||!/^\s+$/.test(gap(adj,noun)))return false;
46
+ const prev=words[k-1];if(prev&&(NE_KEEP.has(prev.w.toLowerCase())||/^(?:а|но)$/i.test(prev.w)||prev.pos==='NPRO'))return false;
47
+ if(words.slice(k+3).some(w=>/^(?:а|но|зато|не|ни)$/i.test(w.w))||words.slice(Math.max(0,k-4),k).some(w=>/^(?:не|ни)$/i.test(w.w)))return false;
48
+ // Dictionary words only: a guess for «исключаетвозможного» or a typo is not an adjective.
49
+ const as=all(adj.w).filter(p=>p.parser==='Dictionary'&&p.tag.POS==='ADJF'&&p.tag.Qual&&!p.tag.Apro&&!p.tag.Anum&&!p.tag.Supr),ns=noun.w.length>1&&noun.pos==='NOUN'?all(noun.w).filter(p=>p.parser==='Dictionary'&&p.tag.POS==='NOUN'):[];
50
+ const cases=as.flatMap(a=>ns.filter(n=>a.tag.CAse===n.tag.CAse&&a.tag.NMbr===n.tag.NMbr&&(a.tag.NMbr==='plur'||a.tag.GNdr===n.tag.GNdr||n.tag['Ms-f'])).map(n=>n.tag.CAse));
51
+ if(!cases.length)return false;
52
+ // In the nominative or accusative with no verb before it in the clause the phrase may be a predicate: «заявления — не пустые слова».
53
+ let c=k;while(c>0&&!/[,;:—–-]/.test(gap(words[c-1],words[c])))c--;
54
+ return cases.some(x=>x!=='nomn'&&x!=='accs')||words.slice(c,k).some(w=>['VERB','INFN','GRND'].includes(w.pos));
55
+ }
56
+ // «занимался настройка»: a noun in the nominative right after a verb that takes the instrumental, when the noun
57
+ // cannot be its subject («в зале занимаются дети» is right). Gives the noun in the instrumental, or null.
58
+ function instrumental(words,k,gap){
59
+ const verb=words[k],noun=words[k+1];
60
+ if(!noun||!(verb.pos==='VERB'&&INSTRUMENTAL.has(verb.lemma))||!/^\s+$/.test(gap(verb,noun)))return null;
61
+ if(words.slice(0,k).some(w=>w.cas==='ablt'))return null;
62
+ const ps=all(noun.w),n=ps.find(p=>p.tag.POS==='NOUN'&&p.tag.CAse==='nomn');
63
+ if(!n||ps.some(p=>p.tag.POS!=='NOUN'||p.parser!=='Dictionary')||ps.some(p=>p.tag.CAse==='ablt'||p.tag['Ms-f']))return null;
64
+ const v=all(verb.w).find(p=>p.tag.POS==='VERB')?.tag;if(!v)return null;
65
+ // A personal pronoun before the verb in the same clause is its subject already: «она занималась музыка».
66
+ let c=k;while(c>0&&!/[,;:—–-]/.test(gap(words[c-1],words[c])))c--;
67
+ const before=words.slice(c,k).some(w=>w.pos==='NPRO'&&w.cas==='nomn'&&/^(?:я|ты|он|она|оно|мы|вы|они)$/i.test(w.w));
68
+ const subject=!before&&v.MOod!=='impr'&&!['1per','2per'].includes(v.PErs)&&(v.NMbr==='plur'?n.tag.NMbr==='plur':n.tag.NMbr==='sing'&&(!v.GNdr||v.GNdr===n.tag.GNdr));
69
+ if(subject)return null;
70
+ try{return n.inflect(['ablt',n.tag.NMbr])?.word||null;}catch{return null;}
71
+ }
72
+ // «изменило пой подход»: the imperative of «петь» cannot follow a preposition or a verb (but «давай пой») and stand before a noun.
73
+ const poiTypo=(words,k,gap)=>{const [prev,w,next]=[words[k-1],words[k],words[k+1]];return !!(prev&&next&&w.w.toLowerCase()==='пой'&&/^\s+$/.test(gap(prev,w))&&/^\s+$/.test(gap(w,next))&&next.pos==='NOUN'&&(prev.pos==='PREP'||prev.pos==='INFN'||prev.pos==='VERB'&&!all(prev.w).some(p=>p.tag.MOod==='impr')));};
31
74
  const sentences=text=>[...text.matchAll(/[^.!?…]+(?:[.!?…]+|$)/g)].map(m=>({start:m.index,text:m[0]})).filter(s=>s.text.trim());
32
75
  // blocks: [{text, kind}] → issues [{block,start,end,id,category,label,message,fix}]
33
76
  export async function morphCheck(blocks){
@@ -44,6 +87,9 @@ export async function morphCheck(blocks){
44
87
  if(words[0].pos==='GRND'){const comma=s.text.indexOf(',',words[0].end-s.start);add(i,words[0].start,comma>0?s.start+comma:words[0].end,'gerund-start');}
45
88
  const prtf=words.filter(w=>w.pos==='PRTF');if(prtf.length>=2)add(i,prtf[1].start,prtf[1].end,'participle-stack');
46
89
  words.forEach((w,k)=>{
90
+ if(neAdjective(words,k,gap))issues.push({block:i,start:w.start,end:words[k+1].end,id:'ne-adjective',...RULES['ne-adjective'],replace:['не'+words[k+1].w.toLowerCase()]});
91
+ const ablt=instrumental(words,k,gap);if(ablt)issues.push({block:i,start:words[k+1].start,end:words[k+1].end,id:'instrumental-case',...RULES['instrumental-case'],replace:[ablt]});
92
+ if(poiTypo(words,k,gap))issues.push({block:i,start:w.start,end:w.end,id:'typo-real-word',...RULES['typo-real-word'],replace:['мой']});
47
93
  if(w.pos==='PRTS'&&w.score>=0.5&&!/^(?:был|была|было|были|будет|будут)$/i.test(words[k-1]?.w||''))add(i,w.start,w.end,'short-passive');
48
94
  if(w.pos==='VERB'&&WEAK.has(w.lemma)){const n=words.slice(k+1,k+3).find(x=>x.pos==='NOUN');if(n&&NOMINAL.test(n.lemma)&&n.lemma!=='участие'||n&&w.lemma.startsWith('при')&&n.lemma==='участие')add(i,w.start,n.end,'weak-verb-noun');}
49
95
  });
@@ -88,3 +134,10 @@ export async function inflectReplacements(issues,blocks){
88
134
  for(const it of todo){const found=(blocks[it.block]?.text.slice(it.start,it.end)||'').split(/[\s-]+/).pop();if(/^[А-Яа-яЁё]+$/.test(found))it.replace=it.variants.map(w=>inflect(found,w));}
89
135
  return issues;
90
136
  }
137
+ // Hints from checkStyle and morphCheck on the same blocks that have no replacement: the variant of the hint in the
138
+ // found word's form (forms.mjs) becomes the replacement.
139
+ export async function formReplacements(issues,blocks){
140
+ if(!issues.some(formWanted))return issues;
141
+ await init();
142
+ return forms(issues,blocks,Az);
143
+ }