autosnippet
Version:
Extract code patterns into a knowledge base for AI coding assistants
338 lines (337 loc) • 13.3 kB
JavaScript
/**
* 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;
}
}