diff --git a/docs/specs/2026-08-31-slice6-consumer-surfaces.md b/docs/specs/2026-08-31-slice6-consumer-surfaces.md new file mode 100644 index 000000000..e9b99e7d1 --- /dev/null +++ b/docs/specs/2026-08-31-slice6-consumer-surfaces.md @@ -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 `. +Тобто нативний 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**. Незалежна дрібна робота: +файл не має бути невидимим для гейта лише тому, що в ньому немає заголовка. + +### Що відкинуто + +**Посилання на перевірюваний артефакт** (причина мусить називати +символ/рядок, гейт перевіряє існування) — найдешевше й виглядає розумним +компромісом, але **не спіймало б жодного** з восьми випадків: у всіх символ +лишався живим, змінювалась поведінка. +