diff --git a/.cspell.json b/.cspell.json index cf243da..3b4fd34 100644 --- a/.cspell.json +++ b/.cspell.json @@ -230,6 +230,7 @@ "десеріалізує", "десеріалізується", "джиттер", + "джоб", "дзеркалює", "диспатч", "діючий", @@ -238,6 +239,7 @@ "достовірних", "дотодішнього", "екземляр", + "емітів", "емітнеться", "ендпоінт", "ендпоінтом", @@ -355,10 +357,12 @@ "лаповане", "лаповані", "лапованому", + "Леджер", "лінковані", "лінкованого", "лінкує", "лінкується", + "лінтом", "лістенер", "лоадера", "логер", @@ -423,6 +427,7 @@ "непарсибельний", "непарсибельні", "непровалідованому", + "нерезолвлений", "нерозпарсюваний", "нерозрізнюваними", "нерозрізнювані", @@ -527,6 +532,7 @@ "резолвиться", "резолвлених", "резолвляться", + "резолвни", "резолвнувся", "резолвнуту", "резолву", @@ -620,6 +626,7 @@ "тікетом", "топосорт", "трейдофів", + "тулчейни", "тулчейнів", "тулчейну", "упоровся", diff --git a/.v8rignore b/.v8rignore index 7d05ce0..3ede9e5 100644 --- a/.v8rignore +++ b/.v8rignore @@ -5,4 +5,3 @@ .marksman.toml .claude/settings.local.json crates/mt-core/tests/fixtures/name-vectors.json -layers/schemas/layers.schema.json diff --git a/bun.lock b/bun.lock index cbf5498..660e32f 100644 --- a/bun.lock +++ b/bun.lock @@ -18,16 +18,6 @@ "vitest": "^4.1.10", }, }, - "layers": { - "name": "@7n/layers", - "version": "0.2.0", - "dependencies": { - "@7n/llm-lib": "^2.5.0", - }, - "optionalDependencies": { - "@earendil-works/pi-ai": "0.80.2", - }, - }, "relay": { "name": "@7n/relay", "version": "0.8.1", @@ -41,13 +31,11 @@ "tree-sitter-rust", ], "packages": { - "@7n/layers": ["@7n/layers@workspace:layers"], - - "@7n/llm-lib": ["@7n/llm-lib@2.8.3", "", { "optionalDependencies": { "@7n/llm-lib-darwin-arm64": "2.8.3", "@7n/llm-lib-linux-x64": "2.8.3" }, "peerDependencies": { "@earendil-works/pi-ai": "~0.80.10", "@earendil-works/pi-coding-agent": "~0.80.10" }, "optionalPeers": ["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent"], "bin": { "n-llm-chains-report": "bin/chains-report.mjs" } }, "sha512-pLK7HSVfXJ69en0gNQ+eXEUTdq/olqabX9RXymeSOAVIhE6egbDD4XNAZYdp26pLxP+Dr3t7Yomj3YihzeUgLw=="], + "@7n/llm-lib": ["@7n/llm-lib@2.12.2", "", { "optionalDependencies": { "@7n/llm-lib-darwin-arm64": "2.12.2", "@7n/llm-lib-linux-x64": "2.12.2" }, "peerDependencies": { "@earendil-works/pi-ai": "~0.80.10", "@earendil-works/pi-coding-agent": "~0.80.10" }, "optionalPeers": ["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent"], "bin": { "n-llm-chains-report": "bin/chains-report.mjs" } }, "sha512-OczF4Zom5XUgLYFgqulVuAFG1HjBGCtseOP+ApHv6u4M5OaN2aaPDdd7cBwFWqzxyhD7wcq8TGOtackJW3itzg=="], - "@7n/llm-lib-darwin-arm64": ["@7n/llm-lib-darwin-arm64@2.8.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2OI/wt+p1NLSbCoOSmvBxLWYJhAtkJdkTd7UUQj6zVTGV6VY00oGfzGv4pGD8Ka/NpuAcHyBq8jcbDDrQqGQGA=="], + "@7n/llm-lib-darwin-arm64": ["@7n/llm-lib-darwin-arm64@2.12.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-AHbWfwNXbQNxRqS+DwGVnDmd86Ru2+p5l/FOPnc8cKbUlTcMJP6RJWcmtNOtGSKbFX+uTgTeboUveqLza6qYCQ=="], - "@7n/llm-lib-linux-x64": ["@7n/llm-lib-linux-x64@2.8.3", "", { "os": "linux", "cpu": "x64" }, "sha512-q21Pc86jI7lC+rc+ai24nNe+c2ng8DU5CNuPU7t1wC/xmSWWZ+k0XuSMxJvGzHmHh/ARoEyphIECbYC13l6iSw=="], + "@7n/llm-lib-linux-x64": ["@7n/llm-lib-linux-x64@2.12.2", "", { "os": "linux", "cpu": "x64" }, "sha512-cgYSO3rucl+iJMKmMK4FtlDSy1o0xlHgxHHkwCpjBD4IoDt5kg4EejeKV09DzIsex+0NdDNtDbD2uzHTCr1i8Q=="], "@7n/mt": ["@7n/mt@0.30.8", "", {}, "sha512-wyDQJQhdNkblodGDrD1LEg5d8yzrUNTAlmL2nyMNKS/t7QnnMCKBs37sDw4EB4wdll8fQ/qqsh/nF9s6QlPdPg=="], @@ -269,7 +257,7 @@ "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.80.10", "", { "dependencies": { "@earendil-works/pi-ai": "^0.80.10", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-nwnOR3SuLYGRFfyQm8ri4Nj5VGVAvAM9GuqQd3u7BUQj0d6hmD2F8w7OHAAjThE3CuySIdM+v8E22QJG6/RfCg=="], - "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.2", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-5GNKfdrRJ4uZ5Zd9iudoXggi/BbUcKnD/xfRHtdR+7q4vWqPvfx8auFuaT+ewGBVI8K4wj87eigFQ/iCSuy9RQ=="], + "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.10", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA=="], "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.80.10", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.80.10", "@earendil-works/pi-ai": "^0.80.10", "@earendil-works/pi-tui": "^0.80.10", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.5.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-aL4apbupCHiVLSXASXvRzH4Q2vmtfrDa+0s909CJuVu/GgGylbDzr7oyF1mPmip5E+VxYYxKWmph4hV04wUcQg=="], @@ -2143,12 +2131,6 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@7n/llm-lib/@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.10", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA=="], - - "@7n/rules/@7n/llm-lib": ["@7n/llm-lib@2.12.2", "", { "optionalDependencies": { "@7n/llm-lib-darwin-arm64": "2.12.2", "@7n/llm-lib-linux-x64": "2.12.2" }, "peerDependencies": { "@earendil-works/pi-ai": "~0.80.10", "@earendil-works/pi-coding-agent": "~0.80.10" }, "optionalPeers": ["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent"], "bin": { "n-llm-chains-report": "bin/chains-report.mjs" } }, "sha512-OczF4Zom5XUgLYFgqulVuAFG1HjBGCtseOP+ApHv6u4M5OaN2aaPDdd7cBwFWqzxyhD7wcq8TGOtackJW3itzg=="], - - "@7n/rules/@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.10", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA=="], - "@7n/rules-lang-js/knip": ["knip@6.27.0", "", { "dependencies": { "fdir": "^6.5.0", "formatly": "^0.3.0", "get-tsconfig": "4.14.0", "jiti": "^2.7.0", "oxc-parser": "^0.137.0", "oxc-resolver": "11.21.3", "picomatch": "^4.0.4", "smol-toml": "^1.6.1", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", "unbash": "^4.0.1", "yaml": "^2.9.0", "zod": "^4.1.11" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-CngYEYrD0n20N06FXA8n3u/0Wnnugoa+B9k14OP+iKIgkCHuzvIdsP3nfwjhByoc1WfogpxfiriMboAXFETDUw=="], "@aws-sdk/credential-provider-http/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.9", "", { "dependencies": { "@smithy/core": "^3.29.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg=="], @@ -2167,12 +2149,8 @@ "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@earendil-works/pi-agent-core/@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.10", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA=="], - "@earendil-works/pi-agent-core/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - "@earendil-works/pi-coding-agent/@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.10", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA=="], - "@earendil-works/pi-coding-agent/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@earendil-works/pi-coding-agent/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], @@ -2329,10 +2307,6 @@ "@7n/rules-lang-js/knip/unbash": ["unbash@4.0.3", "", {}, "sha512-3cudTErfToSc4Ggv8XGXVNVli/xHKUtUZvaY5UVwhOcUPbQGz7PeaEnT/SAVgNziZtX67KEN9swMUYkLghxA1w=="], - "@7n/rules/@7n/llm-lib/@7n/llm-lib-darwin-arm64": ["@7n/llm-lib-darwin-arm64@2.12.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-AHbWfwNXbQNxRqS+DwGVnDmd86Ru2+p5l/FOPnc8cKbUlTcMJP6RJWcmtNOtGSKbFX+uTgTeboUveqLza6qYCQ=="], - - "@7n/rules/@7n/llm-lib/@7n/llm-lib-linux-x64": ["@7n/llm-lib-linux-x64@2.12.2", "", { "os": "linux", "cpu": "x64" }, "sha512-cgYSO3rucl+iJMKmMK4FtlDSy1o0xlHgxHHkwCpjBD4IoDt5kg4EejeKV09DzIsex+0NdDNtDbD2uzHTCr1i8Q=="], - "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@graphql-tools/code-file-loader/globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], diff --git a/crates/agent-core/src/acp.rs b/crates/agent-core/src/acp.rs index 8ad5905..af1bd9d 100644 --- a/crates/agent-core/src/acp.rs +++ b/crates/agent-core/src/acp.rs @@ -501,7 +501,11 @@ mod tests { client.initialize().await.unwrap(); client.new_session("/tmp", &servers).await.unwrap(); - let params = seen.lock().unwrap().clone().expect("session/new не прийшов"); + let params = seen + .lock() + .unwrap() + .clone() + .expect("session/new не прийшов"); assert_eq!(params["cwd"], "/tmp"); assert_eq!(params["mcpServers"], servers); } @@ -515,7 +519,10 @@ mod tests { let mut client = client_for(local, None); client.initialize().await.unwrap(); - let session = client.new_session("/tmp", &serde_json::json!([])).await.unwrap(); + let session = client + .new_session("/tmp", &serde_json::json!([])) + .await + .unwrap(); assert_eq!(session, "s1"); let events = Mutex::new(Vec::new()); @@ -549,7 +556,10 @@ mod tests { let mut client = client_for(local, Some(handler)); client.initialize().await.unwrap(); - let session = client.new_session("/tmp", &serde_json::json!([])).await.unwrap(); + let session = client + .new_session("/tmp", &serde_json::json!([])) + .await + .unwrap(); let events = Mutex::new(Vec::new()); let emit = |event: Event| events.lock().unwrap().push(event); client.prompt(&session, "запиши", &emit).await.unwrap(); @@ -575,7 +585,10 @@ mod tests { let mut client = client_for(local, None); client.initialize().await.unwrap(); - let session = client.new_session("/tmp", &serde_json::json!([])).await.unwrap(); + let session = client + .new_session("/tmp", &serde_json::json!([])) + .await + .unwrap(); let events = Mutex::new(Vec::new()); let emit = |event: Event| events.lock().unwrap().push(event); diff --git a/crates/mt-core/src/directory.rs b/crates/mt-core/src/directory.rs deleted file mode 100644 index b5b89a3..0000000 --- a/crates/mt-core/src/directory.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! Directory: мапінг handle → PII (`.mt/directory.json`, git-ignored). -//! -//! PII-політика (operations.md): у git-файлах вузлів живуть лише handles -//! (`assignee: vkozlov`, `owner: olena`, `from`/`to` ескалацій) — email та -//! імʼя людини лишаються поза історією, у локальному `.mt/directory.json` -//! і на relay (`accounts.email`). Цей модуль — канонічний парсер файлу; -//! читання ФС лишається на боці викликача (як у `config`). -//! -//! Формат — плоский обʼєкт: значення або рядок-email, або обʼєкт -//! `{ "email": "...", "name": "..." }`: -//! -//! ```json -//! { -//! "vkozlov": "v.kozlov@example.com", -//! "olena": { "email": "olena@example.com", "name": "Олена" } -//! } -//! ``` - -use std::collections::HashMap; - -use serde_json::Value; - -/// Канонічний шлях directory-файлу відносно кореня репо. -pub const DIRECTORY_PATH: &str = ".mt/directory.json"; - -/// PII одного handle: email — ключ мапінгу на relay-акаунт, імʼя — display. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DirectoryEntry { - pub email: String, - pub name: Option, -} - -/// Розбирає сирий текст `.mt/directory.json` у мапінг handle → PII. -/// Відсутній файл (`None`), битий JSON чи не-обʼєкт → порожній мапінг -/// (нерозмічена directory — штатний стан, не помилка). Невалідні значення -/// (без email) пропускаються. -pub fn parse_directory(raw: Option<&str>) -> HashMap { - let Some(raw) = raw else { - return HashMap::new(); - }; - let Ok(Value::Object(entries)) = serde_json::from_str::(raw) else { - return HashMap::new(); - }; - entries - .into_iter() - .filter_map(|(handle, value)| { - let entry = match value { - Value::String(email) if !email.trim().is_empty() => DirectoryEntry { - email: email.trim().to_string(), - name: None, - }, - Value::Object(fields) => DirectoryEntry { - email: fields.get("email")?.as_str()?.trim().to_string(), - name: fields - .get("name") - .and_then(Value::as_str) - .map(str::to_string), - }, - _ => return None, - }; - (!entry.email.is_empty()).then_some((handle, entry)) - }) - .collect() -} - -/// Email за handle-ом (None — handle поза directory: емітер не може -/// резолвити адресний push, події їдуть без `to_account_id`). -pub fn resolve_email<'a>( - directory: &'a HashMap, - handle: &str, -) -> Option<&'a str> { - directory.get(handle).map(|entry| entry.email.as_str()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_string_and_object_entries() { - let raw = r#"{ - "vkozlov": "v.kozlov@example.com", - "olena": { "email": " olena@example.com ", "name": "Олена" }, - "broken": { "name": "без email" }, - "empty": " " - }"#; - let directory = parse_directory(Some(raw)); - assert_eq!(directory.len(), 2); - assert_eq!( - resolve_email(&directory, "vkozlov"), - Some("v.kozlov@example.com") - ); - assert_eq!( - directory.get("olena"), - Some(&DirectoryEntry { - email: "olena@example.com".into(), - name: Some("Олена".into()) - }) - ); - assert_eq!(resolve_email(&directory, "broken"), None); - } - - #[test] - fn missing_or_invalid_file_is_empty_mapping() { - assert!(parse_directory(None).is_empty()); - assert!(parse_directory(Some("не json")).is_empty()); - assert!(parse_directory(Some("[1,2]")).is_empty()); - } -} diff --git a/crates/mt-core/src/isolation.rs b/crates/mt-core/src/isolation.rs index f82f574..42355f4 100644 --- a/crates/mt-core/src/isolation.rs +++ b/crates/mt-core/src/isolation.rs @@ -115,9 +115,10 @@ pub struct Wrapped { /// пісочниця забороняє запис навіть у власний worktree. Симптом — «усе /// зламалось», причина — непомітна. fn canonical(path: &Path) -> Result { - path.canonicalize().map_err(|_| IsolationError::Unresolvable { - path: path.to_path_buf(), - }) + path.canonicalize() + .map_err(|_| IsolationError::Unresolvable { + path: path.to_path_buf(), + }) } /// Текст профілю `sandbox-exec` (macOS). @@ -177,7 +178,11 @@ pub fn wrap( // Нерезолвний додатковий шлях пропускаємо мовчки: він міг бути // задекларований наперед (кеш, якого ще немає), і це не привід // валити run. - writable.extend(extra.iter().filter_map(|path| Path::new(path).canonicalize().ok())); + writable.extend( + extra + .iter() + .filter_map(|path| Path::new(path).canonicalize().ok()), + ); let mut wrapped = vec![ "-p".to_string(), macos_profile(&root, &writable), @@ -234,7 +239,9 @@ mod tests { // ранні, тож дозвіл до заборони не дав би нічого. let profile = macos_profile(Path::new("/repo/wt"), &[]); let deny = profile.find("(deny file-write*)").expect("немає заборони"); - let allow = profile.find("(allow file-write* (subpath \"/repo/wt\"))").unwrap(); + let allow = profile + .find("(allow file-write* (subpath \"/repo/wt\"))") + .unwrap(); assert!(deny < allow, "{profile}"); } @@ -278,7 +285,10 @@ mod tests { &[], ) .unwrap_err(); - assert!(matches!(error, IsolationError::Unresolvable { .. }), "{error:?}"); + assert!( + matches!(error, IsolationError::Unresolvable { .. }), + "{error:?}" + ); } /// Найважливіший тест модуля: пісочниця справді ізолює, а не лише @@ -316,8 +326,14 @@ mod tests { .expect("sandbox-exec не запустився"); let stdout = String::from_utf8_lossy(&out.stdout); - assert!(inside_file.exists(), "запис у worktree мусив пройти: {stdout}"); - assert!(stdout.contains("DENIED"), "запис поза worktree не заборонено: {stdout}"); + assert!( + inside_file.exists(), + "запис у worktree мусив пройти: {stdout}" + ); + assert!( + stdout.contains("DENIED"), + "запис поза worktree не заборонено: {stdout}" + ); assert!(!forbidden.exists(), "файл поза worktree створено"); let _ = outside_file; } diff --git a/crates/mt-core/src/lib.rs b/crates/mt-core/src/lib.rs index ef5c2e8..c3c9148 100644 --- a/crates/mt-core/src/lib.rs +++ b/crates/mt-core/src/lib.rs @@ -20,8 +20,6 @@ pub mod config; /// Розвилки: `decision-request`, стан `awaiting-decision`, відповідь власника. pub mod decision; -/// Мапінг handle → PII з git-ignored `.mt/directory.json` (у git-файлах лишаються лише handles). -pub mod directory; /// Парсинг і байт-точна серіалізація YAML-frontmatter task-файлів. pub mod frontmatter; @@ -32,12 +30,13 @@ pub mod i18n; /// Cost/time ledger: агрегація `wall_sec`/`tokens_*`/`cost_usd` з усіх `run_NNN.md` графу. pub mod isolation; +/// Леджер вузла: агрегація `wall_sec`, `tokens_*` і `cost_usd` з `run_NNN.md` графу. pub mod ledger; -/// MCP-сервери surface: декларація, резолв секретів, payload для ACP. -pub mod mcp; /// Lifecycle-мутації вузла: `mt invalidate` і `mt kill` (архівація version chain у `history/`). pub mod lifecycle; +/// MCP-сервери surface: декларація, резолв секретів, payload для ACP. +pub mod mcp; /// NNN-нумерація артефактів (`run_NNN.md`, `fact_NNN.md`, …) — чисті функції над іменами файлів. pub mod nnn; /// Оркестратор `run --auto`: одноразовий прохід по `waiting` агентських вузлах чергами по `agent_concurrency`. diff --git a/crates/mt-core/src/mcp.rs b/crates/mt-core/src/mcp.rs index 41f94e9..5e724df 100644 --- a/crates/mt-core/src/mcp.rs +++ b/crates/mt-core/src/mcp.rs @@ -236,8 +236,7 @@ mod tests { #[test] fn secret_ref_is_resolved_into_env() { - let servers = - servers_for_surface(&config(), &profile(&["mcp:figma"]), &store()).unwrap(); + let servers = servers_for_surface(&config(), &profile(&["mcp:figma"]), &store()).unwrap(); assert_eq!(servers.len(), 1); assert_eq!(servers[0].env["FIGMA_TOKEN"], "fk_live_1"); assert_eq!(servers[0].idle_ttl_sec, 600); @@ -248,8 +247,7 @@ mod tests { // Підставити літерал `secret:…` означало б віддати серверу рядок // замість токена і отримати незрозумілу відмову вже всередині нього. let empty = MemorySecretStore::default(); - let error = - servers_for_surface(&config(), &profile(&["mcp:figma"]), &empty).unwrap_err(); + let error = servers_for_surface(&config(), &profile(&["mcp:figma"]), &empty).unwrap_err(); assert_eq!( error, McpError::MissingSecret { @@ -287,8 +285,7 @@ mod tests { #[test] fn declaration_without_command_is_refused() { let config = json!({"mcp_servers": {"broken": {"args": ["x"]}}}); - let error = - servers_for_surface(&config, &profile(&["mcp:broken"]), &store()).unwrap_err(); + let error = servers_for_surface(&config, &profile(&["mcp:broken"]), &store()).unwrap_err(); assert_eq!( error, McpError::NoCommand { @@ -299,8 +296,7 @@ mod tests { #[test] fn idle_ttl_defaults_to_spec_value() { - let servers = - servers_for_surface(&config(), &profile(&["mcp:browser"]), &store()).unwrap(); + let servers = servers_for_surface(&config(), &profile(&["mcp:browser"]), &store()).unwrap(); assert_eq!(servers[0].idle_ttl_sec, DEFAULT_IDLE_TTL_SEC); } @@ -317,8 +313,7 @@ mod tests { fn acp_payload_uses_protocol_env_shape() { // ACP описує env масивом пар {name, value}; обʼєкт конфігу тут // розійшовся б із протоколом. - let servers = - servers_for_surface(&config(), &profile(&["mcp:figma"]), &store()).unwrap(); + let servers = servers_for_surface(&config(), &profile(&["mcp:figma"]), &store()).unwrap(); assert_eq!( acp_payload(&servers), json!([{ diff --git a/crates/mt-core/src/runner.rs b/crates/mt-core/src/runner.rs index 519a83f..d8639ab 100644 --- a/crates/mt-core/src/runner.rs +++ b/crates/mt-core/src/runner.rs @@ -2300,7 +2300,9 @@ printf -- '---\nschema_version: 1\n---\n\n## Summary\n\nok\n' > "fact_${MT_RUN_N // `unwrap()` одна невдача (зокрема тимчасова, за budget) отруювала // замок і перетворювалась на десяток PoisonError у сусідніх тестах — // справжню причину доводилось відкопувати з-під наслідків. - let _guard = PATH_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let _guard = PATH_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let bin = tempfile::tempdir().unwrap(); for (name, body) in shims { let p = bin.path().join(name); diff --git a/crates/mt-core/src/sandbox.rs b/crates/mt-core/src/sandbox.rs index b2a40f6..e4ec43f 100644 --- a/crates/mt-core/src/sandbox.rs +++ b/crates/mt-core/src/sandbox.rs @@ -277,6 +277,50 @@ pub fn policy_for(config: &Value, skills: &[String]) -> Policy { policy } +/// Скіли вузла з його `a.md`. `None` — прапорця немає (людський вузол, +/// ad-hoc кімната), і це відрізняється від «є, але порожній». +pub fn node_skills(tasks_dir: &Path, node: &str) -> Option> { + let text = std::fs::read_to_string(Path::new(tasks_dir).join(node).join("a.md")).ok()?; + let front = crate::frontmatter::parse_front_matter(&text); + Some( + front + .get("skills") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(), + ) +} + +/// Стеля хоста — обʼєднання **всіх** налаштованих профілів. +/// +/// Це не політика вузла, а верхня межа: нею користуються там, де вузол +/// невідомий або не декларує скілів. +pub fn host_ceiling(config: &Value) -> Policy { + let all: Vec = skill_profiles(config) + .map(|profiles| profiles.keys().cloned().collect()) + .unwrap_or_default(); + policy_for(config, &all) +} + +/// Політика конкретного вузла зі стелею хоста як fallback. +/// +/// Вузол із `a.md` отримує рівно те звуження, яке сам задекларував; вузол +/// без `a.md` (людський, ad-hoc кімната `mt attach`) — стелю хоста. +/// Забороняти все в цьому разі означало б ламати штатний сценарій заради +/// принципу: `a.md` описує **агентний** контракт, а його відсутність не є +/// заявою «нічого не можна». +pub fn policy_for_node(config: &Value, tasks_dir: &Path, node: &str) -> Policy { + match node_skills(tasks_dir, node) { + Some(skills) if !skills.is_empty() => policy_for(config, &skills), + _ => host_ceiling(config), + } +} + #[cfg(test)] mod tests { use super::*; @@ -295,6 +339,50 @@ mod tests { names.iter().map(|s| s.to_string()).collect() } + fn write_node(root: &Path, node: &str, a_md: Option<&str>) { + let dir = root.join(node); + std::fs::create_dir_all(&dir).unwrap(); + if let Some(body) = a_md { + std::fs::write(dir.join("a.md"), body).unwrap(); + } + } + + #[test] + fn node_policy_uses_its_own_skills() { + let root = tempfile::tempdir().unwrap(); + write_node( + root.path(), + "solo", + Some("---\nschema_version: 1\nskills: [bash]\n---\n"), + ); + let policy = policy_for_node(&config(), root.path(), "solo"); + assert!(policy.check_command("git status").is_ok()); + // web-search вузол не просив — його дозволи не течуть сюди. + assert!(policy.check_command("curl x").is_err()); + } + + #[test] + fn node_without_a_md_falls_back_to_host_ceiling() { + // `a.md` описує агентний контракт; його відсутність (людський вузол, + // ad-hoc кімната `mt attach`) не є заявою «нічого не можна». + let root = tempfile::tempdir().unwrap(); + write_node(root.path(), "human", None); + let policy = policy_for_node(&config(), root.path(), "human"); + assert!(policy.check_command("git status").is_ok()); + assert!(policy.check_command("curl x").is_ok(), "стеля хоста ширша"); + } + + #[test] + fn node_with_empty_skills_also_falls_back() { + // Порожній список читається як «не звужую», однаково з відсутнім — + // інакше `skills: []` мовчки блокував би вузол. + let root = tempfile::tempdir().unwrap(); + write_node(root.path(), "empty", Some("---\nschema_version: 1\n---\n")); + assert!(policy_for_node(&config(), root.path(), "empty") + .check_command("curl x") + .is_ok()); + } + #[test] fn project_without_section_is_not_enforcing() { // Межа сумісності: deny-by-default для ненастроєного проєкту зламав diff --git a/crates/mt/src/commands/session.rs b/crates/mt/src/commands/session.rs index 667e5a8..f18b72d 100644 --- a/crates/mt/src/commands/session.rs +++ b/crates/mt/src/commands/session.rs @@ -120,18 +120,9 @@ fn mcp_servers_payload(config: &serde_json::Value) -> serde_json::Value { mt_core::mcp::acp_payload(&resolved) } -/// Sandbox-політика хоста для інтерактивних ходів. -/// -/// Скіли інтерактивної сесії наперед невідомі (вузол підхоплюється в чат -/// уже під час розмови), тому політика береться з профілів **усіх** -/// налаштованих скілів: це стеля хоста, а не вузла. Звуження до вузла — -/// наступний крок, коли гейт знатиме, який саме вузол у кімнаті. -fn sandbox_policy() -> mt_core::sandbox::Policy { - let config = mt_core::config::merge_config(std::fs::read_to_string(".mt.json").ok().as_deref()); - let all_skills: Vec = mt_core::sandbox::skill_profiles(&config) - .map(|profiles| profiles.keys().cloned().collect()) - .unwrap_or_default(); - mt_core::sandbox::policy_for(&config, &all_skills) +/// Конфіг проєкту для sandbox-політики гейта. +fn sandbox_config() -> serde_json::Value { + mt_core::config::merge_config(std::fs::read_to_string(".mt.json").ok().as_deref()) } /// Синхронна обгортка: решта команд `mt` синхронні, тому рантайм @@ -189,15 +180,25 @@ async fn run_serve( Some(command) => { let approval_sessions = Arc::clone(&sessions); let approval_gate = Arc::clone(&gate); - // Sandbox-політика читається один раз на старті хоста: конфіг - // проєкту — не те, що змінюється посеред ходу, а перечитування - // на кожен запит дало б вікно, у якому політика вже інша, а - // рішення ще старе. - let sandbox = Arc::new(sandbox_policy()); + // Конфіг читається один раз на старті хоста: він не змінюється + // посеред ходу, а перечитування на кожен запит дало б вікно, у + // якому політика вже інша, а рішення ще старе. + let sandbox_config = Arc::new(sandbox_config()); + let sandbox_tasks_dir = std::env::current_dir() + .map(|cwd| cwd.join("mt")) + .unwrap_or_else(|_| std::path::PathBuf::from("mt")); let factory: PermissionFactory = Arc::new(move |node: &str| { let sessions = Arc::clone(&approval_sessions); let gate = Arc::clone(&approval_gate); - let sandbox = Arc::clone(&sandbox); + // Політика **вузла** кімнати: фабрика знає, для якого вузла + // відкривається ACP-сесія, тож звуження до `a.md.skills` + // рахується тут, один раз на кімнату. Вузол без `a.md` — + // стеля хоста (sandbox.rs, `policy_for_node`). + let sandbox = Arc::new(mt_core::sandbox::policy_for_node( + &sandbox_config, + &sandbox_tasks_dir, + node, + )); let node = node.to_string(); let handler: PermissionHandler = Arc::new(move |action, diff| { let sessions = Arc::clone(&sessions); @@ -227,9 +228,8 @@ async fn run_serve( handler }); println!("ACP-адаптер: {command}"); - let project_config = mt_core::config::merge_config( - std::fs::read_to_string(".mt.json").ok().as_deref(), - ); + let project_config = + mt_core::config::merge_config(std::fs::read_to_string(".mt.json").ok().as_deref()); let mcp = mcp_servers_payload(&project_config); if let Some(count) = mcp.as_array().map(Vec::len) { if count > 0 { diff --git a/docs/conformance.md b/docs/conformance.md index 31ba353..1fe3e7d 100644 --- a/docs/conformance.md +++ b/docs/conformance.md @@ -2,7 +2,7 @@ **Дата зрізу:** 2026-08-09 **Специфікація:** `@7n/mt` — [цільова архітектура 0.3.0-draft](https://github.com/nitra/mt/blob/main/docs/architecture/index.md) -**Метод:** прочитання глав архітектури проти коду `crates/`, `relay/`, `layers/`; вердикт ставиться за наявністю реальної логіки, не за назвою файлу чи згадкою в коментарі. +**Метод:** прочитання глав архітектури проти коду `crates/` і `relay/`; вердикт ставиться за наявністю реальної логіки, не за назвою файлу чи згадкою в коментарі. > Живий документ. Правило: зміна вердикту йде тим самим PR, що й код, який його змінює. Зріз без дати й методу — не зріз. @@ -55,7 +55,7 @@ | `unresolvable` (3 тригери + алерт) | РЕАЛІЗОВАНО | `lib.rs` `unresolvable_reason`/`write_unresolvable`; алерт — `agent-server/orchestrator.rs` `pending_alerts`; доставка — push тип 3 (`relay/lib/push.mjs`) | — | | Recurrence | ВІДСУТНЄ | — | Уся глава `recurrence.md` | | Secrets broker | РЕАЛІЗОВАНО | `mt-core/secrets.rs` (сховища: Keychain / файл `0600` / `MT_SECRETS_FILE`, `resolve_keys`, `resolve_ref` для `secret:`, `Masker`); інжекція й маскування — `runner.rs` | — (маскування ловить дослівне значення; закодовані форми — межа, названа в модулі) | -| Sandbox `skill_profiles` | ЧАСТКОВО | `mt-core/sandbox.rs` (політика: allowlist, мережа, fs-scope; ENV); жорстка відмова — ACP-гейт у `mt serve`; **ізоляція запису рівня ОС** — `mt-core/isolation.rs` (`sandbox.isolation: worktree`, macOS `sandbox-exec`) | Ізоляція покриває **лише файловий запис** і лише macOS (Linux — fail closed); мережа й allowlist команд лишаються декларативними; політика гейта — стеля хоста, не вузла | +| Sandbox `skill_profiles` | ЧАСТКОВО | `mt-core/sandbox.rs` (політика: allowlist, мережа, fs-scope; ENV); жорстка відмова — ACP-гейт у `mt serve`; **ізоляція запису рівня ОС** — `mt-core/isolation.rs` (`sandbox.isolation: worktree`, macOS `sandbox-exec`) | Ізоляція покриває **лише файловий запис** і лише macOS (Linux — fail closed); мережа й allowlist команд лишаються декларативними | | `.mt.json` — дефолти для реалізованого | РЕАЛІЗОВАНО | `config.rs` `config_defaults` | — (ключі нереалізованих фіч свідомо без дефолтів, див. «Закриті питання») | ## Протокол, сесії, поверхні @@ -95,20 +95,19 @@ | Гейт 2 — plan-review з підписом | ЧАСТКОВО | `spawn.rs` | У фронтматері рішення немає блоку `approved_by` з підписом | | Гейт 3 — аудит-вердикт людини | ЧАСТКОВО | `signal.rs` | Немає блоку підпису в `audit-result_NNN.md` | | Push типів 1/2/3 | РЕАЛІЗОВАНО | маршрутизація — `relay/lib/push.mjs`; транспорт — `fcm-sink.mjs` (FCM HTTP v1) і `push-sink.mjs` (dev), вибір — `create-push.mjs`; контракт — `tests/push-sink-contract.test.mjs` | — (APNs окремо не робимо: FCM доставляє на iOS; доставка живим FCM не перевірена — stub-`fetch` доводить лише форму запитів) | -| PII-directory | ЧАСТКОВО | `mt-core/directory.rs` | Мертвий код: парсер є, викликів немає → `Escalation.to_account_id` ніхто не заповнює | ## i18n, retro, мандати | Підсистема | Вердикт | Коментар | | --- | --- | --- | | i18n: контрактне ядро (base-мова, `source_hash`, «що перекладається», contract-aware сегментація) | РЕАЛІЗОВАНО | `mt-core/i18n.rs`: `I18nConfig`, `TranslationMeta`/`is_fresh`, `is_translatable` (триступенева схема), `segment` (fail-closed), `materialize` (read path, лише свіжі); неоднозначність спеки щодо `## Task`/`## Done when` вирішена явно — див. «Закриті питання» | -| i18n: сховище `refs/mt/i18n`, write path, черга регенерації, live-шар | ВІДСУТНЄ | Ядро є (рядок вище); запис/читання i18n-ref разом із fenced publish; компіляція authored-правки в base; фонова черга agent-server; live-переклад Envelope за capability `self-translate`; `layers/` лишається суміжною задачею з іншим контейнером і конфігом | +| i18n: сховище `refs/mt/i18n`, write path, черга регенерації, live-шар | ВІДСУТНЄ | Ядро є (рядок вище); запис/читання i18n-ref разом із fenced publish; компіляція authored-правки в base; фонова черга agent-server; live-переклад Envelope за capability `self-translate` (власник — relay, див. «Закриті питання») | | i18n: `lang` у ClientHello | РЕАЛІЗОВАНО | `agent-protocol/handshake.rs`; далі хендшейку `lang` не використовується | | retro: датасет і детерміновані пропозиції | РЕАЛІЗОВАНО | `mt-core/retro.rs` (`collect_runs`, `analyze`, приватний звіт `~/.nitra/retro/.md`); CLI — `mt retro`/`mt retro --show`; — (opt-in `retro.enabled`, дефолт `false` — контрактна вимога глави) | | retro: LLM-крок, innovation, impact | ВІДСУТНЄ | Детермінований датасет є (рядок вище); LLM-аналіз поверх датасету; `innovation_NNN.md` і baseline; impact-зрізи; фоновий прогін за `schedule_days`; агрегатор компетенцій як другий вихід | | Мандати: карта, валідація змін, квіз-гейт, ШІ-мандати | РЕАЛІЗОВАНО | `crates/mt-mandates`: `parse_mandates`, `effective_owner`, `validate_mandate_change` (generation fencing, подвійний підпис `escalates_to`, «остання константа»), `validate_approval` | | Мандати: `decision-request`, `awaiting-decision`, `chosen_option` | РЕАЛІЗОВАНО | `mt-core/decision.rs` (артефакт у `decisions/`, маркер стану у вузлі, відповідь), стан — `lib.rs` `detect_state`; CLI — `mt escalate`/`mt decide`; `chosen_option` — `agent-protocol` | -| Мандати: решта глави | ВІДСУТНЄ | — | +| Мандати: решта глави | ВІДСУТНЄ | — | Агент escalation-intake (фаза 1 — у фазі 0 тригер ручний, див. «Закриті питання»), маршрутизація за важелем, прецедентний рушій, селектор призначення, профілі компетенцій, process watcher | | Подія `Escalation` | РЕАЛІЗОВАНО (інша річ) | Записка «вгору» за handle з owner-app spec, не mandates-розвилка: без карти, фасетів, варіантів і підписаного рішення | ## Хвилі робіт @@ -124,6 +123,16 @@ ## Закриті питання +- **`mt escalate` вручну — штатний шлях фази 0** (2026-08-14): рішення Vitalii. Спека каже, що `decision-request` ніколи не пише виконавець — його пакує агент escalation-intake; це лишається метою, але фазою 1. Причина: автоматична класифікація «баг чи вибір» — рівно те судження, заради якого гейт існує, і фальшива евристика тут гірша за її відсутність (вона виглядала б як рішення системи). У фазі 0 тригер натискає людина, механіка розвилки далі повністю автоматична — маршрутизація адресата, `retry_history` з run-файлів, квіз-гейт. Інваріант «виконавець не пише сам собі розвилку» витриманий: `mt escalate` викликає не агент вузла. + +- **`layers/` видалено з `mt-rust`** (2026-08-14): рішення Vitalii. Перевірка перед видаленням спростувала припущення, з яким питання формулювалось: у `mt-rust` `layers/` генерував **нуль** перекладів (`find . -name '*.en.md'` → 0). 18 англомовних дзеркал живуть у репо специфікації, і теки `layers/` там немає взагалі — їх робить інший інструмент. Тобто конфлікту «два рушії одного перекладу» в цьому репо не існувало: був мертвий воркспейс на 40 файлів із власним CLI, схемою і 95 тестами. Прибрано разом із записом у `workspaces`, скриптом `layers` і рядком `.v8rignore` (останнє заразом знімає одну з причин хронічно червоного v8r). Ядро `mt-core/i18n.rs` лишається єдиним рушієм перекладу вмісту графа. + +- **Live-переклад Envelope робить relay** (2026-08-14): рішення Vitalii, ухвалене з відомим наслідком. **Це змінює модель довіри, а не лише місце коду:** `access.md` фіксує поведінкову межу relay — «не парсить payload далі роутінгових полів», а `overview.md` (принцип 4) називає relay ефемерним координатором. Переклад вимагає читати вміст текстових подій і віддавати його моделі, тобто вміст розмов виходить за периметр до провайдера моделі. **Спека потребує правки** в двох місцях: рядок relay у таблиці меж довіри (`access.md`) і принцип 4 (`overview.md`); інакше це мовчазне співіснування двох контрактів. Пом’якшення вже є в каноні й лишається чинним: «якщо вміст розмов не можна показувати навіть relay-оператору — розгортайте власний relay» (self-hosted-first). Окремо лишається інженерне: переклад дельт токен-за-токеном рве речення, тож одиницею перекладу має бути завершене повідомлення (`AgentTextDone`), а не `AgentTextDelta`. + +- **Політика гейта — вузла, не хоста; вузол без `a.md` падає на стелю** (2026-08-14): рішення Vitalii. Заразом виправлено хибне твердження карти «гейт не знає вузла»: `PermissionFactory` отримує `node_hash`, тож звуження не потребувало зміни публічного API. Fallback саме на стелю хоста, а не на заборону всього: `a.md` описує **агентний** контракт, і його відсутність (людський вузол, ad-hoc кімната `mt attach`) не є заявою «нічого не можна» — заборона ламала б штатний сценарій заради принципу. `skills: []` читається так само, як відсутній список, інакше порожня декларація мовчки блокувала б вузол. + +- **`directory.rs` видалено** (2026-08-14): рішення Vitalii. Ланцюг адресної ескалації мав три ланки, працювала одна: handle → email (парсер із тестами) є; email → `account_id` немає ніде — мапінг живе лише на relay, і кадру для запиту не існує; `Escalation` не надсилав **ніхто** (нуль емітерів по всіх крейтах). Наслідок був гірший за відсутність: `push.mjs` свідомо не розсилає подію без `to_account_id`, тож ескалація дійшла б нікому й без сліду, а рядок карти обіцяв частково готовий PII-шлях. Прецедент `mt-napi`: крейт без споживача видаляється. Парсер на 109 рядків перепишеться в M6 разом із емітером і з обдуманою межею резолву — кадр «резолвни акаунт за email» робить relay оракулом наявності акаунтів, тож мінімально безпечна форма мусить обмежуватись membership кореня. + - **Ізоляція — тільки файловий запис і тільки worktree** (2026-08-14): рішення Vitalii. `operations.md` називає fs-scope worktree, але не каже, чи ізолювати мережу й exec. Повна ізоляція ламає підписочні CLI (вони ходять у власні API і запускають тулчейни), тож її вимикали б назад цілком — а вузька межа, яку не хочеться вимикати, захищає більше за широку, яку вимкнули. Реалізовано `sandbox.isolation: worktree` (macOS `sandbox-exec`, вимкнено за замовчуванням): запис дозволений у worktree, `TMPDIR` і явно перелічені `sandbox.isolation_writable`; усе інше — read-only. Мережа й allowlist команд лишаються декларативними. На непідтримуваній платформі — **відмова run-а**, а не тихий запуск без ізоляції. - **MCP: звуження до surface ходу не робимо** (2026-08-14): рішення Vitalii — розвиток MCP спинено. Аналіз трьох варіантів: (А) набір проєкту — контекст усіх тулів у кожному ході, але розмова безперервна; (Б) переоткриття ACP-сесії на зміну surface — рве **контекст розмови** заради економії контексту тулів, тобто платить найдорожчим за найдешевше; (В) кімнати по парі (вузол, surface) — вузький контекст без розриву, ціною N процесів і розділених тредів. Технічна межа: `mcpServers` задається лише в `session/new`, додати сервер у живу сесію нічим. Чинний варіант А лишається робочим; рядок карти лишається `ЧАСТКОВО` не як борг, а як зафіксована межа. @@ -153,14 +162,4 @@ Конфлікти зрізу 2026-08-09 закриті; нижче — питання, що виникли під час реалізації і чекають рішення. -1. **`directory.rs`: дописати ланцюг чи видалити.** Ланцюг адресної ескалації має три ланки, працює одна: handle → email є (парсер із тестами); email → `account_id` **немає ніде** (мапінг живе лише на relay, і кадру для запиту немає); `Escalation` **не емітить ніхто** — нуль емітерів по всіх крейтах. Наслідок сьогодні: якби емітер зʼявився, `push.mjs` свідомо не розсилає подію без `to_account_id`, тож ескалація дійшла б **нікому, без сліду**. Ускладнення: кадр «резолвни акаунт за email» робить relay оракулом наявності акаунтів — мінімально безпечна форма мусить обмежуватись membership кореня. **Рекомендація:** видалити (прецедент `mt-napi`); парсер на 109 рядків перепишеться разом із рештою ланцюга в M6. Альтернатива — лишити, але тоді рядок карти має стати `ВІДСУТНЄ` для всієї фічі, а не `ЧАСТКОВО` для парсера. - -2. **ACP-гейт не знає вузла кімнати.** Sandbox-політика в гейті — стеля хоста (обʼєднання всіх профілів), бо інтерактивна сесія підхоплює вузол уже під час розмови. Звуження до `a.md.skills` конкретного вузла вимагає, щоб `PermissionFactory` отримував вузол — зміна публічного API `agent-server`. - -3. **`layers/` і `mt-core/i18n.rs` — дві відповіді на одне питання.** Обидва перекладають markdown, але різними контейнерами (`x..md` у дереві проти `refs/mt/i18n`), різними конфігами і різними моделями свіжості. Поки i18n без сховища — не конфліктують; щойно сховище зʼявиться, треба вирішити, чи стає `layers/` споживачем ядра. - -4. **Live-переклад Envelope не має власника.** Спека: клієнт без capability `self-translate` отримує текстові події вже перекладеними — тобто виклик моделі на **кожну текстову подію** в гарячому шляху стрічки. Хост, relay, чи відкласти до дешевого локального перекладача? Зараз `lang` далі хендшейку не читає ніхто. - -5. **Хто пакує `decision-request`.** Спека: ніколи не виконавець — його пакує агент escalation-intake. Механіка готова, судження «це вибір, а не баг» робить людина, що викликає `mt escalate`. Заводити агента окремим актором (як аудитор і EngineerAgent) чи лишити ручний виклик штатним для фази 0. - -6. **Rust-тести без CI-гейта.** `cargo test` не ганяє жоден workflow. Просто додати не можна: частина тестів драбини міряє реальний час і чутлива до навантаження (спостережено 2026-08-14: під паралельним clippy+лінтом падає `budget-exceeded` замість `success`, щоразу в інших тестах), а червоний, що блимає, навчаються ігнорувати. Спершу розвʼязати їх від годинника (інʼєкція, як уже зроблено в `presence` і `push`) або винести в окремий повільний джоб — і аж тоді вмикати гейт. Часткове полегшення вже зроблено: `PATH_LOCK` більше не отруюється, тож одна невдача не тягне за собою десяток `PoisonError` і причину видно одразу. +1. **Rust-тести без CI-гейта.** `cargo test` не ганяє жоден workflow. Просто додати не можна: частина тестів драбини міряє реальний час і чутлива до навантаження (спостережено 2026-08-14: під паралельним clippy+лінтом падає `budget-exceeded` замість `success`, щоразу в інших тестах), а червоний, що блимає, навчаються ігнорувати. Спершу розвʼязати їх від годинника (інʼєкція, як уже зроблено в `presence` і `push`) або винести в окремий повільний джоб — і аж тоді вмикати гейт. Часткове полегшення вже зроблено: `PATH_LOCK` більше не отруюється, тож одна невдача не тягне за собою десяток `PoisonError`. diff --git a/layers/.changes/260809-0903.md b/layers/.changes/260809-0903.md deleted file mode 100644 index 383f38b..0000000 --- a/layers/.changes/260809-0903.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -bump: patch -section: Changed ---- -chore: розчистити Lint Text — словник домену і одна друкарська помилка diff --git a/layers/.changes/260809-1000.md b/layers/.changes/260809-1000.md deleted file mode 100644 index 8ab2e30..0000000 --- a/layers/.changes/260809-1000.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -bump: patch -section: Changed ---- -chore: add layers change file for changelog presence diff --git a/layers/CHANGELOG.md b/layers/CHANGELOG.md deleted file mode 100644 index 2f05ebe..0000000 --- a/layers/CHANGELOG.md +++ /dev/null @@ -1,15 +0,0 @@ -# Changelog - -## [0.2.0] - 2026-07-22 - -### Added - -- Рушій шарової документації: подвійний CRC (суть/файл), детермінований status/refresh, LLM-генерація верхніх шарів через @7n/llm-lib, derived-переклади - -All notable changes to this project will be documented in this file. - -## [0.1.0] - 2026-07-12 - -### Added - -- Initial changelog for `@7n/layers`. diff --git a/layers/docs/index.md b/layers/docs/index.md deleted file mode 100644 index 23dc462..0000000 --- a/layers/docs/index.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -type: Directory Index -title: layers -resource: layers/ ---- - -| Файл | Тип | -| --------------------------------------- | --------- | -| [stryker.config.mjs](stryker.config.md) | JS Module | -| [vitest.config.mjs](vitest.config.md) | JS Module | diff --git a/layers/docs/stryker.config.md b/layers/docs/stryker.config.md deleted file mode 100644 index 1c2371c..0000000 --- a/layers/docs/stryker.config.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -type: JS Module -title: stryker.config.mjs -resource: layers/stryker.config.mjs -docgen: - crc: e09937f8 - model: omlx/gemma-4-e4b-it-OptiQ-4bit - tier: local-min - score: 100 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Цей файл налаштовує інструментарій для проведення мутаційного тестування, використовуючи `vitest` та спираючись на конфігураційні файли `mutation.json` та `incremental.json`. Він забезпечує швидкий аналіз, запускаючи лише тести, які покривають мутовані рядки. Результати зберігаються у форматах `json` та `clear-text`, що дозволяє відновлювати сеанс мутаційного тестування. - -## Поведінка - -1. Визначає, що для виконання тестів використовується `vitest` з конфігураційного файлу `vitest.config.mjs`. -2. Встановлює режим аналізу покриття як `perTest`, що забезпечує запуск лише тих тестів, які покривають мутовану лінію, підвищуючи швидкість. -3. Встановлює тимчасову директорію для звітів мутацій як `reports/stryker/.tmp`. -4. Визначає, що результати мутацій збираються у двох форматах: `json` та `clear-text`. -5. Вказує на збереження детального JSON-звіту про мутації у файл `reports/stryker/mutation.json`. -6. Вмикає інкрементальний режим, що дозволяє відновити результати після збоїв процесу. -7. Забезпечує збереження даних між запусками в файл `reports/stryker/incremental.json`, на який посилається інкрементальний режим. - -## Гарантії поведінки - -- (специфічних машинно-виведених гарантій немає) diff --git a/layers/docs/vitest.config.md b/layers/docs/vitest.config.md deleted file mode 100644 index d1e9c42..0000000 --- a/layers/docs/vitest.config.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -type: JS Module -title: vitest.config.mjs -resource: layers/vitest.config.mjs -docgen: - crc: ea8a7da8 - model: omlx/gemma-4-e4b-it-OptiQ-4bit - tier: local-min - score: 100 - issues: best-of-2:retry-won,judge:inaccurate:0.98 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Я створюю лаконічну поведінкову документацію для коду українською мовою у форматі Markdown. Я описую *ЩО* і *НАВІЩО*, уникаючи опис *ЯК*, сигнатур, типів, параметрів функцій, переліку stdlib-модулів, описів regex чи внутрішніх приватних імен. Документація не містить вступів чи висновків, а також не обгортається у ```-блок. Заборонено згадувати шляхи до `node_modules` та гарантувати кешування. Я обов'язково включаю маркери повідомлень, як це вимагає `test.mdc`. - -Огляд: Цей файл відповідає за запуск тестових сценаріїв, агрегуючи тести, розміщені як поруч із кодом, так і у загальному корені, для повного охоплення логіки кодової бази. - -Поведінка: - -1. Збирає тестові файли з обох конвенцій: ті, що розташовані біля відповідного коду (`tests/`) і ті, що знаходяться у верхньому рівні (`/tests/`). -2. Виключає папки залежностей та згенеровані артефакти з процесів тестування. -3. Виконує тести у середовищі `node`. -4. Ізолює тестові процеси, використовуючи окремі форки для підвищення стійкості. -5. Генерує звіт про покриття коду, використовуючи провайдера `v8` та репортери `lcov` і `text-summary`. -6. Ігнорує тимчасові sandbox-копії тестів Stryker, щоб уникнути збоїв при вимірюванні покриття з основного кореня. (test.mdc) - -## Поведінка - -1. Збирає тестові файли з обох конвенцій: ті, що знаходяться поруч із кодом (`tests/`) і ті, що знаходяться у верхньому рівні (`/tests/`). -2. Виключає папки залежностей та згенеровані артефакти з процесів тестування. -3. Виконує тести у середовищі `node`. -4. Ізолює тестові процеси, використовуючи окремі форки для підвищення стійкості. -5. Генерує звіт про покриття коду, використовуючи провайдера `v8` та репортери `lcov` і `text-summary`. -6. Ігнорує тимчасові sandbox-копії тестів від Stryker, оскільки вони можуть бути запущені поза основним коренем репозиторію, що призводить до провалу при запуску з покриттям. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Свідомо пропускає шляхи: `node_modules`. diff --git a/layers/lib/build.mjs b/layers/lib/build.mjs deleted file mode 100644 index 3c11f40..0000000 --- a/layers/lib/build.mjs +++ /dev/null @@ -1,357 +0,0 @@ -/** @see ./docs/build.md */ - -import { posix } from 'node:path' - -import { essenceCrc, fileCrc } from './crc.mjs' -import { layerNumber, topoOrder } from './layers.mjs' -import { - extractEssence, - extractFragment, - formatSourceLine, - insertEssenceDraft, - parseDoc, - parseSourceLine, - replaceFragment, - serializeDoc -} from './md.mjs' -import { computeStatus } from './status.mjs' - -/** - * Поточні пари CRC файлу-джерела. - * @param {string} text вміст файлу - * @returns {{essenceCrc: string | null, fileCrc: string}} пари CRC (essence — null, якщо секції нема) - */ -export function currentCrcs(text) { - const { body } = parseDoc(text) - const essence = extractEssence(body) - return { essenceCrc: essence ? essenceCrc(essence.text) : null, fileCrc: fileCrc(body) } -} - -/** - * Детермінований refresh: для джерел у стані details-only (суть та сама, - * деталі змінились) переписує записаний fileCrc — явний акт підтвердження - * «суть досі валідна». LLM не викликається, вміст док не змінюється. - * @param {object} config результат loadConfig - * @param {{readFile: (rel: string) => string | null, writeFile: (rel: string, text: string) => void}} io доступ до файлів полігона - * @returns {string[]} оновлені доки - */ -export function refreshFileCrcs(config, { readFile, writeFile }) { - const updated = [] - for (const [file, entry] of Object.entries(config.docs)) { - const text = readFile(file) - if (text === null) continue - const isFragment = entry.mode === 'fragment' - const rewrite = isFragment ? refreshFragment(text, readFile) : refreshFrontmatter(text, readFile) - if (rewrite !== null && rewrite !== text) { - writeFile(file, rewrite) - updated.push(file) - } - } - return updated -} - -/** - * @param {{file: string, essenceCrc: string, fileCrc: string}} recorded записана пара CRC - * @param {(rel: string) => string | null} readFile читання файлу полігона - * @returns {{file: string, essenceCrc: string, fileCrc: string}} освіжена пара (stale лишається як була) - */ -function refreshedSource(recorded, readFile) { - const text = readFile(recorded.file) - if (text === null) return recorded - const current = currentCrcs(text) - if (current.essenceCrc !== recorded.essenceCrc) return recorded // stale — робота для build - return { ...recorded, fileCrc: current.fileCrc } -} - -/** - * @param {string} text вміст генерованої доки - * @param {(rel: string) => string | null} readFile читання файлу полігона - * @returns {string | null} оновлений текст або null, якщо frontmatter не наш - */ -function refreshFrontmatter(text, readFile) { - const { fm, body } = parseDoc(text) - const lines = fm?.layers?.sources - if (!Array.isArray(lines)) return null - fm.layers.sources = lines.map(line => formatSourceLine(refreshedSource(parseSourceLine(String(line)), readFile))) - return serializeDoc(fm, body) -} - -/** - * @param {string} text вміст fragment-хоста - * @param {(rel: string) => string | null} readFile читання файлу полігона - * @returns {string | null} оновлений текст або null, якщо маркерів нема - */ -function refreshFragment(text, readFile) { - const fragment = extractFragment(text) - if (!fragment) return null - return replaceFragment(text, { - layer: fragment.layer, - sources: fragment.sources.map(source => refreshedSource(source, readFile)), - content: fragment.inner - }) -} - -const H1_RE = /^# (.+)$/m -const ESSENCE_SECTION_RE = /^## Суть\s*$/gm -const HEADING_LINE_RE = /^#{1,6}\s/ - -/** - * Локальні моделі часто пишуть заголовок і текст без порожнього рядка між ними - * (валідне markdown, але не проходить MD022). Вирівнюємо детерміновано, без LLM. - * @param {string} text вихід моделі - * @returns {string} той самий текст з порожнім рядком після кожного заголовка - */ -function normalizeHeadingSpacing(text) { - const lines = text.split('\n') - const out = [] - for (let i = 0; i < lines.length; i++) { - out.push(lines[i]) - if (HEADING_LINE_RE.test(lines[i]) && lines[i + 1] !== undefined && lines[i + 1].trim() !== '') { - out.push('') - } - } - return out.join('\n') -} - -/** - * @param {string} text вміст markdown-файлу - * @returns {string} текст H1 або порожній рядок - */ -function extractH1(text) { - const match = text.match(H1_RE) - return match ? match[1].trim() : '' -} - -/** - * Аудиторія промпту за номером шару: що вищий шар — то простіша мова. - * @param {number} num номер шару (0 — резюме) - * @returns {string} інструкція аудиторії - */ -function audienceFor(num) { - if (num === 0) { - return ( - 'Аудиторія — малий підприємець без технічної освіти. Поясни простими словами, ' + - 'що це перелік задач, які призначаються людям або ШІ, і вони працюють разом. Жодного жаргону.' - ) - } - if (num === 1) return 'Аудиторія — технічно грамотний читач, який вирішує, чи заглиблюватись. Мінімум жаргону.' - return 'Аудиторія — інженер, який хоче зрозуміти підсистему без читання детальних глав.' -} - -/** - * Збирає суті джерел доки; джерела без прийнятої суті блокують генерацію. - * @param {string[]} sourceFiles відносні шляхи джерел - * @param {(rel: string) => string | null} readFile читання файлу полігона - * @returns {{sources: Array<{file: string, essence: string, h1: string, crcs: {essenceCrc: string, fileCrc: string}}>, blockers: string[]}} суті та причини блокування - */ -function collectEssences(sourceFiles, readFile) { - const sources = [] - const blockers = [] - for (const file of sourceFiles) { - const text = readFile(file) - if (text === null) { - blockers.push(`${file}: джерело відсутнє`) - continue - } - const { body } = parseDoc(text) - const essence = extractEssence(body) - if (!essence) { - blockers.push(`${file}: нема «## Суть» — спершу bootstrap`) - continue - } - if (essence.draft) { - blockers.push(`${file}: суть — чернетка, зніми маркер essence:draft після рев'ю`) - continue - } - sources.push({ file, essence: essence.text, h1: extractH1(body), crcs: currentCrcs(text) }) - } - return { sources, blockers } -} - -/** - * Детермінований футер навігації «углиб» — додає builder, не модель. - * @param {string} file шлях доки, з якої посилаємось - * @param {Array<{file: string, h1: string}>} sources джерела доки - * @returns {string} markdown-секція «## Глибше» - */ -function deeperFooter(file, sources) { - const dir = posix.dirname(file) - const items = sources.map(source => { - const rel = posix.relative(dir, source.file) - return `- [${source.h1 || source.file}](${rel})` - }) - return `## Глибше\n\n${items.join('\n')}\n` -} - -/** - * Промпт генерації однієї доки шару. - * @param {object} entry запис конфігу доки - * @param {Array<{file: string, essence: string, h1: string}>} sources зібрані суті - * @returns {{system: string, user: string, validate: (content: string) => boolean}} запит для createLlm - */ -function buildRequest(entry, sources) { - const num = layerNumber(entry.layer) - const isFragment = entry.mode === 'fragment' - const system = [ - 'Ти — технічний редактор української документації. Ти агрегуєш суті розділів у огляд рівнем вище.', - audienceFor(num), - 'Пиши українською. Використовуй ЛИШЕ надані суті — нічого не вигадуй і не додавай від себе.', - isFragment - ? 'Формат: 2–3 короткі абзаци чистого тексту БЕЗ заголовків, БЕЗ списків посилань.' - : `Формат: почни з рядка "# ${entry.title}", далі секція "## Суть" (3–6 рядків — що цей огляд експортує нагору), далі розгортка з 2–4 секціями "## …". НЕ додавай секцію "## Глибше" і не встав посилань.` - ].join(' ') - const user = sources - .map((source, index) => `### Джерело ${index + 1}: ${source.h1 || source.file}\n\n${source.essence}`) - .join('\n\n') - const validate = isFragment - ? content => content.length > 0 && !content.includes('#') - : content => - content.startsWith(`# ${entry.title}`) && - (content.match(ESSENCE_SECTION_RE) ?? []).length === 1 && - !content.includes('## Глибше') - return { system, user, validate } -} - -/** - * Генерація застарілих док шарів: топологічно знизу вгору, CRC джерел - * перечитуються перед кожною докою — щойно згенерований L2 одразу дає - * свіжу суть для L1. - * @param {object} config результат loadConfig - * @param {object} deps залежності прогону - * @param {{readFile: (rel: string) => string | null, writeFile: (rel: string, text: string) => void}} deps.io доступ до файлів - * @param {{generate: (request: object) => Promise<{content: string, model: string}>}} deps.llm клієнт createLlm - * @param {boolean} [deps.dryRun] лише показати кандидатів, нічого не писати - * @param {boolean} [deps.force] перебудувати всі доки незалежно від стану - * @param {string} [deps.only] обмежитись однією докою - * @param {(text: string) => void} deps.log прогрес - * @param {string} deps.today дата YYYY-MM-DD для frontmatter - * @returns {Promise<{built: string[], skipped: Array<{file: string, reason: string}>, failed: Array<{file: string, reason: string}>}>} підсумок прогону - */ -export async function runBuild(config, { io, llm, dryRun = false, force = false, only, log, today }) { - const built = [] - const skipped = [] - const failed = [] - const report = computeStatus(config, io.readFile) - const stateOf = new Map(report.docs.map(doc => [doc.file, doc.state])) - - for (const file of topoOrder(config)) { - if (only && file !== only) continue - const entry = config.docs[file] - // стан перечитується накриво: після запису нижніх шарів верхні все одно stale у звіті - const needsBuild = force || stateOf.get(file) === 'stale' || io.readFile(file) === null - if (!needsBuild) { - skipped.push({ file, reason: `стан ${stateOf.get(file)} — перебудова не потрібна` }) - continue - } - const { sources, blockers } = collectEssences(entry.sources, io.readFile) - if (dryRun) { - // dry-run не пише нижні шари, тож «джерело відсутнє» для верхніх — очікуване; показуємо всіх кандидатів - const promptSize = sources.reduce((sum, source) => sum + source.essence.length, 0) - const note = blockers.length ? ` · блокери зараз: ${blockers.join('; ')}` : '' - log(`[dry-run] ${file} ← ${entry.sources.join(', ')} (~${promptSize} символів сутей${note})`) - built.push(file) - continue - } - if (blockers.length) { - failed.push({ file, reason: blockers.join('; ') }) - continue - } - const { system, user, validate } = buildRequest(entry, sources) - try { - const { content: rawContent, model } = await llm.generate({ system, user, validate, tier: entry.tier }) - const content = normalizeHeadingSpacing(rawContent) - io.writeFile(file, composeDoc({ entry, content, model, sources, today, io, file })) - built.push(file) - log(`✳️ ${file} ← ${sources.length} джерел (${model})`) - } catch (error) { - if (error.code === 'unavailable') throw error - failed.push({ file, reason: String(error.message ?? error) }) - } - } - return { built, skipped, failed } -} - -/** - * Складає фінальний файл доки: frontmatter зі свіжими CRC + тіло LLM + - * детермінований футер; для fragment — заміна блока в хост-файлі. - * @param {object} parts складові - * @param {object} parts.entry запис конфігу доки - * @param {string} parts.content валідний вихід моделі - * @param {string} parts.model фактично використана модель - * @param {Array<{file: string, h1: string, crcs: {essenceCrc: string, fileCrc: string}}>} parts.sources джерела з CRC - * @param {string} parts.today дата YYYY-MM-DD - * @param {{readFile: (rel: string) => string | null}} parts.io доступ до файлів - * @param {string} parts.file шлях доки - * @returns {string} повний текст файлу для запису - */ -function composeDoc({ entry, content, model, sources, today, io, file }) { - const sourceLines = sources.map(source => ({ file: source.file, ...source.crcs })) - if (entry.mode === 'fragment') { - const host = io.readFile(file) - if (host === null) throw new Error(`fragment-хост ${file} відсутній — додай маркери layers:${entry.layer}`) - const deeper = sources - .map(source => `→ Докладніше: [${source.h1 || source.file}](${posix.relative(posix.dirname(file), source.file)})`) - .join('\n') - return replaceFragment(host, { - layer: entry.layer, - sources: sourceLines, - content: `${content}\n\n${deeper}` - }) - } - const fm = { - type: 'layered-doc', - layer: entry.layer, - title: entry.title, - timestamp: today, - layers: { model, sources: sourceLines.map(line => formatSourceLine(line)) } - } - return serializeDoc(fm, `\n${content.trim()}\n\n${deeperFooter(file, sources)}`) -} - -/** - * Bootstrap чернеток «## Суть» для leaf-док, де секції ще нема. - * Ідемпотентний: доки з суттю пропускаються. Чернетка отримує маркер - * essence:draft — акцепт (зняття маркера) лишається за людиною. - * @param {object} config результат loadConfig - * @param {object} deps залежності прогону - * @param {{readFile: (rel: string) => string | null, writeFile: (rel: string, text: string) => void}} deps.io доступ до файлів - * @param {{generate: (request: object) => Promise<{content: string, model: string}>}} deps.llm клієнт createLlm - * @param {(text: string) => void} deps.log прогрес - * @returns {Promise<{drafted: string[], skipped: string[], failed: Array<{file: string, reason: string}>}>} підсумок - */ -export async function bootstrapEssences(config, { io, llm, log }) { - const drafted = [] - const skipped = [] - const failed = [] - for (const file of config.leaves) { - const text = io.readFile(file) - if (text === null) { - failed.push({ file, reason: 'leaf-дока відсутня' }) - continue - } - const { fm, body } = parseDoc(text) - if (extractEssence(body)) { - skipped.push(file) - continue - } - const system = - 'Ти — технічний редактор української документації. Сформулюй «суть» документа: ' + - '3–6 рядків про те, що цей документ експортує нагору — його головні рішення і гарантії, ' + - 'без деталей реалізації. Чистий текст без заголовків, списків і посилань. Українською.' - try { - const { content } = await llm.generate({ - system, - user: body, - validate: draft => draft.length > 0 && !draft.includes('#') && draft.split('\n').length <= 8 - }) - const nextBody = insertEssenceDraft(body, content) - io.writeFile(file, fm ? serializeDoc(fm, nextBody) : nextBody) - drafted.push(file) - log(`📝 ${file} — чернетка суті додана (рев'ю → зніми essence:draft)`) - } catch (error) { - if (error.code === 'unavailable') throw error - failed.push({ file, reason: String(error.message ?? error) }) - } - } - return { drafted, skipped, failed } -} diff --git a/layers/lib/cli.mjs b/layers/lib/cli.mjs deleted file mode 100644 index b9ea523..0000000 --- a/layers/lib/cli.mjs +++ /dev/null @@ -1,199 +0,0 @@ -/** @see ./docs/cli.md */ - -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import process from 'node:process' - -import { bootstrapEssences, refreshFileCrcs, runBuild } from './build.mjs' -import { loadConfig } from './layers.mjs' -import { createLlm } from './llm.mjs' -import { computeStatus, renderStatus } from './status.mjs' -import { runTranslate } from './translate.mjs' - -const USAGE = `Використання: layers <команда> [опції] - -Команди: - status стан шарів і перекладів (детерміновано, без мережі) - refresh підтвердити details-only: переписати fileCrc джерел без перебудови - bootstrap згенерувати чернетки «## Суть» для leaf-док без секції (LLM) - build перебудувати застарілі доки верхніх шарів із сутей джерел (LLM) - translate згенерувати/оновити derived-переклади док scope (LLM) - -Опції: - --json машиночитний вивід (status) - --strict details-only теж вважати за розсинхрон (status) - --dry-run показати кандидатів, нічого не писати (build, translate) - --force перебудувати всі доки незалежно від стану (build) - --only <файл> обмежитись однією докою (build, translate) - --lang <код> обмежитись однією мовою (translate) - --with-translations після build одразу прогнати translate - -Exit codes: 0 — свіжо/успіх · 1 — є розсинхрон або частина не збудувалась · -2 — структурна проблема (конфіг, no-essence) · 3 — LLM недоступна -` - -/** Опції, що очікують значення наступним аргументом. */ -const VALUE_FLAGS = new Set(['--only', '--lang']) - -/** - * @param {string[]} argv аргументи після імені скрипта - * @returns {{command?: string, docsDir?: string, flags: Record}} розібрані аргументи - */ -function parseArgv(argv) { - /** @type {Record} */ - const flags = {} - const positional = [] - for (let index = 0; index < argv.length; index++) { - const arg = argv[index] - if (VALUE_FLAGS.has(arg)) { - flags[arg.slice(2)] = argv[++index] ?? '' - } else if (arg.startsWith('--')) { - flags[arg.slice(2)] = true - } else { - positional.push(arg) - } - } - return { command: positional[0], docsDir: positional[1], flags } -} - -/** - * Фабрика FS-доступу, замкнена на docsDir; читання відсутнього файлу → null. - * @param {string} docsDir корінь полігона документації - * @returns {{readFile: (rel: string) => string | null, writeFile: (rel: string, text: string) => void}} io-пара - */ -function createIo(docsDir) { - return { - /** - * @param {string} rel відносний шлях - * @returns {string | null} вміст або null - */ - readFile(rel) { - try { - return readFileSync(join(docsDir, rel), 'utf8') - } catch { - return null - } - }, - /** - * @param {string} rel відносний шлях - * @param {string} text новий вміст - * @returns {void} - */ - writeFile(rel, text) { - const target = join(docsDir, rel) - mkdirSync(dirname(target), { recursive: true }) - writeFileSync(target, text) - } - } -} - -/** - * LLM-команди створюють клієнт під прогін і закривають chain у finally. - * @param {object} context контекст виконання - * @param {ReturnType} context.config конфіг полігона - * @param {ReturnType} context.io доступ до файлів - * @param {string} context.command імʼя команди (для caller/chain) - * @param {Record} context.flags CLI-опції - * @param {(text: string) => void} context.log вивід - * @param {object | undefined} context.llmImpl injected-транспорт для тестів - * @returns {Promise} exit code - */ -async function runLlmCommand({ config, io, command, flags, log, llmImpl }) { - const llm = await createLlm({ - tier: config.tier, - maxTokens: config.maxTokens, - caller: `layers:${command}`, - chainKind: `layers-${command}`, - chainUnit: config.docsDir, - impl: llmImpl - }) - const today = new Date().toISOString().slice(0, 10) - const shared = { io, llm, log, today } - try { - if (command === 'bootstrap') { - const { drafted, skipped, failed } = await bootstrapEssences(config, shared) - log(`Чернеток: ${drafted.length} · пропущено (суть є): ${skipped.length} · помилок: ${failed.length}`) - for (const failure of failed) log(` ✗ ${failure.file}: ${failure.reason}`) - return failed.length ? 1 : 0 - } - const buildOptions = { - ...shared, - dryRun: flags['dry-run'] === true, - force: flags.force === true, - only: typeof flags.only === 'string' ? flags.only : undefined - } - if (command === 'build') { - const { built, failed } = await runBuild(config, buildOptions) - log(`Збудовано: ${built.length} · помилок: ${failed.length}`) - for (const failure of failed) log(` ✗ ${failure.file}: ${failure.reason}`) - if (!failed.length && flags['with-translations'] === true) { - const translated = await runTranslate(config, buildOptions) - log(`Перекладів: ${translated.written.length} · помилок: ${translated.failed.length}`) - return translated.failed.length ? 1 : 0 - } - return failed.length ? 1 : 0 - } - const { written, skipped, failed } = await runTranslate(config, { - ...buildOptions, - lang: typeof flags.lang === 'string' ? flags.lang : undefined - }) - log(`Перекладів: ${written.length} · свіжих пропущено: ${skipped.length} · помилок: ${failed.length}`) - for (const failure of failed) log(` ✗ ${failure.file}: ${failure.reason}`) - return failed.length ? 1 : 0 - } finally { - llm.end() - } -} - -/** - * @param {string[]} argv аргументи після імені скрипта - * @param {{log?: (text: string) => void, llmImpl?: object}} [deps] залежності (тести) - * @returns {Promise} exit code - */ -export async function main(argv, { log = globalThis.console.log, llmImpl } = {}) { - const { command, docsDir, flags } = parseArgv(argv) - if (!command || !docsDir) { - log(USAGE) - return command ? 2 : 0 - } - let config - try { - config = loadConfig(docsDir) - } catch (error) { - log(String(error.message ?? error)) - return 2 - } - const io = createIo(docsDir) - - if (command === 'status') { - const report = computeStatus(config, io.readFile) - const { text, exitCode } = renderStatus(report, { - json: flags.json === true, - strict: flags.strict === true - }) - log(text) - return exitCode - } - - if (command === 'refresh') { - const updated = refreshFileCrcs(config, io) - log(updated.length ? `Оновлено fileCrc у: ${updated.join(', ')}` : 'Нічого оновлювати — details-only відсутні') - return 0 - } - - if (command === 'bootstrap' || command === 'build' || command === 'translate') { - try { - return await runLlmCommand({ config, io, command, flags, log, llmImpl }) - } catch (error) { - log(String(error.message ?? error)) - return error.code === 'unavailable' ? 3 : 1 - } - } - - log(`Невідома команда '${command}'\n\n${USAGE}`) - return 2 -} - -if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split('/').pop() ?? '')) { - process.exitCode = await main(process.argv.slice(2)) -} diff --git a/layers/lib/crc.mjs b/layers/lib/crc.mjs deleted file mode 100644 index 02a9f1f..0000000 --- a/layers/lib/crc.mjs +++ /dev/null @@ -1,42 +0,0 @@ -/** @see ./docs/crc.md */ - -import { crc32 } from 'node:zlib' - -/** - * CRC32 тексту (UTF-8) як 8 hex lowercase — формат `docgen.crc`. - * `node:zlib` кодує рядок як UTF-8 сам; підтримується і в Node ≥20.15, і в Bun. - * @param {string} text текст для хешування - * @returns {string} 8 hex lowercase символів CRC32 - */ -export function crc32hex(text) { - return crc32(text).toString(16).padStart(8, '0') -} - -/** - * Нормалізація тексту суті перед CRC: косметика (rewrap, CRLF, хвостові - * пробіли, кількість порожніх рядків) не змінює результат — CRC чутливий - * лише до зміни слів. - * @param {string} text текст суті - * @returns {string} нормалізований текст (одна лінія, згорнутий whitespace) - */ -export function normalizeEssence(text) { - return text.normalize('NFC').replaceAll(/\s+/g, ' ').trim() -} - -/** - * essence-CRC: crc32hex від нормалізованої суті. - * @param {string} text текст суті - * @returns {string} 8 hex lowercase символів CRC32 - */ -export function essenceCrc(text) { - return crc32hex(normalizeEssence(text)) -} - -/** - * file-CRC: crc32hex тіла документа (без frontmatter) з нормалізацією CRLF→LF. - * @param {string} body тіло документа без frontmatter - * @returns {string} 8 hex lowercase символів CRC32 - */ -export function fileCrc(body) { - return crc32hex(body.replaceAll('\r\n', '\n')) -} diff --git a/layers/lib/docs/build.md b/layers/lib/docs/build.md deleted file mode 100644 index 703a173..0000000 --- a/layers/lib/docs/build.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -type: JS Module -title: build.mjs -resource: layers/lib/build.mjs -docgen: - crc: 0c8b7687 - model: omlx/gemma-4-e4b-it-OptiQ-4bit - score: 90 - issues: internal-name:fileCrc,judge:inaccurate:0.98 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Файл відповідає за облік та оновлення цілісності контенту файлів. Він забезпечує актуальність інформації для формування документів шляхом обчислення та оновлення CRC. Функція `currentCrcs` надає поточні значення CRC, тоді як `refreshFileCrcs` оновлює ці значення для файлів. `runBuild` та `bootstrapEssences` використовують цю логіку цілісності для коректного виконання збірки та ініціалізації есенцій. - -## Поведінка - -Поведінка: -currentCrcs обчислює пари CRC для вмісту файлу, визначаючи цілісність його основної частини та суті. -refreshFileCrcs виконує детерміноване оновлення `fileCrc` для документів, чия суть залишається незмінною. -runBuild генерує документ відповідного шару, використовуючи агреговані суті з нижчих шарів, або пропускає його, якщо стан документа не змінився. -bootstrapEssences автоматично створює чернетку секції "Суть" для листових документів, де ця секція відсутня. - -## Публічний API - -currentCrcs — фіксує актуальні криптографічні хеші (CRC) для кожного файлу-джерела. -refreshFileCrcs — оновлює збережений CRC для джерел, які зберігаються в режимі "деталі тільки", підтверджуючи, що основна суть документа залишається вірною без участі LLM. -runBuild — створює шари документації від нижчих до вищих рівнів; перед обробкою кожного джерела зчитує його CRC, щоб гарантувати, що новіші шари використовують актуальні дані попередніх. -bootstrapEssences — генерує початковий варіант секції «## Суть» для найменших довідників, які ще не мають такого опису; це маркер чернетки, який має підтвердити людина. - -## Гарантії поведінки - -- (специфічних машинно-виведених гарантій немає) diff --git a/layers/lib/docs/cli.md b/layers/lib/docs/cli.md deleted file mode 100644 index dc8325b..0000000 --- a/layers/lib/docs/cli.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -type: JS Module -title: cli.mjs -resource: layers/lib/cli.mjs -docgen: - crc: cd284ad0 - model: omlx/gemma-4-e4b-it-OptiQ-4bit - tier: local-min-retry - score: 100 - issues: judge:inaccurate:0.98 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Цей файл є точкою входу та оркестратором основної логіки програми. Він ініціалізує роботу, зчитуючи конфігурацію з `flags.json`, парсячи аргументи CLI за допомогою `parseArgv` та запускаючи основні модулі через `main`. Компоненти взаємодіють з файловою системою, а об'єкти IO створюються через `createIo`. Система забезпечує стійкість шляхом перехоплення помилок (`fail-safe`), не генеруючи винятків назовні, і повертає порожнє значення (`null`) у випадках невдачі, зокрема при роботі з файлами. - -## Поведінка - -Поведінка: -parseArgv розбирає передані аргументи командного рядка, визначаючи команду, шлях до документації та додаткові прапорці. -createIo створює обгортку для операцій з файловою системою, що працює у межах вказаного кореня документації, і при відсутності файлу повертає null. -main виконує основну логіку програми: зчитує конфігурацію, парсить аргументи та ініціює роботу відповідно до вказаної команди. - -## Публічний API - -parseArgv — зчитує та обробляє аргументи командного рядка. -createIo — надає механізм доступу до файлової системи, обмежений каталогом документації. -main — ініціалізує основний потік виконання програми. - -## Гарантії поведінки - -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). -- За певних помилок повертає порожнє значення (напр. `null`) замість винятку. diff --git a/layers/lib/docs/crc.md b/layers/lib/docs/crc.md deleted file mode 100644 index 0d69459..0000000 --- a/layers/lib/docs/crc.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -type: JS Module -title: crc.mjs -resource: layers/lib/crc.mjs -docgen: - crc: c91aead7 - model: openai-codex/gpt-5.4-mini - score: 100 - issues: judge:inaccurate:0.98 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Файл формує стабільне CRC для тексту й документа: `normalizeEssence` зводить суть і тіло файлу до однакового представлення, `essenceCrc` і `fileCrc` дають значення для перевірки змін, а `crc32hex` повертає CRC у hex-форматі. Це дає змогу відрізняти змістові зміни від косметичних і порівнювати результат без прив’язки до форматування. - -## Поведінка - -- `crc32hex` — обчислює CRC32 для тексту в UTF-8 і повертає 8-символьний lowercase hex, сумісний із `docgen.crc`. -- `normalizeEssence` — нормалізує текст суті так, щоб косметичні зміни на кшталт переносу рядків, CRLF, хвостових пробілів і зайвих порожніх рядків не впливали на результат. -- `essenceCrc` — рахує CRC32 від нормалізованої суті тексту. -- `fileCrc` — рахує CRC32 тіла документа без frontmatter із нормалізацією CRLF до LF. - -## Публічний API - -- crc32hex — рахує CRC32 від UTF-8 тексту й повертає його як 8-символьний lowercase hex для `docgen.crc`. -- normalizeEssence — приводить текст суті до стабільного вигляду перед CRC: зміни в обгортанні, CRLF, хвостових пробілах і кількості порожніх рядків не змінюють результат; CRC реагує лише на зміну слів. -- essenceCrc — обчислює essence-CRC як `crc32hex` від нормалізованої суті. -- fileCrc — обчислює file-CRC як `crc32hex` від тіла документа без frontmatter після заміни CRLF на LF. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/layers/lib/docs/index.md b/layers/lib/docs/index.md deleted file mode 100644 index f839b6b..0000000 --- a/layers/lib/docs/index.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -type: Directory Index -title: layers/lib -resource: layers/lib/ ---- - -| Файл | Тип | -| ----------------------------- | --------- | -| [build.mjs](build.md) | JS Module | -| [cli.mjs](cli.md) | JS Module | -| [crc.mjs](crc.md) | JS Module | -| [layers.mjs](layers.md) | JS Module | -| [llm.mjs](llm.md) | JS Module | -| [md.mjs](md.md) | JS Module | -| [status.mjs](status.md) | JS Module | -| [translate.mjs](translate.md) | JS Module | diff --git a/layers/lib/docs/layers.md b/layers/lib/docs/layers.md deleted file mode 100644 index 196f3bf..0000000 --- a/layers/lib/docs/layers.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -type: JS Module -title: layers.mjs -resource: layers/lib/layers.mjs -docgen: - crc: 610eaa98 - model: openai-codex/gpt-5.4-mini - score: 100 - issues: judge:inaccurate:0.98 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -`layers.json` — єдине джерело даних для конфігурації шарів документації; код спирається на нього, щоб отримувати структуру шарів, межі перекладу та мовні шляхи документів. - -`CONFIG_NAME="layers.json"` — назва конфігураційного файлу, який використовує модуль. - -Публічні функції `loadConfig`, `validateConfig`, `topoOrder`, `translationScope`, `langPath`, `layerNumber` працюють із цим конфігом і разом формують перевірену модель шарів для читання та навігації по документації. - -## Поведінка - -- CONFIG_NAME — назва конфігураційного файла шарової документації: `layers.json`. -- loadConfig — читає `layers.json`, збирає конфіг топології документації, перевіряє його валідність і повертає готову структуру для подальшої обробки. -- layerNumber — перетворює мітку шару на порядковий номер, щоб порівнювати шари між собою. -- validateConfig — перевіряє узгодженість шарів, напрямок посилань між доками, коректність режимів і заборону використовувати fragment-доки як джерела. -- topoOrder — повертає порядок перебудови документів з нижчих шарів до вищих, зберігаючи порядок оголошення всередині шару. -- translationScope — повертає повний набір файлів, які мають потрапити в переклад: leaf-доки та всі доки з конфігу. -- langPath — будує шлях до мовної версії документа шляхом додавання коду мови до назви файла. - -## Публічний API - -- CONFIG_NAME — ім’я файлу з топологією шарів, яке код читає як стандартний конфіг. -- loadConfig — читає та звіряє конфіг топології з `/layers.json`. -- layerNumber — дістає номер шару з мітки `L`; менше `n` означає вищий, більш агрегований шар. -- validateConfig — звіряє мітки шарів, напрямок джерел лише з нижчих шарів угору та обмеження fragment; така побудова прибирає цикли. -- topoOrder — задає порядок перебудови знизу вгору, а всередині шару тримає порядок із конфігу. -- translationScope — визначає набір файлів для перекладу: leaf-доки та всі документи з конфігу. -- langPath — формує шлях derived-перекладу, наприклад `architecture/graph.md` + `en` → `architecture/graph.en.md`. - -Changelog: не перевірено — потрібен запуск `npx @nitra/cursor lint changelog` після змін. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/layers/lib/docs/llm.md b/layers/lib/docs/llm.md deleted file mode 100644 index a234324..0000000 --- a/layers/lib/docs/llm.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -type: JS Module -title: llm.mjs -resource: layers/lib/llm.mjs -docgen: - crc: 1f770193 - model: openai-codex/gpt-5.4-mini - tier: cloud-min - score: 100 - issues: judge:inaccurate:0.97 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -`createLlm` створює LLM-клієнт для одноразового отримання відповіді та повертає результат без побічних записів у ФС чи БД. - -Для помилок, пов’язаних із цим шаром, використовується `LlmError`, щоб викликальний код міг відрізняти помилки LLM-взаємодії від інших збоїв. - -`LlmError` -`createLlm` - -## Поведінка - -- `LlmError` — помилка шару LLM із кодом причини `unavailable` або `output`, щоб відрізняти недоступність транспорту від невалідного результату моделі. -- `createLlm` — створює клієнт LLM із one-shot генерацією, валідацією виходу, ретраєм і підвищенням tier, а також завершує chain із підсумком прогону. - -## Публічний API - -- LlmError — помилка на рівні LLM-шару; `unavailable` означає, що немає доступного транспорту або моделі, а `output` — що відповідь моделі не пройшла приймання після всіх спроб. -- createLlm — створює один одноразовий LLM-прогін із retry та підвищенням tier; `llm-lib` підвантажує лише тоді, коли це справді потрібно, тому `status` його не чіпає. - -Changelog: не перевірявся (потрібен `npx @nitra/cursor lint changelog`). - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/layers/lib/docs/md.md b/layers/lib/docs/md.md deleted file mode 100644 index 3a34ee4..0000000 --- a/layers/lib/docs/md.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -type: JS Module -title: md.mjs -resource: layers/lib/md.mjs -docgen: - crc: fb34ab75 - model: openai-codex/gpt-5.5 - tier: cloud-avg - score: 100 - issues: judge:inaccurate:0.98 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Файл підтримує Markdown-документи із секцією «Суть» і fragment-блоками рушія, щоб читати, оновлювати й зберігати стислий зміст документа. - -Експортовані константи-рядки: `DRAFT_MARKER=""` — позначає секцію «Суть» як чернетку, коли документ ще потребує уточнення змісту. - -## Поведінка - -- `DRAFT_MARKER` — позначає секцію «Суть» як чернетку значенням ``. - -- `parseDoc` — відокремлює frontmatter від тіла Markdown-документа й повертає тіло без змін, якщо коректний frontmatter присутній; інакше вважає весь текст тілом. - -- `serializeDoc` — збирає Markdown-документ із frontmatter і тіла у стабільному для власних документів форматі. - -- `extractEssence` — знаходить секцію «Суть», повертає її текст і ознаку чернетки; якщо секції немає, повертає порожній результат. - -- `insertEssenceDraft` — додає чернетку секції «Суть» після головного заголовка та вступного blockquote, щоб документ отримав місце для стислого змісту. - -- `parseSourceLine` — перетворює source-рядок із шляхом і двома CRC-значеннями на структурований запис джерела; невалідний рядок відхиляє помилкою. - -- `formatSourceLine` — перетворює структурований запис джерела назад у source-рядок для маркерів fragment-блоку. - -- `extractFragment` — знаходить fragment-блок рушія в Markdown-тексті, повертає назву шару, список джерел і внутрішній вміст; якщо маркерів немає або блок неповний, повертає порожній результат. - -- `replaceFragment` — замінює наявний fragment-блок новим списком джерел і вмістом, залишаючи решту файлу недоторканою; якщо потрібних маркерів немає, завершується помилкою. - -## Публічний API - -- DRAFT_MARKER — позначає автоматично вставлену чернетку секції «Суть». -- parseDoc — відокремлює frontmatter документа від основного вмісту, зберігаючи тіло без змін. -- serializeDoc — збирає frontmatter і тіло назад у документ зі стабільним повторним читанням для власних docs рушія. -- extractEssence — дістає зі змісту документа секцію «Суть». -- insertEssenceDraft — додає чернеткову секцію «Суть» після головного заголовка та вступної цитати. -- parseSourceLine — розкладає рядок джерела fragment-маркера на шлях і діапазон рядків. -- formatSourceLine — перетворює опис джерела fragment-блоку назад у компактний рядок. -- extractFragment — знаходить у тексті службовий fragment-блок рушія. -- replaceFragment — оновлює fragment-блок: переписує список джерел у маркері та замінює вміст між межами. - -## Гарантії поведінки - -- (специфічних машинно-виведених гарантій немає) diff --git a/layers/lib/docs/status.md b/layers/lib/docs/status.md deleted file mode 100644 index ec8b4b8..0000000 --- a/layers/lib/docs/status.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -type: JS Module -title: status.mjs -resource: layers/lib/status.mjs -docgen: - crc: 34731e4c - model: openai-codex/gpt-5.5 - score: 100 - issues: judge:inaccurate:0.95 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Файл формує read-only статус актуальності відносно поточного вмісту джерел. Він потрібен, щоб обчислити стан через `computeStatus`, подати його для відображення через `renderStatus` і визначити код завершення через `statusExitCode`. - -## Поведінка - -- `computeStatus` визначає актуальність шарів документації та перекладів, порівнює записаний стан із поточним вмістом джерел і повертає зведений звіт без запису у ФС чи БД. -- `renderStatus` перетворює звіт стану на людиночитний або JSON-вивід і додає код завершення для CLI-сценаріїв. -- `statusExitCode` визначає код завершення за найгіршим станом документації, наявністю проблемних перекладів і режимом strict. - -## Публічний API - -- computeStatus — визначає стан шарів і перекладів на основі переданого читача файлів, без звернень до мережі чи файлової системи. -- renderStatus — формує зрозумілий для людини звіт про стан і відповідний код завершення. -- statusExitCode — повертає код завершення: успіх для актуального стану, помилку для застарілих чи чорнових даних, окрему помилку для відсутньої сутності. - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). diff --git a/layers/lib/docs/translate.md b/layers/lib/docs/translate.md deleted file mode 100644 index c4ef93e..0000000 --- a/layers/lib/docs/translate.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -type: JS Module -title: translate.mjs -resource: layers/lib/translate.mjs -docgen: - crc: 4ed830b9 - model: openai-codex/gpt-5.5 - tier: cloud-avg - score: 100 - issues: judge:inaccurate:0.96 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Файл готує Markdown-документи до багатомовного перекладу: `splitBySections` ділить великі тексти на послідовні частини, `rewriteLinks` адаптує локальні посилання для мовних версій, а `runTranslate` керує створенням перекладів. Він потрібен, щоб перекладати документи частинами та не змінювати зовнішні посилання. - -## Поведінка - -- `splitBySections` ділить великий Markdown-документ на послідовні фрагменти за межами H2-секцій, щоб перекладати обсяг, прийнятний для LLM, без зміни порядку вмісту. - -- `rewriteLinks` замінює відносні посилання на `.md`-документи мовними версіями лише тоді, коли ціль входить до області перекладу; зовнішні URL і посилання поза областю лишаються без змін. - -- `runTranslate` проходить документами області перекладу й мовами, пропускає актуальні переклади, у dry-run лише показує кандидатів, а для застарілих або відсутніх перекладів генерує новий Markdown із метаданими та підсумовує записані, пропущені й невдалі файли. - -## Публічний API - -- splitBySections — розбиває вміст за межами H2-секцій на частини в межах ліміту та збирає їх у стабільному порядку. -- rewriteLinks — замінює відносні посилання на `.md`-документи посиланнями на мовні версії, коли ціль входить до області перекладу. -- runTranslate — оновлює derived-переклади документації за змінами базових файлів, зберігає повну деталізацію оригіналу й не чіпає authored-переклади без зміни base. - -## Гарантії поведінки - -- (специфічних машинно-виведених гарантій немає) diff --git a/layers/lib/layers.mjs b/layers/lib/layers.mjs deleted file mode 100644 index 909b3e5..0000000 --- a/layers/lib/layers.mjs +++ /dev/null @@ -1,163 +0,0 @@ -/** @see ./docs/layers.md */ - -import { readFileSync } from 'node:fs' -import { join } from 'node:path' - -const CONFIG_NAME = 'layers.json' - -const LAYER_RE = /^L(\d+)$/ -const MD_EXT_RE = /\.md$/ - -/** - * Читає і валідує конфіг топології `/layers.json`. - * @param {string} docsDir корінь полігона документації - * @returns {{docsDir: string, tier: string, maxTokens: number, i18n: {baseLang: string, langs: string[]}, docs: Record, leaves: string[]}} завантажений і валідований конфіг - */ -export function loadConfig(docsDir) { - let raw - try { - raw = readFileSync(join(docsDir, CONFIG_NAME), 'utf8') - } catch { - throw new Error(`Не знайдено ${join(docsDir, CONFIG_NAME)} — це не тека шарової документації`) - } - const parsed = JSON.parse(raw) - const config = { - docsDir, - tier: parsed.tier ?? 'avg', - maxTokens: parsed.maxTokens ?? 4096, - i18n: { baseLang: parsed.i18n?.baseLang ?? 'uk', langs: parsed.i18n?.langs ?? [] }, - docs: parsed.docs ?? {}, - leaves: [] - } - config.leaves = deriveLeaves(config.docs) - const errors = validateConfig(config) - if (errors.length) throw new Error(`Невалідний ${CONFIG_NAME}:\n- ${errors.join('\n- ')}`) - return config -} - -/** - * Leaf — файл, що фігурує в sources, але не має власного запису в docs. - * @param {Record} docs записи конфігу - * @returns {string[]} відсортовані шляхи leaf-файлів - */ -function deriveLeaves(docs) { - const generated = new Set(Object.keys(docs)) - const leaves = new Set() - for (const entry of Object.values(docs)) { - for (const source of entry.sources ?? []) { - if (!generated.has(source)) leaves.add(source) - } - } - return [...leaves].toSorted() -} - -/** - * Номер шару з мітки `L`; менший n — вищий (агрегованіший) шар. - * @param {string} layer мітка шару (напр. `L2`) - * @returns {number} номер шару або NaN, якщо мітка невалідна - */ -export function layerNumber(layer) { - const match = LAYER_RE.exec(layer ?? '') - return match ? Number(match[1]) : NaN -} - -/** - * Перевіряє мітку шару, непорожність sources і невідомий mode одного запису. - * @param {string} file шлях доки - * @param {{layer: string, sources?: string[], mode?: string}} entry запис конфігу - * @param {number} num номер шару доки (може бути NaN) - * @returns {string[]} помилки форми запису - */ -function validateEntryShape(file, entry, num) { - const errors = [] - if (Number.isNaN(num)) errors.push(`${file}: невалідна мітка шару '${entry.layer}' (очікую L)`) - if (!entry.sources?.length) errors.push(`${file}: порожній список sources`) - if (entry.mode && entry.mode !== 'fragment') errors.push(`${file}: невідомий mode '${entry.mode}'`) - return errors -} - -/** - * Перевіряє напрямок і самопосилання джерел одного запису; заповнює sourcedBy. - * @param {string} file шлях доки - * @param {{layer: string, sources?: string[]}} entry запис конфігу - * @param {number} num номер шару доки - * @param {Record} docs усі доки конфігу - * @param {Map} sourcedBy мапа джерело → дока, що його використовує (мутується) - * @returns {string[]} помилки джерел запису - */ -function validateSources(file, entry, num, docs, sourcedBy) { - const errors = [] - for (const source of entry.sources ?? []) { - sourcedBy.set(source, file) - if (source === file) errors.push(`${file}: посилається сам на себе`) - const sourceEntry = docs[source] - if (sourceEntry && layerNumber(sourceEntry.layer) <= num) { - errors.push(`${file} (${entry.layer}): джерело ${source} (${sourceEntry.layer}) не з нижчого шару`) - } - } - return errors -} - -/** - * Перевіряє, що жодна fragment-дока не фігурує як джерело іншої. - * @param {Record} docs усі доки конфігу - * @param {Map} sourcedBy мапа джерело → дока, що його використовує - * @returns {string[]} помилки fragment-обмеження - */ -function validateFragmentSources(docs, sourcedBy) { - const errors = [] - for (const [file, entry] of Object.entries(docs)) { - if (entry.mode === 'fragment' && sourcedBy.has(file)) { - errors.push(`${file}: fragment-дока не може бути джерелом для ${sourcedBy.get(file)}`) - } - } - return errors -} - -/** - * Перевіряє топологію: мітки шарів, напрямок джерел (лише знизу вгору), - * fragment-обмеження. Порядок «джерело — строго нижчий шар» унеможливлює цикли. - * @param {ReturnType} config результат loadConfig - * @returns {string[]} помилки (порожньо — конфіг валідний) - */ -function validateConfig(config) { - const errors = [] - const sourcedBy = new Map() - for (const [file, entry] of Object.entries(config.docs)) { - const num = layerNumber(entry.layer) - errors.push(...validateEntryShape(file, entry, num), ...validateSources(file, entry, num, config.docs, sourcedBy)) - } - errors.push(...validateFragmentSources(config.docs, sourcedBy)) - return errors -} - -/** - * Порядок перебудови: знизу вгору (більший номер шару — раніше); - * всередині шару — порядок оголошення в конфігу. - * @param {ReturnType} config результат loadConfig - * @returns {string[]} шляхи док у порядку перебудови - */ -export function topoOrder(config) { - return Object.keys(config.docs).toSorted( - (a, b) => layerNumber(config.docs[b].layer) - layerNumber(config.docs[a].layer) - ) -} - -/** - * Файли, що підлягають перекладу: leaf-доки + всі доки конфігу. - * @param {ReturnType} config результат loadConfig - * @returns {string[]} шляхи файлів у scope перекладу - */ -export function translationScope(config) { - return [...config.leaves, ...Object.keys(config.docs)] -} - -/** - * Шлях derived-перекладу: `architecture/graph.md` + `en` → `architecture/graph.en.md`. - * @param {string} file шлях base-файлу - * @param {string} lang код мови - * @returns {string} шлях мовної версії файлу - */ -export function langPath(file, lang) { - return file.replace(MD_EXT_RE, () => `.${lang}.md`) -} diff --git a/layers/lib/llm.mjs b/layers/lib/llm.mjs deleted file mode 100644 index b026e39..0000000 --- a/layers/lib/llm.mjs +++ /dev/null @@ -1,123 +0,0 @@ -/** @see ./docs/llm.md */ - -import process from 'node:process' - -/** - * Помилка LLM-шару. `code`: - * - `unavailable` — транспорт недоступний (нема llm-lib/pi, жодної моделі, мережа); - * - `output` — модель відповідає, але вихід не пройшов валідацію після всіх спроб. - */ -export class LlmError extends Error { - /** - * @param {'unavailable' | 'output'} code клас відмови - * @param {string} message людиночитне пояснення - */ - constructor(code, message) { - super(message) - this.code = code - } -} - -/** Порядок ескалації тирів model-tiers llm-lib (кожен тир сам каскадить local→cloud). */ -const TIERS = ['min', 'avg', 'max'] - -/** - * @param {string} tier поточний тир - * @returns {string | null} наступний (дорожчий) тир або null - */ -function nextTier(tier) { - const index = TIERS.indexOf(tier) - return index !== -1 && index < TIERS.length - 1 ? TIERS[index + 1] : null -} - -/** - * Тонка обгортка над `@7n/llm-lib`: one-shot із ретраєм і tier-ескалацією, - * один chain на прогін. llm-lib вантажиться lazy — status ніколи його не тягне. - * @param {object} options параметри прогону - * @param {string} [options.tier] базовий tier моделей (min/avg/max) - * @param {number} [options.maxTokens] стеля токенів відповіді - * @param {number} [options.timeoutMs] стеля часу одного виклику (переклад повного тіла на local-моделі декодує довше за коротку суть; llm-lib дефолт 120с замало) - * @param {string} options.caller ідентифікатор джерела для trace/telemetry - * @param {string} [options.chainKind] kind ланцюжка (нема — без chain) - * @param {string} [options.chainUnit] unit ланцюжка - * @param {{runOneShot: (request: object) => Promise<{content?: string, model?: string, error?: string}>, startChain?: (options: object) => {end: (result: object) => void}}} [options.impl] injected-транспорт для тестів - * @returns {Promise<{generate: (request: object) => Promise<{content: string, model: string}>, end: (outcome?: string) => void}>} готовий клієнт - */ -export async function createLlm({ - tier = 'avg', - maxTokens = 4096, - timeoutMs = 600_000, - caller, - chainKind, - chainUnit, - impl -}) { - let runOneShot = impl?.runOneShot - let startChain = impl?.startChain - if (!runOneShot) { - try { - ;({ runOneShot } = await import('@7n/llm-lib/one-shot')) - ;({ startChain } = await import('@7n/llm-lib/chain')) - } catch (error) { - throw new LlmError( - 'unavailable', - `LLM-транспорт недоступний: не вдалось завантажити @7n/llm-lib (${error.message}). ` + - 'Перевір встановлення залежностей (bun install) і peer @earendil-works/pi-ai.' - ) - } - } - const chain = chainKind && startChain ? startChain({ kind: chainKind, unit: chainUnit, cwd: process.cwd() }) : null - let outcome = 'fail' - - return { - /** - * Генерація з валідацією: 1 ретрай тим самим tier → 1 ескалація tier-ом вище. - * @param {object} request запит - * @param {string} request.system системний промпт - * @param {string} request.user користувацький промпт - * @param {(content: string) => boolean} [request.validate] структурна перевірка виходу - * @param {string} [request.tier] override базового tier для цього виклику - * @returns {Promise<{content: string, model: string}>} валідний вихід і фактична модель - */ - async generate({ system, user, validate, tier: tierOverride }) { - const baseTier = tierOverride ?? tier - const attempts = [baseTier, baseTier, nextTier(baseTier)].filter(Boolean) - let lastModel = '' - for (const attemptTier of attempts) { - const result = await runOneShot({ - messages: [ - { role: 'system', content: system }, - { role: 'user', content: user } - ], - modelTier: attemptTier, - maxTokens, - timeoutMs, - caller, - ...(chain && { chain }) - }) - if (result.error) throw new LlmError('unavailable', `LLM-виклик не вдався (${attemptTier}): ${result.error}`) - const content = (result.content ?? '').trim() - lastModel = result.model ?? attemptTier - if (content && (!validate || validate(content))) { - outcome = 'success' - return { content, model: lastModel } - } - } - const escalation = nextTier(baseTier) - const tierPath = escalation ? `${baseTier}→${escalation}` : baseTier - throw new LlmError( - 'output', - `Вихід моделі ${lastModel} не пройшов валідацію після ${attempts.length} спроб (tier ${tierPath})` - ) - }, - - /** - * Закриває chain з фактичним результатом прогону. - * @param {string} [finalOutcome] override результату (default — success після першої вдалої генерації) - * @returns {void} - */ - end(finalOutcome = outcome) { - chain?.end({ outcome: finalOutcome }) - } - } -} diff --git a/layers/lib/md.mjs b/layers/lib/md.mjs deleted file mode 100644 index 5c300a6..0000000 --- a/layers/lib/md.mjs +++ /dev/null @@ -1,350 +0,0 @@ -/** @see ./docs/md.md */ - -export const DRAFT_MARKER = '' -const ESSENCE_HEADING = /^##\s+Суть\s*$/ -const NEEDS_QUOTE = /[:#'",[\]{}|>&*!%@`]|^\s|\s$|^$/ -const KEY_RE = /^[A-Za-z_][\w.-]*$/ -const SPLIT_WS_RE = /\s+/ -const FRAGMENT_OPEN = // -const FRAGMENT_CLOSE = // -/** Ключі, чиї рядкові значення завжди лаповані — так з ними працює вся наявна документація репо. */ -const ALWAYS_QUOTE_KEYS = new Set(['description']) -/** Немаркований (non-enumerable) прапор на масиві: розібраний як inline `[a, b]`, а не block-list. */ -const FLOW_FLAG = Symbol('flow') - -/** - * Розбирає документ на frontmatter (YAML-сабсет) і тіло. - * Тіло повертається байт-у-байт (усе після закривального `---`). - * @param {string} text повний вміст файлу - * @returns {{fm: Record | null, body: string}} frontmatter (null — його нема) і тіло - */ -export function parseDoc(text) { - if (!text.startsWith('---\n')) return { fm: null, body: text } - const end = text.indexOf('\n---\n', 3) - if (end === -1) return { fm: null, body: text } - const fmRaw = text.slice(4, end + 1) - return { fm: parseFrontmatter(fmRaw), body: text.slice(end + 5) } -} - -/** - * Серіалізує frontmatter + тіло назад у документ. - * Для власних док рушія round-trip `parseDoc(serializeDoc(fm, body))` байт-стабільний. - * @param {Record} fm розібраний frontmatter - * @param {string} body тіло документа - * @returns {string} повний текст файлу - */ -export function serializeDoc(fm, body) { - return `---\n${serializeFrontmatter(fm)}---\n${body}` -} - -/** - * @param {string} fmRaw сирий текст frontmatter (без роздільників `---`) - * @returns {Record} розібрана мапа верхнього рівня - */ -function parseFrontmatter(fmRaw) { - const [obj] = parseMap(fmRaw.split('\n'), 0, 0) - return obj -} - -/** - * Розбирає рядок `key: value` (або просто `key:`). Ключ — суворо ідентифікатор, - * значення після колону — усе до кінця рядка (без further-нідедлайн ескейпів). - * @param {string} line один trimmed рядок frontmatter - * @returns {{key: string, rawValue: string | undefined} | null} null — рядок не «key: value» - */ -function parseKeyValue(line) { - const colon = line.indexOf(':') - if (colon === -1) return null - const key = line.slice(0, colon) - if (!KEY_RE.test(key)) return null - const rest = line.slice(colon + 1).trim() - return { key, rawValue: rest === '' ? undefined : rest } -} - -/** - * Розбирає block-list (`- item` рядки) від `start`, поки відступ не менший за `indent`. - * @param {string[]} lines усі рядки frontmatter - * @param {number} start індекс першого рядка списку - * @param {number} indent мінімальний відступ елементів списку - * @returns {[unknown[], number]} елементи і індекс першого рядка після списку - */ -function parseBlockList(lines, start, indent) { - const items = [] - let i = start - while (i < lines.length) { - const itemRaw = lines[i] - if (!itemRaw.trim()) { - i++ - continue - } - if (!itemRaw.trim().startsWith('- ') || itemRaw.length - itemRaw.trimStart().length <= indent) break - items.push(parseScalar(itemRaw.trim().slice(2))) - i++ - } - return [items, i] -} - -/** - * Розбирає ключ без inline-значення: наступний глибший рядок вирішує — block-list чи вкладена мапа. - * @param {string[]} lines усі рядки frontmatter - * @param {number} i індекс першого рядка після `key:` - * @param {number} indent відступ поточного рівня - * @returns {[unknown, number]} значення (масив/мапа/null) і індекс наступного нерозібраного рядка - */ -function parseNestedValue(lines, i, indent) { - const next = lines.slice(i).find(line => line.trim()) - if (next?.trim().startsWith('- ')) return parseBlockList(lines, i, indent) - if (next && next.length - next.trimStart().length > indent) return parseMap(lines, i, indent + 2) - return [null, i] -} - -/** - * Рекурсивний розбір мапи з фіксованим відступом; підтримувана глибина — - * рівно та, яку пише сам рушій (вкладена мапа + списки скалярів). - * @param {string[]} lines усі рядки frontmatter - * @param {number} start індекс першого рядка цього рівня - * @param {number} indent мінімальний відступ рядків цього рівня - * @returns {[Record, number]} розібрана мапа і індекс першого рядка за нею - */ -function parseMap(lines, start, indent) { - /** @type {Record} */ - const obj = {} - let i = start - while (i < lines.length) { - const raw = lines[i] - if (!raw.trim()) { - i++ - continue - } - const lineIndent = raw.length - raw.trimStart().length - if (lineIndent < indent) break - const parsed = parseKeyValue(raw.trim()) - if (!parsed) break - i++ - if (parsed.rawValue !== undefined) { - obj[parsed.key] = parseScalar(parsed.rawValue) - continue - } - const [value, nextIndex] = parseNestedValue(lines, i, indent) - obj[parsed.key] = value - i = nextIndex - } - return [obj, i] -} - -/** - * Ділить вміст inline-масиву на елементи за комами верхнього рівня - * (кома всередині `'...'` не рахується роздільником). - * @param {string} inner текст між `[` і `]` - * @returns {string[]} нерозібрані шматки елементів - */ -function splitFlowItems(inner) { - const parts = [] - let current = '' - let inQuote = false - for (const ch of inner) { - if (ch === "'") inQuote = !inQuote - if (ch === ',' && !inQuote) { - parts.push(current) - current = '' - } else { - current += ch - } - } - parts.push(current) - return parts -} - -/** - * Розбирає inline-масив `[a, 'b, c', true]` у справжній масив, помічений FLOW_FLAG - * для збереження inline-формату при серіалізації. - * @param {string} value повний текст значення разом із дужками - * @returns {unknown[]} розібраний масив - */ -function parseFlowArray(value) { - const inner = value.slice(1, -1).trim() - const items = inner === '' ? [] : splitFlowItems(inner).map(item => parseScalar(item.trim())) - Object.defineProperty(items, FLOW_FLAG, { value: true, enumerable: false }) - return items -} - -/** - * @param {string} raw сирий текст значення (усе після `key:`) - * @returns {unknown} розібране значення: boolean, масив або рядок - */ -function parseScalar(raw) { - const value = raw.trim() - if (value === 'true') return true - if (value === 'false') return false - if (value.startsWith('[') && value.endsWith(']')) return parseFlowArray(value) - if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) { - return value.slice(1, -1).replaceAll("''", "'") - } - return value -} - -/** - * @param {Record} obj мапа для серіалізації - * @param {number} [indent] поточний відступ (рекурсія для вкладених мап) - * @returns {string} YAML-сабсет текст (кожен рядок з `\n`) - */ -function serializeFrontmatter(obj, indent = 0) { - const pad = ' '.repeat(indent) - let out = '' - for (const [key, value] of Object.entries(obj)) { - if (value === null || value === undefined) { - out += `${pad}${key}:\n` - } else if (Array.isArray(value)) { - out += value[FLOW_FLAG] ? serializeFlowArray(pad, key, value) : serializeBlockList(pad, key, value) - } else if (typeof value === 'object') { - out += `${pad}${key}:\n${serializeFrontmatter(value, indent + 2)}` - } else { - const rendered = - ALWAYS_QUOTE_KEYS.has(key) && typeof value === 'string' ? quoteScalar(value) : serializeScalar(value) - out += `${pad}${key}: ${rendered}\n` - } - } - return out -} - -/** - * @param {string} pad відступ поточного рівня - * @param {string} key ключ масиву - * @param {unknown[]} items елементи масиву - * @returns {string} рядок `key: [a, b]\n` - */ -function serializeFlowArray(pad, key, items) { - return `${pad}${key}: [${items.map(item => serializeScalar(item)).join(', ')}]\n` -} - -/** - * @param {string} pad відступ поточного рівня - * @param {string} key ключ масиву - * @param {unknown[]} items елементи масиву - * @returns {string} block-list `key:\n - a\n - b\n` - */ -function serializeBlockList(pad, key, items) { - let out = `${pad}${key}:\n` - for (const item of items) out += `${pad} - ${serializeScalar(item)}\n` - return out -} - -/** - * @param {unknown} value скалярне значення - * @returns {string} YAML-представлення (лаповане, якщо містить спецсимволи) - */ -function serializeScalar(value) { - if (typeof value === 'boolean') return String(value) - const text = String(value) - return NEEDS_QUOTE.test(text) ? quoteScalar(text) : text -} - -/** - * @param {string} text значення без лапок - * @returns {string} завжди лаповане YAML-значення - */ -function quoteScalar(text) { - return `'${text.replaceAll("'", "''")}'` -} - -/** - * Витягає блок «## Суть» із тіла документа. - * @param {string} body тіло документа (без frontmatter) - * @returns {{text: string, draft: boolean} | null} null — секції нема - */ -export function extractEssence(body) { - const lines = body.split('\n') - const start = lines.findIndex(line => ESSENCE_HEADING.test(line)) - if (start === -1) return null - let end = lines.length - for (let i = start + 1; i < lines.length; i++) { - if (lines[i].startsWith('## ')) { - end = i - break - } - } - const sectionLines = lines.slice(start + 1, end) - const draft = sectionLines.some(line => line.trim() === DRAFT_MARKER) - const text = sectionLines - .filter(line => line.trim() !== DRAFT_MARKER) - .join('\n') - .trim() - return { text, draft } -} - -/** - * Вставляє секцію «## Суть» (чернетку) після H1 і вступного blockquote. - * @param {string} body тіло документа - * @param {string} essenceText текст чернетки суті - * @returns {string} тіло з вставленою секцією - */ -export function insertEssenceDraft(body, essenceText) { - const lines = body.split('\n') - let insertAt = 0 - const h1 = lines.findIndex(line => line.startsWith('# ')) - if (h1 !== -1) { - insertAt = h1 + 1 - while (insertAt < lines.length && (lines[insertAt].startsWith('>') || !lines[insertAt].trim())) { - insertAt++ - } - } - const section = ['## Суть', '', DRAFT_MARKER, essenceText.trim(), ''] - return [...lines.slice(0, insertAt), ...section, ...lines.slice(insertAt)].join('\n') -} - -/** - * @param {string} line рядок виду 'шлях essenceCrc fileCrc' - * @returns {{file: string, essenceCrc: string, fileCrc: string}} розібрана трійка - */ -export function parseSourceLine(line) { - const parts = line.trim().split(SPLIT_WS_RE) - if (parts.length !== 3) throw new Error(`Невалідний source-рядок: '${line}'`) - const [file, essence, body] = parts - return { file, essenceCrc: essence, fileCrc: body } -} - -/** - * @param {{file: string, essenceCrc: string, fileCrc: string}} source трійка джерела - * @returns {string} рядок 'шлях essenceCrc fileCrc' - */ -export function formatSourceLine({ file, essenceCrc, fileCrc }) { - return `${file} ${essenceCrc} ${fileCrc}` -} - -/** - * Знаходить fragment-блок рушія у тексті (напр. L0 в index.md). - * @param {string} text повний вміст хост-файлу - * @returns {{layer: string, sources: Array<{file: string, essenceCrc: string, fileCrc: string}>, inner: string} | null} null — маркерів нема - */ -export function extractFragment(text) { - const open = text.match(FRAGMENT_OPEN) - if (!open || open.index === undefined) return null - const close = text.slice(open.index).match(FRAGMENT_CLOSE) - if (!close || close.index === undefined) return null - const innerStart = open.index + open[0].length - const innerEnd = open.index + close.index - let inner = text.slice(innerStart, innerEnd) - if (inner.startsWith('\n')) inner = inner.slice(1) - if (inner.endsWith('\n')) inner = inner.slice(0, -1) - return { - layer: open[1], - sources: open[2].split(',').map(part => parseSourceLine(part)), - inner - } -} - -/** - * Замінює fragment-блок: оновлює sources у маркері та вміст між маркерами. - * @param {string} text повний вміст хост-файлу - * @param {{layer: string, sources: Array<{file: string, essenceCrc: string, fileCrc: string}>, content: string}} fragment нові дані блоку - * @returns {string} текст з оновленим блоком; решта файлу недоторкана - */ -export function replaceFragment(text, { layer, sources, content }) { - const open = text.match(FRAGMENT_OPEN) - if (!open || open.index === undefined) throw new Error(`Fragment-маркер layers:${layer} не знайдено`) - const close = text.slice(open.index).match(FRAGMENT_CLOSE) - if (!close || close.index === undefined) throw new Error(`Закривальний маркер /layers:${layer} не знайдено`) - const sourceList = sources.map(source => formatSourceLine(source)).join(', ') - const block = `\n${content.trim()}\n` - return text.slice(0, open.index) + block + text.slice(open.index + close.index + close[0].length) -} diff --git a/layers/lib/status.mjs b/layers/lib/status.mjs deleted file mode 100644 index f0c4cc6..0000000 --- a/layers/lib/status.mjs +++ /dev/null @@ -1,226 +0,0 @@ -/** @see ./docs/status.md */ - -import { essenceCrc, fileCrc } from './crc.mjs' -import { langPath, topoOrder, translationScope } from './layers.mjs' -import { extractEssence, extractFragment, parseDoc, parseSourceLine } from './md.mjs' - -/** Порядок суворості станів: індекс більший — стан гірший. */ -const SEVERITY = ['fresh', 'details-only', 'draft', 'stale', 'no-essence'] - -/** - * @param {string[]} states список станів для згортання в найгірший - * @returns {string} найгірший (найсуворіший) стан зі списку - */ -function worstOf(states) { - let worst = 'fresh' - for (const state of states) { - if (SEVERITY.indexOf(state) > SEVERITY.indexOf(worst)) worst = state - } - return worst -} - -/** - * Поточні CRC і стан суті одного файлу. - * @param {string | null} text вміст файлу або null, якщо його нема - * @returns {{fileCrc: string, essenceCrc: string | null, draft: boolean} | null} стан файлу або null, якщо файлу нема - */ -function inspectSource(text) { - if (text === null) return null - const { body } = parseDoc(text) - const essence = extractEssence(body) - return { - fileCrc: fileCrc(body), - essenceCrc: essence ? essenceCrc(essence.text) : null, - draft: essence?.draft ?? false - } -} - -/** - * Записані у доці пари CRC джерел: із frontmatter `layers.sources` - * або з fragment-маркера. - * @param {string | null} text вміст доки або null, якщо її нема - * @param {boolean} isFragment true — дока fragment-режиму - * @returns {Map | null} null — доки нема - */ -function recordedSources(text, isFragment) { - if (text === null) return null - const map = new Map() - if (isFragment) { - const fragment = extractFragment(text) - if (!fragment) return map - for (const source of fragment.sources) map.set(source.file, source) - return map - } - const { fm } = parseDoc(text) - const lines = fm?.layers?.sources - if (!Array.isArray(lines)) return map - for (const line of lines) { - const source = parseSourceLine(String(line)) - map.set(source.file, source) - } - return map -} - -/** - * Стан перекладу відносно base-версії: незбіг CRC завжди stale, - * інакше — authored або fresh залежно від маркера авторства. - * @param {boolean} inSync true — sourceFileCrc перекладу збігається з base - * @param {boolean} authored true — переклад позначений як authored - * @returns {string} 'stale' | 'authored' | 'fresh' - */ -function translationState(inSync, authored) { - if (!inSync) return 'stale' - return authored ? 'authored' : 'fresh' -} - -/** - * Детермінований стан усіх шарів і перекладів. Без мережі та FS — - * усі читання через injected `readFile(relPath) → string | null`. - * @param {object} config результат loadConfig - * @param {(relPath: string) => string | null} readFile читання файлу полігона - * @returns {{docs: object[], translations: object[], worst: string, translationsPending: boolean}} повний звіт стану - */ -export function computeStatus(config, readFile) { - const inspected = new Map() - /** - * @param {string} file шлях джерела - * @returns {{fileCrc: string, essenceCrc: string | null, draft: boolean} | null} стан джерела (кешовано) - */ - const inspect = file => { - if (!inspected.has(file)) inspected.set(file, inspectSource(readFile(file))) - return inspected.get(file) - } - - const docs = topoOrder(config).map(file => { - const entry = config.docs[file] - const isFragment = entry.mode === 'fragment' - const recorded = recordedSources(readFile(file), isFragment) - const reasons = [] - if (recorded === null) reasons.push('дока відсутня — потрібен build') - - const sources = entry.sources.map(sourceFile => { - const current = inspect(sourceFile) - let state = 'fresh' - let note = '' - if (current === null) { - state = 'stale' - note = 'джерело відсутнє' - } else if (current.essenceCrc === null) { - state = 'no-essence' - note = 'нема секції «## Суть»' - } else if (current.draft) { - state = 'draft' - note = 'суть — чернетка (essence:draft)' - } else { - const rec = recorded?.get(sourceFile) - if (!rec) { - state = 'stale' - note = recorded === null ? '' : 'джерело ще не враховане в доці' - } else if (rec.essenceCrc !== current.essenceCrc) { - state = 'stale' - note = 'суть джерела змінилась' - } else if (rec.fileCrc !== current.fileCrc) { - state = 'details-only' - note = 'змінились лише деталі — перевір «## Суть» джерела і зроби refresh' - } - } - return { file: sourceFile, state, note } - }) - - if (recorded) { - for (const recordedFile of recorded.keys()) { - if (!entry.sources.includes(recordedFile)) { - reasons.push(`у доці записане джерело ${recordedFile}, якого вже нема в конфігу`) - } - } - } - - const state = worstOf([...sources.map(source => source.state), ...(reasons.length ? ['stale'] : [])]) - return { file, layer: entry.layer, state, reasons, sources } - }) - - const translations = [] - for (const file of translationScope(config)) { - const baseText = readFile(file) - if (baseText === null) continue // відсутність base уже зарепорчена в шарах - const baseCrc = fileCrc(parseDoc(baseText).body) - for (const lang of config.i18n.langs) { - const translated = readFile(langPath(file, lang)) - let state = 'missing' - if (translated !== null) { - const { fm } = parseDoc(translated) - const authored = fm?.authored === true - const inSync = fm?.sourceFileCrc === baseCrc - state = translationState(inSync, authored) - } - translations.push({ file: langPath(file, lang), source: file, lang, state }) - } - } - - return { - docs, - translations, - worst: worstOf(docs.map(doc => doc.state)), - translationsPending: translations.some(t => t.state === 'missing' || t.state === 'stale') - } -} - -const STATE_ICON = { - fresh: '✅', - authored: '✍️', - 'details-only': '🔎', - draft: '📝', - stale: '♻️', - 'no-essence': '⛔', - missing: '∅' -} - -/** - * Людиночитний звіт + exit code. - * @param {ReturnType} report результат computeStatus - * @param {{json?: boolean, strict?: boolean}} [options] опції рендеру - * @returns {{text: string, exitCode: number}} текст звіту і exit code - */ -export function renderStatus(report, { json = false, strict = false } = {}) { - const exitCode = statusExitCode(report, strict) - if (json) return { text: JSON.stringify(report, null, 2), exitCode } - - const lines = ['Шари:'] - for (const doc of report.docs) { - lines.push(` ${STATE_ICON[doc.state]} [${doc.layer}] ${doc.file} — ${doc.state}`) - for (const reason of doc.reasons) lines.push(` • ${reason}`) - for (const source of doc.sources) { - if (source.state === 'fresh') continue - const noteSuffix = source.note ? ` — ${source.note}` : '' - lines.push(` ${STATE_ICON[source.state]} ${source.file}${noteSuffix}`) - } - } - if (report.translations.length) { - lines.push('Переклади:') - for (const translation of report.translations) { - if (translation.state === 'fresh' || translation.state === 'authored') continue - lines.push(` ${STATE_ICON[translation.state]} ${translation.file} — ${translation.state}`) - } - const pending = report.translations.filter(t => t.state === 'missing' || t.state === 'stale').length - const done = report.translations.length - pending - lines.push(` разом: ${done} свіжих, ${pending} до генерації`) - } - lines.push(`Підсумок: ${report.worst}${report.translationsPending ? ' + переклади до генерації' : ''}`) - return { text: lines.join('\n'), exitCode } -} - -/** - * 0 — усе свіже (details-only толерується без --strict); - * 1 — є що перебудувати (stale/draft/переклади); - * 2 — структурна проблема (no-essence). - * @param {ReturnType} report результат computeStatus - * @param {boolean} strict true — details-only теж вважати за розсинхрон - * @returns {number} exit code 0/1/2 - */ -export function statusExitCode(report, strict) { - const states = new Set(report.docs.flatMap(doc => [doc.state, ...doc.sources.map(s => s.state)])) - if (states.has('no-essence')) return 2 - if (states.has('stale') || states.has('draft') || report.translationsPending) return 1 - if (strict && states.has('details-only')) return 1 - return 0 -} diff --git a/layers/lib/tests/build-run.test.mjs b/layers/lib/tests/build-run.test.mjs deleted file mode 100644 index 93f5b21..0000000 --- a/layers/lib/tests/build-run.test.mjs +++ /dev/null @@ -1,225 +0,0 @@ -import { describe, expect, test } from 'vitest' - -import { bootstrapEssences, runBuild } from '../build.mjs' -import { extractEssence, parseDoc } from '../md.mjs' -import { computeStatus } from '../status.mjs' -import { leafDoc, memoryReader } from './helpers.mjs' - -const TITLE_RE = /почни з рядка "# (.+?)"/ -/** - * Тихий лог для прогонів у тестах. - * @param {string} text рядок прогресу - * @returns {string} той самий рядок (ігнорується) - */ -const silentLog = text => text - -/** - * Стаб-LLM для build/bootstrap: складає валідний вихід із system-промпту. - * @returns {{generate: (request: object) => Promise<{content: string, model: string}>, calls: object[]}} стаб - */ -function stubLlm() { - const calls = [] - return { - calls, - /** - * @param {object} request запит генерації - * @returns {Promise<{content: string, model: string}>} валідний для запиту вихід - */ - generate(request) { - calls.push(request) - const title = request.system.match(TITLE_RE)?.[1] - let content = 'Просте резюме без заголовків і жаргону.' - if (title) content = `# ${title}\n\n## Суть\n\nСтаб-суть огляду.\n\n## Розгортка\n\nАгрегований текст.` - else if (request.system.includes('Сформулюй «суть»')) content = 'Чернетка суті документа.' - if (request.validate && !request.validate(content)) { - return Promise.reject(Object.assign(new Error('стаб не пройшов валідацію'), { code: 'output' })) - } - return Promise.resolve({ content, model: 'stub/model' }) - } - } -} - -/** @returns {object} конфіг полігона L2→L1→L0(fragment) над двома leaf */ -function makeConfig() { - return { - docsDir: '/віртуальний', - tier: 'avg', - maxTokens: 4096, - i18n: { baseLang: 'uk', langs: [] }, - docs: { - 'overview/core.md': { layer: 'L2', title: 'Ядро', sources: ['a.md', 'b.md'] }, - 'overview/index.md': { layer: 'L1', title: 'Як це працює', sources: ['overview/core.md'] }, - 'index.md': { layer: 'L0', mode: 'fragment', sources: ['overview/index.md'] } - }, - leaves: ['a.md', 'b.md'] - } -} - -/** - * @param {Record} files віртуальна ФС - * @returns {{readFile: (rel: string) => string | null, writeFile: (rel: string, text: string) => void}} io-пара - */ -function ioOver(files) { - return { - readFile: memoryReader(files), - /** - * @param {string} rel шлях - * @param {string} text вміст - * @returns {void} - */ - writeFile(rel, text) { - files[rel] = text - } - } -} - -const HOST = - '# Індекс\n\n\nстаре\n\n\n## Решта\n' - -describe('runBuild', () => { - test('будує знизу вгору: L2 → L1 → L0-fragment, полігон стає fresh', async () => { - const files = { - 'a.md': leafDoc({ essence: 'Суть А.' }), - 'b.md': leafDoc({ essence: 'Суть Б.' }), - 'index.md': HOST - } - const llm = stubLlm() - const result = await runBuild(makeConfig(), { io: ioOver(files), llm, log: silentLog, today: '2026-07-12' }) - expect(result.built).toEqual(['overview/core.md', 'overview/index.md', 'index.md']) - expect(result.failed).toEqual([]) - - const core = parseDoc(files['overview/core.md']) - expect(core.fm.type).toBe('layered-doc') - expect(core.fm.layers.model).toBe('stub/model') - expect(core.fm.layers.sources).toHaveLength(2) - expect(extractEssence(core.body)).not.toBeNull() - expect(core.body).toContain('## Глибше') - expect(core.body).toContain('](../a.md)') - - expect(files['index.md']).toContain('Просте резюме') - expect(files['index.md']).toContain('Докладніше: [Як це працює](overview/index.md)') - expect(files['index.md']).toContain('## Решта') - - const report = computeStatus(makeConfig(), memoryReader(files)) - expect(report.worst).toBe('fresh') - }) - - test('вихід моделі без порожнього рядка після заголовка нормалізується (MD022)', async () => { - const files = { - 'a.md': leafDoc({ essence: 'Суть А.' }), - 'b.md': leafDoc({ essence: 'Суть Б.' }), - 'index.md': HOST - } - const llm = { - /** - * @param {object} request запит генерації - * @returns {Promise<{content: string, model: string}>} вихід без порожніх рядків після заголовків - */ - generate(request) { - const title = request.system.match(TITLE_RE)?.[1] - const content = `# ${title}\n## Суть\nСтаб-суть без відступів.\n## Розгортка\nТекст одразу після заголовка.` - return Promise.resolve({ content, model: 'stub/model' }) - } - } - await runBuild(makeConfig(), { io: ioOver(files), llm, log: silentLog, today: '2026-07-12' }) - const { body } = parseDoc(files['overview/core.md']) - expect(body).toContain('## Суть\n\nСтаб-суть без відступів.') - expect(body).toContain('## Розгортка\n\nТекст одразу після заголовка.') - }) - - test('draft-суть джерела блокує доку, помилка пояснює що робити', async () => { - const files = { - 'a.md': leafDoc({ essence: 'Суть А.', draft: true }), - 'b.md': leafDoc({ essence: 'Суть Б.' }), - 'index.md': HOST - } - const result = await runBuild(makeConfig(), { - io: ioOver(files), - llm: stubLlm(), - log: silentLog, - today: '2026-07-12' - }) - expect(result.failed.find(f => f.file === 'overview/core.md').reason).toContain('essence:draft') - expect(files['overview/core.md']).toBeUndefined() - }) - - test('dry-run: кандидати залоговані, файли не пишуться', async () => { - const files = { - 'a.md': leafDoc({ essence: 'Суть А.' }), - 'b.md': leafDoc({ essence: 'Суть Б.' }), - 'index.md': HOST - } - const lines = [] - const llm = stubLlm() - const result = await runBuild(makeConfig(), { - io: ioOver(files), - llm, - dryRun: true, - log: text => { - lines.push(text) - }, - today: '2026-07-12' - }) - expect(result.built).toHaveLength(3) - expect(llm.calls).toHaveLength(0) - expect(files['overview/core.md']).toBeUndefined() - expect(files['index.md']).toBe(HOST) - expect(lines.join('\n')).toContain('[dry-run]') - }) - - test('свіжі доки пропускаються; ідемпотентність повторного build', async () => { - const files = { - 'a.md': leafDoc({ essence: 'Суть А.' }), - 'b.md': leafDoc({ essence: 'Суть Б.' }), - 'index.md': HOST - } - const io = ioOver(files) - await runBuild(makeConfig(), { io, llm: stubLlm(), log: silentLog, today: '2026-07-12' }) - const snapshot = { ...files } - const second = await runBuild(makeConfig(), { io, llm: stubLlm(), log: silentLog, today: '2026-07-12' }) - expect(second.built).toEqual([]) - expect(second.skipped).toHaveLength(3) - expect(files).toEqual(snapshot) - }) - - test('LLM недоступна → помилка пробивається нагору (exit 3 у cli)', async () => { - const files = { - 'a.md': leafDoc({ essence: 'Суть А.' }), - 'b.md': leafDoc({ essence: 'Суть Б.' }), - 'index.md': HOST - } - const llm = { - /** - * @returns {Promise} завжди відмова транспорту - */ - generate() { - return Promise.reject(Object.assign(new Error('LLM недоступна'), { code: 'unavailable' })) - } - } - await expect( - runBuild(makeConfig(), { io: ioOver(files), llm, log: silentLog, today: '2026-07-12' }) - ).rejects.toMatchObject({ code: 'unavailable' }) - }) -}) - -describe('bootstrapEssences', () => { - test('додає чернетки лише leaf-докам без суті; ідемпотентний', async () => { - const files = { - 'a.md': leafDoc({ essence: null }), - 'b.md': leafDoc({ essence: 'Готова суть.' }) - } - const config = makeConfig() - const io = ioOver(files) - const first = await bootstrapEssences(config, { io, llm: stubLlm(), log: silentLog }) - expect(first.drafted).toEqual(['a.md']) - expect(first.skipped).toEqual(['b.md']) - const essence = extractEssence(parseDoc(files['a.md']).body) - expect(essence).toEqual({ text: 'Чернетка суті документа.', draft: true }) - // frontmatter leaf-доки збережений - expect(parseDoc(files['a.md']).fm.type).toBe('architecture') - - const second = await bootstrapEssences(config, { io, llm: stubLlm(), log: silentLog }) - expect(second.drafted).toEqual([]) - expect(second.skipped).toEqual(['a.md', 'b.md']) - }) -}) diff --git a/layers/lib/tests/build.test.mjs b/layers/lib/tests/build.test.mjs deleted file mode 100644 index 382ad96..0000000 --- a/layers/lib/tests/build.test.mjs +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, test } from 'vitest' - -import { currentCrcs, refreshFileCrcs } from '../build.mjs' -import { formatSourceLine, parseDoc, parseSourceLine } from '../md.mjs' -import { computeStatus } from '../status.mjs' -import { generatedDoc, leafDoc, memoryReader } from './helpers.mjs' - -const MARKER_SOURCES_RE = /sources: (.*?) -->/ - -/** - * Мінімальний конфіг: один L2 над двома leaf + fragment-хост L0. - * @returns {object} конфіг полігона - */ -function makeConfig() { - return { - docsDir: '/віртуальний', - tier: 'avg', - maxTokens: 4096, - i18n: { baseLang: 'uk', langs: [] }, - docs: { - 'overview/core.md': { layer: 'L2', title: 'Ядро', sources: ['a.md', 'b.md'] }, - 'index.md': { layer: 'L0', mode: 'fragment', sources: ['overview/core.md'] } - }, - leaves: ['a.md', 'b.md'] - } -} - -/** - * @param {Record} files мапа шлях → вміст (мутується writeFile) - * @returns {{readFile: (rel: string) => string | null, writeFile: (rel: string, text: string) => void}} io-пара поверх files - */ -function ioOver(files) { - return { - readFile: memoryReader(files), - /** - * @param {string} rel відносний шлях - * @param {string} text новий вміст - * @returns {void} - */ - writeFile(rel, text) { - files[rel] = text - } - } -} - -/** - * Стандартний свіжий полігон: два leaf, L2-дока, fragment-хост L0. - * @returns {Record} мапа шлях → вміст - */ -function freshFiles() { - const a = leafDoc({ essence: 'Суть А.' }) - const b = leafDoc({ essence: 'Суть Б.' }) - const core = generatedDoc({ layer: 'L2', title: 'Ядро', sources: { 'a.md': a, 'b.md': b } }) - const line = formatSourceLine({ file: 'overview/core.md', ...currentCrcs(core) }) - return { - 'a.md': a, - 'b.md': b, - 'overview/core.md': core, - 'index.md': `# Індекс\n\n\nРезюме.\n\n` - } -} - -describe('refreshFileCrcs', () => { - test('details-only: переписує лише fileCrc, дока стає fresh', () => { - const files = freshFiles() - files['a.md'] = leafDoc({ essence: 'Суть А.', details: 'Нові деталі.' }) - const updated = refreshFileCrcs(makeConfig(), ioOver(files)) - expect(updated).toEqual(['overview/core.md']) - const report = computeStatus(makeConfig(), memoryReader(files)) - expect(report.worst).toBe('fresh') - }) - - test('stale-джерело не чіпається — це робота для build', () => { - const files = freshFiles() - const before = parseDoc(files['overview/core.md']).fm.layers.sources - files['a.md'] = leafDoc({ essence: 'Інша суть.' }) - const updated = refreshFileCrcs(makeConfig(), ioOver(files)) - expect(updated).toEqual([]) - expect(parseDoc(files['overview/core.md']).fm.layers.sources).toEqual(before) - }) - - test('усе fresh → нічого не пишеться (ідемпотентність)', () => { - const files = freshFiles() - expect(refreshFileCrcs(makeConfig(), ioOver(files))).toEqual([]) - }) - - test('оновлення frontmatter доки не каскадить: її fileCrc рахується від тіла', () => { - const files = freshFiles() - files['a.md'] = leafDoc({ essence: 'Суть А.', details: 'Нові деталі.' }) - expect(refreshFileCrcs(makeConfig(), ioOver(files))).toEqual(['overview/core.md']) - // другий прохід: маркер index.md досі свіжий, бо тіло core не змінилось - expect(refreshFileCrcs(makeConfig(), ioOver(files))).toEqual([]) - }) - - test('fragment: дрейф тіла джерела оновлює маркер, вміст і решта файлу недоторкані', () => { - const files = freshFiles() - files['overview/core.md'] = files['overview/core.md'].replace('Текст.', 'Інший текст, суть та сама.') - const updated = refreshFileCrcs(makeConfig(), ioOver(files)) - expect(updated).toEqual(['index.md']) - expect(files['index.md']).toContain('Резюме.') - expect(files['index.md']).toContain('# Індекс') - const marker = files['index.md'].match(MARKER_SOURCES_RE)[1] - expect(parseSourceLine(marker).fileCrc).toBe(currentCrcs(files['overview/core.md']).fileCrc) - }) -}) diff --git a/layers/lib/tests/cli-llm.test.mjs b/layers/lib/tests/cli-llm.test.mjs deleted file mode 100644 index 27b0fcd..0000000 --- a/layers/lib/tests/cli-llm.test.mjs +++ /dev/null @@ -1,113 +0,0 @@ -import { writeFileSync } from 'node:fs' -import { join } from 'node:path' - -import { describe, expect, test } from 'vitest' - -import { main } from '../cli.mjs' -import { leafDoc, withTmpDir } from './helpers.mjs' - -const TITLE_RE = /почни з рядка "# (.+?)"/ - -/** - * Injected-транспорт для createLlm: складає валідний вихід із system-промпту. - * @param {{fail?: boolean}} [options] fail — імітувати недоступний транспорт - * @returns {{runOneShot: (request: object) => Promise}} impl для createLlm - */ -function transportStub({ fail = false } = {}) { - return { - /** - * @param {object} request запит one-shot - * @returns {Promise} відповідь стаба - */ - runOneShot(request) { - if (fail) return Promise.resolve({ error: 'ECONNREFUSED 127.0.0.1:8000' }) - const system = request.messages[0].content - const title = system.match(TITLE_RE)?.[1] - let content = 'Просте резюме без заголовків.' - if (title) content = `# ${title}\n\n## Суть\n\nСуть огляду.\n\n## Розгортка\n\nТекст.` - else if (system.includes('Сформулюй «суть»')) content = 'Чернетка суті.' - else if (system.includes('перекладач')) content = request.messages[1].content - return Promise.resolve({ content, model: 'stub/model' }) - } - } -} - -/** - * Мінімальний полігон: два leaf із сутями, один L2, мова en. - * @param {string} dir тимчасова тека - */ -function scaffold(dir) { - writeFileSync(join(dir, 'a.md'), leafDoc({ essence: 'Суть А.' })) - writeFileSync(join(dir, 'b.md'), leafDoc({ essence: 'Суть Б.' })) - writeFileSync( - join(dir, 'layers.json'), - JSON.stringify({ - version: 1, - i18n: { baseLang: 'uk', langs: ['en'] }, - docs: { 'core.md': { layer: 'L2', title: 'Ядро', sources: ['a.md', 'b.md'] } } - }) - ) -} - -/** @returns {{lines: string[], log: (text: string) => void}} колектор виводу */ -function collector() { - /** @type {string[]} */ - const lines = [] - return { - lines, - log: text => { - lines.push(text) - } - } -} - -describe('cli: LLM-команди', () => { - test('build → translate → status: полігон доходить до fresh, exit 0', async () => { - await withTmpDir(async dir => { - scaffold(dir) - const llmImpl = transportStub() - expect(await main(['build', dir], { ...collector(), llmImpl })).toBe(0) - expect(await main(['status', dir], collector())).toBe(1) // переклади ще missing - expect(await main(['translate', dir], { ...collector(), llmImpl })).toBe(0) - expect(await main(['status', dir], collector())).toBe(0) - }) - }) - - test('LLM недоступна → exit 3, файли не чіпаються', async () => { - await withTmpDir(async dir => { - scaffold(dir) - const out = collector() - expect(await main(['build', dir], { ...out, llmImpl: transportStub({ fail: true }) })).toBe(3) - expect(out.lines.join('\n')).toContain('не вдався') - expect(await main(['status', dir, '--json'], collector())).toBe(1) // core.md так і не збудований - }) - }) - - test('build створює вкладену теку призначення, якщо вона ще не існує', async () => { - await withTmpDir(async dir => { - writeFileSync(join(dir, 'a.md'), leafDoc({ essence: 'Суть А.' })) - writeFileSync(join(dir, 'b.md'), leafDoc({ essence: 'Суть Б.' })) - writeFileSync( - join(dir, 'layers.json'), - JSON.stringify({ - version: 1, - docs: { 'overview/core.md': { layer: 'L2', title: 'Ядро', sources: ['a.md', 'b.md'] } } - }) - ) - const result = await main(['build', dir], { ...collector(), llmImpl: transportStub() }) - expect(result).toBe(0) - }) - }) - - test('bootstrap: генерує чернетку суті для leaf без секції', async () => { - await withTmpDir(async dir => { - scaffold(dir) - writeFileSync(join(dir, 'a.md'), leafDoc({ essence: null })) - const out = collector() - expect(await main(['bootstrap', dir], { ...out, llmImpl: transportStub() })).toBe(0) - expect(out.lines.join('\n')).toContain('Чернеток: 1') - // чернетка блокує build до рев'ю - expect(await main(['status', dir], collector())).toBe(1) - }) - }) -}) diff --git a/layers/lib/tests/cli.test.mjs b/layers/lib/tests/cli.test.mjs deleted file mode 100644 index 870747f..0000000 --- a/layers/lib/tests/cli.test.mjs +++ /dev/null @@ -1,98 +0,0 @@ -import { mkdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -import { describe, expect, test } from 'vitest' - -import { main } from '../cli.mjs' -import { generatedDoc, leafDoc, withTmpDir } from './helpers.mjs' - -/** - * Розгортає мінімальний полігон: два leaf + конфіг з одним L2. - * @param {string} dir тека полігона - * @param {{withGenerated?: boolean}} [options] чи класти згенеровану L2-доку - * @returns {void} - */ -function scaffold(dir, { withGenerated = true } = {}) { - const a = leafDoc({ essence: 'Суть А.' }) - const b = leafDoc({ essence: 'Суть Б.' }) - writeFileSync(join(dir, 'a.md'), a) - writeFileSync(join(dir, 'b.md'), b) - mkdirSync(join(dir, 'overview'), { recursive: true }) - if (withGenerated) { - writeFileSync( - join(dir, 'overview/core.md'), - generatedDoc({ layer: 'L2', title: 'Ядро', sources: { 'a.md': a, 'b.md': b } }) - ) - } - writeFileSync( - join(dir, 'layers.json'), - JSON.stringify({ - version: 1, - docs: { 'overview/core.md': { layer: 'L2', title: 'Ядро', sources: ['a.md', 'b.md'] } } - }) - ) -} - -/** - * Збирає лог CLI у масив. - * @returns {{lines: string[], log: (text: string) => void}} акумулятор і колбек логування - */ -function collector() { - /** @type {string[]} */ - const lines = [] - return { - lines, - log: text => { - lines.push(text) - } - } -} - -describe('cli', () => { - test('status на свіжому полігоні → exit 0', async () => { - await withTmpDir(async dir => { - scaffold(dir) - const out = collector() - expect(await main(['status', dir], out)).toBe(0) - expect(out.lines.join('\n')).toContain('Підсумок: fresh') - }) - }) - - test('status: відсутня згенерована дока → exit 1, --json валідний', async () => { - await withTmpDir(async dir => { - scaffold(dir, { withGenerated: false }) - const out = collector() - expect(await main(['status', dir, '--json'], out)).toBe(1) - expect(JSON.parse(out.lines[0]).worst).toBe('stale') - }) - }) - - test('refresh після details-only дрейфу повертає полігон у fresh', async () => { - await withTmpDir(async dir => { - scaffold(dir) - writeFileSync(join(dir, 'a.md'), leafDoc({ essence: 'Суть А.', details: 'Дрейф деталей.' })) - const out = collector() - expect(await main(['refresh', dir], out)).toBe(0) - expect(out.lines.join('\n')).toContain('overview/core.md') - expect(await main(['status', dir], collector())).toBe(0) - }) - }) - - test('тека без layers.json → exit 2', async () => { - await withTmpDir(async dir => { - const out = collector() - expect(await main(['status', dir], out)).toBe(2) - expect(out.lines.join('\n')).toContain('не тека шарової документації') - }) - }) - - test('невідома команда → exit 2 з usage; без аргументів → usage, exit 0', async () => { - await withTmpDir(async dir => { - scaffold(dir) - expect(await main(['вигадка', dir], collector())).toBe(2) - const out = collector() - expect(await main([], out)).toBe(0) - expect(out.lines.join('\n')).toContain('Використання') - }) - }) -}) diff --git a/layers/lib/tests/crc.test.mjs b/layers/lib/tests/crc.test.mjs deleted file mode 100644 index ee83df5..0000000 --- a/layers/lib/tests/crc.test.mjs +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, test } from 'vitest' - -import { crc32hex, essenceCrc, fileCrc, normalizeEssence } from '../crc.mjs' - -const HEX8_RE = /^[0-9a-f]{8}$/ - -describe('crc32hex', () => { - test('порожній рядок → 00000000', () => { - expect(crc32hex('')).toBe('00000000') - }) - - test('класичний check-вектор "123456789" → cbf43926', () => { - expect(crc32hex('123456789')).toBe('cbf43926') - }) - - test('UTF-8: кирилиця хешується за байтами, звірка з node:zlib', async () => { - const { crc32 } = await import('node:zlib') - for (const sample of ['граф задач', 'a', 'Суть\nдеталі', '123456789']) { - expect(crc32hex(sample)).toBe(crc32(sample).toString(16).padStart(8, '0')) - } - }) - - test('завжди 8 hex lowercase', () => { - expect(crc32hex('a')).toMatch(HEX8_RE) - expect(crc32hex(' ')).toMatch(HEX8_RE) - }) -}) - -describe('normalizeEssence / essenceCrc', () => { - test('rewrap абзаців не змінює CRC', () => { - const one = 'Граф задач живе у git.\nКоординація — через CAS claims.' - const two = 'Граф задач живе у git. Координація — через CAS claims.' - expect(essenceCrc(one)).toBe(essenceCrc(two)) - }) - - test('CRLF, хвостові пробіли, порожні рядки не змінюють CRC', () => { - const base = 'Один канон.\nПереклади derived.' - expect(essenceCrc('Один канон.\r\n\r\n Переклади derived. \n')).toBe(essenceCrc(base)) - }) - - test('NFC-нормалізація: композитна і декомпонована "й" еквівалентні', () => { - expect(essenceCrc('його')).toBe(essenceCrc('його')) - }) - - test('зміна слова змінює CRC', () => { - expect(essenceCrc('Граф задач живе у git.')).not.toBe(essenceCrc('Граф задач живе у svn.')) - }) - - test('normalizeEssence колапсить будь-який whitespace до одного пробілу', () => { - expect(normalizeEssence(' a\t\tb\n\nc ')).toBe('a b c') - }) -}) - -describe('fileCrc', () => { - test('CRLF→LF нормалізується, решта байтів значуща', () => { - expect(fileCrc('a\r\nb')).toBe(fileCrc('a\nb')) - expect(fileCrc('a\nb')).not.toBe(fileCrc('a\nb ')) - }) -}) diff --git a/layers/lib/tests/docs/helpers.md b/layers/lib/tests/docs/helpers.md deleted file mode 100644 index ce27169..0000000 --- a/layers/lib/tests/docs/helpers.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -type: JS Module -title: helpers.mjs -resource: layers/lib/tests/helpers.mjs -docgen: - crc: 75f0050c - model: openai-codex/gpt-5.5 - score: 100 - issues: judge:inaccurate:0.99 - judgeModel: openai-codex/gpt-5.4-mini ---- - -## Огляд - -Файл надає read-only допоміжні засоби для тестових фікстур: `withTmpDir`, `leafDoc`, `generatedDoc` і `memoryReader`. Він потрібен, щоб тести відтворювано отримували ізольований тимчасовий контекст, готові тексти leaf- і generated-документів із маркерами повідомлень (test.mdc) та in-memory читання без запису у ФС чи БД. Помилки перехоплюються fail-safe і не виходять назовні. - -## Поведінка - -- `withTmpDir` створює ізольовану тимчасову теку для тестової фікстури за каноном `` і гарантовано прибирає її після виконання. -- `leafDoc` формує текст leaf-документа для тестового полігона з опційною суттю, деталями та draft-маркером. -- `generatedDoc` формує згенерований документ верхнього шару з актуальними контрольними мітками джерел. -- `memoryReader` надає in-memory читач файлів поверх переданого набору текстів і повертає порожній результат для відсутніх шляхів. - -## Публічний API - -- withTmpDir — створює ізольовану тимчасову теку для фікстур, прибирає її після виконання й не змінює робочу теку процесу. -- leafDoc — описує leaf-документацію полігона. -- generatedDoc — представляє згенеровану документацію верхнього шару з актуальними CRC вихідних файлів. -- memoryReader — читає вміст файлів із Map у памʼяті; підтримує сценарії з маркерами повідомлень (test.mdc). - -## Гарантії поведінки - -- Read-only: не виконує операцій запису (ФС/БД). -- Перехоплює помилки і не пропускає винятків назовні (fail-safe). diff --git a/layers/lib/tests/docs/index.md b/layers/lib/tests/docs/index.md deleted file mode 100644 index b78aaa0..0000000 --- a/layers/lib/tests/docs/index.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -type: Directory Index -title: layers/lib/tests -resource: layers/lib/tests/ ---- - -| Файл | Тип | -| ------------------------- | --------- | -| [helpers.mjs](helpers.md) | JS Module | diff --git a/layers/lib/tests/helpers.mjs b/layers/lib/tests/helpers.mjs deleted file mode 100644 index 43a24fe..0000000 --- a/layers/lib/tests/helpers.mjs +++ /dev/null @@ -1,61 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { currentCrcs } from '../build.mjs' -import { DRAFT_MARKER, formatSourceLine, serializeDoc } from '../md.mjs' - -/** - * Канон tmp-фікстур (test.mdc): ізольована тека, гарантоване прибирання, - * без process.chdir. - * @param {(dir: string) => Promise | void} fn тіло тесту, отримує шлях ізольованої теки - * @returns {Promise} завершується після прибирання теки - */ -export async function withTmpDir(fn) { - const dir = mkdtempSync(join(tmpdir(), 'layers-test-')) - try { - await fn(dir) - } finally { - rmSync(dir, { recursive: true, force: true }) - } -} - -/** - * Leaf-дока полігона. - * @param {{essence?: string | null, details?: string, draft?: boolean}} [options] суть (null — без секції), деталі, прапор чернетки - * @returns {string} вміст leaf-файлу - */ -export function leafDoc({ essence = 'Суть без змін.', details = 'Багато деталей.', draft = false } = {}) { - const draftLine = draft ? `${DRAFT_MARKER}\n` : '' - const essenceSection = essence === null ? '' : `## Суть\n\n${draftLine}${essence}\n\n` - return `---\ntype: architecture\ndescription: тест\ntimestamp: 2026-07-12\n---\n\n# Глава\n\n${essenceSection}## Деталі\n\n${details}\n` -} - -/** - * Згенерована дока верхнього шару з коректними (актуальними) CRC джерел. - * @param {{layer: string, title: string, sources: Record, essence?: string}} options шар, назва, вміст джерел і суть доки - * @returns {string} вміст згенерованої доки - */ -export function generatedDoc({ layer, title, sources, essence = 'Агрегована суть.' }) { - const lines = Object.entries(sources).map(([file, text]) => { - const { essenceCrc, fileCrc } = currentCrcs(text) - return formatSourceLine({ file, essenceCrc, fileCrc }) - }) - const fm = { - type: 'layered-doc', - layer, - title, - timestamp: '2026-07-12', - layers: { model: 'stub/model', sources: lines } - } - return serializeDoc(fm, `\n# ${title}\n\n## Суть\n\n${essence}\n\n## Розгортка\n\nТекст.\n`) -} - -/** - * In-memory readFile поверх Map. - * @param {Record} files мапа шлях → вміст - * @returns {(rel: string) => string | null} readFile-сумісна функція - */ -export function memoryReader(files) { - return rel => files[rel] ?? null -} diff --git a/layers/lib/tests/layers.test.mjs b/layers/lib/tests/layers.test.mjs deleted file mode 100644 index 7bd5ad1..0000000 --- a/layers/lib/tests/layers.test.mjs +++ /dev/null @@ -1,120 +0,0 @@ -import { writeFileSync } from 'node:fs' -import { join } from 'node:path' - -import { describe, expect, test } from 'vitest' - -import { langPath, layerNumber, loadConfig, topoOrder, translationScope } from '../layers.mjs' -import { withTmpDir } from './helpers.mjs' - -const VALID_CONFIG = { - version: 1, - tier: 'avg', - i18n: { baseLang: 'uk', langs: ['en'] }, - docs: { - 'overview/core.md': { layer: 'L2', title: 'Ядро', sources: ['a.md', 'b.md'] }, - 'overview/index.md': { layer: 'L1', title: 'Як це працює', sources: ['overview/core.md'] }, - 'index.md': { layer: 'L0', mode: 'fragment', sources: ['overview/index.md'] } - } -} - -const NO_CONFIG_RE = /не тека шарової документації/ -const WRONG_DIRECTION_RE = /не з нижчого шару/ -const SELF_REFERENCE_RE = /сам на себе/ -const FRAGMENT_AS_SOURCE_RE = /fragment-дока не може бути джерелом/ -const INVALID_LAYER_RE = /невалідна мітка шару[\s\S]*порожній список/ - -/** - * @param {string} dir тека полігона - * @param {object} config вміст layers.json - */ -function writeConfig(dir, config) { - writeFileSync(join(dir, 'layers.json'), JSON.stringify(config)) -} - -describe('loadConfig', () => { - test('валідний конфіг: leaves виводяться, дефолти застосовуються', async () => { - await withTmpDir(dir => { - writeConfig(dir, VALID_CONFIG) - const config = loadConfig(dir) - expect(config.leaves).toEqual(['a.md', 'b.md']) - expect(config.maxTokens).toBe(4096) - expect(config.i18n).toEqual({ baseLang: 'uk', langs: ['en'] }) - }) - }) - - test('нема layers.json → зрозуміла помилка', async () => { - await withTmpDir(dir => { - expect(() => loadConfig(dir)).toThrow(NO_CONFIG_RE) - }) - }) - - test('джерело з того самого шару → помилка напрямку', async () => { - await withTmpDir(dir => { - writeConfig(dir, { - docs: { - 'x.md': { layer: 'L1', sources: ['y.md'] }, - 'y.md': { layer: 'L1', sources: ['leaf.md'] } - } - }) - expect(() => loadConfig(dir)).toThrow(WRONG_DIRECTION_RE) - }) - }) - - test('самопосилання → помилка', async () => { - await withTmpDir(dir => { - writeConfig(dir, { docs: { 'x.md': { layer: 'L1', sources: ['x.md'] } } }) - expect(() => loadConfig(dir)).toThrow(SELF_REFERENCE_RE) - }) - }) - - test('fragment-дока як джерело іншої → помилка', async () => { - await withTmpDir(dir => { - writeConfig(dir, { - docs: { - 'index.md': { layer: 'L1', mode: 'fragment', sources: ['leaf.md'] }, - 'top.md': { layer: 'L0', sources: ['index.md'] } - } - }) - expect(() => loadConfig(dir)).toThrow(FRAGMENT_AS_SOURCE_RE) - }) - }) - - test('порожні sources та невалідна мітка шару → помилки', async () => { - await withTmpDir(dir => { - writeConfig(dir, { docs: { 'x.md': { layer: 'горішній', sources: [] } } }) - expect(() => loadConfig(dir)).toThrow(INVALID_LAYER_RE) - }) - }) -}) - -describe('утиліти топології', () => { - test('layerNumber парсить L, інше → NaN', () => { - expect(layerNumber('L2')).toBe(2) - expect(layerNumber('L0')).toBe(0) - expect(Number.isNaN(layerNumber('шар'))).toBe(true) - }) - - test('topoOrder — знизу вгору (L2 → L1 → L0)', async () => { - await withTmpDir(dir => { - writeConfig(dir, VALID_CONFIG) - expect(topoOrder(loadConfig(dir))).toEqual(['overview/core.md', 'overview/index.md', 'index.md']) - }) - }) - - test('translationScope = leaves + всі доки конфігу', async () => { - await withTmpDir(dir => { - writeConfig(dir, VALID_CONFIG) - expect(translationScope(loadConfig(dir))).toEqual([ - 'a.md', - 'b.md', - 'overview/core.md', - 'overview/index.md', - 'index.md' - ]) - }) - }) - - test('langPath додає суфікс мови перед .md', () => { - expect(langPath('architecture/graph.md', 'en')).toBe('architecture/graph.en.md') - }) -}) diff --git a/layers/lib/tests/llm.test.mjs b/layers/lib/tests/llm.test.mjs deleted file mode 100644 index 5ffb66e..0000000 --- a/layers/lib/tests/llm.test.mjs +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, test } from 'vitest' - -import { createLlm, LlmError } from '../llm.mjs' - -/** - * Стаб runOneShot зі сценарієм відповідей і журналом викликів. - * @param {Array<{content?: string, model?: string, error?: string}>} responses черга відповідей - * @returns {{calls: object[], impl: {runOneShot: (request: object) => Promise}}} журнал і injected-транспорт - */ -function stubTransport(responses) { - const calls = [] - return { - calls, - impl: { - /** - * @param {object} request запит one-shot - * @returns {Promise} відповідь за сценарієм - */ - runOneShot(request) { - calls.push(request) - return Promise.resolve(responses[Math.min(calls.length - 1, responses.length - 1)]) - } - } - } -} - -describe('createLlm.generate', () => { - test('успіх із першої спроби: повертає content і model', async () => { - const { calls, impl } = stubTransport([{ content: 'готово', model: 'stub/m1' }]) - const llm = await createLlm({ tier: 'avg', caller: 'test', impl }) - const result = await llm.generate({ system: 's', user: 'u' }) - expect(result).toEqual({ content: 'готово', model: 'stub/m1' }) - expect(calls).toHaveLength(1) - expect(calls[0].modelTier).toBe('avg') - expect(calls[0].caller).toBe('test') - }) - - test('невалідний вихід: ретрай тим самим tier, потім ескалація avg→max', async () => { - const { calls, impl } = stubTransport([ - { content: 'погано', model: 'stub/m1' }, - { content: 'знову погано', model: 'stub/m1' }, - { content: 'ВАЛІДНО', model: 'stub/m2' } - ]) - const llm = await createLlm({ tier: 'avg', caller: 'test', impl }) - const result = await llm.generate({ system: 's', user: 'u', validate: c => c === 'ВАЛІДНО' }) - expect(result.model).toBe('stub/m2') - expect(calls.map(call => call.modelTier)).toEqual(['avg', 'avg', 'max']) - }) - - test('усі спроби невалідні → LlmError code=output', async () => { - const { impl } = stubTransport([{ content: 'погано', model: 'stub/m1' }]) - const llm = await createLlm({ tier: 'max', caller: 'test', impl }) - await expect(llm.generate({ system: 's', user: 'u', validate: () => false })).rejects.toMatchObject({ - code: 'output' - }) - }) - - test('транспортна помилка → LlmError code=unavailable без ретраїв', async () => { - const { calls, impl } = stubTransport([{ error: 'ECONNREFUSED 127.0.0.1:8000' }]) - const llm = await createLlm({ tier: 'avg', caller: 'test', impl }) - await expect(llm.generate({ system: 's', user: 'u' })).rejects.toMatchObject({ code: 'unavailable' }) - expect(calls).toHaveLength(1) - }) - - test('chain: кожен виклик несе chain, end закриває з фактичним outcome', async () => { - const ended = [] - const { calls, impl } = stubTransport([{ content: 'ок', model: 'stub/m1' }]) - impl.startChain = () => ({ - /** @param {object} result результат ланцюжка */ - end(result) { - ended.push(result) - } - }) - const llm = await createLlm({ tier: 'avg', caller: 'test', chainKind: 'layers-test', chainUnit: 'x', impl }) - await llm.generate({ system: 's', user: 'u' }) - llm.end() - expect(calls[0].chain).toBeDefined() - expect(ended).toEqual([{ outcome: 'success' }]) - }) - - test('LlmError зберігає code і message', () => { - const error = new LlmError('unavailable', 'нема транспорту') - expect(error.code).toBe('unavailable') - expect(error.message).toContain('нема транспорту') - }) -}) diff --git a/layers/lib/tests/md.test.mjs b/layers/lib/tests/md.test.mjs deleted file mode 100644 index cb6dcce..0000000 --- a/layers/lib/tests/md.test.mjs +++ /dev/null @@ -1,224 +0,0 @@ -import { describe, expect, test } from 'vitest' - -import { - DRAFT_MARKER, - extractEssence, - extractFragment, - formatSourceLine, - insertEssenceDraft, - parseDoc, - parseSourceLine, - replaceFragment, - serializeDoc -} from '../md.mjs' - -const INVALID_SOURCE_LINE_RE = /Невалідний/ -const MARKER_NOT_FOUND_RE = /не знайдено/ - -const GENERATED_DOC = `--- -type: layered-doc -layer: L2 -title: 'Ядро: граф задач і координація через git' -description: 'Агрегований огляд графа і git-координації' -timestamp: 2026-07-12 -layers: - model: omlx/gemma-4-e2b-it-4bit - sources: - - architecture/overview.md e58c1f00 9f8e7d6c - - architecture/graph.md 41ab02de 77c3a1b9 ---- - -# Ядро - -## Суть - -Ядро — граф задач у git. - -## Розгортка - -Деталі. -` - -describe('parseDoc / serializeDoc', () => { - test('round-trip власної доки байт-у-байт', () => { - const { fm, body } = parseDoc(GENERATED_DOC) - expect(fm).not.toBeNull() - expect(serializeDoc(fm, body)).toBe(GENERATED_DOC) - }) - - test('розбирає вкладену мапу, список і лаповані значення', () => { - const { fm } = parseDoc(GENERATED_DOC) - expect(fm.layer).toBe('L2') - expect(fm.title).toBe('Ядро: граф задач і координація через git') - expect(fm.layers.model).toBe('omlx/gemma-4-e2b-it-4bit') - expect(fm.layers.sources).toEqual([ - 'architecture/overview.md e58c1f00 9f8e7d6c', - 'architecture/graph.md 41ab02de 77c3a1b9' - ]) - }) - - test('булеві значення парсяться і серіалізуються', () => { - const doc = serializeDoc({ authored: false, stale: true }, '\nтіло\n') - const { fm } = parseDoc(doc) - expect(fm.authored).toBe(false) - expect(fm.stale).toBe(true) - }) - - test('документ без frontmatter → fm: null, тіло без змін', () => { - const text = '# Просто markdown\n' - expect(parseDoc(text)).toEqual({ fm: null, body: text }) - }) - - test('незакритий frontmatter трактується як тіло', () => { - const text = '---\ntype: x\n# без закриття\n' - expect(parseDoc(text).fm).toBeNull() - }) - - test("апостроф в лапованому значенні екранується '' і відновлюється", () => { - const doc = serializeDoc({ title: "обʼєднання 'графа'" }, '\n') - expect(parseDoc(doc).fm.title).toBe("обʼєднання 'графа'") - }) - - test('description завжди лаповане, навіть без спецсимволів (конвенція репо)', () => { - const doc = serializeDoc({ description: 'Просте речення без ком і двокрапок' }, '\n') - expect(doc).toContain("description: 'Просте речення без ком і двокрапок'\n") - }) -}) - -const ARCHITECTURE_DOC = `--- -type: architecture -description: 'Акаунти і ключі пристроїв, relay та membership, ролі, три approval-гейти з Ed25519-підписами, push' -tags: [access, relay, membership, approvals, security] -timestamp: 2026-07-07 ---- - -# Люди, пристрої, доступ - -Текст. -` - -describe('parseDoc / serializeDoc: чужі доки з inline-масивами (формат B)', () => { - test('round-trip авторської доки з `tags: [...]` байт-у-байт', () => { - const { fm, body } = parseDoc(ARCHITECTURE_DOC) - expect(fm.tags).toEqual(['access', 'relay', 'membership', 'approvals', 'security']) - expect(serializeDoc(fm, body)).toBe(ARCHITECTURE_DOC) - }) - - test('inline-масив лишається справжнім масивом, не рядком', () => { - const { fm } = parseDoc(ARCHITECTURE_DOC) - expect(Array.isArray(fm.tags)).toBe(true) - expect(fm.tags).toHaveLength(5) - }) - - test('block-list (наш формат) і inline-масив (чужий формат) не плутаються', () => { - const { fm: generatedFm } = parseDoc(GENERATED_DOC) - expect(generatedFm.layers.sources).toEqual([ - 'architecture/overview.md e58c1f00 9f8e7d6c', - 'architecture/graph.md 41ab02de 77c3a1b9' - ]) - // block-list парситься без FLOW-прапора → серіалізується назад у block-list, не inline - const reserialized = serializeDoc(generatedFm, parseDoc(GENERATED_DOC).body) - expect(reserialized).toContain(' - architecture/overview.md e58c1f00 9f8e7d6c') - expect(reserialized).not.toContain('sources: [') - }) - - test('масив, побудований наново (без флагу), серіалізується як block-list', () => { - const doc = serializeDoc({ tags: ['a', 'b'] }, '\n') - expect(doc).toContain('tags:\n - a\n - b\n') - }) - - test('порожній inline-масив round-trip', () => { - const doc = '---\ntags: []\n---\n\nтіло\n' - const { fm, body } = parseDoc(doc) - expect(fm.tags).toEqual([]) - expect(serializeDoc(fm, body)).toBe(doc) - }) -}) - -describe('extractEssence', () => { - test('витягає текст суті без заголовка', () => { - const essence = extractEssence('# Док\n\n## Суть\n\nПерший рядок.\nДругий.\n\n## Далі\n\nІнше.\n') - expect(essence).toEqual({ text: 'Перший рядок.\nДругий.', draft: false }) - }) - - test('нема секції → null', () => { - expect(extractEssence('# Док\n\n## Огляд\n')).toBeNull() - }) - - test('draft-маркер розпізнається і вилучається з тексту', () => { - const essence = extractEssence(`# Док\n\n## Суть\n\n${DRAFT_MARKER}\nЧернетка суті.\n`) - expect(essence).toEqual({ text: 'Чернетка суті.', draft: true }) - }) - - test('секція в кінці файлу (без наступного ##)', () => { - expect(extractEssence('# Док\n\n## Суть\n\nОстання.\n').text).toBe('Остання.') - }) -}) - -describe('insertEssenceDraft', () => { - test('вставляє після H1 і вступного blockquote', () => { - const body = '# Заголовок\n\n> Вступ.\n\n## Перша секція\n\nТекст.\n' - const result = insertEssenceDraft(body, 'Суть доки.') - expect(result.indexOf('## Суть')).toBeGreaterThan(result.indexOf('> Вступ.')) - expect(result.indexOf('## Суть')).toBeLessThan(result.indexOf('## Перша секція')) - const essence = extractEssence(result) - expect(essence).toEqual({ text: 'Суть доки.', draft: true }) - }) - - test('без H1 — вставка на початок', () => { - const result = insertEssenceDraft('Просто текст.\n', 'Суть.') - expect(result.startsWith('## Суть')).toBe(true) - }) -}) - -describe('source-рядки', () => { - test('parse/format round-trip', () => { - const line = 'architecture/graph.md 41ab02de 77c3a1b9' - expect(formatSourceLine(parseSourceLine(line))).toBe(line) - }) - - test('невалідний рядок кидає помилку', () => { - expect(() => parseSourceLine('лише-шлях')).toThrow(INVALID_SOURCE_LINE_RE) - }) -}) - -describe('fragment-блоки', () => { - const TEXT = [ - '# Індекс', - '', - '', - 'Старе резюме.', - '', - '', - '## Решта', - '' - ].join('\n') - - test('extractFragment читає шар, джерела і вміст', () => { - const fragment = extractFragment(TEXT) - expect(fragment.layer).toBe('L0') - expect(fragment.sources).toEqual([{ file: 'overview/index.md', essenceCrc: 'e58c1f00', fileCrc: '9f8e7d6c' }]) - expect(fragment.inner).toBe('Старе резюме.') - }) - - test('replaceFragment оновлює маркер і вміст, не чіпаючи решту', () => { - const updated = replaceFragment(TEXT, { - layer: 'L0', - sources: [{ file: 'overview/index.md', essenceCrc: 'aaaaaaaa', fileCrc: 'bbbbbbbb' }], - content: 'Нове резюме.' - }) - expect(updated).toContain('overview/index.md aaaaaaaa bbbbbbbb') - expect(updated).toContain('Нове резюме.') - expect(updated).not.toContain('Старе резюме.') - expect(updated).toContain('# Індекс') - expect(updated).toContain('## Решта') - expect(extractFragment(updated).inner).toBe('Нове резюме.') - }) - - test('нема маркерів → extractFragment null, replaceFragment кидає', () => { - expect(extractFragment('# Без маркерів\n')).toBeNull() - expect(() => replaceFragment('# Без маркерів\n', { layer: 'L0', sources: [], content: 'x' })).toThrow( - MARKER_NOT_FOUND_RE - ) - }) -}) diff --git a/layers/lib/tests/status.test.mjs b/layers/lib/tests/status.test.mjs deleted file mode 100644 index 946cd17..0000000 --- a/layers/lib/tests/status.test.mjs +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, expect, test } from 'vitest' - -import { currentCrcs } from '../build.mjs' -import { formatSourceLine } from '../md.mjs' -import { computeStatus, renderStatus, statusExitCode } from '../status.mjs' -import { generatedDoc, leafDoc, memoryReader } from './helpers.mjs' - -const DETAILS_REFRESH_RE = /refresh/ -const NEEDS_BUILD_RE = /потрібен build/ -const MISSING_FROM_CONFIG_RE = /b\.md.*нема в конфігу/ -const MD_EXT_RE = /\.md$/ - -/** - * Мінімальний конфіг з одним L2 над двома leaf. - * @param {object} [docsOverride] заміна секції docs (дефолт — один L2-запис) - * @returns {object} конфіг полігона для computeStatus - */ -function makeConfig(docsOverride) { - return { - docsDir: '/віртуальний', - tier: 'avg', - maxTokens: 4096, - i18n: { baseLang: 'uk', langs: [] }, - docs: docsOverride ?? { - 'overview/core.md': { layer: 'L2', title: 'Ядро', sources: ['a.md', 'b.md'] } - }, - leaves: ['a.md', 'b.md'] - } -} - -/** - * Стандартна пара свіжих leaf + узгоджена L2-дока. - * @returns {Record} мапа шлях → вміст файлів полігона - */ -function freshFiles() { - const a = leafDoc({ essence: 'Суть А.' }) - const b = leafDoc({ essence: 'Суть Б.' }) - return { - 'a.md': a, - 'b.md': b, - 'overview/core.md': generatedDoc({ layer: 'L2', title: 'Ядро', sources: { 'a.md': a, 'b.md': b } }) - } -} - -/** - * @param {ReturnType} report звіт computeStatus - * @param {string} [file] шлях доки для пошуку - * @returns {object | undefined} запис доки зі звіту - */ -function docState(report, file = 'overview/core.md') { - return report.docs.find(doc => doc.file === file) -} - -/** - * Переклад-фікстура для `file`: узгоджений або розсинхронізований sourceFileCrc. - * @param {Record} files мапа шлях → вміст (для обчислення поточного CRC) - * @param {string} file шлях base-файлу перекладу - * @param {{authored?: boolean, crcOverride?: string}} [options] прапор authored і override CRC - * @returns {string} вміст файлу перекладу - */ -function translationOf(files, file, { authored = false, crcOverride } = {}) { - const crc = crcOverride ?? currentCrcs(files[file]).fileCrc - return `---\ntype: layered-translation\nsource: ${file}\nlang: en\nsourceFileCrc: ${crc}\nauthored: ${authored}\n---\n\n# Translated\n` -} - -describe('computeStatus: матриця станів джерела', () => { - test('усе збігається → fresh, exit 0', () => { - const report = computeStatus(makeConfig(), memoryReader(freshFiles())) - expect(docState(report).state).toBe('fresh') - expect(report.worst).toBe('fresh') - expect(statusExitCode(report, false)).toBe(0) - }) - - test('змінились лише деталі → details-only; exit 0, зі --strict → 1', () => { - const files = freshFiles() - files['a.md'] = leafDoc({ essence: 'Суть А.', details: 'Інші деталі, суть та сама.' }) - const report = computeStatus(makeConfig(), memoryReader(files)) - expect(docState(report).state).toBe('details-only') - expect(docState(report).sources.find(s => s.file === 'a.md').note).toMatch(DETAILS_REFRESH_RE) - expect(statusExitCode(report, false)).toBe(0) - expect(statusExitCode(report, true)).toBe(1) - }) - - test('змінилась суть → stale, exit 1', () => { - const files = freshFiles() - files['a.md'] = leafDoc({ essence: 'Зовсім нова суть.' }) - const report = computeStatus(makeConfig(), memoryReader(files)) - expect(docState(report).state).toBe('stale') - expect(statusExitCode(report, false)).toBe(1) - }) - - test('джерело зникло → stale', () => { - const files = freshFiles() - delete files['a.md'] - const report = computeStatus(makeConfig(), memoryReader(files)) - expect(docState(report).sources.find(s => s.file === 'a.md').state).toBe('stale') - }) - - test('джерело без «## Суть» → no-essence, exit 2', () => { - const files = freshFiles() - files['a.md'] = leafDoc({ essence: null }) - const report = computeStatus(makeConfig(), memoryReader(files)) - expect(docState(report).state).toBe('no-essence') - expect(statusExitCode(report, false)).toBe(2) - }) - - test('суть-чернетка → draft, exit 1', () => { - const files = freshFiles() - files['a.md'] = leafDoc({ essence: 'Суть А.', draft: true }) - const report = computeStatus(makeConfig(), memoryReader(files)) - expect(docState(report).state).toBe('draft') - expect(statusExitCode(report, false)).toBe(1) - }) - - test('доки верхнього шару ще нема → stale з причиною', () => { - const files = freshFiles() - delete files['overview/core.md'] - const report = computeStatus(makeConfig(), memoryReader(files)) - expect(docState(report).state).toBe('stale') - expect(docState(report).reasons.join(' ')).toMatch(NEEDS_BUILD_RE) - }) - - test('нове джерело в конфігу, не враховане в доці → stale', () => { - const files = freshFiles() - const a = files['a.md'] - files['overview/core.md'] = generatedDoc({ layer: 'L2', title: 'Ядро', sources: { 'a.md': a } }) - const report = computeStatus(makeConfig(), memoryReader(files)) - expect(docState(report).sources.find(s => s.file === 'b.md').state).toBe('stale') - }) - - test('у доці записане джерело, прибране з конфігу → stale з причиною', () => { - const files = freshFiles() - const config = makeConfig({ - 'overview/core.md': { layer: 'L2', title: 'Ядро', sources: ['a.md'] } - }) - config.leaves = ['a.md'] - const report = computeStatus(config, memoryReader(files)) - expect(docState(report).state).toBe('stale') - expect(docState(report).reasons.join(' ')).toMatch(MISSING_FROM_CONFIG_RE) - }) - - test('пріоритет: no-essence перекриває stale і details-only', () => { - const files = freshFiles() - files['a.md'] = leafDoc({ essence: null }) - files['b.md'] = leafDoc({ essence: 'Нова суть Б.' }) - const report = computeStatus(makeConfig(), memoryReader(files)) - expect(docState(report).state).toBe('no-essence') - }) -}) - -describe('computeStatus: fragment-доки', () => { - const FRAGMENT_CONFIG = { - ...makeConfig({ - 'overview/core.md': { layer: 'L2', title: 'Ядро', sources: ['a.md', 'b.md'] }, - 'index.md': { layer: 'L0', mode: 'fragment', sources: ['overview/core.md'] } - }), - leaves: ['a.md', 'b.md'] - } - - test('fragment зі свіжими CRC у маркері → fresh', () => { - const files = freshFiles() - const core = files['overview/core.md'] - const line = formatSourceLine({ file: 'overview/core.md', ...currentCrcs(core) }) - files['index.md'] = `# Індекс\n\n\nРезюме.\n\n` - const report = computeStatus(FRAGMENT_CONFIG, memoryReader(files)) - expect(docState(report, 'index.md').state).toBe('fresh') - }) - - test('fragment без маркерів → stale (джерело не враховане)', () => { - const files = freshFiles() - files['index.md'] = '# Індекс без маркерів\n' - const report = computeStatus(FRAGMENT_CONFIG, memoryReader(files)) - expect(docState(report, 'index.md').state).toBe('stale') - }) -}) - -describe('computeStatus: переклади', () => { - const I18N_CONFIG = { ...makeConfig(), i18n: { baseLang: 'uk', langs: ['en'] } } - - test('нема перекладу → missing, translationsPending, exit 1', () => { - const report = computeStatus(I18N_CONFIG, memoryReader(freshFiles())) - expect(report.translations.every(t => t.state === 'missing')).toBe(true) - expect(report.translationsPending).toBe(true) - expect(statusExitCode(report, false)).toBe(1) - }) - - test('переклади з актуальним sourceFileCrc → fresh, exit 0', () => { - const files = freshFiles() - for (const file of ['a.md', 'b.md', 'overview/core.md']) { - files[file.replace(MD_EXT_RE, '.en.md')] = translationOf(files, file) - } - const report = computeStatus(I18N_CONFIG, memoryReader(files)) - expect(report.translations.every(t => t.state === 'fresh')).toBe(true) - expect(statusExitCode(report, false)).toBe(0) - }) - - test('base змінився → переклад stale; authored при незмінному base → authored', () => { - const files = freshFiles() - files['a.en.md'] = translationOf(files, 'a.md', { crcOverride: 'deadbeef' }) - files['b.en.md'] = translationOf(files, 'b.md', { authored: true }) - files['overview/core.en.md'] = translationOf(files, 'overview/core.md') - const report = computeStatus(I18N_CONFIG, memoryReader(files)) - const byFile = Object.fromEntries(report.translations.map(t => [t.file, t.state])) - expect(byFile['a.en.md']).toBe('stale') - expect(byFile['b.en.md']).toBe('authored') - expect(byFile['overview/core.en.md']).toBe('fresh') - }) -}) - -describe('renderStatus', () => { - test('--json віддає повний звіт, exit code збігається', () => { - const report = computeStatus(makeConfig(), memoryReader(freshFiles())) - const { text, exitCode } = renderStatus(report, { json: true }) - expect(JSON.parse(text).worst).toBe('fresh') - expect(exitCode).toBe(0) - }) - - test('людиночитний звіт містить шар, стан і підсумок', () => { - const files = freshFiles() - files['a.md'] = leafDoc({ essence: 'Нова суть.' }) - const { text, exitCode } = renderStatus(computeStatus(makeConfig(), memoryReader(files))) - expect(text).toContain('[L2] overview/core.md — stale') - expect(text).toContain('суть джерела змінилась') - expect(text).toContain('Підсумок: stale') - expect(exitCode).toBe(1) - }) -}) diff --git a/layers/lib/tests/translate.test.mjs b/layers/lib/tests/translate.test.mjs deleted file mode 100644 index 882aaaf..0000000 --- a/layers/lib/tests/translate.test.mjs +++ /dev/null @@ -1,192 +0,0 @@ -import { describe, expect, test } from 'vitest' - -import { currentCrcs } from '../build.mjs' -import { parseDoc } from '../md.mjs' -import { rewriteLinks, runTranslate, splitBySections } from '../translate.mjs' -import { leafDoc, memoryReader } from './helpers.mjs' - -/** - * Тихий лог для прогонів у тестах. - * @param {string} text рядок прогресу - * @returns {string} той самий рядок (ігнорується) - */ -const silentLog = text => text - -/** - * Identity-стаб перекладача: повертає вхід без змін (структура збережена). - * @param {{transform?: (chunk: string) => string}} [options] трансформація «перекладу» - * @returns {{generate: (request: object) => Promise<{content: string, model: string}>, calls: object[]}} стаб - */ -function stubLlm({ transform } = {}) { - const calls = [] - return { - calls, - /** - * @param {object} request запит генерації - * @returns {Promise<{content: string, model: string}>} «переклад» фрагмента - */ - generate(request) { - calls.push(request) - const content = transform ? transform(request.user) : request.user - if (request.validate && !request.validate(content)) { - return Promise.reject(Object.assign(new Error('структура не збережена'), { code: 'output' })) - } - return Promise.resolve({ content, model: 'stub/translator' }) - } - } -} - -/** @returns {object} конфіг з одним L2 і мовою en */ -function makeConfig() { - return { - docsDir: '/віртуальний', - tier: 'avg', - maxTokens: 4096, - i18n: { baseLang: 'uk', langs: ['en'] }, - docs: { 'overview/core.md': { layer: 'L2', title: 'Ядро', sources: ['a.md', 'b.md'] } }, - leaves: ['a.md', 'b.md'] - } -} - -/** - * @param {Record} files віртуальна ФС - * @returns {{readFile: (rel: string) => string | null, writeFile: (rel: string, text: string) => void}} io-пара - */ -function ioOver(files) { - return { - readFile: memoryReader(files), - /** - * @param {string} rel шлях - * @param {string} text вміст - * @returns {void} - */ - writeFile(rel, text) { - files[rel] = text - } - } -} - -function freshFiles() { - return { - 'a.md': leafDoc({ - essence: 'Суть А.', - details: 'Деталі з [лінком на Б](b.md) і [зовнішнім](https://x.test/y.md).' - }), - 'b.md': leafDoc({ essence: 'Суть Б.' }) - } -} - -describe('runTranslate', () => { - test('генерує derived-переклади з правильним frontmatter і мовними лінками', async () => { - const files = freshFiles() - const result = await runTranslate(makeConfig(), { - io: ioOver(files), - llm: stubLlm(), - log: silentLog, - today: '2026-07-12' - }) - expect(result.written).toEqual(['a.en.md', 'b.en.md']) - const { fm, body } = parseDoc(files['a.en.md']) - expect(fm).toMatchObject({ - type: 'layered-translation', - source: 'a.md', - lang: 'en', - authored: false, - model: 'stub/translator' - }) - expect(fm.sourceFileCrc).toBe(currentCrcs(files['a.md']).fileCrc) - expect(body).toContain('](b.en.md)') // ціль у scope → мовний файл - expect(body).toContain('https://x.test/y.md') // зовнішній лінк недоторканий - }) - - test('свіжий переклад пропускається; зміна base → регенерація', async () => { - const files = freshFiles() - const io = ioOver(files) - const deps = { io, llm: stubLlm(), log: silentLog, today: '2026-07-12' } - await runTranslate(makeConfig(), deps) - const second = await runTranslate(makeConfig(), deps) - expect(second.written).toEqual([]) - expect(second.skipped).toEqual(['a.en.md', 'b.en.md']) - - files['a.md'] = leafDoc({ essence: 'Суть А.', details: 'Нові деталі.' }) - const third = await runTranslate(makeConfig(), deps) - expect(third.written).toEqual(['a.en.md']) - }) - - test('authored-переклад: при незмінному base не чіпається, при зміні — регенерація з попередженням', async () => { - const files = freshFiles() - const io = ioOver(files) - await runTranslate(makeConfig(), { io, llm: stubLlm(), log: silentLog, today: '2026-07-12' }) - files['b.en.md'] = files['b.en.md'].replace('authored: false', 'authored: true') - - const warnings = [] - const deps = { - io, - llm: stubLlm(), - log: text => { - warnings.push(text) - }, - today: '2026-07-12' - } - const untouched = await runTranslate(makeConfig(), deps) - expect(untouched.skipped).toContain('b.en.md') - - files['b.md'] = leafDoc({ essence: 'Суть Б.', details: 'База змінилась.' }) - const regenerated = await runTranslate(makeConfig(), deps) - expect(regenerated.written).toEqual(['b.en.md']) - expect(warnings.join('\n')).toContain('authored-переклад застарів') - }) - - test('переклад, що губить лінки, фейлиться валідацією і не пишеться', async () => { - const files = freshFiles() - const result = await runTranslate(makeConfig(), { - io: ioOver(files), - llm: stubLlm({ transform: chunk => chunk.replaceAll(/\]\([^)]*\)/g, '](зникло)') }), - only: 'a.md', - log: silentLog, - today: '2026-07-12' - }) - expect(result.failed.map(f => f.file)).toEqual(['a.en.md']) - expect(files['a.en.md']).toBeUndefined() - }) - - test('dry-run нічого не пише; --lang фільтрує мови', async () => { - const files = freshFiles() - const result = await runTranslate(makeConfig(), { - io: ioOver(files), - llm: stubLlm(), - dryRun: true, - lang: 'en', - log: silentLog, - today: '2026-07-12' - }) - expect(result.written).toEqual(['a.en.md', 'b.en.md']) - expect(files['a.en.md']).toBeUndefined() - }) -}) - -describe('splitBySections', () => { - test('коротке тіло — один фрагмент', () => { - expect(splitBySections('# X\n\nтекст\n')).toEqual(['# X\n\nтекст\n']) - }) - - test('довге тіло ділиться по межах H2, конкатенація відтворює оригінал', () => { - const section = `## Секція\n\n${'слово '.repeat(1200)}\n\n` - const body = `# Довга дока\n\n${section}${section}${section}` - const chunks = splitBySections(body) - expect(chunks.length).toBeGreaterThan(1) - expect(chunks.join('')).toBe(body) - for (const chunk of chunks.slice(1)) expect(chunk.startsWith('## ')).toBe(true) - }) -}) - -describe('rewriteLinks', () => { - test('відносні цілі у scope отримують мовний суфікс, якорі зберігаються', () => { - const scope = new Set(['a.md', 'architecture/graph.md']) - const text = 'Див. [A](../a.md#секція) і [G](graph.md), але не [чуже](other.md).' - const rewritten = rewriteLinks(text, 'architecture/overview.md', 'en', scope) - expect(rewritten).toContain('](../a.en.md#секція)') - expect(rewritten).toContain('](graph.en.md)') - expect(rewritten).toContain('](other.md)') - }) -}) diff --git a/layers/lib/translate.mjs b/layers/lib/translate.mjs deleted file mode 100644 index a1a0741..0000000 --- a/layers/lib/translate.mjs +++ /dev/null @@ -1,212 +0,0 @@ -/** @see ./docs/translate.md */ - -import { posix } from 'node:path' - -import { fileCrc } from './crc.mjs' -import { langPath, translationScope } from './layers.mjs' -import { parseDoc, serializeDoc } from './md.mjs' - -/** Рядки-маркери fragment-блоків рушія — у переклад не потрапляють. */ -const MARKER_LINE_RE = /^\s*$/ -const FENCE_RE = /^```/gm -const LINK_RE = /\]\(([^)\s]+)\)/g -/** Понад цей обсяг (символів) тіло перекладається почастинно за H2-секціями. */ -const CHUNK_LIMIT = 12000 - -/** - * Прибирає рядки fragment-маркерів, лишаючи їхній вміст. - * @param {string} body тіло base-документа - * @returns {string} тіло без службових маркерів - */ -function stripLayerMarkers(body) { - return body - .split('\n') - .filter(line => !MARKER_LINE_RE.test(line)) - .join('\n') -} - -/** - * Мультимножина цілей посилань — для перевірки, що переклад їх не загубив. - * @param {string} text markdown - * @returns {string} канонічний підпис цілей - */ -function linkSignature(text) { - return Array.from(text.matchAll(LINK_RE), match => match[1]) - .toSorted() - .join('\n') -} - -/** - * Структурна перевірка перекладу відносно оригіналу: кількість fence-рядків - * і цілі посилань мають збігтися (тексти лінків перекладаються, цілі — ні). - * @param {string} original фрагмент оригіналу - * @param {string} translated фрагмент перекладу - * @returns {boolean} true — структура збережена - */ -function structurePreserved(original, translated) { - const fencesEqual = (original.match(FENCE_RE) ?? []).length === (translated.match(FENCE_RE) ?? []).length - return fencesEqual && linkSignature(original) === linkSignature(translated) -} - -/** - * Ділить тіло на фрагменти по межах H2-секцій, кожен ≤ CHUNK_LIMIT - * (наскільки дозволяють секції); детермінована збірка — простий join. - * @param {string} body тіло документа - * @returns {string[]} фрагменти у вихідному порядку - */ -export function splitBySections(body) { - if (body.length <= CHUNK_LIMIT) return [body] - const starts = [0] - const lines = body.split('\n') - let offset = 0 - for (const line of lines) { - if (line.startsWith('## ') && offset > 0) starts.push(offset) - offset += line.length + 1 - } - const blocks = starts.map((start, index) => body.slice(start, starts[index + 1])) - const chunks = [] - let current = '' - for (const block of blocks) { - if (current && current.length + block.length > CHUNK_LIMIT) { - chunks.push(current) - current = '' - } - current += block - } - if (current) chunks.push(current) - return chunks -} - -/** - * Переписує відносні `.md`-посилання на мовні файли, якщо ціль — у scope перекладу. - * @param {string} text перекладений markdown - * @param {string} file шлях base-доки (для резолву відносних цілей) - * @param {string} lang код мови - * @param {Set} scope файли, що мають переклади - * @returns {string} текст із мовними посиланнями - */ -export function rewriteLinks(text, file, lang, scope) { - const dir = posix.dirname(file) - return text.replaceAll(LINK_RE, (whole, target) => { - const [path, anchor] = String(target).split('#') - if (!path?.endsWith('.md') || path.includes('://')) return whole - const resolved = posix.normalize(posix.join(dir, path)) - if (!scope.has(resolved)) return whole - const suffix = anchor ? `#${anchor}` : '' - return `](${langPath(path, lang)}${suffix})` - }) -} - -/** - * Derived-переклади всіх док scope: staleness за fileCrc base-версії - * (переклад віддзеркалює деталі, не лише суть). Authored-переклад - * не перезаписується, поки base не змінився (i18n.md). - * @param {object} config результат loadConfig - * @param {object} deps залежності прогону - * @param {{readFile: (rel: string) => string | null, writeFile: (rel: string, text: string) => void}} deps.io доступ до файлів - * @param {{generate: (request: object) => Promise<{content: string, model: string}>}} deps.llm клієнт createLlm - * @param {string} [deps.lang] обмежитись однією мовою - * @param {string} [deps.only] обмежитись однією докою - * @param {boolean} [deps.dryRun] лише показати кандидатів - * @param {(text: string) => void} deps.log прогрес - * @param {string} deps.today дата YYYY-MM-DD - * @returns {Promise<{written: string[], skipped: string[], failed: Array<{file: string, reason: string}>}>} підсумок - */ -export async function runTranslate(config, deps) { - const summary = { written: [], skipped: [], failed: [] } - const langs = deps.lang ? [deps.lang] : config.i18n.langs - const scope = new Set(translationScope(config)) - - for (const file of translationScope(config)) { - if (deps.only && file !== deps.only) continue - const base = deps.io.readFile(file) - if (base === null) continue - const { body } = parseDoc(base) - for (const code of langs) { - await translateTarget({ ...deps, file, body, baseCrc: fileCrc(body), code, scope, summary }) - } - } - return summary -} - -/** - * Обробляє одну пару «дока × мова»: skip свіжого, dry-run, генерація й запис. - * @param {object} task контекст пари - * @param {{readFile: (rel: string) => string | null, writeFile: (rel: string, text: string) => void}} task.io доступ до файлів - * @param {{generate: (request: object) => Promise<{content: string, model: string}>}} task.llm клієнт createLlm - * @param {string} task.file шлях base-доки - * @param {string} task.body тіло base-доки - * @param {string} task.baseCrc fileCrc base-тіла - * @param {string} task.code код мови - * @param {Set} task.scope файли scope перекладу - * @param {{written: string[], skipped: string[], failed: Array<{file: string, reason: string}>}} task.summary акумулятор підсумку - * @param {boolean} [task.dryRun] лише показати кандидатів - * @param {(text: string) => void} task.log прогрес - * @param {string} task.today дата YYYY-MM-DD - * @returns {Promise} результат — у task.summary - */ -async function translateTarget({ io, llm, file, body, baseCrc, code, scope, summary, dryRun = false, log, today }) { - const target = langPath(file, code) - const existing = io.readFile(target) - if (existing !== null) { - const { fm } = parseDoc(existing) - if (fm?.sourceFileCrc === baseCrc) { - summary.skipped.push(target) - return - } - if (fm?.authored === true) log(`⚠️ ${target}: authored-переклад застарів — перегенеровую (base змінився)`) - } - if (dryRun) { - log(`[dry-run] ${target} ← ${file}`) - summary.written.push(target) - return - } - try { - const { content, model } = await translateBody(stripLayerMarkers(body), code, llm) - const fm = { - type: 'layered-translation', - source: file, - lang: code, - sourceFileCrc: baseCrc, - authored: false, - translated: today, - model - } - io.writeFile(target, serializeDoc(fm, `\n${rewriteLinks(content, file, code, scope).trim()}\n`)) - summary.written.push(target) - log(`🌐 ${target} (${model})`) - } catch (error) { - if (error.code === 'unavailable') throw error - summary.failed.push({ file: target, reason: String(error.message ?? error) }) - } -} - -/** - * Contract-aware переклад тіла: почастинно за секціями, зі структурною валідацією. - * @param {string} body тіло base-доки без маркерів - * @param {string} lang код мови BCP-47 - * @param {{generate: (request: object) => Promise<{content: string, model: string}>}} llm клієнт createLlm - * @returns {Promise<{content: string, model: string}>} перекладене тіло і фактична модель - */ -async function translateBody(body, lang, llm) { - const system = [ - `Ти — технічний перекладач. Переклади markdown мовою з кодом BCP-47 '${lang}'.`, - 'Збережи структуру: заголовки тих самих рівнів, списки, таблиці, blockquote.', - 'НЕ перекладай і не змінюй: вміст code fences, inline-код у зворотних лапках,', - 'шляхи файлів, URL, цілі посилань у дужках (), frontmatter-ключі, ідентифікатори.', - 'Поверни ЛИШЕ перекладений markdown без пояснень і обгорток.' - ].join(' ') - const parts = [] - let model = '' - for (const chunk of splitBySections(body)) { - if (!chunk.trim()) continue - const result = await llm.generate({ - system, - user: chunk, - validate: translated => structurePreserved(chunk, translated) - }) - parts.push(result.content.trim()) - model = result.model - } - return { content: parts.join('\n\n'), model } -} diff --git a/layers/package.json b/layers/package.json deleted file mode 100644 index d85bebf..0000000 --- a/layers/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "@7n/layers", - "version": "0.2.0", - "private": true, - "description": "Рушій шарової документації: подвійний CRC (суть/файл), LLM-генерація верхніх шарів, derived-переклади", - "type": "module", - "engines": { - "node": ">=24", - "bun": ">=1.3" - }, - "main": "./lib/status.mjs", - "scripts": { - "test": "vitest run" - }, - "dependencies": { - "@7n/llm-lib": "^2.5.0" - }, - "optionalDependencies": { - "@earendil-works/pi-ai": "0.80.2" - } -} diff --git a/layers/schemas/layers.schema.json b/layers/schemas/layers.schema.json deleted file mode 100644 index cdeff41..0000000 --- a/layers/schemas/layers.schema.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Конфіг шарової документації @7n/layers (/layers.json)", - "type": "object", - "required": ["version", "docs"], - "additionalProperties": false, - "properties": { - "$schema": { "type": "string" }, - "version": { "const": 1 }, - "tier": { "enum": ["min", "avg", "max"] }, - "maxTokens": { "type": "integer", "minimum": 256 }, - "i18n": { - "type": "object", - "additionalProperties": false, - "properties": { - "baseLang": { "type": "string", "minLength": 2 }, - "langs": { "type": "array", "items": { "type": "string", "minLength": 2 } } - } - }, - "docs": { - "type": "object", - "additionalProperties": { - "type": "object", - "required": ["layer", "sources"], - "additionalProperties": false, - "properties": { - "layer": { "type": "string", "pattern": "^L\\d+$" }, - "title": { "type": "string" }, - "mode": { "enum": ["fragment"] }, - "tier": { "enum": ["min", "avg", "max"] }, - "sources": { "type": "array", "items": { "type": "string" }, "minItems": 1 } - } - } - } - } -} diff --git a/layers/stryker.config.mjs b/layers/stryker.config.mjs deleted file mode 100644 index 6635bef..0000000 --- a/layers/stryker.config.mjs +++ /dev/null @@ -1,14 +0,0 @@ -/** @type {import('@stryker-mutator/core').PartialStrykerOptions} */ -export default { - testRunner: 'vitest', - vitest: { configFile: 'vitest.config.mjs' }, - // perTest: Stryker запускає лише тести, що покривають мутовану лінію — головний приріст - // швидкості проти command runner (де треба було б ганяти ввесь test-suite на кожен мутант). - coverageAnalysis: 'perTest', - tempDirName: 'reports/stryker/.tmp', - reporters: ['json', 'clear-text'], - jsonReporter: { fileName: 'reports/stryker/mutation.json' }, - // incremental: зберігає результати між запусками, відновлює після краш/kill. - incremental: true, - incrementalFile: 'reports/stryker/incremental.json' -} diff --git a/layers/vitest.config.mjs b/layers/vitest.config.mjs deleted file mode 100644 index e32a546..0000000 --- a/layers/vitest.config.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - // Підхоплюються обидві основні розкладки: тести поряд із кодом (rule `test`-конвенція — - // у піддиректоріях `tests/`) і top-level integration suites у `/tests/`. - include: ['**/*.test.{js,mjs}', 'tests/**/*.test.{js,mjs}'], - // reports/stryker/.tmp/ містить sandbox-копії тестів від Stryker (incremental - // або aborted-runs); без exclude vitest run --coverage їх підхоплює і вони - // фейляться, бо запускаються поза реальним repo root. - exclude: ['**/node_modules/**', '**/dist/**', '**/reports/stryker/**'], - environment: 'node', - // `pool: 'forks'` — defense-in-depth ізоляція процесів між test-файлами. - // У default `pool: 'threads'` усі workers ділять один процес → паралельний - // `process.chdir(dir)` у тестовій фікстурі перехоплює cwd сусіда посеред - // FS- або `git`-операції. Канон тестів — `withTmpDir(async dir => ...)` (test.mdc). - pool: 'forks', - coverage: { provider: 'v8', reporter: ['lcov', 'text-summary'] } - } -}) diff --git a/mt/lint-fix-doc-files-check-f2f4fc1e/a.md b/mt/lint-fix-doc-files-check-f2f4fc1e/a.md deleted file mode 100644 index 2e598b7..0000000 --- a/mt/lint-fix-doc-files-check-f2f4fc1e/a.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-27T15:41:40+03:00 -model_tier: AVG -skills: - - bash - - write-files -interactive: false ---- - diff --git a/mt/lint-fix-doc-files-check-f2f4fc1e/task.md b/mt/lint-fix-doc-files-check-f2f4fc1e/task.md deleted file mode 100644 index 22f93bc..0000000 --- a/mt/lint-fix-doc-files-check-f2f4fc1e/task.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-27T09:04:53.060Z -budget_sec: 1800 -audit: required -hint: atomic ---- - -## Task - -Виправити порушення правила `doc-files` (concern `check`), які не закрила інлайн fix-драбина. - -## Done when - -- `doc-files` не повідомляє порушень у target-файлах (див. ## Check). - -## Check - -npx @7n/rules lint --no-fix --cwd ../.. doc-files - -## Inputs - -Target-файли: - -- `crates/mt-core/src/lib.rs` -- `crates/mt-core/src/worktree.rs` -- `crates/mt-napi/build.rs` -- `crates/mt-napi/src/context.rs` -- `crates/mt-napi/src/graph.rs` -- `crates/mt-napi/src/lib.rs` -- `crates/mt-napi/src/plan.rs` -- `crates/mt-napi/src/worktree.rs` -- `crates/mt/src/commands/doctor.rs` -- `crates/mt/src/commands/graph.rs` -- `crates/mt/src/commands/lifecycle.rs` -- `crates/mt/src/commands/mod.rs` -- `crates/mt/src/commands/plan.rs` -- `crates/mt/src/commands/run.rs` -- `crates/mt/src/commands/signal.rs` -- `crates/mt/src/commands/task.rs` -- `crates/mt/src/commands/worktree.rs` -- `crates/mt/src/context.rs` -- `crates/mt/src/main.rs` -- `crates/mt/src/output.rs` -- `crates/mt/tests/cli.rs` -- `crates/mt/tests/common/mod.rs` -- `npm/mt-napi/index.mjs` -- `npm/mt-napi/native.mjs` diff --git a/mt/lint-fix-js-eslint-04a66bfd/a.md b/mt/lint-fix-js-eslint-04a66bfd/a.md deleted file mode 100644 index 637d220..0000000 --- a/mt/lint-fix-js-eslint-04a66bfd/a.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-22T13:14:55+03:00 -model_tier: AVG -skills: - - bash - - write-files -interactive: false ---- - diff --git a/mt/lint-fix-js-eslint-04a66bfd/task.md b/mt/lint-fix-js-eslint-04a66bfd/task.md deleted file mode 100644 index c1c1358..0000000 --- a/mt/lint-fix-js-eslint-04a66bfd/task.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -schema_version: 1 -created_at: 2026-07-17T11:29:02.512Z -budget_sec: 1800 -audit: required -hint: atomic ---- - -## Task - -Виправити порушення правила `js` (concern `eslint`), які не закрила інлайн fix-драбина. - -## Done when - -- `js` не повідомляє порушень у target-файлах (див. ## Check). - -## Check - -npx @7n/rules lint --no-fix --cwd ../.. js - -## Inputs - -Target-файли: - -- `relay/lib/push.mjs` -- `relay/lib/signing.mjs` -- `relay/lib/tests/relay.test.mjs` -- `relay/lib/tests/server.test.mjs` diff --git a/package.json b/package.json index 65975b2..ed647f5 100644 --- a/package.json +++ b/package.json @@ -3,15 +3,13 @@ "version": "1.0.0", "private": true, "workspaces": [ - "relay", - "layers" + "relay" ], "type": "module", "scripts": { "test": "bunx --bun vitest run", "oxfmt": "oxfmt .", - "coverage": "npx @7n/rules lint test --no-fix", - "layers": "bun ./layers/lib/cli.mjs" + "coverage": "npx @7n/rules lint test --no-fix" }, "devDependencies": { "@7n/mt": "^0.30.8",