# level.mjs

## Огляд

Модуль `level.mjs` реалізує **scale-adaptive детекцію рівня складності й рівня ризику** задачі за її текстовим описом. Ідея запозичена з BMAD (project-levels / risk-profile) і адаптована до внутрішніх термінів проєкту.

Призначення модуля у контексті dispatcher:

- На етапі `init` (ініціалізація задачі/флоу) функція виклику аналізує текстовий опис задачі і відносить її до одного з чотирьох рівнів складності `L0..L3` та одного з трьох рівнів ризику `low | med | high`.
- Поєднання `level` + `risk` _right-size_-ить (масштабує) подальший пайплайн: зокрема, скільки adversarial-рецензентів спавнить команда `flow review` і яка її глибина/фокус, а також які фази робочого процесу рекомендовані як контракт.
- Усі рішення детермінуються пошуком ключових слів у нижньому регістрі без використання регулярних виразів — це свідомий вибір для уникнення slow-regex та проблем зі словесними межами для кириличних символів.

Файл є **чистою бібліотекою без побічних ефектів**: не звертається до файлової системи, мережі, не пише в `stdout/stderr`, не залежить від часу. Усі експорти — детерміновані функції від рядкового опису (або числа/рядка).

## Експорти / API

Модуль експортує чотири іменовані функції (усі — `export function`):

| Експорт             | Сигнатура                                    | Призначення                                                                 |
| ------------------- | -------------------------------------------- | --------------------------------------------------------------------------- |
| `detectLevel`       | `(desc: string) => 0 \| 1 \| 2 \| 3`         | Визначає рівень складності задачі за описом.                                |
| `reviewersForLevel` | `(level: number) => number`                  | Кількість adversarial-рецензентів на основі рівня (1..3).                   |
| `detectRisk`        | `(desc: string) => 'low' \| 'med' \| 'high'` | Визначає рівень ризику задачі за описом.                                    |
| `reviewersForRisk`  | `(risk: string) => number`                   | Кількість рецензентів на основі ризику (1..3).                              |
| `reviewersFor`      | `(level: number, risk?: string) => number`   | Підсумкова кількість рецензентів: максимум вимог рівня й ризику, з капом 3. |

Внутрішні (НЕ експортовані) допоміжні функції: `isAsciiAlnum`, `hasWord`.

Внутрішні константи з наборами ключових слів: `L3_KEYS`, `L0_WORD_KEYS`, `L0_SUBSTR_KEYS`, `L2_KEYS`, `HIGH_RISK_KEYS`, `MED_RISK_KEYS`.

## Константи (внутрішні)

### `L3_KEYS`

Ключові слова, що сигналізують про **архітектурні/великі/міграційні** задачі (рівень L3).

```text
['platform', 'migration', 'rewrite', 'architecture', 'enterprise',
 'редизайн', 'міграц', 'переписат']
```

Збігаються звичайним `indexOf` (підрядок, case-insensitive після приведення опису до lowercase). Кириличні корені `міграц` / `переписат` спеціально вкорочені до основи слова (стемінг), щоб ловити будь-які закінчення (`міграція`, `міграційний`, `переписати`, `переписану`).

### `L0_WORD_KEYS`

ASCII-дієслова, що сигналізують про **тривіальні** задачі (рівень L0). Матчаться **тільки як ціле слово** через `hasWord` — щоб `fix` не ловило `prefix` / `fixture`.

```text
['fix', 'typo', 'bump', 'rename', 'hotfix']
```

### `L0_SUBSTR_KEYS`

Кириличні ключі для L0, матчаться **підрядком** (стемінг: `перейменув` ловить `перейменування`, `перейменувати` тощо).

```text
['опечат', 'перейменув']
```

### `L2_KEYS`

Ключові слова для **багатофайлових фіч/рефакторів** (рівень L2). Матчаться підрядком.

```text
['feature', 'epic', 'refactor', 'рефактор', 'фіча']
```

### `HIGH_RISK_KEYS`

Ключі **високого** ризику (безпека, гроші, доступи). Матчаються підрядком.

```text
['security', 'auth', 'crypto', 'payment', 'secret', 'token',
 'permission', 'password', 'безпек']
```

### `MED_RISK_KEYS`

Ключі **середнього** ризику (дані, незворотність). Матчаться підрядком.

```text
['data', ' db', 'database', 'migration', 'delete', 'gateway',
 'міграц', 'видален']
```

Зверніть увагу: ключ `' db'` навмисно записаний з провідним пробілом — це дешевий спосіб відрізнити окреме слово `db` від випадкових підрядків (наприклад, у `dbg`, `dbus`), не вдаючись до пошуку зі словесними межами.

## Функції

### `isAsciiAlnum(ch)` _(internal)_

**Сигнатура:** `function isAsciiAlnum(ch: string | undefined): boolean`

**Параметри:**

- `ch` — один символ (або `undefined`, що моделює "край рядка" перед нульовою позицією чи за останньою).

**Повертає:** `true`, якщо `ch` — ASCII-літера в нижньому регістрі (`a..z`) або ASCII-цифра (`0..9`). Інакше `false`. Для `undefined` повертає `false` (край рядка не вважається символом alnum).

**Side effects:** немає.

**Використання:** як перевірка межі слова в `hasWord`. Свідомо охоплює лише lowercase ASCII (бо вхід вже зведено до `.toLowerCase()` у `detectLevel`/`detectRisk`).

### `hasWord(text, word)` _(internal)_

**Сигнатура:** `function hasWord(text: string, word: string): boolean`

**Параметри:**

- `text` — основний текст (передбачається lowercase після `.toLowerCase()`).
- `word` — шукане ASCII-слово (lowercase).

**Повертає:** `true`, якщо в `text` зустрічається `word` і обидві його межі (символ безпосередньо до й безпосередньо після) **не є** ASCII-alnum (тобто це справді ціле слово); інакше `false`.

**Алгоритм:**

1. Шукає перше входження `word` через `text.indexOf(word)`.
2. Поки знайдено (`i !== -1`):
   - Перевіряє ліву межу: `text[i - 1]` (або `undefined`, якщо `i === 0`).
   - Перевіряє праву межу: `text[i + word.length]` (або `undefined`, якщо це кінець рядка).
   - Якщо **жодна** з них не є ASCII-alnum — повертає `true`.
   - Інакше шукає наступне входження через `text.indexOf(word, i + 1)`.
3. Якщо всі входження виявилися підрядками всередині більших слів — повертає `false`.

**Side effects:** немає.

**Чому без regex:** конвенція файлу — уникати `RegExp` для запобігання slow-regex атакам і неоднозначностям `\b` на кириличних межах слів (де `\b` в JS-regex поводиться непередбачувано). Ручний прохід через `indexOf` дешевший і явніший.

### `detectLevel(desc)`

**Експорт:** `export function detectLevel(desc): 0 | 1 | 2 | 3`

**Параметри:**

- `desc` — опис задачі (будь-який тип; усередині нормалізується через `String(desc ?? '')`).

**Повертає:** один із літералів `0 | 1 | 2 | 3` — рівень складності.

**Side effects:** немає.

**Алгоритм (з пріоритетом L3 > L0 > L2 > L1):**

1. Нормалізує `desc` до рядка: `String(desc ?? '')` (значення `null`/`undefined` стають порожнім рядком).
2. Зводить рядок у нижній регістр: `const d = ...toLowerCase()`.
3. Створює локальний хелпер `has = keys => keys.some(k => d.includes(k))` (підрядковий пошук).
4. Обчислює `isL0`:
   - `L0_WORD_KEYS.some(k => hasWord(d, k))` — ASCII-дієслова як ціле слово, **АБО**
   - `L0_SUBSTR_KEYS.some(k => d.includes(k))` — кириличні підрядки.
5. Послідовно перевіряє:
   - Якщо `has(L3_KEYS)` → `3`.
   - Інакше якщо `isL0` → `0`.
   - Інакше якщо `has(L2_KEYS)` → `2`.
   - Інакше → дефолт `1`.

**Семантика пріоритетів:**

- L3 виграє у всіх: навіть якщо опис містить і `migration`, і `fix`, рівень буде `3` — велика міграція важливіша за згадку дрібного фіксу.
- L0 наступний: тривіальні зміни не повинні маскуватися під L2-фічу через випадковий збіг.
- L2 — middle ground для багатофайлових задач.
- L1 — дефолт, коли жоден сигнал не зловлено.

**Приклади:**

- `'Fix typo in README'` → L0 (`fix` як слово; `typo`).
- `'Add database migration for users'` → L3 (через `migration`, що в L3_KEYS).
- `'Refactor auth module'` → L2 (`refactor`; `auth` тут не змінює рівень — це сигнал ризику, не рівня).
- `'Update copy on landing'` → L1 (нічого не зловлено).
- `'Prefix new fields with role_'` → L1 (`prefix` НЕ ловиться L0_WORD_KEYS через цілословний матч `fix`).

### `reviewersForLevel(level)`

**Експорт:** `export function reviewersForLevel(level: number): number`

**Параметри:**

- `level` — числовий рівень (зазвичай `0..3`, але приймає будь-яке число).

**Повертає:** ціле в діапазоні `1..3` — рекомендована кількість adversarial-рецензентів суто на основі рівня.

**Side effects:** немає.

**Таблиця рішень:**

| `level`                    | Повертає |
| -------------------------- | -------- |
| `>= 3`                     | `3`      |
| `=== 2`                    | `2`      |
| решта (`0`, `1`, від'ємні) | `1`      |

**Семантика:** глибина review корелює з масштабом задачі.

### `detectRisk(desc)`

**Експорт:** `export function detectRisk(desc): 'low' | 'med' | 'high'`

**Параметри:**

- `desc` — опис задачі (будь-який тип; усередині нормалізується через `String(desc ?? '')`).

**Повертає:** літерал `'low' | 'med' | 'high'`.

**Side effects:** немає.

**Алгоритм:**

1. Нормалізує: `const d = String(desc ?? '').toLowerCase()`.
2. `has = keys => keys.some(k => d.includes(k))` — суто підрядковий пошук (для ризику немає різниці ASCII vs кирилиця: всі ключі матчаться як підрядки).
3. Якщо `has(HIGH_RISK_KEYS)` → `'high'`.
4. Інакше якщо `has(MED_RISK_KEYS)` → `'med'`.
5. Інакше → `'low'`.

**Семантика пріоритетів:** `high > med > low`. Якщо опис одночасно містить і high-, і med-сигнал — виграє `high`.

**Приклади:**

- `'Add password reset flow'` → `'high'` (`password`).
- `'Migrate user data to new schema'` → `'high'` (`migration`/`міграц` — НІ, але `data` в MED_RISK_KEYS… насправді тут `migration` теж у MED_RISK_KEYS, а high-ключів немає → `'med'`).
- `'Bump dep version'` → `'low'`.

### `reviewersForRisk(risk)`

**Експорт:** `export function reviewersForRisk(risk: string): number`

**Параметри:**

- `risk` — рядок (очікувано `'low' | 'med' | 'high'`; інші значення трактуються як low).

**Повертає:** ціле `1..3` — рекомендована кількість рецензентів суто за ризиком.

**Side effects:** немає.

**Таблиця рішень:**

| `risk`                                                | Повертає |
| ----------------------------------------------------- | -------- |
| `'high'`                                              | `3`      |
| `'med'`                                               | `2`      |
| решта (включно з `'low'`, `undefined`, неочікуваними) | `1`      |

### `reviewersFor(level, risk)`

**Експорт:** `export function reviewersFor(level: number, risk?: string): number`

**Параметри:**

- `level` — числовий рівень складності (`0..3`).
- `risk` — необов'язковий рядок ризику (`'low' | 'med' | 'high'`).

**Повертає:** ціле `1..3` — підсумкова кількість рецензентів, **максимум** вимог за рівнем і за ризиком, з верхнім капом `3`.

**Side effects:** немає.

**Формула:**

```text
Math.min(3, Math.max(reviewersForLevel(level), reviewersForRisk(risk)))
```

**Семантика:**

- Високий ризик піднімає глибину review навіть для L0/L1.
- Велика задача (L3) гарантує максимум рецензентів незалежно від ризику.
- Кап `3` страхує від екзотичних вхідних значень `level > 3` (не виходимо за межі узгодженого діапазону).

**Приклади (level, risk → reviewers):**

| `level` | `risk`      | Результат       |
| ------- | ----------- | --------------- |
| `0`     | `'low'`     | `max(1, 1) = 1` |
| `0`     | `'high'`    | `max(1, 3) = 3` |
| `2`     | `'low'`     | `max(2, 1) = 2` |
| `3`     | `'med'`     | `max(3, 2) = 3` |
| `1`     | `undefined` | `max(1, 1) = 1` |

## Залежності

**Зовнішні (npm/Node.js):** немає. Файл повністю самодостатній.

**Стандартні API:** використовує лише вбудовані операції рядка (`String`, `toLowerCase`, `indexOf`, доступ за індексом, `length`) та `Math.min` / `Math.max`.

**Внутрішні (з інших модулів проєкту):** немає `import`/`require`.

**Хто залежить від цього модуля:** очікувано — модулі команди `init` (ініціалізація флоу) та `flow review` (вибір кількості рецензентів). Інші частини dispatcher можуть викликати `reviewersFor` як єдину точку входу для розрахунку review-глибини.

## Потік виконання / Використання

Типовий потік:

1. **Ініціалізація задачі (`init`).** Викликач має текстовий опис задачі (наприклад, з ADR-чернетки або з CLI-аргументу). Імпортує `detectLevel` і `detectRisk`:

   ```js
   import { detectLevel, detectRisk, reviewersFor } from './level.mjs'

   const level = detectLevel(taskDescription) // 0 | 1 | 2 | 3
   const risk = detectRisk(taskDescription) // 'low' | 'med' | 'high'
   ```

2. **Запис у стан флоу.** Значення `level` і `risk` зберігаються у стейті задачі (state-store / spec / артефакти), щоб бути доступними наступним командам.

3. **Розрахунок глибини review (`flow review`).** Перед спавном adversarial-рецензентів викликається `reviewersFor`:

   ```js
   import { reviewersFor } from './level.mjs'

   const reviewers = reviewersFor(level, risk) // 1..3
   for (let i = 0; i < reviewers; i++) {
     spawnAdversarialReviewer({ index: i, level, risk })
   }
   ```

4. **Рекомендовані фази (контракт).** Рівень і ризик також впливають на те, які фази робочого процесу обов'язкові (наприклад, для L3/high — повний цикл з планом, ревью, gate; для L0/low — спрощений шлях). Це використання вже виходить за межі цього файлу, але `detectLevel` + `detectRisk` — точка входу для таких рішень.

**Інваріанти й гарантії:**

- Усі функції чисті й детерміновані: однаковий вхід → однаковий вихід.
- `detectLevel` / `detectRisk` стійкі до `null` / `undefined` / нерядкових входів (приведення через `String(x ?? '')`).
- Жодна функція не кидає виключень для типових входів.
- Жодна функція не виконує I/O.

**Обмеження детекції:**

- Простий пошук підрядків може давати false-positive на природній мові (наприклад, фраза "no migration needed" все одно тригерить L3). Це свідомий компроміс на користь швидкості і детермінізму.
- ASCII-дієслова L0 потребують матчу цілим словом; кириличні L0 матчаться підрядком — це асиметрія, продиктована особливостями токенізації двох алфавітів.

## Rebuild Test

Документація достатня для відтворення модуля з нуля без перегляду оригіналу:

- Усі експорти, їхні сигнатури, параметри, тип повертального значення й семантика описані.
- Внутрішні константи (списки ключових слів) наведені дослівно, з поясненням, чому окремі ключі сформовані саме так (стемінг, провідний пробіл у `' db'`).
- Алгоритми `detectLevel` / `detectRisk` / `hasWord` / `isAsciiAlnum` описані покроково з порядком пріоритетів і дефолтами.
- Формула `reviewersFor` з капом і максимумом наведена явно.
- Зафіксовано принципову відмову від regex з обґрунтуванням.

За цією специфікацією модуль `level.mjs` можна повністю відтворити, і його поведінка на типових входах буде ідентичною оригіналу.
