@7n/rules 1.34.1 → 1.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.35.0] - 2026-07-20
4
+
5
+ ### Added
6
+
7
+ - tauri: додано концерн gitignore_target — перевірка й автофікс ignore-запису ws/src-tauri/target/ у корінному .gitignore (typo-інцидент nitra/task)
8
+
9
+ ### Fixed
10
+
11
+ - run-v8r: не рахувати ajv schema-compile-помилку (несправна зовнішня схема, напр. unicodeRegExp-несумісність у офіційній azure-pipelines-vscode/service-schema.json) як порушення файлу — друкує ⚠-попередження й пропускає, замість постійного false-positive ✖
12
+ - acp-runner (skill claude): watchdog дочірнього ACP-процесу — fail-fast, якщо агент не запустився чи вийшов до кінця ходу, замість вічного зависання connection.prompt
13
+
3
14
  ## [1.34.1] - 2026-07-20
4
15
 
5
16
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.34.1",
3
+ "version": "1.35.0",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -0,0 +1,8 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@7n/rules/schemas/concern.json",
3
+ "fixability": "config",
4
+ "lint": {
5
+ "scope": "full",
6
+ "glob": [".gitignore", "**/src-tauri/Cargo.toml"]
7
+ }
8
+ }
@@ -0,0 +1,31 @@
1
+ ---
2
+ type: JS Module
3
+ title: fix-gitignore_target.mjs
4
+ resource: npm/rules/tauri/gitignore_target/fix-gitignore_target.mjs
5
+ docgen:
6
+ crc: 0ceef709
7
+ model: openai-codex/gpt-5.4-mini
8
+ score: 100
9
+ issues: judge:inaccurate:0.98
10
+ judgeModel: openai-codex/gpt-5.4-mini
11
+ ---
12
+
13
+ ## Огляд
14
+
15
+ T0-autofix для `tauri/gitignore_target`: дописує в корінний `.gitignore` відсутні ignore-записи `<ws>/src-tauri/target/` у секцію з міткою `# Tauri — Rust build artifacts (tauri.mdc)` (константа `GITIGNORE_TARGET_HEADER`). `insertMissingTargetEntries` виконує сам текстовий splice; `patterns` — масив T0-визначень (`id`/`test`/`apply`) центрального fix-pipeline, що зчитує відсутні записи з `violation.data.missing` і застосовує `insertMissingTargetEntries` до файлів через `applyToFiles`.
16
+
17
+ ## Поведінка
18
+
19
+ - `GITIGNORE_TARGET_HEADER` — мітка секції в корінному `.gitignore`, куди групуються записи для Tauri build artifacts.
20
+ - `insertMissingTargetEntries` — дописує відсутні ignore-записи для `<ws>/src-tauri/target/` у `.gitignore`: або в наявну секцію з `GITIGNORE_TARGET_HEADER` (поруч з іншими entries), або в новий блок у кінець файла; якщо нічого додавати не треба (`missingEntries` порожній), повертає `null` і вміст не змінюється.
21
+ - `patterns` — один T0-патерн (`tauri-gitignore-target-insert`): спрацьовує на violations з `data.kind === MISSING_GITIGNORE_TARGET_ENTRIES` і застосовує `insertMissingTargetEntries` до кожного цільового `.gitignore`.
22
+
23
+ ## Публічний API
24
+
25
+ - GITIGNORE_TARGET_HEADER — заголовок секції в корінному `.gitignore` для Tauri build-артефактів.
26
+ - insertMissingTargetEntries — додає відсутні `"<ws>/src-tauri/target/"` у відповідну секцію `.gitignore` або створює новий блок наприкінці файла.
27
+ - patterns — T0-визначення autofix-у для detector-а `tauri/gitignore_target`, що підключається до центрального fix-pipeline.
28
+
29
+ ## Гарантії поведінки
30
+
31
+ - (специфічних машинно-виведених гарантій немає)
@@ -0,0 +1,10 @@
1
+ ---
2
+ type: Directory Index
3
+ title: npm/rules/tauri/gitignore_target
4
+ resource: npm/rules/tauri/gitignore_target/
5
+ ---
6
+
7
+ | Файл | Тип |
8
+ | --------------------------------------------------- | --------- |
9
+ | [fix-gitignore_target.mjs](fix-gitignore_target.md) | JS Module |
10
+ | [main.mjs](main.md) | JS Module |
@@ -0,0 +1,37 @@
1
+ ---
2
+ type: JS Module
3
+ title: main.mjs
4
+ resource: npm/rules/tauri/gitignore_target/main.mjs
5
+ docgen:
6
+ crc: 83aa81ee
7
+ model: openai-codex/gpt-5.5
8
+ tier: cloud-avg
9
+ score: 85
10
+ issues: internal-name:findSrcTauriDirs,anchor-miss:(tauri.mdc),judge:inaccurate:0.98
11
+ judgeModel: openai-codex/gpt-5.4-mini
12
+ ---
13
+
14
+ ## Огляд
15
+
16
+ Файл read-only обходить усі `<ws>/src-tauri/` у монорепо спільним механізмом із `tauri/cargo_mutants_config` (`findSrcTauriDirs`), без дублювання обходу. Для кожного знайденого workspace корінний `ROOT_GITIGNORE = ".gitignore"` повинен містити точний запис `<ws>/src-tauri/target/`, звірений по точному рядку — typo на кшталт `owner/target/` (реальний інцидент у `nitra/task`) не рахується присутністю потрібного запису.
17
+
18
+ ## Поведінка
19
+
20
+ - `MISSING_GITIGNORE_TARGET_ENTRIES` — reason `"missing-gitignore-target-entries"` для порушення, коли в корінному `.gitignore` бракує записів для Tauri build-артефактів.
21
+ - `ROOT_GITIGNORE` — назва корінного файлу `".gitignore"`, у якому мають бути всі очікувані ignore-записи.
22
+ - `expectedTargetEntry` — формує точний ignore-запис для `target/` конкретного `src-tauri/` відносно кореня монорепо.
23
+ - `findMissingEntries` — визначає, яких очікуваних ignore-записів немає у `.gitignore`, за точним збігом рядка (після trim).
24
+ - `lint` — read-only перевіряє всі знайдені `src-tauri/` у монорепо й повідомляє про відсутні ignore-записи одним violation на весь `.gitignore`.
25
+
26
+ ## Публічний API
27
+
28
+ - MISSING_GITIGNORE_TARGET_ENTRIES — reason `missing-gitignore-target-entries` для випадків, коли в кореневому `.gitignore` немає записів для `src-tauri/target/`.
29
+ - ROOT_GITIGNORE — файл `.gitignore` у корені монорепо, який є єдиним місцем для цих ignore-записів.
30
+ - expectedTargetEntry — формує потрібний запис для ігнорування build-артефактів окремого workspace з `src-tauri/`.
31
+ - findMissingEntries — визначає, яких очікуваних записів бракує в кореневому `.gitignore`; збіг рахується лише для точного рядка після trim, тому `target/` або `owner/target/` не замінюють потрібний запис.
32
+ - lint — повідомляє про workspace з Tauri, для яких у кореневому `.gitignore` бракує `src-tauri/target/`.
33
+
34
+ ## Гарантії поведінки
35
+
36
+ - Read-only: не виконує операцій запису (ФС/БД).
37
+ - Свідомо пропускає шляхи: `.git`.
@@ -0,0 +1,78 @@
1
+ /** @see ./docs/fix-gitignore_target.md */
2
+
3
+ /**
4
+ * T0-autofix для `tauri/gitignore_target` — детерміновано дописує в корінний
5
+ * `.gitignore` відсутні ignore-записи `<ws>/src-tauri/target/` (з violation.data.missing,
6
+ * а не з повторного сканування монорепо — детектор уже визначив, яких саме
7
+ * записів бракує). Текстовий splice (як `tauri/linux_deps`): зберігає коментарі
8
+ * й формат, мінімальний diff. Ідемпотентно: повторний прогін на вже
9
+ * виправленому файлі нічого не змінює (`findMissingEntries` у main.mjs
10
+ * повторно перевіряє стан файла).
11
+ */
12
+ import { applyToFiles } from '../../../scripts/utils/apply-to-files.mjs'
13
+
14
+ import { MISSING_GITIGNORE_TARGET_ENTRIES } from './main.mjs'
15
+
16
+ /** Заголовок-коментар секції Tauri build-артефактів у корінному `.gitignore`. */
17
+ export const GITIGNORE_TARGET_HEADER = '# Tauri — Rust build artifacts (tauri.mdc)'
18
+
19
+ /**
20
+ * Знаходить кінець контурного блоку entries, що йде одразу за заголовком
21
+ * (перший порожній рядок, наступний коментар або кінець файла).
22
+ * @param {string[]} lines рядки `.gitignore`
23
+ * @param {number} headerIdx індекс рядка заголовка
24
+ * @returns {number} індекс, куди вставляти нові entries
25
+ */
26
+ function findBlockEnd(lines, headerIdx) {
27
+ let i = headerIdx + 1
28
+ while (i < lines.length && lines[i].trim() !== '' && !lines[i].trim().startsWith('#')) i++
29
+ return i
30
+ }
31
+
32
+ /**
33
+ * Дописує відсутні `<ws>/src-tauri/target/` entries: якщо секція `GITIGNORE_TARGET_HEADER`
34
+ * вже є — вставляє в кінець її блоку (поруч з наявними entries); інакше додає
35
+ * новий блок (заголовок + entries) у кінець файла.
36
+ * @param {string} content вміст `.gitignore`
37
+ * @param {string[]} missingEntries відсутні ignore-рядки (`<ws>/src-tauri/target/`)
38
+ * @returns {string|null} новий вміст або null, якщо нічого не змінилось
39
+ */
40
+ export function insertMissingTargetEntries(content, missingEntries) {
41
+ if (missingEntries.length === 0) return null
42
+
43
+ const lines = content.split('\n')
44
+ const headerIdx = lines.findIndex(l => l.trim() === GITIGNORE_TARGET_HEADER)
45
+
46
+ if (headerIdx !== -1) {
47
+ const blockEnd = findBlockEnd(lines, headerIdx)
48
+ lines.splice(blockEnd, 0, ...missingEntries)
49
+ return lines.join('\n')
50
+ }
51
+
52
+ const trailingBlank = lines.length > 0 && lines.at(-1) === ''
53
+ const body = trailingBlank ? lines.slice(0, -1) : lines
54
+ const needsBlankSep = body.length > 0 && body.at(-1).trim() !== ''
55
+
56
+ const next = [...body]
57
+ if (needsBlankSep) next.push('')
58
+ next.push(GITIGNORE_TARGET_HEADER, ...missingEntries, '')
59
+ return next.join('\n')
60
+ }
61
+
62
+ /** @type {import('../../../scripts/lib/lint-surface/types.mjs').T0Pattern[]} */
63
+ export const patterns = [
64
+ {
65
+ id: 'tauri-gitignore-target-insert',
66
+ test: violations => violations.some(v => v.data?.kind === MISSING_GITIGNORE_TARGET_ENTRIES && v.file),
67
+ apply: (violations, ctx) => {
68
+ const targets = violations.filter(v => v.data?.kind === MISSING_GITIGNORE_TARGET_ENTRIES && v.file)
69
+ const touchedFiles = applyToFiles(targets, ctx, rel => content => {
70
+ const v = targets.find(x => x.file === rel)
71
+ return insertMissingTargetEntries(content, v?.data?.missing ?? [])
72
+ })
73
+ return touchedFiles.length > 0
74
+ ? { touchedFiles, message: `Tauri build-артефакти → .gitignore (${touchedFiles.length} file(s))` }
75
+ : { touchedFiles: [] }
76
+ }
77
+ }
78
+ ]
@@ -0,0 +1,24 @@
1
+ ## `.gitignore`: ignore build-артефактів `<ws>/src-tauri/target/`
2
+
3
+ Кожен `<ws>/src-tauri/` (Rust-крейт Tauri) компілюється в `<ws>/src-tauri/target/` — build-артефакти Cargo (сотні MB, десятки тисяч файлів). Корінний `.gitignore` монорепо повинен містити точний ignore-запис для кожного такого воркспейсу:
4
+
5
+ ```gitignore
6
+ # Tauri — Rust build artifacts (tauri.mdc)
7
+ <ws>/src-tauri/target/
8
+ ```
9
+
10
+ ### Чому точний шлях, а не голий `target/`
11
+
12
+ Реальний інцидент: у `nitra/task` корінний `.gitignore` мав `owner/target/` замість `owner/src-tauri/target/` — воркспейс скопійований з іншого проєкту без заміни шляху (typo). `target/` там ігнорувався не там, де компілятор його реально створює, тож ~600MB / ~28k файлів build-артефактів не потрапляли під жоден ignore-патерн і мало не потрапили в коміт через `git add -A`.
13
+
14
+ Тому перевірка звіряє **точний рядок** `<ws>/src-tauri/target/` для кожного знайденого `src-tauri/`, а не факт наявності підрядка `target/` десь у файлі — голий `target/` (чи вже наявний, але помилковий `owner/target/`) не закриває violation.
15
+
16
+ ### Область дії
17
+
18
+ - Обхід усіх `<ws>/src-tauri/` — спільний з `tauri/cargo_mutants_config` (`findSrcTauriDirs`, без дублювання обходу монорепо).
19
+ - Ціль фіксу — **один** корінний `.gitignore` монорепо (не по workspace).
20
+ - Детектор лише **додає** відсутнє: сторонні/застарілі ignore-записи (на кшталт помилкового `owner/target/` з інциденту вище) не видаляються — вони можуть бути навмисними чи потребувати ручного review.
21
+
22
+ ### Автофікс (T0)
23
+
24
+ Дописує відсутні записи в кінець секції `# Tauri — Rust build artifacts (tauri.mdc)`: якщо секція вже є — поруч із наявними entries (мінімальний diff, без переписування файла); якщо нема — створює новий блок у кінці `.gitignore`. Ідемпотентно: повторний прогін на вже виправленому файлі нічого не змінює.
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @see ./docs/main.md
3
+ *
4
+ * Read-only detector: для кожного знайденого `<ws>/src-tauri/` у монорепо
5
+ * корінний `.gitignore` повинен містити точний ignore-запис
6
+ * `<ws>/src-tauri/target/` (tauri.mdc).
7
+ *
8
+ * Реальний інцидент: у `nitra/task` `.gitignore` мав `owner/target/` замість
9
+ * `owner/src-tauri/target/` (typo — воркспейс скопійований без заміни шляху),
10
+ * тому build-артефакти (~600MB, ~28k файлів) не ігнорувались і мало не
11
+ * потрапили в коміт через `git add -A`. Тому перевірка — точний match рядка
12
+ * повного шляху, не пошук голого `target/` десь у файлі: голий `target/`
13
+ * (чи `owner/target/`) дав би false negative саме на цьому кейсі.
14
+ *
15
+ * Обхід `<ws>/src-tauri/` — спільний з `tauri/cargo_mutants_config`
16
+ * (`findSrcTauriDirs`), без дублювання.
17
+ */
18
+ import { existsSync } from 'node:fs'
19
+ import { readFile } from 'node:fs/promises'
20
+ import { join, relative } from 'node:path'
21
+
22
+ import { createViolationReporter } from '../../../scripts/lib/lint-surface/violation-reporter.mjs'
23
+ import { findSrcTauriDirs } from '../cargo_mutants_config/main.mjs'
24
+
25
+ /** Стабільний reason: у корінному `.gitignore` бракує ignore-запису(ів) для `src-tauri/target/`. */
26
+ export const MISSING_GITIGNORE_TARGET_ENTRIES = 'missing-gitignore-target-entries'
27
+
28
+ /** Корінний `.gitignore` — один на монорепо, не по workspace. */
29
+ export const ROOT_GITIGNORE = '.gitignore'
30
+
31
+ /**
32
+ * Очікуваний ignore-запис для build-артефактів одного `<ws>/src-tauri/`.
33
+ * @param {string} cwd корінь монорепо
34
+ * @param {string} srcTauriDir абсолютний шлях до `src-tauri/`
35
+ * @returns {string} очікуваний рядок у корінному `.gitignore` (`<ws>/src-tauri/target/`)
36
+ */
37
+ export function expectedTargetEntry(cwd, srcTauriDir) {
38
+ return `${relative(cwd, srcTauriDir)}/target/`
39
+ }
40
+
41
+ /**
42
+ * Знаходить очікувані entries, яких бракує у вмісті кореневого `.gitignore`.
43
+ * Точний match рядка (після trim) — голий `target/` чи typo на кшталт
44
+ * `owner/target/` не рахуються присутністю очікуваного запису.
45
+ * @param {string} content вміст `.gitignore`
46
+ * @param {string[]} expectedEntries очікувані ignore-рядки
47
+ * @returns {string[]} відсутні entries (зі збереженням порядку expectedEntries)
48
+ */
49
+ export function findMissingEntries(content, expectedEntries) {
50
+ const present = new Set(content.split('\n').map(l => l.trim()))
51
+ return expectedEntries.filter(e => !present.has(e))
52
+ }
53
+
54
+ /**
55
+ * @param {import('../../../scripts/lib/lint-surface/types.mjs').LintContext} ctx контекст лінт-прогону
56
+ * @returns {Promise<import('../../../scripts/lib/lint-surface/types.mjs').LintResult>} результат перевірки
57
+ */
58
+ export async function lint(ctx) {
59
+ const cwd = ctx.cwd
60
+ const reporter = createViolationReporter(ctx)
61
+
62
+ const srcTauriDirs = await findSrcTauriDirs(cwd)
63
+ if (srcTauriDirs.length === 0) return reporter.result()
64
+
65
+ const expectedEntries = srcTauriDirs.map(dir => expectedTargetEntry(cwd, dir))
66
+ const abs = join(cwd, ROOT_GITIGNORE)
67
+ const content = existsSync(abs) ? await readFile(abs, 'utf8') : ''
68
+
69
+ const missing = findMissingEntries(content, expectedEntries)
70
+ if (missing.length === 0) {
71
+ reporter.pass(`${ROOT_GITIGNORE}: build-артефакти всіх src-tauri/ ігноруються`)
72
+ return reporter.result()
73
+ }
74
+
75
+ reporter.fail(
76
+ `${ROOT_GITIGNORE}: бракує ignore-запису(ів) для Tauri build-артефактів [${missing.join(', ')}] — build-артефакти можуть потрапити в коміт (tauri.mdc)`,
77
+ {
78
+ reason: MISSING_GITIGNORE_TARGET_ENTRIES,
79
+ file: ROOT_GITIGNORE,
80
+ data: { kind: MISSING_GITIGNORE_TARGET_ENTRIES, missing }
81
+ }
82
+ )
83
+ return reporter.result()
84
+ }
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/text/run-v8r/main.mjs
5
5
  docgen:
6
- crc: 7abeb17f
6
+ crc: 91a9608d
7
7
  model: manual
8
8
  ---
9
9
 
@@ -116,6 +116,55 @@ const PROCESSING_LINE_RE = /^ℹ Processing (.+)$/u
116
116
  const FOUND_REMOTE_SCHEMA_RE = /^ℹ Found schema in (https?:\/\/\S+)/u
117
117
  const NOISE_LINE_RE = /^(?:ℹ .*|Resolving dependencies|Resolved, downloaded and extracted.*|Saved lockfile)$/u
118
118
 
119
+ /**
120
+ * Рядок ajv-помилки компіляції самої схеми (не документа). v8r ловить це в `try/catch` навколо
121
+ * `ajv.compileAsync(schema)` (`validateDocument` у v8r/src/cli.js) і друкує голий
122
+ * `SyntaxError.message` без файлового контексту чи "is invalid"-заголовка — на відміну від
123
+ * genuine validation-помилки, де ajv повертає `errors[]` з прив'язкою до документа. Найчастіша
124
+ * причина: ajv за замовчуванням компілює `pattern` з прапорцем `/u` (`unicodeRegExp: true`), а
125
+ * реальні опубліковані схеми (напр. офіційна `azure-pipelines-vscode/service-schema.json`) містять
126
+ * legacy over-escaped regex, валідний поза Unicode-режимом, але `SyntaxError` у ньому. Це несправна
127
+ * схема, а не невалідний файл користувача — v8r не дає способу це відрізнити нативно (немає опції
128
+ * `unicodeRegExp` у config-schema.json), тому розрізняємо тут за форматом самого повідомлення.
129
+ * `logger.error` у v8r (src/logger.js) додає префікс `✖ ` перед `e.message` — опційний у regex,
130
+ * бо `extractFailureLines` не чіпає цей префікс (лише прибирає `ℹ`-шум).
131
+ */
132
+ const AJV_SCHEMA_COMPILE_ERROR_RE = /^(?:✖ )?Invalid regular expression:.*$/mu
133
+
134
+ /**
135
+ * Чи складається `detail` ВИКЛЮЧНО з рядків ajv-помилки компіляції схеми (без жодної genuine
136
+ * validation-помилки). Навмисно консервативно: якщо серед рядків `detail` є хоч один, що НЕ
137
+ * збігається з `AJV_SCHEMA_COMPILE_ERROR_RE` (напр. "file.yml is invalid" чи ajv `errors[]`-деталь
138
+ * genuine порушення в тому ж batch-виклику v8r по glob-у) — не втручаємось, викликач лишає
139
+ * оригінальний `code`/`detail` без змін, щоб не замаскувати реальну проблему.
140
+ * @param {string} detail рядки `✖ …` з `extractFailureLines`
141
+ * @returns {boolean} true — усі непорожні рядки `detail` є ajv schema-compile-помилками
142
+ */
143
+ function isOnlyAjvSchemaCompileErrors(detail) {
144
+ const lines = detail
145
+ .split('\n')
146
+ .map(line => line.trim())
147
+ .filter(line => line.length > 0)
148
+ if (lines.length === 0) return false
149
+ return lines.every(line => AJV_SCHEMA_COMPILE_ERROR_RE.test(line))
150
+ }
151
+
152
+ /**
153
+ * Друкує інформаційне попередження (не violation) для кожного `detail`-рядка ajv schema-compile-
154
+ * помилки — пояснює, що причина у несправній зовнішній схемі, не в нашому файлі.
155
+ * @param {string} detail рядки `✖ …`, для яких `isOnlyAjvSchemaCompileErrors` вже повернув true
156
+ * @returns {void}
157
+ */
158
+ function reportAjvSchemaCompileFailures(detail) {
159
+ for (const line of detail.split('\n')) {
160
+ const trimmed = line.trim()
161
+ if (trimmed.length === 0) continue
162
+ process.stdout.write(
163
+ `⚠ run-v8r: зовнішня схема не компілюється в ajv (не файл) — ${trimmed} Ймовірно, ajv unicodeRegExp-несумісність зі старим стилем escape у чужій схемі; помилка не рахується як порушення.\n`
164
+ )
165
+ }
166
+ }
167
+
119
168
  /**
120
169
  * Прибирає v8r/bunx noise-рядки (весь `ℹ`-статус: Loaded config file, Patterns and relative
121
170
  * paths, Pre-warming the cache, Processing <file>, Found schema in …, Validating …; і службовий
@@ -190,7 +239,11 @@ export function stripBunNodeShimDirs(pathValue) {
190
239
  * @param {boolean} [verbose] друкувати повний raw stdout/stderr v8r при помилці; інакше — лише
191
240
  * рядки `✖ …` без `ℹ`-шуму (Pre-warming the cache, Processing <file>, Found schema in …)
192
241
  * @returns {Promise<{ exitError: true } | { exitError: false, code: number, detail: string }>}
193
- * помилка spawn або код v8r (0/98 — трактує викликач) + деталь `✖ …`-рядків
242
+ * помилка spawn або код v8r (0/98 — трактує викликач) + деталь `✖ …`-рядків. Якщо ВЕСЬ `detail`
243
+ * складається лише з ajv schema-compile-помилок (несправна зовнішня схема, не наш файл —
244
+ * `isOnlyAjvSchemaCompileErrors`) — `code` примусово 0, а причина друкується окремим `⚠`-попередженням
245
+ * (`reportAjvSchemaCompileFailures`), а не як `✖`-порушення; мішаний випадок (є хоч один
246
+ * genuine validation-рядок) лишається без змін навмисно, щоб не замаскувати реальну проблему.
194
247
  */
195
248
  async function runOneV8rInvocation(targets, configPath, verbose = false) {
196
249
  const bunPath = resolveCmd('bun') ?? process.execPath
@@ -206,16 +259,24 @@ async function runOneV8rInvocation(targets, configPath, verbose = false) {
206
259
 
207
260
  warnAboutRemoteSchemaFallback(result.stderr ?? '')
208
261
 
209
- const exitCode = result.exitCode ?? 1
262
+ let exitCode = result.exitCode ?? 1
210
263
  let detail = ''
211
264
  if (exitCode !== 0 && exitCode !== 98) {
212
265
  detail = extractFailureLines(`${result.stdout ?? ''}\n${result.stderr ?? ''}`)
266
+ const onlySchemaCompileErrors = isOnlyAjvSchemaCompileErrors(detail)
213
267
  if (verbose) {
214
268
  if (result.stdout?.length) process.stdout.write(result.stdout)
215
269
  if (result.stderr?.length) process.stderr.write(result.stderr)
270
+ } else if (onlySchemaCompileErrors) {
271
+ reportAjvSchemaCompileFailures(detail)
216
272
  } else if (detail.length) {
217
273
  process.stdout.write(`${detail}\n`)
218
274
  }
275
+ if (onlySchemaCompileErrors) {
276
+ if (verbose) reportAjvSchemaCompileFailures(detail)
277
+ exitCode = 0
278
+ detail = ''
279
+ }
219
280
  }
220
281
  return { exitError: false, code: exitCode, detail }
221
282
  }
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { spawn } from 'node:child_process'
19
+ import { once } from 'node:events'
19
20
  import { readFileSync, writeFileSync } from 'node:fs'
20
21
  import { createRequire } from 'node:module'
21
22
  import { dirname, join } from 'node:path'
@@ -118,6 +119,47 @@ class AcpSkillClient {
118
119
  }
119
120
  }
120
121
 
122
+ /**
123
+ * @param {import('node:child_process').ChildProcess} child дочірній процес
124
+ * @returns {Promise<{ event: 'error', error: Error }>} подія спавн-помилки
125
+ */
126
+ async function watchSpawnError(child) {
127
+ const [error] = await once(child, 'error')
128
+ return { event: 'error', error }
129
+ }
130
+
131
+ /**
132
+ * @param {import('node:child_process').ChildProcess} child дочірній процес
133
+ * @returns {Promise<{ event: 'exit', code: number|null, signal: string|null }>} подія завершення процесу
134
+ */
135
+ async function watchExit(child) {
136
+ const [code, signal] = await once(child, 'exit')
137
+ return { event: 'exit', code, signal }
138
+ }
139
+
140
+ /**
141
+ * Watchdog дочірнього процесу: провалюється швидко, якщо ACP-агент не запустився чи
142
+ * вийшов до кінця ходу — без цього мертвий процес вішає `connection.prompt` назавжди
143
+ * (`Connection#receive` у `@zed-industries/agent-client-protocol` тихо завершує читання
144
+ * на закритому stdout, не відхиляючи pending-запити).
145
+ * @param {import('node:child_process').ChildProcess} child дочірній процес ACP-агента
146
+ * @param {string} kind провайдер (для тексту помилки)
147
+ * @returns {Promise<never>} ніколи не резолвиться успішно — лише провалюється
148
+ * @throws {Error} на спавн-помилку чи будь-який вихід процесу до завершення ходу
149
+ */
150
+ async function waitForChildFailure(child, kind) {
151
+ const result = await Promise.race([watchSpawnError(child), watchExit(child)])
152
+
153
+ if (result.event === 'error') {
154
+ throw new Error(`acp ${kind}: не вдалося запустити процес: ${result.error.message}`)
155
+ }
156
+ throw new Error(
157
+ result.signal
158
+ ? `acp ${kind}: процес вбитий сигналом ${result.signal} до завершення ходу`
159
+ : `acp ${kind}: процес завершився з кодом ${result.code} до завершення ходу`
160
+ )
161
+ }
162
+
121
163
  /**
122
164
  * Виконує скіл через зовнішнього ACP-агента. Єдиний лишений провайдер —
123
165
  * deprecated `claude` (`cursor`/`codex` — див. `@7n/llm-lib/acp`).
@@ -139,6 +181,7 @@ export async function runAcpRunner(kind, prompt, projectDir, logError, deps = {}
139
181
 
140
182
  const acp = deps.acp ?? (await import('@zed-industries/agent-client-protocol'))
141
183
  const child = spawnFn(bin, args, { cwd: projectDir, stdio: ['pipe', 'pipe', 'inherit'], env: process.env })
184
+ const failure = waitForChildFailure(child, kind)
142
185
 
143
186
  try {
144
187
  const input = Writable.toWeb(child.stdin)
@@ -147,15 +190,19 @@ export async function runAcpRunner(kind, prompt, projectDir, logError, deps = {}
147
190
  const stream = acp.ndJsonStream(input, output)
148
191
  const connection = new acp.ClientSideConnection(() => client, stream)
149
192
 
150
- await connection.initialize({
151
- protocolVersion: acp.PROTOCOL_VERSION,
152
- clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } }
153
- })
154
- const session = await connection.newSession({ cwd: projectDir, mcpServers: [] })
155
- const result = await connection.prompt({
156
- sessionId: session.sessionId,
157
- prompt: [{ type: 'text', text: prompt }]
158
- })
193
+ const turn = (async () => {
194
+ await connection.initialize({
195
+ protocolVersion: acp.PROTOCOL_VERSION,
196
+ clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } }
197
+ })
198
+ const session = await connection.newSession({ cwd: projectDir, mcpServers: [] })
199
+ return connection.prompt({
200
+ sessionId: session.sessionId,
201
+ prompt: [{ type: 'text', text: prompt }]
202
+ })
203
+ })()
204
+
205
+ const result = await Promise.race([turn, failure])
159
206
 
160
207
  if (result.stopReason !== 'end_turn') {
161
208
  logError(`acp ${kind}: stopReason=${result.stopReason}`)