From 8968d7186bcf146eb718441e9978ac2782de1b90 Mon Sep 17 00:00:00 2001 From: vitaliytv Date: Tue, 28 Jul 2026 17:17:03 +0300 Subject: [PATCH 01/13] feat(npm): sync PostToolUse/Stop hooks for Codex CLI (.codex/hooks.json) Adds Codex CLI as a third hook sync target alongside Claude Code and Cursor: per-file PostToolUse lint hook (matcher `apply_patch`, V4A-patch path extraction) plus opt-in ADR Stop-hooks and rtk PreToolUse group, mirroring the existing Claude/Cursor mechanics in sync-claude-config.mjs. Known risks documented inline: Codex's apply_patch PostToolUse coverage is unconfirmed upstream (openai/codex#16732), and the rtk PreToolUse subcommand for Codex is a best-guess (rtk's actual Codex integration is AGENTS.md-based, not hook-rewrite-based). Co-Authored-By: Claude Sonnet 5 --- .v8rignore | 1 + .../codex-hooks.template.json | 16 ++ npm/bin/n-rules-cli.mjs | 1 + npm/scripts/docs/hook.md | 45 +++-- npm/scripts/docs/sync-claude-config.md | 167 ++++++++++-------- npm/scripts/hook.mjs | 76 ++++++-- npm/scripts/sync-claude-config.mjs | 157 +++++++++++++++- npm/scripts/tests/hook.test.mjs | 98 ++++++++++ npm/scripts/tests/sync-claude-config.test.mjs | 95 ++++++++++ 9 files changed, 552 insertions(+), 104 deletions(-) create mode 100644 npm/.claude-template/codex-hooks.template.json create mode 100644 npm/scripts/tests/hook.test.mjs 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/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/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/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..636ce9dcc 100644 --- a/npm/scripts/docs/sync-claude-config.md +++ b/npm/scripts/docs/sync-claude-config.md @@ -3,87 +3,106 @@ 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: c5fe96c7 + 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`). +- RTK_CODEX_HOOK_COMMAND_MARKER — Маркер rtk PreToolUse hook'а у `.codex/hooks.json` (правило `local-ai`) — best-guess +підкоманда, НЕ підтверджена офіційно. rtk (rtk-ai/rtk) додав підтримку Codex CLI через +AGENTS.md-інструкції (PR rtk-ai/rtk#377 «add Codex CLI support via AGENTS.md + RTK.md +workflow»), а не через programmatic PreToolUse command-rewrite, як для Claude/Cursor — +тому чи існує взагалі підкоманда `rtk hook codex`, не перевірено. Fail-open guard +(`command -v rtk`) рятує лише від «rtk не встановлено»: якщо rtk встановлено, але +підкоманда відсутня, rtk поверне ненульовий exit і PreToolUse СПРАЦЮЄ як блокуючий — +на відміну від Claude/Cursor guard тут немає страховки саме на цей випадок. Виправити, +коли rtk підтвердить реальний контракт для Codex. +- 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 групи та rtk PreToolUse-група. +На відміну від `.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 (; ще 44 ## Гарантії поведінки -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). +- Містить локальні fail-safe гілки; інші помилки можуть поширюватися назовні. - Свідомо пропускає шляхи: `.git`. diff --git a/npm/scripts/hook.mjs b/npm/scripts/hook.mjs index cc065c359..94a24b455 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 ? moveMatch[1] : 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/sync-claude-config.mjs b/npm/scripts/sync-claude-config.mjs index 42ee69dda..e09e37b92 100644 --- a/npm/scripts/sync-claude-config.mjs +++ b/npm/scripts/sync-claude-config.mjs @@ -62,6 +62,18 @@ export const CURSOR_ADR_NORMALIZE_HOOK_COMMAND_MARKER = '.claude/hooks/normalize export const RTK_CLAUDE_HOOK_COMMAND_MARKER = 'rtk hook claude' /** Маркер rtk preToolUse hook'а у `.cursor/hooks.json` (правило `local-ai`). */ export const RTK_CURSOR_HOOK_COMMAND_MARKER = 'rtk hook cursor' +/** + * Маркер rtk PreToolUse hook'а у `.codex/hooks.json` (правило `local-ai`) — best-guess + * підкоманда, НЕ підтверджена офіційно. rtk (rtk-ai/rtk) додав підтримку Codex CLI через + * AGENTS.md-інструкції (PR rtk-ai/rtk#377 «add Codex CLI support via AGENTS.md + RTK.md + * workflow»), а не через programmatic PreToolUse command-rewrite, як для Claude/Cursor — + * тому чи існує взагалі підкоманда `rtk hook codex`, не перевірено. Fail-open guard + * (`command -v rtk`) рятує лише від «rtk не встановлено»: якщо rtk встановлено, але + * підкоманда відсутня, rtk поверне ненульовий exit і PreToolUse СПРАЦЮЄ як блокуючий — + * на відміну від Claude/Cursor guard тут немає страховки саме на цей випадок. Виправити, + * коли rtk підтвердить реальний контракт для Codex. + */ +export const RTK_CODEX_HOOK_COMMAND_MARKER = 'rtk hook codex' /** * Усі маркери managed-hook'ів пакета — за ними відрізняємо свої записи від користувацьких. * Legacy stop-hook включений сюди, щоб старі entries автоматично видалялись при наступному sync-у. @@ -76,7 +88,8 @@ export const MANAGED_HOOK_COMMAND_MARKERS = Object.freeze([ LEGACY_STOP_HOOK_COMMAND_MARKER, ADR_HOOK_COMMAND_MARKER, ADR_NORMALIZE_HOOK_COMMAND_MARKER, - RTK_CLAUDE_HOOK_COMMAND_MARKER + RTK_CLAUDE_HOOK_COMMAND_MARKER, + RTK_CODEX_HOOK_COMMAND_MARKER ]) const CLAUDE_DIR = '.claude' @@ -85,6 +98,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 +192,60 @@ 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 + }) + ]) +}) + +/** + * Канонічна Codex CLI rtk PreToolUse-група (правило `local-ai`) — best-guess, див. + * {@link RTK_CODEX_HOOK_COMMAND_MARKER} щодо непідтвердженого контракту підкоманди. + */ +const CODEX_RTK_PRE_TOOL_USE_HOOK_GROUP = Object.freeze({ + matcher: 'Bash', + hooks: Object.freeze([ + Object.freeze({ + type: 'command', + command: `command -v rtk >/dev/null 2>&1 && exec ${RTK_CODEX_HOOK_COMMAND_MARKER}; exit 0`, + timeout: 30 + }) + ]) +}) + /** * @typedef {object} HookEntry * @property {string} type тип hook'а у форматі Claude Code (зазвичай `'command'`) @@ -436,6 +505,87 @@ 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 із доданою rtk PreToolUse-групою. + * @param {Record | undefined} hooks вихідна hooks-секція темплейту + * @returns {Record} копія з доданою rtk-групою + */ +function codexHooksWithRtkHook(hooks) { + /** @type {Record} */ + const out = {} + for (const [event, groups] of Object.entries(hooks ?? {})) { + out[event] = Array.isArray(groups) ? [...groups] : [] + } + out.PreToolUse = [...(out.PreToolUse ?? []), CODEX_RTK_PRE_TOOL_USE_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`) + * @param {boolean} [options.includeLocalAiHook] чи додати rtk PreToolUse-групу (правило `local-ai`) + * @returns {Record} результат merge-у (порожні події видаляються) + */ +export function mergeCodexHooks(existing, template, options = {}) { + let effectiveTemplate = options.includeAdrHook ? codexHooksWithAdrHook(template) : (template ?? {}) + if (options.includeLocalAiHook) { + effectiveTemplate = codexHooksWithRtkHook(effectiveTemplate) + } + 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 групи та rtk PreToolUse-група. + * На відміну від `.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 групи + * @param {boolean} [options.includeLocalAiHook] чи додавати rtk PreToolUse-групу + * @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 +885,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 +937,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, includeLocalAiHook }) 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..81cbd9d46 100644 --- a/npm/scripts/tests/sync-claude-config.test.mjs +++ b/npm/scripts/tests/sync-claude-config.test.mjs @@ -20,11 +20,13 @@ import { LEGACY_STOP_HOOK_COMMAND_MARKER, MANAGED_HOOK_COMMAND_MARKER, mergeAllowList, + mergeCodexHooks, mergeCursorHooksConfig, mergeHooks, mergeSettings, removeOrphanAdrHookLib, RTK_CLAUDE_HOOK_COMMAND_MARKER, + RTK_CODEX_HOOK_COMMAND_MARKER, RTK_CURSOR_HOOK_COMMAND_MARKER, RTK_PI_EXTENSION_FILE, syncAdrHookLibScripts, @@ -32,6 +34,7 @@ import { syncClaudeCommands, syncClaudeConfig, syncClaudeSettings, + syncCodexHooksConfig, syncGitignoreAdrFragment } from '../sync-claude-config.mjs' import { withTmpDir, writeJson } from '../utils/test-helpers.mjs' @@ -72,6 +75,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 +394,58 @@ 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('includeLocalAiHook додає rtk-групу у PreToolUse з matcher Bash', () => { + const merged = mergeCodexHooks(undefined, baseTemplate, { includeLocalAiHook: true }) + expect(merged.PreToolUse).toHaveLength(1) + expect(merged.PreToolUse[0].matcher).toBe('Bash') + expect(merged.PreToolUse[0].hooks[0].command).toContain(RTK_CODEX_HOOK_COMMAND_MARKER) + }) + + test('повторний merge не дублює managed-групи; вимкнення прибирає їх', () => { + const withBoth = mergeCodexHooks(undefined, baseTemplate, { includeAdrHook: true, includeLocalAiHook: true }) + const again = mergeCodexHooks(withBoth, baseTemplate, { includeAdrHook: true, includeLocalAiHook: true }) + expect(again.Stop).toHaveLength(2) + expect(again.PreToolUse).toHaveLength(1) + const disabled = mergeCodexHooks(again, baseTemplate, { includeAdrHook: false, includeLocalAiHook: false }) + 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, { includeLocalAiHook: true }) + expect(merged.PreToolUse).toHaveLength(2) + 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 +459,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 +533,7 @@ describe('syncClaudeConfig (інтеграція)', () => { expect(result).toEqual({ settings: false, cursorHooks: false, + codexHooks: false, commands: [], adrHook: false, adrNormalizeHook: false, @@ -754,6 +830,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) From 8937f69df637c78c49cb534fbd3ef35859a0a9d4 Mon Sep 17 00:00:00 2001 From: vitaliytv Date: Tue, 28 Jul 2026 17:19:27 +0300 Subject: [PATCH 02/13] fix(deps): refresh stale bun.lock (frozen-lockfile drift) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bun.lock was out of sync with workspace package.json versions (pre-existing drift, unrelated to the Codex hooks change) — CI's bun install --frozen-lockfile failed on this PR because of it. Co-Authored-By: Claude Sonnet 5 --- bun.lock | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/bun.lock b/bun.lock index fbfe568df..e7fe1e716 100644 --- a/bun.lock +++ b/bun.lock @@ -37,7 +37,7 @@ }, "llm-lib": { "name": "@7n/llm-lib", - "version": "2.9.9", + "version": "2.11.0", "bin": { "n-llm-chains-report": "bin/chains-report.mjs", }, @@ -60,13 +60,13 @@ }, "npm": { "name": "@7n/rules", - "version": "1.50.1", + "version": "1.52.2", "bin": { "n-rules": "bin/n-rules.js", "n-cursor": "bin/n-rules.js", }, "dependencies": { - "@7n/llm-lib": "2.9.9", + "@7n/llm-lib": "2.10.1", "@7n/mt": "^0.5.1", "@zed-industries/agent-client-protocol": "^0.4.5", "ajv": "^8.20.0", @@ -90,7 +90,7 @@ }, "plugins/ci-azure": { "name": "@7n/rules-ci-azure", - "version": "1.4.8", + "version": "2.0.1", "dependencies": { "yaml": "^2.9.0", }, @@ -100,7 +100,7 @@ }, "plugins/ci-github": { "name": "@7n/rules-ci-github", - "version": "1.9.8", + "version": "2.0.1", "dependencies": { "yaml": "^2.9.0", }, @@ -110,7 +110,7 @@ }, "plugins/lang-js": { "name": "@7n/rules-lang-js", - "version": "0.22.20", + "version": "0.23.2", "dependencies": { "eslint": "^10.4.1", "globby": "^16.0.0", @@ -132,14 +132,14 @@ }, "plugins/lang-php": { "name": "@7n/rules-lang-php", - "version": "0.1.0", + "version": "0.2.1", "peerDependencies": { "@7n/rules": ">=1.2.0", }, }, "plugins/lang-python": { "name": "@7n/rules-lang-python", - "version": "0.10.1", + "version": "0.11.1", "dependencies": { "globby": "^16.0.0", "smol-toml": "^1.7.0", @@ -154,7 +154,7 @@ }, "plugins/lang-rust": { "name": "@7n/rules-lang-rust", - "version": "0.13.1", + "version": "0.14.1", "dependencies": { "globby": "^16.0.0", "smol-toml": "^1.7.0", @@ -2537,6 +2537,8 @@ "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=="], From 0dda84a20a34d69e0e5545620c1345d1a0abde83 Mon Sep 17 00:00:00 2001 From: vitaliytv Date: Tue, 28 Jul 2026 17:30:03 +0300 Subject: [PATCH 03/13] chore: merge main, refresh bun.lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main advanced past this branch's base with a release commit (llm-lib 2.12.0, rules 1.53.0, plugin version bumps) — GitHub's PR merge-preview checkout combines that with this branch, so bun.lock needs to match the merged package.json state, not just the branch tip. Co-Authored-By: Claude Sonnet 5 --- bun.lock | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/bun.lock b/bun.lock index e7fe1e716..fed56c5d3 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,7 +110,7 @@ }, "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", @@ -139,7 +139,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 +2537,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=="], From c04592571d5bb14a16ed6231f33b96fc096ccbfe Mon Sep 17 00:00:00 2001 From: vitaliytv Date: Tue, 28 Jul 2026 17:34:04 +0300 Subject: [PATCH 04/13] fix(scripts): satisfy unicorn/prefer-minimal-ternary in hook.mjs Co-Authored-By: Claude Sonnet 5 --- npm/scripts/hook.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npm/scripts/hook.mjs b/npm/scripts/hook.mjs index 94a24b455..5b70e1e38 100644 --- a/npm/scripts/hook.mjs +++ b/npm/scripts/hook.mjs @@ -78,7 +78,7 @@ export function extractCodexPatchPaths(patch) { const updateMatch = V4A_UPDATE_FILE_RE.exec(lines[i]) if (updateMatch) { const moveMatch = V4A_MOVE_TO_RE.exec(lines[i + 1] ?? '') - paths.push((moveMatch ? moveMatch[1] : updateMatch[1]).trim()) + paths.push((moveMatch || updateMatch)[1].trim()) } } return paths From 55f59f49d681bdbc51e0c1779b75a5f32ea8c166 Mon Sep 17 00:00:00 2001 From: vitaliytv Date: Tue, 28 Jul 2026 20:33:35 +0300 Subject: [PATCH 05/13] fix(npm): configure Codex rtk via AGENTS --- npm/AGENTS.template.md | 4 ++ npm/bin/tests/n-rules-cwd.test.mjs | 12 +++++ npm/scripts/docs/sync-claude-config.md | 17 +++--- npm/scripts/sync-claude-config.mjs | 54 +++---------------- npm/scripts/tests/sync-claude-config.test.mjs | 11 ++-- 5 files changed, 34 insertions(+), 64 deletions(-) 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/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/scripts/docs/sync-claude-config.md b/npm/scripts/docs/sync-claude-config.md index 636ce9dcc..759d9e312 100644 --- a/npm/scripts/docs/sync-claude-config.md +++ b/npm/scripts/docs/sync-claude-config.md @@ -3,7 +3,7 @@ type: JS Module title: sync-claude-config.mjs resource: npm/scripts/sync-claude-config.mjs docgen: - crc: c5fe96c7 + crc: 7e76e00d model: omlx/gemma-4-e2b-it-4bit tier: local-min score: 35 @@ -20,15 +20,9 @@ docgen: - 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`). -- RTK_CODEX_HOOK_COMMAND_MARKER — Маркер rtk PreToolUse hook'а у `.codex/hooks.json` (правило `local-ai`) — best-guess -підкоманда, НЕ підтверджена офіційно. rtk (rtk-ai/rtk) додав підтримку Codex CLI через -AGENTS.md-інструкції (PR rtk-ai/rtk#377 «add Codex CLI support via AGENTS.md + RTK.md -workflow»), а не через programmatic PreToolUse command-rewrite, як для Claude/Cursor — -тому чи існує взагалі підкоманда `rtk hook codex`, не перевірено. Fail-open guard -(`command -v rtk`) рятує лише від «rtk не встановлено»: якщо rtk встановлено, але -підкоманда відсутня, rtk поверне ненульовий exit і PreToolUse СПРАЦЮЄ як блокуючий — -на відміну від Claude/Cursor guard тут немає страховки саме на цей випадок. Виправити, -коли rtk підтвердить реальний контракт для Codex. +- RTK_CODEX_HOOK_COMMAND_MARKER — Legacy-маркер помилкового Codex rtk hook'а. Лишається лише щоб наступний sync прибрав +його зі старих `.codex/hooks.json`; новий hook не генерується, бо rtk не має підкоманди +`hook codex`. - MANAGED_HOOK_COMMAND_MARKERS — Усі маркери managed-hook'ів пакета — за ними відрізняємо свої записи від користувацьких. Legacy stop-hook включений сюди, щоб старі entries автоматично видалялись при наступному sync-у. - PI_DIR — Корінь pi.dev артефактів у проєкті-споживачі. @@ -57,7 +51,8 @@ Cursor читає project-level config з `.cursor/hooks.json`; hook scripts л перевикористовує ту саму {@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 групи та rtk PreToolUse-група. +див. JSDoc {@link ../../hook.mjs}) + опційні ADR Stop-hook групи. Codex не має rtk hook: +інтеграція rtk відбувається через пряму інструкцію в AGENTS.md. На відміну від `.claude/settings.json` тут немає секції `permissions` — Codex тримає дозволи окремо в `config.toml`. - syncClaudeSettings — Синхронізує `.claude/settings.json` за темплейтом, зберігаючи решту diff --git a/npm/scripts/sync-claude-config.mjs b/npm/scripts/sync-claude-config.mjs index e09e37b92..86779e457 100644 --- a/npm/scripts/sync-claude-config.mjs +++ b/npm/scripts/sync-claude-config.mjs @@ -63,15 +63,9 @@ export const RTK_CLAUDE_HOOK_COMMAND_MARKER = 'rtk hook claude' /** Маркер rtk preToolUse hook'а у `.cursor/hooks.json` (правило `local-ai`). */ export const RTK_CURSOR_HOOK_COMMAND_MARKER = 'rtk hook cursor' /** - * Маркер rtk PreToolUse hook'а у `.codex/hooks.json` (правило `local-ai`) — best-guess - * підкоманда, НЕ підтверджена офіційно. rtk (rtk-ai/rtk) додав підтримку Codex CLI через - * AGENTS.md-інструкції (PR rtk-ai/rtk#377 «add Codex CLI support via AGENTS.md + RTK.md - * workflow»), а не через programmatic PreToolUse command-rewrite, як для Claude/Cursor — - * тому чи існує взагалі підкоманда `rtk hook codex`, не перевірено. Fail-open guard - * (`command -v rtk`) рятує лише від «rtk не встановлено»: якщо rtk встановлено, але - * підкоманда відсутня, rtk поверне ненульовий exit і PreToolUse СПРАЦЮЄ як блокуючий — - * на відміну від Claude/Cursor guard тут немає страховки саме на цей випадок. Виправити, - * коли rtk підтвердить реальний контракт для Codex. + * Legacy-маркер помилкового Codex rtk hook'а. Лишається лише щоб наступний sync прибрав + * його зі старих `.codex/hooks.json`; новий hook не генерується, бо rtk не має підкоманди + * `hook codex`. */ export const RTK_CODEX_HOOK_COMMAND_MARKER = 'rtk hook codex' /** @@ -231,21 +225,6 @@ const CODEX_ADR_NORMALIZE_STOP_HOOK_GROUP = Object.freeze({ ]) }) -/** - * Канонічна Codex CLI rtk PreToolUse-група (правило `local-ai`) — best-guess, див. - * {@link RTK_CODEX_HOOK_COMMAND_MARKER} щодо непідтвердженого контракту підкоманди. - */ -const CODEX_RTK_PRE_TOOL_USE_HOOK_GROUP = Object.freeze({ - matcher: 'Bash', - hooks: Object.freeze([ - Object.freeze({ - type: 'command', - command: `command -v rtk >/dev/null 2>&1 && exec ${RTK_CODEX_HOOK_COMMAND_MARKER}; exit 0`, - timeout: 30 - }) - ]) -}) - /** * @typedef {object} HookEntry * @property {string} type тип hook'а у форматі Claude Code (зазвичай `'command'`) @@ -520,21 +499,6 @@ function codexHooksWithAdrHook(hooks) { return out } -/** - * Будує копію hooks-секції темплейту Codex із доданою rtk PreToolUse-групою. - * @param {Record | undefined} hooks вихідна hooks-секція темплейту - * @returns {Record} копія з доданою rtk-групою - */ -function codexHooksWithRtkHook(hooks) { - /** @type {Record} */ - const out = {} - for (const [event, groups] of Object.entries(hooks ?? {})) { - out[event] = Array.isArray(groups) ? [...groups] : [] - } - out.PreToolUse = [...(out.PreToolUse ?? []), CODEX_RTK_PRE_TOOL_USE_HOOK_GROUP] - return out -} - /** * Зливає hooks-секцію `.codex/hooks.json`. Формат ідентичний `.claude/settings.json.hooks` * (matcher + hooks[] з type/command/timeout — підтверджено `vendor/codex-hooks.json`), тож @@ -543,28 +507,26 @@ function codexHooksWithRtkHook(hooks) { * @param {Record | undefined} template hooks-секція з `codex-hooks.template.json` * @param {object} [options] опції merge-у * @param {boolean} [options.includeAdrHook] чи додати ADR Stop-hook групи (правило `adr`) - * @param {boolean} [options.includeLocalAiHook] чи додати rtk PreToolUse-групу (правило `local-ai`) + * @param {boolean} [options.includeLocalAiHook] застарілий прапорець: Codex rtk hook не підтримується * @returns {Record} результат merge-у (порожні події видаляються) */ export function mergeCodexHooks(existing, template, options = {}) { - let effectiveTemplate = options.includeAdrHook ? codexHooksWithAdrHook(template) : (template ?? {}) - if (options.includeLocalAiHook) { - effectiveTemplate = codexHooksWithRtkHook(effectiveTemplate) - } + 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 групи та rtk PreToolUse-група. + * див. 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 групи - * @param {boolean} [options.includeLocalAiHook] чи додавати rtk PreToolUse-групу + * @param {boolean} [options.includeLocalAiHook] застарілий прапорець, збережений для сумісності викликів * @returns {Promise<{ written: boolean, path: string }>} результат: чи писали файл, та його відносний шлях */ export async function syncCodexHooksConfig(projectRoot, templateDir, options = {}) { diff --git a/npm/scripts/tests/sync-claude-config.test.mjs b/npm/scripts/tests/sync-claude-config.test.mjs index 81cbd9d46..d0f07f69c 100644 --- a/npm/scripts/tests/sync-claude-config.test.mjs +++ b/npm/scripts/tests/sync-claude-config.test.mjs @@ -26,7 +26,6 @@ import { mergeSettings, removeOrphanAdrHookLib, RTK_CLAUDE_HOOK_COMMAND_MARKER, - RTK_CODEX_HOOK_COMMAND_MARKER, RTK_CURSOR_HOOK_COMMAND_MARKER, RTK_PI_EXTENSION_FILE, syncAdrHookLibScripts, @@ -420,18 +419,16 @@ describe('mergeCodexHooks', () => { expect(merged.Stop[1].hooks[0].command).toContain('.claude/hooks/normalize-decisions.sh') }) - test('includeLocalAiHook додає rtk-групу у PreToolUse з matcher Bash', () => { + test('includeLocalAiHook не додає непідтримуваний rtk hook у Codex', () => { const merged = mergeCodexHooks(undefined, baseTemplate, { includeLocalAiHook: true }) - expect(merged.PreToolUse).toHaveLength(1) - expect(merged.PreToolUse[0].matcher).toBe('Bash') - expect(merged.PreToolUse[0].hooks[0].command).toContain(RTK_CODEX_HOOK_COMMAND_MARKER) + expect(merged.PreToolUse).toBeUndefined() }) test('повторний merge не дублює managed-групи; вимкнення прибирає їх', () => { const withBoth = mergeCodexHooks(undefined, baseTemplate, { includeAdrHook: true, includeLocalAiHook: true }) const again = mergeCodexHooks(withBoth, baseTemplate, { includeAdrHook: true, includeLocalAiHook: true }) expect(again.Stop).toHaveLength(2) - expect(again.PreToolUse).toHaveLength(1) + expect(again.PreToolUse).toBeUndefined() const disabled = mergeCodexHooks(again, baseTemplate, { includeAdrHook: false, includeLocalAiHook: false }) expect(disabled.Stop).toBeUndefined() expect(disabled.PreToolUse).toBeUndefined() @@ -441,7 +438,7 @@ describe('mergeCodexHooks', () => { test('зберігає користувацькі групи поряд з managed', () => { const existing = { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo my-hook' }] }] } const merged = mergeCodexHooks(existing, baseTemplate, { includeLocalAiHook: true }) - expect(merged.PreToolUse).toHaveLength(2) + expect(merged.PreToolUse).toHaveLength(1) expect(merged.PreToolUse[0].hooks[0].command).toBe('echo my-hook') }) }) From 27bc41784d64f4e685a76863511e29c5130daedf Mon Sep 17 00:00:00 2001 From: vitaliytv Date: Tue, 28 Jul 2026 20:51:01 +0300 Subject: [PATCH 06/13] refactor(npm): remove obsolete Codex rtk option --- npm/scripts/docs/sync-claude-config.md | 4 ++-- npm/scripts/sync-claude-config.mjs | 4 +--- npm/scripts/tests/sync-claude-config.test.mjs | 13 ++++--------- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/npm/scripts/docs/sync-claude-config.md b/npm/scripts/docs/sync-claude-config.md index 759d9e312..5062cd0e7 100644 --- a/npm/scripts/docs/sync-claude-config.md +++ b/npm/scripts/docs/sync-claude-config.md @@ -3,7 +3,7 @@ type: JS Module title: sync-claude-config.mjs resource: npm/scripts/sync-claude-config.mjs docgen: - crc: 7e76e00d + crc: dddda436 model: omlx/gemma-4-e2b-it-4bit tier: local-min score: 35 @@ -95,7 +95,7 @@ cleanup-у `.claude/hooks/{capture,normalize}-decisions.sh`). ## Сценарії використання -- `npm/scripts/tests/sync-claude-config.test.mjs` (mergeAllowList; mergeHooks) — union без дублікатів, порядок: спочатку існуючі; обробляє undefined по обидва боки; видаляє managed-групу (у т.ч. legacy; зберігає користувацькі групи поряд з managed; legacy Stop-hook (; ще 44 +- `npm/scripts/tests/sync-claude-config.test.mjs` (mergeAllowList; mergeHooks) — union без дублікатів, порядок: спочатку існуючі; обробляє undefined по обидва боки; видаляє managed-групу (у т.ч. legacy; зберігає користувацькі групи поряд з managed; legacy Stop-hook (; ще 43 ## Гарантії поведінки diff --git a/npm/scripts/sync-claude-config.mjs b/npm/scripts/sync-claude-config.mjs index 86779e457..bf6d1c9e6 100644 --- a/npm/scripts/sync-claude-config.mjs +++ b/npm/scripts/sync-claude-config.mjs @@ -507,7 +507,6 @@ function codexHooksWithAdrHook(hooks) { * @param {Record | undefined} template hooks-секція з `codex-hooks.template.json` * @param {object} [options] опції merge-у * @param {boolean} [options.includeAdrHook] чи додати ADR Stop-hook групи (правило `adr`) - * @param {boolean} [options.includeLocalAiHook] застарілий прапорець: Codex rtk hook не підтримується * @returns {Record} результат merge-у (порожні події видаляються) */ export function mergeCodexHooks(existing, template, options = {}) { @@ -526,7 +525,6 @@ export function mergeCodexHooks(existing, template, options = {}) { * @param {string} templateDir каталог `.claude-template/` усередині пакету * @param {object} [options] опції merge-у * @param {boolean} [options.includeAdrHook] чи додавати ADR Stop-hook групи - * @param {boolean} [options.includeLocalAiHook] застарілий прапорець, збережений для сумісності викликів * @returns {Promise<{ written: boolean, path: string }>} результат: чи писали файл, та його відносний шлях */ export async function syncCodexHooksConfig(projectRoot, templateDir, options = {}) { @@ -899,7 +897,7 @@ 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, includeLocalAiHook }) + const codexHooks = await syncCodexHooksConfig(projectRoot, templateDir, { includeAdrHook }) const commands = await syncClaudeCommands(projectRoot, templateDir) return { settings: settings.written, diff --git a/npm/scripts/tests/sync-claude-config.test.mjs b/npm/scripts/tests/sync-claude-config.test.mjs index d0f07f69c..6a9be0eb3 100644 --- a/npm/scripts/tests/sync-claude-config.test.mjs +++ b/npm/scripts/tests/sync-claude-config.test.mjs @@ -419,17 +419,12 @@ describe('mergeCodexHooks', () => { expect(merged.Stop[1].hooks[0].command).toContain('.claude/hooks/normalize-decisions.sh') }) - test('includeLocalAiHook не додає непідтримуваний rtk hook у Codex', () => { - const merged = mergeCodexHooks(undefined, baseTemplate, { includeLocalAiHook: true }) - expect(merged.PreToolUse).toBeUndefined() - }) - test('повторний merge не дублює managed-групи; вимкнення прибирає їх', () => { - const withBoth = mergeCodexHooks(undefined, baseTemplate, { includeAdrHook: true, includeLocalAiHook: true }) - const again = mergeCodexHooks(withBoth, baseTemplate, { includeAdrHook: true, includeLocalAiHook: true }) + 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, { includeAdrHook: false, includeLocalAiHook: false }) + const disabled = mergeCodexHooks(again, baseTemplate) expect(disabled.Stop).toBeUndefined() expect(disabled.PreToolUse).toBeUndefined() expect(disabled.PostToolUse).toHaveLength(1) @@ -437,7 +432,7 @@ describe('mergeCodexHooks', () => { test('зберігає користувацькі групи поряд з managed', () => { const existing = { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo my-hook' }] }] } - const merged = mergeCodexHooks(existing, baseTemplate, { includeLocalAiHook: true }) + const merged = mergeCodexHooks(existing, baseTemplate) expect(merged.PreToolUse).toHaveLength(1) expect(merged.PreToolUse[0].hooks[0].command).toBe('echo my-hook') }) From c4e79f6372d555a54b9de9d13d831cfd27e9cd20 Mon Sep 17 00:00:00 2001 From: vitaliytv Date: Tue, 28 Jul 2026 20:52:33 +0300 Subject: [PATCH 07/13] refactor(npm): drop unused Codex rtk cleanup --- npm/scripts/docs/sync-claude-config.md | 2 +- npm/scripts/sync-claude-config.mjs | 9 +-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/npm/scripts/docs/sync-claude-config.md b/npm/scripts/docs/sync-claude-config.md index 5062cd0e7..346bbb217 100644 --- a/npm/scripts/docs/sync-claude-config.md +++ b/npm/scripts/docs/sync-claude-config.md @@ -3,7 +3,7 @@ type: JS Module title: sync-claude-config.mjs resource: npm/scripts/sync-claude-config.mjs docgen: - crc: dddda436 + crc: c1eda647 model: omlx/gemma-4-e2b-it-4bit tier: local-min score: 35 diff --git a/npm/scripts/sync-claude-config.mjs b/npm/scripts/sync-claude-config.mjs index bf6d1c9e6..9676373c3 100644 --- a/npm/scripts/sync-claude-config.mjs +++ b/npm/scripts/sync-claude-config.mjs @@ -62,12 +62,6 @@ export const CURSOR_ADR_NORMALIZE_HOOK_COMMAND_MARKER = '.claude/hooks/normalize export const RTK_CLAUDE_HOOK_COMMAND_MARKER = 'rtk hook claude' /** Маркер rtk preToolUse hook'а у `.cursor/hooks.json` (правило `local-ai`). */ export const RTK_CURSOR_HOOK_COMMAND_MARKER = 'rtk hook cursor' -/** - * Legacy-маркер помилкового Codex rtk hook'а. Лишається лише щоб наступний sync прибрав - * його зі старих `.codex/hooks.json`; новий hook не генерується, бо rtk не має підкоманди - * `hook codex`. - */ -export const RTK_CODEX_HOOK_COMMAND_MARKER = 'rtk hook codex' /** * Усі маркери managed-hook'ів пакета — за ними відрізняємо свої записи від користувацьких. * Legacy stop-hook включений сюди, щоб старі entries автоматично видалялись при наступному sync-у. @@ -82,8 +76,7 @@ export const MANAGED_HOOK_COMMAND_MARKERS = Object.freeze([ LEGACY_STOP_HOOK_COMMAND_MARKER, ADR_HOOK_COMMAND_MARKER, ADR_NORMALIZE_HOOK_COMMAND_MARKER, - RTK_CLAUDE_HOOK_COMMAND_MARKER, - RTK_CODEX_HOOK_COMMAND_MARKER + RTK_CLAUDE_HOOK_COMMAND_MARKER ]) const CLAUDE_DIR = '.claude' From b42e7e791a54fa174304560683986bf3a8ad5144 Mon Sep 17 00:00:00 2001 From: vitaliytv Date: Tue, 28 Jul 2026 21:41:14 +0300 Subject: [PATCH 08/13] fix(ci): resolve repository lint findings --- .cspell.json | 11 +- .jscpd.json | 8 +- bun.lock | 3 +- npm/scripts/docs/sync-claude-config.md | 3 - .../coverage-provider/docs/js-collector.md | 35 +-- .../coverage-provider/fix/coverage-fix.mjs | 22 +- .../fix/docs/coverage-fix.md | 35 ++- .../coverage-provider/js-collector.mjs | 180 ++++++----- plugins/lang-js/package.json | 98 +++--- .../rules/js-bun-db/lib/bun-sql-scan.mjs | 7 +- .../rules/js-bun-db/lib/docs/bun-sql-scan.md | 44 +-- .../vue/lib/docs/vue-forbidden-imports.md | 286 +++--------------- .../rules/vue/lib/vue-forbidden-imports.mjs | 3 +- 13 files changed, 276 insertions(+), 459 deletions(-) diff --git a/.cspell.json b/.cspell.json index 562af6cd3..ffac4e0e6 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ÀÂÄ", @@ -748,6 +746,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/bun.lock b/bun.lock index fed56c5d3..4341d5558 100644 --- a/bun.lock +++ b/bun.lock @@ -117,12 +117,11 @@ "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": [ diff --git a/npm/scripts/docs/sync-claude-config.md b/npm/scripts/docs/sync-claude-config.md index 346bbb217..941a69c56 100644 --- a/npm/scripts/docs/sync-claude-config.md +++ b/npm/scripts/docs/sync-claude-config.md @@ -20,9 +20,6 @@ docgen: - 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`). -- RTK_CODEX_HOOK_COMMAND_MARKER — Legacy-маркер помилкового Codex rtk hook'а. Лишається лише щоб наступний sync прибрав -його зі старих `.codex/hooks.json`; новий hook не генерується, бо rtk не має підкоманди -`hook codex`. - MANAGED_HOOK_COMMAND_MARKERS — Усі маркери managed-hook'ів пакета — за ними відрізняємо свої записи від користувацьких. Legacy stop-hook включений сюди, щоб старі entries автоматично видалялись при наступному sync-у. - PI_DIR — Корінь pi.dev артефактів у проєкті-споживачі. diff --git a/plugins/lang-js/coverage-provider/docs/js-collector.md b/plugins/lang-js/coverage-provider/docs/js-collector.md index 2653d7cf9..4746aeab3 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: 520a4f73 + 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..5c6d78b34 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: 7370f53d + 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/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` файлів виконується попередній етап: регулярним виразом витягуються вмісти всіх тегів `