mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-24 13:41:01 +03:00
merge: хуки support-guard + суфлёр (опт-ин) в support-state-format
Консолидация: §1B (в навыках) + §1A/суфлёр (хуки, экспериментальные, опт-ин).
This commit is contained in:
@@ -58,6 +58,7 @@
|
|||||||
| `databases` | array | да | — | Список баз данных | `/db-list add` |
|
| `databases` | array | да | — | Список баз данных | `/db-list add` |
|
||||||
| `default` | string | нет | — | `id` базы по умолчанию | `/db-list` |
|
| `default` | string | нет | — | `id` базы по умолчанию | `/db-list` |
|
||||||
| `editingAllowedCheck` | `"deny"`/`"warn"`/`"off"` | нет | `deny` | Глобальная реакция support-guard на правку объектов на замке (см. ниже) | Руками |
|
| `editingAllowedCheck` | `"deny"`/`"warn"`/`"off"` | нет | `deny` | Глобальная реакция support-guard на правку объектов на замке (см. ниже) | Руками |
|
||||||
|
| `skillSuggester` | `"on"`/`"off"` | нет | `on` | Подсказки навыков от хука skill-suggester (только если хук включён, см. ниже) | Руками |
|
||||||
| `webPath` | string | нет | `tools/apache24` | Каталог Apache HTTP Server | Руками |
|
| `webPath` | string | нет | `tools/apache24` | Каталог Apache HTTP Server | Руками |
|
||||||
| `ffmpegPath` | string | нет | `tools/ffmpeg/bin/ffmpeg.exe` | Путь к ffmpeg | Руками |
|
| `ffmpegPath` | string | нет | `tools/ffmpeg/bin/ffmpeg.exe` | Путь к ffmpeg | Руками |
|
||||||
| `tts` | object | нет | Edge TTS, DmitryNeural | Настройки озвучки видео | Руками |
|
| `tts` | object | нет | Edge TTS, DmitryNeural | Настройки озвучки видео | Руками |
|
||||||
@@ -78,6 +79,7 @@
|
|||||||
| `branches` | string[] | нет | Git-ветки или glob-паттерны (`release/*`, `feature/*`) | Руками |
|
| `branches` | string[] | нет | Git-ветки или glob-паттерны (`release/*`, `feature/*`) | Руками |
|
||||||
| `configSrc` | string | нет | Каталог XML-выгрузки конфигурации | Руками |
|
| `configSrc` | string | нет | Каталог XML-выгрузки конфигурации | Руками |
|
||||||
| `editingAllowedCheck` | `"deny"`/`"warn"`/`"off"` | нет | Override реакции support-guard для этой базы (см. ниже) | Руками |
|
| `editingAllowedCheck` | `"deny"`/`"warn"`/`"off"` | нет | Override реакции support-guard для этой базы (см. ниже) | Руками |
|
||||||
|
| `skillSuggester` | `"on"`/`"off"` | нет | Override подсказок навыков для этой базы (см. ниже) | Руками |
|
||||||
| `webUrl` | string | нет | URL веб-клиента для `/web-test` | Руками |
|
| `webUrl` | string | нет | URL веб-клиента для `/web-test` | Руками |
|
||||||
|
|
||||||
### Support-guard и `editingAllowedCheck`
|
### Support-guard и `editingAllowedCheck`
|
||||||
@@ -91,6 +93,14 @@
|
|||||||
|
|
||||||
Триггер проверки — наличие `ParentConfigurations.bin` (конфигурация на поддержке), а не регистрация в `.v8-project.json`. Поле лишь меняет реакцию. Берётся `databases[].editingAllowedCheck` базы, чей `configSrc` охватывает редактируемый путь; иначе — корневое `editingAllowedCheck`; иначе `deny`.
|
Триггер проверки — наличие `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` используют единый алгоритм:
|
Все навыки `/db-*`, `/epf-build`, `/epf-dump`, `/erf-build`, `/erf-dump`, `/web-publish` используют единый алгоритм:
|
||||||
|
|||||||
@@ -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; рабочие выгрузки не затрагиваются).
|
||||||
@@ -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 <ConfigurationExtensionPurpose> 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/<Name>/Ext/
|
||||||
|
if (name === 'Form.xml' && segs.includes('Forms')) return mk('form');
|
||||||
|
|
||||||
|
// Template.xml under .../Templates/<Name>/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/<Name>.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: <Collection>/<Name>.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 };
|
||||||
|
}
|
||||||
@@ -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[].<key> for the matching configSrc, else global
|
||||||
|
// proj.<key>, 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');
|
||||||
|
}
|
||||||
@@ -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 <MetaDataObject> 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
|
||||||
|
// <ConfigurationExtensionPurpose> 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 <dir>.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, '\\$&');
|
||||||
|
}
|
||||||
@@ -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\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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) => `<?xml version="1.0" encoding="UTF-8"?>\n<MetaDataObject><Catalog uuid="${u}"></Catalog></MetaDataObject>`;
|
||||||
|
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'), '<?xml version="1.0"?><Form/>');
|
||||||
|
writeFileSync(join(SYNTH, 'Catalogs', 'Obj', 'Templates', 'Print', 'Ext', 'Template.xml'),
|
||||||
|
'<?xml version="1.0"?>\n<document xmlns="http://v8.1c.ru/8.2/data/spreadsheet"></document>');
|
||||||
|
writeFileSync(join(SYNTH, 'Catalogs', 'Obj', 'Templates', 'Scheme', 'Ext', 'Template.xml'),
|
||||||
|
'<?xml version="1.0"?>\n<DataCompositionSchema xmlns="http://v8.1c.ru/8.1/data/data-composition-system/schema"></DataCompositionSchema>');
|
||||||
|
writeFileSync(join(SYNTH, 'Roles', 'R', 'Ext', 'Rights.xml'), '<?xml version="1.0"?><Rights/>');
|
||||||
|
writeFileSync(join(SYNTH, 'ext', 'Configuration.xml'),
|
||||||
|
'<?xml version="1.0"?>\n<MetaDataObject><Configuration uuid="x"><Properties><ConfigurationExtensionPurpose>Customization</ConfigurationExtensionPurpose></Properties></Configuration></MetaDataObject>');
|
||||||
|
|
||||||
|
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);
|
||||||
Reference in New Issue
Block a user