diff --git a/.cspell.json b/.cspell.json index 7d4ef263c..27d0ccba8 100644 --- a/.cspell.json +++ b/.cspell.json @@ -165,6 +165,7 @@ "автогенерація", "автогенерованих", "автогенерується", + "Автодетектовані", "автозастосування", "автозастосуванням", "автозастосунок", @@ -218,6 +219,7 @@ "валідаційного", "валідаційні", "валідований", + "вантажує", "вендорені", "вендоримо", "вендорингу", @@ -478,6 +480,7 @@ "підписочний", "підписочних", "підписочні", + "підтеків", "підходящу", "підшляховий", "пінені", diff --git a/npm/.changes/260723-0740.md b/npm/.changes/260723-0740.md new file mode 100644 index 000000000..cb7fdce71 --- /dev/null +++ b/npm/.changes/260723-0740.md @@ -0,0 +1,5 @@ +--- +bump: patch +section: Fixed +--- +doc-files: явна діагностика замість тихого 0 кандидатів, коли задекларований плагін (.n-rules.json) не встановлений у node_modules diff --git a/npm/rules/doc-files/check/docs/main.md b/npm/rules/doc-files/check/docs/main.md index 501d5539b..cfec75a1b 100644 --- a/npm/rules/doc-files/check/docs/main.md +++ b/npm/rules/doc-files/check/docs/main.md @@ -3,30 +3,27 @@ type: JS Module title: main.mjs resource: npm/rules/doc-files/check/main.mjs docgen: - crc: 2af6c6b9 - model: omlx/gemma-4-e4b-it-OptiQ-4bit - score: 100 - issues: judge:inaccurate:0.99 - judgeModel: openai-codex/gpt-5.4-mini + crc: dd3f8c2a --- ## Огляд -Lint-детектор concern-а `doc-files/check`: виявляє застарілі файлові доки (CRC-mismatch, відсутність, деградація) та «сирітські» доки, чиє джерело видалено. Виключно read-only детект — генерація й очистка живуть у `fix-worker.mjs` (docgen), не тут. +Lint-детектор doc-files: знаходить застарілі файлові доки (CRC-mismatch/missing/degraded) і сирітські доки, чиє джерело видалено. Read-only — саму генерацію/очистку виконує `fix-worker.mjs`. ## Поведінка -1. `lint(ctx)`: у delta-режимі (`ctx.files` задано) змінені шляхи зводяться до множини вихідних кодових файлів — для зміненої `docs/*.md`-доки reverse-map-ом знаходиться її джерело у батьківській теці; без `ctx.files` — повний скан репо. -2. Для кожного джерела перевіряється актуальність його доки (`describeFile`/`scanForDocFiles`); застаріла/відсутня дока → порушення з причиною (`reason`) і шляхом джерела. -3. Окремо скануються «сирітські» доки (`scanOrphanedDocs`) — `docs/*.md`, чиє джерело видалено; кожна — порушення `orphaned-doc`. -4. Повертається `LintResult` зі списком порушень — без жодних мутацій. +`lint(ctx)` збирає застарілі доки через `collectStale` (для конкретного набору змінених файлів або, якщо файлів не передано, повним скануванням дерева) і додає для кожної окреме порушення з причиною (`missing`/`crc-mismatch`/`degraded`) та шляхом джерела. Окремо сканує `docs/`-теки на сирітські доки (`scanOrphanedDocs`) — джерело яких більше не існує. + +Реверс-мапінг: якщо серед змінених файлів трапляється сама `.md`-дока (а не її джерело), `sourceForDoc` шукає відповідний вихідний файл поруч (той самий basename, легальне розширення) і саме його передає далі в перевірку застарілості — так зміна доки теж тригерить звірку CRC її джерела. + +Якщо мапа doc-files-розширень від активних плагінів порожня, а хоча б один плагін явно задекларований у `.n-rules.json`, але не встановлений у `node_modules` — детектор додає `diagnostics`-запис (`level: 'warn'`) з підказкою запустити `bun install`. Це відрізняє "плагін не встановлено" від "усі доки актуальні": без нього обидва випадки виглядають як 0 порушень. ## Публічний API -collectStale — перелік застарілих доків: для `files` (delta) або всього репо (undefined); -lint — детектор застарілих і сирітських файлових доків, повертає `{ violations }`. +- `lint(ctx)` — детектор concern-а `doc-files/check`: повертає `{ violations, diagnostics? }`. ## Гарантії поведінки -- Read-only: не виконує операцій запису (ФС/БД). -- Fail-safe: помилка читання теки джерела дає `null`-reverse-map (джерело пропускається), не виняток. +- Read-only: не пише і не видаляє жодних файлів. +- Перехоплює помилки читання файлової системи (напр. нечитабельна тека) — не пропускає винятків назовні. +- Diagnostics-попередження про невстановлений плагін рендериться лише при `--verbose` на explicit CLI-виклику (`lint doc-files --no-fix --verbose`) — у PostToolUse-хуку (без `verbose`) обчислюється, але ніколи не друкується. diff --git a/npm/rules/doc-files/check/main.mjs b/npm/rules/doc-files/check/main.mjs index c5c4aa0c7..0ef15accd 100644 --- a/npm/rules/doc-files/check/main.mjs +++ b/npm/rules/doc-files/check/main.mjs @@ -5,6 +5,7 @@ import { join, dirname, basename, extname } from 'node:path' import { existsSync, readdirSync } from 'node:fs' import { describeFile, isDocCandidate, isSourceFile, scanForDocFiles, scanOrphanedDocs } from '../docgen-scan/main.mjs' +import { unavailableDocFilesPlugins } from '../docgen-scan/lang-extensions.mjs' const DOC_MD_RE = /(?:^|\/)docs\/[^/]+\.md$/u @@ -95,5 +96,11 @@ export function lint(ctx) { ) } - return { violations } + const unavailable = unavailableDocFilesPlugins(cwd) + if (unavailable.length === 0) return { violations } + + const word = unavailable.length > 1 ? 'плагіни' : 'плагін' + const verb = unavailable.length > 1 ? 'не встановлені' : 'не встановлений' + const message = `doc-files: 0 кандидатів через недоступні плагіни — ${word} ${unavailable.join(', ')} ${verb} у node_modules — запусти bun install` + return { violations, diagnostics: [{ level: 'warn', message }] } } diff --git a/npm/rules/doc-files/docgen-scan/docs/lang-extensions.md b/npm/rules/doc-files/docgen-scan/docs/lang-extensions.md index ba3c450e9..8f828a7f3 100644 --- a/npm/rules/doc-files/docgen-scan/docs/lang-extensions.md +++ b/npm/rules/doc-files/docgen-scan/docs/lang-extensions.md @@ -3,32 +3,40 @@ type: JS Module title: lang-extensions.mjs resource: npm/rules/doc-files/docgen-scan/lang-extensions.mjs docgen: - crc: 3c5bc28b - model: openai-codex/gpt-5.4-mini - tier: cloud-min - score: 100 - issues: judge:inaccurate:0.98 - judgeModel: openai-codex/gpt-5.4-mini + crc: 14b28432 + model: omlx/gemma-4-e2b-it-4bit + tier: local-min + score: 95 --- ## Огляд -Збирає з активних плагінів у репозиторії мапу розширень doc-files і мовні екстрактори для них, щоб інші частини системи могли підготувати обробку файла за доступними плагінними можливостями. `pluginDocFilesExtensions` формує перелік підтримуваних doc-files розширень, `loadDocFilesExtractors` підтягує екстрактори мов із плагінів, а `clearDocFilesLangCache` скидає кеш у межах прогону. Спирається на `.n-rules.json` і `.n-cursor.json` як джерело конфігурації активних плагінів та їхніх правил. Працює fail-safe: биті handler-модулі мовчки пропускає, не кидає винятків назовні, кешує стан у межах прогону. +Огляд: Модуль керує завантаженням та очищенням документів та логіки для перевірок. Забезпечує функціональний потік, який включає отримання мапи розширень, завантаження екстракторів та очищення кешу для тестування. ## Поведінка -- `pluginDocFilesExtensions` — повертає мапу розширень doc-files, які декларують активні плагіни в репозиторії, з урахуванням кешу для поточного прогону. -- `loadDocFilesExtractors` — завантажує мовні екстрактори з handler-модулів плагінів для doc-files і повертає їх за розширеннями; биті модулі мовчки пропускає, тож для таких файлів далі можливий whole-file шлях. -- `clearDocFilesLangCache` — скидає внутрішній кеш мовних розширень і екстракторів, щоб наступний прогін прочитав актуальний стан заново. +Поведінка крос-функціональний потік +pluginDocFilesExtensions повертає мапу розширень задекларованих у конфігах +loadDocFilesExtractors вантажує мовні екстрактори з handler-модулів +unavailableDocFilesPlugins повертає список плагінів, які не встановлені +clearDocFilesLangCache скидає кеші для тестування ## Публічний API -- pluginDocFilesExtensions — збирає з активних плагінів карту розширень для doc-files і тримає її в процесному кеші; порожній результат означає, що жоден плагін не оголосив підтримку. -- loadDocFilesExtractors — підвантажує мовні extractors із plugin handler-модулів для extension-point `doc-files`; якщо модуль зламаний, його тихо пропускає і далі обробляє файл цілком. -- clearDocFilesLangCache — очищає кеші, щоб тести починали з чистого стану. +- pluginDocFilesExtensions — Мапа doc-files-розширень від плагінів для репо (`.rs` → 'Rust Module', …), +з кешем на процес. Порожня мапа — жодний активний плагін їх не декларує. +- loadDocFilesExtractors — Асинхронно вантажить мовні екстрактори з handler-модулів плагінів +(extension-point `doc-files`): default-експорт +`{ id, extensions: string[], extractFacts?, extractUnits? }`. +Битий модуль — мовчазний пропуск (генерація тоді йде whole-file шляхом). +- unavailableDocFilesPlugins — Задекларовані у `.n-rules.json` плагіни, недоступні в `node_modules` — рахується лише +коли мапа doc-files-розширень порожня (інакше принаймні один плагін реально доступний, +шукати "недоступні" немає сенсу — не hot-path concern, рахується лише в рідкісному +порожньому випадку). +- clearDocFilesLangCache — Скидає кеші (для тестів). ## Гарантії поведінки -- Read-only: не виконує операцій запису (ФС/БД). +- Власних операцій запису (ФС/БД) у файлі немає; виклики імпортованих модулів можуть писати. - Перехоплює помилки і не пропускає винятків назовні (fail-safe). - Кешує результати в межах одного прогону. diff --git a/npm/rules/doc-files/docgen-scan/lang-extensions.mjs b/npm/rules/doc-files/docgen-scan/lang-extensions.mjs index 1df54a7ed..43af01737 100644 --- a/npm/rules/doc-files/docgen-scan/lang-extensions.mjs +++ b/npm/rules/doc-files/docgen-scan/lang-extensions.mjs @@ -3,7 +3,11 @@ import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { getDocFilesExtensions, getHandlers } from '../../../scripts/lib/resolve-plugins.mjs' +import { + getDocFilesExtensions, + getHandlers, + getUnavailableDeclaredPlugins +} from '../../../scripts/lib/resolve-plugins.mjs' /** * Мовні розширення doc-files від плагінів (фаза 4 spec lang-plugins-extraction). @@ -82,6 +86,19 @@ export async function loadDocFilesExtractors(cwd) { return map } +/** + * Задекларовані у `.n-rules.json` плагіни, недоступні в `node_modules` — рахується лише + * коли мапа doc-files-розширень порожня (інакше принаймні один плагін реально доступний, + * шукати "недоступні" немає сенсу — не hot-path concern, рахується лише в рідкісному + * порожньому випадку). + * @param {string} cwd корінь репозиторію + * @returns {string[]} npm-імена задекларованих, але не встановлених плагінів (порожньо — усе гаразд) + */ +export function unavailableDocFilesPlugins(cwd) { + if (Object.keys(pluginDocFilesExtensions(cwd)).length > 0) return [] + return getUnavailableDeclaredPlugins(cwd, readPluginsConfigSync(cwd)) +} + /** Скидає кеші (для тестів). */ export function clearDocFilesLangCache() { EXT_CACHE.clear() diff --git a/npm/rules/doc-files/docgen-scan/tests/lang-extensions.test.mjs b/npm/rules/doc-files/docgen-scan/tests/lang-extensions.test.mjs new file mode 100644 index 000000000..6944134d3 --- /dev/null +++ b/npm/rules/doc-files/docgen-scan/tests/lang-extensions.test.mjs @@ -0,0 +1,110 @@ +import { describe, expect, test } from 'vitest' +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { withTmpDir, installFakeLangJsPlugin } from '../../../../scripts/utils/test-helpers.mjs' +import { + pluginDocFilesExtensions, + unavailableDocFilesPlugins, + loadDocFilesExtractors, + clearDocFilesLangCache +} from '../lang-extensions.mjs' + +/** + * Пише фейковий плагін з handler-модулем `doc-files` у node_modules tmp-репо + * і декларує його в `.n-rules.json`. + * @param {string} dir корінь tmp-репо + * @param {string} handlerBody вміст handler-модуля (ESM default export) + */ +async function installFakeHandlerPlugin(dir, handlerBody) { + const pkgRoot = join(dir, 'node_modules', '@x', 'lang-fake') + await mkdir(pkgRoot, { recursive: true }) + await writeFile( + join(pkgRoot, 'package.json'), + JSON.stringify({ + name: '@x/lang-fake', + version: '0.0.0', + 'n-rules': { contributes: { rules: false, handlers: { 'doc-files': './handler.mjs' } } } + }) + ) + await writeFile(join(pkgRoot, 'handler.mjs'), handlerBody) + await writeFile(join(dir, '.n-rules.json'), JSON.stringify({ plugins: ['@x/lang-fake'] })) +} + +describe('pluginDocFilesExtensions', () => { + test('немає .n-rules.json і немає плагінів у node_modules — порожня мапа', async () => { + await withTmpDir(dir => { + expect(pluginDocFilesExtensions(dir)).toEqual({}) + }) + }) + + test('встановлений і задекларований плагін — розширення в мапі', async () => { + await withTmpDir(async dir => { + await installFakeLangJsPlugin(dir) + expect(pluginDocFilesExtensions(dir)).toMatchObject({ '.mjs': 'JS Module' }) + }) + }) +}) + +describe('unavailableDocFilesPlugins', () => { + test('плагін задекларований у .n-rules.json, але не встановлений — потрапляє у список', async () => { + await withTmpDir(async dir => { + await writeFile(join(dir, '.n-rules.json'), JSON.stringify({ plugins: ['@7n/rules-lang-js'] })) + expect(unavailableDocFilesPlugins(dir)).toEqual(['@7n/rules-lang-js']) + }) + }) + + test('плагін встановлений — мапа розширень непорожня, список недоступних порожній', async () => { + await withTmpDir(async dir => { + await installFakeLangJsPlugin(dir) + expect(unavailableDocFilesPlugins(dir)).toEqual([]) + }) + }) + + test('немає .n-rules.json взагалі — порожній список (нема що вважати недоступним)', async () => { + await withTmpDir(dir => { + expect(unavailableDocFilesPlugins(dir)).toEqual([]) + }) + }) + + test('битий .n-rules.json (невалідний JSON) — трактується як конфіг без плагінів', async () => { + await withTmpDir(async dir => { + await writeFile(join(dir, '.n-rules.json'), '{ not valid json') + expect(unavailableDocFilesPlugins(dir)).toEqual([]) + }) + }) +}) + +describe('loadDocFilesExtractors', () => { + test('плагін з handler-модулем doc-files — екстрактор доступний за розширенням', async () => { + await withTmpDir(async dir => { + await installFakeHandlerPlugin( + dir, + `export default { id: 'fake', extensions: ['.fake'], extractFacts: () => ({}) }\n` + ) + const map = await loadDocFilesExtractors(dir) + expect(map.get('.fake')?.id).toBe('fake') + }) + }) + + test('битий handler-модуль (кидає при імпорті) — мовчазний пропуск', async () => { + await withTmpDir(async dir => { + await installFakeHandlerPlugin(dir, `throw new Error('boom')\n`) + const map = await loadDocFilesExtractors(dir) + expect(map.size).toBe(0) + }) + }) +}) + +describe('clearDocFilesLangCache', () => { + test('скидає кеш — наступний виклик перечитує змінений .n-rules.json', async () => { + await withTmpDir(async dir => { + expect(pluginDocFilesExtensions(dir)).toEqual({}) + await installFakeLangJsPlugin(dir) + // Без скидання кешу пропозиція лишалась би порожньою (кеш keyed за dir). + expect(pluginDocFilesExtensions(dir)).toEqual({}) + clearDocFilesLangCache() + expect(pluginDocFilesExtensions(dir)).toMatchObject({ '.mjs': 'JS Module' }) + }) + }) +}) diff --git a/npm/rules/doc-files/tests/main.test.mjs b/npm/rules/doc-files/tests/main.test.mjs index 6812f0ea2..38988bcb2 100644 --- a/npm/rules/doc-files/tests/main.test.mjs +++ b/npm/rules/doc-files/tests/main.test.mjs @@ -95,4 +95,30 @@ describe('lint — детект (read-only detector)', () => { expect(violations[0].file).toBe('src/a.mjs') }) }) + + test('плагін задекларований, але не встановлений (свіжий worktree без bun install) — 0 violations + warn-діагностика', async () => { + await withTmpDir(async root => { + // Навмисно НЕ ставимо фейковий плагін у node_modules — лише декларація в .n-rules.json, + // як у щойно створеному git worktree без `bun install`. + await writeFile(join(root, '.n-rules.json'), JSON.stringify({ plugins: ['@7n/rules-lang-js'] })) + await ensureDir(join(root, 'src')) + await writeFile(join(root, 'src', 'a.mjs'), 'export const a = 1\n') + const { violations, diagnostics } = await lint(ctxFor(root)) + expect(violations).toEqual([]) + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0].level).toBe('warn') + expect(diagnostics[0].message).toContain('@7n/rules-lang-js') + expect(diagnostics[0].message).toContain('bun install') + }) + }) + + test('плагін встановлений — без діагностики, навіть якщо 0 violations', async () => { + await withTmpDir(async root => { + await installFakeLangJsPlugin(root) + await writeSourceWithFreshDoc(root, 'src/a.mjs', 'export const a = 1\n') + const { violations, diagnostics } = await lint(ctxFor(root, ['src/a.mjs'])) + expect(violations).toEqual([]) + expect(diagnostics).toBeUndefined() + }) + }) }) diff --git a/npm/scripts/lib/docs/resolve-plugins.md b/npm/scripts/lib/docs/resolve-plugins.md index c58fcf2f6..08948e479 100644 --- a/npm/scripts/lib/docs/resolve-plugins.md +++ b/npm/scripts/lib/docs/resolve-plugins.md @@ -3,15 +3,122 @@ type: JS Module title: resolve-plugins.mjs resource: npm/scripts/lib/resolve-plugins.mjs docgen: - crc: 2bac9b49 + crc: 52b1765f + model: omlx/gemma-4-e2b-it-4bit + tier: local-min + score: 40 + issues: internal-name:hasLangSignal,internal-name:listScannableSubdirs,internal-name:readRepositoryUrl,internal-name:hasGithubWorkflows,internal-name:detectCiPlugins,surzhik,best-of-2:retry-error --- -Резолв плагінів @7n/rules: визначає, які пакети-плагіни активні у проєкті, де їхні `rules/`-каталоги, які capabilities вони надають і які handlers надають. +## Огляд -Джерело правди — поле `plugins: string[]` у `.n-rules.json`; воно завжди перекриває автодетект, а явний порожній масив означає «плагіни вимкнено». Якщо поля немає, `detectPluginsFromRepo` шукає файлові сигнали: наявність yml у `.github/workflows/` дає `@7n/rules-ci-github`, файл `azure-pipelines.yml` у корені — `@7n/rules-ci-azure` (реєстр `KNOWN_CI_PLUGINS`). Лише коли файлових CI-сигналів немає, вмикається fallback за `repository.url` кореневого package.json (`github.com` → github, `dev.azure.com`/`visualstudio.com` → azure). Обидва сигнали дають обидва плагіни, жодного — порожній список. Окремо від CI детектяться мовні плагіни (реєстр `KNOWN_LANG_PLUGINS`, лише файлові сигнали, без URL-fallback): кореневий `pyproject.toml` → `@7n/rules-lang-python`. +Огляд: Цей файл забезпечує пошук сигналів-маркерів у файловій системі для визначення активності плагінів. Він використовує BFS-обхід для пошуку цих сигналів, визначає доступні `capabilities`, розв'язує список плагінів з конфігурації, гарантує встановлення необхідних пакетів, обробляє маніфести плагінів та надає інструменти для роботи з їхніми обробниками (`handlers`). -`ensurePluginInstalled` ставить відсутній плагін через `bun add -d` (пакет стає devDependency — зміна видима у diff). Будь-який фейл установки (offline, пакет не опублікований) — warning і graceful skip, ніколи не hard-fail: лінт і синк мають працювати без мережі. +## Поведінка -`resolvePlugins(projectRoot, config, options)` — головна функція: повертає масив `{name, packageRoot, rulesDir, manifest}` доступних плагінів з кешем на процес. `options.allowInstall: false` — hot-path режим (hook, lint): лише вже встановлені пакети, без `bun add`; `options.quiet: true` глушить warning-и (hook викликається на кожен файл). Плагін, що декларує правила (`contributes.rules !== false`), але не має каталогу `rules/`, пропускається як битий; плагін із явним `contributes.rules: false` (лише handlers, як `lang-*`) — легальний, `resolveRulesDirs` його просто не включає у джерела правил. +Перевіряє наявність файлу-сигналу у корені або підтеках до `maxDepth` рівнів. Використовує BFS-обхід, який пропускає приховані директорії, `node_modules`, та `target`, для забезпечення ефективного виклику на hot-path. Повертає `true` при знаходженні сигналу. Викликає `listScannableSubdirs`. -Маніфест — блок `"n-rules"` у package.json плагіна: `capabilities` (масив рядків на кшталт `ci:github`, живлять гейт концернів `requires.capability`) і `contributes.handlers` (мапа extension-point → відносний шлях модуля). `getActiveCapabilities` агрегує capabilities усіх плагінів у Set; `getHandlers(point)` повертає абсолютні шляхи модулів-обробників (перший реальний споживач — taze-оркестратор, extension-point `taze`). `resolveRulesDirs` віддає впорядковані джерела правил: ядро завжди перше (його правила й концерни виграють колізії), далі плагіни у порядку списку. `clearPluginResolveCache` скидає кеш (для тестів). +### listScannableSubdirs +Отримує видимі підтеки для неглибокого скану, ігноруючи приховані та службові директорії. Повертає масив абсолютних шляхів підтеків (порожній, якщо директорія нечитабельна). + +### readRepositoryUrl +Отримує `repository.url` з кореневого `package.json` (як рядок або об'єкт). Повертає `null`, якщо URL відсутній або файл нечитабельний. + +### hasGithubWorkflows +Перевіряє наявність файлів у директорії `.github/workflows/` будь-якого YAML/YML файлу. Повертає `true` при наявності. + +### detectCiPlugins +Автоматично визначає CI-плагіни за станом репозиторію. Пріоритет надається файловим сигналам. Якщо файлових сигналів немає, використовується `repository.url` з кореневого `package.json`. Якщо обидва сигнали присутні, обидва плагіни повертаються. Якщо немає жодного сигналу, повертається порожній масив. Викликає `hasGithubWorkflows` та `readRepositoryUrl`. + +### detectPluginsFromRepo +Автоматично визначає плагіни за станом репозиторію. Використовує файлові сигнали як пріоритет, з fallback на `repository.url` лише для CI-плагінів, коли файлових сигналів немає. Для Rust використовується перевірка до трьох рівнів підтек. Викликає `detectCiPlugins` та `hasLangSignal`. + +### pluginCategory +Визначає категорію плагіна на основі naming convention `@7n/rules--`. Повертає `null` для пакетів, що не відповідають цій конвенції (сторонні/кастомні плагіни) — такий пакет ніколи не з'являється сам через автодетект, а якщо присутній у явному `config.plugins`, вимикає backfill категорій для всього списку. + +### resolvePluginList +Обчислює список плагінів проєкту. Використовує явне поле `config.plugins` з `.n-rules.json` або автоматичний автодетект. Явний пустий масив означає "плагіни вимкнено". Якщо `config.plugins` містить сторонній пакет, який не є `@7n/rules-*`, то backfill для заповнення списку вимикається повністю. + +### computePluginList +Обчислює список плагінів без кешу. Використовує сире значення `config.plugins` з `.n-rules.json`. Повертає список плагінів. Викликає `detectPluginsFromRepo` та `pluginCategory`. + +### ensurePluginInstalled +Гарантує наявність пакета: якщо він відсутній у `node_modules`, виконує `bun add -d ` для додання його як `devDependency`. Фейл повертає `false` з попередженням. + +### readPluginManifest +Отримує нормалізований маніфест плагіна з його `package.json` з блоку `"n-rules"`. + +### resolvePlugins +Виконує повне визначення доступних плагінів проєкту з використанням кешу. Дозволяє встановити вже встановлені пакети через `allowInstall: false` (для hot-path хука), ігнорує `bun add`, ігнорує `quiet` для уникнення попереджень при виклику на кожному файлі. Повертає масив доступних плагінів. + +### resolveRulesDirs +Отримує шляхи до каталогів для всіх поверхонь ядра. Ядро має пріоритет. Надає масив об'єктів, що містять ім'я плагіна, шлях до його директорії та шлях до його кореня пакета. + +### getActiveCapabilities +Отримує набір capability-рядків з усіх доступних плагінів, що використовуються для визначення `requires.capability` у `concern.json`. + +### getDocFilesExtensions +Агрегує розширення файлів, що використовуються для генерації мапи від розширення до типу-мітка. Повертає мапу `extension -> type-мітка`. + +### getUnavailableDeclaredPlugins +Повертає список плагінів, які задекларовані у `config.plugins`, але відсутні у `node_modules`. Нічого не встановлює і нічого не друкує — чистий предикат для explicit CLI-діагностики. + +### getHandlers +Отримує шляхи до модулів-обробників для певного extension-point правила ядра. + +### clearPluginResolveCache +Скидає кеш, призначений для тестів. + +## Публічний API + +- KNOWN_CI_PLUGINS — Відомі CI-плагіни для автовизначення: сигнал у дереві репо → npm-пакет. +- KNOWN_LANG_PLUGINS — Відомі мовні плагіни: файловий сигнал екосистеми → npm-пакет. `maxDepth` — +до якої глибини шукати сигнал: python — лише корінь (uv-провайдер v1 +обробляє тільки кореневий pyproject.toml; js — кореневий package.json); rust — до 3 рівнів, бо в +монорепо Cargo.toml часто вкладений (Tauri `app/src-tauri/Cargo.toml`), +а провайдер обробляє всі знайдені маніфести. +- detectPluginsFromRepo — Автодетект плагінів за станом репозиторію: CI-плагіни (файлові сигнали з +fallback на `repository.url`) + мовні плагіни (лише файлові сигнали — +маніфест екосистеми в корені або, для rust, у підтеках до 3 рівнів; URL-fallback для мов безглуздий). +- pluginCategory — Категорія плагіна за naming convention `@7n/rules--` (напр. `ci`, `lang`). +`null` — пакет поза цією конвенцією (сторонній/кастомний плагін); такий пакет ніколи не +зʼявляється сам через автодетект і, якщо присутній у явному `config.plugins`, вимикає +per-категорійний backfill для всього списку (див. `resolvePluginList`). +- resolvePluginList — Список плагінів проєкту: явний `config.plugins` або автодетект. + +Явний `plugins` непорожній і складається **виключно** з пакетів `@7n/rules--*` — +автодетект домішує лише категорії, відсутні в списку (ADR +`260719-2154-per-category-автодетект-плагінів`); категорія, присутня хоч одним пакетом, +лишається зафіксованою користувачем. Якщо `plugins` містить хоча б один сторонній +(не `@7n/rules-*`) пакет — це сигнал ручного керування, backfill вимикається повністю +(як і раніше: список повертається як є). Явний `[]` — «плагіни вимкнено», без backfill. +Поле відсутнє взагалі — повний автодетект. + +Результат кешується на процес за `(projectRoot, declared)` — виклик з `resolvePlugins` +(через `resolveRulesDirs` тощо) і прямий виклик у sync-CLI інакше дублювали б і файловий +скан, і warning про backfill. + ігнорується при cache hit — warning друкується щонайбільше раз на `(root, declared)` за процес +- ensurePluginInstalled — Гарантує, що плагін встановлений: якщо `node_modules/` нема — `bun add -d ` +(дописує devDependency і ставить). Фейл — warning + false, без винятку. +- resolvePlugins — Повний резолв плагінів проєкту (з кешем на процес). + лише вже встановлені пакети, без `bun add`; `quiet` — без warning-ів (hook на кожен файл) +- resolveRulesDirs — Rules-каталоги для всіх поверхонь ядра: ядро першим (його правила/концерни виграють +колізії), далі плагіни у порядку списку. +- getActiveCapabilities — Активні capabilities від усіх доступних плагінів (для гейта `requires.capability` у concern.json). +- getDocFilesExtensions — Агреговані doc-files-розширення активних плагінів: '.rs' → 'Rust Module' тощо. +Синхронно і без установки (hot-path hook) — лише вже встановлені плагіни. +- getUnavailableDeclaredPlugins — Задекларовані у `config.plugins` пакети, недоступні в `node_modules` (не встановлені). +Не встановлює нічого (`allowInstall: false`) і не друкує — чистий предикат для +explicit CLI-діагностики (напр. doc-files: 0 кандидатів через невстановлений плагін), +яка сама вирішує, коли й де показати попередження. Автодетектовані (не задекларовані +явно) плагіни тут не враховуються — сигнал стосується саме явного `.n-rules.json`. +- getHandlers — Handlers для extension-point правила ядра (v1 — лише API; перший споживач — v2). +- clearPluginResolveCache — Скидає кеш резолву (для тестів). + +## Гарантії поведінки + +- Власних операцій запису (ФС/БД) у файлі немає; виклики імпортованих модулів можуть писати. +- Перехоплює помилки і не пропускає винятків назовні (fail-safe). +- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. +- Кешує результати в межах одного прогону. +- Свідомо пропускає шляхи: `.github`, `.git`, `node_modules`. diff --git a/npm/scripts/lib/resolve-plugins.mjs b/npm/scripts/lib/resolve-plugins.mjs index 83f0db15f..468479880 100644 --- a/npm/scripts/lib/resolve-plugins.mjs +++ b/npm/scripts/lib/resolve-plugins.mjs @@ -401,6 +401,26 @@ export function getDocFilesExtensions(projectRoot, config) { return out } +/** + * Задекларовані у `config.plugins` пакети, недоступні в `node_modules` (не встановлені). + * Не встановлює нічого (`allowInstall: false`) і не друкує — чистий предикат для + * explicit CLI-діагностики (напр. doc-files: 0 кандидатів через невстановлений плагін), + * яка сама вирішує, коли й де показати попередження. Автодетектовані (не задекларовані + * явно) плагіни тут не враховуються — сигнал стосується саме явного `.n-rules.json`. + * @param {string} projectRoot корінь репозиторію + * @param {{ plugins?: unknown } | null | undefined} config розпарсений `.n-rules.json` + * @returns {string[]} npm-імена задекларованих, але не встановлених плагінів (порожньо — усе гаразд) + */ +export function getUnavailableDeclaredPlugins(projectRoot, config) { + const declared = Array.isArray(config?.plugins) + ? config.plugins.filter(p => typeof p === 'string' && p.trim() !== '') + : [] + if (declared.length === 0) return [] + const root = resolve(projectRoot) + const availableNames = new Set(resolvePlugins(root, config, { allowInstall: false, quiet: true }).map(p => p.name)) + return declared.filter(name => !availableNames.has(name)) +} + /** * Handlers для extension-point правила ядра (v1 — лише API; перший споживач — v2). * @param {string} projectRoot корінь репозиторію diff --git a/npm/scripts/lib/tests/resolve-plugins.test.mjs b/npm/scripts/lib/tests/resolve-plugins.test.mjs index 9aca5d637..85e2bcacd 100644 --- a/npm/scripts/lib/tests/resolve-plugins.test.mjs +++ b/npm/scripts/lib/tests/resolve-plugins.test.mjs @@ -12,6 +12,7 @@ import { ensurePluginInstalled, getActiveCapabilities, getHandlers, + getUnavailableDeclaredPlugins, pluginCategory, resolvePluginList, resolvePlugins, @@ -317,6 +318,30 @@ describe('resolvePlugins', () => { }) }) +describe('getUnavailableDeclaredPlugins', () => { + test('задекларований, але не встановлений плагін — у списку, без console.warn', async () => { + await withTmpDir(dir => { + const warn = vi.spyOn(console, 'warn').mockImplementation(noop) + expect(getUnavailableDeclaredPlugins(dir, { plugins: ['@7n/rules-lang-js'] })).toEqual(['@7n/rules-lang-js']) + expect(warn).not.toHaveBeenCalled() + }) + }) + + test('встановлений плагін — не потрапляє у список', async () => { + await withTmpDir(async dir => { + await writeFakePlugin(dir, '@7n/rules-lang-js', {}) + expect(getUnavailableDeclaredPlugins(dir, { plugins: ['@7n/rules-lang-js'] })).toEqual([]) + }) + }) + + test('немає config.plugins — порожній список', async () => { + await withTmpDir(dir => { + expect(getUnavailableDeclaredPlugins(dir, {})).toEqual([]) + expect(getUnavailableDeclaredPlugins(dir, null)).toEqual([]) + }) + }) +}) + describe('ensurePluginInstalled', () => { test('уже встановлений → true без bun add', async () => { await withTmpDir(async dir => {