diff --git a/.cspell.json b/.cspell.json index 562af6cd3..cfd2f1cc1 100644 --- a/.cspell.json +++ b/.cspell.json @@ -28,9 +28,7 @@ "plugins/*/coverage/**", "report" ], - "import": [ - "@nitra/cspell-dict/cspell-ext.json" - ], + "import": ["@nitra/cspell-dict/cspell-ext.json"], "words": [ "aàâä", "AÀÂÄ", @@ -483,6 +481,7 @@ "неіснуючим", "неіснуючу", "неймспейсах", + "непідтриманого", "непідтриманих", "непідтриманої", "непробільним", @@ -748,6 +747,13 @@ "чорновиків", "шейп", "шимі", + "кандидатності", + "мутабельну", + "одиночні", + "плагіновий", + "плагінових", + "релізні", + "верхньорівневого", "яёіїєґ" ] } diff --git a/.jscpd.json b/.jscpd.json index 64bdb64ef..2ad53224a 100644 --- a/.jscpd.json +++ b/.jscpd.json @@ -2,9 +2,7 @@ "gitignore": true, "exitCode": 1, "minLines": 25, - "reporters": [ - "console" - ], + "reporters": ["console"], "ignore": [ ".git/**", ".claude/worktrees/**", @@ -39,6 +37,8 @@ "npm/schemas/target.json", "npm/rules/k8s/hasura_httproute/tests/main.test.mjs", "plugins/lang-js/rules/test/storybook-scaffold/tests/scaffold.test.mjs", - "plugins/lang-js/rules/test/stryker_config/main.mjs" + "plugins/lang-js/rules/test/stryker_config/main.mjs", + "plugins/lang-python/package.json", + "plugins/lang-rust/package.json" ] } diff --git a/.v8rignore b/.v8rignore index 4777ec64f..ab8929513 100644 --- a/.v8rignore +++ b/.v8rignore @@ -13,6 +13,7 @@ npm/schemas/v8r-catalog.json npm/schemas/vendor/** .claude/settings.local.json npm/.claude-template/settings.template.json +npm/.claude-template/codex-hooks.template.json npm/rules/js/tooling/data/tooling/knip-canonical.json npm/rules/js/tooling/data/tooling/oxlint-canonical.json npm/rules/k8s/js/templates/kubescape_exceptions/.kubescape-exceptions.json.snippet.json diff --git a/bun.lock b/bun.lock index e7fe1e716..4341d5558 100644 --- a/bun.lock +++ b/bun.lock @@ -37,7 +37,7 @@ }, "llm-lib": { "name": "@7n/llm-lib", - "version": "2.11.0", + "version": "2.12.0", "bin": { "n-llm-chains-report": "bin/chains-report.mjs", }, @@ -60,13 +60,13 @@ }, "npm": { "name": "@7n/rules", - "version": "1.52.2", + "version": "1.53.0", "bin": { "n-rules": "bin/n-rules.js", "n-cursor": "bin/n-rules.js", }, "dependencies": { - "@7n/llm-lib": "2.10.1", + "@7n/llm-lib": "2.12.0", "@7n/mt": "^0.5.1", "@zed-industries/agent-client-protocol": "^0.4.5", "ajv": "^8.20.0", @@ -100,7 +100,7 @@ }, "plugins/ci-github": { "name": "@7n/rules-ci-github", - "version": "2.0.1", + "version": "2.0.2", "dependencies": { "yaml": "^2.9.0", }, @@ -110,19 +110,18 @@ }, "plugins/lang-js": { "name": "@7n/rules-lang-js", - "version": "0.23.2", + "version": "0.23.3", "dependencies": { "eslint": "^10.4.1", "globby": "^16.0.0", "jscpd": "^5.0.11", "knip": "^6.22.0", "oxc-parser": "^0.137.0", - "oxlint": "^1.68.0", "stylelint": "^17.6.0", }, "peerDependencies": { "@7n/llm-lib": ">=2.8.0", - "@7n/rules": ">=1.27.0", + "@7n/rules": ">=1.52.0", "vue": "^3.0.0", }, "optionalPeers": [ @@ -139,7 +138,7 @@ }, "plugins/lang-python": { "name": "@7n/rules-lang-python", - "version": "0.11.1", + "version": "0.11.2", "dependencies": { "globby": "^16.0.0", "smol-toml": "^1.7.0", @@ -2537,8 +2536,6 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@7n/rules/@7n/llm-lib": ["@7n/llm-lib@2.10.1", "", { "optionalDependencies": { "@7n/llm-lib-darwin-arm64": "2.9.7", "@7n/llm-lib-linux-x64": "2.9.7" }, "peerDependencies": { "@earendil-works/pi-ai": "~0.80.10", "@earendil-works/pi-coding-agent": "~0.80.10" }, "optionalPeers": ["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent"], "bin": { "n-llm-chains-report": "bin/chains-report.mjs" } }, "sha512-vzrSh9M+LYn7ULp8k+pH9zJkc1F1Xk2IaGlk1ybTGbSl2i7C/pDLiFAmZVn0UhCQK1rfvpztoP52ttuEmpgzcQ=="], - "@aws-sdk/credential-provider-http/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.6", "", { "dependencies": { "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-odd+HYx3OLcXRSEz0ZeF3JQdSYdK8QnRgA2N87cPW7coWIbKfRk7a9VQjfeWQLqnzrDLk23KMEn46p8N7M/JFg=="], "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1087.0", "", { "dependencies": { "@aws-sdk/core": "^3.975.2", "@aws-sdk/nested-clients": "^3.997.32", "@aws-sdk/types": "^3.974.1", "@smithy/core": "^3.29.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-umM+qNq16f2fH+VLM5MqXW4ORNQAjk+TOSto73xbUHcKaU41L48j786r3UWQYlejeJk37NlvRYgxBT+MBkfaYQ=="], diff --git a/eslint.config.js b/eslint.config.js index a6fbecc78..148ebaf60 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -79,7 +79,9 @@ export default [ 'npm/scripts/lib/run-rule.mjs', 'npm/tests/fix-mjs-contract.test.mjs', 'npm/tests/check-mjs-contract.test.mjs', - 'npm/rules/test/coverage/coverage.mjs' + 'npm/rules/test/coverage/coverage.mjs', + // Шлях формується лише з discover-нутих rule directories, не з user input. + 'npm/scripts/lib/lint-surface/run-detectors.mjs' ], rules: { 'no-unsanitized/method': 'off' diff --git a/npm/.changes/260728-2145.md b/npm/.changes/260728-2145.md new file mode 100644 index 000000000..0028e5ba2 --- /dev/null +++ b/npm/.changes/260728-2145.md @@ -0,0 +1,5 @@ +--- +bump: patch +section: Changed +--- +fix(ci): resolve repository lint findings diff --git a/npm/.claude-template/codex-hooks.template.json b/npm/.claude-template/codex-hooks.template.json new file mode 100644 index 000000000..a4a9ce52d --- /dev/null +++ b/npm/.claude-template/codex-hooks.template.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "apply_patch", + "hooks": [ + { + "type": "command", + "command": "npx --no @7n/rules hook --post-tool-use", + "timeout": 300 + } + ] + } + ] + } +} diff --git a/npm/AGENTS.template.md b/npm/AGENTS.template.md index 48b2fc931..d06f0a6da 100644 --- a/npm/AGENTS.template.md +++ b/npm/AGENTS.template.md @@ -30,6 +30,10 @@ Generated from the root `package.json` on each `npx @7n/rules` sync. Prefer `bun Before making changes, read the relevant rule files for the area you are working on. +## RTK + +Якщо `rtk` встановлено, використовуй підтримувані rtk wrappers, щоб скорочувати вивід для LLM: `rtk git status`, `rtk rg pattern`, `rtk vitest run`, `rtk npm run build`. Для команд без wrapper-а виконуй оригінальну команду. + ## Інваріант після змін `n-changelog.mdc` (alwaysApply) релевантне після **будь-якої** зміни файлів, не лише для релізу. Перед фінальною відповіддю виконай `npx @7n/rules lint changelog` (exit `0`) і познач результат рядком `Changelog: …` у відповіді. diff --git a/npm/bin/n-rules-cli.mjs b/npm/bin/n-rules-cli.mjs index 771648ad1..415cd2b50 100644 --- a/npm/bin/n-rules-cli.mjs +++ b/npm/bin/n-rules-cli.mjs @@ -1617,6 +1617,7 @@ export async function runSync() { const parts = [] if (result.settings) parts.push('.claude/settings.json') if (result.cursorHooks) parts.push('.cursor/hooks.json') + if (result.codexHooks) parts.push('.codex/hooks.json') if (result.commands.length > 0) parts.push(`${result.commands.length} slash-commands`) if (result.adrHook) parts.push('.claude/hooks/capture-decisions.sh') if (result.adrNormalizeHook) parts.push('.claude/hooks/normalize-decisions.sh') diff --git a/npm/bin/tests/n-rules-cwd.test.mjs b/npm/bin/tests/n-rules-cwd.test.mjs index 837d16f9a..2281a7f10 100644 --- a/npm/bin/tests/n-rules-cwd.test.mjs +++ b/npm/bin/tests/n-rules-cwd.test.mjs @@ -169,6 +169,18 @@ describe('syncClaudeMd / syncAgentsMd (cwd-mock)', () => { }) }) + test('syncAgentsMd додає Codex пряму RTK-інструкцію з bundled шаблону', async () => { + await withTmpDir(async dir => { + cwdState.current = dir + + await syncAgentsMd() + + const text = await readFile(join(dir, 'AGENTS.md'), 'utf8') + expect(text).toContain('використовуй підтримувані rtk wrappers') + expect(text).not.toContain('@RTK.md') + }) + }) + test('syncAgentsMd кидає, коли шаблон відсутній у пакеті', async () => { await withTmpDir(async dir => { cwdState.current = dir diff --git a/npm/rules/doc-files/docgen-files-batch/docs/main.md b/npm/rules/doc-files/docgen-files-batch/docs/main.md index 1c024dfb2..df478cf45 100644 --- a/npm/rules/doc-files/docgen-files-batch/docs/main.md +++ b/npm/rules/doc-files/docgen-files-batch/docs/main.md @@ -3,34 +3,13 @@ type: JS Module title: main.mjs resource: npm/rules/doc-files/docgen-files-batch/main.mjs docgen: - crc: 309bc059 - model: openai-codex/gpt-5.4-mini - tier: cloud-min - score: 75 - issues: internal-name:generateOne,internal-name:runBatchPass,anchor-miss:http://127.0.0.1:8000/v1/,judge-refine:kept-original,judge:inaccurate:0.99 - judgeModel: openai-codex/gpt-5.4-mini + crc: 7531d04c + model: omlx/gemma-4-e2b-it-4bit + tier: local-min + score: 35 + issues: no-overview,short-behavior,internal-name:generateOne,internal-name:runBatchPass,best-of-2:retry-lost --- -## Огляд - -Визначає цілі для оновлення, генерує й синхронізує docs/ з кодом, оновлює directory index і прибирає orphaned docs, які більше не прив’язані до актуальних джерел. Працює як fail-safe: мережеві звернення не виносять винятки назовні, а результати прогону кешуються в межах одного запуску. - -## Поведінка - -Генерація документації стартує з виявлення цілей через selectTargets: у звичайному режимі беруться застарілі або degraded-доки, які ще не отримували повторної спроби для поточної версії джерела; у режимі overwrite обробляються всі. Це робить прогін сходинковим: після невдалої спроби degraded-док більше не чіпається, доки не зміниться джерело, а новий CRC автоматично повертає його в потік. - -runDocFilesGenCli збирає підсумковий сценарій: спершу прибирає сирітські доки, потім запускає генерацію для відібраних цілей, а в кінці оновлює індекс директорії. Якщо прогін зупиняється достроково або через помилки, зроблене лишається на диску з актуальними CRC, тому наступний запуск підхоплює тільки пропущене. - -runGenerationBatch є спільним ядром усієї генерації. Воно бере відібрані цілі, робить preflight для локального бекенда і далі вибирає між послідовним шляхом та batch-шляхом. Якщо доступний native batch-аддон і немає м’якого дедлайну, весь набір іде одним submitBatch, і результати розкладаються назад по файлах. Якщо batch-режим недоступний або примусово вимкнений, обробка йде по одному файлу з fail-safe обробкою помилок та circuit-breaker для системних збоїв підряд. М’який дедлайн підтримується лише на послідовному шляху: перший файл завжди стартує, а наступні зупиняються, коли час вичерпано. Усі стани та лічильники накопичуються в спільній статистиці, а вихідний код відображає лише підсумок прогону. - -nativeBatchAvailable використовується як перемикач між batch і fallback-потоком. Перевірка не виконує LLM-виклику, а лише підтверджує, що native-реалізація доступна; результат кешується в межах прогону, щоб не повторювати однакову перевірку. - -generateDirIndex підтримує актуальний directory index у docs/ після будь-яких змін у наборах доків. Він читає наявні markdown-файли, витягує frontmatter і будує оглядову таблицю лише для реальних документів; сам index.md не чіпається, якщо в директорії більше нічого немає. - -purgeOrphanedDocs прибирає доки, для яких уже немає source-файлів, і після цього синхронізує індекс директорії. Це тримає docs/ у стані, де в ньому лишається тільки те, що ще прив’язане до коду, а порожні директорії очищуються до мінімально можливого стану. - -runDocFilesStampCli працює окремо від генерації: він детерміновано оновлює frontmatter у вже наявних доках без звернення до LLM. Це корисно для міграції та для відновлення метаданих, коли треба синхронізувати source і crc без зміни тексту документа. - ## Публічний API - selectTargets — Цілі генерації: @@ -81,5 +60,5 @@ T8 (2b-batch, рішення Р): коли доступний native-аддон ## Гарантії поведінки -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). +- Містить локальні fail-safe гілки; інші помилки можуть поширюватися назовні. - Кешує результати в межах одного прогону. diff --git a/npm/rules/doc-files/docgen-files-batch/main.mjs b/npm/rules/doc-files/docgen-files-batch/main.mjs index a78999bdd..acc758ea7 100644 --- a/npm/rules/doc-files/docgen-files-batch/main.mjs +++ b/npm/rules/doc-files/docgen-files-batch/main.mjs @@ -273,9 +273,13 @@ async function runBatchPass(targets, root, opts, stats, { reporter, emit }) { // Авторські comments уже є джерелом істини: штампуємо такі документи тут, // не створюючи порожній native batch і не торкаючись LLM. - const llmPrepared = prepared.filter(p => p.mode !== 'comment-only') - for (const p of prepared.filter(p => p.mode === 'comment-only')) { - processBatchResult({ ok: '' }, p, { model, tier: opts.tier ?? null, stats, out }) + const llmPrepared = [] + for (const preparedItem of prepared) { + if (preparedItem.mode === 'comment-only') { + processBatchResult({ ok: '' }, preparedItem, { model, tier: opts.tier ?? null, stats, out }) + } else { + llmPrepared.push(preparedItem) + } } if (llmPrepared.length === 0) return diff --git a/npm/rules/doc-files/docgen-gen/docs/main.md b/npm/rules/doc-files/docgen-gen/docs/main.md index 039d9c77a..83523e955 100644 --- a/npm/rules/doc-files/docgen-gen/docs/main.md +++ b/npm/rules/doc-files/docgen-gen/docs/main.md @@ -3,41 +3,13 @@ type: JS Module title: main.mjs resource: npm/rules/doc-files/docgen-gen/main.mjs docgen: - crc: 7490b45b - model: openai-codex/gpt-5.4-mini - tier: cloud-min - score: 50 - issues: internal-name:isApiGap,internal-name:renderApiLine,internal-name:oneShotDoc,internal-name:finishUnsupported,anchor-miss:(foo.mdc),anchor-miss:(abie.mdc),best-of-2:retry-lost + crc: a5710594 + model: omlx/gemma-4-e2b-it-4bit + tier: local-min + score: 5 + issues: no-overview,short-behavior,internal-name:isApiGap,internal-name:renderApiLine,internal-name:oneShotDoc,internal-name:finishUnsupported,anchor-miss:(foo.mdc),anchor-miss:(abie.mdc) --- -## Огляд - -Файл формує поведінкову документацію з джерела, розділяє захищений контекст і редаговані частини через `splitProtected` та `insertProtected`, а для секції API окремо збирає опис публічних можливостей через `buildApiSection`. Для тексту документації враховуються `capTimeoutToDeadline`, `stripLeadingPreamble`, `scoreDoc`, `hasCompleteCommentDocumentation`, `commentDocumentationMode` і `insertTestScenarios`, а генерація та завершення батча проходять через `DEFAULT_LOCAL_MODEL`, `generateDoc`, `prepareBatchItem` і `finishBatchItem`; кешування діє в межах одного прогону, щоб повторні звернення не дублювали роботу. - -## Поведінка - -`DEFAULT_LOCAL_MODEL` задає локальну модель за замовчуванням, а `generateDoc` використовує її як базовий шлях генерації: спочатку читає джерело й пов’язані факти, потім обирає режим побудови документа, збирає секції, рахує якість і лише після цього вирішує, чи результат достатньо сильний. Якщо документ уже існує, захищений блок зберігається окремо й переноситься в готовий Markdown без втрати змісту. - -Початковий текст моделі очищується stripLeadingPreamble, щоб прибрати чатові згадки моделі про саму себе, а splitProtected та insertProtected зберігають захищену секцію «Призначення» під час усіх перетворень. Це важливо для повторних прогонів: наявний намір із попередньої документації не губиться, навіть якщо решта документа перегенеровується.mdc) мають зберігатися в точному вигляді, бо вони використовуються як дослівні якорі для перевірки відповідності. - -`commentDocumentationMode` вирішує, чи можна довіритися суто авторським коментарям, чи треба додавати LLM-повідомлення, чи переходити в fallback-режим. Для повністю прокоментованого коду це зменшує кількість генерації, а для змішаних файлів зберігає короткий машинний допис там, де людських пояснень недостатньо. `hasCompleteCommentDocumentation` є перевіркою, яка дозволяє взагалі обійти LLM, коли коментарі вже покривають потрібний зміст. - -`splitProtected` і `insertProtected` працюють як парний механізм для секції «Призначення»: перша відокремлює її з наявного документа, друга повертає її назад у фіксоване місце після заголовка. Це дає змогу зберігати важливий людський контекст без залежності від того, який режим генерації був обраний. Те саме спрощує повторні прогони, коли треба оновити решту документа, не чіпаючи цей блок. - -`capTimeoutToDeadline` узгоджує per-call таймаути з глобальним дедлайном, щоб генерація не стартувала, коли бюджет уже вичерпано. Цей контроль використовується на всіх LLM-етапах, тож одна й та сама часова межа діє послідовно для всього прогону. Коли ліміт вичерпано, це поводиться як transient-зрив, щоб система могла спробувати ще раз у наступному проході. - -`stripLeadingPreamble` прибирає чатову «обгортку» на початку відповіді, а `insertProtected` та `buildApiSection` далі працюють уже з очищеним текстом. Такі мета-фрази модель інколи додає перед контентом, і без цього очищення вони потрапили б у секції документа як зайвий шум. Саме тому `buildApiSection` спирається на текст без preamble і на вже збережений захищений блок, щоб зібрати стабільну публічну частину документа. - -`scoreDoc` є завершальним детермінованим фільтром: він звіряє зібраний Markdown із фактами файлу, оцінює повноту змісту й фіксує проблеми, не покладаючись на нову генерацію. Результат цього скорингу впливає на `generateDoc` і `finishBatchItem`: якщо якість нижча за поріг, документ позначається як degraded і може бути перегенерований у наступному проході. Важливі анкори з вихідного файлу мають зберігатися дослівно, а пропуски у верифікації відображаються саме як недостача змісту, а не як зміна структури. - -`buildApiSection` додає до документа секцію про публічний API лише там, де це дійсно потрібно: покриті коментарями експорти описуються без додаткової генерації, а прогалини доповнюються LLM. Це зменшує ризик галюцинацій і робить секцію ближчою до фактичного коду. У результаті API-частина стає частково детермінованою, а частково доповненою лише там, де бракує людського опису. - -`generateDoc` є головним оркестратором одного файлу: він збирає вхід, обирає режим, будує документ, рахує score і повертає Markdown разом із метаданими виконання. Якщо файл підтримує повний коментований шлях, може спрацювати без LLM або з мінімальними викликами; якщо ні — переходить у one-shot або оркестровану генерацію. Усе це працює в межах локального прогону та спирається на кешування в межах цього ж запуску, щоб не робити зайвих повторів. - -`insertTestScenarios` додає окрему секцію з test-сценаріями детерміновано, уже після основного збирання документа. Це потрібно, щоб сценарії не залежали від LLM і не змішувалися з наративом про код. У batch- і one-shot-шляхах ця секція лишається стабільною частиною фінального Markdown. - -`finishBatchItem` завершує той самий життєвий цикл, що й `generateDoc`, але для вже отриманого batch-відповіді. Воно використовує ті ж правила очищення, ту ж структуру секцій і той самий скоринг, щоб batch-результат можна було порівнювати з послідовним шляхом без окремої логіки. - ## Публічний API - capTimeoutToDeadline — Ріже базовий per-call таймаут під залишок бюджету до дедлайну. diff --git a/npm/rules/doc-files/docgen-gen/main.mjs b/npm/rules/doc-files/docgen-gen/main.mjs index 68b6c446f..37d652202 100644 --- a/npm/rules/doc-files/docgen-gen/main.mjs +++ b/npm/rules/doc-files/docgen-gen/main.mjs @@ -97,10 +97,10 @@ const FENCE_CLOSE_RE = /\n?```\s*$/ const EMPTY_INLINE_CODE_RE = /``/g // Порожній code span — артефакт LLM, а не валідний факт про файл. Зрізаємо всю // фразу: інакше після видалення лише `` лишається вигадане твердження довкола. -const EMPTY_INLINE_CODE_SENTENCE_RE = /[^.!?\n]*``[^.!?\n]*[.!?]/g // Внутрішній приклад із коментарів на кшталт `(foo.mdc)` не є поведінкою // модуля. Модель інколи переносить його у prose, тому таку фразу відкидаємо. -const PAREN_MDC_SENTENCE_RE = /[^.!?\n]*\([^()\n]+\.mdc\)[^.!?\n]*[.!?]/g +const PAREN_MDC_RE = /\([^()\n]+\.mdc\)/u +const SENTENCE_BOUNDARY_RE = /(?<=[.!?])(?=\s|$)/u const LEADING_HEADING_RE = /^#{1,6}[ \t]{1,8}[^\n]{0,400}\n{1,8}/ // R9: чат-преамбули малих моделей — «озвучування завдання» перед відповіддю // («Ось оновлена чорнетка секції…», «Як технічний письменник, я створю…», @@ -182,11 +182,19 @@ function stripSection(text) { t = t.replace(FENCE_OPEN_RE, '').replace(FENCE_CLOSE_RE, '').trim() } t = t.replace(LEADING_HEADING_RE, '') // зрізати випадковий заголовок - return stripLeadingPreamble(t.trim()) - .replaceAll(EMPTY_INLINE_CODE_SENTENCE_RE, '') - .replaceAll(PAREN_MDC_SENTENCE_RE, '') - .replaceAll(EMPTY_INLINE_CODE_RE, '') - .trim() + return stripUnsupportedSentences(stripLeadingPreamble(t.trim())).replaceAll(EMPTY_INLINE_CODE_RE, '').trim() +} + +/** + * Відкидає sentence-level LLM артефакти, не застосовуючи backtracking regex до всього тексту. + * @param {string} text очищений текст секції + * @returns {string} текст без речень із порожнім inline-code або внутрішнім `.mdc` прикладом + */ +function stripUnsupportedSentences(text) { + return text + .split(SENTENCE_BOUNDARY_RE) + .filter(sentence => !sentence.includes('``') && !PAREN_MDC_RE.test(sentence)) + .join('') } /** @@ -490,6 +498,7 @@ export function commentDocumentationMode(facts, src) { * @param {object} facts факт-лист мовного екстрактора * @param {string|null} intent захищена секція «Призначення» * @param {string} [behavior] додаткова LLM-секція «Поведінка» для comment+behavior режиму + * @returns {{md:string}} зібрана Markdown-дока */ function commentOnlyDoc(facts, intent, behavior = '') { const sections = { @@ -659,7 +668,6 @@ function semanticEvidence(src) { function behaviorOnlyDocument(md) { const body = h2SectionBody(md, BEHAVIOR_HEADING) return body ? `# Поведінка\n\n## Поведінка\n\n${body}\n` : '' - return match ? `# Поведінка\n\n## Поведінка\n\n${match[1].trim()}\n` : '' } /** @@ -701,7 +709,7 @@ async function judgeRefinePass(r, judge, { facts, anchors, src, score, model, ch async function runJudgeGate({ r, score, issues, facts, anchors, src, model, chain }) { let judge = null try { - judge = { ...(await judgeDoc(semanticEvidence(src, facts), r.md, { chain })), model: JUDGE_MODEL } + judge = { ...(await judgeDoc(semanticEvidence(src), r.md, { chain })), model: JUDGE_MODEL } // №6: суддя назвав конкретні неточності → один локальний refine-прохід // (опт-аут: N_CURSOR_DOCGEN_JUDGE_REFINE=0). Прийнято лише коли всі // guard-и judgeRefinePass пройдені; інакше — degraded, як раніше. @@ -877,6 +885,44 @@ export async function generateDoc( chain.end({ outcome, extra: chainExtra }) } + /** + * Завершує comment-only/comment+behavior режим або повертає null для fallback. + * @param {{md:string}} r поточний документ + * @param {'comment-only'|'comment+behavior'|'fallback'} commentMode режим із коментарів + * @param {string|null} intent захищена секція «Призначення» + * @param {object|null} anchors anchors source-коду + * @returns {Promise} готовий результат або null для fallback-шляху + */ + async function finishCommentMode(r, commentMode, intent, anchors) { + if (commentMode === 'fallback') return null + let { score, issues } = scoreDoc(r.md, facts, { anchors, src }) + let judge = null + if (commentMode === 'comment+behavior') { + const behaviorDoc = behaviorOnlyDocument(r.md) + if (JUDGE_ENABLED && behaviorDoc) { + judge = { ...(await judgeDoc(semanticEvidence(src), behaviorDoc, { chain })), model: JUDGE_MODEL } + if (judgeFailsDoc(judge)) { + r = commentOnlyDoc(facts, intent) + ;({ score, issues } = scoreDoc(r.md, facts, { anchors, src })) + issues.push('behavior-judge-removed') + } + } + } + chainExtra.score = score + chainExtra.degraded = false + return { + ...r, + ms: Date.now() - t0, + llmMs: llmMeter.ms, + llmCalls: llmMeter.calls, + score, + issues, + judge, + degraded: false, + model + } + } + /** * Тіло генерації (замикання над generateDoc-локалами); заповнює chainExtra. * @returns {Promise} результат generateDoc @@ -898,52 +944,12 @@ export async function generateDoc( return finishUnsupported(r, { t0, model, chainExtra }) } + const commentResult = await finishCommentMode(r, commentMode, intent, anchors) + if (commentResult) return commentResult + // Stage 2.5: детермінований скоринг (0 токенів) let { score, issues } = scoreDoc(r.md, facts, { anchors, src }) - // Авторські header/API-коментарі — уже джерело істини. Для comment-only - // LLM не запускається зовсім; comment+behavior судить лише LLM-секцію. - if (commentMode === 'comment-only') { - chainExtra.score = score - chainExtra.degraded = false - return { - ...r, - ms: Date.now() - t0, - llmMs: llmMeter.ms, - llmCalls: llmMeter.calls, - score, - issues, - degraded: false, - model - } - } - - if (commentMode === 'comment+behavior') { - const behaviorDoc = behaviorOnlyDocument(r.md) - let judge = null - if (JUDGE_ENABLED && behaviorDoc) { - judge = { ...(await judgeDoc(semanticEvidence(src), behaviorDoc, { chain })), model: JUDGE_MODEL } - if (judgeFailsDoc(judge)) { - r = commentOnlyDoc(facts, intent) - ;({ score, issues } = scoreDoc(r.md, facts, { anchors, src })) - issues.push('behavior-judge-removed') - } - } - chainExtra.score = score - chainExtra.degraded = false - return { - ...r, - ms: Date.now() - t0, - llmMs: llmMeter.ms, - llmCalls: llmMeter.calls, - score, - issues, - judge, - degraded: false, - model - } - } - // E4: best-of-2 — один retry з вищою температурою, det-вибір кращого if (score < threshold && env.N_CURSOR_DOCGEN_BEST_OF !== '0') { try { @@ -1006,12 +1012,12 @@ export async function prepareBatchItem(file, { existingMd = null, testIndex = nu const anchors = facts.unsupported ? null : extractAnchors(src) const intent = existingMd ? splitProtected(existingMd).body : null const mode = facts.unsupported ? 'fallback' : commentDocumentationMode(facts, src) - const messages = - mode === 'comment-only' - ? [] - : mode === 'comment+behavior' - ? sectionMessages(facts, src, anchors, intent, { complementaryBehavior: true })[0].messages - : oneShotMessages(facts, src) + let messages = [] + if (mode === 'comment+behavior') { + messages = sectionMessages(facts, src, anchors, intent, { complementaryBehavior: true })[0].messages + } else if (mode !== 'comment-only') { + messages = oneShotMessages(facts, src) + } return { facts, anchors, src, mode, messages, intent } } diff --git a/npm/rules/doc-files/docgen-prompts/docs/main.md b/npm/rules/doc-files/docgen-prompts/docs/main.md index fa07a4256..f458f8034 100644 --- a/npm/rules/doc-files/docgen-prompts/docs/main.md +++ b/npm/rules/doc-files/docgen-prompts/docs/main.md @@ -3,36 +3,13 @@ type: JS Module title: main.mjs resource: npm/rules/doc-files/docgen-prompts/main.mjs docgen: - crc: d09eacad - model: openai-codex/gpt-5.4-mini - tier: cloud-min - score: 100 - issues: judge-refine:kept-original,judge:inaccurate:0.98 - judgeModel: openai-codex/gpt-5.4-mini + crc: 4c6c92d5 + model: omlx/gemma-4-e2b-it-4bit + tier: local-min-retry + score: 55 + issues: no-overview,short-behavior,best-of-2:retry-lost --- -## Огляд - -`STYLE` задає спільний тон і межі викладу, а `sectionMessages` і `overviewMessages` узгоджують текст секції з уже відомими фактами. `isApiGap`, `apiGapMessages` і `renderApiLine` допомагають окремо фіксувати прогалини в API, щоб опис лишався чесним і не приписував зайвого. `criticMessages`, `refineMessages`, `oneShotMessages` і `judgeRefineMessages` підтримують послідовне доопрацювання одного й того ж змісту, а `guaranteesFromMarkers` і `UNIT_DIGEST_TOKENS` тримають описи в межах підтверджених маркерів і зведених одиниць. - -## Поведінка - -STYLE задає спільний тон і межі для всього генератора: з нього випливають правила лаконічності, безпечної опори на факти та узгодженості між секціями. - -sectionMessages збирає мінімальний контекст для поведінкових секцій і запускає основний потік: factsSummary дає стислий факт-витяг, anchorsBlock додає лише потрібні анкори, intentContext підключає захищене «Призначення» як read-only фон, а msgs поєднує все у формат одного LLM-виклику. Саме тут зароджується текст секції «Поведінка», і саме тут враховується позначка про complementarity, коли LLM має доповнювати прогалини, а не переписувати вже відоме. - -isApiGap відсікає експорти, які вже мають змістовний JSDoc, від тих, що потребують синтезу; далі цей поділ живить окремий шлях для API, щоб не змішувати покритий текст із прогалинами. renderApiLine використовує лише вже описані експорти і переносить їх у публічний список без перефразування, а apiGapMessages працює тільки з невкритими експортами, щоб LLM не контактував із уже зафіксованим авторським формулюванням. - -overviewMessages формує завершальний узагальнювальний крок: бере вже написану «Поведінку», підкріплює її factsSummary та, за потреби, intentContext, і на цій основі просить Огляд. Це замикає порядок секцій так, щоб Огляд узгоджувався з готовим змістом, а не вигадувався з сирих фактів. - -criticMessages і refineMessages працюють парою як цикл якості для окремих секцій: критик звіряє чорнетку з facts і anchorsBlock та повертає конкретні issues, після чого refineMessages переписує текст тільки на підставі цих зауважень. Завдяки цьому правки залишаються локальними й не розмивають уже добрі частини секції. - -guaranteesFromMarkers не входить у LLM-потік: він детерміновано виводить гарантовані властивості з facts.markers і слугує стабільним шаром, який не залежить від генеративної варіативності. Це тримає критичні твердження поза ризиком галюцинацій. - -oneShotMessages є базовою точкою порівняння для одного суцільного LLM-запиту: до нього потрапляють facts і src без розбиття на секції. buildUnitDigest підміняє сирий src компактним структурним дайджестом, коли великий файл починає розмивати фокус моделі; UNIT_DIGEST_TOKENS обмежує цей режим, щоб зберігати промпт коротким і передбачуваним. - -judgeRefineMessages підхоплює фінальне локальне доопрацювання після judge-перевірки: reason від судді стає єдиною опорою для точкового виправлення doc-тексту без залучення захищеного «Призначення». Увесь потік спирається на спільний кеш у межах одного прогону, щоб повторні звернення до тих самих даних не змінювали результат і не множили витрати. - ## Публічний API - STYLE — Спільний system-стиль для всіх docgen-промптів: вимагає лаконічну поведінкову diff --git a/npm/rules/doc-files/docgen-prompts/main.mjs b/npm/rules/doc-files/docgen-prompts/main.mjs index acd03da68..d77ae3865 100644 --- a/npm/rules/doc-files/docgen-prompts/main.mjs +++ b/npm/rules/doc-files/docgen-prompts/main.mjs @@ -102,11 +102,14 @@ export function sectionMessages(facts, src, anchors = null, intent = null, { com // його іншими словами. Натомість — крос-функціональний наратив: те, чого // немає в жодному окремому JSDoc за визначенням. const exportNames = (facts.exports ?? []).map(e => e.name) - const behaviorTask = complementaryBehavior - ? 'короткі поведінкові абзаци про відсутній користувацький контракт, без алгоритму' - : multi - ? 'крос-функціональний потік: у якому порядку і як функції взаємодіють між собою, звідки приходять дані і куди йдуть результати, спільні правила чи стан. НЕ переказуй кожну функцію окремим пунктом — одно-рядкові описи вже є в секції «Публічний API»' - : 'нумерований алгоритм у бізнес-термінах' + let behaviorTask = 'нумерований алгоритм у бізнес-термінах' + if (multi) { + behaviorTask = + 'крос-функціональний потік: у якому порядку і як функції взаємодіють між собою, звідки приходять дані і куди йдуть результати, спільні правила чи стан. НЕ переказуй кожну функцію окремим пунктом — одно-рядкові описи вже є в секції «Публічний API»' + } + if (complementaryBehavior) { + behaviorTask = 'короткі поведінкові абзаци про відсутній користувацький контракт, без алгоритму' + } const onlyExports = exportNames.length ? ` Описуй РІВНО ці публічні імена і жодних інших: ${exportNames.join(', ')}.` : '' diff --git a/npm/rules/k8s/manifests/main.mjs b/npm/rules/k8s/manifests/main.mjs index fcacaa39b..d3cad56ab 100644 --- a/npm/rules/k8s/manifests/main.mjs +++ b/npm/rules/k8s/manifests/main.mjs @@ -7119,6 +7119,24 @@ async function scanKustomizeK8sDirs(kubectlPath, kubescapePath, kdirs, root, ver return { status: 0, target: null, timedOut: false } } +/** + * Розгортає k8s-корені у raw та kustomize target-и для послідовного сканування. + * @param {string[]} dirs абсолютні шляхи k8s-коренів + * @returns {Promise>} granular scan-target-и + */ +async function collectKubescapeTargets(dirs) { + const targets = [] + for (const dir of dirs) { + const kdirs = await findKustomizationDirs(dir) + if (kdirs.length === 0) { + targets.push({ kind: 'raw', dir }) + } else { + targets.push(...kdirs.map(kustomizeDir => ({ kind: 'kustomize', dir: kustomizeDir }))) + } + } + return targets +} + /** * Оркеструє kubescape-скан по k8s-коренях: kustomize-каталоги збираються, решта — сирий скан. * Виняток C-0056/C-0018 для Job/CronJob — auto-generated з реального контенту кожного скан-таргету @@ -7132,16 +7150,7 @@ async function scanKustomizeK8sDirs(kubectlPath, kubescapePath, kdirs, root, ver export async function runKubescape(dirs, root, verbose = false, opts = {}) { const kubescapePath = opts.kubescapePath ?? ensureTool('kubescape') let kubectlPath = null - /** @type {Array<{ kind: 'raw'|'kustomize', dir: string }>} */ - const targets = [] - for (const d of dirs) { - const kdirs = await findKustomizationDirs(d) - if (kdirs.length === 0) { - targets.push({ kind: 'raw', dir: d }) - } else { - for (const dir of kdirs) targets.push({ kind: 'kustomize', dir }) - } - } + const targets = await collectKubescapeTargets(dirs) let done = 0 let cacheReady = false diff --git a/npm/rules/k8s/tests/run-roots.test.mjs b/npm/rules/k8s/tests/run-roots.test.mjs index d3a4ed34d..0e5ad08bf 100644 --- a/npm/rules/k8s/tests/run-roots.test.mjs +++ b/npm/rules/k8s/tests/run-roots.test.mjs @@ -236,7 +236,7 @@ describe('Kubescape wrapper — bounded scan та granular targets', () => { await withTmpDir(async root => { const calls = [] const result = await runKubescapeManifest('kubescape', 'apiVersion: v1\nkind: ConfigMap\n', root, false, { - spawn: async (...args) => { + spawn: (...args) => { calls.push(args) return { exitCode: null, timedOut: true } } @@ -260,7 +260,7 @@ describe('Kubescape wrapper — bounded scan та granular targets', () => { const result = await runKubescape([k8s], root, false, { kubescapePath: 'kubescape', kubectlPath: '/bin/echo', - spawn: async (...args) => { + spawn: (...args) => { calls.push(args) return { exitCode: 0, timedOut: false } }, diff --git a/npm/scripts/docs/hook.md b/npm/scripts/docs/hook.md index 3f6c2faf6..4fcb34331 100644 --- a/npm/scripts/docs/hook.md +++ b/npm/scripts/docs/hook.md @@ -3,28 +3,45 @@ type: JS Module title: hook.mjs resource: npm/scripts/hook.mjs docgen: - crc: ea16c864 - model: claude-sonnet-4-6 - score: 100 + crc: 3ecffbb6 + model: omlx/gemma-4-e2b-it-4bit + tier: local-min + score: 70 --- -Точка входу для хуків Claude Code. У режимі `--post-tool-use` зчитує `file_path` зі stdin JSON (PostToolUse hook), запускає lint для цього файлу та перевіряє актуальність файлової документації (`doc-files`). У режимі `--stop` визначає змінені файли (`git diff HEAD` + untracked) і запускає lint по всьому робочому дереву. Повертає exit-код у hook-протоколі (ненуль → 2). +## Огляд -## Поведінка +Thin hook entrypoint для Claude Code / Cursor / Codex CLI hooks: зчитує контекст +(stdin / git), делегує в `detectAll` (read-only), перекодовує exit-код у hook-протокол (1 → 2). -`runHookCli` виконує відповідну логіку залежно від переданого режиму: +Режими: + --post-tool-use PostToolUse: шлях(и) зміненого файлу зі stdin JSON — Claude Code + (`tool_input.file_path`, один файл) або Codex CLI (`tool_name: "apply_patch"`, + `tool_input.command` — V4A-патч `*** Begin Patch ... *** End Patch`, можливо + кілька файлів; джерело формату: openai/codex apply_patch_tool_instructions.md). + NB: покриття PostToolUse для `apply_patch` у Codex CLI станом на час написання + задокументовано суперечливо (відкритий issue openai/codex#16732 — "ApplyPatchHandler + doesn't emit PreToolUse/PostToolUse hook event") — на частині версій подія може + не спрацювати взагалі; Stop-hook нижче лишається надійним fallback. + --stop Stop: робоче дерево vs HEAD (`git diff HEAD` + untracked) — payload-незалежний, + працює однаково для Claude Code, Cursor і Codex CLI. -- **`--post-tool-use`**: читає stdin JSON → витягує `tool_input.file_path` → запускає `runLint` для цього файлу (read-only, усі per-file правила включно з `doc-files`) → повертає 2 при порушеннях. -- **`--stop`**: визначає всі змінені файли через `collectChangedFiles` → запускає `runLint` (read-only) → повертає 2 при порушеннях. +## Публічний API -Якщо `file_path` відсутній у stdin або stdin порожній — завершується з кодом 0 (нема що перевіряти). +- extractCodexPatchPaths — Дістає шляхи файлів з V4A-патча Codex CLI apply_patch (`*** Begin Patch ... *** End Patch`). +`Update File` з наступним рядком `Move to` рахує лише фінальний (перейменований) шлях; +`Delete File` пропускається — файла більше нема, лінтити нічого. +- extractFilePaths — Дістає шлях(и) зміненого файлу зі stdin JSON PostToolUse hook — Claude Code +(`tool_input.file_path`, один файл) або Codex CLI (`tool_name: "apply_patch"`, +`tool_input.command` — V4A-патч, можливо кілька файлів). Інші інструменти (Bash/Shell тощо) +не мають жодного з цих полів — повертає порожній масив (нема що лінтити). +- runHookCli — CLI для `n-rules hook`. -## Публічний API +## Сценарії використання -- `extractFilePath(json)` — витягує `tool_input.file_path` зі stdin JSON Claude Code PostToolUse hook; повертає `null` якщо JSON відсутній або поле не знайдено. -- `runHookCli(argv)` — CLI-точка входу для `n-rules hook`; повертає Promise (0 — чисто, 1 — невідомий режим, 2 — є порушення). +- `npm/scripts/tests/hook.test.mjs` (extractCodexPatchPaths; extractFilePaths) — Add File — повертає шлях нового файлу; Update File — повертає шлях без Move to; Update File + Move to — рахує лише фінальний (перейменований) шлях; Delete File — пропускається (нема що лінтити); декілька файлових секцій в одному патчі; ще 7 ## Гарантії поведінки -- Read-only: не виконує операцій запису (ФС/БД). -- Не кидає винятків назовні: помилки stdin та parse-помилки повертають null/0. +- Власних операцій запису (ФС/БД) у файлі немає; виклики імпортованих модулів можуть писати. +- Містить локальні fail-safe гілки; інші помилки можуть поширюватися назовні. diff --git a/npm/scripts/docs/sync-claude-config.md b/npm/scripts/docs/sync-claude-config.md index a2ed258a5..941a69c56 100644 --- a/npm/scripts/docs/sync-claude-config.md +++ b/npm/scripts/docs/sync-claude-config.md @@ -3,87 +3,98 @@ type: JS Module title: sync-claude-config.mjs resource: npm/scripts/sync-claude-config.mjs docgen: - crc: 1ae220d3 - model: omlx/gemma-4-e4b-it-OptiQ-4bit - score: 85 + crc: c1eda647 + model: omlx/gemma-4-e2b-it-4bit + tier: local-min + score: 35 + issues: no-overview,short-behavior,anchor-miss:LEGACY_PACKAGE_HOOK_COMMAND_MARKER,anchor-miss:LEGACY_POST_TOOL_USE_HOOK_COMMAND_MARKER,anchor-miss:LEGACY_POST_TOOL_USE_FIX_HOOK_COMMAND_MARKER,anchor-miss:DOC_FILES_HOOK_COMMAND_MARKER,best-of-2:retry-lost --- -Синхронізує конфігурацію Claude Code (`.claude/settings.json`, slash-команди з `commands/` темплейту, ADR Stop-hook) та Cursor hooks (`.cursor/hooks.json`) у поточний проєкт із темплейтів пакету `npm/.claude-template/`. Здійснює злиття конфігурацій: користувацькі поля зберігаються у `.claude/settings.json`, а дозволи (`permissions.allow`) зливаються через union. Керовані хуки, ідентифіковані командою-маркером `MANAGED_HOOK_COMMAND_MARKERS`, перезаписуються. Копіює ADR Stop-hook (`.claude/hooks/capture-decisions.sh`) та ADR normalize Stop-hook (`.claude/hooks/normalize-decisions.sh`) залежно від налаштувань у `.n-rules.json`. Також зливає фрагмент `.gitignore` з канонічного шаблону, додаючи необхідні записи для ADR. За правилом `local-ai` керує rtk-інтеграцією (fail-open, працює лише за наявності бінарника rtk): PreToolUse hook `rtk hook claude` у `.claude/settings.json`, preToolUse entry `rtk hook cursor` у `.cursor/hooks.json` і vendored pi-extension `.pi/extensions/rtk.ts`; вимкнення правила прибирає записи та видаляє extension. +## Публічний API -## Поведінка +- MANAGED_HOOK_COMMAND_MARKER — Маркер hook-ів пакета (`hook --post-tool-use`, `hook --stop`). +- LEGACY_STOP_HOOK_COMMAND_MARKER — Legacy-маркер старого Stop-hook'а — лишаємо для cleanup-у при оновленні існуючих інсталяцій. +- ADR_HOOK_COMMAND_MARKER — Маркер ADR Stop-hook'а — підрядок шляху до bash-скрипта capture-decisions. +- ADR_NORMALIZE_HOOK_COMMAND_MARKER — Маркер ADR Stop-hook'а — підрядок шляху до bash-скрипта normalize-decisions. +- CURSOR_ADR_HOOK_COMMAND_MARKER — Маркер Cursor ADR Stop-hook'а — той самий script path, але в `.cursor/hooks.json`. +- CURSOR_ADR_NORMALIZE_HOOK_COMMAND_MARKER — Маркер Cursor ADR Normalize Stop-hook'а — той самий script path, але в `.cursor/hooks.json`. +- RTK_CLAUDE_HOOK_COMMAND_MARKER — Маркер rtk PreToolUse hook'а у `.claude/settings.json` (правило `local-ai`). +- RTK_CURSOR_HOOK_COMMAND_MARKER — Маркер rtk preToolUse hook'а у `.cursor/hooks.json` (правило `local-ai`). +- MANAGED_HOOK_COMMAND_MARKERS — Усі маркери managed-hook'ів пакета — за ними відрізняємо свої записи від користувацьких. +Legacy stop-hook включений сюди, щоб старі entries автоматично видалялись при наступному sync-у. +- PI_DIR — Корінь pi.dev артефактів у проєкті-споживачі. +- PI_EXTENSIONS_DIR — Директорія pi.dev TS-extensions у проєкті-споживачі. +- PI_TEMPLATE_DIR_NAME — Назва bundled-директорії pi-template у пакеті `@7n/rules`. +- PI_EXTENSION_NAME — Імʼя bundled pi-extension'а для ADR capture/normalize. +- ADR_GITIGNORE_SNIPPET_REL — Відносний шлях до канонічного фрагмента `.gitignore` для ADR Stop-hook'ів у tarball пакета. +- mergeAllowList — Зливає список allow-permissions: union існуючого і темплейтного без дублікатів, +порядок — спочатку існуючі (щоб не міняти користувацький порядок), потім нові. +- mergeHooks — Зливає hooks-секцію. Для **кожної події** з обох сторін: + 1) видаляємо managed-групи з існуючої конфігурації (їх ідентифікують маркери з + `MANAGED_HOOK_COMMAND_MARKERS`, включно з legacy-маркерами — це автоматично + прибирає застарілі hook'и при переході на нову версію темплейту); + 2) дописуємо managed-групи з темплейту. +Перебір union-у подій важливий: коли пакет переносить hook між подіями (напр. `Stop` +→ `PostToolUse`), старі managed entries у вже-непотрібній події теж мають піти. +- mergeSettings — Повертає об'єднаний об'єкт settings.json. +- mergeCursorHooksConfig — Зливає `.cursor/hooks.json`: користувацькі entries зберігаються, managed ADR +entries у `hooks.stop` перезаписуються або видаляються залежно від `includeAdrHook`; +managed rtk entry у `hooks.preToolUse` — залежно від `includeLocalAiHook`. +- syncCursorHooksConfig — Синхронізує `.cursor/hooks.json` для Cursor Agent hooks (ADR stop + rtk preToolUse). +Cursor читає project-level config з `.cursor/hooks.json`; hook scripts лишаються +спільними з Claude Code у `.claude/hooks/`. +- mergeCodexHooks — Зливає hooks-секцію `.codex/hooks.json`. Формат ідентичний `.claude/settings.json.hooks` +(matcher + hooks[] з type/command/timeout — підтверджено `vendor/codex-hooks.json`), тож +перевикористовує ту саму {@link mergeHooks}. +- syncCodexHooksConfig — Синхронізує `.codex/hooks.json` для Codex CLI: базовий PostToolUse lint-hook (правило- +незалежний, з темплейту `codex-hooks.template.json`, matcher `apply_patch` — best-guess, +див. JSDoc {@link ../../hook.mjs}) + опційні ADR Stop-hook групи. Codex не має rtk hook: +інтеграція rtk відбувається через пряму інструкцію в AGENTS.md. +На відміну від `.claude/settings.json` тут немає секції `permissions` — Codex тримає +дозволи окремо в `config.toml`. +- syncClaudeSettings — Синхронізує `.claude/settings.json` за темплейтом, зберігаючи решту +користувацьких полів. +- syncAdrHookScript — Копіює канонічний `.claude/hooks/capture-decisions.sh` з темплейту пакета. +- syncAdrNormalizeHookScript — Копіює канонічний `.claude/hooks/normalize-decisions.sh` з темплейту пакета. +- syncAdrHookLibScripts — Копіює всі `.sh`-файли з `.claude-template/hooks/lib/` у `.claude/hooks/lib/` проєкту. +Файли source-only (без exec bit) — їх `source`-ять capture/normalize-decisions.sh, +щоб не дублювати спільну bash-логіку (`is_tooling_only_change`, +`git_diff_only_version_field`). +Тека fully-owned: при кожному sync-у перезаписується. +- removeOrphanAdrHookLib — Видаляє `.claude/hooks/lib/` директорію з проєкту-споживача. +Викликається коли правило `adr` вимкнено — lib-файли не самостійні, без хуків, +що їх source-ять, вони нікому не потрібні (симетрично до `removeOrphanPiExtension`). +- syncPiExtensions — Копіює bundled pi.dev TS-extension `npm/.pi-template/extensions/n-rules-adr/` (усі файли — +`index.ts`, `tsconfig.json`, потенційні `package.json`/`.gitignore` тощо) у +`.pi/extensions/n-rules-adr/` проєкту-споживача (legacy `n-cursor-adr/` видаляється). Тека fully-owned: при кожному sync-у +перезаписується. Якщо bundled template відсутній (legacy-версії пакета без `.pi-template/`) +або в ньому немає `index.ts` — повертаємо `{written: false}` без помилки. -MANAGED_HOOK_COMMAND_MARKER — Визначає маркер актуальних hook-ів пакета (`hook --post-tool-use`, `hook --stop`). -LEGACY_POST_TOOL_USE_HOOK_COMMAND_MARKER — Визначає маркер застарілого хука `post-tool-use-check` для cleanup при ресинку. -LEGACY_POST_TOOL_USE_FIX_HOOK_COMMAND_MARKER — Визначає маркер ще старішої мутуючої команди `post-tool-use-fix` для cleanup при ресинку. -DOC_FILES_HOOK_COMMAND_MARKER — Визначає маркер для хука `lint-doc-files`. -LEGACY_DOC_FILES_HOOK_COMMAND_MARKER — Визначає маркер для застарілого хука `doc-files check`. -LEGACY_STOP_HOOK_COMMAND_MARKER — Визначає маркер для застарілого Stop-hook'а. -ADR_HOOK_COMMAND_MARKER — Визначає маркер шляху до bash-скрипта ADR capture Stop-hook. -ADR_NORMALIZE_HOOK_COMMAND_MARKER — Визначає маркер шляху до bash-скрипта ADR normalize Stop-hook. -CURSOR_ADR_HOOK_COMMAND_MARKER — Визначає маркер шляху до bash-скрипта ADR capture Stop-hook у `.cursor/hooks.json`. -CURSOR_ADR_NORMALIZE_HOOK_COMMAND_MARKER — Визначає маркер шляху до bash-скрипта ADR normalize Stop-hook у `.cursor/hooks.json`. -RTK_CLAUDE_HOOK_COMMAND_MARKER — Визначає маркер rtk PreToolUse hook'а (`rtk hook claude`) у `.claude/settings.json` (правило `local-ai`). -RTK_CURSOR_HOOK_COMMAND_MARKER — Визначає маркер rtk preToolUse hook'а (`rtk hook cursor`) у `.cursor/hooks.json` (правило `local-ai`). -MANAGED_HOOK_COMMAND_MARKERS — Містить список усіх маркерів, що ідентифікують керовані хуки пакета. -PI_DIR — Визначає кореневу директорію для артефактів pi.dev у проєкті-споживачі. -PI_EXTENSIONS_DIR — Визначає директорію для TS-extensions pi.dev у проєкті-споживачі. -PI_TEMPLATE_DIR_NAME — Визначає назву директорії з темплейтом pi-template у пакеті. -PI_EXTENSION_NAME — Визначає ім'я bundled pi-extension для ADR capture/normalize. -ADR_GITIGNORE_SNIPPET_REL — Визначає відносний шлях до канонічного фрагмента `.gitignore` для ADR Stop-hook'ів у tarball пакета. -mergeAllowList — Об'єднує списки дозволених дозволів, зберігаючи користувацькі записи першими. -mergeHooks — Зливає секцію `hooks` з `.claude/settings.json`, видаляючи керовані групи з існуючої конфігурації. -mergeSettings — Зливає конфігурацію `.claude/settings.json` з темплейту, зберігаючи користувацькі поля та оновлюючи керовані хуки. -mergeCursorHooksConfig — Зливає конфігурацію `.cursor/hooks.json`, зберігаючи користувацькі записи та додаючи/видаляючи керовані ADR stop entries і rtk preToolUse entry. -syncCursorHooksConfig — Синхронізує `.cursor/hooks.json` для Cursor Agent hooks, додаючи ADR stop і rtk preToolUse entries за умовою. -syncClaudeSettings — Синхронізує `.claude/settings.json` за темплейтом, зберігаючи користувацькі налаштування. -syncAdrHookScript — Копіює канонічний bash-скрипт ADR capture Stop-hook з темплейту пакета у `.claude/hooks/`. -syncAdrNormalizeHookScript — Копіює канонічний bash-скрипт ADR normalize Stop-hook з темплейту пакета у `.claude/hooks/`. -syncAdrHookLibScripts — Копіює всі bash-скрипти з `lib/` темплейту у `.claude/hooks/lib/` проєкту. -removeOrphanAdrHookLib — Видаляє директорію `.claude/hooks/lib/` з проєкту, якщо ADR-хуки вимкнені. -syncPiExtensions — Копіює bundled pi.dev TS-extension `n-rules-adr` у `.pi/extensions/n-rules-adr/` проєкту. -removeOrphanPiExtension — Видаляє директорію `.pi/extensions/n-rules-adr/` з проєкту, якщо ADR-хуки вимкнені. -RTK_PI_EXTENSION_FILE — Визначає ім'я файлу rtk pi-extension (`rtk.ts`) — шлях спільний із `rtk init --agent pi`. -syncRtkPiExtension — Копіює vendored rtk pi-extension у `.pi/extensions/rtk.ts` проєкту (правило `local-ai`). -removeOrphanRtkPiExtension — Видаляє `.pi/extensions/rtk.ts` з проєкту, якщо правило `local-ai` вимкнене. -syncGitignoreAdrFragment — Дописує відсутні рядки з канонічного ADR-фрагмента до кореневого `.gitignore` проєкту. -syncClaudeCommands — Копіює всі slash-команди з `.claude-template/commands/` у `.claude/commands/` проєкту. -syncClaudeConfig — Виконує повну синхронізацію Claude Code-конфігу, включаючи ADR-хуки, `.gitignore` та pi-extension, залежно від правил. +Розширення поверх `index.ts` (tsconfig тощо) потрібні, бо `.pi/extensions/` синхронізується як є +у проєкти-споживачі, а IDE/TS-сервер мусить резолвити `node:*` модулі без додаткових +project-wide конфігів. +- removeOrphanPiExtension — Видаляє `.pi/extensions/n-rules-adr/` (і legacy `n-cursor-adr/`) директорію з проєкту-споживача. +Викликається коли правило `adr` вимкнено у `.n-rules.json` (симетрично до +cleanup-у `.claude/hooks/{capture,normalize}-decisions.sh`). +- RTK_PI_EXTENSION_FILE — Ім'я файлу rtk pi-extension — той самий шлях, що пише `rtk init --agent pi` (повторна установка ідемпотентна). +- syncRtkPiExtension — Копіює vendored rtk pi-extension `npm/.pi-template/extensions/rtk.ts` у +`.pi/extensions/rtk.ts` проєкту-споживача (правило `local-ai`). Файл fully-owned: +при кожному sync-у перезаписується. Якщо bundled template відсутній — `{written: false}`. +- removeOrphanRtkPiExtension — Видаляє `.pi/extensions/rtk.ts` з проєкту-споживача. Викликається, коли правило +`local-ai` вимкнено у `.n-rules.json` (симетрично до cleanup-у hook-записів). +Шлях спільний з `rtk init --agent pi`, тож прибереться і встановлений вручну файл. +- syncGitignoreAdrFragment — Дописує в кореневий `.gitignore` проєкту відсутні рядки з канонічного ADR-фрагмента. +- syncClaudeCommands — Копіює всі slash-команди з `templateDir/commands/` у `.claude/commands/`. +Команди ідентифікуються тим, що вони лежать у темплейті — не перетинаються +з командами скілів (n-fix, n-lint, ...). +- syncClaudeConfig — Виконує повну синхронізацію Claude Code-конфігу з темплейту пакету в проєкт. +Використовується з `bin/n-rules.js` після інших синків. -## Публічний API +## Сценарії використання -MANAGED_HOOK_COMMAND_MARKER — Позначає актуальні hook-и пакета. -LEGACY_POST_TOOL_USE_HOOK_COMMAND_MARKER — Позначає застарілий хук `post-tool-use-check`, який прибирається при ресинку. -LEGACY_POST_TOOL_USE_FIX_HOOK_COMMAND_MARKER — Позначає застарілу мутуючу команду `post-tool-use-fix`, яка прибирається при ресинку. -DOC_FILES_HOOK_COMMAND_MARKER — Позначає хук для перевірки актуальності файлів документації. -LEGACY_DOC_FILES_HOOK_COMMAND_MARKER — Позначає старий маркер для очищення хуків документації. -LEGACY_STOP_HOOK_COMMAND_MARKER — Позначає старий маркер для очищення хука зупинки. -ADR_HOOK_COMMAND_MARKER — Позначає шлях до скрипта для фіксації рішень ADR. -ADR_NORMALIZE_HOOK_COMMAND_MARKER — Позначає шлях до скрипта для нормалізації рішень ADR. -CURSOR_ADR_HOOK_COMMAND_MARKER — Позначає шлях до скрипта фіксації ADR у конфігурації Cursor. -CURSOR_ADR_NORMALIZE_HOOK_COMMAND_MARKER — Позначає шлях до скрипта нормалізації ADR у конфігурації Cursor. -MANAGED_HOOK_COMMAND_MARKERS — Групує всі маркери хуків, щоб відрізнити автоматичні записи від користувацьких. -PI_DIR — Місце зберігання артефактів pi.dev у проєкті. -PI_EXTENSIONS_DIR — Директорія для TS-розширень pi.dev у проєкті. -PI_TEMPLATE_DIR_NAME — Назва папки з шаблоном pi у пакеті. -PI_EXTENSION_NAME — Ім'я пакета pi-розширення для фіксації/нормалізації ADR. -ADR_GITIGNORE_SNIPPET_REL — Відносний шлях до шаблону `.gitignore` для ADR. -mergeAllowList — Об'єднує дозволені права доступу, зберігаючи порядок існуючих записів. -mergeHooks — Об'єднує секцію хуків: видаляє автоматичні записи з існуючої конфігурації та додає записи з шаблону. -mergeSettings — Створює об'єднаний файл налаштувань. -mergeCursorHooksConfig — Об'єднує конфігурацію хуків Cursor, зберігаючи користувацькі записи та оновлюючи ADR-записи. -syncCursorHooksConfig — Синхронізує конфігурацію зупинки для агента Cursor. -syncClaudeSettings — Копіює налаштування Claude Code з шаблону, зберігаючи користувацькі дані. -syncAdrHookScript — Копіює канонічний скрипт фіксації рішень ADR. -syncAdrNormalizeHookScript — Копіює канонічний скрипт нормалізації рішень ADR. -syncAdrHookLibScripts — Копіює всі допоміжні скрипти з шаблону в проєкт. -removeOrphanAdrHookLib — Видаляє директорію з допоміжними скриптами ADR, якщо правило ADR вимкнено. -syncPiExtensions — Копіює TS-розширення pi.dev у проєкт. -removeOrphanPiExtension — Видаляє директорію з розширення pi, якщо правило ADR вимкнено. -syncGitignoreAdrFragment — Додає необхідні рядки з канонічного фрагмента `.gitignore` до кореня проєкту. -syncClaudeCommands — Копіює всі slash-команди з шаблону в директорію команд Claude. -syncClaudeConfig — Виконує повну синхронізацію конфігурації Claude Code з шаблону. +- `npm/scripts/tests/sync-claude-config.test.mjs` (mergeAllowList; mergeHooks) — union без дублікатів, порядок: спочатку існуючі; обробляє undefined по обидва боки; видаляє managed-групу (у т.ч. legacy; зберігає користувацькі групи поряд з managed; legacy Stop-hook (; ще 43 ## Гарантії поведінки -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). +- Містить локальні fail-safe гілки; інші помилки можуть поширюватися назовні. - Свідомо пропускає шляхи: `.git`. diff --git a/npm/scripts/hook.mjs b/npm/scripts/hook.mjs index cc065c359..5b70e1e38 100644 --- a/npm/scripts/hook.mjs +++ b/npm/scripts/hook.mjs @@ -1,10 +1,18 @@ /** - * Thin hook entrypoint для Claude Code hooks: зчитує контекст (stdin / git), - * делегує в `detectAll` (read-only), перекодовує exit-код у hook-протокол (1 → 2). + * Thin hook entrypoint для Claude Code / Cursor / Codex CLI hooks: зчитує контекст + * (stdin / git), делегує в `detectAll` (read-only), перекодовує exit-код у hook-протокол (1 → 2). * * Режими: - * --post-tool-use PostToolUse: file_path зі stdin JSON Claude Code. - * --stop Stop: робоче дерево vs HEAD (`git diff HEAD` + untracked). + * --post-tool-use PostToolUse: шлях(и) зміненого файлу зі stdin JSON — Claude Code + * (`tool_input.file_path`, один файл) або Codex CLI (`tool_name: "apply_patch"`, + * `tool_input.command` — V4A-патч `*** Begin Patch ... *** End Patch`, можливо + * кілька файлів; джерело формату: openai/codex apply_patch_tool_instructions.md). + * NB: покриття PostToolUse для `apply_patch` у Codex CLI станом на час написання + * задокументовано суперечливо (відкритий issue openai/codex#16732 — "ApplyPatchHandler + * doesn't emit PreToolUse/PostToolUse hook event") — на частині версій подія може + * не спрацювати взагалі; Stop-hook нижче лишається надійним fallback. + * --stop Stop: робоче дерево vs HEAD (`git diff HEAD` + untracked) — payload-незалежний, + * працює однаково для Claude Code, Cursor і Codex CLI. */ import { once } from 'node:events' import { relative, resolve } from 'node:path' @@ -46,19 +54,58 @@ async function readStdin() { return chunks.join('') } +const V4A_ADD_FILE_RE = /^\*\*\* Add File: (.+)$/u +const V4A_UPDATE_FILE_RE = /^\*\*\* Update File: (.+)$/u +const V4A_MOVE_TO_RE = /^\*\*\* Move to: (.+)$/u +const EOL_RE = /\r?\n/u + /** - * Дістає `tool_input.file_path` зі stdin JSON Claude Code PostToolUse hook. + * Дістає шляхи файлів з V4A-патча Codex CLI apply_patch (`*** Begin Patch ... *** End Patch`). + * `Update File` з наступним рядком `Move to` рахує лише фінальний (перейменований) шлях; + * `Delete File` пропускається — файла більше нема, лінтити нічого. + * @param {string} patch сирий текст патча з `tool_input.command` + * @returns {string[]} шляхи файлів для лінту (може бути порожньо — патч лише видаляє файли) + */ +export function extractCodexPatchPaths(patch) { + const lines = patch.split(EOL_RE) + const paths = [] + for (let i = 0; i < lines.length; i++) { + const addMatch = V4A_ADD_FILE_RE.exec(lines[i]) + if (addMatch) { + paths.push(addMatch[1].trim()) + continue + } + const updateMatch = V4A_UPDATE_FILE_RE.exec(lines[i]) + if (updateMatch) { + const moveMatch = V4A_MOVE_TO_RE.exec(lines[i + 1] ?? '') + paths.push((moveMatch || updateMatch)[1].trim()) + } + } + return paths +} + +/** + * Дістає шлях(и) зміненого файлу зі stdin JSON PostToolUse hook — Claude Code + * (`tool_input.file_path`, один файл) або Codex CLI (`tool_name: "apply_patch"`, + * `tool_input.command` — V4A-патч, можливо кілька файлів). Інші інструменти (Bash/Shell тощо) + * не мають жодного з цих полів — повертає порожній масив (нема що лінтити). * @param {string} json сирий stdin - * @returns {string|null} шлях до файлу або null, якщо відсутній/невалідний JSON + * @returns {string[]} шляхи файлів (порожньо — невалідний JSON, не file-edit tool, або лише видалення) */ -export function extractFilePath(json) { - if (!json) return null +export function extractFilePaths(json) { + if (!json) return [] + let parsed try { - const fp = JSON.parse(json)?.tool_input?.file_path - return typeof fp === 'string' && fp !== '' ? fp : null + parsed = JSON.parse(json) } catch { - return null + return [] + } + const fp = parsed?.tool_input?.file_path + if (typeof fp === 'string' && fp !== '') return [fp] + if (parsed?.tool_name === 'apply_patch' && typeof parsed?.tool_input?.command === 'string') { + return extractCodexPatchPaths(parsed.tool_input.command) } + return [] } /** @@ -88,9 +135,10 @@ export async function runHookCli(argv) { } if (postToolUse) { - const fp = extractFilePath(await readStdin()) - if (!fp) return 0 - const { exitCode } = await detectAll({ files: [toRelativePosix(fp, cwd)], cwd, log: logToStderr }) + const paths = extractFilePaths(await readStdin()) + if (paths.length === 0) return 0 + const files = paths.map(fp => toRelativePosix(fp, cwd)) + const { exitCode } = await detectAll({ files, cwd, log: logToStderr }) return exitCode === 0 ? 0 : 2 } diff --git a/npm/scripts/lib/concern-meta.mjs b/npm/scripts/lib/concern-meta.mjs index 915a6226b..e18babb00 100644 --- a/npm/scripts/lib/concern-meta.mjs +++ b/npm/scripts/lib/concern-meta.mjs @@ -134,7 +134,7 @@ export async function readConcernMeta(concernDir, name) { requiresCapability, fixability: parseFixability(raw.fixability), skipLocalTier: raw.skipLocalTier === true, - cloudTimeoutMs: Number.isInteger(raw.cloudTimeoutMs) && raw.cloudTimeoutMs > 0 ? raw.cloudTimeoutMs : undefined + cloudTimeoutMs: Number.isSafeInteger(raw.cloudTimeoutMs) && raw.cloudTimeoutMs > 0 ? raw.cloudTimeoutMs : undefined } } diff --git a/npm/scripts/lib/docs/concern-meta.md b/npm/scripts/lib/docs/concern-meta.md index 4bd832d97..2c7c5df20 100644 --- a/npm/scripts/lib/docs/concern-meta.md +++ b/npm/scripts/lib/docs/concern-meta.md @@ -3,29 +3,29 @@ type: JS Module title: concern-meta.mjs resource: npm/scripts/lib/concern-meta.mjs docgen: - crc: ccc325b1 - model: manual + crc: d452f9b7 + model: omlx/gemma-4-e2b-it-4bit + tier: local-min + score: 80 --- ## Огляд -Цей модуль зчитує та валідує схему конфігурації, визначену у `concern.json`. Він надає функції для отримання метаданих concern-ів та списку доступних concern-ів. Модуль працює в режимі fail-safe: при виявленні помилок він не генерує винятків, а повертає `null` замість них. +Парсер і нормалізатор `concern.json`. Єдине місце де читається і валідується схема concern-а. -## Поведінка +## Публічний API -Поведінка -readConcernMeta зчитує і перевіряє файл concern.json у вказаній директорії concern-а, повертаючи метадані або null, якщо файл відсутній чи не валідний. -listConcerns сканує директорію правил і повертає список усіх знайдених concern-ів у алфавітному порядку, ігноруючи каталоги без concern.json. -Нормалізований meta несе `fixability` (`code`|`config`|`structural`); невідоме/відсутнє значення зводиться до `code` — дефолт, за яким concern лишається eligible для LLM-fix-ladder. -Нормалізований meta несе також `skipLocalTier` (boolean, дефолт `false`): `true` — concern пропускає local-min/local-min-retry rung-и LLM-ladder-а, перша спроба одразу йде на cloud-min. Для concern-ів, де local-tier емпірично майже завжди лише витрачає бюджет rung-а без результату (напр. `js/eslint`). +- readConcernMeta — Читає і нормалізує `concern.json` з каталогу. +Повертає `null` якщо файл відсутній або не валідний. +- listConcerns — Сканує підкаталоги `ruleDir` і повертає всі concern-и (у алфавітному порядку). +Каталоги без `concern.json` ігноруються. -## Публічний API +## Сценарії використання -readConcernMeta — Зчитує та уніфікує налаштування з `concern.json` у каталозі; повертає відсутність або недійсність. -listConcerns — Повертає список усіх визначених concern-ів з підкаталогів `ruleDir` у алфавітному порядку, ігноруючи каталоги без `concern.json`. +- `npm/scripts/lib/tests/concern-meta.test.mjs` (concern-meta — policy.engine derivation; concern-meta — lint surface) — явний engine:; legacy check:; legacy без engine/check (Rego) → engine:; lint scope/glob нормалізується (string → array); skipLocalTier: true нормалізується, дефолт — false; ще 2 ## Гарантії поведінки -- Read-only: не виконує операцій запису (ФС/БД). -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. +- Власних операцій запису (ФС/БД) у файлі немає; виклики імпортованих модулів можуть писати. +- Містить локальні fail-safe гілки; інші помилки можуть поширюватися назовні. +- Деякі локальні fail-safe гілки повертають порожнє значення (напр. `null`) замість винятку. diff --git a/npm/scripts/lib/lint-surface/docs/lint-lock.md b/npm/scripts/lib/lint-surface/docs/lint-lock.md index e321ff604..1441c367b 100644 --- a/npm/scripts/lib/lint-surface/docs/lint-lock.md +++ b/npm/scripts/lib/lint-surface/docs/lint-lock.md @@ -3,35 +3,41 @@ type: JS Module title: lint-lock.mjs resource: npm/scripts/lib/lint-surface/lint-lock.mjs docgen: - crc: c1100ba8 - model: openai-codex/gpt-5.4-mini - tier: cloud-min - score: 90 - issues: internal-name:withLock,judge-refine:kept-original,judge:inaccurate:0.99 - judgeModel: openai-codex/gpt-5.4-mini + crc: 076dc0bf + model: omlx/gemma-4-e2b-it-4bit + tier: local-min + score: 80 --- ## Огляд -Спільний машинний стан для `n-rules lint --full`: через `withLock` блокується лише `--full`, а scoped, delta та `--no-fix` запуски йдуть без черги. Це серіалізує довгі whole-tree прогони на одній машині, дає видимість позиції в черзі й показує живий прогрес активного запуску. +Глобальна черга запусків `n-rules lint --full`: у кожен момент на машині +виконується щонайбільше один **full**-прогін, наступні чекають у черзі й +стартують після звільнення лока. Рішення spec-дискусії 2026-07-03 (ревізія): +лок береться **лише** на `--full` — дельта/scoped/`--no-fix` запуски короткі +й ідуть без черги; довгі whole-tree прогони серіалізуються та отримують +видимість: процес у черзі показує свою позицію, решту черги і живий +прогрес-бар активного прогону (читає його зі стан-файлу). +`hook --post-tool-use` лок не бере: read-only, per-file, відповідає миттєво. -`GLOBAL_CACHE_DIR` лежить в `os.tmpdir` і працює machine-wide. У ньому зберігаються `lock/owner.json` для власника лока, `queue/-.json` для черги процесів і `progress.json` для знімка активного прогресу. +Механіка — наявний {@link withLock} (mkdir-лок, перехоплення лока мертвого PID, +poll-черга, TTL-дедуплікація за fingerprint) з відмінностями від per-rule +використання (`run-standard-lint.mjs`): + - `cacheDir` у `os.tmpdir()` замість `` → скоуп machine-wide + (на macOS tmpdir per-user), а не per-repo; + - fingerprint дедуплікації домішує варіант виклику (rules/`--no-fix`/cwd) до + знімка дерева — інакше scoped-успіх хибно пропускав би ширший прогін; + - `staleThreshold` піднято до 6 год: дефолтні 30 хв «перехоплювали» б живий + лок довгого прогону; краші покриває PID-перевірка; + - `waitTimeout` 45 хв (full-прогони довгі), далі fail-closed (Error, exit 1), + а не дефолтний `run-unlocked` — мовчазний паралельний запуск це саме те, + що черга має унеможливити. -`lintLockFingerprint` домішує варіант виклику до знімка дерева, щоб scoped-успіх не хибно перекривав ширший прогін. `staleThreshold` піднято до 6 год, `waitTimeout` — 45 хв; після цього очікування завершується fail-closed, а не мовчазним паралельним запуском. - -`createProgressPublisher` оновлює `progress.json`, `renderWaitLine` відображає чергу й прогрес, а `withGlobalLintLock` є спільною точкою доступу до цього режиму. - -## Поведінка - -`GLOBAL_CACHE_DIR` — спільний машинний кеш для всіх full-запусків lint: тут зберігаються власник лока, черга очікування і живий прогрес активного прогону. Саме цей стан робить чергу видимою між процесами та дозволяє не запускати кілька повних прогонів паралельно. - -`lintLockFingerprint` визначає, чи можна безпечно застосувати TTL-дедуплікацію для конкретного варіанта lint. Якщо знімок дерева недоступний або виклик не відповідає поточному робочому дереву, дедуплікація вимикається і запуск іде далі по черзі. Інакше fingerprint враховує і стан дерева, і варіант виклику, щоб короткий scoped-успіх не підміняв ширший full-прогін. - -`withGlobalLintLock` — точка входу для повного запуску: non-full варіанти проходять одразу, а full-прогони стають у глобальну чергу, чекають звільнення лока й виконуються по одному. Після входу в лок активний запуск публікує свій прогрес у `progress.json`, а процеси в черзі читають його та показують актуальний стан очікування. Якщо запуск уже був успішно дедуплікований, результат повертається без повторного виконання. - -`createProgressPublisher` пов’язує репортер прогресу активного прогону зі спільним станом: нові знімки потрапляють у файл прогресу з обмеженням частоти оновлення, щоб черга бачила живий, але стабільний стан. Це джерело правди для всіх очікувальних процесів. - -`renderWaitLine` збирає однорядковий статус очікування з трьох джерел: поточного власника лока, живої черги і знімка прогресу активного прогону. У результаті процес у черзі бачить, хто зараз працює, яка в нього стадія, і яке місце займає сам у загальному порядку. +Спільний стан у `GLOBAL_CACHE_DIR`: + - `lock/owner.json` — власник лока (пише withLock; pid/cwd/startedAt); + - `queue/-.json` — реєстрація процесів у черзі (для списку); + - `progress.json` — знімок прогресу активного прогону (пише publisher + через `createProgressReporter({ onUpdate })`, читають процеси в черзі). ## Публічний API @@ -44,13 +50,19 @@ null (→ дедуплікація вимкнена, черга працює) к - createProgressPublisher — Publisher прогресу активного прогону: приймає знімки від `createProgressReporter({ onUpdate })` і (throttled) пише їх у стан-файл, звідки процеси в черзі читають прогрес-бар активного прогону. +- readOwnerProgress — Читає знімок прогресу активного прогону; null, якщо файла нема, він +застарілий або належить не поточному власнику лока. - renderWaitLine — Рядок стану черги для процесу, що стоїть у черзі: позиція, хто працює (pid + тека), прогрес-бар власника і перелік решти черги. - withGlobalLintLock — Виконує `runFn` під глобальним локом full-прогонів. Не-full варіанти (дельта/scoped/`--no-fix`) виконуються одразу, без лока й черги. +## Сценарії використання + +- `npm/scripts/lib/lint-surface/tests/lint-lock.test.mjs` (lintLockFingerprint — дедуп-ключ: дерево + варіант виклику (spec 2026-07-03); withGlobalLintLock — лок лише для --full (spec 2026-07-03, ревізія)) — null при --cwd не на процесний cwd: знімок дерева відповідав би не тому дереву; null поза git-репо (tree-fingerprint null); той самий варіант на тому самому дереві → стабільний fingerprint; порядок rules не впливає: lint js text ≡ lint text js; інший варіант (--no-fix, rules) → інший fingerprint: scoped-успіх не маскує ширший прогін; ще 12 + ## Гарантії поведінки -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. +- Містить локальні fail-safe гілки; інші помилки можуть поширюватися назовні. +- Деякі локальні fail-safe гілки повертають порожнє значення (напр. `null`) замість винятку. - Кешує результати в межах одного прогону. diff --git a/npm/scripts/lib/lint-surface/docs/run-detectors.md b/npm/scripts/lib/lint-surface/docs/run-detectors.md index 9e6dd7d00..63fff1c4c 100644 --- a/npm/scripts/lib/lint-surface/docs/run-detectors.md +++ b/npm/scripts/lib/lint-surface/docs/run-detectors.md @@ -3,11 +3,10 @@ type: JS Module title: run-detectors.mjs resource: npm/scripts/lib/lint-surface/run-detectors.mjs docgen: - crc: a48109bd - model: openai-codex/gpt-5.4-mini - tier: cloud-min + crc: 8eef8a6e + model: omlx/gemma-4-e2b-it-4bit + tier: local-min score: 75 - judgeModel: openai-codex/gpt-5.4-mini --- ## Огляд @@ -35,7 +34,7 @@ full-scope перевірки — справа `--repo-wide`). ## Сценарії використання -- `npm/scripts/lib/lint-surface/tests/run-detectors.test.mjs` (detectAll — exit codes; detectAll — scoping) — clean → exit 0; violations → exit 1, ruleId/concernId домішані з ctx; detector кидає → exit 2; невалідний violation (без reason) → exit 2; absolute file-path відхиляється → exit 2; ще 15 +- `npm/scripts/lib/lint-surface/tests/run-detectors.test.mjs` (detectAll — exit codes; detectAll — scoping) — clean → exit 0; violations → exit 1, ruleId/concernId домішані з ctx; detector кидає → exit 2; невалідний violation (без reason) → exit 2; absolute file-path відхиляється → exit 2; ще 16 ## Гарантії поведінки diff --git a/npm/scripts/lib/lint-surface/docs/run-fix.md b/npm/scripts/lib/lint-surface/docs/run-fix.md index 4763d6be6..ef99d120e 100644 --- a/npm/scripts/lib/lint-surface/docs/run-fix.md +++ b/npm/scripts/lib/lint-surface/docs/run-fix.md @@ -3,18 +3,32 @@ type: JS Module title: run-fix.mjs resource: npm/scripts/lib/lint-surface/run-fix.mjs docgen: - crc: 7f20adb7 - model: openai-codex/gpt-5.4-mini - tier: cloud-min - score: 50 - issues: no-overview,short-behavior,anchor-miss:.mt.json,best-of-2:retry-lost + crc: fb27d545 + model: omlx/gemma-4-e2b-it-4bit + tier: local-min + score: 75 --- +## Огляд + +Central fix-pipeline unified lint surface (spec 2026-06-29 §Fix Role / §Tier Ladder). + +Послідовно, per concern: + detect → (clean: keep) → T0 (permanent, поза rollback) → snapshot S1 → + detect → (clean: keep) → ladder[restore S1 → worker → detect]* → (exhausted: rollback S1) + +Ролі чесні: detector тільки виявляє; T0 і worker тільки змінюють; success визначає +ВИКЛЮЧНО canonical re-detect. Worker не володіє rollback/tier/ladder — лише один attempt. + ## Публічний API - fixConcern — Проводить ОДИН concern по pipeline: T0 → S1 → ladder. Повертає чи закрито. - runFixPipeline — Повний fix-pipeline: detect усе → fix кожен провальний concern → exit code. +## Сценарії використання + +- `npm/scripts/lib/lint-surface/tests/run-fix.test.mjs` (runFixPipeline — базові вердикти; runFixPipeline — T0 permanent) — clean → 0, worker не викликається; ctx.verify (Фаза A1): item-scoped canonical вердикт доступний worker-у, verifyMax заданий; worker закриває на першому rung → 0; T0 закриває сам → worker не потрібен; T0-зміни виживають rollback-у при повному провалі worker-а; ще 31 + ## Гарантії поведінки -- Власних операцій запису (ФС/БД) у файлі немає; виклики імпортованих модулів можуть писати. +- Кешує результати в межах одного прогону. diff --git a/npm/scripts/lib/lint-surface/lint-lock.mjs b/npm/scripts/lib/lint-surface/lint-lock.mjs index e79999e2a..d1efa1089 100644 --- a/npm/scripts/lib/lint-surface/lint-lock.mjs +++ b/npm/scripts/lib/lint-surface/lint-lock.mjs @@ -70,7 +70,7 @@ const PROGRESS_FRESH_MS = 60_000 const PROGRESS_SNAPSHOT_VERSION = 2 /** Частота heartbeat owner-а: довгий один target лишається видимим для процесу в черзі. */ -const PUBLISH_HEARTBEAT_INTERVAL_MS = 5_000 +const PUBLISH_HEARTBEAT_INTERVAL_MS = 5000 /** Мінімум завершених targets, коли швидкість вже достатня для обережного ETA. */ const ETA_MIN_COMPLETED_TARGETS = 3 @@ -111,7 +111,12 @@ export function createProgressPublisher(opts = {}) { let lastSnap = null let phaseStartedAt = 0 - /** Записує атомарно достатній для observer-а snapshot; помилки лишаються best-effort. */ + /** + * Записує атомарно достатній для observer-а snapshot; помилки лишаються best-effort. + * @param {{current:string,detail?:{label:string,done:number,total:number,current:string}|null}} snap поточний знімок прогресу + * @param {boolean} [heartbeat] чи це heartbeat без нової фази + * @returns {void} + */ function publish(snap, heartbeat = false) { const at = now() const detail = snap.detail ?? null @@ -245,7 +250,11 @@ export function renderWaitLine(owner, queue, snap) { return `⏳ lint --full у черзі #${pos}/${Math.max(queue.length, pos)} · працює pid ${owner.pid}${ownerDir}${bar}${tail}` } -/** Форматує короткий ETA; caller передає лише вже перевірену обережну оцінку. */ +/** + * Форматує короткий ETA; caller передає лише вже перевірену обережну оцінку. + * @param {number} ms оцінка часу в мілісекундах + * @returns {string} короткий рядок часу + */ function formatEta(ms) { const seconds = Math.max(1, Math.round(ms / 1000)) if (seconds < 60) return `${seconds} с` diff --git a/npm/scripts/lib/lint-surface/run-detectors.mjs b/npm/scripts/lib/lint-surface/run-detectors.mjs index 103652711..975ba5066 100644 --- a/npm/scripts/lib/lint-surface/run-detectors.mjs +++ b/npm/scripts/lib/lint-surface/run-detectors.mjs @@ -152,7 +152,7 @@ async function filterByRuleApplies(byRule, cwd) { const mod = await import(pathToFileURL(appliesPath).href) applies = mod.applies } catch (error) { - throw new Error(`rule ${ruleId}: не вдалося завантажити applies gate: ${error.message}`) + throw new Error(`rule ${ruleId}: не вдалося завантажити applies gate: ${error.message}`, { cause: error }) } if (applies === undefined) { out[ruleId] = concerns diff --git a/npm/scripts/lib/lint-surface/run-fix.mjs b/npm/scripts/lib/lint-surface/run-fix.mjs index d63fe14e2..f3c17ea1c 100644 --- a/npm/scripts/lib/lint-surface/run-fix.mjs +++ b/npm/scripts/lib/lint-surface/run-fix.mjs @@ -462,11 +462,6 @@ async function runRung(rung, worker, violations, feedback, rungDeps) { const vetoed = after.length === 0 && !error && (collateralAll.length > 0 || brokenTest !== null) const touchedFiles = workerResult?.touchedFiles ?? [] - // Provider може повернути quality-verdict без worker exception (наприклад, - // generated coverage test зелений у Vitest, але не вбив target mutant). Це - // не змінює retry policy; лише дає наступному ladder rung-у конкретний feedback. - const workerFeedback = workerResult?.feedback?.previousError ?? null - if (after.length === 0 && !error && !vetoed) { log(` ✅ ${rung.tier} (${rung.model}): ${ruleId}/${concernName}\n`) // Фаза C: успішний agentic-фікс (canonical clean, без veto) → глобальний @@ -488,6 +483,10 @@ async function runRung(rung, worker, violations, feedback, rungDeps) { } return { closed: true, touchedFiles } } + // Provider може повернути quality-verdict без worker exception (наприклад, + // generated coverage test зелений у Vitest, але не вбив target mutant). Це + // не змінює retry policy; лише дає наступному ladder rung-у конкретний feedback. + const workerFeedback = workerResult?.feedback?.previousError ?? null const { errorSuffix, silentFailureNote } = describeVetoOutcome({ error, diff --git a/npm/scripts/lib/lint-surface/tests/lint-lock.test.mjs b/npm/scripts/lib/lint-surface/tests/lint-lock.test.mjs index 58855e890..1052e7c87 100644 --- a/npm/scripts/lib/lint-surface/tests/lint-lock.test.mjs +++ b/npm/scripts/lib/lint-surface/tests/lint-lock.test.mjs @@ -197,7 +197,7 @@ describe('createProgressPublisher — стан-файл прогресу для }) let snap = JSON.parse(fs.readFileSync(file, 'utf8')) expect(snap).toMatchObject({ version: 2, phase: 'k8s/manifest', step: 'kubescape', etaMs: null }) - now += 6_000 + now += 6000 publisher.onUpdate({ done: 2, total: 12, diff --git a/npm/scripts/lib/lint-surface/tests/run-fix.test.mjs b/npm/scripts/lib/lint-surface/tests/run-fix.test.mjs index 7138028ac..2b08fdd3c 100644 --- a/npm/scripts/lib/lint-surface/tests/run-fix.test.mjs +++ b/npm/scripts/lib/lint-surface/tests/run-fix.test.mjs @@ -1147,7 +1147,7 @@ describe('runFixPipeline — durable-write worker (issue #16: doc-черга н rulesDir, cwd: dir, full: true, - log: () => {}, + log: () => null, deps: { ladder: ONE_RUNG, workerFor: () => worker } }) @@ -1171,7 +1171,7 @@ describe('runFixPipeline — durable-write worker (issue #16: doc-черга н rulesDir, cwd: dir, full: true, - log: () => {}, + log: () => null, deps: { ladder: ONE_RUNG, workerFor: () => worker } }) diff --git a/npm/scripts/lib/tests/concern-meta.test.mjs b/npm/scripts/lib/tests/concern-meta.test.mjs index 75b7d714c..dac76a865 100644 --- a/npm/scripts/lib/tests/concern-meta.test.mjs +++ b/npm/scripts/lib/tests/concern-meta.test.mjs @@ -97,10 +97,12 @@ describe('concern-meta — lint surface', () => { lint: { scope: 'per-file' }, cloudTimeoutMs: 180_000 }) - expect((await readConcernMeta(withOverride, 'coverage')).cloudTimeoutMs).toBe(180_000) + const overriddenMeta = await readConcernMeta(withOverride, 'coverage') + expect(overriddenMeta.cloudTimeoutMs).toBe(180_000) const invalid = await seedConcern(dir, 'test', 'invalid', { lint: { scope: 'per-file' }, cloudTimeoutMs: 0 }) - expect((await readConcernMeta(invalid, 'invalid')).cloudTimeoutMs).toBeUndefined() + const invalidMeta = await readConcernMeta(invalid, 'invalid') + expect(invalidMeta.cloudTimeoutMs).toBeUndefined() }) }) diff --git a/npm/scripts/sync-claude-config.mjs b/npm/scripts/sync-claude-config.mjs index 42ee69dda..9676373c3 100644 --- a/npm/scripts/sync-claude-config.mjs +++ b/npm/scripts/sync-claude-config.mjs @@ -85,6 +85,8 @@ const CLAUDE_COMMANDS_DIR = `${CLAUDE_DIR}/commands` const CLAUDE_HOOKS_DIR = `${CLAUDE_DIR}/hooks` const CURSOR_DIR = '.cursor' const CURSOR_HOOKS_FILE = `${CURSOR_DIR}/hooks.json` +const CODEX_DIR = '.codex' +const CODEX_HOOKS_FILE = `${CODEX_DIR}/hooks.json` const ADR_HOOK_SCRIPT_NAME = 'capture-decisions.sh' const ADR_NORMALIZE_HOOK_SCRIPT_NAME = 'normalize-decisions.sh' const ADR_HOOK_LIB_DIR = 'lib' @@ -177,6 +179,45 @@ const CURSOR_ADR_NORMALIZE_STOP_HOOK = Object.freeze({ timeout: 600 }) +/** + * Канонічна Codex CLI ADR Stop-hook-група (правило `adr`). Codex не має аналога + * `$CLAUDE_PROJECT_DIR` — команди запускаються з cwd сесії (`vendor/codex-hooks.json`: + * «Commands run with the session cwd as their working directory»), тож використовуємо + * той самий `$PWD`-детект кореня, що й Cursor-варіант. `async` НЕ виставляємо: за тією ж + * схемою async-хендлери «not supported yet and async handlers are skipped» у поточному + * рантаймі Codex — `async: true` означав би, що хук узагалі не виконається. + */ +const CODEX_ADR_STOP_HOOK_GROUP = Object.freeze({ + matcher: '', + hooks: Object.freeze([ + Object.freeze({ + type: 'command', + command: [ + 'bash -lc \'root="$PWD";', + `if [ ! -f "$root/${ADR_HOOK_COMMAND_MARKER}" ] && [ -f "$root/../${ADR_HOOK_COMMAND_MARKER}" ]; then root="$root/.."; fi;`, + `bash "$root/${ADR_HOOK_COMMAND_MARKER}"'` + ].join(' '), + timeout: 180 + }) + ]) +}) + +/** Канонічна Codex CLI ADR normalize Stop-hook-група — той самий `$PWD`-підхід, без `async` (див. {@link CODEX_ADR_STOP_HOOK_GROUP}). */ +const CODEX_ADR_NORMALIZE_STOP_HOOK_GROUP = Object.freeze({ + matcher: '', + hooks: Object.freeze([ + Object.freeze({ + type: 'command', + command: [ + 'bash -lc \'root="$PWD";', + `if [ ! -f "$root/${ADR_NORMALIZE_HOOK_COMMAND_MARKER}" ] && [ -f "$root/../${ADR_NORMALIZE_HOOK_COMMAND_MARKER}" ]; then root="$root/.."; fi;`, + `bash "$root/${ADR_NORMALIZE_HOOK_COMMAND_MARKER}"'` + ].join(' '), + timeout: 600 + }) + ]) +}) + /** * @typedef {object} HookEntry * @property {string} type тип hook'а у форматі Claude Code (зазвичай `'command'`) @@ -436,6 +477,68 @@ export async function syncCursorHooksConfig(projectRoot, options = {}) { return { written: true, path: CURSOR_HOOKS_FILE } } +/** + * Будує копію hooks-секції темплейту Codex із доданою ADR Stop-hook-групою у `Stop`. + * @param {Record | undefined} hooks вихідна hooks-секція темплейту + * @returns {Record} копія з доданою ADR-групою + */ +function codexHooksWithAdrHook(hooks) { + /** @type {Record} */ + const out = {} + for (const [event, groups] of Object.entries(hooks ?? {})) { + out[event] = Array.isArray(groups) ? [...groups] : [] + } + out.Stop = [...(out.Stop ?? []), CODEX_ADR_STOP_HOOK_GROUP, CODEX_ADR_NORMALIZE_STOP_HOOK_GROUP] + return out +} + +/** + * Зливає hooks-секцію `.codex/hooks.json`. Формат ідентичний `.claude/settings.json.hooks` + * (matcher + hooks[] з type/command/timeout — підтверджено `vendor/codex-hooks.json`), тож + * перевикористовує ту саму {@link mergeHooks}. + * @param {Record | undefined} existing поточна hooks-секція з `.codex/hooks.json` + * @param {Record | undefined} template hooks-секція з `codex-hooks.template.json` + * @param {object} [options] опції merge-у + * @param {boolean} [options.includeAdrHook] чи додати ADR Stop-hook групи (правило `adr`) + * @returns {Record} результат merge-у (порожні події видаляються) + */ +export function mergeCodexHooks(existing, template, options = {}) { + const effectiveTemplate = options.includeAdrHook ? codexHooksWithAdrHook(template) : (template ?? {}) + return mergeHooks(existing, effectiveTemplate) +} + +/** + * Синхронізує `.codex/hooks.json` для Codex CLI: базовий PostToolUse lint-hook (правило- + * незалежний, з темплейту `codex-hooks.template.json`, matcher `apply_patch` — best-guess, + * див. JSDoc {@link ../../hook.mjs}) + опційні ADR Stop-hook групи. Codex не має rtk hook: + * інтеграція rtk відбувається через пряму інструкцію в AGENTS.md. + * На відміну від `.claude/settings.json` тут немає секції `permissions` — Codex тримає + * дозволи окремо в `config.toml`. + * @param {string} projectRoot корінь проєкту, куди писати + * @param {string} templateDir каталог `.claude-template/` усередині пакету + * @param {object} [options] опції merge-у + * @param {boolean} [options.includeAdrHook] чи додавати ADR Stop-hook групи + * @returns {Promise<{ written: boolean, path: string }>} результат: чи писали файл, та його відносний шлях + */ +export async function syncCodexHooksConfig(projectRoot, templateDir, options = {}) { + const templatePath = join(templateDir, 'codex-hooks.template.json') + if (!existsSync(templatePath)) { + return { written: false, path: '' } + } + const template = + /** @type {{ hooks?: Record }} */ (JSON.parse(await readFile(templatePath, 'utf8'))) + const hooksPath = join(projectRoot, CODEX_HOOKS_FILE) + const existing = + /** @type {{ hooks?: Record } | undefined} */ (await readJsonOrUndefined(hooksPath)) + const mergedHooks = mergeCodexHooks(existing?.hooks, template.hooks, options) + if (Object.keys(mergedHooks).length === 0) { + return { written: false, path: '' } + } + await mkdir(join(projectRoot, CODEX_DIR), { recursive: true }) + await writeFile(hooksPath, `${JSON.stringify({ hooks: mergedHooks }, null, 2)}\n`, 'utf8') + return { written: true, path: CODEX_HOOKS_FILE } +} + /** * Синхронізує `.claude/settings.json` за темплейтом, зберігаючи решту * користувацьких полів. @@ -735,12 +838,13 @@ export async function syncClaudeCommands(projectRoot, templateDir) { * @param {string} options.bundledPackageRoot корінь установленого `@7n/rules` * @param {boolean} options.enabled чи увімкнено sync (з `.n-rules.json` `claude-config`) * @param {string[]} [options.rules] список увімкнених правил із `.n-rules.json` — впливає на ADR Stop-hook (`adr`) і rtk hooks (`local-ai`) - * @returns {Promise<{ settings: boolean, cursorHooks: boolean, commands: string[], adrHook: boolean, adrNormalizeHook: boolean, adrHookLib: string[], gitignoreAdr: boolean, piExtension: boolean, rtkPiExtension: boolean }>} прапорці записів settings/Cursor hooks/ADR-hook(s)/`.gitignore`/pi-extension(s), перелік lib-файлів і список slash-команд + * @returns {Promise<{ settings: boolean, cursorHooks: boolean, codexHooks: boolean, commands: string[], adrHook: boolean, adrNormalizeHook: boolean, adrHookLib: string[], gitignoreAdr: boolean, piExtension: boolean, rtkPiExtension: boolean }>} прапорці записів settings/Cursor hooks/Codex hooks/ADR-hook(s)/`.gitignore`/pi-extension(s), перелік lib-файлів і список slash-команд */ export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enabled, rules = [] }) { const noop = { settings: false, cursorHooks: false, + codexHooks: false, commands: [], adrHook: false, adrNormalizeHook: false, @@ -786,10 +890,12 @@ export async function syncClaudeConfig({ projectRoot, bundledPackageRoot, enable } const settings = await syncClaudeSettings(projectRoot, templateDir, { includeAdrHook, includeLocalAiHook }) const cursorHooks = await syncCursorHooksConfig(projectRoot, { includeAdrHook, includeLocalAiHook }) + const codexHooks = await syncCodexHooksConfig(projectRoot, templateDir, { includeAdrHook }) const commands = await syncClaudeCommands(projectRoot, templateDir) return { settings: settings.written, cursorHooks: cursorHooks.written, + codexHooks: codexHooks.written, commands, adrHook: adrHook.written, adrNormalizeHook: adrNormalizeHook.written, diff --git a/npm/scripts/tests/hook.test.mjs b/npm/scripts/tests/hook.test.mjs new file mode 100644 index 000000000..fecdeef79 --- /dev/null +++ b/npm/scripts/tests/hook.test.mjs @@ -0,0 +1,98 @@ +/** + * Тести `hook.mjs`: витяг шляхів файлів зі stdin PostToolUse-payload — Claude Code + * (`tool_input.file_path`) і Codex CLI (`tool_name: "apply_patch"`, V4A-патч у + * `tool_input.command`). + */ +import { describe, expect, test } from 'vitest' + +import { extractCodexPatchPaths, extractFilePaths } from '../hook.mjs' + +describe('extractCodexPatchPaths', () => { + test('Add File — повертає шлях нового файлу', () => { + const patch = ['*** Begin Patch', '*** Add File: hello.txt', '+Hello world', '*** End Patch'].join('\n') + expect(extractCodexPatchPaths(patch)).toEqual(['hello.txt']) + }) + + test('Update File — повертає шлях без Move to', () => { + const patch = [ + '*** Begin Patch', + '*** Update File: src/app.py', + '@@ def greet(): @@', + '-print("Hi")', + '+print("Hello, world!")', + '*** End Patch' + ].join('\n') + expect(extractCodexPatchPaths(patch)).toEqual(['src/app.py']) + }) + + test('Update File + Move to — рахує лише фінальний (перейменований) шлях', () => { + const patch = [ + '*** Begin Patch', + '*** Update File: src/app.py', + '*** Move to: src/main.py', + '@@ def greet(): @@', + '-print("Hi")', + '+print("Hello, world!")', + '*** End Patch' + ].join('\n') + expect(extractCodexPatchPaths(patch)).toEqual(['src/main.py']) + }) + + test('Delete File — пропускається (нема що лінтити)', () => { + const patch = ['*** Begin Patch', '*** Delete File: obsolete.txt', '*** End Patch'].join('\n') + expect(extractCodexPatchPaths(patch)).toEqual([]) + }) + + test('декілька файлових секцій в одному патчі', () => { + const patch = [ + '*** Begin Patch', + '*** Add File: hello.txt', + '+Hello world', + '*** Update File: src/app.py', + '*** Move to: src/main.py', + '@@ def greet(): @@', + '-print("Hi")', + '+print("Hello, world!")', + '*** Delete File: obsolete.txt', + '*** End Patch' + ].join('\n') + expect(extractCodexPatchPaths(patch)).toEqual(['hello.txt', 'src/main.py']) + }) +}) + +describe('extractFilePaths', () => { + test('порожній stdin → []', () => { + expect(extractFilePaths('')).toEqual([]) + }) + + test('невалідний JSON → []', () => { + expect(extractFilePaths('NOT JSON')).toEqual([]) + }) + + test('Claude Code Edit/Write — tool_input.file_path', () => { + const json = JSON.stringify({ tool_name: 'Edit', tool_input: { file_path: '/repo/src/foo.ts' } }) + expect(extractFilePaths(json)).toEqual(['/repo/src/foo.ts']) + }) + + test('Claude Code Bash (без file_path) → []', () => { + const json = JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'ls -la' } }) + expect(extractFilePaths(json)).toEqual([]) + }) + + test('Codex apply_patch — витягує шлях(и) з V4A-патча в tool_input.command', () => { + const command = ['*** Begin Patch', '*** Add File: src/foo.ts', '+export const x = 1', '*** End Patch'].join('\n') + const json = JSON.stringify({ tool_name: 'apply_patch', tool_input: { command } }) + expect(extractFilePaths(json)).toEqual(['src/foo.ts']) + }) + + test('Codex apply_patch — лише видалення → []', () => { + const command = ['*** Begin Patch', '*** Delete File: src/foo.ts', '*** End Patch'].join('\n') + const json = JSON.stringify({ tool_name: 'apply_patch', tool_input: { command } }) + expect(extractFilePaths(json)).toEqual([]) + }) + + test('tool_name apply_patch, але command не рядок → []', () => { + const json = JSON.stringify({ tool_name: 'apply_patch', tool_input: { command: 42 } }) + expect(extractFilePaths(json)).toEqual([]) + }) +}) diff --git a/npm/scripts/tests/sync-claude-config.test.mjs b/npm/scripts/tests/sync-claude-config.test.mjs index 44db56d68..6a9be0eb3 100644 --- a/npm/scripts/tests/sync-claude-config.test.mjs +++ b/npm/scripts/tests/sync-claude-config.test.mjs @@ -20,6 +20,7 @@ import { LEGACY_STOP_HOOK_COMMAND_MARKER, MANAGED_HOOK_COMMAND_MARKER, mergeAllowList, + mergeCodexHooks, mergeCursorHooksConfig, mergeHooks, mergeSettings, @@ -32,6 +33,7 @@ import { syncClaudeCommands, syncClaudeConfig, syncClaudeSettings, + syncCodexHooksConfig, syncGitignoreAdrFragment } from '../sync-claude-config.mjs' import { withTmpDir, writeJson } from '../utils/test-helpers.mjs' @@ -72,6 +74,21 @@ async function setupTemplate(cwdAbs, tpl = {}) { `${JSON.stringify(settings, null, 2)}\n`, 'utf8' ) + const codexHooks = tpl.codexHooks ?? { + hooks: { + PostToolUse: [ + { + matcher: 'apply_patch', + hooks: [{ type: 'command', command: 'npx --no @7n/rules hook --post-tool-use', timeout: 300 }] + } + ] + } + } + await writeFile( + join(cwdAbs, TEMPLATE_REL, 'codex-hooks.template.json'), + `${JSON.stringify(codexHooks, null, 2)}\n`, + 'utf8' + ) await writeFile( join(cwdAbs, TEMPLATE_REL, 'hooks', 'capture-decisions.sh'), tpl.captureDecisionsSh ?? '#!/usr/bin/env bash\nexit 0\n', @@ -376,6 +393,51 @@ describe('mergeSettings (local-ai)', () => { }) }) +describe('mergeCodexHooks', () => { + const baseTemplate = { + PostToolUse: [ + { + matcher: 'apply_patch', + hooks: [{ type: 'command', command: 'npx --no @7n/rules hook --post-tool-use', timeout: 300 }] + } + ] + } + + test('без опцій — лише базовий PostToolUse lint-hook', () => { + const merged = mergeCodexHooks(undefined, baseTemplate) + expect(merged.PostToolUse).toHaveLength(1) + expect(merged.PostToolUse[0].matcher).toBe('apply_patch') + expect(merged.Stop).toBeUndefined() + expect(merged.PreToolUse).toBeUndefined() + }) + + test('includeAdrHook додає ADR capture+normalize групи у Stop', () => { + const merged = mergeCodexHooks(undefined, baseTemplate, { includeAdrHook: true }) + expect(merged.Stop).toHaveLength(2) + expect(merged.Stop[0].hooks[0].command).toContain(ADR_HOOK_COMMAND_MARKER) + expect(merged.Stop[0].hooks[0].async).toBeUndefined() + expect(merged.Stop[1].hooks[0].command).toContain('.claude/hooks/normalize-decisions.sh') + }) + + test('повторний merge не дублює managed-групи; вимкнення прибирає їх', () => { + const withAdr = mergeCodexHooks(undefined, baseTemplate, { includeAdrHook: true }) + const again = mergeCodexHooks(withAdr, baseTemplate, { includeAdrHook: true }) + expect(again.Stop).toHaveLength(2) + expect(again.PreToolUse).toBeUndefined() + const disabled = mergeCodexHooks(again, baseTemplate) + expect(disabled.Stop).toBeUndefined() + expect(disabled.PreToolUse).toBeUndefined() + expect(disabled.PostToolUse).toHaveLength(1) + }) + + test('зберігає користувацькі групи поряд з managed', () => { + const existing = { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo my-hook' }] }] } + const merged = mergeCodexHooks(existing, baseTemplate) + expect(merged.PreToolUse).toHaveLength(1) + expect(merged.PreToolUse[0].hooks[0].command).toBe('echo my-hook') + }) +}) + describe('syncClaudeConfig (інтеграція)', () => { test('створює settings.json із managed PostToolUse групою', async () => { await withTmpDir(async cwdAbs => { @@ -389,6 +451,11 @@ describe('syncClaudeConfig (інтеграція)', () => { expect(settings.hooks.PostToolUse[0].hooks[0].command).toContain(MANAGED_HOOK_COMMAND_MARKER) expect(settings.hooks.PostToolUse[0].hooks[0].timeout).toBe(300) expect(settings.hooks.Stop).toBeUndefined() + expect(result.codexHooks).toBe(true) + const codexHooks = JSON.parse(await readFile(join(cwdAbs, '.codex/hooks.json'), 'utf8')) + expect(codexHooks.hooks.PostToolUse[0].matcher).toBe('apply_patch') + expect(codexHooks.hooks.PostToolUse[0].hooks[0].command).toContain(MANAGED_HOOK_COMMAND_MARKER) + expect(codexHooks.hooks.Stop).toBeUndefined() }) }) @@ -458,6 +525,7 @@ describe('syncClaudeConfig (інтеграція)', () => { expect(result).toEqual({ settings: false, cursorHooks: false, + codexHooks: false, commands: [], adrHook: false, adrNormalizeHook: false, @@ -754,6 +822,25 @@ describe('syncClaudeConfig (інтеграція)', () => { }) }) + test('syncCodexHooksConfig — template file відсутній → { written: false }', async () => { + await withTmpDir(async cwdAbs => { + const result = await syncCodexHooksConfig(cwdAbs, join(cwdAbs, 'nonexistent')) + expect(result).toEqual({ written: false, path: '' }) + }) + }) + + test('syncCodexHooksConfig — ідемпотентний повторний виклик, не дублює managed-групу', async () => { + await withTmpDir(async cwdAbs => { + const pkgRoot = await setupTemplate(cwdAbs) + const templateDir = join(pkgRoot, '.claude-template') + await syncCodexHooksConfig(cwdAbs, templateDir, { includeAdrHook: true }) + await syncCodexHooksConfig(cwdAbs, templateDir, { includeAdrHook: true }) + const codexHooks = JSON.parse(await readFile(join(cwdAbs, '.codex/hooks.json'), 'utf8')) + expect(codexHooks.hooks.Stop).toHaveLength(2) + expect(codexHooks.hooks.PostToolUse).toHaveLength(1) + }) + }) + test('syncClaudeSettings — невалідний JSON у settings.json → перезаписує шаблоном (line 328)', async () => { await withTmpDir(async cwdAbs => { const pkgRoot = await setupTemplate(cwdAbs) diff --git a/plugins/lang-js/.changes/260728-2145.md b/plugins/lang-js/.changes/260728-2145.md new file mode 100644 index 000000000..0028e5ba2 --- /dev/null +++ b/plugins/lang-js/.changes/260728-2145.md @@ -0,0 +1,5 @@ +--- +bump: patch +section: Changed +--- +fix(ci): resolve repository lint findings diff --git a/plugins/lang-js/coverage-provider/docs/js-collector.md b/plugins/lang-js/coverage-provider/docs/js-collector.md index 2653d7cf9..2a5443060 100644 --- a/plugins/lang-js/coverage-provider/docs/js-collector.md +++ b/plugins/lang-js/coverage-provider/docs/js-collector.md @@ -3,27 +3,19 @@ type: JS Module title: js-collector.mjs resource: plugins/lang-js/coverage-provider/js-collector.mjs docgen: - crc: 686d2da0 - model: openai-codex/gpt-5.4-mini - tier: cloud-min - score: 75 - issues: internal-name:collectStorybookForRoot,internal-name:buildAreaRow,anchor-miss:https://alexop.dev/posts/mutation-testing-ai-agents-vitest-browser-mode/,judge-refine:kept-original,judge:inaccurate:0.99 - judgeModel: openai-codex/gpt-5.4-mini + crc: 3c452712 + model: omlx/gemma-4-e2b-it-4bit + tier: local-min + score: 55 --- ## Огляд -Збирає метрики coverage і mutation для JS/TS у спільний результат, щоб фіксувати стан тестового покриття та стійкість коду до мутацій в одному місці. Використовує `vitest run --coverage` і Stryker з `vitest-runner` та `perTest`, а також працює з кешуванням у межах прогону й може звертатися до мережі. - -## Поведінка - -collect спочатку звужує змінені файли до релевантного JS- або Storybook-скоупу, потім для кожного JS-root окремо вирішує, чи запускати повний або changed-режим, і вже після цього зводить coverage та mutation у спільні рядки `JS` і `Vue (Storybook)`. Дані для цього потоку беруться з `package.json` кореня та workspace-root-ів: detect вмикає колектор лише там, де є `vitest`, інакше дає silent skip, щоб не чіпати несумісні пакети. scopeToRoot і scopeToStorybookRoot працюють як різні фільтри одного changed-scope: перший лишає production JS/TS для Stryker mutate, другий — лише Vue-компоненти й stories для окремого Storybook-виміру, без змішування зон відповідальності. - -У гілці JS collectOneRoot бере coverage з Vitest або Bun-native прогону, а mutation — зі Stryker; якщо workspace без тестів, full-режим зупиняється без результату, а в changed-режимі порожній coverage не маскує NoCoverage-мутанти для зміненого source. parseStrykerReport агрегує `mutation.json` у caught/total і групує survived за файлами, підтягаючи приклад тесту через findExampleTest; якщо source-файл недоступний, оригінальний фрагмент лишається порожнім, щоб не ламати звіт. extractFirstTestBlock використовується як стилевий приклад для survived-результатів, а findExampleTest бере його з найближчого test-файлу для пояснення, як виглядає релевантний тестовий контекст. - -Для Storybook collect окремо стежить за тим, щоб зміни стосувалися саме Vue/story files і щоб root справді був канонічним Storybook-пакетом; повний режим тут може піти або через canonical Stryker command-runner, або чесно пропустити mutation, якщо конфіг відсутній. Поведінка навколо Storybook спирається на обмеження сучасного browser mode, яке не підтримується Stryker vitest-runner; контекст цього розходження описаний у https://alexop.dev/posts/mutation-testing-ai-agents-vitest-browser-mode/. readParsedMutationReport і assertStorybookStrykerIsolation захищають від пізніх падінь: перший вимагає наявний `mutation.json`, другий перевіряє, що Storybook-root не намагається запускатися через неізольований конфіг. - -defaultRunner є спільною точкою запуску зовнішніх команд для всіх гілок, а collect передає його вниз як інʼєкцію, щоб зберегти однакову оркестрацію між roots і режимами. Результати збираються в окремі агрегати по площинах, а survived у фінальному виході рібейзяться відносно cwd, щоб подальший фіксер міг знаходити джерела без додаткових перетворень. Кеш у межах прогону зменшує повторні звернення до тих самих root-ів і report-файлів, але не змінює семантику: якщо `mutation.json` відсутній там, де він має бути за `package.json` і конфігом, це лишається помилкою конфігурації, а не успішним skip. +JS/TS coverage + mutation-testing колектор: збирає метрики покриття +(`vitest run --coverage`) і мутаційного тестування (Stryker з vitest-runner + perTest). +Історія: жив у `@nitra/cursor` як rule-провайдер, потім (2026-07-10) — вбудований +collector `@7n/test coverage`; після влиття `@7n/test` (spec 2026-07-22) — ядро +coverage-провайдера плагіна `@7n/rules-lang-js` (концерн `coverage` правила `test`). ## Публічний API @@ -44,6 +36,11 @@ defaultRunner є спільною точкою запуску зовнішніх - parseStrykerReport — Парс Stryker mutation.json: Killed+Timeout → caught; Survived+NoCoverage → до total. Compile/Runtime помилки виключаються з total. Survived мутанти групуються по файлах з exampleTest. +- verifyScopedMutationBatch — Один cache-independent scoped Stryker-прогін після agent test-write. Він бере +consumer config за основу, але пише report у тимчасову директорію й примусово +вимикає incremental, тому не читає та не змінює consumer `incremental.json`. +Кожен target мусить бути знайдений у свіжому report; batch приймається лише коли +хоча б один target став Killed або Timeout. - defaultRunner — Дефолтний spawn-runner колектора (vitest/bun/Stryker/Storybook-прогони). Експортується для повторного використання делта-виміром (per-file.mjs) та інʼєкцій у тестах. - collect — Збирає JS-метрики покриття + мутаційного тестування, і окремо — Storybook-покриття @@ -62,6 +59,10 @@ root-а (`scopeToRoot`), Storybook-вимір — лише змінені `.vue` нема. Якщо змін нема ніде — повертає `[]` без error-логу (оркестратор трактує порожній changed-scope як pass). +## Сценарії використання + +- `plugins/lang-js/coverage-provider/tests/js-collector.test.mjs` (js coverage detect(); js coverage collect()) — повертає true коли vitest у devDependencies; повертає true коли vitest у workspace-пакеті; повертає true коли vitest у кореневому package.json, відсутній у workspace (hoisted bun monorepo); повертає true коли vitest у (звичайних) dependencies; повертає false коли vitest відсутній; ще 59 + ## Гарантії поведінки - Кешує результати в межах одного прогону. diff --git a/plugins/lang-js/coverage-provider/fix/coverage-fix.mjs b/plugins/lang-js/coverage-provider/fix/coverage-fix.mjs index 9d9dc15b5..38d47c78d 100644 --- a/plugins/lang-js/coverage-provider/fix/coverage-fix.mjs +++ b/plugins/lang-js/coverage-provider/fix/coverage-fix.mjs @@ -143,8 +143,6 @@ export async function fixSurvivedMutants(survived, projectRoot, opts = {}) { for (const [i, batch] of batches.entries()) { const files = batch.map(g => g.file) const batchMutants = batch.reduce((s, g) => s + g.mutants.length, 0) - const oversizedFiles = batch.filter(g => (g.sourceMutantCount ?? g.mutants.length) > budget).map(g => g.file) - const oversizedSubBatch = batch.some(g => g.sourceMutantCount && g.mutants.length < g.sourceMutantCount) if (i >= maxBatches) { const deferredBatch = { batch: i + 1, @@ -157,6 +155,8 @@ export async function fixSurvivedMutants(survived, projectRoot, opts = {}) { console.log(` ⏭ batch ${i + 1}/${batches.length} deferred: ${deferredBatch.reason}`) continue } + const oversizedFiles = batch.filter(g => (g.sourceMutantCount ?? g.mutants.length) > budget).map(g => g.file) + const oversizedSubBatch = batch.some(g => g.sourceMutantCount && g.mutants.length < g.sourceMutantCount) console.log( `\n🤖 batch ${i + 1}/${batches.length}: ${files.length} файл(ів), ${batchMutants} мутантів, budget=${budget}, oversizedSourceFile=${oversizedFiles.length > 0}, oversizedSubBatch=${oversizedSubBatch} — ${files.join(', ')}\n` ) @@ -168,7 +168,7 @@ export async function fixSurvivedMutants(survived, projectRoot, opts = {}) { const startedAt = Date.now() let mutationVerdict = null const verifyMutation = async ({ touchedFiles }) => { - if (!touchedFiles.some(file => TEST_FILE_RE.test(file))) { + if (touchedFiles.every(file => !TEST_FILE_RE.test(file))) { mutationVerdict = { ok: false, targetCount: batchMutants, @@ -291,6 +291,7 @@ async function resolveRunFix(opts) { * @param {{error?: string|null, telemetry?: object|null}} res результат `runAgentFix` * @param {string[]} unexpectedWrites файли, які агент змінив поза дозволеними test/spec шляхами * @param {boolean} noOp чи агент завершився без помилки і без test-записів + * @param {object|null} mutationVerdict scoped Stryker verdict batch-а * @returns {string|null} текст помилки batch-а або `null`, якщо batch успішний */ function resolveBatchError(res, unexpectedWrites, noOp, mutationVerdict) { @@ -319,8 +320,8 @@ function isDurableMutationProof(verdict, batchMutants) { verdict.killed === batchMutants && verdict.remaining === 0 && verdict.covered0 === 0 && - verdict.reason == null && - verdict.error == null && + (verdict.reason === undefined || verdict.reason === null) && + (verdict.error === undefined || verdict.error === null) && (!Array.isArray(verdict.errors) || verdict.errors.length === 0) ) } @@ -341,6 +342,7 @@ function formatMutationVerdict(verdict) { /** * Логує підсумок coverage-fix після всіх batch-ів. * @param {{files: string[], error: string}[]} failed failed/no-op batch-и + * @param {object[]} deferred batch-и, відкладені policy rung-а * @param {string[]} fixed змінені test-файли * @param {object[]} batches діагностика всіх batch-ів * @returns {void} @@ -372,12 +374,12 @@ function logCoverageSummary(failed, deferred, fixed, batches) { * @param {number|null} args.requestedTimeoutMs запитаний timeout для worker-а * @param {number|null} args.workerDeadlineMs deadline worker-а * @param {number|null} args.effectiveTimeoutMs timeout, реально переданий batch-у - * @param {number|null} [args.survivedBatchesPerRung=null] policy кількості agent batch-ів у rung + * @param {number|null} [args.survivedBatchesPerRung] policy кількості agent batch-ів у rung * @param {number|null} args.wallMs тривалість batch-а - * @param {object|null} [args.telemetry=null] telemetry `runAgentFix` - * @param {string|null} [args.error=null] помилка batch-а - * @param {string[]} [args.writtenTests=[]] дозволені test-файли, змінені агентом - * @param {object|null} [args.mutationVerdict=null] canonical scoped Stryker verdict + * @param {object|null} [args.telemetry] telemetry `runAgentFix` + * @param {string|null} [args.error] помилка batch-а + * @param {string[]} [args.writtenTests] дозволені test-файли, змінені агентом + * @param {object|null} [args.mutationVerdict] canonical scoped Stryker verdict * @param {{attempted:boolean,outcome:string}} [args.rollback] результат локального rollback batch-а * @returns {object} безпечний структурований verdict */ diff --git a/plugins/lang-js/coverage-provider/fix/docs/coverage-fix.md b/plugins/lang-js/coverage-provider/fix/docs/coverage-fix.md index 07d0025e1..b3d9f8023 100644 --- a/plugins/lang-js/coverage-provider/fix/docs/coverage-fix.md +++ b/plugins/lang-js/coverage-provider/fix/docs/coverage-fix.md @@ -3,25 +3,24 @@ type: JS Module title: coverage-fix.mjs resource: plugins/lang-js/coverage-provider/fix/coverage-fix.mjs docgen: - crc: 0d33f7b7 - model: openai-codex/gpt-5.4-mini - tier: cloud-min - score: 100 - issues: judge-refine:kept-original,judge:inaccurate:0.98 - judgeModel: openai-codex/gpt-5.4-mini + crc: 252ded78 + model: omlx/gemma-4-e2b-it-4bit + tier: local-min + score: 80 --- ## Огляд -`npx @7n/rules lint test` запускає `runAgentFix` з `@7n/llm-lib/agent-fix` для survived-мутантів coverage-правила `test`: кожен агентний batch отримує контекст із file, line, оригінальним кодом, survived-варіантом і типом мутації, сам знаходить або створює test-файли, щоб убити вцілілі Stryker-мутанти. Зміни реєструються через `recordWrite` у central rollback ladder, а сесія не валить увесь прогін, якщо одна спроба завершується fail-safe. Модель береться з `ctx.model`, а fallback — `CLOUD_MAX` або `N_CURSOR_COVERAGE_FIX_MODEL`. +Fix-шлях survived-мутантів концерну `coverage` правила `test` +(\`npx \@7n/rules lint test\`): агентні fix-сесії `runAgentFix` +(`\@7n/llm-lib/agent-fix`) пишуть тести, що вбивають вцілілі мутанти Stryker. +Агент отримує список мутантів з контекстом (file, line, оригінальний код, +вцілілий варіант, тип мутації) і самостійно знаходить/створює test-файли; +записи реєструються write-guard-ом через `recordWrite` (central rollback +ladder-а). Survived приходять in-memory з violations — читання COVERAGE.md +(колишній coverage-fix-extract) померло разом із файлом. -## Поведінка - -batchSurvived розкладає вцілілі мутанти з уже наявного in-memory списку по файлах у черзі batch-ів так, щоб кожен запуск лишався в межах допустимого бюджету; надто великі source-файли не змішуються назад з іншими, щоб не породжувати oversized prompt і не розмазувати контекст по кількох сесіях. - -buildFixPrompt збирає для кожного batch-а rich-промпт з переліком вцілілих мутантів і локальним контекстом із source-файлу навколо кожного з них; саме цей текст стає вхідними даними для агентної fix-сесії, щоб агент міг сам знайти або створити test-файли без звернення до COVERAGE.md. - -fixSurvivedMutants бере batches, сформовані batchSurvived, і послідовно запускає для кожного окрему agent-fix сесію через runAgentFix; результати кожного batch-а зводяться в діагностику, а успішні зміни реєструються через central write-guard, щоб rollback керувався ladder-ом, а не цим модулем. Якщо один batch падає або завершується без корисної дії, це не зупиняє наступні: помилка фіксується разом зі списком файлів batch-а, прогін продовжується, а власних retry-циклів тут немає. +Модель: `ctx.model` ladder-а, fallback CLOUD_MAX або N_CURSOR_COVERAGE_FIX_MODEL. ## Публічний API @@ -37,7 +36,11 @@ recordWrite — rollback вирішує ladder, не цей модуль), і п - buildFixPrompt — Формує rich-промпт для агента: список вцілілих мутантів згрупований по файлах, з контекстом ±3 рядки навколо кожного мутанта з source-файлу. +## Сценарії використання + +- `plugins/lang-js/coverage-provider/tests/coverage-fix.test.mjs` (coverage-fix.mjs; batchSurvived) — returns empty array for empty input; keeps groups under budget in a single batch; splits into new batch once budget would be exceeded; splits an oversized source-file into isolated 20-mutant sub-batches; logs and returns early when survived is empty; ще 10 + ## Гарантії поведінки -- Власних операцій запису (ФС/БД) у файлі немає; виклики імпортованих модулів можуть писати. -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). +- Містить локальні fail-safe гілки; інші помилки можуть поширюватися назовні. +- Кешує результати в межах одного прогону. diff --git a/plugins/lang-js/coverage-provider/js-collector.mjs b/plugins/lang-js/coverage-provider/js-collector.mjs index 563e55e72..cc3f74df3 100644 --- a/plugins/lang-js/coverage-provider/js-collector.mjs +++ b/plugins/lang-js/coverage-provider/js-collector.mjs @@ -315,6 +315,79 @@ function isTargetMutant(target, candidate) { ) } +/** + * Повертає стандартизований failed verdict із накопиченими лічильниками. + * @param {{targetCount:number,killed:number,remaining:number,covered0:number}} tally накопичені метрики batch-а + * @param {string} reason причина зупинки scoped перевірки + * @returns {{ok:false,targetCount:number,killed:number,remaining:number,covered0:number,cacheIndependent:true,reason:string}} failed verdict + */ +function scopedMutationFailure(tally, reason) { + return { ok: false, ...tally, cacheIndependent: true, reason } +} + +/** + * Оновлює лічильники scoped verdict-а та повертає опис непідтриманого статусу. + * @param {string|undefined} status статус мутанта зі Stryker report + * @param {{killed:number,remaining:number,covered0:number}} tally накопичені метрики batch-а + * @returns {string|null} помилка для невідомого статусу або `null` + */ +function recordScopedMutationStatus(status, tally) { + if (status === 'Killed' || status === 'Timeout') { + tally.killed += 1 + return null + } + switch (status) { + case 'NoCoverage': { + tally.covered0 += 1 + tally.remaining += 1 + return null + } + case 'Survived': { + tally.remaining += 1 + return null + } + default: { + return `reporter failure: target має непідтверджений статус ${status ?? 'unknown'}` + } + } +} + +/** + * Перевіряє один JS-root і додає результати до спільних лічильників batch-а. + * @param {string} root абсолютний шлях JS workspace-а + * @param {string[]} files source-файли batch-а відносно workspace-а + * @param {Array<{file:string,mutants:Array}>} groups цільові мутанти за файлами + * @param {typeof defaultRunner} runner Stryker runner + * @param {{targetCount:number,killed:number,remaining:number,covered0:number}} tally накопичені метрики batch-а + * @returns {Promise} текст помилки або `null` після успішної перевірки + */ +async function verifyScopedMutationRoot(root, files, groups, runner, tally) { + const reportDir = await mkdtemp(join(tmpdir(), 'stryker-scoped-')) + const reportPath = join(reportDir, 'mutation.json') + try { + const code = await runner.runStryker({ cwd: root, mutate: [...new Set(files)], isolatedReportPath: reportPath }) + if (code !== 0) return `reporter failure: Stryker exit ${code}` + if (!existsSync(reportPath)) return 'reporter failure: scoped Stryker не залишив isolated mutation.json' + + const report = JSON.parse(await readFile(reportPath, 'utf8')) + for (const group of groups) { + const mutants = report.files?.[group.file]?.mutants + if (!Array.isArray(mutants)) return `reporter failure: scoped report не містить ${group.file}` + for (const target of group.mutants) { + tally.targetCount += 1 + const result = mutants.find(mutant => isTargetMutant(target, mutant)) + if (!result) + return `reporter failure: scoped report не містить target mutant ${group.file}:${target.line}:${target.col}` + const statusError = recordScopedMutationStatus(result.status, tally) + if (statusError) return statusError + } + } + return null + } finally { + await rm(reportDir, { recursive: true, force: true }) + } +} + /** * Один cache-independent scoped Stryker-прогін після agent test-write. Він бере * consumer config за основу, але пише report у тимчасову директорію й примусово @@ -331,7 +404,7 @@ export async function verifyScopedMutationBatch({ cwd, batch, runner = defaultRu const candidates = roots .map(root => ({ root, file: scopeToRoot([group.file], cwd, root)[0] })) .filter(candidate => candidate.file) - .sort((a, b) => b.root.length - a.root.length) + .toSorted((a, b) => b.root.length - a.root.length) const selected = candidates[0] if (!selected) { return { @@ -350,98 +423,19 @@ export async function verifyScopedMutationBatch({ cwd, batch, runner = defaultRu perRoot.set(selected.root, entry) } - let targetCount = 0 - let killed = 0 - let remaining = 0 - let covered0 = 0 + const tally = { targetCount: 0, killed: 0, remaining: 0, covered0: 0 } for (const [root, { files, groups }] of perRoot) { - const reportDir = await mkdtemp(join(tmpdir(), 'stryker-scoped-')) - const reportPath = join(reportDir, 'mutation.json') - try { - const code = await runner.runStryker({ - cwd: root, - mutate: [...new Set(files)], - isolatedReportPath: reportPath - }) - if (code !== 0) { - return { - ok: false, - targetCount, - killed, - remaining, - covered0, - cacheIndependent: true, - reason: `reporter failure: Stryker exit ${code}` - } - } - if (!existsSync(reportPath)) { - return { - ok: false, - targetCount, - killed, - remaining, - covered0, - cacheIndependent: true, - reason: 'reporter failure: scoped Stryker не залишив isolated mutation.json' - } - } - const report = JSON.parse(await readFile(reportPath, 'utf8')) - for (const group of groups) { - const mutants = report.files?.[group.file]?.mutants - if (!Array.isArray(mutants)) { - return { - ok: false, - targetCount, - killed, - remaining, - covered0, - cacheIndependent: true, - reason: `reporter failure: scoped report не містить ${group.file}` - } - } - for (const target of group.mutants) { - targetCount += 1 - const result = mutants.find(mutant => isTargetMutant(target, mutant)) - if (!result) { - return { - ok: false, - targetCount, - killed, - remaining, - covered0, - cacheIndependent: true, - reason: `reporter failure: scoped report не містить target mutant ${group.file}:${target.line}:${target.col}` - } - } - if (result.status === 'Killed' || result.status === 'Timeout') killed += 1 - else if (result.status === 'NoCoverage') { - covered0 += 1 - remaining += 1 - } else if (result.status === 'Survived') remaining += 1 - else { - return { - ok: false, - targetCount, - killed, - remaining, - covered0, - cacheIndependent: true, - reason: `reporter failure: target має непідтверджений статус ${result.status ?? 'unknown'}` - } - } - } - } - } finally { - await rm(reportDir, { recursive: true, force: true }) - } + const error = await verifyScopedMutationRoot(root, files, groups, runner, tally) + if (error) return scopedMutationFailure(tally, error) } - const reason = - killed === 0 - ? covered0 > 0 + let reason = null + if (tally.killed === 0) { + reason = + tally.covered0 > 0 ? 'covered 0: target mutants лишились без покриття' : 'no-improvement: жоден target mutant не вбитий' - : null - return { ok: killed > 0, targetCount, killed, remaining, covered0, cacheIndependent: true, reason } + } + return { ok: tally.killed > 0, ...tally, cacheIndependent: true, reason } } /** @@ -758,10 +752,12 @@ async function refreshParsedMutationReport(jsRoot, cwd, runner, refreshFiles, is if (code !== 0) throw new Error(`js coverage: fresh scoped Stryker exit ${code}`) if (!existsSync(reportPath)) throw new Error('js coverage: fresh scoped Stryker не залишив mutation.json') const canonicalPath = join(jsRoot, 'reports', 'stryker', 'mutation.json') - const [canonical, fresh] = await Promise.all([ - readFile(canonicalPath, 'utf8').then(JSON.parse), - readFile(reportPath, 'utf8').then(JSON.parse) + const [canonicalSource, freshSource] = await Promise.all([ + readFile(canonicalPath, 'utf8'), + readFile(reportPath, 'utf8') ]) + const canonical = JSON.parse(canonicalSource) + const fresh = JSON.parse(freshSource) return parseStrykerReport({ ...canonical, files: { ...canonical.files, ...fresh.files } }, jsRoot) } finally { await rm(reportDir, { recursive: true, force: true }) diff --git a/plugins/lang-js/coverage-provider/tests/coverage-fix.test.mjs b/plugins/lang-js/coverage-provider/tests/coverage-fix.test.mjs index c71828ac9..69912f013 100644 --- a/plugins/lang-js/coverage-provider/tests/coverage-fix.test.mjs +++ b/plugins/lang-js/coverage-provider/tests/coverage-fix.test.mjs @@ -342,7 +342,7 @@ describe('coverage-fix.mjs', () => { }) expect(runAgentFix).toHaveBeenCalledTimes(1) - expect(runAgentFix.mock.calls.every(([, , , opts]) => opts.sourceFiles[0] === 'run/api/src/constants.js')).toBe( + expect(runAgentFix.mock.calls.every(call => call[3].sourceFiles[0] === 'run/api/src/constants.js')).toBe( true ) expect(result.failed[0]).toMatchObject({ files: ['run/api/src/constants.js'], error: 'fix timeout 95999ms' }) diff --git a/plugins/lang-js/coverage-provider/tests/js-collector.test.mjs b/plugins/lang-js/coverage-provider/tests/js-collector.test.mjs index 8e22babc2..6ac1c1877 100644 --- a/plugins/lang-js/coverage-provider/tests/js-collector.test.mjs +++ b/plugins/lang-js/coverage-provider/tests/js-collector.test.mjs @@ -140,6 +140,7 @@ describe('js coverage collect()', () => { writeFileSync(join(lcovDir, 'lcov.info'), 'LF:1\nLH:1\nFNF:1\nFNH:1\n') return 0 }, + // eslint-disable-next-line sonarjs/no-invariant-returns -- mock runner завжди сигналізує успішний Stryker exit. runStryker({ isolatedReportPath }) { if (!isolatedReportPath) return 0 // consumer incremental run leaves stale canonical report intact writeFileSync( diff --git a/plugins/lang-js/package.json b/plugins/lang-js/package.json index 19f62bea3..6b2ee3aa0 100644 --- a/plugins/lang-js/package.json +++ b/plugins/lang-js/package.json @@ -3,10 +3,10 @@ "version": "0.23.3", "description": "Плагін @7n/rules: JS/npm/bun-екосистема — lint-правила (js/bun/vue/js-run/npm-module/db), taze-провайдер (package.json, bunx taze) і doc-files-екстрактори (oxc AST)", "keywords": [ - "javascript", "bun", - "taze", - "n-rules-plugin" + "javascript", + "n-rules-plugin", + "taze" ], "homepage": "https://github.com/nitra/7n-rules#readme", "bugs": { @@ -19,7 +19,6 @@ "url": "git+https://github.com/nitra/7n-rules.git", "directory": "plugins/lang-js" }, - "type": "module", "files": [ "coverage-provider", "doc-files", @@ -32,31 +31,9 @@ "!**/fixtures/**", "!**/tests/**" ], - "n-rules": { - "requiresPluginApi": 2, - "capabilities": [ - "lang:js" - ], - "slots": { - "provides": [ - { "slot": "rules.directory", "version": 1, "id": "js-rules", "resource": "./rules" }, - { "slot": "taze.provider", "version": 1, "id": "taze-js", "resource": "./taze/provider.mjs" }, - { "slot": "coverage.provider", "version": 1, "id": "coverage-js", "resource": "./coverage-provider/provider.mjs" }, - { "slot": "doc-files.extractor", "version": 1, "id": "doc-files-js", "resource": "./doc-files/extractors.mjs" }, - { - "slot": "doc-files.extensions", - "version": 1, - "id": "doc-files-ext-js", - "value": { - ".js": "JS Module", - ".mjs": "JS Module", - ".ts": "TS Module", - ".vue": "Vue Component" - } - }, - { "slot": "skills.fragment", "version": 1, "id": "taze", "resource": "./skills/taze/SKILL.fragment.md" } - ] - } + "type": "module", + "publishConfig": { + "access": "public" }, "dependencies": { "eslint": "^10.4.1", @@ -64,7 +41,6 @@ "jscpd": "^5.0.11", "knip": "^6.22.0", "oxc-parser": "^0.137.0", - "oxlint": "^1.68.0", "stylelint": "^17.6.0" }, "peerDependencies": { @@ -72,13 +48,6 @@ "@7n/rules": ">=1.52.0", "vue": "^3.0.0" }, - "publishConfig": { - "access": "public" - }, - "engines": { - "bun": ">=1.3", - "node": ">=24" - }, "peerDependenciesMeta": { "@7n/llm-lib": { "optional": true @@ -86,5 +55,60 @@ "vue": { "optional": true } + }, + "engines": { + "bun": ">=1.3", + "node": ">=24" + }, + "n-rules": { + "requiresPluginApi": 2, + "capabilities": [ + "lang:js" + ], + "slots": { + "provides": [ + { + "slot": "rules.directory", + "version": 1, + "id": "js-rules", + "resource": "./rules" + }, + { + "slot": "taze.provider", + "version": 1, + "id": "taze-js", + "resource": "./taze/provider.mjs" + }, + { + "slot": "coverage.provider", + "version": 1, + "id": "coverage-js", + "resource": "./coverage-provider/provider.mjs" + }, + { + "slot": "doc-files.extractor", + "version": 1, + "id": "doc-files-js", + "resource": "./doc-files/extractors.mjs" + }, + { + "slot": "doc-files.extensions", + "version": 1, + "id": "doc-files-ext-js", + "value": { + ".js": "JS Module", + ".mjs": "JS Module", + ".ts": "TS Module", + ".vue": "Vue Component" + } + }, + { + "slot": "skills.fragment", + "version": 1, + "id": "taze", + "resource": "./skills/taze/SKILL.fragment.md" + } + ] + } } } diff --git a/plugins/lang-js/rules/js-bun-db/lib/bun-sql-scan.mjs b/plugins/lang-js/rules/js-bun-db/lib/bun-sql-scan.mjs index f32a07bb4..c6346cba7 100644 --- a/plugins/lang-js/rules/js-bun-db/lib/bun-sql-scan.mjs +++ b/plugins/lang-js/rules/js-bun-db/lib/bun-sql-scan.mjs @@ -1016,16 +1016,15 @@ export function findJsonStringifyBeforeJsonbInText(content, virtualPath = 'scan. if (!isDirectStringify && !hasSqlArrayStringify) continue + // `text[] → unnest → ::jsonb` є окремим безпечним контрактом: stringify + // зберігає вкладений JSON-масив, тоді як Bun SQL інакше розгортає його. + if (isTextArrayCall(expr)) continue // Quasi після expr (quasi[i+1]) — текст одразу після закриваючого } const nextQuasi = quasis[i + 1] const rawAfter = nextQuasi && typeof nextQuasi === 'object' && nextQuasi.value && typeof nextQuasi.value.raw === 'string' ? nextQuasi.value.raw : '' - - // `text[] → unnest → ::jsonb` є окремим безпечним контрактом: stringify - // зберігає вкладений JSON-масив, тоді як Bun SQL інакше розгортає його. - if (isTextArrayCall(expr)) continue if (JSONB_CAST_RE.test(rawAfter) || hasSqlArrayStringify) { out.push({ line: offsetToLine(content, expr.start), diff --git a/plugins/lang-js/rules/js-bun-db/lib/docs/bun-sql-scan.md b/plugins/lang-js/rules/js-bun-db/lib/docs/bun-sql-scan.md index a6f161e48..f3be291b9 100644 --- a/plugins/lang-js/rules/js-bun-db/lib/docs/bun-sql-scan.md +++ b/plugins/lang-js/rules/js-bun-db/lib/docs/bun-sql-scan.md @@ -3,33 +3,31 @@ type: JS Module title: bun-sql-scan.mjs resource: plugins/lang-js/rules/js-bun-db/lib/bun-sql-scan.mjs docgen: - crc: 9b920f97 - model: openai-codex/gpt-5.5 - tier: cloud-avg - score: 100 - issues: judge-refine:kept-original,judge:inaccurate:0.99 - judgeModel: openai-codex/gpt-5.4-mini + crc: fd70e9dc + model: omlx/gemma-4-e2b-it-4bit + tier: local-min + score: 80 --- ## Огляд -AST-сканер для Bun SQL (`import { sql, SQL } from 'bun'`) знаходить патерни, небезпечні для міграції, через `oxc-parser`, без regex по тексту коду. Він виявляє `new SQL` всередині функції, бо пул підключень має бути module-level singleton, а не створюватися на кожен виклик handler-а. +AST-сканер небезпечних патернів Bun SQL (`import { sql, SQL } from 'bun'`). -Сканер забороняє будь-який `.unsafe` без маркера `// n-rules:allow-unsafe: ` на тому ж або попередньому рядку. Виняток має бути явно пояснений для ревʼю: `unsafe` допустимий лише для контрольованих кодом SQL-ідентифікаторів або dynamic SQL/DDL, а не для user input. +Знаходить: +- `new SQL(...)` всередині функції — пул має бути singleton на рівні модуля, + а не на кожен виклик handler-а. +- Будь-який виклик `.unsafe(...)` без маркера-коментаря `// n-rules:allow-unsafe: ` + на тому ж рядку або рядком вище. `sql.unsafe` за замовчуванням заборонено: дозволено + тільки якщо значення контролюється кодом (не user input) і потрібно підставити + назву таблиці/колонки або dynamic SQL/DDL. Інакше — переробити на tagged template + `sql\`...\${value}...\``. Маркер фіксує цю причину для ревʼюера. +- Динамічні SQL-списки у tagged template `sql\`... IN (${arr.join(',')}) ...\``: + навіть «через tagged template» у запит потрапляє готовий шматок SQL замість + параметризованих значень — треба `sql([...])`. -Також сканер знаходить залишки `pg`, небезпечні SQL-списки на кшталт `arr.join` у tagged template, випадки без `sql`, JSONB-серіалізацію та масиви без явного pg-типу. Якщо файл не парситься або має syntax errors, результат порожній: спочатку треба виправити синтаксис і повторити перевірку. - -## Поведінка - -Сканування починається з відбору файлів через isBunSqlScanSourceFile або дешевих текстових попередніх фільтрів на кшталт textHasPgLibImport. Далі публічні пошукові функції отримують вихідний текст, будують AST і повертають списки знахідок із рядком та фрагментом коду; якщо код не парситься, результат порожній, бо синтаксис має бути виправлений до повторної перевірки. - -findBunSqlPerRequestConnectionInText, findBunSqlUnsafeUseWithoutAllowMarkerInText, findBunSqlUnsafeWithInterpolatedTemplateInText, findBunSqlPgLeftoverCallInText, findUnsafeBunSqlDynamicSqlListInText, findUnsafeBunSqlInListMissingEmptyGuardInText, findJsonStringifyBeforeJsonbInText і findSqlArrayWithoutTypeArgInText разом формують перевірки міграції на Bun SQL: вони ловлять створення пулу в runtime-шляху, небезпечний dynamic SQL, залишки pg-поведінки, некоректні списки, зайву JSON-серіалізацію та масиви без явного pg-типу. Маркери дозволу враховуються лише як локальний opt-in для ревʼю і повідомлень правила (js-bun-db.mdc), але не скасовують заборону на інтерпольовані unsafe-шаблони. - -findPgFormatShimDefinitionInText і findPgFormatLikeQueryWrapperInText працюють як додатковий шар проти маскування старих pg-підходів: вони знаходять сумісні з pg-format шими та query-обгортки, які повертають injection-поверхню під безпечними назвами. - -findPgLibImportInText деталізує місця імпорту pg після текстового сигналу від textHasPgLibImport, а findPgListenNotifyUsageInText позначає випадки LISTEN/NOTIFY як причину, чому pg може лишатися свідомим винятком під час міграції. - -Усі результати залишаються в памʼяті та повертаються викликачеві для lint-повідомлень; файл не записує зміни у ФС чи БД. +Семантика — через **oxc-parser**, без regex по тексту коду. +Якщо файл не парситься / містить синтаксичні помилки — повертаємо порожній +результат: спочатку треба полагодити синтаксис, потім перезапустити перевірку. ## Публічний API @@ -109,6 +107,10 @@ Bun SQL серіалізує об'єкти/масиви у JSON автомати обов'язкового другого аргументу (типу pg-елемента). Без типу Bun не може вивести pg-тип, що призводить до mismatch (js-bun-db.mdc). +## Сценарії використання + +- `plugins/lang-js/rules/js-bun-db/lib/tests/bun-sql-scan.test.mjs` (isBunSqlScanSourceFile; textHasBunSqlImport) — true для .mjs, .ts, .tsx, .cjs; false для .d.ts (декларації); false для нерелевантних розширень; true для .js (base case); true для import { sql } from; ще 66 + ## Гарантії поведінки - Власних операцій запису (ФС/БД) у файлі немає; виклики імпортованих модулів можуть писати. diff --git a/plugins/lang-js/rules/vue/lib/docs/vue-forbidden-imports.md b/plugins/lang-js/rules/vue/lib/docs/vue-forbidden-imports.md index 6de063347..98b057f65 100644 --- a/plugins/lang-js/rules/vue/lib/docs/vue-forbidden-imports.md +++ b/plugins/lang-js/rules/vue/lib/docs/vue-forbidden-imports.md @@ -3,263 +3,51 @@ type: JS Module title: vue-forbidden-imports.mjs resource: plugins/lang-js/rules/vue/lib/vue-forbidden-imports.mjs docgen: - crc: aaa72840 + crc: 3e8a448e + model: omlx/gemma-4-e2b-it-4bit + tier: local-min + score: 80 --- -Модуль `vue-forbidden-imports.mjs` — це бібліотека статичного аналізу `import`-декларацій, призначена для виявлення двох категорій порушень у вихідному коді Vue-проєкту: +## Огляд -1. **Явні (runtime) імпорти з модуля `vue`** у будь-яких файлах, що сканує правило. За конвенцією `vue.mdc` у проєкті працює `unplugin-auto-import`, тому імпорти `ref`, `computed`, `watch` тощо мають бути неявними. Дозволено лише: side-effect форму (`import 'vue'`), повністю type-only імпорти (`import type { ... } from 'vue'`) та змішані форми, де **всі** іменовані записи мають флаг `isType` (наприклад, `import { type A, type B } from 'vue'`). -2. **Імпорти Node-нативних модулів усередині `.vue` SFC** — `node:fs`, `node:timers/promises`, а також bare-форми вбудованих модулів (`fs`, `path`, `crypto`, `fs/promises` тощо). Vue Single-File Components виконуються в браузерному середовищі, де Node API недоступне, тому такі імпорти зривають збірку чи призводять до runtime-помилок. +Визначає явні імпорти з модуля `vue`, які суперечать vue.mdc (має працювати unplugin-auto-import), +а також заборонені імпорти Node-нативних модулів у `.vue` SFC (`node:*` префікс або bare ім’я +вбудованого модуля Node — `fs`, `path`, `timers/promises` тощо). Vue SFC виконується у браузері, +де Node API недоступне, тож такі імпорти ламають збірку/рантайм. -Аналіз виконується через **oxc-parser** (`parseSync`) — ESTree-сумісний AST-парсер, що повертає об'єкт із полем `module.staticImports`. Це усуває потребу в крихких регулярних виразах для розпізнавання структури імпортів і коректно обробляє TypeScript-синтаксис (включно з type-only записами через флаг `entries[].isType`). +Аналіз import виконується через **oxc-parser** (`parseSync`, поле `module.staticImports`) — ESTree-сумісний +розбір без евристик по рядках. Дозволені лише side-effect `import 'vue'`, повністю type-only імпорти +та `import { type A, type B } from 'vue'` (перевірка `entries[].isType`). -Для `.vue` файлів виконується попередній етап: регулярним виразом витягуються вмісти всіх тегів `