366 lines
30 KiB
JavaScript
366 lines
30 KiB
JavaScript
// Команды снаружи. Панель, бот или сама рука владельца кладут файл в `.night/commands/`,
|
||
// а читает его НАДЗИРАТЕЛЬ — работник этого модуля не видит вовсе: он другой процесс
|
||
// в другом каталоге и разговаривает только файлами своего почтового ящика.
|
||
//
|
||
// 🔴 Файл заведён ЧАСТЬЮ. Здесь только приём команд; `closeItem`, `afterItemDone`,
|
||
// `afterTaskDone` и `deferredTaskGate` дописывает в этот же файл кусок 4 — заводить
|
||
// его заново ему не надо.
|
||
import { existsSync, readdirSync, unlinkSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { readJson, writeJsonAtomic } from './state.mjs';
|
||
|
||
// Закрытый список, и порядок в нём значимый: «стоп всё» главнее «стопа», «стоп» главнее
|
||
// «паузы». Слово не из списка не читается вовсе — выдумывать, что оно значит, нельзя.
|
||
// 🔴 Список вывезен наружу нарочно: надзиратель называет его владельцу в сводке, когда
|
||
// команду не понял. Второй такой же список рядом разошёлся бы с этим в первый же месяц.
|
||
export const COMMAND_WORDS = ['stopall', 'stop', 'pause', 'resume'];
|
||
const CMDS = COMMAND_WORDS;
|
||
|
||
export function readCommand(commandsDirPath, workerId) {
|
||
// 🪤 Каталог команд заводит рука владельца, и в первую ночь его может не быть.
|
||
// Брошенная тут ошибка убила бы надзирателя молча — тот самый класс беды, из-за
|
||
// которого 334 прогона умирали три месяца незамеченными.
|
||
if (!commandsDirPath || !existsSync(commandsDirPath)) return null;
|
||
let files;
|
||
try {
|
||
files = readdirSync(commandsDirPath);
|
||
} catch {
|
||
return null; // каталог недоступен — это отсутствие команды, а не повод падать
|
||
}
|
||
for (const cmd of CMDS) {
|
||
const all = `all.${cmd}.json`;
|
||
const mine = `${workerId}.${cmd}.json`;
|
||
// Общая идёт первой: она касается и того, кому лично ничего не давали.
|
||
for (const name of [all, mine]) {
|
||
if (!files.includes(name)) continue;
|
||
// Порванный файл — отсутствие сведений, а не мусор: слово команды стоит
|
||
// в самом имени файла, и его достаточно.
|
||
const body = readJson(join(commandsDirPath, name)) ?? {};
|
||
// 🪤 Общую команду, которую ЭТОТ работник уже исполнил, второй раз не отдаём:
|
||
// иначе он гасился бы по ней снова и снова, пока файл ждёт остальных.
|
||
if (name === all && (body.done ?? []).includes(workerId)) continue;
|
||
// 🔴🔴 `cmd` стоит ПОСЛЕ тела, а не до него, и это не вкус. Слово команды берётся
|
||
// из ИМЕНИ файла — оно и есть закрытый список. Поставь мы `cmd` перед `...body`,
|
||
// и тело файла перебило бы его: положи кто-нибудь в `w-1.pause.json` тело
|
||
// {"cmd":"stopall"} — работник был бы погашен, хотя имя файла говорит «пауза».
|
||
// Тем же способом обходился бы и весь закрытый список `CMDS`. Тело команды несёт
|
||
// сведения (кто, когда, почему), а не слово; слово — только из имени.
|
||
return { ...body, cmd, file: name, common: name === all };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 🔴🔴 Владелец узнаёт, что его команду НЕ ПОНЯЛИ (решение диспетчера). Слово не из
|
||
// закрытого списка исполнять нельзя — выдумывать его смысл мы не станем. Но и молчать
|
||
// нельзя: владелец положил файл, ушёл спать и думает, что «пауза» нажата, а её никто
|
||
// не понял. Молчание в ответ на команду хозяина недопустимо.
|
||
// 🪤 Эта работа НИЧЕГО не удаляет и ничего не исполняет — только называет. Файл владельца
|
||
// остаётся лежать: он и есть доказательство, и утром хозяин увидит его на месте, а не
|
||
// будет гадать, куда тот делся.
|
||
export function unknownCommands(commandsDirPath, workerId) {
|
||
if (!commandsDirPath || !existsSync(commandsDirPath)) return [];
|
||
let files;
|
||
try {
|
||
files = readdirSync(commandsDirPath);
|
||
} catch {
|
||
return []; // каталог недоступен — сказать нечего, и падать не о чем
|
||
}
|
||
const out = [];
|
||
for (const name of files) {
|
||
// Команда — это ровно `<кому>.<слово>.json`. Всё прочее в каталоге не наше дело:
|
||
// объявлять чужой файл «непонятой командой» — значит пугать владельца на пустом месте.
|
||
const razbor = /^(.+)\.([^.]+)\.json$/.exec(name);
|
||
if (!razbor) continue;
|
||
const [, komu, slovo] = razbor;
|
||
// Чужая личная команда — не наша забота: о ней скажет надзиратель того работника,
|
||
// иначе одну и ту же строку написали бы семеро разом.
|
||
if (komu !== 'all' && komu !== workerId) continue;
|
||
if (CMDS.includes(slovo)) continue; // понятную здесь не называем
|
||
out.push({ file: name, word: slovo, common: komu === 'all' });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// 🪤 Прежде эта работа стирала И общий, И личный файл разом. Из-за этого «стоп всё»
|
||
// съедал первый же прочитавший, а остальные шестеро продолжали работать при отданной
|
||
// команде — и владелец видел «остановлено», когда ночь шла дальше. Теперь личная команда
|
||
// убирается сразу, а общая — только когда её исполнили ВСЕ, кого она касается; до тех пор
|
||
// в её файле копится список исполнивших.
|
||
export function consumeCommand(commandsDirPath, workerId, cmd, { concerns = [] } = {}) {
|
||
const result = { personal_removed: false, common_removed: false, done: [] };
|
||
if (!commandsDirPath || !existsSync(commandsDirPath)) return result;
|
||
|
||
const mine = join(commandsDirPath, `${workerId}.${cmd}.json`);
|
||
if (existsSync(mine)) {
|
||
unlinkSync(mine);
|
||
result.personal_removed = true;
|
||
}
|
||
|
||
const all = join(commandsDirPath, `all.${cmd}.json`);
|
||
if (!existsSync(all)) return result;
|
||
|
||
// 🔴 Список исполнивших живёт В ФАЙЛЕ, а не в памяти надзирателя: надзиратели — разные
|
||
// процессы, и каждый видел бы свой собственный список. Перечитываем прямо перед записью,
|
||
// чтобы не затереть отметку соседа, поставленную секунду назад.
|
||
const body = readJson(all) ?? {};
|
||
const done = Array.from(new Set([...(body.done ?? []), workerId]));
|
||
result.done = done;
|
||
|
||
// Кого команда касается, знает зовущий: надзиратель берёт состав прогона из `run.json`.
|
||
// Списка нет — файл НЕ удаляем: лучше лишний раз промолчать, чем снять команду с тех,
|
||
// до кого она не дошла.
|
||
if (concerns.length && concerns.every(w => done.includes(w))) {
|
||
unlinkSync(all);
|
||
result.common_removed = true;
|
||
return result;
|
||
}
|
||
writeJsonAtomic(all, { ...body, done });
|
||
return result;
|
||
}
|
||
|
||
// ── Кусок 4: закрытие пункта, очередь пунктов, конец задачи ────────────────────
|
||
//
|
||
// 🔴 «Пункт закрыт» — не слово работника, а доказательство: сохранённый коммит ПЛЮС
|
||
// зелёные проверки ИМЕННО ЭТОГО пункта. Зелень меряет надзиратель сам (`item-audit.mjs`,
|
||
// задача 4), сюда она приходит уже замеренной; слово работника доказательством не считается.
|
||
//
|
||
// 🔴 Порядок отказов значим. Владелец и работник читают ПЕРВУЮ названную причину и чинят
|
||
// ЕЁ. Поэтому сперва «в плане нет проверок», потом «в расчёте на ответ», потом «нет
|
||
// коммита», и только потом «проверки красные»: скажи мы про красный код там, где на деле
|
||
// не пришёл ответ хозяина, — работник полночи чинил бы верное.
|
||
|
||
// Номера пунктов владелец пишет то `2`, то `"2"`. Сравнение знак в знак вернуло бы
|
||
// работнику пункт, который он только что закрыл, и он крутился бы на нём до утра.
|
||
function odinNomer(id) {
|
||
return String(id ?? '').trim();
|
||
}
|
||
|
||
export function closeItem({ item, commit_sha, tests_green, on_assumption = false, answer_received = false }) {
|
||
if (!item || typeof item !== 'object') {
|
||
throw new Error('closeItem: пункт не передан. Это сбой зова, а не свойство пункта: тихий отказ «не засчитан» выглядел бы как честная работа замка, хотя замок ничего не смотрел.');
|
||
}
|
||
|
||
// Проверка 39б: у пункта в плане не записано ни одной проверки — закрывать нечем.
|
||
if (!Array.isArray(item.tests) || item.tests.length === 0) {
|
||
return { closed: false, reason: `Для пункта ${odinNomer(item.id) || '?'} в плане не записано ни одной проверки — закрыть его нечем. Проверки пункта ставит тот, кто пишет план, строкой-пометой под заголовком.` };
|
||
}
|
||
|
||
// Проверка 40: сделанное «в расчёте на ответ» не закрывается, пока ответа нет,
|
||
// сколько бы зелёных проверок по нему ни прошло.
|
||
if (on_assumption && !answer_received) {
|
||
return { closed: false, reason: `Пункт ${odinNomer(item.id)} сделан «в расчёте на ответ» владельца, а ответа ещё нет. Закрывать такой пункт нельзя, сколько бы зелёных проверок по нему ни прошло: ответ может отменить сделанное.` };
|
||
}
|
||
|
||
if (!commit_sha) {
|
||
return { closed: false, reason: `Сделанное по пункту ${odinNomer(item.id)} не сохранено — коммита в ветке задачи нет. Несохранённое не переживёт ночь.` };
|
||
}
|
||
|
||
// Проверка 38: нет зелени по ЭТОМУ пункту — не засчитан. `tests_green` приходит
|
||
// от надзирателя замеренным (задача 4); не переданный вовсе — это «замерить
|
||
// не удалось», а не «наверное зелено».
|
||
if (tests_green !== true) {
|
||
return { closed: false, reason: `Проверки пункта ${odinNomer(item.id)} (${item.tests.join(', ')}) не прогнаны или не зелёные — пункт не засчитан.` };
|
||
}
|
||
|
||
return { closed: true, reason: null };
|
||
}
|
||
|
||
// 🔴🔴 ГРАНИЦА ЭТОЙ РАБОТЫ — ЧИТАТЬ ДО ТОГО, КАК ВЗЯТЬ ЕЁ ОТВЕТ ЗА «ВОТ ТВОЙ ПУНКТ».
|
||
//
|
||
// На вопрос «какой пункт работнику брать следующим» в затее отвечают ДВЕ работы, и они
|
||
// отвечают РАЗНОЕ на одном и том же хозяйстве. Это не поломка, это разделение ролей:
|
||
//
|
||
// · `nextFreeItem` (`file-claims.mjs`) — ВЫДАЁТ пункт. Ей передают каталог прогона,
|
||
// она читает список занятых файлов и пропускает пункты, чьи файлы держит чужой.
|
||
// · `afterItemDone` (здесь) — говорит, ЕСТЬ ЛИ ВООБЩЕ ЧТО БРАТЬ и не пора ли гаснуть.
|
||
//
|
||
// ЧЕГО ЭТА РАБОТА НЕ ЗНАЕТ: занятых файлов. Ей не передают ни каталога прогона, ни списка
|
||
// занятий — и передать нечем, в её подписи такого входа нет. Она смотрит только на план
|
||
// и на список закрытых пунктов.
|
||
//
|
||
// ОТ ЧЕГО ОНА НЕ ЗАЩИЩАЕТ: от того, что названный ею пункт прямо сейчас правит сосед.
|
||
// Замер 02.08.2026: w-1 занял `app/a.php` (пункт 1) и сидит на нём, w-2 спрашивает —
|
||
// `nextFreeItem` отвечает «пункт 2», `afterItemDone` отвечает «пункт 1», то есть тот,
|
||
// на котором сидит сосед. Возьми кто-нибудь этот ответ как выдачу пункта — двое молча
|
||
// правили бы одни файлы всю ночь при зелёных проверках, а замок занятий не сработал бы
|
||
// ни разу: его просто не спросили.
|
||
//
|
||
// 🔴 КАК ЗВАТЬ ПРАВИЛЬНО (так зовёт план задачи 5, проверено 02.08.2026): у этой работы
|
||
// спрашивают «пусто или нет» и «гаснуть ли», а САМ ПУНКТ берут через `nextFreeItem`.
|
||
// Ответ `next_item` тут — это «работа ещё есть», а не «вот твой пункт».
|
||
//
|
||
// 🪤 Собрался передать сюда каталог прогона или занятия — ОСТАНОВИСЬ и иди к владельцу.
|
||
// Подписи обеих работ ждёт задача 5, и менять разделение ролей — его решение, не твоё.
|
||
// Границу сторожит проверка «две двери на один вопрос» в `items.test.mjs`: она покраснеет
|
||
// и на попытку научить эту работу занятиям, и на попытку отучить от них `nextFreeItem`.
|
||
export function afterItemDone({ items, done, answered_during_day = false }) {
|
||
if (!Array.isArray(items)) {
|
||
throw new Error('afterItemDone: подан не список пунктов. Тихий ответ «пункты кончились» погасил бы всех работников задачи на ровном месте, и владелец прочёл бы утром «работа сделана», когда её никто не делал.');
|
||
}
|
||
if (!Array.isArray(done)) {
|
||
throw new Error('afterItemDone: не подан список закрытых пунктов. Тихое «ничего не закрыто» вернуло бы работнику тот самый пункт, который он только что закрыл.');
|
||
}
|
||
|
||
if (answered_during_day) {
|
||
return {
|
||
next_item: null,
|
||
extinguish_reason: 'Работник дождался ответа владельца днём — доделал свой пункт и гаснет, нового не берёт: иначе он жёг бы недельный запас вместе с владельцем.',
|
||
task_note: 'Остаток плана сам не подхватывается — задача продолжится только по слову владельца «продолжай».',
|
||
};
|
||
}
|
||
|
||
const zakryto = done.map(odinNomer);
|
||
const next = items.find(i => !zakryto.includes(odinNomer(i?.id))) ?? null;
|
||
return {
|
||
next_item: next,
|
||
extinguish_reason: next ? null : 'Пункты плана кончились — брать больше нечего.',
|
||
task_note: null,
|
||
};
|
||
}
|
||
|
||
export function afterTaskDone({ task_id, workers }) {
|
||
if (!Array.isArray(workers) || workers.length === 0) {
|
||
throw new Error('afterTaskDone: не сказано, каких работников гасить. Тихий пустой список прочёлся бы как «все погашены», и задача считалась бы законченной при живых работниках.');
|
||
}
|
||
return {
|
||
task_id,
|
||
// Список копируется: правка снаружи не должна менять то, что уже решено.
|
||
extinguish_workers: [...workers],
|
||
// Проверка 25: следующая задача в том же прогоне не начинается — задача упирается
|
||
// в приёмку владельца.
|
||
start_next_task: false,
|
||
// 🟡 Флаг поднят и ляжет в `run.json` (задача 5). САМОГО приёмщика по нему заводит
|
||
// кусок 7 (`startReviewer`) — здесь заводить его нечем и незачем. Пока по флагу
|
||
// никто ничего не запускает, и это названо вслух, чтобы правило не сочли работающим.
|
||
reviewer_starts: true,
|
||
note: 'Задача закончена: все её работники погашены, следующая задача в этом прогоне не начинается. Дальше — приёмка владельца, приёмщика по этому флагу заводит кусок 7.',
|
||
};
|
||
}
|
||
|
||
// ── Кусок 4, задача 3: ворота возврата отложенной задачи ───────────────────────
|
||
//
|
||
// 🔴 Проверка 28: задача не влезла в прогон — следующим прогоном САМА не подхватывается.
|
||
// 🔴 Проверка 29: не прошла приёмку — возвращается только по слову владельца, с тем же
|
||
// планом и со списком брака, и делает её НОВЫЙ работник.
|
||
// 🔴 Проверка 30: план отложенной задачи сначала заново проходит проверку — и в случае
|
||
// «не влезла», и в случае «не прошла приёмку». Пока задача лежала, продукт менялся.
|
||
//
|
||
// 🪤 Слово `reason` тут значит две разные вещи, и договор о стыках их не развёл: НА ВХОДЕ
|
||
// это вид случая (закрытый список), НА ВЫХОДЕ — текст отказа владельцу. Имена заданы
|
||
// договором дословно и менять их нельзя; столкновение названо вслух.
|
||
import { DEFER_REASONS } from './deferred.mjs';
|
||
// 🔴🔴 СРАВНИВАТЕЛЬ ПУТИ ЗДЕСЬ НЕ ЗАВОДИТСЯ СВОЙ, и это отступление от плана задачи 3,
|
||
// названное вслух. План (01.08) велел завести в этом файле свою мелочь `odinVidPuti`,
|
||
// сводящую только косые черты. К 02.08 в `paths.mjs` уже живут ДВЕ работы с этим же
|
||
// вопросом — `samePath` («это один и тот же путь?») и `odinVidPuti` (свой вид имени
|
||
// файла), и на второй копии сравнивателя там стоят два сторожа: «сравниватель пути —
|
||
// ОДИН на всю затею». Третья копия под тем же именем `odinVidPuti`, но с другим
|
||
// поведением — ровно тот класс, на котором уже обжигались.
|
||
//
|
||
// 🔴 И дело не только в чистоте: своя мелочь плана ВРАЛА БЫ ЖИВЬЁМ. Замерено 02.08.2026
|
||
// (`node` на трёх сравнивателях): владелец запускает прогон ПОЛНЫМ путём
|
||
// (`night run C:\…\план.md` — так сказано в примечании к `samePath`), а в след ложится
|
||
// путь ОТ КОРНЯ хранилища. Сведение одних косых даёт «план не тот» на ОДНОМ И ТОМ ЖЕ
|
||
// плане: ворота отказали бы владельцу на его же работе, отложенная задача не вернулась
|
||
// бы никогда, и починить это он не смог бы ничем. То же и с `./план.md`.
|
||
// `samePath` оба случая проходит верно (замерено), а разные планы по-прежнему разводит.
|
||
import { samePath } from './paths.mjs';
|
||
|
||
export function deferredTaskGate({
|
||
reason, owner_said_continue, plan_revalidated, defect_list = [],
|
||
// 🔴 «ТОТ ЖЕ ПЛАН» (проверка 29 дословно). `plan_path` — путь плана ИЗ СЛЕДА, тот,
|
||
// по которому задачу откладывали. `plan_path_now` — путь плана в ЭТОМ запуске.
|
||
// Не сравни их — задачу можно было бы «доделать» по совсем другому плану, и доделано
|
||
// было бы НЕ то, что не доделали. Оба умолчания пустые: не дали — не сравниваем
|
||
// и молчим, а не выдумываем совпадение.
|
||
plan_path = null,
|
||
plan_path_now = null,
|
||
}) {
|
||
if (!DEFER_REASONS.includes(reason)) {
|
||
// 🟡 Отступление от плана, названное вслух: план велел ЭТУ строку не трогать, потому
|
||
// что её проверяют только словами `not_finished|rejected`. Но после третьего слова
|
||
// (`impossible`, шаг 9ж) она стала ВРАТЬ владельцу: говорила «одним из двух», а список
|
||
// принимает три. Врущее сообщение об отказе — ровно тот класс, от которого вся затея
|
||
// и строится, поэтому счёт исправлен. Сами слова в строке не тронуты.
|
||
throw new Error(`Ворота возврата: вид отложенной задачи должен быть одним из трёх — not_finished, rejected или impossible. Дано: ${JSON.stringify(reason)}. Молчаливое «это не rejected» отменило бы требование списка брака, и задача вернулась бы в работу без него.`);
|
||
}
|
||
|
||
// 🔴 ТОТ ЖЕ ПЛАН (проверка 29). Стоит ПЕРВЫМ из отказов, и это нарочно: слово владельца
|
||
// «продолжай» относится к ТОЙ задаче, которую отложили. Подмени план — и «продолжай»
|
||
// сказано уже про другую работу, а владелец об этом не знает.
|
||
if (plan_path && plan_path_now && !samePath(plan_path, plan_path_now)) {
|
||
return {
|
||
may_run: false,
|
||
reason: `План не тот. Задачу откладывали по плану ${plan_path}, а запускают её по плану ${plan_path_now} — это разные планы, и доделано будет не то, что не доделали. Запустите с тем же планом; если план вправду сменился, это новая задача, а старый след снимите руками.`,
|
||
fresh_worker_required: false,
|
||
defect_list: [...defect_list],
|
||
};
|
||
}
|
||
|
||
// Проверка 28/29а: без слова владельца — никуда. Молчание не согласие.
|
||
if (!owner_said_continue) {
|
||
return {
|
||
may_run: false,
|
||
reason: 'Отложенная задача сама не подхватывается. Нужно слово владельца «продолжай» — ключ --continue в строке запуска.',
|
||
fresh_worker_required: false,
|
||
defect_list: [...defect_list],
|
||
};
|
||
}
|
||
|
||
// Проверка 30: план перепроверяется заново — в ОБОИХ случаях, и «не влезла»,
|
||
// и «не прошла приёмку».
|
||
if (!plan_revalidated) {
|
||
return {
|
||
may_run: false,
|
||
reason: 'План отложенной задачи должен заново пройти проверку: пока задача лежала, продукт менялся, и вчера годный план сегодня может не годиться вовсе.',
|
||
fresh_worker_required: false,
|
||
defect_list: [...defect_list],
|
||
};
|
||
}
|
||
|
||
// Проверка 29в: у вернувшейся с приёмки список брака обязателен. У той, что просто
|
||
// не успела, брака не было — и спрашивать его не с кого.
|
||
if (reason === 'rejected' && defect_list.length === 0) {
|
||
return {
|
||
may_run: false,
|
||
reason: 'Задача возвращена с приёмки владельца, но список брака пуст — работать не с чем. Подайте его ключом --defects <файл>. (До куска 7 список подаётся с руки: настоящую приёмку, откуда он возьмётся сам, строит кусок 7.)',
|
||
fresh_worker_required: false,
|
||
defect_list: [],
|
||
};
|
||
}
|
||
|
||
return {
|
||
may_run: true,
|
||
reason: null,
|
||
// Проверка 29г: делает её НОВЫЙ работник — прежний погас вместе с концом задачи.
|
||
fresh_worker_required: true,
|
||
// Список брака идёт ДАЛЬШЕ тем же составом: иначе ворота его прочли, кивнули
|
||
// и потеряли, а работник получил бы тот же план без единого слова о том, что в нём
|
||
// не так.
|
||
defect_list: [...defect_list],
|
||
};
|
||
}
|
||
|
||
// ── Долг куска 5 (Р14) и куска 7 (блок 6а), внесён 01.08 ───────────────────────
|
||
//
|
||
// 🔴 Пункт, который владелец ответом «нет» велел признать НЕВЫПОЛНИМЫМ (кусок 5, Р14),
|
||
// не проходит через `closeItem`: у него никогда не будет ни коммита, ни зелёных проверок —
|
||
// он и не должен их иметь. Без этого правила такой пункт НАВСЕГДА остаётся «не среди
|
||
// засчитанных», задача не закрывается никогда, и приёмщик (кусок 7, блок 6а) не заводится
|
||
// вовсе — молча (находка куска 7 СВ-6).
|
||
//
|
||
// 🪤 Списки `verified` и `impossible` по смыслу не пересекаются (пункт либо закрыт зелёными
|
||
// проверками, либо признан невыполнимым — не оба сразу), но эта функция того не проверяет:
|
||
// она не судья, а чистое сложение множеств. Судят в разных местах: замок (эта же задача,
|
||
// `closeItem`) — про «закрыт»; надзиратель круга (кусок 5, блок 2в) — про «невыполним».
|
||
//
|
||
// 🔴🔴 Откуда взять живой список невыполнимых — здесь НЕ решено. `item-verdict.json` кусок 5
|
||
// кладёт в ящик РАБОТНИКА одним файлом, и следующий вердикт затирает предыдущий; копящего
|
||
// ленджера невыполнимых пунктов затея на 02.08 не заводит нигде. Значит правило готово,
|
||
// а живой провод к нему — нет: кто будет его делать, обязан сперва завести такой ленджер,
|
||
// иначе звать это правило будет нечем.
|
||
export function reshyonnyeSNevypolnimymi(verified = [], impossible = []) {
|
||
return [...new Set([...verified, ...impossible].map(String))];
|
||
}
|