From f2f33e24d6b6fba7e23aff91b8b265140a5f97f0 Mon Sep 17 00:00:00 2001 From: Yuriy Date: Sat, 31 Jan 2026 11:54:33 +0300 Subject: [PATCH] Protocol v1 release: golden traces, CI, make/npm, policy - Golden traces: docs/golden_traces/v1/, format protocol/request/context/result - trace_to_golden bin, golden_traces_v1_validate test - Makefile: make golden, make test-protocol - npm scripts: golden, test-protocol - CI: .github/workflows/protocol-check.yml - PROTOCOL_V1.md, PROTOCOL_V2_PLAN.md - Policy for updating golden traces in README Co-authored-by: Cursor --- .github/workflows/protocol-check.yml | 28 +++ CHANGELOG.md | 16 +- Makefile | 24 +++ docs/LLM_PLAN_FORMAT.md | 28 ++- docs/PROTOCOL_V1.md | 96 ++++++++++ docs/PROTOCOL_V2_PLAN.md | 74 ++++++++ docs/golden_traces/README.md | 56 ++++++ docs/golden_traces/v1/001_fix_bug_plan.json | 43 +++++ docs/golden_traces/v1/002_fix_bug_apply.json | 48 +++++ .../v1/003_generate_project_apply.json | 45 +++++ .../v1/004_protected_path_block.json | 44 +++++ .../v1/005_update_without_base_block.json | 44 +++++ .../v1/006_context_diet_applied.json | 43 +++++ .../v1/007_no_changes_apply.json | 42 +++++ package.json | 4 +- src-tauri/src/bin/trace_to_golden.rs | 118 +++++++++++++ src-tauri/src/commands/llm_planner.rs | 120 ++++++++++++- src-tauri/src/context.rs | 164 +++++++++++++++--- tests/README.md | 8 + 19 files changed, 1014 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/protocol-check.yml create mode 100644 Makefile create mode 100644 docs/PROTOCOL_V1.md create mode 100644 docs/PROTOCOL_V2_PLAN.md create mode 100644 docs/golden_traces/README.md create mode 100644 docs/golden_traces/v1/001_fix_bug_plan.json create mode 100644 docs/golden_traces/v1/002_fix_bug_apply.json create mode 100644 docs/golden_traces/v1/003_generate_project_apply.json create mode 100644 docs/golden_traces/v1/004_protected_path_block.json create mode 100644 docs/golden_traces/v1/005_update_without_base_block.json create mode 100644 docs/golden_traces/v1/006_context_diet_applied.json create mode 100644 docs/golden_traces/v1/007_no_changes_apply.json create mode 100644 src-tauri/src/bin/trace_to_golden.rs diff --git a/.github/workflows/protocol-check.yml b/.github/workflows/protocol-check.yml new file mode 100644 index 0000000..0bcccb2 --- /dev/null +++ b/.github/workflows/protocol-check.yml @@ -0,0 +1,28 @@ +name: Protocol v1 check + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + protocol: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} + + - name: golden_traces_v1_validate + run: cd src-tauri && cargo test golden_traces_v1_validate --no-fail-fast diff --git a/CHANGELOG.md b/CHANGELOG.md index 684afbf..e3de535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ ## [2.4.4] — 2025-01-31 +### Protocol stability (v1) + +- **Schema version:** `LLM_PLAN_SCHEMA_VERSION=1`, `x_schema_version` в схеме, `schema_hash` (sha256) в trace. +- **Версионирование:** при изменении контракта ответа LLM — увеличивать schema_version; trace содержит schema_version и schema_hash для воспроизводимости. +- **Рекомендуемый тег:** `v1.0.0` или `v0.x` — зафиксировать «стабильный релиз» перед введением v2. + ### Добавлено - **UX:** история сессий по проекту — блок «История сессий» с раскрывающимся списком сессий (дата, количество событий, последнее сообщение); обновление списка после agentic run. @@ -35,7 +41,15 @@ - **Capability detection:** при ошибке API response_format — автоматический retry без response_format (Ollama и др.). - **Schema version:** `x_schema_version` в llm_response_schema.json; schema_hash (sha256) в trace; LLM_PLAN_SCHEMA_VERSION в prompt. - **Кеш контекста:** read_file/search/logs/env кешируются в plan-цикле; CONTEXT_CACHE_HIT/MISS. -- **Контекст-диета:** PAPAYU_CONTEXT_MAX_FILES=8, MAX_FILE_CHARS=20k, MAX_TOTAL_CHARS=120k; head+tail truncation; CONTEXT_DIET_APPLIED. +- **Контекст-диета:** PAPAYU_CONTEXT_MAX_FILES=8, MAX_FILE_CHARS=20k, MAX_TOTAL_CHARS=120k; head+tail truncation; MIN_CHARS_FOR_PRIORITY0=4k; CONTEXT_DIET_APPLIED. +- **Trace:** context_stats (files_count, dropped, total_chars, logs_chars, truncated) и cache_stats (hits/misses по env/logs/read/search, hit_rate). +- **Кеш logs:** ключ Logs включает `last_n` — разные last_n не пересекаются. +- **Golden traces:** эталонные fixtures в `docs/golden_traces/v1/` — формат protocol/request/context/result (без raw_content). Тест `golden_traces_v1_validate` валидирует schema_version, schema_hash, JSON schema, validate_actions, NO_CHANGES при apply+empty. Конвертер `trace_to_golden` (cargo run --bin trace_to_golden). +- **Compatibility matrix:** в PROTOCOL_V1.md — Provider Compatibility таблица и 5 поведенческих гарантий. +- **PROTOCOL_V2_PLAN.md:** план v2 (PATCH_FILE, REPLACE_RANGE, base_sha256). +- **make/npm shortcuts:** `make golden` (trace→fixture), `make test-protocol` (golden_traces_v1_validate). +- **CI:** `.github/workflows/protocol-check.yml` — golden_traces_v1_validate на push/PR. +- **Политика golden traces:** в docs/golden_traces/README.md — когда/как обновлять, при смене schema_hash. ### Изменено diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d295984 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +.PHONY: golden golden-latest test-protocol test-all + +# make golden TRACE_ID= — из .papa-yu/traces/.json +# make golden — из последней трассы (golden-latest) +golden: + @if [ -n "$$TRACE_ID" ]; then \ + cd src-tauri && cargo run --bin trace_to_golden -- "$$TRACE_ID"; \ + else \ + $(MAKE) golden-latest; \ + fi + +golden-latest: + @LATEST=$$(ls -t .papa-yu/traces/*.json 2>/dev/null | head -1); \ + if [ -z "$$LATEST" ]; then \ + echo "No traces in .papa-yu/traces/. Run with PAPAYU_TRACE=1, propose fixes, then make golden."; \ + exit 1; \ + fi; \ + cd src-tauri && cargo run --bin trace_to_golden -- "../$$LATEST" + +test-protocol: + cd src-tauri && cargo test golden_traces_v1_validate + +test-all: + cd src-tauri && cargo test diff --git a/docs/LLM_PLAN_FORMAT.md b/docs/LLM_PLAN_FORMAT.md index 16b20c5..68fdf2c 100644 --- a/docs/LLM_PLAN_FORMAT.md +++ b/docs/LLM_PLAN_FORMAT.md @@ -83,7 +83,9 @@ LLM должен вернуть **только валидный JSON** — ли **Кеш контекста:** read_file/search/logs/env кешируются в пределах plan-цикла. Логи: CONTEXT_CACHE_HIT, CONTEXT_CACHE_MISS. -**Контекст-диета:** PAPAYU_CONTEXT_MAX_FILES=8, PAPAYU_CONTEXT_MAX_FILE_CHARS=20000, PAPAYU_CONTEXT_MAX_TOTAL_CHARS=120000. Файлы: head+tail truncation. Лог: CONTEXT_DIET_APPLIED. +**Контекст-диета:** см. раздел «Контекст-диета» ниже. + +**Trace:** при `PAPAYU_TRACE=1` в трассу добавляются `context_stats` (context_files_count, context_files_dropped_count, context_total_chars, context_logs_chars, context_truncated_files_count) и `cache_stats` (hits/misses по типам env/logs/read/search, hit_rate). ### Fix-plan режим (user.output_format) @@ -128,6 +130,30 @@ LLM должен вернуть **только валидный JSON** — ли --- +--- + +## Контекст-диета (поведение рантайма) + +Контекст может быть урезан для контроля стоимости токенов и стабильности ответов. + +**Env-переменные лимитов:** +| Переменная | По умолчанию | Описание | +|------------|--------------|----------| +| `PAPAYU_CONTEXT_MAX_FILES` | 8 | Макс. число FILE/SEARCH/LOGS/ENV блоков в FULFILLED_CONTEXT | +| `PAPAYU_CONTEXT_MAX_FILE_CHARS` | 20000 | Макс. символов на один файл (read_file) | +| `PAPAYU_CONTEXT_MAX_TOTAL_CHARS` | 120000 | Макс. символов всего блока FULFILLED_CONTEXT | +| `PAPAYU_CONTEXT_MAX_LOG_CHARS` | 12000 | Резерв для логов (в текущей реализации не используется) | + +**Порядок урезания:** при нехватке budget — search hits, logs; FILE-блоки (запрошенные read_file) — последними; для priority=0 файлов гарантируется минимум 4k chars даже при нехватке total budget. + +**Truncation:** при превышении MAX_FILE_CHARS — head+tail (60/40) с маркером `...[TRUNCATED N chars]...`. + +**Лог:** `CONTEXT_DIET_APPLIED files=N dropped=M truncated=T total_chars=C` при dropped>0 или truncated>0. + +**Trace:** в `context_stats` — context_files_count, context_files_dropped_count, context_total_chars, context_logs_chars, context_truncated_files_count. + +--- + ## context_requests (типы запросов) | type | Обязательные поля | Описание | diff --git a/docs/PROTOCOL_V1.md b/docs/PROTOCOL_V1.md new file mode 100644 index 0000000..343edc7 --- /dev/null +++ b/docs/PROTOCOL_V1.md @@ -0,0 +1,96 @@ +# Protocol v1 — контракт papa-yu + +Краткий документ (1–2 страницы): что гарантируется, лимиты, логирование, PLAN→APPLY, strict/best-effort. + +--- + +## Версионирование + +- **schema_version:** 1 +- **schema_hash:** sha256 от `llm_response_schema.json` (в trace) +- При изменении контракта — увеличивать schema_version; v2 — новый документ. + +--- + +## Гарантии + +1. **JSON:** ответ LLM парсится; при неудаче — 1 repair-ретрай с подсказкой. +2. **Валидация:** path (no `../`, absolute, `~`), конфликты действий, content (no NUL, pseudo-binary). +3. **UPDATE base:** в APPLY каждый UPDATE_FILE — только для файлов, прочитанных в Plan. +4. **Protected paths:** `.env`, `*.pem`, `*.key`, `id_rsa*`, `**/secrets/**` — запрещены. +5. **Apply:** snapshot → apply → auto_check; при падении check — rollback. + +--- + +## Лимиты + +| Область | Переменная | По умолчанию | +|---------|------------|--------------| +| path_len | — | 240 | +| actions | — | 200 | +| total_content_bytes | — | 5MB | +| context_files | PAPAYU_CONTEXT_MAX_FILES | 8 | +| file_chars | PAPAYU_CONTEXT_MAX_FILE_CHARS | 20000 | +| context_total | PAPAYU_CONTEXT_MAX_TOTAL_CHARS | 120000 | + +--- + +## Логирование + +| Событие | Где | +|---------|-----| +| LLM_REQUEST_SENT | stderr (model, schema_version, provider, token_budget, input_chars) | +| LLM_RESPONSE_OK, LLM_RESPONSE_REPAIR | stderr | +| VALIDATION_FAILED | stderr (code, reason) | +| CONTEXT_CACHE_HIT, CONTEXT_CACHE_MISS | stderr (key) | +| CONTEXT_DIET_APPLIED | stderr (files, dropped, truncated, total_chars) | +| APPLY_SUCCESS, APPLY_ROLLBACK, PREVIEW_READY | stderr | + +**Trace (PAPAYU_TRACE=1):** `.papa-yu/traces/.json` — config_snapshot, context_stats, cache_stats, validated_json, schema_version, schema_hash. + +--- + +## PLAN → APPLY + +1. **Plan:** `plan: <текст>` или «исправь/почини» → LLM возвращает `actions: []`, `summary`, `context_requests`. +2. **Apply:** `apply: <текст>` или «ok»/«применяй» при сохранённом плане → LLM применяет план с тем же context. +3. **NO_CHANGES:** при пустом `actions` в APPLY — `summary` обязан начинаться с `NO_CHANGES:`. + +--- + +## Strict / best-effort + +- **strict (PAPAYU_LLM_STRICT_JSON=1):** `response_format: json_schema` в API; при ошибке schema — repair-ретрай. +- **best-effort:** без response_format; извлечение из ```json ... ```; при ошибке — repair-ретрай. +- **Capability detection:** при 4xx/5xx с упоминанием response_format — retry без него. + +--- + +## Кеш контекста + +read_file, search, logs, env кешируются в plan-цикле. Ключ Logs: `{source, last_n}` — разные last_n не пересекаются. + +--- + +## Контекст-диета + +При превышении лимитов — урезание: search → logs → файлы низкого приоритета; FILE (read_file) — последними; для priority=0 гарантия минимум 4k chars. + +--- + +## Provider Compatibility + +| Provider | Endpoint | `response_format: json_schema` | `strict` | OneOf (array/object) | Режим | +|----------|----------|--------------------------------:|---------:|---------------------:|-------| +| OpenAI | `/v1/chat/completions` | ✅ | ✅ | ✅ | strict + local validate | +| OpenAI-compatible (часть) | разные | ⚠️ | ⚠️ | ⚠️ | best-effort + local validate + repair | +| Ollama | `/api/chat` | ❌ (часто) | ❌ | ⚠️ | best-effort + local validate + repair | + +**Поведенческие гарантии:** +1. Если `response_format` не поддержан провайдером → fallback и лог `LLM_RESPONSE_FORMAT_FALLBACK`. +2. Локальная schema validation выполняется всегда (если schema compile ok). +3. Repair-ретрай выполняется один раз при невалидном JSON. +4. Если после repair невалидно → Err. +5. Capability detection: при 4xx/5xx с упоминанием response_format/json_schema → retry без него. + +Ускоряет диагностику: если Ollama/локальная модель «тупит» — выключи `PAPAYU_LLM_STRICT_JSON` или оставь пустым. diff --git a/docs/PROTOCOL_V2_PLAN.md b/docs/PROTOCOL_V2_PLAN.md new file mode 100644 index 0000000..cf5b2c9 --- /dev/null +++ b/docs/PROTOCOL_V2_PLAN.md @@ -0,0 +1,74 @@ +# План Protocol v2 + +Минимальный набор изменений для v2 — без «воды». + +--- + +## 3.1. Главная цель v2 + +Снизить риск/стоимость «UPDATE_FILE целиком» и улучшить точность правок: +- частичные патчи, +- «операции редактирования» вместо полной перезаписи. + +--- + +## 3.2. Минимальный набор изменений + +### A) Новый action kind: `PATCH_FILE` + +Вместо полного `content`, передаётся unified diff: + +```json +{ "kind": "PATCH_FILE", "path": "src/app.py", "patch": "@@ -1,3 +1,4 @@\n..." } +``` + +- Валидация патча локально. +- Применение патча транзакционно. +- Preview diff становится тривиальным. + +### B) Новый action kind: `REPLACE_RANGE` + +Если unified diff сложен: + +```json +{ + "kind": "REPLACE_RANGE", + "path": "src/app.py", + "start_line": 120, + "end_line": 180, + "content": "новый блок" +} +``` + +Плюсы: проще валидировать. Минусы: зависит от line numbers (хрупко при изменениях). + +### C) «Base hash» для UPDATE/PATCH + +Исключить race (файл изменился между plan/apply): + +```json +{ "kind": "PATCH_FILE", "path": "...", "base_sha256": "...", "patch": "..." } +``` + +Если hash не совпал → Err и переход в PLAN. + +--- + +## 3.3. Совместимость v1/v2 + +- `schema_version=1` → нынешний формат (UPDATE_FILE, CREATE_FILE, …). +- `schema_version=2` → допускает `PATCH_FILE` / `REPLACE_RANGE` и расширенные поля. + +В коде: +- Компилировать обе схемы: `llm_response_schema_v1.json`, `llm_response_schema_v2.json`. +- Выбор активной по env: `PAPAYU_PROTOCOL_VERSION=1|2`. +- Валидация/парсер: сначала проверить schema v2 (если включена), иначе v1. + +--- + +## 3.4. Порядок внедрения v2 без риска + +1. Добавить v2 schema + валидаторы + apply engine, **не включая по умолчанию**. +2. Добавить «LLM prompt v2» (рекомендовать PATCH_FILE вместо UPDATE_FILE). +3. Прогнать на своих проектах и собрать golden traces v2. +4. Когда стабильно — сделать v2 дефолтом, сохранив совместимость v1. diff --git a/docs/golden_traces/README.md b/docs/golden_traces/README.md new file mode 100644 index 0000000..1ee17e6 --- /dev/null +++ b/docs/golden_traces/README.md @@ -0,0 +1,56 @@ +# Golden traces — эталонные артефакты + +Фиксируют детерминированные результаты papa-yu без зависимости от LLM. +Позволяют ловить регрессии в валидации, парсинге, диете, кеше. + +## Структура + +``` +docs/golden_traces/ + README.md + v1/ # Protocol v1 fixtures + 001_fix_bug_plan.json + 002_fix_bug_apply.json + ... +``` + +## Формат fixture (без секретов) + +Минимальный стабильный JSON: +- `protocol` — schema_version, schema_hash +- `request` — mode, input_chars, token_budget, strict_json, provider, model +- `context` — context_digest (опц.), context_stats, cache_stats +- `result` — validated_json (объект), validation_outcome, error_code + +Без raw_content, без секретов. + +## Генерация из трасс + +```bash +cd src-tauri +cargo run --bin trace_to_golden -- [output_path] +cargo run --bin trace_to_golden -- [output_path] +``` + +Читает trace из `.papa-yu/traces/.json` или из файла. Пишет в `docs/golden_traces/v1/`. + +## Регрессионный тест + +```bash +cargo test golden_traces_v1_validate +# или +make test-protocol +npm run test-protocol +``` + +--- + +## Политика обновления golden traces + +**Когда обновлять:** только при намеренном изменении протокола или валидатора (path/content/conflicts, schema, диета). + +**Как обновлять:** `trace_to_golden` — `make golden` (из последней трассы) или `make golden TRACE_ID=`. + +**Как добавлять новый сценарий:** выполни propose с PAPAYU_TRACE=1, затем `make golden` и сохрани вывод в `v1/NNN_.json` с номером NNN. + +**При смене schema_hash:** либо bump schema_version (новый документ v2), либо обнови все fixtures (`trace_to_golden` на свежие трассы) и зафиксируй в CHANGELOG. diff --git a/docs/golden_traces/v1/001_fix_bug_plan.json b/docs/golden_traces/v1/001_fix_bug_plan.json new file mode 100644 index 0000000..075521c --- /dev/null +++ b/docs/golden_traces/v1/001_fix_bug_plan.json @@ -0,0 +1,43 @@ +{ + "protocol": { + "schema_version": 1, + "schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e" + }, + "request": { + "mode": "plan", + "input_chars": 12000, + "token_budget": 4096, + "strict_json": true, + "provider": "openai", + "model": "gpt-4o-mini" + }, + "context": { + "context_stats": { + "context_files_count": 1, + "context_files_dropped_count": 0, + "context_total_chars": 1500, + "context_logs_chars": 0, + "context_truncated_files_count": 0 + }, + "cache_stats": { + "env_hits": 0, + "env_misses": 1, + "logs_hits": 0, + "logs_misses": 0, + "read_hits": 0, + "read_misses": 1, + "search_hits": 0, + "search_misses": 0, + "hit_rate": 0.0 + } + }, + "result": { + "validated_json": { + "actions": [], + "summary": "Диагноз: ошибка в main. План: заменить println! аргумент.", + "context_requests": [{"type": "read_file", "path": "src/main.rs"}] + }, + "validation_outcome": "ok", + "error_code": null + } +} diff --git a/docs/golden_traces/v1/002_fix_bug_apply.json b/docs/golden_traces/v1/002_fix_bug_apply.json new file mode 100644 index 0000000..5995300 --- /dev/null +++ b/docs/golden_traces/v1/002_fix_bug_apply.json @@ -0,0 +1,48 @@ +{ + "protocol": { + "schema_version": 1, + "schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e" + }, + "request": { + "mode": "apply", + "input_chars": 15000, + "token_budget": 4096, + "strict_json": true, + "provider": "openai", + "model": "gpt-4o-mini" + }, + "context": { + "context_stats": { + "context_files_count": 1, + "context_files_dropped_count": 0, + "context_total_chars": 1800, + "context_logs_chars": 0, + "context_truncated_files_count": 0 + }, + "cache_stats": { + "env_hits": 0, + "env_misses": 1, + "logs_hits": 0, + "logs_misses": 0, + "read_hits": 1, + "read_misses": 0, + "search_hits": 0, + "search_misses": 0, + "hit_rate": 0.5 + } + }, + "result": { + "validated_json": { + "actions": [ + { + "kind": "UPDATE_FILE", + "path": "src/main.rs", + "content": "fn main() {\n println!(\"fix\");\n}\n" + } + ], + "summary": "Исправлена функция main." + }, + "validation_outcome": "ok", + "error_code": null + } +} diff --git a/docs/golden_traces/v1/003_generate_project_apply.json b/docs/golden_traces/v1/003_generate_project_apply.json new file mode 100644 index 0000000..d8cfe92 --- /dev/null +++ b/docs/golden_traces/v1/003_generate_project_apply.json @@ -0,0 +1,45 @@ +{ + "protocol": { + "schema_version": 1, + "schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e" + }, + "request": { + "mode": "apply", + "input_chars": 8000, + "token_budget": 4096, + "strict_json": true, + "provider": "openai", + "model": "gpt-4o-mini" + }, + "context": { + "context_stats": { + "context_files_count": 0, + "context_files_dropped_count": 0, + "context_total_chars": 800, + "context_logs_chars": 0, + "context_truncated_files_count": 0 + }, + "cache_stats": { + "env_hits": 1, + "env_misses": 0, + "logs_hits": 0, + "logs_misses": 0, + "read_hits": 0, + "read_misses": 0, + "search_hits": 0, + "search_misses": 0, + "hit_rate": 0.5 + } + }, + "result": { + "validated_json": { + "actions": [ + {"kind": "CREATE_DIR", "path": "src"}, + {"kind": "CREATE_FILE", "path": "README.md", "content": "# Project\n\n## Run\n\n`cargo run`\n"} + ], + "summary": "Созданы папка src и README." + }, + "validation_outcome": "ok", + "error_code": null + } +} diff --git a/docs/golden_traces/v1/004_protected_path_block.json b/docs/golden_traces/v1/004_protected_path_block.json new file mode 100644 index 0000000..f4eac13 --- /dev/null +++ b/docs/golden_traces/v1/004_protected_path_block.json @@ -0,0 +1,44 @@ +{ + "protocol": { + "schema_version": 1, + "schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e" + }, + "request": { + "mode": "apply", + "input_chars": 5000, + "token_budget": 4096, + "strict_json": false, + "provider": "ollama", + "model": "llama3.2" + }, + "context": { + "context_stats": { + "context_files_count": 0, + "context_files_dropped_count": 0, + "context_total_chars": 500, + "context_logs_chars": 0, + "context_truncated_files_count": 0 + }, + "cache_stats": { + "env_hits": 0, + "env_misses": 1, + "logs_hits": 0, + "logs_misses": 0, + "read_hits": 0, + "read_misses": 0, + "search_hits": 0, + "search_misses": 0, + "hit_rate": 0.0 + } + }, + "result": { + "validated_json": { + "actions": [ + {"kind": "UPDATE_FILE", "path": ".env", "content": "FOO=bar\n"} + ], + "summary": "Updated .env" + }, + "validation_outcome": "err", + "error_code": "protected or non-text file: .env" + } +} diff --git a/docs/golden_traces/v1/005_update_without_base_block.json b/docs/golden_traces/v1/005_update_without_base_block.json new file mode 100644 index 0000000..0a843de --- /dev/null +++ b/docs/golden_traces/v1/005_update_without_base_block.json @@ -0,0 +1,44 @@ +{ + "protocol": { + "schema_version": 1, + "schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e" + }, + "request": { + "mode": "apply", + "input_chars": 10000, + "token_budget": 4096, + "strict_json": true, + "provider": "openai", + "model": "gpt-4o-mini" + }, + "context": { + "context_stats": { + "context_files_count": 1, + "context_files_dropped_count": 0, + "context_total_chars": 2000, + "context_logs_chars": 0, + "context_truncated_files_count": 0 + }, + "cache_stats": { + "env_hits": 0, + "env_misses": 1, + "logs_hits": 0, + "logs_misses": 0, + "read_hits": 1, + "read_misses": 0, + "search_hits": 0, + "search_misses": 0, + "hit_rate": 0.5 + } + }, + "result": { + "validated_json": { + "actions": [ + {"kind": "UPDATE_FILE", "path": "src/secret.rs", "content": "// changed"} + ], + "summary": "Updated" + }, + "validation_outcome": "err", + "error_code": "ERR_UPDATE_WITHOUT_BASE: UPDATE_FILE path 'src/secret.rs' not read in plan" + } +} diff --git a/docs/golden_traces/v1/006_context_diet_applied.json b/docs/golden_traces/v1/006_context_diet_applied.json new file mode 100644 index 0000000..97f5342 --- /dev/null +++ b/docs/golden_traces/v1/006_context_diet_applied.json @@ -0,0 +1,43 @@ +{ + "protocol": { + "schema_version": 1, + "schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e" + }, + "request": { + "mode": "plan", + "input_chars": 100000, + "token_budget": 4096, + "strict_json": true, + "provider": "openai", + "model": "gpt-4o-mini" + }, + "context": { + "context_stats": { + "context_files_count": 6, + "context_files_dropped_count": 3, + "context_total_chars": 118000, + "context_logs_chars": 5000, + "context_truncated_files_count": 2 + }, + "cache_stats": { + "env_hits": 1, + "env_misses": 0, + "logs_hits": 0, + "logs_misses": 1, + "read_hits": 2, + "read_misses": 4, + "search_hits": 1, + "search_misses": 1, + "hit_rate": 0.4444444444444444 + } + }, + "result": { + "validated_json": { + "actions": [], + "summary": "Диагноз: требуется больше контекста.", + "context_requests": [] + }, + "validation_outcome": "ok", + "error_code": null + } +} diff --git a/docs/golden_traces/v1/007_no_changes_apply.json b/docs/golden_traces/v1/007_no_changes_apply.json new file mode 100644 index 0000000..e43605f --- /dev/null +++ b/docs/golden_traces/v1/007_no_changes_apply.json @@ -0,0 +1,42 @@ +{ + "protocol": { + "schema_version": 1, + "schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e" + }, + "request": { + "mode": "apply", + "input_chars": 5000, + "token_budget": 4096, + "strict_json": true, + "provider": "openai", + "model": "gpt-4o-mini" + }, + "context": { + "context_stats": { + "context_files_count": 1, + "context_files_dropped_count": 0, + "context_total_chars": 1000, + "context_logs_chars": 0, + "context_truncated_files_count": 0 + }, + "cache_stats": { + "env_hits": 0, + "env_misses": 1, + "logs_hits": 0, + "logs_misses": 0, + "read_hits": 0, + "read_misses": 1, + "search_hits": 0, + "search_misses": 0, + "hit_rate": 0.0 + } + }, + "result": { + "validated_json": { + "actions": [], + "summary": "NO_CHANGES: Проверка завершена, правок не требуется." + }, + "validation_outcome": "ok", + "error_code": null + } +} diff --git a/package.json b/package.json index 90fefda..90ad411 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "build": "tsc -b && vite build", "preview": "vite preview", "tauri": "tauri", - "icons:export": "node scripts/export-icon.js" + "icons:export": "node scripts/export-icon.js", + "golden": "cd src-tauri && cargo run --bin trace_to_golden --", + "test-protocol": "cd src-tauri && cargo test golden_traces_v1_validate" }, "dependencies": { "@tauri-apps/api": "^2.0.0", diff --git a/src-tauri/src/bin/trace_to_golden.rs b/src-tauri/src/bin/trace_to_golden.rs new file mode 100644 index 0000000..ae9fafa --- /dev/null +++ b/src-tauri/src/bin/trace_to_golden.rs @@ -0,0 +1,118 @@ +//! Преобразует trace из .papa-yu/traces/.json в golden fixture. +//! +//! Использование: +//! cargo run --bin trace_to_golden -- [output_path] +//! cargo run --bin trace_to_golden -- [output_path] + +use sha2::{Digest, Sha256}; +use std::env; +use std::fs; +use std::path::Path; + +fn schema_hash() -> String { + let schema_raw = include_str!("../../config/llm_response_schema.json"); + let mut hasher = Sha256::new(); + hasher.update(schema_raw.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn main() -> Result<(), Box> { + let args: Vec = env::args().collect(); + if args.len() < 2 { + eprintln!("Usage: trace_to_golden [output_path]"); + std::process::exit(1); + } + let input = &args[1]; + let output = args.get(2).map(|s| s.as_str()); + + let content = if Path::new(input).is_file() { + fs::read_to_string(input)? + } else { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into()); + let trace_path = Path::new(&manifest_dir) + .join("../.papa-yu/traces") + .join(format!("{}.json", input)); + fs::read_to_string(&trace_path) + .map_err(|e| format!("read {}: {}", trace_path.display(), e))? + }; + + let trace: serde_json::Value = serde_json::from_str(&content)?; + let golden = trace_to_golden_format(&trace)?; + let out_json = serde_json::to_string_pretty(&golden)?; + + let out_path = match output { + Some(p) => p.to_string(), + None => { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into()); + let name = trace.get("trace_id").and_then(|v| v.as_str()).unwrap_or("out"); + format!( + "{}/../docs/golden_traces/v1/{}_golden.json", + manifest_dir, name + ) + } + }; + fs::create_dir_all(Path::new(&out_path).parent().unwrap_or(Path::new(".")))?; + fs::write(&out_path, out_json)?; + println!("Written: {}", out_path); + Ok(()) +} + +fn trace_to_golden_format(trace: &serde_json::Value) -> Result> { + let schema_version = trace + .get("schema_version") + .or_else(|| trace.get("config_snapshot").and_then(|c| c.get("schema_version"))) + .cloned() + .unwrap_or(serde_json::json!(1)); + let schema_hash_val = trace + .get("schema_hash") + .or_else(|| trace.get("config_snapshot").and_then(|c| c.get("schema_hash"))) + .cloned() + .unwrap_or_else(|| serde_json::Value::String(schema_hash())); + + let validated = trace.get("validated_json").cloned(); + let validated_obj = validated + .as_ref() + .and_then(|v| v.as_str()) + .and_then(|s| serde_json::from_str(s).ok()) + .or_else(|| validated.clone()) + .unwrap_or(serde_json::Value::Null); + + let config = trace.get("config_snapshot").and_then(|c| c.as_object()); + let strict_json = config + .and_then(|c| c.get("strict_json")) + .and_then(|v| v.as_str()) + .map(|s| !s.is_empty() && matches!(s.to_lowercase().as_str(), "1" | "true" | "yes")) + .unwrap_or(false); + + let validation_outcome = if trace.get("event").and_then(|v| v.as_str()) == Some("VALIDATION_FAILED") { + "err" + } else { + "ok" + }; + let error_code = trace.get("error").and_then(|v| v.as_str()).map(String::from); + + let golden = serde_json::json!({ + "protocol": { + "schema_version": schema_version, + "schema_hash": schema_hash_val + }, + "request": { + "mode": trace.get("mode").unwrap_or(&serde_json::Value::Null).clone(), + "input_chars": trace.get("input_chars").unwrap_or(&serde_json::Value::Null).clone(), + "token_budget": config.and_then(|c| c.get("max_tokens")).unwrap_or(&serde_json::Value::Null).clone(), + "strict_json": strict_json, + "provider": trace.get("provider").unwrap_or(&serde_json::Value::Null).clone(), + "model": trace.get("model").unwrap_or(&serde_json::Value::Null).clone() + }, + "context": { + "context_stats": trace.get("context_stats").cloned().unwrap_or(serde_json::Value::Null), + "cache_stats": trace.get("cache_stats").cloned().unwrap_or(serde_json::Value::Null) + }, + "result": { + "validated_json": validated_obj, + "validation_outcome": validation_outcome, + "error_code": error_code + } + }); + Ok(golden) +} diff --git a/src-tauri/src/commands/llm_planner.rs b/src-tauri/src/commands/llm_planner.rs index a0f8018..db4a2ae 100644 --- a/src-tauri/src/commands/llm_planner.rs +++ b/src-tauri/src/commands/llm_planner.rs @@ -861,6 +861,7 @@ pub async fn plan( let mut repair_done = false; let mut skip_response_format = false; // capability detection: fallback при ошибке response_format let mut context_cache = context::ContextCache::new(); + let mut last_context_stats: Option = None; let (last_actions, last_summary_override, last_plan_json, last_context_for_return) = loop { let effective_response_format = if skip_response_format { @@ -1059,7 +1060,8 @@ pub async fn plan( Some(&mut context_cache), Some(&trace_id), ); - user_message.push_str(&fulfilled); + last_context_stats = Some(fulfilled.context_stats); + user_message.push_str(&fulfilled.content); round += 1; continue; } @@ -1102,6 +1104,27 @@ pub async fn plan( "actions_count": last_actions.len(), "validated_json": last_plan_json, }); + if let Some(ref cs) = last_context_stats { + trace_val["context_stats"] = serde_json::json!({ + "context_files_count": cs.context_files_count, + "context_files_dropped_count": cs.context_files_dropped_count, + "context_total_chars": cs.context_total_chars, + "context_logs_chars": cs.context_logs_chars, + "context_truncated_files_count": cs.context_truncated_files_count, + }); + } + let cache_stats = context_cache.stats(); + trace_val["cache_stats"] = serde_json::json!({ + "env_hits": cache_stats.env_hits, + "env_misses": cache_stats.env_misses, + "logs_hits": cache_stats.logs_hits, + "logs_misses": cache_stats.logs_misses, + "read_hits": cache_stats.read_hits, + "read_misses": cache_stats.read_misses, + "search_hits": cache_stats.search_hits, + "search_misses": cache_stats.search_misses, + "hit_rate": cache_stats.hit_rate(), + }); write_trace(path, &trace_id, &mut trace_val); Ok(AgentPlan { @@ -1123,6 +1146,8 @@ mod tests { validate_update_without_base, FIX_PLAN_SYSTEM_PROMPT, LLM_PLAN_SCHEMA_VERSION, }; use crate::types::{Action, ActionKind}; + use std::fs; + use std::path::Path; #[test] fn test_schema_version_is_one() { @@ -1349,4 +1374,97 @@ mod tests { assert_eq!(actions.len(), 1); assert_eq!(actions[0].path, "src"); } + + #[test] + fn golden_traces_v1_validate() { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/golden_traces/v1"); + if !dir.exists() { + return; + } + let expected_schema_hash = schema_hash(); + for entry in fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + let name = path.file_name().unwrap().to_string_lossy(); + let s = fs::read_to_string(&path).unwrap_or_else(|_| panic!("read {}", name)); + let v: serde_json::Value = + serde_json::from_str(&s).unwrap_or_else(|e| panic!("{}: json {}", name, e)); + + assert_eq!( + v.get("protocol") + .and_then(|p| p.get("schema_version")) + .and_then(|x| x.as_u64()), + Some(1), + "{}: schema_version", + name + ); + let sh = v.get("protocol") + .and_then(|p| p.get("schema_hash")) + .and_then(|x| x.as_str()) + .unwrap_or(""); + assert_eq!(sh, expected_schema_hash, "{}: schema_hash", name); + + let validated = v.get("result") + .and_then(|r| r.get("validated_json")) + .cloned() + .unwrap_or(serde_json::Value::Null); + if validated.is_null() { + continue; + } + super::validate_json_against_schema(&validated).unwrap_or_else(|e| { + panic!("{}: schema validation: {}", name, e) + }); + + let validated_str = serde_json::to_string(&validated).unwrap(); + let parsed = super::parse_plan_response(&validated_str) + .unwrap_or_else(|e| panic!("{}: parse validated_json: {}", name, e)); + + if v.get("result") + .and_then(|r| r.get("validation_outcome")) + .and_then(|x| x.as_str()) + == Some("ok") + { + assert!( + validate_actions(&parsed.actions).is_ok(), + "{}: validate_actions", + name + ); + } + + let mode = v.get("request") + .and_then(|r| r.get("mode")) + .and_then(|x| x.as_str()) + .unwrap_or(""); + if mode == "apply" && parsed.actions.is_empty() { + let summary = validated + .get("summary") + .and_then(|x| x.as_str()) + .unwrap_or(""); + assert!( + summary.starts_with("NO_CHANGES:"), + "{}: apply with empty actions requires NO_CHANGES: prefix in summary", + name + ); + } + + let ctx_stats = v.get("context").and_then(|c| c.get("context_stats")); + let cache_stats = v.get("context").and_then(|c| c.get("cache_stats")); + if let Some(stats) = ctx_stats { + for key in ["context_files_count", "context_total_chars"] { + if let Some(n) = stats.get(key).and_then(|x| x.as_u64()) { + assert!(n <= 1_000_000, "{}: {} reasonable", name, key); + } + } + } + if let Some(stats) = cache_stats { + for key in ["env_hits", "env_misses", "read_hits", "read_misses"] { + if let Some(n) = stats.get(key).and_then(|x| x.as_u64()) { + assert!(n <= 1_000_000, "{}: cache {} reasonable", name, key); + } + } + } + } + } } diff --git a/src-tauri/src/context.rs b/src-tauri/src/context.rs index 39e6bd5..6793e36 100644 --- a/src-tauri/src/context.rs +++ b/src-tauri/src/context.rs @@ -47,16 +47,58 @@ pub enum ContextCacheKey { Search { query: String, glob: Option }, } +/// Статистика кеша (hits/misses по типам). +#[derive(Default, Clone, Debug)] +pub struct CacheStats { + pub env_hits: u32, + pub env_misses: u32, + pub logs_hits: u32, + pub logs_misses: u32, + pub read_hits: u32, + pub read_misses: u32, + pub search_hits: u32, + pub search_misses: u32, +} + +impl CacheStats { + pub fn total_hits(&self) -> u32 { + self.env_hits + self.logs_hits + self.read_hits + self.search_hits + } + pub fn total_misses(&self) -> u32 { + self.env_misses + self.logs_misses + self.read_misses + self.search_misses + } + pub fn hit_rate(&self) -> f64 { + let t = self.total_hits() + self.total_misses(); + if t == 0 { + 0.0 + } else { + self.total_hits() as f64 / t as f64 + } + } +} + +/// Статистика контекста (диета). +#[derive(Default, Clone, Debug)] +pub struct ContextStats { + pub context_files_count: u32, + pub context_files_dropped_count: u32, + pub context_total_chars: usize, + pub context_logs_chars: usize, + pub context_truncated_files_count: u32, +} + /// Кеш контекста для сессии (plan-цикла). #[derive(Default)] pub struct ContextCache { map: HashMap, + pub cache_stats: CacheStats, } impl ContextCache { pub fn new() -> Self { Self { map: HashMap::new(), + cache_stats: CacheStats::default(), } } @@ -67,6 +109,10 @@ impl ContextCache { pub fn put(&mut self, key: ContextCacheKey, value: String) { self.map.insert(key, value); } + + pub fn stats(&self) -> &CacheStats { + &self.cache_stats + } } /// Собирает базовый контекст перед первым запросом к модели: env, команды из project prefs. @@ -130,6 +176,12 @@ fn gather_env() -> String { lines.join("\n") } +/// Результат fulfill_context_requests: текст + статистика контекста. +pub struct FulfillResult { + pub content: String, + pub context_stats: ContextStats, +} + /// Выполняет context_requests от модели и возвращает текст для добавления в user message. /// Использует кеш, если передан; логирует CONTEXT_CACHE_HIT/MISS при trace_id. pub fn fulfill_context_requests( @@ -138,8 +190,9 @@ pub fn fulfill_context_requests( max_log_lines: usize, mut cache: Option<&mut ContextCache>, trace_id: Option<&str>, -) -> String { +) -> FulfillResult { let mut parts = Vec::new(); + let mut logs_chars: usize = 0; for r in requests { let obj = match r.as_object() { Some(o) => o, @@ -160,12 +213,15 @@ pub fn fulfill_context_requests( end, }; let content = if let Some(ref mut c) = cache { - if let Some(v) = c.get(&key) { + let hit = c.get(&key).map(|v| v.clone()); + if let Some(v) = hit { + c.cache_stats.read_hits += 1; if let Some(tid) = trace_id { eprintln!("[{}] CONTEXT_CACHE_HIT key=read_file path={}", tid, path); } - v.clone() + v } else { + c.cache_stats.read_misses += 1; let v = read_file_snippet(project_root, path, start as usize, end as usize); let out = format!("FILE[{}]:\n{}", path, v); if let Some(tid) = trace_id { @@ -189,12 +245,15 @@ pub fn fulfill_context_requests( glob: glob.clone(), }; let content = if let Some(ref mut c) = cache { - if let Some(v) = c.get(&key) { + let hit = c.get(&key).map(|v| v.clone()); + if let Some(v) = hit { + c.cache_stats.search_hits += 1; if let Some(tid) = trace_id { eprintln!("[{}] CONTEXT_CACHE_HIT key=search query={}", tid, query); } - v.clone() + v } else { + c.cache_stats.search_misses += 1; let hits = search_in_project(project_root, query, glob.as_deref()); let out = format!("SEARCH[{}]:\n{}", query, hits.join("\n")); if let Some(tid) = trace_id { @@ -220,14 +279,17 @@ pub fn fulfill_context_requests( source: source.to_string(), last_n, }; - let content = if let Some(ref mut c) = cache { - if let Some(v) = c.get(&key) { - if let Some(tid) = trace_id { - eprintln!("[{}] CONTEXT_CACHE_HIT key=logs source={}", tid, source); - } - v.clone() - } else { - let v = format!( + let content = if let Some(ref mut c) = cache { + let hit = c.get(&key).map(|v| v.clone()); + if let Some(v) = hit { + c.cache_stats.logs_hits += 1; + if let Some(tid) = trace_id { + eprintln!("[{}] CONTEXT_CACHE_HIT key=logs source={}", tid, source); + } + v + } else { + c.cache_stats.logs_misses += 1; + let v = format!( "LOGS[{}]: (last_n={}; приложение не имеет доступа к логам runtime — передай вывод в запросе)\n", source, last_n ); @@ -243,18 +305,22 @@ pub fn fulfill_context_requests( source, last_n ) }; + logs_chars += content.len(); parts.push(content); } "env" => { let key = ContextCacheKey::Env; - let content = if let Some(ref mut c) = cache { - if let Some(v) = c.get(&key) { - if let Some(tid) = trace_id { - eprintln!("[{}] CONTEXT_CACHE_HIT key=env", tid); - } - v.clone() - } else { - let v = format!("ENV (повторно):\n{}", gather_env()); + let content = if let Some(ref mut c) = cache { + let hit = c.get(&key).map(|v| v.clone()); + if let Some(v) = hit { + c.cache_stats.env_hits += 1; + if let Some(tid) = trace_id { + eprintln!("[{}] CONTEXT_CACHE_HIT key=env", tid); + } + v + } else { + c.cache_stats.env_misses += 1; + let v = format!("ENV (повторно):\n{}", gather_env()); if let Some(tid) = trace_id { eprintln!("[{}] CONTEXT_CACHE_MISS key=env size={}", tid, v.len()); } @@ -270,27 +336,47 @@ pub fn fulfill_context_requests( } } if parts.is_empty() { - String::new() + FulfillResult { + content: String::new(), + context_stats: ContextStats::default(), + } } else { let max_files = context_max_files(); let max_total = context_max_total_chars(); + const MIN_CHARS_FOR_PRIORITY0: usize = 4096; let header = "\n\nFULFILLED_CONTEXT:\n"; let mut total_chars = header.len(); let mut result_parts = Vec::with_capacity(parts.len().min(max_files)); let mut dropped = 0; + let mut truncated = 0; for (_i, p) in parts.iter().enumerate() { if result_parts.len() >= max_files { dropped += 1; continue; } let part_len = p.len() + if result_parts.is_empty() { 0 } else { 2 }; + let budget_left = max_total.saturating_sub(total_chars); if total_chars + part_len > max_total && !result_parts.is_empty() { - dropped += 1; + let is_file = p.starts_with("FILE["); + if is_file && budget_left >= MIN_CHARS_FOR_PRIORITY0 { + let to_add = if p.len() > budget_left { + truncated += 1; + let head = (budget_left as f32 * 0.6) as usize; + format!("{}...[TRUNCATED]...", &p[..head.min(p.len())]) + } else { + p.clone() + }; + total_chars += to_add.len() + if result_parts.is_empty() { 0 } else { 2 }; + result_parts.push(to_add); + } else { + dropped += 1; + } continue; } let to_add = if total_chars + part_len > max_total { let allowed = max_total - total_chars - 30; if allowed > 100 { + truncated += 1; format!("{}...[TRUNCATED]...", &p[..allowed.min(p.len())]) } else { p.clone() @@ -301,15 +387,24 @@ pub fn fulfill_context_requests( total_chars += to_add.len() + if result_parts.is_empty() { 0 } else { 2 }; result_parts.push(to_add); } + let content = format!("{}{}", header, result_parts.join("\n\n")); + let files_in_result = result_parts.iter().filter(|s| s.starts_with("FILE[")).count() as u32; + let context_stats = ContextStats { + context_files_count: files_in_result, + context_files_dropped_count: dropped as u32, + context_total_chars: total_chars, + context_logs_chars: logs_chars, + context_truncated_files_count: truncated, + }; if let Some(tid) = trace_id { - if dropped > 0 { + if dropped > 0 || truncated > 0 { eprintln!( - "[{}] CONTEXT_DIET_APPLIED files={} dropped={} total_chars={}", - tid, result_parts.len(), dropped, total_chars + "[{}] CONTEXT_DIET_APPLIED files={} dropped={} truncated={} total_chars={}", + tid, result_parts.len(), dropped, truncated, total_chars ); } } - format!("{}{}", header, result_parts.join("\n\n")) + FulfillResult { content, context_stats } } } @@ -529,6 +624,21 @@ mod tests { assert!(cache.get(&key).is_some()); } + #[test] + fn test_cache_logs_key_includes_last_n() { + let mut cache = ContextCache::new(); + cache.put( + ContextCacheKey::Logs { source: "runtime".to_string(), last_n: 200 }, + "LOGS last_n=200".to_string(), + ); + cache.put( + ContextCacheKey::Logs { source: "runtime".to_string(), last_n: 500 }, + "LOGS last_n=500".to_string(), + ); + assert!(cache.get(&ContextCacheKey::Logs { source: "runtime".to_string(), last_n: 200 }).unwrap().contains("200")); + assert!(cache.get(&ContextCacheKey::Logs { source: "runtime".to_string(), last_n: 500 }).unwrap().contains("500")); + } + #[test] fn test_context_diet_max_files() { let max = context_max_files(); diff --git a/tests/README.md b/tests/README.md index 7006ec0..ed0877d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,6 +8,13 @@ tests/ └── fixtures/ # Тестовые фикстуры (минимальные проекты) ├── minimal-node/ # Node.js проект без README └── minimal-rust/ # Rust проект без README + +docs/golden_traces/ # Эталонные трассы (регрессия, без raw_content) +├── README.md +└── v1/ # Protocol v1 fixtures + 001_fix_bug_plan.json + 002_fix_bug_apply.json + ... ``` ## Юнит-тесты (Rust) @@ -20,6 +27,7 @@ cargo test ``` Текущие тесты покрывают: +- `golden_traces_v1_validate` — валидация fixtures в `docs/golden_traces/v1/` (schema_version, schema_hash, parse, validate_actions, NO_CHANGES) - `detect_project_type` — определение типа проекта - `get_project_limits` — лимиты по типу проекта - `is_protected_file` — защита служебных файлов