feat(hooks): §1A гард поддержки + суфлёр навыков (node-хуки Claude Code)

Харнес-слой поверх пола §1B: ловит правки мимо навыков-мутаторов.

- support-guard.mjs (PreToolUse Edit|Write|MultiEdit) — §1A: блокирует
  сырую правку объекта поставщика «на замке» / read-only конфы; реакция
  deny|warn|off из .v8-project.json editingAllowedCheck, идентично §1B.
- skill-suggester.mjs (PostToolUse Read|Grep|Glob|Edit|Write|MultiEdit) —
  ненавязчивая подсказка профильного навыка, throttle 1×/сессия/группа,
  не блокирует; флаг skillSuggester (on|off).
- common/: support-state.mjs (порт декодера bin 1:1 из Assert-EditAllowed),
  project.mjs (реакция из .v8-project.json), object-class.mjs (карта
  путь→навык с различением cf/cfe и mxl/скд по нюху корня).
- test/run.mjs: 38 standalone-тестов на корпусе cfsrc + синтетике.
- plugin.json: hooks → ./hooks/hooks.json (авто-загрузка в плагине).

§1C (грубый Bash-гейт) отброшен — дублирует §1B, формат bin заморожен.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-06-20 18:39:05 +03:00
co-authored by Claude Opus 4.8
parent 07ea676326
commit ebd620d262
9 changed files with 808 additions and 1 deletions
+108
View File
@@ -0,0 +1,108 @@
// 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, message }
// 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',
]);
const MESSAGES = {
meta: 'Структуру объекта 1С быстрее даёт навык `meta-info` (одна сводка вместо сырого XML), а структурные правки — `meta-edit` (реквизиты/ТЧ/измерения/ресурсы).',
form: 'Для управляемой формы 1С есть `form-info` (анализ элементов/реквизитов/команд) и `form-edit` (точечные правки).',
mxl: 'Это табличный документ 1С: `mxl-info`/`mxl-decompile` дают редактируемое описание, `mxl-compile` собирает обратно.',
skd: 'Это схема компоновки данных (СКД): `skd-info` для анализа, `skd-edit` для точечных правок.',
role: 'Для прав роли 1С есть `role-info` (сводка прав/RLS) и `role-compile` (создание из DSL).',
cf: 'Корень конфигурации 1С: `cf-info` (обзор состава/свойств) и `cf-edit` (правки настроек/состава).',
cfe: 'Это расширение конфигурации (CFE): `cfe-diff` для анализа, а доработку безопаснее вести через `cfe-borrow`/`cfe-patch-method`.',
subsystem: 'Подсистема 1С: `subsystem-info` (состав/дерево) и `subsystem-edit` (правки состава/свойств).',
template: 'Это макет объекта 1С: для табличного документа — навыки `mxl-*`, для СКД — `skd-*`.',
search: 'Для навигации по метаданным 1С есть структурированные навыки `*-info` (meta-info/cf-info/form-info/…) — обычно быстрее сырого поиска по XML.',
};
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, message } 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;
}
}
// Classify a Grep/Glob search target: if it points inside a 1C config tree (or a known
// collection appears in the path/pattern) → suggest the info-skills. Best-effort, lean silent.
export function classifySearch(target) {
try {
if (!target) return null;
const segs = segments(target);
if (segs.some((s) => META_COLLECTIONS.has(s) || s === 'Roles' || s === 'Subsystems')) return mk('search');
return null;
} catch {
return null;
}
}
function mk(group) {
return { group, message: MESSAGES[group] };
}
+67
View File
@@ -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');
}
+142
View File
@@ -0,0 +1,142 @@
// 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, cfgDir, targetPath }. Never throws.
export function decideSupport(targetPath, require = 'editable') {
const result = { blocked: false, reason: '', 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.reason = 'возможность изменения конфигурации выключена (вся конфигурация read-only)';
} else if (require === 'removed') {
if (best !== null && best !== 2) {
result.blocked = true;
result.reason = 'объект на поддержке (не снят с поддержки) — удаление сломает обновления';
}
} else {
if (best !== null && best === 0) {
result.blocked = true;
result.reason = 'объект на замке (поддержка поставщика) — прямая правка сломает обновления';
}
}
return result;
} catch {
return result;
}
}
function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}