+
* - 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 `);
}
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,
};
`);
}
if ($sec.find('p').length === 0) {
errors.push(`section[${i}] missing