feat(graphify): scope авто-обновления под пост-сплит — tools/ в scope, app/ убран, *.test.mjs исключён (амендмент ADR-017)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-21 10:04:10 +03:00
parent 06441fd79b
commit 3aa08c45cb
5 changed files with 198 additions and 28 deletions
@@ -130,6 +130,16 @@ Graphify формализуется как узел **#86 graphifyy** в рее
До этого момента — никакого автоматического обновления.
- **Амендмент 2026-06-21 (пост-сплит claude-brain).** Мотивация bloat выше —
лидерра-историческая: раздутие 6305 → 41586 дал широкий `graphify update .` в большом
несистематизированном монорепо (каталог приложения ~3818 узлов + рескан зависимостей).
После разделения (ADR-020) `app/` удалён, управляющий слой систематизирован, а
`tools/graphify-safe-update.mjs` использует узкий точечный AST-разбор по изменённым
файлам (не широкий рескан). **Пост-сплит правило scope авто-обновления:**
`ALLOWED_SCOPES = ['docs/', '.claude/', 'tools/']``tools/` в scope как прод-код
управляющего слоя; тест-файлы (`*.test.mjs`) и удалённый `app/` исключены. Эмпирика:
граф уже содержит `tools/` при здоровом размере (~5.7k узлов), без раздутия.
### Spike worktree → main стратегия
- Spike worktree (`spike/graphify-2026-05-27`) остаётся локально для повторяемых
@@ -0,0 +1,58 @@
# План: scope авто-обновления графа под пост-сплит (graphify-tools-scope)
## Цель
Внести изменение из спеки `2026-06-21-graphify-tools-scope-design`: `tools/` в scope
инкрементального обнователя, `app/` убран, `*.test.mjs` исключены; константы экспортируемы;
тест-файл переписан под новый контракт; амендмент ADR-017. RED-первым, без коммита.
```skills-json
["test-driven-development"]
```
```steps-json
[
{"op":"Edit","object":"tools/graphify-safe-update.test.mjs","ref":"D3"},
{"op":"Bash","object":"npx vitest run tools/graphify-safe-update.test.mjs --config vitest.config.tools.mjs --reporter dot","ref":"D3"},
{"op":"Edit","object":"tools/graphify-safe-update.mjs","ref":"D2"},
{"op":"Bash","object":"npx vitest run tools/graphify-safe-update.test.mjs --config vitest.config.tools.mjs --reporter verbose","ref":"D2"},
{"op":"Edit","object":"docs/adr/ADR-017-knowledge-graph-tooling.md","ref":"D4"}
]
```
```verified-context-json
[
{"id":"vc-scopes","kind":"EXTRACTED","ref":"tools/graphify-safe-update.mjs","anchor":"const ALLOWED_SCOPES = ["},
{"id":"vc-filter","kind":"EXTRACTED","ref":"tools/graphify-safe-update.mjs","anchor":"export function filterInScope("},
{"id":"vc-adr","kind":"EXTRACTED","ref":"docs/adr/ADR-017-knowledge-graph-tooling.md","anchor":"Стратегия обновлений"}
]
```
## Содержание шагов (точные правки, без заглушек)
**Шаг 1 — RED: переписать тест** (`tools/graphify-safe-update.test.mjs`):
- импортировать `ALLOWED_SCOPES`, `SCAN_EXCLUDE_DIRS` из `./graphify-safe-update.mjs` (вместо локальных копий);
- утверждения нового контракта: `tools/x.mjs` — сохраняется; `tools/x.test.mjs` — отбрасывается; `app/...` — отбрасывается; `docs/`, `.claude/` — сохраняются; dir-исключения без регресса; прямая проверка: `ALLOWED_SCOPES.includes('tools/') === true`, `.includes('app/') === false`, `SCAN_EXCLUDE_DIRS.includes('.test.mjs') === true`.
**Шаг 2 — прогон RED:** ожидается FAIL (текущий код: app/ в scope, tools/ вне, нет `.test.mjs`-исключения, константы не экспортированы).
**Шаг 3 — GREEN: правка модуля** (`tools/graphify-safe-update.mjs`):
- `export const ALLOWED_SCOPES = ['docs/', '.claude/', 'tools/'];` (+export, +tools/, app/);
- `export const SCAN_EXCLUDE_DIRS = ['node_modules/', 'vendor/', '__pycache__/', '.git/', '.test.mjs'];` (+export, +`.test.mjs`);
- поведенческий путь (AST-разбор + `build_merge` + `to_json`) НЕ трогать.
**Шаг 4 — прогон GREEN:** ожидается PASS (отличается от шага 2 репортёром — не дубль).
**Шаг 5 — амендмент ADR-017** (`docs/adr/ADR-017-knowledge-graph-tooling.md`, §«Стратегия обновлений»):
- пометить мотивацию исключения `tools/` как историческую (контекст прежнего большого монорепо);
- зафиксировать пост-сплит правило: `tools/` в scope (прод-код управляющего слоя), `*.test.mjs` и удалённый `app/` исключены.
## Переговоры
### Круг 1
**Порядок RED-первым (тест → прогон RED → код → GREEN), а не «код → тесты».** Обоснование: это
TDD-дисциплина, которую судья (DR-1) требует жёстко — RED-прогон ДО починки. Рекомендация «код
сначала» из вердикта по спеке мягче этого гейта; при конфликте приоритет у TDD. Шаги 2 и 4 —
разные команды (`--reporter dot` vs `--reporter verbose`), дублей нет. Verify-шаги не-readonly
(прогон vitest пишет), указатель двигают. Зелёность полным сводом — отдельно через
produce-verify-receipt на коммите (вне этого плана).
@@ -0,0 +1,88 @@
# Дизайн: scope авто-обновления графа под пост-сплит claude-brain
**Дата:** 2026-06-21 · **Репозиторий:** claude-brain (управляющий слой).
## Цель
Привести scope инкрементального обнователя графа `tools/graphify-safe-update.mjs` в
соответствие с пост-сплит реальностью проекта: индексировать прод-код управляющего
слоя (`tools/`), перестать ссылаться на удалённый `app/`, и не засорять граф
внутренностями тестов.
## Контекст и проблема {#D1}
Инкрементальный обнователь фильтрует изменённые файлы по списку разрешённых
префиксов. Сейчас список — `docs/`, `.claude/`, `app/`. Последствия:
- **`tools/` исключён** — изменения прод-кода управляющего слоя (роутер/наставник/
судья/пол/замок) НЕ попадают в инкрементальное обновление графа. Узлы `tools/`
в графе замораживаются с последней полной сборки и дрейфуют от истины.
- **`app/` мёртв** — каталог удалён при разделении репозитория; префикс ссылается
в пустоту.
Исторически широкий scope исключал `tools/` ради защиты от раздутия графа. Та защита
относилась к прежнему большому несистематизированному монорепо (каталог приложения
~3800 узлов + широкий рескан, тянувший зависимости). В текущем проекте этого нет:
управляющий слой систематизирован (парные `имя.mjs`/`имя.test.mjs`), а обнователь
использует узкий путь точечного AST-разбора по изменённым файлам, а не широкий рескан.
Эмпирически: граф уже содержит `tools/` при здоровом размере без раздутия.
## Изменение: scope обнователя {#D2}
**Контракт.** Список разрешённых префиксов становится `docs/`, `.claude/`, `tools/`
(добавлен `tools/`, удалён `app/`). Дополнительно из индексации исключаются тест-файлы
по суффиксу `.test.mjs` — узлы тест-внутренностей (локальные переменные, заглушки) для
навигации по графу являются шумом, не архитектурой.
**Реализация (детерминированная, чистые функции).**
- Константа списка префиксов и список исключающих подстрок становятся **экспортируемыми**
— чтобы новый контракт проверялся тестом напрямую, а не только через поведение CLI.
- Исключающая подстрока `.test.mjs` добавляется в существующий список дисквалифицирующих
подстрок; функция отбора по области уже отбрасывает путь, если он содержит любую такую
подстроку (`p.includes(ex)`).
- Поведенческий путь (точечный AST-разбор + слияние по стабильным идентификаторам)
не меняется — меняется только множество файлов, попадающих в этот путь.
## Крайние случаи и тесты {#D3}
Тест-файл обнователя сейчас кодирует СТАРЫЙ контракт и должен быть переписан под новый
(это нормальный разворот контракта по TDD):
- **Было** (отменяется): «`tools/...` отвергается», «`app/...` сохраняется».
- **Стало** (новые утверждения):
- `tools/<имя>.mjs` (прод-код) — **сохраняется**;
- `tools/<имя>.test.mjs`**отбрасывается** (исключение по `.test.mjs`);
- `app/...`**отбрасывается** (вне нового scope);
- `docs/...`, `.claude/...` — сохраняются (без регресса);
- исключения по каталогам (`node_modules/`, `vendor/`, `__pycache__/`, `.git/`) — без
регресса;
- пустой ввод → пустой вывод.
- Прямая проверка контракта: экспортируемый список префиксов содержит `tools/` и не
содержит `app/`.
Разделение код/документ по расширению (`partitionByExtension`) не меняется.
## Амендмент ADR-017 {#D4}
Раздел «Стратегия обновлений» ADR-017 фиксировал исключение `tools/` под прежний большой
репозиторий. Внести амендмент: пометить ту мотивацию как историческую (контекст прежнего
монорепо) и зафиксировать пост-сплит правило — `tools/` входит в scope как прод-код
управляющего слоя; `*.test.mjs` и удалённый `app/` исключены. Решение об амендменте —
владельца (зафиксировано на планировании 2026-06-21).
## Критерий готовности {#D5}
- Экспортируемый список префиксов = `['docs/', '.claude/', 'tools/']`; исключения содержат
`.test.mjs`.
- Переписанный тест-файл обнователя зелёный полным сводом (`npx vitest run --config
vitest.config.tools.mjs`), без новых падений в остальном своде.
- ADR-017 несёт амендмент с историческим примечанием и пост-сплит правилом scope.
- Никаких изменений поведенческого пути разбора/слияния, кроме множества входных файлов.
```verified-context-json
[
{"id":"vc-scopes","kind":"EXTRACTED","ref":"tools/graphify-safe-update.mjs","anchor":"const ALLOWED_SCOPES = ["},
{"id":"vc-filter","kind":"EXTRACTED","ref":"tools/graphify-safe-update.mjs","anchor":"export function filterInScope("},
{"id":"vc-adr","kind":"EXTRACTED","ref":"docs/adr/ADR-017-knowledge-graph-tooling.md","anchor":"Стратегия обновлений"}
]
```
+2 -2
View File
@@ -31,9 +31,9 @@ import { execFileSync } from 'node:child_process';
import { existsSync, writeFileSync, readFileSync } from 'node:fs';
import { join, resolve, extname } from 'node:path';
const ALLOWED_SCOPES = ['docs/', '.claude/', 'app/'];
export const ALLOWED_SCOPES = ['docs/', '.claude/', 'tools/'];
const CODE_EXTS = new Set(['.php', '.ts', '.js', '.vue', '.mjs', '.cjs', '.py', '.go']);
const SCAN_EXCLUDE_DIRS = ['node_modules/', 'vendor/', '__pycache__/', '.git/'];
export const SCAN_EXCLUDE_DIRS = ['node_modules/', 'vendor/', '__pycache__/', '.git/', '.test.mjs'];
/**
* Pure: filter a list of git-diff file paths down to those within allowed
+40 -26
View File
@@ -4,21 +4,46 @@
// the spike-bloat incident (ADR-017 § "Стратегия обновлений").
import { describe, it, expect } from 'vitest';
import { filterInScope, partitionByExtension } from './graphify-safe-update.mjs';
import { filterInScope, partitionByExtension, ALLOWED_SCOPES, SCAN_EXCLUDE_DIRS } from './graphify-safe-update.mjs';
const ALLOWED_SCOPES = ['docs/', '.claude/', 'app/'];
const SCAN_EXCLUDE_DIRS = ['node_modules/', 'vendor/', '__pycache__/', '.git/'];
const CODE_EXTS = new Set(['.php', '.ts', '.js', '.vue', '.mjs', '.cjs', '.py', '.go']);
describe('ALLOWED_SCOPES / SCAN_EXCLUDE_DIRS contract (post-split)', () => {
it('includes tools/ and excludes the removed app/ scope', () => {
expect(ALLOWED_SCOPES.includes('tools/')).toBe(true);
expect(ALLOWED_SCOPES.includes('app/')).toBe(false);
expect(ALLOWED_SCOPES).toEqual(['docs/', '.claude/', 'tools/']);
});
it('excludes test files by the .test.mjs suffix', () => {
expect(SCAN_EXCLUDE_DIRS.includes('.test.mjs')).toBe(true);
});
});
describe('filterInScope', () => {
it('keeps files inside allowed scopes', () => {
const input = ['docs/foo.md', '.claude/skills/x/SKILL.md', 'app/Models/Deal.php'];
it('keeps files inside allowed scopes (docs/.claude/tools)', () => {
const input = ['docs/foo.md', '.claude/skills/x/SKILL.md', 'tools/floor-decide.mjs'];
expect(filterInScope(input, ALLOWED_SCOPES, SCAN_EXCLUDE_DIRS)).toEqual(input);
});
it('rejects files outside allowed scopes (tools/, root .md, bin/)', () => {
it('keeps tools/ production code', () => {
const input = ['tools/enforce-supreme-gate.mjs', 'tools/plan-lock.mjs'];
expect(filterInScope(input, ALLOWED_SCOPES, SCAN_EXCLUDE_DIRS)).toEqual(input);
});
it('rejects tools/ test files (.test.mjs suffix)', () => {
const input = ['tools/floor-decide.test.mjs', 'tools/enforce-supreme-gate.test.mjs'];
expect(filterInScope(input, ALLOWED_SCOPES, SCAN_EXCLUDE_DIRS)).toEqual([]);
});
it('keeps tools/ source but drops its sibling test file', () => {
const input = ['tools/floor-decide.mjs', 'tools/floor-decide.test.mjs'];
expect(filterInScope(input, ALLOWED_SCOPES, SCAN_EXCLUDE_DIRS)).toEqual(['tools/floor-decide.mjs']);
});
it('rejects the removed app/ scope and other out-of-scope paths', () => {
const input = [
'tools/graphify-safe-update.mjs',
'app/Models/Deal.php',
'CHANGELOG.md',
'bin/something.exe',
'package.json',
@@ -27,28 +52,16 @@ describe('filterInScope', () => {
expect(filterInScope(input, ALLOWED_SCOPES, SCAN_EXCLUDE_DIRS)).toEqual([]);
});
it('rejects vendor/node_modules even if nominally under app/', () => {
it('rejects vendor/node_modules/.git even if nominally under an allowed scope', () => {
const input = [
'app/vendor/laravel/framework/Foo.php',
'app/node_modules/foo/bar.js',
'app/__pycache__/x.pyc',
'app/.git/HEAD',
'tools/vendor/foo/bar.mjs',
'docs/node_modules/foo/bar.js',
'tools/__pycache__/x.pyc',
'.claude/.git/HEAD',
];
expect(filterInScope(input, ALLOWED_SCOPES, SCAN_EXCLUDE_DIRS)).toEqual([]);
});
it('keeps real app/ files alongside vendor exclusions', () => {
const input = [
'app/app/Services/LedgerService.php',
'app/vendor/laravel/framework/Container.php',
'app/resources/js/views/Dashboard.vue',
];
expect(filterInScope(input, ALLOWED_SCOPES, SCAN_EXCLUDE_DIRS)).toEqual([
'app/app/Services/LedgerService.php',
'app/resources/js/views/Dashboard.vue',
]);
});
it('returns empty array on empty input', () => {
expect(filterInScope([], ALLOWED_SCOPES, SCAN_EXCLUDE_DIRS)).toEqual([]);
});
@@ -56,14 +69,15 @@ describe('filterInScope', () => {
it('handles mixed in-scope and out-of-scope', () => {
const input = [
'docs/CLAUDE.md',
'tools/x.mjs',
'tools/floor-decide.mjs',
'tools/floor-decide.test.mjs',
'app/Models/Deal.php',
'CHANGELOG.md',
'.claude/skills/y/SKILL.md',
];
expect(filterInScope(input, ALLOWED_SCOPES, SCAN_EXCLUDE_DIRS)).toEqual([
'docs/CLAUDE.md',
'app/Models/Deal.php',
'tools/floor-decide.mjs',
'.claude/skills/y/SKILL.md',
]);
});