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:
@@ -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("}
|
||||
]
|
||||
```
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user