AI-аудитор: narrative от LLM, AuditLogger, SecretsGuard, PolicyEngine, RAG по проекту

П.1 — LLM-narrative: fetch_narrative_for_report в llm_planner, вызов из
analyze_project_cmd и run_batch; fallback на шаблон при отсутствии API.

П.2 — В промпт «Предложить исправления» добавлен приоритет по findings из отчёта.

П.3 — AuditLogger (audit_log.rs, /audit), SecretsGuard (secrets_guard.rs, /secrets),
PolicyEngine (policy_engine.rs, /policies): Rust-модули + страницы в навигации.

П.5 — RAG упрощённый: rag_query.rs, rag_query_cmd, страница «Вопрос по проекту» (/project-chat).

Документ: docs/ПЛАН_AI_АУДИТОР.md с планом и секцией «Реализовано».

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yuriy 2026-02-11 21:29:24 +03:00
parent de9c2660d5
commit 289b1b6dac
14 changed files with 1236 additions and 12 deletions

View File

@ -0,0 +1,129 @@
# Реалистичный план: PAPA YU как AI-аудитор проектов
**Цель:** не «ещё один Cursor», а специализированный AI-аудитор — уникальная ниша. План привязан к текущему коду и разбит на шаги с минимальным риском.
---
## Реализовано (текущая сборка)
- **П.1** — LLM-narrative: `fetch_narrative_for_report()` в `llm_planner.rs`, вызов из `analyze_project_cmd` и `run_batch`; fallback на шаблон при отсутствии API.
- **П.2**В промпт «Предложить исправления» добавлено явное указание учитывать findings из отчёта.
- **П.3****AuditLogger**: `audit_log.rs`, команды `audit_log_list_cmd`, запись при анализе и apply; страница «Журнал» (/audit). **SecretsGuard**: `secrets_guard.rs`, `scan_secrets_cmd`, страница «Секреты» (/secrets). **PolicyEngine**: `policy_engine.rs`, `get_policies_cmd`, `run_policy_check_cmd`, страница «Политики» (/policies).
- **П.5 (упрощённый)** — RAG: `commands/rag_query.rs`, `rag_query_cmd` (контекст из файлов проекта + вопрос → LLM), страница «Вопрос по проекту» (/project-chat).
---
## 1. LLM → осмысленный narrative вместо шаблонов
**Сейчас:** в `analyze_project.rs` функция `build_human_narrative()` собирает текст из шаблонов: «Я проанализировал проект X», «Это React+Vite», «Найдено проблем: N. Рекомендую: …».
**Цель:** отправлять в LLM структурированный контекст (путь, тип проекта, findings, actions, signals) и получать один короткий narrative (24 предложения) для отчёта и agent-sync.
**Шаги:**
| # | Задача | Где | Оценка |
|---|--------|-----|--------|
| 1.1 | Вынести сборку «llm_context для narrative» в отдельную функцию (path, project_type, findings, actions, signals → JSON или текст). | `analyze_project.rs` | малый |
| 1.2 | Добавить опциональный вызов LLM для narrative: если заданы `PAPAYU_LLM_*`, после построения отчёта вызвать API с этим контекстом и промптом «Напиши краткий вывод аудитора для разработчика (24 предложения)». | новый модуль `narrative_llm.rs` или в `llm_planner.rs` | малый |
| 1.3 | Подставить ответ LLM в `report.narrative` вместо `build_human_narrative()`. Fallback: при ошибке/отсутствии API — текущий шаблон. | `analyze_project.rs` | малый |
**Риск:** низкий. Не ломает существующий поток, только обогащает текст.
---
## 2. Код-генерация по findings → LLM, preview, apply
**Сейчас:** есть цепочка: анализ → отчёт (findings + actions) → «Предложить исправления» (LLM генерирует план действий) → preview (diff) → apply. Генерация «безопасных» действий без LLM — в `generate_actions_from_report` (эвристики: README, .gitignore, tests).
**Цель:** явно вести от findings к исправлениям: findings + контекст кода → LLM → actions → preview → apply.
**Шаги:**
| # | Задача | Где | Оценка |
|---|--------|-----|--------|
| 2.1 | В режиме «Предложить исправления» передавать в LLM не только goal/report, но и список findings с evidence (уже есть в `AnalyzeReport`). Промпт: «По этим находкам предложи конкретные правки (EDIT_FILE, CREATE_FILE)». | `llm_planner.rs`, конструирование prompt | малый |
| 2.2 | Опционально: кнопка «Исправить по finding» — по одному выбранному finding отправить в LLM контекст (файл/фрагмент) и получить один или несколько actions, затем preview. | UI: `Tasks.tsx` + новая command `generate_actions_for_finding` | средний |
| 2.3 | Оставить текущий preview/apply без изменений (уже показывают diff и применяют с проверкой). | — | — |
**Риск:** низкий. Расширяем существующий LLM-контур.
---
## 3. Оживить «заглушки»: PolicyEngine, SecretsGuard, AuditLogger
**Сейчас:** в другом проекте (papayu-main) упоминаются маршруты `/policies`, `/audit`, `/secrets` как заглушки. В papa-yu их нет — фокус на задачах и анализе.
**Цель:** ввести в papa-yu три модуля, привязанные к Rust-бэкенду, без переписывания всего приложения.
**Шаги:**
| # | Задача | Где | Оценка |
|---|--------|-----|--------|
| 3.1 | **AuditLogger** — логирование действий аудитора (анализ, apply, undo, отказ). Rust: команда `audit_log(event_type, payload)` + запись в файл `.papa-yu/audit.log` или SQLite. UI: страница «Журнал» — список последних событий (дата, тип, проект, результат). | `src-tauri/src/audit_log.rs`, `lib.rs`, новая страница `AuditLog.tsx`, маршрут `/audit` | средний |
| 3.2 | **SecretsGuard** — проверка проекта на типичные утечки (ключи в коде, .env в репо, хардкод паролей). Rust: сканирование выбранной папки (эвристики + опционально regex из конфига), возврат списка «подозрений» без хранения секретов. UI: страница «Секреты» — кнопка «Проверить», таблица файл/строка/тип. | `src-tauri/src/secrets_guard.rs`, команда `scan_secrets`, страница `SecretsGuard.tsx`, маршрут `/secrets` | средний |
| 3.3 | **PolicyEngine** — набор правил аудита (например «обязателен README», «запрещён коммит .env»). Rust: конфиг правил (JSON), проверка проекта по правилам, отчёт pass/fail. UI: страница «Политики» — список правил, результат последней проверки. | `src-tauri/src/policy_engine.rs`, команды `get_policies`, `run_policy_check`, страница `PolicyEngine.tsx`, маршрут `/policies` | средний |
**Риск:** средний. Новый код, но изолированные модули.
---
## 4. Deep analysis: парсинг кода, уязвимости, мёртвый код
**Сейчас:** анализ в `analyze_project.rs` — обход файловой системы, эвристики (наличие README, tests, .gitignore и т.д.), без разбора содержимого кода.
**Цель:** не только структура, но и парсинг кода (tree-sitter), поиск типичных уязвимостей, индикация мёртвого кода.
**Шаги:**
| # | Задача | Где | Оценка |
|---|--------|-----|--------|
| 4.1 | Подключить tree-sitter (Rust: `tree-sitter` crate) для одного языка-пилота (например JavaScript/TypeScript). Обход выбранных файлов, построение AST, подсчёт функций/классов, экспортов. | новый модуль `src-tauri/src/deep_analysis/` или `code_parser.rs` | большой |
| 4.2 | Добавить в отчёт «глубокие» findings: неиспользуемые экспорты (мёртвый код), простые паттерны риска (eval, innerHTML без санитизации — по правилам). | расширить `Finding`, вызов парсера из `analyze_project` или отдельной command | средний |
| 4.3 | Опционально: интеграция с Snyk Code / аналогом (уже есть `snyk_sync`) — показывать код-уязвимости рядом с нашими findings. | уже частично есть; доработать отображение в UI | малый |
**Риск:** большой для 4.1 (зависимость, поддержка языков). Рационально начать с одного языка и набора из 35 правил.
---
## 5. RAG по документации и коду: чат с контекстом проекта
**Сейчас:** domain notes, project_content, выбор папки/файлов. Чат «с агентом» в UI есть, но без RAG по загруженному проекту.
**Цель:** пользователь выбрал проект → задаёт вопросы в чате → агент отвечает с опорой на индекс кода/документации (RAG).
**Шаги:**
| # | Задача | Где | Оценка |
|---|--------|-----|--------|
| 5.1 | Индексация: по выбранному пути собрать «документы» (файлы с расширениями .md, .ts, .tsx, .rs и т.д.), разбить на чанки, опционально эмбеддинги (внешний API или локальная модель). Хранить индекс в `.papa-yu/rag_index` (SQLite + векторы или только текст для простого поиска). | новый модуль `src-tauri/src/rag/` (index, search), команды `rag_index_project`, `rag_query` | большой |
| 5.2 | Поиск по запросу: по тексту вопроса найти релевантные чанки (keyword или semantic), сформировать контекст для LLM. | `rag/query.rs` | средний |
| 5.3 | Чат: поле «Вопрос по проекту» → вызов `rag_query` + вызов LLM с контекстом → ответ в UI. Без переделки всего чата — один сценарий «вопрос по коду». | `Tasks.tsx` или отдельная вкладка «Вопросы по проекту», команда `chat_on_project` | средний |
**Риск:** большой для полного RAG с эмбеддингами. Упрощённый вариант: индексация без эмбеддингов, поиск по ключевым словам + контекст из найденных файлов в промпт — быстрее внедрить.
---
## Приоритеты и порядок
| Приоритет | Блок | Зачем первым |
|-----------|------|--------------|
| 1 | П.1 — LLM-narrative | Быстрый визуальный выигрыш, малый объём работ, не ломает поток. |
| 2 | П.2 — Findings → LLM → apply | Уже есть цепочка; усиление связи findings → исправления усиливает позицию «аудитор». |
| 3 | П.3 — AuditLogger | Минимальная «оживляющая» заглушка: всё что делается — логируется. Нужно для доверия и аудита. |
| 4 | П.3 — SecretsGuard | Сильно востребовано в аудите; эвристики без тяжёлых зависимостей. |
| 5 | П.3 — PolicyEngine | Завершает триаду «аудиторских» модулей. |
| 6 | П.5 — RAG упрощённый | Чат по проекту без полного RAG: индекс файлов + keyword-поиск + контекст в LLM. |
| 7 | П.4 — Deep analysis | Самый тяжёлый; после закрепления п.13 и 5 имеет смысл вводить tree-sitter по одному языку. |
| 8 | П.5 — RAG с эмбеддингами | При необходимости углубления чата по коду. |
---
## Кратко по файлам
- **Narrative от LLM:** `analyze_project.rs` (build_human_narrative → вызов LLM), новый хелпер в `llm_planner.rs` или `narrative_llm.rs`.
- **Findings → LLM:** `llm_planner.rs` (prompt), `generate_actions_from_report.rs` / новая command для одного finding.
- **Audit/Secrets/Policy:** новые модули в `src-tauri/src/`, новые страницы и маршруты в `src/App.tsx`.
- **Deep analysis:** новый модуль `code_parser` / `deep_analysis`, расширение `analyze_project` или отдельная command.
- **RAG:** новый модуль `rag/`, команды индексации и запроса, блок чата в UI.
Документ можно использовать как дорожную карту для спринтов и оценок.

View File

@ -0,0 +1,73 @@
//! Журнал аудита: запись событий (анализ, apply, undo) в файл.
//! Файл: app_data_dir/papa-yu/audit.log или project_path/.papa-yu/audit.log при указании пути проекта.
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEvent {
pub ts: String,
pub event_type: String,
pub project_path: Option<String>,
pub result: Option<String>,
pub details: Option<String>,
}
fn audit_file_path(base_dir: &Path) -> std::path::PathBuf {
base_dir.join("papa-yu").join("audit.log")
}
/// Записывает событие в audit.log в app_data_dir (глобальный лог приложения).
pub fn log_event(
app_audit_dir: &Path,
event_type: &str,
project_path: Option<&str>,
result: Option<&str>,
details: Option<&str>,
) -> Result<(), String> {
let file_path = audit_file_path(app_audit_dir);
if let Some(parent) = file_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&file_path)
.map_err(|e| format!("audit log open: {}", e))?;
let ts = Utc::now().to_rfc3339();
let line = serde_json::json!({
"ts": ts,
"event_type": event_type,
"project_path": project_path,
"result": result,
"details": details
});
writeln!(file, "{}", line).map_err(|e| format!("audit log write: {}", e))?;
file.flush().map_err(|e| format!("audit log flush: {}", e))?;
Ok(())
}
/// Читает последние N строк из audit.log. Возвращает события от новых к старым.
pub fn read_events(app_audit_dir: &Path, limit: usize) -> Vec<AuditEvent> {
let file_path = audit_file_path(app_audit_dir);
let content = match std::fs::read_to_string(&file_path) {
Ok(c) => c,
Err(_) => return vec![],
};
let lines: Vec<&str> = content.lines().rev().take(limit).collect();
let mut out = Vec::with_capacity(lines.len());
for line in lines.iter().rev() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(ev) = serde_json::from_str::<AuditEvent>(trimmed) {
out.push(ev);
}
}
out.reverse();
out
}

View File

@ -12,7 +12,7 @@
use crate::context;
use crate::memory;
use crate::types::{Action, ActionKind, AgentPlan};
use crate::types::{Action, ActionKind, AgentPlan, AnalyzeReport};
use jsonschema::JSONSchema;
use serde::Deserialize;
use sha2::{Digest, Sha256};
@ -516,6 +516,7 @@ fn build_prompt(
Путь проекта: {}
Цель пользователя: {}
{}
Важно: предложи исправления в первую очередь по находкам (findings) из отчёта каждая находка может быть закрыта одним или несколькими действиями (CREATE_FILE, PATCH_FILE и т.д.). В отчёте ниже есть массив findings с полями title, details, path используй их как приоритет.
Отчёт анализа (JSON):
{}
{}
@ -1180,6 +1181,91 @@ fn parse_plan_response(json_str: &str) -> Result<PlanParseResult, String> {
})
}
/// Контекст для генерации narrative: путь, находки, число действий (без вызова LLM).
pub fn build_narrative_context(report: &AnalyzeReport) -> String {
let mut parts = vec![
format!("Проект: {}", report.path),
format!("Находок: {}", report.findings.len()),
format!("Доступных исправлений: {}", report.actions.len()),
];
if !report.findings.is_empty() {
let list: Vec<String> = report
.findings
.iter()
.take(10)
.map(|f| format!("- {}: {}", f.title, f.details))
.collect();
parts.push("Находки:".to_string());
parts.push(list.join("\n"));
}
parts.join("\n")
}
/// Запрашивает у LLM краткий narrative (24 предложения) по отчёту. Без JSON — только текст.
/// Использует те же PAPAYU_LLM_* переменные. При ошибке или отсутствии конфига возвращает Err.
pub async fn fetch_narrative_for_report(report: &AnalyzeReport) -> Result<String, String> {
let api_url = std::env::var("PAPAYU_LLM_API_URL").map_err(|_| "PAPAYU_LLM_API_URL not set".to_string())?;
let api_url = api_url.trim();
if api_url.is_empty() {
return Err("PAPAYU_LLM_API_URL is empty".to_string());
}
let api_key = std::env::var("PAPAYU_LLM_API_KEY").ok();
let model = std::env::var("PAPAYU_LLM_MODEL").unwrap_or_else(|_| "gpt-4o-mini".to_string());
let timeout_sec = std::env::var("PAPAYU_LLM_TIMEOUT_SEC")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.unwrap_or(30);
let max_tokens = std::env::var("PAPAYU_LLM_MAX_TOKENS")
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(512);
let system = "Ты — аудитор проектов. Напиши краткий вывод для разработчика: 24 предложения по контексту ниже. Только текст, без заголовков и маркированных списков. Язык: русский.";
let user = build_narrative_context(report);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(timeout_sec))
.build()
.map_err(|e| format!("HTTP client: {}", e))?;
let body = ChatRequest {
model: model.trim().to_string(),
messages: vec![
ChatMessage { role: "system".to_string(), content: system.to_string() },
ChatMessage { role: "user".to_string(), content: user },
],
temperature: Some(0.3),
max_tokens: Some(max_tokens),
top_p: Some(1.0),
presence_penalty: Some(0.0),
frequency_penalty: Some(0.0),
response_format: None,
};
let mut req = client.post(api_url).json(&body);
if let Some(ref key) = api_key {
if !key.trim().is_empty() {
req = req.header("Authorization", format!("Bearer {}", key.trim()));
}
}
let resp = req.send().await.map_err(|e| format!("Request: {}", e))?;
let status = resp.status();
let text = resp.text().await.map_err(|e| format!("Response: {}", e))?;
if !status.is_success() {
return Err(format!("API {}: {}", status, text.chars().take(500).collect::<String>()));
}
let chat: ChatResponse = serde_json::from_str(&text).map_err(|e| format!("Response JSON: {}", e))?;
let content = chat
.choices
.as_ref()
.and_then(|c| c.first())
.and_then(|c| c.message.content.as_deref())
.unwrap_or("")
.trim();
if content.is_empty() {
return Err("Empty narrative from API".to_string());
}
Ok(content.to_string())
}
const MAX_CONTEXT_ROUNDS: u32 = 2;
/// Один запрос к LLM без repair/retry. Для мульти-провайдера: сбор планов от нескольких ИИ.

View File

@ -13,6 +13,7 @@ mod preview_actions;
mod project_content;
mod projects;
mod propose_actions;
mod rag_query;
mod redo_last;
mod run_batch;
mod settings_export;
@ -38,10 +39,12 @@ pub use projects::{
list_projects, list_sessions, set_project_settings,
};
pub use propose_actions::propose_actions;
pub use rag_query::chat_on_project;
pub use redo_last::redo_last;
pub use run_batch::run_batch;
pub use settings_export::{export_settings, import_settings};
pub use trends::{fetch_trends_recommendations, get_trends_recommendations};
pub use llm_planner::{fetch_narrative_for_report, is_llm_configured};
pub use undo_last::{get_undo_redo_state_cmd, undo_available, undo_last};
pub use undo_last_tx::undo_last_tx;
pub use undo_status::undo_status;

View File

@ -0,0 +1,103 @@
//! Упрощённый RAG: контекст из файлов проекта + вопрос → LLM. Без эмбеддингов, keyword-контекст.
use std::path::Path;
use super::project_content;
const RAG_CONTEXT_CHARS: usize = 80_000;
/// Собирает контекст по проекту и отправляет вопрос в LLM. Возвращает ответ или ошибку.
pub async fn chat_on_project(project_path: &Path, question: &str) -> Result<String, String> {
if !project_path.exists() || !project_path.is_dir() {
return Err("Папка проекта не найдена".to_string());
}
let context = project_content::get_project_content_for_llm(
project_path,
Some(RAG_CONTEXT_CHARS),
);
let api_url = std::env::var("PAPAYU_LLM_API_URL").map_err(|_| "PAPAYU_LLM_API_URL не задан".to_string())?;
let api_url = api_url.trim();
if api_url.is_empty() {
return Err("PAPAYU_LLM_API_URL пустой".to_string());
}
let api_key = std::env::var("PAPAYU_LLM_API_KEY").ok();
let model = std::env::var("PAPAYU_LLM_MODEL").unwrap_or_else(|_| "gpt-4o-mini".to_string());
let timeout_sec = std::env::var("PAPAYU_LLM_TIMEOUT_SEC")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.unwrap_or(60);
let max_tokens = std::env::var("PAPAYU_LLM_MAX_TOKENS")
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(4096);
let system = "Ты — ассистент по коду проекта. Отвечай кратко по контексту ниже. Если в контексте нет ответа — так и скажи. Язык ответа: русский.";
let user = format!(
"Контекст (файлы проекта):\n\n{}\n\nВопрос: {}",
context.chars().take(120_000).collect::<String>(),
question
);
#[derive(serde::Serialize)]
struct ChatMessage {
role: String,
content: String,
}
#[derive(serde::Serialize)]
struct ChatRequest {
model: String,
messages: Vec<ChatMessage>,
temperature: f32,
max_tokens: u32,
}
#[derive(serde::Deserialize)]
struct ChatChoice {
message: ChatMessageResponse,
}
#[derive(serde::Deserialize)]
struct ChatMessageResponse {
content: Option<String>,
}
#[derive(serde::Deserialize)]
struct ChatResponse {
choices: Option<Vec<ChatChoice>>,
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_sec))
.build()
.map_err(|e| format!("HTTP client: {}", e))?;
let body = ChatRequest {
model: model.trim().to_string(),
messages: vec![
ChatMessage { role: "system".to_string(), content: system.to_string() },
ChatMessage { role: "user".to_string(), content: user },
],
temperature: 0.3,
max_tokens,
};
let mut req = client.post(api_url).json(&body);
if let Some(ref key) = api_key {
if !key.trim().is_empty() {
req = req.header("Authorization", format!("Bearer {}", key.trim()));
}
}
let resp = req.send().await.map_err(|e| format!("Request: {}", e))?;
let status = resp.status();
let text = resp.text().await.map_err(|e| format!("Response: {}", e))?;
if !status.is_success() {
return Err(format!("API {}: {}", status, text.chars().take(300).collect::<String>()));
}
let chat: ChatResponse = serde_json::from_str(&text).map_err(|e| format!("JSON: {}", e))?;
let content = chat
.choices
.as_ref()
.and_then(|c| c.first())
.and_then(|c| c.message.content.as_deref())
.unwrap_or("")
.trim();
if content.is_empty() {
return Err("Пустой ответ от API".to_string());
}
Ok(content.to_string())
}

View File

@ -1,12 +1,14 @@
use std::path::Path;
use crate::agent_sync;
use crate::audit_log;
use crate::commands::get_project_profile::get_project_limits;
use crate::commands::{analyze_project, apply_actions, preview_actions};
use crate::commands::{analyze_project, apply_actions, fetch_narrative_for_report, preview_actions};
use crate::commands::is_llm_configured;
use crate::snyk_sync;
use crate::tx::get_undo_redo_state;
use crate::types::{BatchEvent, BatchPayload};
use tauri::AppHandle;
use tauri::{AppHandle, Manager};
pub async fn run_batch(app: AppHandle, payload: BatchPayload) -> Result<Vec<BatchEvent>, String> {
let mut events = Vec::new();
@ -17,14 +19,28 @@ pub async fn run_batch(app: AppHandle, payload: BatchPayload) -> Result<Vec<Batc
payload.paths.clone()
};
let report = analyze_project(paths.clone(), payload.attached_files.clone())
let mut report = analyze_project(paths.clone(), payload.attached_files.clone())
.map_err(|e| e.to_string())?;
if is_llm_configured() {
if let Ok(narrative) = fetch_narrative_for_report(&report).await {
report.narrative = narrative;
}
}
let snyk_findings = if snyk_sync::is_snyk_sync_enabled() {
snyk_sync::fetch_snyk_code_issues().await.ok()
} else {
None
};
agent_sync::write_agent_sync_if_enabled(&report, snyk_findings);
if let Ok(dir) = app.path().app_data_dir() {
let _ = audit_log::log_event(
&dir,
"analyze",
paths.first().map(String::as_str),
Some("ok"),
Some(&format!("findings={}", report.findings.len())),
);
}
events.push(BatchEvent {
kind: "report".to_string(),
report: Some(report.clone()),

View File

@ -1,5 +1,8 @@
mod agent_sync;
mod audit_log;
mod commands;
mod policy_engine;
mod secrets_guard;
mod snyk_sync;
mod context;
mod domain_notes;
@ -16,28 +19,44 @@ mod verify;
use commands::FolderLinks;
use commands::{
add_project, agentic_run, analyze_project, analyze_weekly_reports, append_session_event,
apply_actions, apply_actions_tx, apply_project_setting_cmd, export_settings,
fetch_trends_recommendations, generate_actions, generate_actions_from_report,
get_project_profile, get_project_settings, get_trends_recommendations, get_undo_redo_state_cmd,
import_settings, list_projects, list_sessions, load_folder_links, preview_actions,
propose_actions, redo_last, run_batch, save_folder_links, save_report_to_file,
set_project_settings, undo_available, undo_last, undo_last_tx, undo_status,
apply_actions, apply_actions_tx, apply_project_setting_cmd, chat_on_project, export_settings,
fetch_narrative_for_report, fetch_trends_recommendations, generate_actions,
generate_actions_from_report, get_project_profile, get_project_settings,
get_trends_recommendations, get_undo_redo_state_cmd, import_settings, list_projects,
list_sessions, load_folder_links, preview_actions, propose_actions, redo_last, run_batch,
save_folder_links, save_report_to_file, set_project_settings, undo_available, undo_last,
undo_last_tx, undo_status,
};
use tauri::Manager;
use types::{ApplyPayload, BatchPayload};
#[tauri::command]
async fn analyze_project_cmd(
app: tauri::AppHandle,
paths: Vec<String>,
attached_files: Option<Vec<String>>,
) -> Result<types::AnalyzeReport, String> {
let report = analyze_project(paths, attached_files)?;
let mut report = analyze_project(paths.clone(), attached_files)?;
if commands::is_llm_configured() {
if let Ok(narrative) = fetch_narrative_for_report(&report).await {
report.narrative = narrative;
}
}
let snyk_findings = if snyk_sync::is_snyk_sync_enabled() {
snyk_sync::fetch_snyk_code_issues().await.ok()
} else {
None
};
agent_sync::write_agent_sync_if_enabled(&report, snyk_findings);
if let Ok(dir) = app.path().app_data_dir() {
let _ = audit_log::log_event(
&dir,
"analyze",
paths.first().map(String::as_str),
Some("ok"),
Some(&format!("findings={}", report.findings.len())),
);
}
Ok(report)
}
@ -48,7 +67,17 @@ fn preview_actions_cmd(payload: ApplyPayload) -> Result<types::PreviewResult, St
#[tauri::command]
fn apply_actions_cmd(app: tauri::AppHandle, payload: ApplyPayload) -> types::ApplyResult {
apply_actions(app, payload)
let result = apply_actions(app.clone(), payload.clone());
if let Ok(dir) = app.path().app_data_dir() {
let _ = audit_log::log_event(
&dir,
"apply",
Some(&payload.root_path),
if result.ok { Some("ok") } else { Some("fail") },
result.error.as_deref(),
);
}
result
}
#[tauri::command]
@ -149,6 +178,37 @@ async fn distill_and_save_domain_note_cmd(
domain_notes::distill_and_save_note(path, &query, &answer_md, &sources_tuples, confidence).await
}
/// Журнал аудита: последние события.
#[tauri::command]
fn audit_log_list_cmd(app: tauri::AppHandle, limit: Option<usize>) -> Result<Vec<audit_log::AuditEvent>, String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
Ok(audit_log::read_events(&dir, limit.unwrap_or(100)))
}
/// Сканирование проекта на подозрительные секреты.
#[tauri::command]
fn scan_secrets_cmd(project_path: String) -> Vec<secrets_guard::SecretSuspicion> {
secrets_guard::scan_secrets(std::path::Path::new(&project_path))
}
/// Список правил политик.
#[tauri::command]
fn get_policies_cmd() -> Vec<policy_engine::PolicyRule> {
policy_engine::get_policies()
}
/// Проверка проекта по правилам.
#[tauri::command]
fn run_policy_check_cmd(project_path: String) -> Vec<policy_engine::PolicyCheckResult> {
policy_engine::run_policy_check(std::path::Path::new(&project_path))
}
/// RAG: вопрос по коду проекта (контекст из файлов + LLM).
#[tauri::command]
async fn rag_query_cmd(project_path: String, question: String) -> Result<String, String> {
chat_on_project(std::path::Path::new(&project_path), &question).await
}
/// Сохранить отчёт в docs/reports/weekly_YYYY-MM-DD.md.
#[tauri::command]
fn save_report_cmd(
@ -172,6 +232,11 @@ pub fn run() {
.plugin(tauri_plugin_process::init())
.invoke_handler(tauri::generate_handler![
analyze_project_cmd,
audit_log_list_cmd,
scan_secrets_cmd,
get_policies_cmd,
run_policy_check_cmd,
rag_query_cmd,
preview_actions_cmd,
apply_actions_cmd,
undo_last,

View File

@ -0,0 +1,112 @@
//! Движок политик: проверка проекта по правилам (README, .gitignore, .env не в репо и т.д.).
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyRule {
pub id: String,
pub name: String,
pub description: String,
pub check: String, // "file_exists" | "file_missing" | "no_env_in_repo"
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyCheckResult {
pub rule_id: String,
pub passed: bool,
pub message: String,
}
fn default_rules() -> Vec<PolicyRule> {
vec![
PolicyRule {
id: "readme".to_string(),
name: "README".to_string(),
description: "В корне должен быть README.md или аналог".to_string(),
check: "file_exists".to_string(),
},
PolicyRule {
id: "gitignore".to_string(),
name: ".gitignore".to_string(),
description: "Должен быть .gitignore".to_string(),
check: "file_exists".to_string(),
},
PolicyRule {
id: "no_env".to_string(),
name: ".env не в репо".to_string(),
description: ".env не должен коммититься (должен быть в .gitignore)".to_string(),
check: "no_env_in_repo".to_string(),
},
PolicyRule {
id: "tests".to_string(),
name: "Папка tests/".to_string(),
description: "Рекомендуется иметь tests/ или __tests__".to_string(),
check: "dir_exists".to_string(),
},
]
}
/// Возвращает список правил по умолчанию.
pub fn get_policies() -> Vec<PolicyRule> {
default_rules()
}
/// Запускает проверку проекта по правилам.
pub fn run_policy_check(project_path: &Path) -> Vec<PolicyCheckResult> {
let rules = get_policies();
let mut results = Vec::with_capacity(rules.len());
for rule in rules {
let (passed, message) = match rule.check.as_str() {
"file_exists" => {
let files = match rule.id.as_str() {
"readme" => ["README.md", "README.MD", "README.txt", "README"],
"gitignore" => [".gitignore", "", "", ""],
_ => continue,
};
let exists = files.iter().any(|f| !f.is_empty() && project_path.join(f).exists());
(
exists,
if exists {
"OK".to_string()
} else {
format!("Отсутствует: {}", rule.name)
},
)
}
"dir_exists" => {
let exists = project_path.join("tests").is_dir() || project_path.join("__tests__").is_dir();
(
exists,
if exists { "OK".to_string() } else { "Нет tests/ или __tests__".to_string() },
)
}
"no_env_in_repo" => {
let env_path = project_path.join(".env");
let gitignore = project_path.join(".gitignore");
let env_exists = env_path.is_file();
let ignored = if gitignore.is_file() {
std::fs::read_to_string(&gitignore).map_or(false, |c| c.lines().any(|l| l.trim() == ".env"))
} else {
false
};
let passed = !env_exists || ignored;
(
passed,
if passed {
"OK".to_string()
} else {
".env присутствует; добавьте .env в .gitignore".to_string()
},
)
}
_ => (false, "Неизвестное правило".to_string()),
};
results.push(PolicyCheckResult {
rule_id: rule.id.clone(),
passed,
message,
});
}
results
}

View File

@ -0,0 +1,115 @@
//! Сканирование проекта на типичные утечки: ключи в коде, .env в репо, хардкод паролей.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
const MAX_FILE_SIZE: usize = 256 * 1024; // 256 KB
const SKIP_DIRS: &[&str] = &["node_modules", "target", "dist", ".git", "build", ".next"];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretSuspicion {
pub path: String,
pub line: Option<u32>,
pub kind: String,
pub snippet: String,
}
fn is_skip_dir(name: &str) -> bool {
SKIP_DIRS.contains(&name)
}
fn check_content(path: &str, content: &str) -> Vec<SecretSuspicion> {
let mut out = Vec::new();
let _lower = content.to_lowercase();
let lines: Vec<&str> = content.lines().collect();
for (i, line) in lines.iter().enumerate() {
let line_lower = line.to_lowercase();
if line_lower.contains("api_key") && line_lower.contains("=") && !line_lower.contains("example") {
out.push(SecretSuspicion {
path: path.to_string(),
line: Some((i + 1) as u32),
kind: "api_key".to_string(),
snippet: line.trim().chars().take(80).collect::<String>(),
});
}
if line_lower.contains("password") && line_lower.contains("=") && !line_lower.contains("example") && !line_lower.contains("env.example") {
out.push(SecretSuspicion {
path: path.to_string(),
line: Some((i + 1) as u32),
kind: "password".to_string(),
snippet: line.trim().chars().take(80).collect::<String>(),
});
}
if line_lower.contains("secret") && line_lower.contains("=") && !line_lower.contains("example") {
out.push(SecretSuspicion {
path: path.to_string(),
line: Some((i + 1) as u32),
kind: "secret".to_string(),
snippet: line.trim().chars().take(80).collect::<String>(),
});
}
if (line_lower.contains("sk-") || line_lower.contains("ghp_") || line_lower.contains("xoxb-")) && line.len() > 10 {
out.push(SecretSuspicion {
path: path.to_string(),
line: Some((i + 1) as u32),
kind: "token_like".to_string(),
snippet: "[REDACTED]".to_string(),
});
}
}
if path.ends_with(".env") && !path.ends_with(".env.example") {
if !out.is_empty() {
return out;
}
out.push(SecretSuspicion {
path: path.to_string(),
line: None,
kind: "env_file".to_string(),
snippet: ".env не должен быть в репозитории; используйте .env.example".to_string(),
});
}
out
}
/// Сканирует директорию и возвращает список подозрений на утечку секретов.
pub fn scan_secrets(project_path: &Path) -> Vec<SecretSuspicion> {
let mut out = Vec::new();
let walker = walkdir::WalkDir::new(project_path)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
e.path().file_name().map_or(true, |n| {
!is_skip_dir(n.to_string_lossy().as_ref())
})
});
for entry in walker.filter_map(|e| e.ok()) {
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let allowed = matches!(
(ext, name),
("ts", _) | ("tsx", _) | ("js", _) | ("jsx", _) | ("rs", _) | ("py", _) | ("json", _)
| ("env", _) | ("yaml", _) | ("yml", _) | ("toml", _) | ("md", _)
) || name.starts_with(".env");
if !allowed {
continue;
}
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
if content.len() > MAX_FILE_SIZE {
continue;
}
let rel = path.strip_prefix(project_path).unwrap_or(path);
let rel_str = rel.to_string_lossy().to_string();
for s in check_content(&rel_str, &content) {
out.push(s);
}
}
out
}

View File

@ -3,6 +3,10 @@ import Tasks from "./pages/Tasks";
import Dashboard from "./pages/Dashboard";
import ProjectNotes from "./pages/ProjectNotes";
import Updates from "./pages/Updates";
import AuditLog from "./pages/AuditLog";
import SecretsGuard from "./pages/SecretsGuard";
import PolicyEngine from "./pages/PolicyEngine";
import ProjectChat from "./pages/ProjectChat";
import Finances from "./pages/Finances";
import Personnel from "./pages/Personnel";
@ -103,6 +107,66 @@ function Layout({ children }: { children: React.ReactNode }) {
>
Обновления
</NavLink>
<NavLink
to="/audit"
style={({ isActive }) => ({
padding: "10px 18px",
borderRadius: "999px",
fontWeight: 600,
fontSize: "14px",
textDecoration: "none",
color: isActive ? "#1e3a5f" : "rgba(255,255,255,0.9)",
background: isActive ? "#fff" : "rgba(255,255,255,0.15)",
transition: "background 0.2s ease, color 0.2s ease",
})}
>
Журнал
</NavLink>
<NavLink
to="/secrets"
style={({ isActive }) => ({
padding: "10px 18px",
borderRadius: "999px",
fontWeight: 600,
fontSize: "14px",
textDecoration: "none",
color: isActive ? "#1e3a5f" : "rgba(255,255,255,0.9)",
background: isActive ? "#fff" : "rgba(255,255,255,0.15)",
transition: "background 0.2s ease, color 0.2s ease",
})}
>
Секреты
</NavLink>
<NavLink
to="/policies"
style={({ isActive }) => ({
padding: "10px 18px",
borderRadius: "999px",
fontWeight: 600,
fontSize: "14px",
textDecoration: "none",
color: isActive ? "#1e3a5f" : "rgba(255,255,255,0.9)",
background: isActive ? "#fff" : "rgba(255,255,255,0.15)",
transition: "background 0.2s ease, color 0.2s ease",
})}
>
Политики
</NavLink>
<NavLink
to="/project-chat"
style={({ isActive }) => ({
padding: "10px 18px",
borderRadius: "999px",
fontWeight: 600,
fontSize: "14px",
textDecoration: "none",
color: isActive ? "#1e3a5f" : "rgba(255,255,255,0.9)",
background: isActive ? "#fff" : "rgba(255,255,255,0.15)",
transition: "background 0.2s ease, color 0.2s ease",
})}
>
Вопрос по проекту
</NavLink>
</nav>
</header>
<main style={{ flex: 1, padding: "24px", overflow: "visible", minHeight: 0 }}>{children}</main>
@ -119,6 +183,10 @@ export default function App() {
<Route path="/panel" element={<Dashboard />} />
<Route path="/notes" element={<ProjectNotes />} />
<Route path="/updates" element={<Updates />} />
<Route path="/audit" element={<AuditLog />} />
<Route path="/secrets" element={<SecretsGuard />} />
<Route path="/policies" element={<PolicyEngine />} />
<Route path="/project-chat" element={<ProjectChat />} />
<Route path="/finances" element={<Finances />} />
<Route path="/personnel" element={<Personnel />} />
</Routes>

92
src/pages/AuditLog.tsx Normal file
View File

@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
interface AuditEvent {
ts: string;
event_type: string;
project_path: string | null;
result: string | null;
details: string | null;
}
export default function AuditLog() {
const [events, setEvents] = useState<AuditEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = async () => {
setLoading(true);
setError(null);
try {
const list = await invoke<AuditEvent[]>("audit_log_list_cmd", { limit: 100 });
setEvents(list);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
return (
<div style={{ maxWidth: 900 }}>
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 16 }}>Журнал аудита</h1>
<p style={{ color: "var(--color-text-muted)", marginBottom: 24 }}>
События анализа и применения изменений. Лог хранится локально.
</p>
<button
type="button"
onClick={load}
disabled={loading}
style={{
padding: "8px 16px",
background: "var(--color-border)",
border: "none",
borderRadius: 8,
cursor: loading ? "not-allowed" : "pointer",
marginBottom: 16,
}}
>
Обновить
</button>
{error && <p style={{ color: "#dc2626", marginBottom: 16 }}>{error}</p>}
{events.length === 0 && !loading && (
<p style={{ color: "var(--color-text-muted)" }}>Событий пока нет.</p>
)}
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{events.map((ev, i) => (
<li
key={i}
style={{
padding: "12px 16px",
marginBottom: 8,
background: "var(--color-card)",
borderRadius: 8,
border: "1px solid var(--color-border)",
fontSize: 14,
}}
>
<span style={{ fontWeight: 600 }}>{ev.event_type}</span>
{ev.project_path && (
<span style={{ color: "var(--color-text-muted)", marginLeft: 8 }}>
{ev.project_path}
</span>
)}
{ev.result && (
<span style={{ marginLeft: 8, color: ev.result === "ok" ? "#16a34a" : "#dc2626" }}>
{ev.result}
</span>
)}
<div style={{ marginTop: 4, color: "var(--color-text-muted)", fontSize: 12 }}>
{ev.ts}
{ev.details && ` · ${ev.details}`}
</div>
</li>
))}
</ul>
</div>
);
}

139
src/pages/PolicyEngine.tsx Normal file
View File

@ -0,0 +1,139 @@
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
interface PolicyRule {
id: string;
name: string;
description: string;
check: string;
}
interface PolicyCheckResult {
rule_id: string;
passed: boolean;
message: string;
}
export default function PolicyEngine() {
const [projectPath, setProjectPath] = useState<string | null>(null);
const [rules, setRules] = useState<PolicyRule[]>([]);
const [results, setResults] = useState<PolicyCheckResult[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadPolicies = async () => {
try {
const list = await invoke<PolicyRule[]>("get_policies_cmd");
setRules(list);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const selectFolder = async () => {
const selected = await open({ directory: true });
if (selected) setProjectPath(selected);
};
const runCheck = async () => {
if (!projectPath) {
setError("Сначала выберите папку проекта");
return;
}
setLoading(true);
setError(null);
try {
const list = await invoke<PolicyCheckResult[]>("run_policy_check_cmd", {
projectPath,
});
setResults(list);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
};
useEffect(() => {
loadPolicies();
}, []);
return (
<div style={{ maxWidth: 700 }}>
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 16 }}>Движок политик</h1>
<p style={{ color: "var(--color-text-muted)", marginBottom: 24 }}>
Проверка проекта по правилам: README, .gitignore, .env не в репо, наличие tests/.
</p>
<div style={{ display: "flex", gap: 12, alignItems: "center", marginBottom: 24, flexWrap: "wrap" }}>
<button
type="button"
onClick={selectFolder}
style={{
padding: "10px 18px",
background: "var(--color-border)",
border: "none",
borderRadius: 8,
cursor: "pointer",
}}
>
Выбрать папку
</button>
{projectPath && (
<span style={{ fontSize: 14, color: "var(--color-text-muted)" }}>{projectPath}</span>
)}
<button
type="button"
onClick={runCheck}
disabled={loading || !projectPath}
style={{
padding: "10px 18px",
background: "#2563eb",
color: "#fff",
border: "none",
borderRadius: 8,
fontWeight: 600,
cursor: loading || !projectPath ? "not-allowed" : "pointer",
}}
>
{loading ? "Проверка…" : "Проверить"}
</button>
</div>
{error && <p style={{ color: "#dc2626", marginBottom: 16 }}>{error}</p>}
{results && (
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{results.map((r, i) => (
<li
key={i}
style={{
padding: "12px 16px",
marginBottom: 8,
background: "var(--color-card)",
borderRadius: 8,
border: "1px solid var(--color-border)",
display: "flex",
alignItems: "center",
gap: 12,
}}
>
<span
style={{
width: 24,
height: 24,
borderRadius: "50%",
background: r.passed ? "#16a34a" : "#dc2626",
flexShrink: 0,
}}
title={r.passed ? "Выполнено" : "Не выполнено"}
/>
<div>
<span style={{ fontWeight: 600 }}>{r.rule_id}</span>
<div style={{ fontSize: 14, color: "var(--color-text-muted)" }}>{r.message}</div>
</div>
</li>
))}
</ul>
)}
</div>
);
}

111
src/pages/ProjectChat.tsx Normal file
View File

@ -0,0 +1,111 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
export default function ProjectChat() {
const [projectPath, setProjectPath] = useState<string | null>(null);
const [question, setQuestion] = useState("");
const [answer, setAnswer] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectFolder = async () => {
const selected = await open({ directory: true });
if (selected) setProjectPath(selected);
};
const ask = async () => {
if (!projectPath || !question.trim()) {
setError("Выберите папку проекта и введите вопрос");
return;
}
setLoading(true);
setError(null);
setAnswer(null);
try {
const result = await invoke<string>("rag_query_cmd", {
projectPath,
question: question.trim(),
});
setAnswer(result);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
};
return (
<div style={{ maxWidth: 720 }}>
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 16 }}>Вопрос по проекту</h1>
<p style={{ color: "var(--color-text-muted)", marginBottom: 24 }}>
Задайте вопрос по коду ответ будет с учётом контекста файлов проекта (нужен PAPAYU_LLM_API_URL).
</p>
<div style={{ display: "flex", gap: 12, alignItems: "center", marginBottom: 16, flexWrap: "wrap" }}>
<button
type="button"
onClick={selectFolder}
style={{
padding: "10px 18px",
background: "var(--color-border)",
border: "none",
borderRadius: 8,
cursor: "pointer",
}}
>
Выбрать папку
</button>
{projectPath && (
<span style={{ fontSize: 14, color: "var(--color-text-muted)" }}>{projectPath}</span>
)}
</div>
<textarea
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="Например: где настраивается роутинг? или какие тесты есть для API?"
rows={3}
style={{
width: "100%",
padding: 12,
borderRadius: 8,
border: "1px solid var(--color-border)",
marginBottom: 12,
resize: "vertical",
fontFamily: "inherit",
}}
/>
<button
type="button"
onClick={ask}
disabled={loading || !projectPath}
style={{
padding: "10px 20px",
background: "#2563eb",
color: "#fff",
border: "none",
borderRadius: 8,
fontWeight: 600,
cursor: loading || !projectPath ? "not-allowed" : "pointer",
}}
>
{loading ? "Отправка…" : "Спросить"}
</button>
{error && <p style={{ marginTop: 16, color: "#dc2626", fontSize: 14 }}>{error}</p>}
{answer && (
<div
style={{
marginTop: 24,
padding: 16,
background: "var(--color-card)",
borderRadius: 12,
border: "1px solid var(--color-border)",
whiteSpace: "pre-wrap",
fontSize: 14,
}}
>
{answer}
</div>
)}
</div>
);
}

112
src/pages/SecretsGuard.tsx Normal file
View File

@ -0,0 +1,112 @@
import { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
interface SecretSuspicion {
path: string;
line: number | null;
kind: string;
snippet: string;
}
export default function SecretsGuard() {
const [projectPath, setProjectPath] = useState<string | null>(null);
const [list, setList] = useState<SecretSuspicion[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectFolder = async () => {
const selected = await open({ directory: true });
if (selected) setProjectPath(selected);
};
const runScan = async () => {
if (!projectPath) {
setError("Сначала выберите папку проекта");
return;
}
setLoading(true);
setError(null);
try {
const result = await invoke<SecretSuspicion[]>("scan_secrets_cmd", {
projectPath,
});
setList(result);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
};
return (
<div style={{ maxWidth: 800 }}>
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 16 }}>Защита секретов</h1>
<p style={{ color: "var(--color-text-muted)", marginBottom: 24 }}>
Сканирование проекта на типичные утечки: ключи в коде, .env в репо, подозрительные паттерны.
</p>
<div style={{ display: "flex", gap: 12, alignItems: "center", marginBottom: 24, flexWrap: "wrap" }}>
<button
type="button"
onClick={selectFolder}
style={{
padding: "10px 18px",
background: "var(--color-border)",
border: "none",
borderRadius: 8,
cursor: "pointer",
}}
>
Выбрать папку
</button>
{projectPath && (
<span style={{ fontSize: 14, color: "var(--color-text-muted)" }}>{projectPath}</span>
)}
<button
type="button"
onClick={runScan}
disabled={loading || !projectPath}
style={{
padding: "10px 18px",
background: "#2563eb",
color: "#fff",
border: "none",
borderRadius: 8,
fontWeight: 600,
cursor: loading || !projectPath ? "not-allowed" : "pointer",
}}
>
{loading ? "Сканирование…" : "Проверить"}
</button>
</div>
{error && <p style={{ color: "#dc2626", marginBottom: 16 }}>{error}</p>}
{list.length > 0 && (
<p style={{ marginBottom: 12, fontSize: 14 }}>
Найдено подозрений: <strong>{list.length}</strong>
</p>
)}
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{list.map((s, i) => (
<li
key={i}
style={{
padding: "12px 16px",
marginBottom: 8,
background: "var(--color-card)",
borderRadius: 8,
border: "1px solid var(--color-border)",
fontSize: 14,
}}
>
<span style={{ fontWeight: 600 }}>{s.path}</span>
{s.line != null && (
<span style={{ color: "var(--color-text-muted)", marginLeft: 8 }}>стр. {s.line}</span>
)}
<span style={{ marginLeft: 8, color: "#b45309" }}>{s.kind}</span>
<div style={{ marginTop: 6, fontSize: 13, fontFamily: "monospace" }}>{s.snippet}</div>
</li>
))}
</ul>
</div>
);
}