diff --git a/docs/v8-project-guide.md b/docs/v8-project-guide.md index f8bfe8ab..24e0d243 100644 --- a/docs/v8-project-guide.md +++ b/docs/v8-project-guide.md @@ -58,6 +58,7 @@ | `databases` | array | да | — | Список баз данных | `/db-list add` | | `default` | string | нет | — | `id` базы по умолчанию | `/db-list` | | `editingAllowedCheck` | `"deny"`/`"warn"`/`"off"` | нет | `deny` | Глобальная реакция support-guard на правку объектов на замке (см. ниже) | Руками | +| `skillSuggester` | `"on"`/`"off"` | нет | `on` | Подсказки навыков от хука skill-suggester (только если хук включён, см. ниже) | Руками | | `webPath` | string | нет | `tools/apache24` | Каталог Apache HTTP Server | Руками | | `ffmpegPath` | string | нет | `tools/ffmpeg/bin/ffmpeg.exe` | Путь к ffmpeg | Руками | | `tts` | object | нет | Edge TTS, DmitryNeural | Настройки озвучки видео | Руками | @@ -78,6 +79,7 @@ | `branches` | string[] | нет | Git-ветки или glob-паттерны (`release/*`, `feature/*`) | Руками | | `configSrc` | string | нет | Каталог XML-выгрузки конфигурации | Руками | | `editingAllowedCheck` | `"deny"`/`"warn"`/`"off"` | нет | Override реакции support-guard для этой базы (см. ниже) | Руками | +| `skillSuggester` | `"on"`/`"off"` | нет | Override подсказок навыков для этой базы (см. ниже) | Руками | | `webUrl` | string | нет | URL веб-клиента для `/web-test` | Руками | ### Support-guard и `editingAllowedCheck` @@ -91,6 +93,14 @@ Триггер проверки — наличие `ParentConfigurations.bin` (конфигурация на поддержке), а не регистрация в `.v8-project.json`. Поле лишь меняет реакцию. Берётся `databases[].editingAllowedCheck` базы, чей `configSrc` охватывает редактируемый путь; иначе — корневое `editingAllowedCheck`; иначе `deny`. +### Хуки и `skillSuggester` (экспериментально) + +Помимо встроенной в навыки проверки (выше), есть **опциональные хуки Claude Code** (каталог `hooks/`), которые по умолчанию **выключены** и подключаются вручную (см. `hooks/README.md`): +- **support-guard** — перехватывает правки исходников на поддержке **в обход навыков** (прямые `Edit`/`Write`); реакцию берёт из того же `editingAllowedCheck`; +- **skill-suggester** — ненавязчиво подсказывает профильный навык, когда модель работает с исходниками напрямую. + +`skillSuggester` (`on`/`off`, по умолчанию `on`) включает/выключает подсказки skill-suggester. Действует только когда хук подключён; раскладка та же — `databases[].skillSuggester` для базы по `configSrc`, иначе корневое, иначе `on`. + ### Разрешение базы Все навыки `/db-*`, `/epf-build`, `/epf-dump`, `/erf-build`, `/erf-dump`, `/web-publish` используют единый алгоритм: diff --git a/hooks/README.md b/hooks/README.md new file mode 100644 index 00000000..dd5123a0 --- /dev/null +++ b/hooks/README.md @@ -0,0 +1,85 @@ +# Хуки: защита конфигураций на поддержке + подсказка навыков + +> ⚠️ **Экспериментально, по умолчанию выключено.** Эти хуки **не подключаются автоматически** даже при +> установке плагина — их нужно включить вручную (см. «Установка» ниже). Базовая защита поддержки уже работает +> без хуков — встроена в сами навыки-мутаторы; хуки лишь добавляют перехват правок **в обход навыков** и +> подсказки. Фича новая, обкатывается; отзывы приветствуются. + +Два хука Claude Code, которые помогают безопасно дорабатывать типовые конфигурации 1С: + +- **Защита от правки «на замке».** Если модель пытается напрямую (инструментами `Edit`/`Write`) + изменить объект типовой конфигурации, который стоит на поддержке поставщика, редактирование + **блокируется** — иначе оно молча сломает будущие обновления вендора. В отказе сразу даётся, что делать + дальше под конкретный случай (доработать в расширении или явно разрешить редактирование). +- **Подсказка навыков.** Когда модель работает с исходниками 1С «вручную» (читает сырой XML или правит его + напрямую), хук ненавязчиво напоминает про профильный навык — и по делу: при **чтении** ведёт на `*-info` + (понять структуру), при **правке** — на мутатор (`meta-edit`/`form-edit`/`skd-edit`/…). Не блокирует, + подсказывает не чаще одного раза за сессию на группу и действие. + +Это дополнительный слой поверх проверок, которые уже встроены в сами навыки: навыки-мутаторы и так не дадут +испортить объект на поддержке. Хуки добавляют защиту для случаев, когда правят файлы **в обход навыков**. + +> Хуки — возможность только Claude Code. На других платформах их нет; там работают встроенные в навыки проверки. + +## Требования + +**Node.js 18+** (тот же, что нужен для `/web-test`). Команда `node` должна быть доступна в PATH. + +## Установка (ручная — фича экспериментальная) + +Хуки сейчас **не включаются автоматически** ни одним способом установки (плагин их не объявляет в манифесте). +Чтобы включить: + +1. Убедитесь, что каталог `hooks/` доступен в проекте. При установке плагином он уже в составе плагина — + используйте путь `${CLAUDE_PLUGIN_ROOT}/hooks/...`. При установке копированием навыков скопируйте `hooks/` + в проект, например в `<проект>/.claude/hooks/`, и используйте `${CLAUDE_PROJECT_DIR}/.claude/hooks/...`. +2. Добавьте в `<проект>/.claude/settings.json` (пути ниже — для варианта с копированием): + +```json +{ + "hooks": { + "PreToolUse": [ + { "matcher": "Edit|Write|MultiEdit", + "hooks": [{ "type": "command", + "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/support-guard.mjs\"" }] } + ], + "PostToolUse": [ + { "matcher": "Read|Edit|Write|MultiEdit", + "hooks": [{ "type": "command", + "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/skill-suggester.mjs\"" }] } + ] + } +} +``` + +## Настройка (`.v8-project.json`) + +Поведение настраивается в файле проекта `.v8-project.json` — глобально и/или по конкретной базе +(`databases[].…`, переопределяет глобальное): + +| Поле | Значения | По умолчанию | Что делает | +|------|----------|--------------|------------| +| `editingAllowedCheck` | `deny` / `warn` / `off` | `deny` | Реакция защиты: блокировать правку объекта на замке / только предупреждать / выключить проверку. | +| `skillSuggester` | `on` / `off` | `on` | Включает/выключает подсказки навыков. | + +Источник истины по состоянию поддержки — сама выгрузка конфигурации; `.v8-project.json` лишь настраивает +реакцию. + +## Что делать при отказе защиты + +Текст отказа сам подсказывает варианты под конкретную ситуацию. Кратко: + +- **Безопаснее всего** — вести доработку в расширении (навыки `cfe-borrow` / `cfe-patch-method`): + состояние поддержки менять не нужно, обновления вендора сохраняются. +- **Либо** осознанно разрешить редактирование через навык `support-edit` (включить редактирование объекта, + снять его с поддержки или включить возможность изменения всей конфигурации). Готовую команду под ваш + случай печатает сам отказ. + +## Проверка + +```bash +node hooks/test/run.mjs +``` + +Прогоняет защиту и подсказку на реальных выгрузках и на временных тестовых данных (в `test-tmp/`, не +попадает в git; рабочие выгрузки не затрагиваются). diff --git a/hooks/common/object-class.mjs b/hooks/common/object-class.mjs new file mode 100644 index 00000000..40193824 --- /dev/null +++ b/hooks/common/object-class.mjs @@ -0,0 +1,123 @@ +// object-class.mjs v1.0 — classify a 1C source path → relevant skill group (suggester) +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// Conservative path→skill map for the skill-suggester hook. Returns { group, read, write } +// or null (stay silent) when the path is not a recognizable 1C artifact. Distinguishes +// cf vs cfe (extension) by sniffing in Configuration.xml, +// and mxl vs skd templates by the root namespace. Never throws. + +import { readFileSync, existsSync, statSync } from 'node:fs'; +import { basename, dirname } from 'node:path'; + +// Top-level metadata collections handled by meta-* (Roles handled separately → role-*). +const META_COLLECTIONS = new Set([ + 'Catalogs', 'Documents', 'Enums', 'Reports', 'DataProcessors', 'InformationRegisters', + 'AccumulationRegisters', 'AccountingRegisters', 'CalculationRegisters', 'DocumentJournals', + 'ChartsOfCharacteristicTypes', 'ChartsOfAccounts', 'ChartsOfCalculationTypes', 'BusinessProcesses', + 'Tasks', 'ExchangePlans', 'Constants', 'CommonModules', 'FilterCriteria', 'SettingsStorages', + 'CommonAttributes', 'DefinedTypes', 'SessionParameters', 'CommonForms', 'CommonTemplates', + 'CommonCommands', 'CommandGroups', 'CommonPictures', 'WebServices', 'HTTPServices', 'WSReferences', + 'ScheduledJobs', 'FunctionalOptions', 'FunctionalOptionsParameters', 'EventSubscriptions', + 'Sequences', 'ExternalDataSources', 'IntegrationServices', +]); + +// Per-group nudges, split by action: `read` → info-skill (понять структуру), +// `write` → mutator-skill (безопасно изменить). Подсказка зависит от того, что делает модель. +const MESSAGES = { + meta: { + read: 'Структуру объекта 1С быстрее даёт навык `meta-info` (одна сводка вместо сырого XML).', + write: 'Структурные правки объекта (реквизиты/ТЧ/измерения/ресурсы) безопаснее через `meta-edit` — он следит за uuid, порядком и валидностью.', + }, + form: { + read: 'Управляемую форму 1С удобнее разбирать навыком `form-info` (элементы/реквизиты/команды/события).', + write: 'Правки формы (добавить элементы/реквизиты/команды) — через `form-edit`, а не ручной правкой XML.', + }, + mxl: { + read: 'Это табличный документ 1С: `mxl-info` показывает области/параметры, `mxl-decompile` даёт редактируемое описание.', + write: 'Табличный документ правят не вручную: `mxl-decompile` → правка JSON → `mxl-compile`.', + }, + skd: { + read: 'Это схема компоновки данных (СКД): `skd-info` показывает наборы/поля/параметры.', + write: 'Точечные правки СКД — через `skd-edit` (поля/итоги/фильтры/текст запроса).', + }, + role: { + read: 'Права роли удобнее смотреть навыком `role-info` (объекты/права/RLS).', + write: 'Роль создают и правят из DSL навыком `role-compile`.', + }, + cf: { + read: 'Корень конфигурации удобнее смотреть навыком `cf-info` (свойства/состав/счётчики объектов).', + write: 'Правки корня (свойства/состав/роли по умолчанию/интерфейс) — через `cf-edit`.', + }, + cfe: { + read: 'Это расширение конфигурации (CFE): свойства и состав читает `cf-info`, специфику (заимствования/перехватчики/проверку переноса) — `cfe-diff`.', + write: 'Доработку в расширении безопаснее вести навыками `cfe-borrow`/`cfe-patch-method`, а не ручной правкой XML.', + }, + subsystem: { + read: 'Подсистему удобнее смотреть навыком `subsystem-info` (состав/дерево/командный интерфейс).', + write: 'Правки подсистемы (состав/дочерние/свойства) — через `subsystem-edit`.', + }, + template: { + read: 'Это макет объекта 1С: для табличного документа — `mxl-info`, для СКД — `skd-info`.', + write: 'Макет правят навыками: табличный документ — `mxl-*`, СКД — `skd-*`.', + }, +}; + +function segments(p) { + return p.replace(/\\/g, '/').split('/').filter(Boolean); +} + +function sniffRoot(path) { + try { + if (!existsSync(path) || !statSync(path).isFile()) return ''; + const fd = readFileSync(path, 'utf8'); + return fd.slice(0, 600); + } catch { + return ''; + } +} + +// Classify a concrete file path. Returns { group, read, write } (action-specific nudges) or null. +export function classifyFile(path) { + try { + const segs = segments(path); + const name = basename(path); + if (!name) return null; + + if (name.toLowerCase().endsWith('.bsl')) return null; // module code — no skill, stay silent + + // Form.xml under .../Forms//Ext/ + if (name === 'Form.xml' && segs.includes('Forms')) return mk('form'); + + // Template.xml under .../Templates//Ext/ → sniff root namespace (mxl vs skd) + if (name === 'Template.xml' && segs.includes('Templates')) { + const head = sniffRoot(path); + if (/data\/spreadsheet/.test(head)) return mk('mxl'); + if (/DataCompositionSchema|data-composition-schema/i.test(head)) return mk('skd'); + return mk('template'); // unreadable / unknown → generic + } + + // Roles: Rights.xml or Roles/.xml + if (name === 'Rights.xml' && segs.includes('Roles')) return mk('role'); + + // Configuration.xml → cf vs cfe (extension marker) + if (name === 'Configuration.xml') { + const head = sniffRoot(path); + return /ConfigurationExtensionPurpose/.test(head) ? mk('cfe') : mk('cf'); + } + + const parent = basename(dirname(path)); + // Top-level object root: /.xml + if (name.toLowerCase().endsWith('.xml')) { + if (parent === 'Roles') return mk('role'); + if (parent === 'Subsystems') return mk('subsystem'); + if (META_COLLECTIONS.has(parent)) return mk('meta'); + } + return null; + } catch { + return null; + } +} + +function mk(group) { + return { group, read: MESSAGES[group].read, write: MESSAGES[group].write }; +} diff --git a/hooks/common/project.mjs b/hooks/common/project.mjs new file mode 100644 index 00000000..1811c576 --- /dev/null +++ b/hooks/common/project.mjs @@ -0,0 +1,67 @@ +// project.mjs v1.0 — read reaction mode from .v8-project.json for Claude Code hooks +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// Canonical port of Get-EditMode / _sg_get_edit_mode +// (reference: .claude/skills/meta-edit/scripts/meta-edit.ps1:181-201, meta-edit.py:50-68). +// configSrc is matched here ONLY to fetch a per-database override — identically to the +// in-skill guard §1B, so that a raw Edit and an edit-via-skill behave the same under the +// same databases[].editingAllowedCheck. Never throws — falls back to the default. + +import { readFileSync, existsSync, statSync } from 'node:fs'; +import { dirname, join, resolve, sep } from 'node:path'; + +const WIN = process.platform === 'win32'; + +function norm(p) { + let s = resolve(p).replace(/[\\/]+$/, ''); + return WIN ? s.toLowerCase() : s; +} + +function findV8Project(startDir) { + let d = startDir; + for (let i = 0; i < 20 && d; i++) { + const pj = join(d, '.v8-project.json'); + if (existsSync(pj)) return pj; + const parent = dirname(d); + if (parent === d) break; + d = parent; + } + return null; +} + +// Generic reader: returns databases[]. for the matching configSrc, else global +// proj., else fallback. cwd is the hook's stdin cwd; cfgDir is the resolved config root. +export function getProjectSetting(key, cfgDir, cwd, fallback) { + try { + const pj = findV8Project(cwd) || (cfgDir ? findV8Project(cfgDir) : null); + if (!pj) return fallback; + let raw = readFileSync(pj, 'utf8'); + if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1); // strip BOM + const proj = JSON.parse(raw); + if (cfgDir && Array.isArray(proj.databases)) { + const cfgFull = norm(cfgDir); + for (const db of proj.databases) { + if (db && db.configSrc) { + const src = norm(db.configSrc); + if (cfgFull === src || cfgFull.startsWith(src + sep)) { + if (db[key]) return db[key]; + } + } + } + } + if (proj[key]) return proj[key]; + return fallback; + } catch { + return fallback; + } +} + +// Guard reaction: deny (default) | warn | off. +export function getEditMode(cfgDir, cwd) { + return getProjectSetting('editingAllowedCheck', cfgDir, cwd, 'deny'); +} + +// Suggester switch: on (default) | off. +export function getSuggesterMode(cfgDir, cwd) { + return getProjectSetting('skillSuggester', cfgDir, cwd, 'on'); +} diff --git a/hooks/common/support-state.mjs b/hooks/common/support-state.mjs new file mode 100644 index 00000000..43fe3e77 --- /dev/null +++ b/hooks/common/support-state.mjs @@ -0,0 +1,147 @@ +// support-state.mjs v1.0 — decode 1C support state (Ext/ParentConfigurations.bin) for Claude Code hooks +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// Canonical port of the in-skill guard Assert-EditAllowed / assert_edit_allowed +// (reference: .claude/skills/meta-edit/scripts/meta-edit.ps1:160-261, meta-edit.py:22-148). +// See docs/1c-support-state-spec.md. Detects whether a target file lives under a +// vendor configuration on support and whether editing it is blocked. Never throws — +// any decode error degrades to "not blocked" (allow). configSrc / .v8-project.json +// are NOT used here (reaction lookup lives in project.mjs); the config root is found +// purely by walking up to Ext/ParentConfigurations.bin. + +import { readFileSync, existsSync, statSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const GUID_RE = /\buuid="([0-9a-fA-F-]{36})"/; + +// First uuid="..." in an object XML == root element uuid (the wrapper +// carries none), matching the reference's "first element child uuid" semantics. +export function rootUuid(xmlPath) { + try { + if (!existsSync(xmlPath) || !statSync(xmlPath).isFile()) return null; + const text = readFileSync(xmlPath, 'utf8'); + const m = GUID_RE.exec(text); + return m ? m[1] : null; + } catch { + return null; + } +} + +// Walk up from startPath (a file or dir) to find the configuration root: the directory +// that holds Ext/ParentConfigurations.bin or Configuration.xml. Returns +// { cfgDir, binPath, isExtension } or nulls. isExtension is positive recognition via +// in Configuration.xml (spec §1) — distinguishes an +// extension (no real support) from "support fully removed" (bin also near-empty). +export function findConfigRoot(startPath) { + let cfgDir = null, binPath = null, configXml = null; + let d = startPath; + try { + d = existsSync(startPath) && statSync(startPath).isDirectory() ? startPath : dirname(startPath); + } catch { + d = dirname(startPath); + } + for (let i = 0; i < 12 && d; i++) { + const cand = join(d, 'Ext', 'ParentConfigurations.bin'); + const cfgX = join(d, 'Configuration.xml'); + if (existsSync(cand) || existsSync(cfgX)) { + cfgDir = d; + binPath = cand; + configXml = existsSync(cfgX) ? cfgX : null; + break; + } + const parent = dirname(d); + if (parent === d) break; + d = parent; + } + let isExtension = false; + if (configXml) { + try { + isExtension = readFileSync(configXml, 'utf8').includes('ConfigurationExtensionPurpose'); + } catch { /* ignore */ } + } + return { cfgDir, binPath, isExtension }; +} + +// Decode the bin header + per-object rules and apply the support rule for `require` +// ('editable' — blocked if locked f1=0; 'removed' — blocked unless f1=2). +// Returns { blocked, reason, code, cfgDir, targetPath }. `code` discriminates the cause +// ('capability-off' | 'locked' | 'not-removed') so callers can tailor the remedy. +// Never throws. +export function decideSupport(targetPath, require = 'editable') { + const result = { blocked: false, reason: '', code: null, cfgDir: null, targetPath }; + try { + let elemUuid = rootUuid(targetPath); + // Walk up: collect elemUuid (from .xml of a sub-element) and the config root. + let cfgDir = null, binPath = null; + let d; + try { + d = existsSync(targetPath) && statSync(targetPath).isDirectory() ? targetPath : dirname(targetPath); + } catch { + d = dirname(targetPath); + } + for (let i = 0; i < 12 && d; i++) { + if (!elemUuid) elemUuid = rootUuid(d + '.xml'); + if (!cfgDir) { + const cand = join(d, 'Ext', 'ParentConfigurations.bin'); + if (existsSync(cand) || existsSync(join(d, 'Configuration.xml'))) { + cfgDir = d; + binPath = cand; + } + } + if (elemUuid && cfgDir) break; + const parent = dirname(d); + if (parent === d) break; + d = parent; + } + result.cfgDir = cfgDir; + // New object (no element file): fall back to config root uuid. + if (!elemUuid && cfgDir) elemUuid = rootUuid(join(cfgDir, 'Configuration.xml')); + if (!binPath || !existsSync(binPath)) return result; + + let data = readFileSync(binPath); + if (data.length <= 32) return result; + if (data.length >= 3 && data[0] === 0xef && data[1] === 0xbb && data[2] === 0xbf) data = data.subarray(3); + const text = data.toString('utf8'); + + const h = /^\{6,(\d+),(\d+),/.exec(text); + if (!h) return result; + const G = parseInt(h[1], 10); + const K = parseInt(h[2], 10); + if (K === 0) return result; + + let best = null; + if (elemUuid) { + const re = new RegExp('([0-2]),0,' + escapeRe(elemUuid.toLowerCase()), 'g'); + let m; + while ((m = re.exec(text)) !== null) { + const f1 = parseInt(m[1], 10); + if (best === null || f1 < best) best = f1; + } + } + + if (G === 1) { + result.blocked = true; + result.code = 'capability-off'; + result.reason = 'возможность изменения конфигурации выключена (вся конфигурация read-only)'; + } else if (require === 'removed') { + if (best !== null && best !== 2) { + result.blocked = true; + result.code = 'not-removed'; + result.reason = 'объект на поддержке (не снят с поддержки) — удаление сломает обновления'; + } + } else { + if (best !== null && best === 0) { + result.blocked = true; + result.code = 'locked'; + result.reason = 'объект на замке (поддержка поставщика) — прямое редактирование сломает обновления'; + } + } + return result; + } catch { + return result; + } +} + +function escapeRe(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 00000000..88d4db0e --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,26 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/support-guard.mjs\"" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Read|Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/skill-suggester.mjs\"" + } + ] + } + ] + } +} diff --git a/hooks/skill-suggester.mjs b/hooks/skill-suggester.mjs new file mode 100644 index 00000000..3ea4fc22 --- /dev/null +++ b/hooks/skill-suggester.mjs @@ -0,0 +1,86 @@ +// skill-suggester.mjs v1.0 — PostToolUse hook: nudge toward the matching 1C skill when +// the model works the sources with raw tools (forgot a skill, or went manual). +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// stdin: PostToolUse JSON { tool_name, tool_input, session_id, cwd, ... }. +// Non-blocking: emits stdout JSON hookSpecificOutput.additionalContext (model-visible). +// Throttled to 1×/session/skill-group via marker files. Switch: skillSuggester (on|off) +// in .v8-project.json. Never throws. + +import { classifyFile } from './common/object-class.mjs'; +import { findConfigRoot } from './common/support-state.mjs'; +import { getSuggesterMode } from './common/project.mjs'; +import { resolve, isAbsolute, join } from 'node:path'; +import { existsSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; + +// Only file-targeting tools — these are where a skill genuinely substitutes the raw action. +// Content search (Grep/Glob) is intentionally NOT nudged: *-info skills help understand a +// located object, not find one by content. +function pickTarget(input, cwd) { + const ti = input.tool_input || {}; + const tool = input.tool_name; + if (tool !== 'Read' && tool !== 'Edit' && tool !== 'Write' && tool !== 'MultiEdit') return null; + const raw = typeof ti.file_path === 'string' ? ti.file_path + : (Array.isArray(ti.file_edits) && ti.file_edits[0]?.file_path) || null; + if (!raw) return null; + return isAbsolute(raw) ? raw : resolve(cwd, raw); +} + +function sanitize(s) { + return String(s || 'nosession').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80); +} + +// Core. opts.throttleDir overrides the marker directory (tests). Returns {stdout,stderr,exitCode}. +export function processInput(input, opts = {}) { + const empty = { stdout: '', stderr: '', exitCode: 0 }; + try { + const cwd = typeof input.cwd === 'string' ? input.cwd : process.cwd(); + const path = pickTarget(input, cwd); + if (!path) return empty; + + const hit = classifyFile(path); + if (!hit) return empty; + + // Read → info-skill; Edit/Write/MultiEdit → mutator-skill. + const action = input.tool_name === 'Read' ? 'read' : 'write'; + const message = hit[action]; + if (!message) return empty; + + const { cfgDir } = findConfigRoot(path); + if (getSuggesterMode(cfgDir, cwd) === 'off') return empty; + + // Throttle per (session, group, action): at most one read-nudge and one write-nudge + // per skill-group per session. + const dir = opts.throttleDir || tmpdir(); + const marker = join(dir, `cc-1c-suggest-${sanitize(input.session_id)}-${hit.group}-${action}`); + if (existsSync(marker)) return empty; + try { writeFileSync(marker, ''); } catch { /* throttle best-effort */ } + + const decision = { + hookSpecificOutput: { + hookEventName: 'PostToolUse', + additionalContext: `[1c-skills] ${message}`, + }, + }; + return { stdout: JSON.stringify(decision), stderr: '', exitCode: 0 }; + } catch { + return empty; + } +} + +async function readStdin() { + const chunks = []; + for await (const c of process.stdin) chunks.push(c); + return Buffer.concat(chunks).toString('utf8'); +} + +if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('skill-suggester.mjs')) { + const raw = await readStdin(); + let input = {}; + try { input = raw.trim() ? JSON.parse(raw) : {}; } catch { input = {}; } + const { stdout, stderr, exitCode } = processInput(input); + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr + '\n'); + process.exit(exitCode); +} diff --git a/hooks/support-guard.mjs b/hooks/support-guard.mjs new file mode 100644 index 00000000..34c0143b --- /dev/null +++ b/hooks/support-guard.mjs @@ -0,0 +1,121 @@ +// support-guard.mjs v1.0 — PreToolUse hook (§1A): block raw Edit/Write/MultiEdit of +// vendor objects "на замке" / read-only configs that bypass the in-skill guard (§1B). +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// stdin: PreToolUse JSON { tool_name, tool_input, cwd, ... }. +// Decision via stdout JSON hookSpecificOutput.permissionDecision (deny) — see +// docs/1c-support-state-spec.md. Reaction (deny|warn|off) from .v8-project.json +// editingAllowedCheck, identical to §1B. Never blocks on its own errors. + +import { decideSupport } from './common/support-state.mjs'; +import { getEditMode } from './common/project.mjs'; +import { resolve, isAbsolute } from 'node:path'; + +// Collect candidate file paths from an Edit/Write/MultiEdit tool_input. Handles the +// single-file form ({ file_path }) and the array form ({ file_edits: [{ file_path }] }). +function candidatePaths(toolInput) { + const out = []; + if (!toolInput || typeof toolInput !== 'object') return out; + if (typeof toolInput.file_path === 'string') out.push(toolInput.file_path); + if (Array.isArray(toolInput.file_edits)) { + for (const e of toolInput.file_edits) { + if (e && typeof e.file_path === 'string') out.push(e.file_path); + } + } + return out; +} + +// Tailored, model-actionable diagnostic per block cause (see decideSupport `code`). +// Fills in the real target/config paths so the suggested commands are ready to run. +function diagnostic(code, target, cfgDir) { + const head = + '[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, ' + + 'прямое редактирование молча сломает будущие обновления.'; + const cfe = + 'Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — ' + + 'состояние поддержки менять не нужно, обновления вендора сохраняются.'; + const offNote = 'Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json.'; + const root = cfgDir || '<каталог дампа>'; + + if (code === 'capability-off') { + return [ + head, + `Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — ` + + `поэтому объект «${target}» редактировать нельзя.`, + cfe, + `Либо снять защиту явно (навык support-edit, два шага):`, + ` 1. support-edit -Path "${root}" -Capability on — включить возможность изменения (объекты пока остаются на замке);`, + ` 2. support-edit -Path "${target}" -Set editable — открыть этот объект для редактирования.`, + `Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора.`, + offNote, + ].join('\n'); + } + if (code === 'not-removed') { + return [ + head, + `Состояние: объект «${target}» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора.`, + cfe, + `Либо сначала снять объект с поддержки, затем удалять:`, + ` support-edit -Path "${target}" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно.`, + offNote, + ].join('\n'); + } + // locked (G=0, f1=0) + return [ + head, + `Состояние: объект «${target}» на замке (возможность изменения конфигурации включена, но сам объект не редактируется).`, + cfe, + `Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):`, + ` • support-edit -Path "${target}" -Set editable — редактировать и дальше получать обновления вендора (при обновлении возможны конфликты слияния);`, + ` • support-edit -Path "${target}" -Set off-support — снять с поддержки: редактирование свободно, обновления по объекту больше не приходят.`, + offNote, + ].join('\n'); +} + +// Core decision. Returns { stdout, stderr, exitCode }. Pure (no I/O) for testability. +export function processInput(input) { + const empty = { stdout: '', stderr: '', exitCode: 0 }; + try { + const cwd = typeof input.cwd === 'string' ? input.cwd : process.cwd(); + const paths = candidatePaths(input.tool_input); + for (const p of paths) { + const target = isAbsolute(p) ? p : resolve(cwd, p); + const r = decideSupport(target, 'editable'); + if (!r.blocked) continue; + const mode = getEditMode(r.cfgDir, cwd); + if (mode === 'off') continue; + if (mode === 'warn') { + return { stdout: '', stderr: `[support-guard] ПРЕДУПРЕЖДЕНИЕ: ${r.reason}. Цель: ${target}`, exitCode: 0 }; + } + // deny (default): structured PreToolUse decision. + const decision = { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: diagnostic(r.code, target, r.cfgDir), + }, + }; + return { stdout: JSON.stringify(decision), stderr: '', exitCode: 0 }; + } + return empty; + } catch { + return empty; // guard errors must never block + } +} + +async function readStdin() { + const chunks = []; + for await (const c of process.stdin) chunks.push(c); + return Buffer.concat(chunks).toString('utf8'); +} + +// Run only when executed directly (not when imported by tests). +if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('support-guard.mjs')) { + const raw = await readStdin(); + let input = {}; + try { input = raw.trim() ? JSON.parse(raw) : {}; } catch { input = {}; } + const { stdout, stderr, exitCode } = processInput(input); + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr + '\n'); + process.exit(exitCode); +} diff --git a/hooks/test/run.mjs b/hooks/test/run.mjs new file mode 100644 index 00000000..f4358e77 --- /dev/null +++ b/hooks/test/run.mjs @@ -0,0 +1,233 @@ +// run.mjs v1.0 — standalone tests for hook common/ modules against the cfsrc corpus +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// No live hook registration needed: exercises decideSupport / getEditMode / findConfigRoot +// directly. Run: node hooks/test/run.mjs + +import { decideSupport, findConfigRoot, rootUuid } from '../common/support-state.mjs'; +import { getEditMode, getSuggesterMode } from '../common/project.mjs'; +import { processInput as guard } from '../support-guard.mjs'; +import { processInput as suggest } from '../skill-suggester.mjs'; +import { execFileSync } from 'node:child_process'; +import { rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; + +const CORPUS = 'C:/WS/tasks/cfsrc'; +const ACC = join(CORPUS, 'acc_8.3.24'); // G=1 — whole config locked +const ERP = join(CORPUS, 'erp_8.3.24'); // K=0 — support removed +const REPO = 'C:/WS/tasks/skills'; + +let pass = 0, fail = 0; +function check(name, cond, detail = '') { + if (cond) { pass++; console.log(` PASS ${name}`); } + else { fail++; console.log(` FAIL ${name}${detail ? ' — ' + detail : ''}`); } +} + +console.log('=== support-state: decideSupport ==='); + +if (existsSync(join(ACC, 'Configuration.xml'))) { + const r = decideSupport(join(ACC, 'Configuration.xml'), 'editable'); + check('acc_8.3.24 (G=1) → blocked', r.blocked === true, JSON.stringify(r)); + check('acc_8.3.24 reason mentions read-only', /read-only/.test(r.reason)); +} else { console.log(' SKIP acc_8.3.24 corpus missing'); } + +if (existsSync(join(ERP, 'Configuration.xml'))) { + const r = decideSupport(join(ERP, 'Configuration.xml'), 'editable'); + check('erp_8.3.24 (K=0) → NOT blocked', r.blocked === false, JSON.stringify(r)); + check('erp_8.3.24 cfgDir resolved', !!r.cfgDir); +} else { console.log(' SKIP erp_8.3.24 corpus missing'); } + +{ + const r = decideSupport(join(REPO, 'README.md'), 'editable'); + check('repo README (no bin) → NOT blocked', r.blocked === false, JSON.stringify(r)); +} + +console.log('=== support-state: findConfigRoot / rootUuid ==='); +if (existsSync(join(ACC, 'Configuration.xml'))) { + const cr = findConfigRoot(join(ACC, 'Ext', 'ParentConfigurations.bin')); + check('findConfigRoot locates cfgDir', !!cr.cfgDir); + check('findConfigRoot base config isExtension=false', cr.isExtension === false); + const u = rootUuid(join(ACC, 'Configuration.xml')); + check('rootUuid(Configuration.xml) is a guid', !!u && /^[0-9a-fA-F-]{36}$/.test(u), String(u)); +} + +console.log('=== support-state: synthetic per-object f1 (G=0) ==='); +{ + // Corpus only has G=1 / K=0; synthesize a G=0 single-vendor config with a locked + // (f1=0), an editable (f1=1) and a removed-from-support (f1=2) object to exercise + // the uuid rule + min-f1 fold. Fixtures go to test-tmp (gitignored), never cfsrc. + const ROOT = join(REPO, 'test-tmp', 'hooks-synth'); + const U = { + root: '11111111-1111-1111-1111-111111111111', + locked: '22222222-2222-2222-2222-222222222222', + edit: '33333333-3333-3333-3333-333333333333', + removed: '44444444-4444-4444-4444-444444444444', + free: '55555555-5555-5555-5555-555555555555', // not in bin → not on support + }; + mkdirSync(join(ROOT, 'Ext'), { recursive: true }); + mkdirSync(join(ROOT, 'Catalogs'), { recursive: true }); + const rec = (f1, u) => `${f1},0,${u},${u},`; + const binText = + `{6,0,1,aaaaaaaa-0000-0000-0000-000000000000,0,bbbbbbbb-0000-0000-0000-000000000000,` + + `"1.0","Vendor","Name",4,` + + rec(0, U.root) + rec(0, U.locked) + rec(1, U.edit) + rec(2, U.removed).replace(/,$/, '') + `}`; + writeFileSync(join(ROOT, 'Ext', 'ParentConfigurations.bin'), + Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(binText, 'utf8')])); + const objXml = (u) => `\n`; + writeFileSync(join(ROOT, 'Configuration.xml'), objXml(U.root)); + for (const [k, u] of [['Locked', U.locked], ['Editable', U.edit], ['Removed', U.removed], ['Free', U.free]]) { + writeFileSync(join(ROOT, 'Catalogs', k + '.xml'), objXml(u)); + } + + const rLocked = decideSupport(join(ROOT, 'Catalogs', 'Locked.xml'), 'editable'); + check('synth locked (f1=0) → blocked', rLocked.blocked === true, JSON.stringify(rLocked)); + check('synth locked reason mentions замке', /замке/.test(rLocked.reason)); + const rEdit = decideSupport(join(ROOT, 'Catalogs', 'Editable.xml'), 'editable'); + check('synth editable (f1=1) → NOT blocked', rEdit.blocked === false, JSON.stringify(rEdit)); + const rRemoved = decideSupport(join(ROOT, 'Catalogs', 'Removed.xml'), 'editable'); + check('synth removed (f1=2) → NOT blocked', rRemoved.blocked === false, JSON.stringify(rRemoved)); + const rFree = decideSupport(join(ROOT, 'Catalogs', 'Free.xml'), 'editable'); + check('synth free (not in bin) → NOT blocked', rFree.blocked === false, JSON.stringify(rFree)); + // meta-remove semantics: deletion needs f1=2 (removed from support). + const rmLocked = decideSupport(join(ROOT, 'Catalogs', 'Locked.xml'), 'removed'); + check('synth remove locked (f1=0) → blocked', rmLocked.blocked === true, JSON.stringify(rmLocked)); + const rmRemoved = decideSupport(join(ROOT, 'Catalogs', 'Removed.xml'), 'removed'); + check('synth remove removed (f1=2) → NOT blocked', rmRemoved.blocked === false, JSON.stringify(rmRemoved)); +} + +console.log('=== project: reaction modes ==='); +{ + const m = getEditMode(ACC, REPO); + check('getEditMode default → deny', m === 'deny', m); + const s = getSuggesterMode(ACC, REPO); + check('getSuggesterMode default → on', s === 'on', s); +} + +console.log('=== support-guard: §1A PreToolUse ==='); +{ + const SYNTH = join(REPO, 'test-tmp', 'hooks-synth'); + const edit = (fp, cwd = REPO) => guard({ tool_name: 'Edit', cwd, tool_input: { file_path: fp } }); + + // deny (default): G=1 corpus → capability-off remedy; synth locked → editable remedy. + if (existsSync(join(ACC, 'Configuration.xml'))) { + const r = edit(join(ACC, 'Configuration.xml')); + let d = null; try { d = JSON.parse(r.stdout); } catch { /* */ } + const reason = d?.hookSpecificOutput?.permissionDecisionReason || ''; + check('guard acc (G=1) → deny JSON', d?.hookSpecificOutput?.permissionDecision === 'deny', r.stdout); + check('guard G=1 reason → -Capability on remedy', /-Capability on/.test(reason) && /возможность изменения/.test(reason), reason); + } + const rLocked = edit(join(SYNTH, 'Catalogs', 'Locked.xml')); + let dLocked = null; try { dLocked = JSON.parse(rLocked.stdout); } catch { /* */ } + const reasonLocked = dLocked?.hookSpecificOutput?.permissionDecisionReason || ''; + check('guard synth locked → deny JSON', dLocked?.hookSpecificOutput?.permissionDecision === 'deny', rLocked.stdout); + check('guard locked reason → -Set editable with real path', /-Set editable/.test(reasonLocked) && reasonLocked.includes('Locked.xml'), reasonLocked); + check('guard reason offers cfe + support-edit', /cfe-borrow/.test(reasonLocked) && /support-edit/.test(reasonLocked), reasonLocked); + + // allow: erp + synth editable + non-config file → empty stdout, exit 0. + const rEdit = edit(join(SYNTH, 'Catalogs', 'Editable.xml')); + check('guard synth editable → allow (no stdout)', rEdit.stdout === '' && rEdit.exitCode === 0, JSON.stringify(rEdit)); + const rReadme = edit(join(REPO, 'README.md')); + check('guard non-config → allow', rReadme.stdout === '' && rReadme.exitCode === 0); + + // MultiEdit array form. + const rMulti = guard({ tool_name: 'MultiEdit', cwd: REPO, + tool_input: { file_edits: [{ file_path: join(SYNTH, 'Catalogs', 'Editable.xml') }, { file_path: join(SYNTH, 'Catalogs', 'Locked.xml') }] } }); + let dMulti = null; try { dMulti = JSON.parse(rMulti.stdout); } catch { /* */ } + check('guard MultiEdit (one locked) → deny', dMulti?.hookSpecificOutput?.permissionDecision === 'deny', rMulti.stdout); + + // warn / off via local .v8-project.json in the synth root. + writeFileSync(join(SYNTH, '.v8-project.json'), JSON.stringify({ editingAllowedCheck: 'warn' })); + const rWarn = edit(join(SYNTH, 'Catalogs', 'Locked.xml'), SYNTH); + check('guard warn → allow + stderr note', rWarn.stdout === '' && /ПРЕДУПРЕЖДЕНИЕ/.test(rWarn.stderr), JSON.stringify(rWarn)); + writeFileSync(join(SYNTH, '.v8-project.json'), JSON.stringify({ editingAllowedCheck: 'off' })); + const rOff = edit(join(SYNTH, 'Catalogs', 'Locked.xml'), SYNTH); + check('guard off → silent allow', rOff.stdout === '' && rOff.stderr === '', JSON.stringify(rOff)); + + // Real stdin→stdout wiring through node (deny path, default project). + try { + const payload = JSON.stringify({ tool_name: 'Edit', cwd: REPO, tool_input: { file_path: join(SYNTH, 'Catalogs', 'Locked.xml') } }); + // remove the off-mode project file so default deny applies + writeFileSync(join(SYNTH, '.v8-project.json'), JSON.stringify({ editingAllowedCheck: 'deny' })); + const out = execFileSync(process.execPath, [join(REPO, 'hooks', 'support-guard.mjs')], { input: payload, encoding: 'utf8' }); + const d = JSON.parse(out); + check('guard via stdin subprocess → deny JSON', d?.hookSpecificOutput?.permissionDecision === 'deny', out); + } catch (e) { + check('guard via stdin subprocess → deny JSON', false, String(e)); + } +} + +console.log('=== skill-suggester: PostToolUse nudge ==='); +{ + const SYNTH = join(REPO, 'test-tmp', 'hooks-synth'); + const THR = join(REPO, 'test-tmp', 'hooks-throttle'); + rmSync(THR, { recursive: true, force: true }); + mkdirSync(THR, { recursive: true }); + // suggester reads skillSuggester from .v8-project.json; clear synth project file → default on + rmSync(join(SYNTH, '.v8-project.json'), { force: true }); + + // sniff fixtures + mkdirSync(join(SYNTH, 'Catalogs', 'Obj', 'Forms', 'F', 'Ext'), { recursive: true }); + mkdirSync(join(SYNTH, 'Catalogs', 'Obj', 'Templates', 'Print', 'Ext'), { recursive: true }); + mkdirSync(join(SYNTH, 'Catalogs', 'Obj', 'Templates', 'Scheme', 'Ext'), { recursive: true }); + mkdirSync(join(SYNTH, 'Roles', 'R', 'Ext'), { recursive: true }); + mkdirSync(join(SYNTH, 'ext'), { recursive: true }); + writeFileSync(join(SYNTH, 'Catalogs', 'Obj', 'Forms', 'F', 'Ext', 'Form.xml'), '
'); + writeFileSync(join(SYNTH, 'Catalogs', 'Obj', 'Templates', 'Print', 'Ext', 'Template.xml'), + '\n'); + writeFileSync(join(SYNTH, 'Catalogs', 'Obj', 'Templates', 'Scheme', 'Ext', 'Template.xml'), + '\n'); + writeFileSync(join(SYNTH, 'Roles', 'R', 'Ext', 'Rights.xml'), ''); + writeFileSync(join(SYNTH, 'ext', 'Configuration.xml'), + '\nCustomization'); + + const call = (fp, session, tool) => suggest({ tool_name: tool, session_id: session, cwd: REPO, tool_input: { file_path: fp } }, { throttleDir: THR }); + const read = (fp, session = 's1') => call(fp, session, 'Read'); + const edit = (fp, session = 's1') => call(fp, session, 'Edit'); + const grp = (r) => { try { return JSON.parse(r.stdout)?.hookSpecificOutput?.additionalContext; } catch { return null; } }; + + // Read → info-skill; Edit → mutator-skill (same group, distinct nudge). + const rMeta = read(join(SYNTH, 'Catalogs', 'Locked.xml'), 'A'); + check('Read Catalogs/X.xml → meta-info', /meta-info/.test(grp(rMeta) || ''), rMeta.stdout); + const rMetaEdit = edit(join(SYNTH, 'Catalogs', 'Editable.xml'), 'A'); // same session+group, write action + check('Edit Catalogs/X.xml → meta-edit (not throttled by the read)', /meta-edit/.test(grp(rMetaEdit) || ''), rMetaEdit.stdout); + const rMeta2 = read(join(SYNTH, 'Catalogs', 'Editable.xml'), 'A'); // same session+group+action → throttled + check('second Read meta same session → silent (throttle)', rMeta2.stdout === '', rMeta2.stdout); + const rForm = read(join(SYNTH, 'Catalogs', 'Obj', 'Forms', 'F', 'Ext', 'Form.xml'), 'A'); + check('Read Form.xml (diff group) → form-info', /form-info/.test(grp(rForm) || ''), rForm.stdout); + + const rMxl = read(join(SYNTH, 'Catalogs', 'Obj', 'Templates', 'Print', 'Ext', 'Template.xml'), 'B'); + check('Read spreadsheet Template → mxl-info', /mxl-info/.test(grp(rMxl) || ''), rMxl.stdout); + const rSkd = edit(join(SYNTH, 'Catalogs', 'Obj', 'Templates', 'Scheme', 'Ext', 'Template.xml'), 'B'); + check('Edit DCS Template → skd-edit', /skd-edit/.test(grp(rSkd) || ''), rSkd.stdout); + const rRole = read(join(SYNTH, 'Roles', 'R', 'Ext', 'Rights.xml'), 'B'); + check('Read Rights.xml → role-info', /role-info/.test(grp(rRole) || ''), rRole.stdout); + + const rCf = read(join(ACC, 'Configuration.xml'), 'C'); + check('Read base Configuration.xml → cf-info', /cf-info/.test(grp(rCf) || ''), rCf.stdout); + const rCfe = read(join(SYNTH, 'ext', 'Configuration.xml'), 'C'); + check('Read extension Configuration.xml → cfe/cf-info', /cfe-diff|cf-info/.test(grp(rCfe) || ''), rCfe.stdout); + const rCfeEdit = edit(join(SYNTH, 'ext', 'Configuration.xml'), 'C'); + check('Edit extension Configuration.xml → cfe-borrow/patch', /cfe-borrow|cfe-patch-method/.test(grp(rCfeEdit) || ''), rCfeEdit.stdout); + + // blind spots + const rBsl = read(join(SYNTH, 'Catalogs', 'Obj', 'Ext', 'ObjectModule.bsl'), 'D'); + check('Read .bsl → silent', rBsl.stdout === '', rBsl.stdout); + const rReadme = read(join(REPO, 'README.md'), 'D'); + check('Read non-1C file → silent', rReadme.stdout === '', rReadme.stdout); + + // search tools no longer nudge + const rGrep = suggest({ tool_name: 'Grep', session_id: 'E', cwd: REPO, tool_input: { path: join(ACC, 'Catalogs'), pattern: 'foo' } }, { throttleDir: THR }); + check('Grep → silent (search trigger removed)', rGrep.stdout === '', rGrep.stdout); + const rGlob = suggest({ tool_name: 'Glob', session_id: 'E', cwd: REPO, tool_input: { pattern: '**/Catalogs/*.xml' } }, { throttleDir: THR }); + check('Glob → silent (search trigger removed)', rGlob.stdout === '', rGlob.stdout); + + // skillSuggester off + writeFileSync(join(SYNTH, '.v8-project.json'), JSON.stringify({ skillSuggester: 'off' })); + const rOff = suggest({ tool_name: 'Read', session_id: 'F', cwd: SYNTH, tool_input: { file_path: join(SYNTH, 'Catalogs', 'Locked.xml') } }, { throttleDir: THR }); + check('suggest skillSuggester=off → silent', rOff.stdout === '', rOff.stdout); + rmSync(join(SYNTH, '.v8-project.json'), { force: true }); +} + +console.log(`\n${fail === 0 ? 'ALL OK' : 'FAILURES'}: ${pass} passed, ${fail} failed`); +process.exit(fail === 0 ? 0 : 1);