UNPKG

autosnippet

Version:

Extract code patterns into a knowledge base for AI coding assistants

338 lines (337 loc) 13.3 kB
/** * SourceRefReconciler — Recipe 来源引用健康检查 + 自动修复 * * 从 knowledge_entries.reasoning.sources 填充 recipe_source_refs 桥接表, * 验证路径存在性,检测 git rename,修复路径引用。 * * 状态机: * active — 文件存在,路径有效 * renamed — 文件已移动到 new_path,等待修复 * stale — 路径失效,无法自动修复 */ import { execFile } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { promisify } from 'node:util'; import Logger from '../../infrastructure/logging/Logger.js'; const execFileAsync = promisify(execFile); /* ────────────────────── Class ────────────────────── */ /** 默认跳过 24h 内已验证的条目 */ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; export class SourceRefReconciler { #projectRoot; #sourceRefRepo; #knowledgeRepo; #signalBus; #logger = Logger.getInstance(); #ttlMs; constructor(projectRoot, sourceRefRepo, knowledgeRepo, options) { this.#projectRoot = projectRoot; this.#sourceRefRepo = sourceRefRepo; this.#knowledgeRepo = knowledgeRepo; this.#signalBus = options?.signalBus ?? null; this.#ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS; } /** * 从 knowledge_entries.reasoning 填充 recipe_source_refs 表。 * 对已有条目验证路径存在性,更新 status。 */ async reconcile(opts) { const force = opts?.force ?? false; const report = { inserted: 0, active: 0, stale: 0, skipped: 0, recipesProcessed: 0, }; // 确保表可访问 if (!this.#sourceRefRepo.isAccessible()) { this.#logger.warn('SourceRefReconciler: recipe_source_refs table not accessible, skipping'); return report; } // 获取所有有 reasoning 的知识条目 const rows = await this.#knowledgeRepo.findAllIdAndReasoning(); const now = Date.now(); for (const row of rows) { let sources = []; try { const reasoning = JSON.parse(row.reasoning); sources = Array.isArray(reasoning.sources) ? reasoning.sources.filter((s) => typeof s === 'string' && s.length > 0) : []; } catch { continue; } if (sources.length === 0) { continue; } report.recipesProcessed++; for (const sourcePath of sources) { // 检查是否已有记录 const existing = this.#sourceRefRepo.findOne(row.id, sourcePath); if (existing && !force) { // TTL 检查:跳过近期已验证的条目 if (now - existing.verifiedAt < this.#ttlMs) { report.skipped++; if (existing.status === 'active') { report.active++; } else if (existing.status === 'stale') { report.stale++; } continue; } } // 验证路径存在性 const absPath = path.resolve(this.#projectRoot, sourcePath); const exists = fs.existsSync(absPath); if (existing) { // 更新已有记录 if (exists) { this.#sourceRefRepo.upsert({ recipeId: row.id, sourcePath, status: 'active', newPath: null, verifiedAt: now, }); report.active++; } else { this.#sourceRefRepo.upsert({ recipeId: row.id, sourcePath, status: 'stale', verifiedAt: now, }); report.stale++; } } else { // 新增记录 const status = exists ? 'active' : 'stale'; this.#sourceRefRepo.upsert({ recipeId: row.id, sourcePath, status, verifiedAt: now, }); report.inserted++; if (exists) { report.active++; } else { report.stale++; } } } } this.#logger.info('SourceRefReconciler: reconcile complete', { inserted: report.inserted, active: report.active, stale: report.stale, skipped: report.skipped, recipesProcessed: report.recipesProcessed, }); // 通过 SignalBus 发射信号 — 让 Governance 子系统感知 sourceRef 健康状况 if (this.#signalBus && report.stale > 0) { this.#emitStaleSignals(); } return report; } /** * 为每个有 stale sourceRef 的 Recipe 发射 quality 信号。 * KnowledgeMetabolism 订阅 quality → 触发完整治理周期。 */ #emitStaleSignals() { if (!this.#signalBus) { return; } try { const staleRecipes = this.#sourceRefRepo.getStaleCountsByRecipe(); for (const row of staleRecipes) { const staleRatio = row.staleCount / row.totalCount; this.#signalBus.send('quality', 'SourceRefReconciler', staleRatio, { target: row.recipeId, metadata: { reason: 'source_ref_stale', staleCount: row.staleCount, totalRefs: row.totalCount, }, }); } } catch { // 信号发射失败不影响主流程 } } /** * 对 stale 条目尝试 git rename 修复。 * 使用 execFile() 安全执行 git log(防止命令注入)。 */ async repairRenames() { const report = { renamed: 0, stillStale: 0 }; // 获取所有 stale 条目 const staleRows = this.#sourceRefRepo.findStale(); if (staleRows.length === 0) { return report; } // 获取 git rename 映射 const renameMap = await this.#getGitRenameMap(); const now = Date.now(); for (const row of staleRows) { const newPath = renameMap.get(row.sourcePath); if (newPath) { // 验证 newPath 存在 const absNewPath = path.resolve(this.#projectRoot, newPath); if (fs.existsSync(absNewPath)) { this.#sourceRefRepo.upsert({ recipeId: row.recipeId, sourcePath: row.sourcePath, status: 'renamed', newPath, verifiedAt: now, }); report.renamed++; continue; } } report.stillStale++; } if (report.renamed > 0) { this.#logger.info('SourceRefReconciler: rename repair complete', { renamed: report.renamed, stillStale: report.stillStale, }); // 修复成功 → 发射正向 quality 信号(value≈0 表示健康方向) if (this.#signalBus) { this.#signalBus.send('quality', 'SourceRefReconciler', 0.1, { metadata: { reason: 'source_ref_repaired', renamed: report.renamed, stillStale: report.stillStale, }, }); } } return report; } /** * 将 renamed 条目的 new_path 写回 Recipe .md 文件的 _reasoning.sources。 * 完成后 status → active。 */ async applyRepairs() { const report = { applied: 0, failed: 0 }; const renamedRows = this.#sourceRefRepo.findRenamed(); if (renamedRows.length === 0) { return report; } // 按 recipeId 分组 const byRecipe = new Map(); for (const row of renamedRows) { if (!byRecipe.has(row.recipeId)) { byRecipe.set(row.recipeId, []); } byRecipe.get(row.recipeId)?.push({ sourcePath: row.sourcePath, newPath: row.newPath }); } // 获取 recipe 的 sourceFile 以定位 .md 文件 const now = Date.now(); for (const [recipeId, renames] of byRecipe) { try { const entry = await this.#knowledgeRepo.findSourceFileAndReasoning(recipeId); if (!entry?.sourceFile || !entry.reasoning) { report.failed += renames.length; continue; } const mdPath = path.resolve(this.#projectRoot, entry.sourceFile); if (!fs.existsSync(mdPath)) { report.failed += renames.length; continue; } // 读取并修改 .md 文件中的 reasoning.sources const _content = fs.readFileSync(mdPath, 'utf8'); let reasoning; try { reasoning = JSON.parse(entry.reasoning); } catch { report.failed += renames.length; continue; } const sources = Array.isArray(reasoning.sources) ? [...reasoning.sources] : []; let modified = false; for (const rename of renames) { const idx = sources.indexOf(rename.sourcePath); if (idx >= 0) { sources[idx] = rename.newPath; modified = true; } } if (modified) { reasoning.sources = sources; // 更新 .md 文件中的 reasoning frontmatter // 查找 YAML frontmatter 中的 reasoning 并替换 const updatedReasoning = JSON.stringify(reasoning); // 更新 DB reasoning 列 await this.#knowledgeRepo.updateReasoning(recipeId, updatedReasoning, now); // 更新 recipe_source_refs 状态 for (const rename of renames) { this.#sourceRefRepo.replaceSourcePath(recipeId, rename.sourcePath, rename.newPath, now); } report.applied += renames.length; } else { report.failed += renames.length; } } catch (err) { this.#logger.warn('SourceRefReconciler: applyRepairs failed for recipe', { recipeId, error: err.message, }); report.failed += renames.length; } } if (report.applied > 0) { this.#logger.info('SourceRefReconciler: applyRepairs complete', report); } return report; } /* ═══ Private helpers ═══════════════════════════════ */ /** * 通过 git log 获取 rename 映射(旧路径 → 新路径) * 使用 execFile 防止命令注入 */ async #getGitRenameMap() { const renameMap = new Map(); try { const { stdout } = await execFileAsync('git', ['log', '--diff-filter=R', '--name-status', '--pretty=format:', '-n', '200'], { cwd: this.#projectRoot, timeout: 10000, maxBuffer: 1024 * 1024, }); // 解析 git log 输出: R100\told_path\tnew_path for (const line of stdout.split('\n')) { const trimmed = line.trim(); if (!trimmed.startsWith('R')) { continue; } const parts = trimmed.split('\t'); if (parts.length >= 3) { const oldPath = parts[1]; const newPath = parts[2]; if (oldPath && newPath) { renameMap.set(oldPath, newPath); } } } } catch { // git 不可用或不在 git 仓库中 — 跳过 rename 检测 this.#logger.debug('SourceRefReconciler: git rename detection unavailable'); } return renameMap; } }