Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 240 additions & 0 deletions docs/specs/2026-08-31-slice6-consumer-surfaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
# Зріз 6 — рішення щодо консюмерських поверхонь

**Дата:** 2026-08-31. **Статус:** ухвалено власником ітеративним розбором.
**Зв'язані:** `2026-08-01-rules-cli-phase8-skeleton.md` §12,
`2026-08-30-contract-roadmap-blocked-concerns.md` (рішення 1-5, форма
контракту `5.0.0`).

Цей документ несе рішення, які **не стосуються форми контракту**, але без
яких зріз 6 забирає в консюмера канали, якими той досі щось діставав.

## Рішення 6 — `text/run-v8r`: нативна валідація, каталог вшитий

### Стан на момент рішення

Концерн **уже портований нативно**
(`crates/rules-core/src/concerns/text_run_v8r.rs`, у реєстрі детекторів
`concerns/mod.rs:261`) — але порт зберіг спосіб виклику: `bun x v8r <files>`.
Тобто нативний Rust-детектор спавнить пакетний менеджер, щоб запустити
JS-валідатор, який читає наші ж завендорені схеми. `v8r` при цьому в реєстрі
тулів **відсутній** (`tools ensure` про нього не знає).

`npm/schemas/vendor/**` — 21 схема, 3.2 МБ, офлайн-каталог для нього.
Директорія **захищена** (`CLAUDE.md`), тож рішення стосується лише способу
доставки, не вмісту.

### Рішення

Замінити зовнішній `v8r` нативною валідацією: JSON-Schema крейт у Rust плюс
добір схеми за завендореним каталогом. Каталог вшивається в бінар на збірці.

**Чому не `npm:v8r` у реєстрі тулів** (варіант, ідентичний тому, що #590
зробив для `eslint`/`oxlint`/`jscpd`, і тому дешевий): він консервує
архітектурну аномалію — кожен крок ланцюжка «bun → npm → JS-валідатор → наші
схеми» ми вміємо робити самі. І він лишає питання 3.2 МБ відкритим, тоді як
нативна валідація закриває його тим самим рухом.

`run-v8r` — детектор, тобто біжить на кожному повному лінті; спавн `bun x`
там означає плату за запуск пакетного менеджера заради валідації JSON.

Побічно знімається остання неявна залежність від bun у детекторному контурі
(після #590, який відвʼязав `lang-js` від `path:bunx`).

## Рішення 7 — `$schema`: власний Apicurio-реєстр

### Проблема

`npm/bin/n-rules-cli.mjs:126` тримає
`CONFIG_SCHEMA_URL = 'https://unpkg.com/@7n/rules/schemas/n-rules.json'`, і
цей рядок ми пишемо в `.n-rules.json` **консюмера** (наш власний файл теж
його має, рядок 2). Після зняття npm `unpkg` віддасть 404, і IDE перестане
валідувати конфіг — **мовчки**, без жодного повідомлення.

### Рішення

Схеми переїжджають у власний **Apicurio Registry**. Це не просто інший хост:
реєстр із версіонуванням артефактів і REST-API, тобто вміє віддавати схему
за конкретною версією.

### Дві умови, без яких рішення не працює

Обидві — частина рішення, не побажання.

1. **Пінована версія, не `latest`.** `$schema` має вказувати на конкретну
версію артефакту, і `n-rules` має писати саме той URL, що відповідає
версії бінаря, який синкає. Інакше повертається та сама вада, від якої
йдемо: консюмер на старішому бінарі дістає підказки від новішої схеми —
дрейф того ж класу, що `KNOWN_PLUGIN_RANGES` (#578), лише зовні й
непомітно.
2. **Анонімне читання.** IDE не має креденшелів. Якщо реєстр закритий,
підказки не працюють, і знову **мовчки**. Це вже виміряна пастка:
`git.7n.ai` OCI-реєстр анонімний pull не віддає (`GET /v2/` → `401`,
зміряно 2026-08-30). Якщо Apicurio стане за тим самим периметром,
`$schema` повторить долю `unpkg` — інший URL, той самий мовчазний
відмов для стороннього.

### Прийнята слабкість

Airgapped-консюмер підказок IDE не матиме. Мережа повертається в контур, з
якого ми її прибирали (`--offline` транспорту проєктувався саме під такі
дерева). Це прийнятно, бо стосується **зручності IDE**, а не роботи
інструмента — але записано явно, щоб згодом не зʼявилось як «раптова» вада.

### Відкинуті варіанти

- **Локальний файл, який пише бінар** — давав версійну відповідність за
побудовою й повну незалежність від мережі, але додавав ще один артефакт у
чуже дерево, тобто розширював Д4.
- **SchemaStore** — консюмеру не треба нічого, але це залежність від
каталогу, який ми не контролюємо, і публічне зобовʼязання щодо імені
`.n-rules.json`, яке ми вже одного разу міняли (слід у
`n-rules-cli.mjs:258`).
- **Прибрати `$schema`** — свідома втрата реальної зручності.

## Рішення 8 — `exports["./rules/*"]`: помирає природно, тести плагінів переїжджають у Rust

### Що показав розбір

`exports` у `npm/package.json` — канал, яким **плагіни тягнуть спільний JS із
ядра**. Заміряно (`grep -rho "@7n/rules/[a-z/.-]*\.mjs"`):

| що тягнуть | разів |
|---|---:|
| `scripts/lib/lint-surface/types.mjs` | 28 |
| `scripts/utils/test-helpers.mjs` | 18 |
| `scripts/lib/ci-artifact-collect.mjs` | 10 |
| `rules/test/coverage/lib/llm.mjs` | 10 |
| `scripts/lib/ensure-tool.mjs` | 6 |

**Більшість цих споживачів зникає за вже ухваленими рішеннями:** `types.mjs`
не потрібен, коли типи приходять із WIT (рішення 3); `llm.mjs` іде разом із
`coverage-provider` у слотовий світ; `ensure-tool` — клас B розвідки
прогалини. Тобто продуктивне спільне використання вмирає разом із
JS-поверхнею плагінів, і окремого рішення не потребує.

### Справжній залишок — тестова інфраструктура

`test-helpers.mjs` (18 споживачів) тримають **тести плагінів на vitest**.
Коли плагін стає чистим wasm-компонентом, його продуктивний код Rust, а
тести лишаються JS і тягнуть хелпери каналом, якого не буде.

**Рішення:** тести переїжджають у Rust разом із кодом. Після рішення 3
плагін І Є крейт, тож тести природно живуть там же. Прецедент доведений і
працює: `crates/rules-plugin-host/tests/plugin_lang_js.rs` — 86 тестів проти
справжнього зібраного `.wasm`.

**Межа рішення, названа явно.** Частина цих тестів перевіряє не гостя, а
поведінку хоста на JS-боці (резолв, драбина, диспатч). Доки `run-fix.mjs` і
`detect.mjs` лишаються JS (клас B, не A), їхні тести природно лишаються JS.
Рішення 8 закриває тести **плагінів**, але не всю тестову поверхню; повне
зникнення vitest настане лише разом із портом оркестрації.

## Рішення 9 — публічне ядро несе власний OCI-реєстр

`git.7n.ai` анонімного читання не віддає (`GET /v2/` → `401`, зміряно
2026-08-30). **Рішення:** власний OCI-реєстр (Harbor/Zot) з анонімним
читанням, узгоджено з рішенням 7 про власний Apicurio.

### Заперечення, висловлене й відхилене

Рекомендація фасилітатора була інша: спершу перевірити, чи анонімне читання
на `git.7n.ai` — це інстансна конфігурація Forgejo (`401` не доводить
архітектурної неможливості), і лише якщо ні — гібрид `ghcr.io` + `git.7n.ai`.
Аргумент: **схеми й бінарі мають різну ціну недоступності**. Лежить
Apicurio — консюмер втрачає підказки IDE; лежить OCI-реєстр із ядром —
консюмер не може поставити чи оновити інструмент узагалі.

Власник обрав власний реєстр свідомо. Ризик прийнятий.

### Що з цього рішення випливає негайно

Доступність публічного каналу стає нашою, тож дві речі перестають бути
зручністю й стають **несучими**:

1. **Офлайн-дисципліна `oci-dist`** — `--offline` як first-class режим, кеш,
lock із точними digest. Проєктувалась під airgapped-дерева; тепер це ще
й буфер на випадок недоступності власного реєстру.
2. **CI-образи, що вже несуть бінар** (рішення §12.2). Проєктувалось як
економія часу прогону; тепер це єдине, що тримає CI консюмера, коли
реєстр лежить.

Обидві треба реалізовувати з цим статусом, а не як оптимізації.

## Рішення 10 — `hook` лишається портованим, підстава переписується

Зріз 4 портував `hook` («найгарячіша поверхня продукту, стріляє після кожної
правки файлу агентом») з підставою про латентність старту Node. Розвідка
оркестрації це **зміряла**: `hook_cmd.rs:32-39` — старт node дає **менше 3%**
латентності. Виграш, заради якого робився зріз, у межах похибки.

**Рішення:** код лишається, підстава переписується. Порт `hook` справді
потрібен — не заради 3%, а тому що крок 0 плану робить бінар вхідною точкою,
і `hook` мусить бути нативним разом з усім іншим. Підстава була хибна,
висновок правильний.

Виміряні 3% фіксуються **явно**, щоб хибна підстава не була процитована як
прецедент для наступного зрізу — саме так поширювався застарілий доккомент
`lint_plan.rs:9-27` (за добу проріс у план і в §12.4.1 спеки зрізу 6).

Повернення в JS відкинуто: воно викидає робочий код у найгарячішій поверхні,
який довелося б написати знову.

## Рішення 11 — гейт проти рацій, що переживають свою причину

### Масштаб класу

За одну добу — **вісім** випадків: `tauri/release` (§2.97),
`component_profile` в `oci-dist` (§2.101), два в класифікації канонів
(§2.99), `test/stryker_config`, `rules-docs`, дискавері в `lint_plan.rs`,
підстава зрізу `hook`. Плюс список відкритих питань, де `bun/package_json`
висів уже вирішеним.

### Дві виміряні діри

1. `FIX_STAYS_IN_JS` звіряється машинно **за складом**; її причини — вільний
текст, не звірюваний ні з чим.
2. CRC-гейт доків стереже **дрейф тексту, не правдивість**: валідний CRC над
твердженням, яке код спростовує
(`plugins/lang-rust/coverage-provider/docs/provider.md:15`), і файл на 612
рядків поза гейтом узагалі, бо без frontmatter (`npm/bin/docs/n-rules.md`).

### Рішення: тест на ПРИЧИНУ для кожного запису, що стверджує живе обмеження

**Ключова відмінність, без якої гейт не працює.** `oci-dist` мав тест
`rejects_p2_component_profile` — і він не врятував, а навпаки, зробив
застарілу засновку липкою: доки тест зелений, обмеження виглядало
обґрунтованим.

Різниця в тому, що саме стверджує тест:

- тест на **наслідок** («P2 відхиляється», «гість не має гілки `fix`») —
**консервує** обмеження;
- тест на **причину** («napi-міст будує `FixRequest::files` лише з полів
`file` переданих violations») — **червоніє, коли причина зникає**.

`test/stryker_config` — точна ілюстрація: якби причина була закріплена
тестом, то в момент, коли міст навчився `fix_glob` (§2.95), тест би впав, і
розрив виявився б одразу, а не через місяці.

### Обсяг

Заміряно 2026-08-31: у реєстрі **105 записів**, із них **30 стверджують живе
обмеження** — решта фіксують зроблену роботу й тесту на причину не
потребують за визначенням. Тобто «для кожного запису» — це 30 тестів.

Рекомендація фасилітатора була вужча (тест на причину лише для чотирьох
контрактних блокерів, термін придатності для решти) з аргументом про ціну.
Власник обрав повне покриття; вимірювання показує, що воно здійсненне.

### Окремо — друга діра

Поширити CRC-гейт на файли **без frontmatter**. Незалежна дрібна робота:
файл не має бути невидимим для гейта лише тому, що в ньому немає заголовка.

### Що відкинуто

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