/** * DevLoop · docStore * * PRD / SDD / units.json / integration/spec.json 的读写、归档、校验。 * 所有 Agent 不直接 fs.writeFile 这些文件 — 必须走 docStore,确保: * 1. cheerio HTML 校验在写盘前跑(不合规拒收 → ValidationError) * 2. 旧版自动归档到 history/{kind}-rev-{N}/(或单文件 .json) * 3. 写完后自动调 changelogWriter.append(如 changelogWriter 已就位) * * 规格:docs/prd/newfunction/_spec/sdd-control.md · C-9 * docs/prd/newfunction/_spec/sdd-model.md · M-3 / M-4 / M-2 / M-6 * * 注意:prd / sdd 现在是多页 HTML 站点目录而非单文件。 * - writePRD(pid, files) 接受 [{relPath, content}],逐个校验逐个落盘 * - readPRD(pid, relPath) 单文件读 * - listPrdFiles(pid) 列文件 * 单文件写法(writePRDChapter)也保留为常用 helper。 */ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const cheerio = require('cheerio'); const pathResolver = require('./path-resolver'); // ──────────────────────────────────────────────────────────────── // Validation // ──────────────────────────────────────────────────────────────── class ValidationError extends Error { constructor(message, details = []) { super(message); this.name = 'ValidationError'; this.details = details; } } /** * 校验单页 PRD/SDD HTML 是否符合规范。 * - 必含顶级
且 data-rev 数字 * - 每个 section 必含

+

* - PRD 的 data-rev 单调递增(仅检查升序,跳号警告但不报错) * * 不强求 8 类信息覆盖(那是 PM/Architect 自检层的事,不在 DocStore 阻塞) * * @param {string} html * @param {'prd'|'sdd'} kind * @returns {{ ok: boolean, errors: string[] }} */ function validateHTML(html, kind) { const errors = []; if (typeof html !== 'string' || html.length === 0) { return { ok: false, errors: ['empty html'] }; } let $; try { $ = cheerio.load(html, { decodeEntities: false }); } catch (e) { return { ok: false, errors: [`cheerio parse failed: ${e.message}`] }; } const article = $(`article[data-doc="${kind}"]`); if (article.length === 0) { errors.push(`missing

`); return { ok: false, errors }; } if (article.length > 1) { errors.push(`multiple
elements`); } const articleRev = article.first().attr('data-rev'); if (!articleRev || !/^\d+$/.test(articleRev)) { errors.push(`article missing or invalid data-rev: "${articleRev}"`); } const sections = article.first().find('section'); if (sections.length === 0) { errors.push('article has no
'); } const revs = []; sections.each((i, el) => { const $sec = $(el); if ($sec.find('h3').length === 0) { errors.push(`section[${i}] missing

`); } if ($sec.find('p').length === 0) { errors.push(`section[${i}] missing

`); } const rev = $sec.attr('data-rev'); if (rev && /^\d+$/.test(rev)) revs.push(parseInt(rev, 10)); }); // section data-rev 单调递增检查(允许相等,因为可能多 section 同 rev) for (let i = 1; i < revs.length; i++) { if (revs[i] < revs[i - 1]) { errors.push(`data-rev not monotonic at section[${i}]: ${revs[i - 1]} → ${revs[i]}`); } } return { ok: errors.length === 0, errors }; } /** * 校验 units.json schema(M-2)+ DAG 无环 * @returns {{ ok, errors }} */ function validateUnitsSchema(json) { const errors = []; if (!json || typeof json !== 'object') { return { ok: false, errors: ['units json must be object'] }; } for (const k of ['project_id', 'units']) { if (!json[k]) errors.push(`missing field: ${k}`); } if (!Array.isArray(json.units)) { errors.push('units must be array'); return { ok: false, errors }; } const idPattern = /^[a-z]+(-[a-z]+)*-(be|fe|mw|db)$/; const validStatuses = ['pending', 'coding', 'testing', 'failed', 'passed', 'blocked']; const validTypes = ['be', 'fe', 'mw', 'db']; const seen = new Set(); for (const u of json.units) { if (!u.id) { errors.push('unit missing id'); continue; } if (!idPattern.test(u.id)) { errors.push(`unit id "${u.id}" violates naming pattern`); } if (seen.has(u.id)) { errors.push(`duplicate unit id: ${u.id}`); } seen.add(u.id); if (!validTypes.includes(u.type)) { errors.push(`unit ${u.id} invalid type: ${u.type}`); } if (!validStatuses.includes(u.status)) { errors.push(`unit ${u.id} invalid status: ${u.status}`); } if (!Array.isArray(u.depends_on)) { errors.push(`unit ${u.id} depends_on must be array`); } } // 引用完整性 + DAG 无环 const ids = new Set(json.units.map(u => u.id)); for (const u of json.units) { for (const dep of u.depends_on || []) { if (!ids.has(dep)) { errors.push(`unit ${u.id} depends_on unknown unit: ${dep}`); } } } if (errors.length === 0 && hasCycle(json.units)) { errors.push('units DAG has cycle'); } return { ok: errors.length === 0, errors }; } function hasCycle(units) { const idx = new Map(units.map((u, i) => [u.id, i])); const color = new Array(units.length).fill(0); // 0=white, 1=gray, 2=black let cycle = false; function visit(i) { if (cycle) return; if (color[i] === 2) return; if (color[i] === 1) { cycle = true; return; } color[i] = 1; for (const dep of units[i].depends_on || []) { if (idx.has(dep)) visit(idx.get(dep)); } color[i] = 2; } for (let i = 0; i < units.length && !cycle; i++) visit(i); return cycle; } // ──────────────────────────────────────────────────────────────── // PRD / SDD multi-file site // ──────────────────────────────────────────────────────────────── /** 列项目下 prd/ 站点内所有 html 章节相对路径 */ function listPrdFiles(pid) { return _listSiteHtmls(pathResolver.resolvePrdSite(pid)); } function listSddFiles(pid) { return _listSiteHtmls(pathResolver.resolveSddSite(pid)); } function _listSiteHtmls(siteRoot) { const out = []; if (!fs.existsSync(siteRoot)) return out; function walk(dir, prefix) { for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const rel = prefix ? `${prefix}/${e.name}` : e.name; const full = path.join(dir, e.name); if (e.isDirectory()) { if (e.name === 'assets') continue; // assets 不计入 chapter 列表 walk(full, rel); } else if (e.name.endsWith('.html')) { out.push(rel); } } } walk(siteRoot, ''); return out.sort(); } /** 读单个 PRD/SDD 章节 */ function readPrdChapter(pid, relPath) { const file = path.join(pathResolver.resolvePrdSite(pid), relPath); if (!fs.existsSync(file)) throw new Error(`PRD chapter not found: ${relPath}`); return fs.readFileSync(file, 'utf8'); } function readSddChapter(pid, relPath) { const file = path.join(pathResolver.resolveSddSite(pid), relPath); if (!fs.existsSync(file)) throw new Error(`SDD chapter not found: ${relPath}`); return fs.readFileSync(file, 'utf8'); } /** * 写一个 PRD chapter(HTML)。校验通过 + 旧版(如有)归档 + 落盘。 * @returns { rev: number, path: string } */ function writePrdChapter(pid, relPath, html, opts = {}) { return _writeChapter(pid, 'prd', relPath, html, opts); } function writeSddChapter(pid, relPath, html, opts = {}) { return _writeChapter(pid, 'sdd', relPath, html, opts); } function _writeChapter(pid, kind, relPath, html, opts) { const validation = validateHTML(html, kind); if (!validation.ok && !opts.skipValidation) { throw new ValidationError( `${kind}/${relPath} validation failed`, validation.errors); } const siteRoot = kind === 'prd' ? pathResolver.resolvePrdSite(pid) : pathResolver.resolveSddSite(pid); const target = path.join(siteRoot, relPath); fs.mkdirSync(path.dirname(target), { recursive: true }); // 归档旧版(如有) if (fs.existsSync(target)) { archiveFile(pid, kind, relPath, fs.readFileSync(target, 'utf8')); } fs.writeFileSync(target, html); // 解析 article data-rev 作为新 rev let rev = 1; try { const $ = cheerio.load(html); const r = $(`article[data-doc="${kind}"]`).first().attr('data-rev'); if (r && /^\d+$/.test(r)) rev = parseInt(r, 10); } catch { /* ignore */ } return { rev, path: target }; } /** 把旧文件归档到 history/{kind}-rev-{N}/{relPath} */ function archiveFile(pid, kind, relPath, oldContent) { // 取旧版的 rev(从 oldContent 解析 article data-rev) let oldRev = 0; try { const $ = cheerio.load(oldContent); const r = $(`article[data-doc="${kind}"]`).first().attr('data-rev'); if (r && /^\d+$/.test(r)) oldRev = parseInt(r, 10); } catch { /* ignore */ } const archiveDir = path.join( pathResolver.resolveHistoryDir(pid), `${kind}-rev-${oldRev || 'unknown'}` ); const archiveTarget = path.join(archiveDir, relPath); fs.mkdirSync(path.dirname(archiveTarget), { recursive: true }); fs.writeFileSync(archiveTarget, oldContent); } // ──────────────────────────────────────────────────────────────── // units.json / integration/spec.json // ──────────────────────────────────────────────────────────────── function readUnits(pid) { const p = pathResolver.resolveUnitsJson(pid); if (!fs.existsSync(p)) return null; return JSON.parse(fs.readFileSync(p, 'utf8')); } function writeUnits(pid, json, opts = {}) { const validation = validateUnitsSchema(json); if (!validation.ok && !opts.skipValidation) { throw new ValidationError('units.json validation failed', validation.errors); } const p = pathResolver.resolveUnitsJson(pid); // 归档旧版 if (fs.existsSync(p)) { const old = JSON.parse(fs.readFileSync(p, 'utf8')); const oldRev = old.sdd_rev || 'unknown'; const archivePath = path.join( pathResolver.resolveHistoryDir(pid), `units-rev-${oldRev}.json` ); fs.mkdirSync(path.dirname(archivePath), { recursive: true }); fs.writeFileSync(archivePath, JSON.stringify(old, null, 2)); } fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, JSON.stringify(json, null, 2)); return { rev: json.sdd_rev || 1, path: p }; } function readIntegrationSpec(pid) { const p = path.join(pathResolver.resolveIntegrationDir(pid), 'spec.json'); if (!fs.existsSync(p)) return null; return JSON.parse(fs.readFileSync(p, 'utf8')); } function writeIntegrationSpec(pid, json) { const p = path.join(pathResolver.resolveIntegrationDir(pid), 'spec.json'); fs.mkdirSync(path.dirname(p), { recursive: true }); // 校验 involves ⊆ units.json const units = readUnits(pid); if (units && Array.isArray(json.cases)) { const unitIds = new Set(units.units.map(u => u.id)); const errors = []; for (const c of json.cases) { for (const u of c.involves || []) { if (!unitIds.has(u)) { errors.push(`case ${c.id} involves unknown unit: ${u}`); } } } if (errors.length > 0) { throw new ValidationError('integration spec references unknown units', errors); } } fs.writeFileSync(p, JSON.stringify(json, null, 2)); return { path: p }; } // ──────────────────────────────────────────────────────────────── // Project meta + utilities // ──────────────────────────────────────────────────────────────── function readProjectMeta(pid) { const p = pathResolver.resolveProjectJson(pid); if (!fs.existsSync(p)) return null; return JSON.parse(fs.readFileSync(p, 'utf8')); } function writeProjectMeta(pid, meta) { const p = pathResolver.resolveProjectJson(pid); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, JSON.stringify(meta, null, 2)); return { path: p }; } /** * canonical JSON + sha256 截 16 hex 字符 * @param {object} contract * @returns {string} "sha256:..." */ function hashContract(contract) { if (!contract || typeof contract !== 'object') { throw new Error('hashContract: contract must be object'); } const body = contract.exports || contract; const normalized = canonical(body); const full = crypto.createHash('sha256').update(normalized).digest('hex'); return 'sha256:' + full.slice(0, 16); } function canonical(v) { if (v === null || typeof v !== 'object') return JSON.stringify(v); if (Array.isArray(v)) return '[' + v.map(canonical).join(',') + ']'; const keys = Object.keys(v).sort(); return '{' + keys.map(k => JSON.stringify(k) + ':' + canonical(v[k])).join(',') + '}'; } /** 验证某 Unit 的 contract_hash 与重算一致 */ function verifyContractHash(unit, contract) { const recomputed = hashContract(contract); return { ok: recomputed === unit.contract_hash, expected: unit.contract_hash, actual: recomputed, }; } module.exports = { // 校验 validateHTML, validateUnitsSchema, ValidationError, // PRD/SDD 多页站点 listPrdFiles, listSddFiles, readPrdChapter, readSddChapter, writePrdChapter, writeSddChapter, // units / integration readUnits, writeUnits, readIntegrationSpec, writeIntegrationSpec, // project meta readProjectMeta, writeProjectMeta, // utils hashContract, verifyContractHash, canonical, hasCycle, };