/** * DevLoop · bugTracker * * bugs.ndjson 的 CRUD + 状态机(P-9)。 * - bugs.ndjson 是 append-only 流。一条 bug 的「当前态」= 该 id 的最新一行。 * - dedupKey = "{unit_id}:{test_name}"。open() 时同 key 已存在 open/fixing → 不重复,返旧 id。 * - transition() 走 P-9 状态机校验,非法转换抛错。 * * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-16 * docs/prd/newfunction/_spec/sdd-model.md · M-14 * docs/prd/newfunction/_spec/sdd-process.md · P-9 */ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const pathResolver = require('./path-resolver'); // 可选依赖:timelineLogger / sseBroker。LoopController 启动时 attach。 let timelineLogger = null; let sseBroker = null; function attachTimelineLogger(t) { timelineLogger = t; } function attachSseBroker(b) { sseBroker = b; } // ──────────────────────────────────────────────────────────────── // 状态机 // ──────────────────────────────────────────────────────────────── const VALID_TRANSITIONS = { open: ['fixing', 'wontfix', 'duplicate'], fixing: ['fixed', 'open'], fixed: ['verified', 'open'], verified: ['open'], wontfix: [], duplicate: [], }; function canTransition(from, to) { if (!VALID_TRANSITIONS[from]) return false; return VALID_TRANSITIONS[from].includes(to); } class InvalidBugTransitionError extends Error { constructor(from, to, bugId) { super(`Invalid bug transition for ${bugId}: ${from} → ${to}`); this.name = 'InvalidBugTransitionError'; this.from = from; this.to = to; this.bugId = bugId; } } // ──────────────────────────────────────────────────────────────── // 核心 // ──────────────────────────────────────────────────────────────── function dedupKey(bug) { const parts = [bug.unit_id || 'unknown', bug.test_name || bug.title || '']; return parts.join(':'); } function _genBugId(now = new Date()) { const ts = now.toISOString().replace(/[-:T.Z]/g, '').slice(0, 14); // YYYYMMDDHHMMSS const tag = crypto.randomBytes(2).toString('hex'); return `bug-${ts.slice(0,8)}-${ts.slice(8,12)}-${tag}`; } function _now() { return new Date().toISOString(); } function _logPath(pid) { return pathResolver.resolveBugsLog(pid); } function _appendLine(pid, obj) { const p = _logPath(pid); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.appendFileSync(p, JSON.stringify(obj) + '\n'); } /** 流式读全部行(小项目 OK;大项目可优化为 readline 流式) */ function _readAll(pid) { const p = _logPath(pid); if (!fs.existsSync(p)) return []; const raw = fs.readFileSync(p, 'utf8'); return raw.split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } /** * 按 bug id 聚合:最后一行 = 终态。 * @returns Map */ function _aggregateLatest(pid) { const lines = _readAll(pid); const map = new Map(); for (const line of lines) { if (!line.id) continue; map.set(line.id, line); } return map; } // ──────────────────────────────────────────────────────────────── // open / transition // ──────────────────────────────────────────────────────────────── /** * 开 bug。如 dedupKey 命中已存在 status=open|fixing 的 bug → 不重复,返旧 bug。 * * @param {string} pid * @param {object} bug - 必填: source, unit_id?, run_id?, test_name?, category, severity, title, detail, affected_units * @returns Bug (含 id) */ function open(pid, bug) { // dedup 检查 const key = dedupKey(bug); const all = _aggregateLatest(pid); for (const b of all.values()) { if ((b.status === 'open' || b.status === 'fixing') && dedupKey(b) === key) { return b; } } const id = _genBugId(); const now = _now(); const full = { id, discovered_at: now, source: bug.source || 'unit_test', unit_id: bug.unit_id, run_id: bug.run_id, test_name: bug.test_name, category: bug.category || 'unknown', severity: bug.severity || 'medium', title: (bug.title || '(no title)').slice(0, 120), detail: bug.detail || '', affected_units: bug.affected_units || (bug.unit_id ? [bug.unit_id] : []), status: 'open', status_history: [{ ts: now, from: '(none)', to: 'open', by: bug.by || 'tester', note: bug.note, }], fix_refs: [], }; _appendLine(pid, full); if (timelineLogger) { timelineLogger.append(pid, { source: bug.source || 'tester', kind: 'bug_opened', ref: id, summary: full.title, }); } if (sseBroker) { sseBroker.emit(pid, 'bug.opened', { id, category: full.category, severity: full.severity, title: full.title }); } return full; } /** * 状态转换。同步发 SSE + timeline。 */ function transition(pid, bugId, { to, by, note, fix_refs, duplicate_of } = {}) { if (!to) throw new Error('transition requires `to` field'); const map = _aggregateLatest(pid); const b = map.get(bugId); if (!b) throw new Error(`bug ${bugId} not found`); if (!canTransition(b.status, to)) { throw new InvalidBugTransitionError(b.status, to, bugId); } const now = _now(); // 复制一份 + 应用变更 const next = { ...b, status: to, status_history: [ ...(b.status_history || []), { ts: now, from: b.status, to, by: by || 'system', note }, ], fix_refs: fix_refs ? [...(b.fix_refs || []), ...fix_refs] : (b.fix_refs || []), ...(duplicate_of ? { duplicate_of } : {}), }; _appendLine(pid, next); if (timelineLogger) { timelineLogger.append(pid, { source: by || 'system', kind: to === 'open' ? 'bug_reopened' : 'bug_transitioned', ref: bugId, summary: `${b.status} → ${to}`, }); } if (sseBroker) { sseBroker.emit(pid, 'bug.transitioned', { id: bugId, from: b.status, to, by }); } return next; } // ──────────────────────────────────────────────────────────────── // 读 // ──────────────────────────────────────────────────────────────── /** * 列 bug(每个 id 的最新态)。可按 filter 过滤。 * @param {object} filter - { status?: string[], severity?: string[], unit_id?, since?: ISO date } */ function list(pid, filter = {}) { let out = Array.from(_aggregateLatest(pid).values()); if (filter.status?.length) { out = out.filter(b => filter.status.includes(b.status)); } if (filter.severity?.length) { out = out.filter(b => filter.severity.includes(b.severity)); } if (filter.unit_id) { out = out.filter(b => b.unit_id === filter.unit_id); } if (filter.since) { const t = new Date(filter.since).getTime(); out = out.filter(b => new Date(b.discovered_at).getTime() >= t); } return out; } function get(pid, bugId) { return _aggregateLatest(pid).get(bugId) || null; } /** 完整 status_history(所有 history 条目,含 open 时的初始条目) */ function history(pid, bugId) { const b = get(pid, bugId); if (!b) return []; return b.status_history || []; } function stats(pid) { const all = list(pid); const out = { open: 0, fixing: 0, fixed: 0, verified: 0, wontfix: 0, duplicate: 0, by_severity: { critical: 0, high: 0, medium: 0, low: 0 }, total: all.length, }; for (const b of all) { if (out[b.status] !== undefined) out[b.status]++; if (out.by_severity[b.severity] !== undefined) out.by_severity[b.severity]++; } return out; } module.exports = { // 写 open, transition, // 读 list, get, history, stats, // 工具 dedupKey, canTransition, VALID_TRANSITIONS, InvalidBugTransitionError, // 依赖注入 attachTimelineLogger, attachSseBroker, };