feat(brain-config): чистый загрузчик brain.local.md + fail-safe resolveConfig (Фаза 1 Задачи 1-2)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-15 10:21:50 +03:00
parent 3a422a00c3
commit b9730afb8a
4 changed files with 281 additions and 0 deletions
@@ -0,0 +1,52 @@
# План-церемония v3: фикс сбора тестов `brain-config` (Фаза 1, Задачи 1–2)
## Цель
Исправить сбор тестов модуля `tools/brain-config.mjs`. Конфиг `vitest.config.tools.mjs`
задаёт `globals: true` (верифицировано — см. `verified-context-json`, якорь
`vitest-globals`), значит `describe/it/expect` доступны как глобалы и **не должны**
импортироваться явно. Текущий тест-файл импортирует их из `'vitest'`, из-за чего
`describe` приходит из контекста без раннера и сбор падает («reading config»).
Фикс: переписать тест без этого импорта (опора на глобалы конфига). Код модуля уже
корректен и не трогается. Контракт и крайние случаи — спека
`2026-06-15-brain-config-module-spec.md` (якоря D1–D6). Итог: единичный файл и
полный свод `tools/` зелёные.
## Безопасность отката (премортем)
- **Глобалы гарантированы.** `globals: true` в конфиге (верифицировано якорем) —
значит `describe/it/expect` инъектируются раннером глобально; тест без импорта
их получит. Если бы глобалов не было, этот же тест падал бы по `describe is not
defined` — но конфиг подтверждает обратное.
- **Нет необратимых действий.** План — только Write тест-файла + два прогона тестов.
Ни `git commit`, ни `push`, ни CI не запускаются → денежных/CI-последствий нет.
- **Откат тривиален.** Трогается один файл (`tools/brain-config.test.mjs`); модуль
`brain-config.mjs` не меняется. Если шаг 2 неожиданно RED — план встаёт до шага 3
(регрессии), откат = восстановить прежний тест-файл, другие файлы не затронуты.
```skills-json
["test-driven-development"]
```
```steps-json
[
{"op":"Write","object":"tools/brain-config.test.mjs","ref":"D6"},
{"op":"Bash","object":"npx vitest run --config vitest.config.tools.mjs tools/brain-config.test.mjs","ref":"D6"},
{"op":"Bash","object":"npx vitest run --config vitest.config.tools.mjs","ref":"D6"}
]
```
## Порядок шагов (человекочитаемо)
1. **Write** `tools/brain-config.test.mjs` — те же тесты `parseBrainConfig` +
`resolveConfig` fail-safe (D1/D2/D3), но **без** `import ... from 'vitest'`
(глобалы из конфига, `globals: true`). Критерий — D6.
2. **Bash** vitest на файл — ожидается GREEN (сбор починен, контракт + fail-safe).
3. **Bash** полный свод `tools/` — ожидается GREEN (регрессия не сломана, модуль новый).
```verified-context-json
[
{"id":"tools-pure-module-style","kind":"EXTRACTED","ref":"tools/cost-pricing.mjs","anchor":"export const PRICING = Object.freeze("},
{"id":"vitest-globals","kind":"EXTRACTED","ref":"vitest.config.tools.mjs","anchor":"globals: true"}
]
```
@@ -0,0 +1,92 @@
# Спека: модуль `tools/brain-config.mjs` — чтение проектной настройки мозга
**Дата:** 2026-06-15
**Статус:** к исполнению (Фаза 1, Задачи 1–2)
## Цель
Дать движку единый чистый модуль, который читает проектную настройку из файла
`.claude/brain.local.md` и отдаёт типизированные значения с безопасными
направлениями отказа. Захардкоженные «знания про конкретный проект», рассыпанные
по `tools/*.mjs`, должны получить один источник правды. Этот модуль — только
загрузчик и нормализатор настройки; он не меняет поведение существующих файлов
(они подключатся к нему отдельными задачами с дефолтами = текущие значения).
## Контракт API {#D1}
Модуль `tools/brain-config.mjs` (Node ESM, стиль каталога `tools/`) экспортирует
три чистые/полу-чистые функции:
- `parseBrainConfig(md) -> object` — чистая. На вход markdown-строка (содержимое
`.claude/brain.local.md`). Возвращает объект ключей из YAML-frontmatter. На `null`,
пустую строку или текст без frontmatter — пустой объект `{}`.
- `resolveConfig(raw) -> object` — чистая. На вход результат `parseBrainConfig`.
Накладывает дефолты и fail-safe направления (§D3). Возвращает полный объект
настройки.
- `loadConfig(root = '.', fsImpl = fs) -> object` — I/O-обёртка. Читает
`${root}/.claude/brain.local.md`; если файла нет — отдаёт дефолты (через
`resolveConfig({})`). `fsImpl` инъектируется для тестов.
Ключи настройки: `config_version`, `registry_path`, `state_dir`, `evidence_archive`,
`normative_files` (список), `project_url_whitelist` (список), `classifier_context`,
`economy_default`, `enabled_hook_groups` (список).
## Алгоритм парсинга frontmatter {#D2}
Минимальный YAML-frontmatter без внешних зависимостей:
- Границы frontmatter — регэксп `^---\n([\s\S]*?)\n---` (блок между первыми `---`).
- Внутри блока построчно:
- строка вида ` - item` (элемент списка) добавляется к текущему ключу-списку;
- строка вида `key: value` начинает новый ключ; пустое `value` означает начало
списка (ключ инициализируется пустым массивом, последующие `- item` в него
падают);
- непустое числовое `value` (`^\d+$`) приводится к числу, иначе остаётся строкой;
- после скалярного ключа «текущий ключ-список» сбрасывается.
- Строки, не подходящие ни под один шаблон, игнорируются.
## Fail-safe направления (дефолты + отказ) {#D3}
`resolveConfig` накладывает безопасные дефолты на отсутствующие ключи. Отсутствие
или невалидность ключа НЕ отключает молча безопасность.
| Ключ | Дефолт при отсутствии | Направление |
|---|---|---|
| `state_dir` | `.claude/brain-state` | безопасный дефолт |
| `evidence_archive` | `brain-state` | безопасный дефолт |
| `normative_files` | `[]` (легитимно пусто) | явная пустота |
| `registry_path` | `''` (зависимости off осознанно) | явная пустота |
| `project_url_whitelist` | `[]` + флаг `project_url_whitelist_failClosed: true` | **fail-closed** (внешнее закрыто) |
| `classifier_context` | строка с маркером `generic` | безопасная деградация качества |
| `economy_default` | `'100'` | безопасный дефолт |
`project_url_whitelist_failClosed` равен `true`, когда whitelist пуст или не массив
(потребитель обязан блокировать внешние адреса), и `false`, когда список непуст.
## Крайние случаи {#D4}
- `parseBrainConfig(null)` и `parseBrainConfig('')``{}`.
- Текст без frontmatter (`'тело без ---'`) → `{}`.
- `loadConfig` при отсутствующем файле (читалка бросает) → дефолты, без падения.
- Числовое значение (`config_version: 1`) → число `1`, не строка.
- Список из одного элемента → массив длины 1.
## Конвенция заголовка модуля {#D5}
Файл начинается с shebang `#!/usr/bin/env node` и JSDoc-блока, описывающего
назначение (единый источник проектной настройки мозга, чистый парсер +
`resolveConfig` с fail-safe дефолтами). Стиль — как у соседних чистых модулей
каталога `tools/` (замороженные константы через `Object.freeze`, ESM-импорт fs).
## Критерий приёмки {#D6}
- `npx vitest run --config vitest.config.tools.mjs tools/brain-config.test.mjs`
сначала RED (нет реализации), затем GREEN (все кейсы контракта и fail-safe).
- `npx vitest run --config vitest.config.tools.mjs` — полный свод `tools/` остаётся
зелёным (модуль новый, существующее поведение не затронуто).
```verified-context-json
[
{"id":"tools-pure-module-style","kind":"EXTRACTED","ref":"tools/cost-pricing.mjs","anchor":"export const PRICING = Object.freeze("}
]
```
+68
View File
@@ -0,0 +1,68 @@
/**
* brain-config — единый источник проектной настройки мозга (.claude/brain.local.md).
*
* Чистый парсер frontmatter (parseBrainConfig) + resolveConfig с fail-safe
* дефолтами (спека 2026-06-15-brain-config-module-spec.md §D3) + I/O-обёртка
* loadConfig. Захардкоженные «знания про конкретный проект» из tools/*.mjs
* получают один источник правды; направления отказа безопасны (отсутствие ключа
* не отключает молча безопасность/деньги/защиту).
*/
import fsDefault from 'node:fs';
const FM_RE = /^---\n([\s\S]*?)\n---/;
/** Минимальный YAML-frontmatter: `key: value` + список ` - item`. Без зависимостей. Чистая. */
export function parseBrainConfig(md) {
const m = FM_RE.exec(String(md || ''));
if (!m) return {};
const out = {};
let curKey = null;
for (const line of m[1].split('\n')) {
const item = /^\s+-\s+(.*)$/.exec(line);
if (item && curKey) {
(out[curKey] ||= []).push(item[1].trim());
continue;
}
const kv = /^([A-Za-z_]+):\s*(.*)$/.exec(line);
if (!kv) continue;
const [, k, v] = kv;
if (v === '') {
out[k] = [];
curKey = k;
} else {
out[k] = /^\d+$/.test(v) ? Number(v) : v;
curKey = null;
}
}
return out;
}
/** Безопасные дефолты (§D3). Отсутствие ключа не отключает молча защиту/деньги. */
const DEFAULTS = Object.freeze({
state_dir: '.claude/brain-state',
evidence_archive: 'brain-state',
normative_files: [],
registry_path: '',
project_url_whitelist: [],
classifier_context: 'generic project (no profile configured)',
economy_default: '100',
});
/** Наложить дефолты + fail-safe направления (§D3). Чистая. */
export function resolveConfig(raw) {
const c = { ...DEFAULTS, ...(raw || {}) };
// project_url_whitelist: пусто/не-массив → fail-CLOSED (внешка закрыта), не «пускать всё».
c.project_url_whitelist_failClosed = !(Array.isArray(c.project_url_whitelist) && c.project_url_whitelist.length > 0);
return c;
}
/** I/O-обёртка: прочитать .claude/brain.local.md проекта (нет файла → дефолты). */
export function loadConfig(root = '.', fsImpl = fsDefault) {
let md = '';
try {
md = fsImpl.readFileSync(`${root}/.claude/brain.local.md`, 'utf8');
} catch {
md = '';
}
return resolveConfig(parseBrainConfig(md));
}
+69
View File
@@ -0,0 +1,69 @@
import { parseBrainConfig, resolveConfig } from './brain-config.mjs';
describe('parseBrainConfig', () => {
it('читает YAML-frontmatter ключи', () => {
const md = '---\nconfig_version: 1\nregistry_path: docs/registry/nodes.yaml\nnormative_files:\n - docs/Pravila_raboty_Claude_v1_1.md\n---\nтело';
const c = parseBrainConfig(md);
expect(c.config_version).toBe(1);
expect(c.registry_path).toBe('docs/registry/nodes.yaml');
expect(c.normative_files).toEqual(['docs/Pravila_raboty_Claude_v1_1.md']);
});
it('читает список из нескольких элементов', () => {
const md = '---\nnormative_files:\n - a.md\n - b.md\n - c.md\n---';
expect(parseBrainConfig(md).normative_files).toEqual(['a.md', 'b.md', 'c.md']);
});
it('нет файла/пусто → пустой объект (дефолты применит resolveConfig)', () => {
expect(parseBrainConfig('')).toEqual({});
expect(parseBrainConfig(null)).toEqual({});
});
it('текст без frontmatter → пустой объект', () => {
expect(parseBrainConfig('тело без разделителей')).toEqual({});
});
});
describe('resolveConfig fail-safe (§D3)', () => {
it('state_dir отсутствует → безопасный дефолт', () => {
expect(resolveConfig({}).state_dir).toBe('.claude/brain-state');
});
it('evidence_archive отсутствует → безопасный дефолт', () => {
expect(resolveConfig({}).evidence_archive).toBe('brain-state');
});
it('project_url_whitelist пуст → fail-closed маркер (внешка закрыта)', () => {
const r = resolveConfig({});
expect(r.project_url_whitelist).toEqual([]);
expect(r.project_url_whitelist_failClosed).toBe(true);
});
it('project_url_whitelist непуст → fail-closed снят', () => {
const r = resolveConfig({ project_url_whitelist: ['liderra.ru'] });
expect(r.project_url_whitelist).toEqual(['liderra.ru']);
expect(r.project_url_whitelist_failClosed).toBe(false);
});
it('normative_files пуст → [] (legitimate)', () => {
expect(resolveConfig({}).normative_files).toEqual([]);
});
it('registry_path пуст → пустая строка (зависимости off осознанно)', () => {
expect(resolveConfig({}).registry_path).toBe('');
});
it('classifier_context пуст → generic-строка', () => {
expect(resolveConfig({}).classifier_context).toMatch(/generic|общий/i);
});
it('economy_default пуст → дефолт 100', () => {
expect(resolveConfig({}).economy_default).toBe('100');
});
it('переданные значения переживают resolveConfig', () => {
const r = resolveConfig({ state_dir: 'docs/observer', registry_path: 'docs/registry/nodes.yaml' });
expect(r.state_dir).toBe('docs/observer');
expect(r.registry_path).toBe('docs/registry/nodes.yaml');
});
});