/**
* 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,
};