diff --git a/server/src/tools/devloop/_errors.js b/server/src/tools/devloop/_errors.js new file mode 100644 index 0000000..d82165d --- /dev/null +++ b/server/src/tools/devloop/_errors.js @@ -0,0 +1,41 @@ +/** + * DevLoop tool layer · 标准化错误类型 + * + * 用法: + * throw new ToolValidationError('summary', 'too short'); + * throw new ToolSandboxError('/abs/path', 'pm'); + * + * Claude CLI 看到 thrown error → 包装为 tool_result { is_error: true, content: msg } + */ + +class ToolValidationError extends Error { + constructor(field, reason) { + super(`${field}: ${reason}`); + this.name = 'ToolValidationError'; + this.field = field; + this.reason = reason; + } +} + +class ToolSandboxError extends Error { + constructor(p, agent) { + super(`Sandbox violation: ${agent} tried to write ${p}`); + this.name = 'ToolSandboxError'; + this.path = p; + this.agent = agent; + } +} + +class ToolMissingDependencyError extends Error { + constructor(dep) { + super(`Tool requires ${dep} in ctx (LoopController injection)`); + this.name = 'ToolMissingDependencyError'; + this.dep = dep; + } +} + +module.exports = { + ToolValidationError, + ToolSandboxError, + ToolMissingDependencyError, +}; diff --git a/server/src/tools/devloop/_sandbox.js b/server/src/tools/devloop/_sandbox.js new file mode 100644 index 0000000..f1d09f1 --- /dev/null +++ b/server/src/tools/devloop/_sandbox.js @@ -0,0 +1,60 @@ +/** + * Sandbox 边界校验 — Agent 写盘前的 path 检查。 + * + * 用法: + * const { assertWritable } = require('./_sandbox'); + * assertWritable(abs, allowList, ctx.agent); + * + * allowList 形式 (glob-like): + * "prd/**" → ${ctx.projectDir}/prd/** 任意子文件 + * "units/{unitId}/code/**" → ${ctx.unitDir}/code/** (unitId 自动替换) + * "agent-state/**" → ${ctx.projectDir}/agent-state/** 任意 + * "timeline.ndjson" → 精确文件 + */ + +const path = require('path'); +const { ToolSandboxError } = require('./_errors'); + +/** + * @param {string} abs - 待写绝对路径 + * @param {string[]} allowList - 允许写的相对 pattern (相对 ctx.projectDir 或 ctx.unitDir) + * @param {object} ctx - { agent, projectDir, unitDir? } + */ +function assertWritable(abs, allowList, ctx) { + const normalized = path.resolve(abs); + if (isWithinAllowList(normalized, allowList, ctx)) return; + throw new ToolSandboxError(normalized, ctx.agent || 'unknown'); +} + +function isWithinAllowList(abs, allowList, ctx) { + for (const pattern of allowList) { + if (matchesPattern(abs, pattern, ctx)) return true; + } + return false; +} + +function matchesPattern(abs, pattern, ctx) { + // 替换 {unitId} 等占位符 + const resolved = pattern + .replace('{unitId}', ctx.unit_id || '__no_unit__'); + + // 决定 base:含 units/{unitId}/ 的 pattern → 用 unitDir 的父目录(=projectDir/units/) + // 其他 → 用 projectDir + // 简化:统一用 projectDir 作为 base 拼接 pattern + const base = ctx.projectDir || ''; + const expected = path.join(base, resolved); + + // 处理 ** glob + if (expected.endsWith('/**')) { + const prefix = expected.slice(0, -3); // 去掉 "/**" + return abs === prefix || abs.startsWith(prefix + path.sep); + } + + return abs === expected; +} + +module.exports = { + assertWritable, + isWithinAllowList, + matchesPattern, +}; diff --git a/server/src/tools/devloop/bash-json-guard.js b/server/src/tools/devloop/bash-json-guard.js new file mode 100644 index 0000000..fab968d --- /dev/null +++ b/server/src/tools/devloop/bash-json-guard.js @@ -0,0 +1,53 @@ +/** + * DevLoop · Bash inline-JSON guard 中间件 + * + * 拦截 Coder/Tester/Integrator 通过 Bash 工具发送的inline JSON 命令 + * (CLAUDE.md L91-99 强约束)。挂到 Bash 工具的 preExecute 钩子里。 + * + * 反例(禁): curl -d '{"k":"v"}' http://... + * 正例: echo '{"k":"v"}' > /tmp/x.json && curl -d @/tmp/x.json http://... + * + * 规格: docs/prd/newfunction/_spec/arch-tool-registry.md · §⑥ + */ + +// 检测 `-d '{...}'` `-d "{...}"` 或 `--data '{...}'` `--data-raw '{...}'` +const INLINE_JSON_PATTERNS = [ + /-d\s+(['"])\s*\{[\s\S]*?\}\s*\1/, + /--data\s+(['"])\s*\{[\s\S]*?\}\s*\1/, + /--data-raw\s+(['"])\s*\{[\s\S]*?\}\s*\1/, + /--data-binary\s+(['"])\s*\{[\s\S]*?\}\s*\1/, +]; + +function jsonGuard(cmd) { + if (typeof cmd !== 'string') return { ok: true }; + for (const re of INLINE_JSON_PATTERNS) { + if (re.test(cmd)) { + return { + ok: false, + reason: 'inline JSON in Bash command is forbidden (see CLAUDE.md L91-99). ' + + 'Use: echo \'{"k":"v"}\' > /tmp/x.json && curl -d @/tmp/x.json', + matched_pattern: re.source, + }; + } + } + return { ok: true }; +} + +/** + * preExecute hook 注册形式(LemonBoss Bash 工具内部消费) + */ +const bashHooks = { + preExecute: [ + (cmd) => { + const r = jsonGuard(cmd); + if (!r.ok) { + const e = new Error(r.reason); + e.code = 'JSON_GUARD_BLOCK'; + e.matched_pattern = r.matched_pattern; + throw e; + } + }, + ], +}; + +module.exports = { jsonGuard, bashHooks, INLINE_JSON_PATTERNS }; diff --git a/server/src/tools/devloop/coder-tools.js b/server/src/tools/devloop/coder-tools.js new file mode 100644 index 0000000..9a62a92 --- /dev/null +++ b/server/src/tools/devloop/coder-tools.js @@ -0,0 +1,163 @@ +/** + * DevLoop · coder-tools (3 个) + * contract_bridge verify contract.json ↔ units.json hash 一致 + * sandbox_check 扫本 Unit 周围 mtime,确认没写出沙箱 + * append_coder_log 结构化追加 coder.log (强制 retry 模板字段) + * + * 规格: docs/prd/newfunction/_spec/agents-coder.md · §7 + */ + +const fs = require('fs'); +const path = require('path'); + +const pathResolver = require('../../../services/devloop/path-resolver'); +const docStore = require('../../../services/devloop/doc-store'); +const { ToolValidationError, ToolMissingDependencyError } = require('./_errors'); + +const contract_bridge = { + name: 'contract_bridge', + description: 'verify contract.json hash 与 units.json 中该 Unit 的 contract_hash 一致', + inputSchema: { + type: 'object', + required: ['unit_id'], + properties: { unit_id: { type: 'string' } }, + }, + async execute({ unit_id }, ctx) { + _req(ctx, ['pid']); + const unitDir = pathResolver.resolveUnitDir(ctx.pid, unit_id); + const contractPath = path.join(unitDir, 'contract.json'); + if (!fs.existsSync(contractPath)) { + return { ok: false, reason: 'contract.json missing' }; + } + const contract = JSON.parse(fs.readFileSync(contractPath, 'utf8')); + const units = docStore.readUnits(ctx.pid); + if (!units) return { ok: false, reason: 'units.json missing' }; + const unit = units.units.find(u => u.id === unit_id); + if (!unit) return { ok: false, reason: 'unit not in units.json' }; + + const recomputed = docStore.hashContract(contract); + if (contract.version !== recomputed) { + return { + ok: false, + reason: 'contract.version self-inconsistent', + claimed: contract.version, actual: recomputed, + }; + } + if (recomputed !== unit.contract_hash) { + return { + ok: false, + reason: 'contract does not match SDD-declared hash', + expected: unit.contract_hash, actual: recomputed, + hint: 'contract.json 与 SDD 不一致,改 code 让契约对齐 SDD', + }; + } + return { ok: true, hash: recomputed }; + }, +}; + +const sandbox_check = { + name: 'sandbox_check', + description: '扫本 Unit 沙箱外的文件 mtime 变化,确认 Coder 没写出去', + inputSchema: { + type: 'object', + required: ['unit_id', 'since'], + properties: { + unit_id: { type: 'string' }, + since: { type: 'number', description: 'epoch ms; 之后被修改的文件才算' }, + }, + }, + async execute({ unit_id, since }, ctx) { + _req(ctx, ['pid']); + const projectRoot = pathResolver.resolveProjectRoot(ctx.pid); + const unitDir = pathResolver.resolveUnitDir(ctx.pid, unit_id); + + const allowedPrefixes = [ + path.join(unitDir, 'code'), + path.join(unitDir, 'contract.json'), + path.join(unitDir, 'coder.log'), + path.join(projectRoot, 'agent-state'), + path.join(projectRoot, 'timeline.ndjson'), + path.join(projectRoot, 'bugs.ndjson'), + path.join(projectRoot, 'changelog.ndjson'), + path.join(projectRoot, '.git'), + ]; + + const violations = []; + function walk(dir) { + if (!fs.existsSync(dir)) return; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p); + else { + try { + const st = fs.statSync(p); + if (st.mtimeMs < since) continue; + const within = allowedPrefixes.some(a => p === a || p.startsWith(a + path.sep)); + if (!within) violations.push(p); + } catch { /* ignore */ } + } + } + } + walk(projectRoot); + + return { ok: violations.length === 0, violations }; + }, +}; + +const append_coder_log = { + name: 'append_coder_log', + description: '结构化追加 coder.log。retry 模式必含 last_failure + last_cause 字段。', + inputSchema: { + type: 'object', + required: ['unit_id', 'attempt', 'this_fix', 'rationale'], + properties: { + unit_id: { type: 'string' }, + attempt: { type: 'integer', minimum: 1 }, + ts: { type: 'string' }, + last_failure: { type: 'string' }, + last_cause: { type: 'string' }, + this_fix: { type: 'string', minLength: 4 }, + rationale: { type: 'string', minLength: 4 }, + changed_files: { type: 'array', items: { type: 'string' } }, + }, + }, + async execute(decision, ctx) { + _req(ctx, ['pid']); + if (decision.attempt > 1) { + if (!decision.last_failure || !decision.last_cause) { + throw new ToolValidationError( + 'retry_decision', + 'attempt > 1 requires last_failure + last_cause fields' + ); + } + } + const p = path.join(pathResolver.resolveUnitDir(ctx.pid, decision.unit_id), 'coder.log'); + fs.mkdirSync(path.dirname(p), { recursive: true }); + + const fullEntry = { + ts: decision.ts || new Date().toISOString(), + attempt: decision.attempt, + ...(decision.last_failure ? { last_failure: decision.last_failure } : {}), + ...(decision.last_cause ? { last_cause: decision.last_cause } : {}), + this_fix: decision.this_fix, + rationale: decision.rationale, + changed_files: decision.changed_files || [], + }; + + const block = '---\n' + + Object.entries(fullEntry).map(([k, v]) => { + const val = typeof v === 'object' ? JSON.stringify(v) : v; + return `${k}: ${val}`; + }).join('\n') + '\n---\n\n'; + fs.appendFileSync(p, block); + return { ok: true, path: p, attempt: decision.attempt }; + }, +}; + +function _req(ctx, keys) { + for (const k of keys) { + if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`); + } +} + +module.exports = { contract_bridge, sandbox_check, append_coder_log }; diff --git a/server/src/tools/devloop/doc-tools.js b/server/src/tools/devloop/doc-tools.js new file mode 100644 index 0000000..06c5d47 --- /dev/null +++ b/server/src/tools/devloop/doc-tools.js @@ -0,0 +1,555 @@ +/** + * DevLoop · doc-tools (PM + Architect 用,11 个工具) + * + * bootstrap_prd_site init prd/ 站点 (copy shell.css/js + 建目录) + * bootstrap_sdd_site 同上,SDD + * write_chapter 写 PRD chapter (走 docStore.writePrdChapter) + * write_mvcp_page 写 SDD MVCP 页(model/view/control/process 之一) + * archive_doc 把当前 prd|sdd|units 归档到 history/ + * diff_html cheerio section 级 diff (返 added/changed/removed 列表) + * diff_units units.json 三态 diff + * echo_diff 发 SSE prd.updated/sdd.updated + change_summary + * update_search_index 重建 prd|sdd 站点的 search-index.json (复用 build-search-index 脚本) + * validate_prd_site 整站 cheerio 校验 + 8 类信息 ≥4 + * validate_sdd_consistency SDD ↔ units.json ↔ integration/spec.json 一致性 + * hash_contract 暴露 docStore.hashContract + * + * 规格: docs/prd/newfunction/_spec/sdd-control.md + * docs/prd/newfunction/_spec/agents-pm.md + * docs/prd/newfunction/_spec/agents-architect.md + */ + +const fs = require('fs'); +const path = require('path'); +const { spawn } = require('child_process'); +const cheerio = require('cheerio'); + +const pathResolver = require('../../../services/devloop/path-resolver'); +const docStore = require('../../../services/devloop/doc-store'); +const unitRegistry = require('../../../services/devloop/unit-registry'); +const changelogWriter = require('../../../services/devloop/changelog-writer'); +const { ToolValidationError } = require('./_errors'); + +// 模板目录:copy shell.css/shell.js 进项目站点 +const TEMPLATE_ASSETS_DIR = path.resolve(__dirname, '../../../../docs/prd/newfunction/assets'); + +// ──────────────────────────────────────────────────────────────── +// bootstrap_prd_site / bootstrap_sdd_site +// ──────────────────────────────────────────────────────────────── + +function _bootstrapSite(siteRoot, kind) { + const subdirs = kind === 'prd' + ? ['product', 'feature', 'flow', 'prototype', 'assets'] + : ['mvcp', 'assets']; + + for (const d of subdirs) fs.mkdirSync(path.join(siteRoot, d), { recursive: true }); + + // 复制 shell.css + shell.js (如模板存在) + for (const f of ['shell.css', 'shell.js']) { + const src = path.join(TEMPLATE_ASSETS_DIR, f); + const dst = path.join(siteRoot, 'assets', f); + if (fs.existsSync(src) && !fs.existsSync(dst)) { + fs.copyFileSync(src, dst); + } else if (!fs.existsSync(dst)) { + // 模板不存在时落空文件 (开发 / 测试场景) + fs.writeFileSync(dst, `/* ${f} placeholder; copy real template here */\n`); + } + } + // 空的 search-index.json + const idx = path.join(siteRoot, 'assets', 'search-index.json'); + if (!fs.existsSync(idx)) { + fs.writeFileSync(idx, JSON.stringify({ version: '1', pages: 0, entries: [] })); + } + return { ok: true, root: siteRoot, dirs: subdirs.length, assets: 3 }; +} + +const bootstrap_prd_site = { + name: 'bootstrap_prd_site', + description: '初始化项目 prd/ 站点目录(含 assets/shell.css + shell.js + search-index.json 占位)。', + inputSchema: { type: 'object', properties: {} }, + async execute(_input, ctx) { + _req(ctx, ['pid']); + return _bootstrapSite(pathResolver.resolvePrdSite(ctx.pid), 'prd'); + }, +}; + +const bootstrap_sdd_site = { + name: 'bootstrap_sdd_site', + description: '初始化项目 sdd/ 站点目录(含 mvcp/ + assets/)。', + inputSchema: { type: 'object', properties: {} }, + async execute(_input, ctx) { + _req(ctx, ['pid']); + return _bootstrapSite(pathResolver.resolveSddSite(ctx.pid), 'sdd'); + }, +}; + +// ──────────────────────────────────────────────────────────────── +// write_chapter / write_mvcp_page +// ──────────────────────────────────────────────────────────────── + +const write_chapter = { + name: 'write_chapter', + description: '写 PRD chapter (HTML)。kind=prd 时走 prd/{relPath};kind=sdd 时走 sdd/{relPath}。自动校验 + 归档旧版。', + inputSchema: { + type: 'object', + required: ['kind', 'relPath', 'html'], + properties: { + kind: { type: 'string', enum: ['prd', 'sdd'] }, + relPath: { type: 'string', minLength: 1 }, + html: { type: 'string', minLength: 1 }, + }, + }, + async execute({ kind, relPath, html }, ctx) { + _req(ctx, ['pid']); + const writeFn = kind === 'prd' ? docStore.writePrdChapter : docStore.writeSddChapter; + try { + const r = writeFn(ctx.pid, relPath, html); + return { ok: true, ...r }; + } catch (e) { + if (e.name === 'ValidationError') { + throw new ToolValidationError('html', `${e.message}: ${e.details?.join(' · ')}`); + } + throw e; + } + }, +}; + +const write_mvcp_page = { + name: 'write_mvcp_page', + description: '写 SDD MVCP 页 (mvcp/{model|view|control|process}.html)。等价于 write_chapter(kind=sdd, relPath=mvcp/{which}.html)。', + inputSchema: { + type: 'object', + required: ['which', 'html'], + properties: { + which: { type: 'string', enum: ['model', 'view', 'control', 'process'] }, + html: { type: 'string', minLength: 1 }, + }, + }, + async execute({ which, html }, ctx) { + return write_chapter.execute( + { kind: 'sdd', relPath: `mvcp/${which}.html`, html }, + ctx + ); + }, +}; + +// ──────────────────────────────────────────────────────────────── +// archive_doc +// ──────────────────────────────────────────────────────────────── + +const archive_doc = { + name: 'archive_doc', + description: '手动触发归档当前 prd/ 或 sdd/ 整站到 history/ (write_chapter 已自动归档单文件; 此工具用于整站快照)。', + inputSchema: { + type: 'object', + required: ['kind'], + properties: { + kind: { type: 'string', enum: ['prd', 'sdd'] }, + }, + }, + async execute({ kind }, ctx) { + _req(ctx, ['pid']); + const siteRoot = kind === 'prd' + ? pathResolver.resolvePrdSite(ctx.pid) + : pathResolver.resolveSddSite(ctx.pid); + if (!fs.existsSync(siteRoot)) { + throw new ToolValidationError('kind', `${kind}/ does not exist`); + } + + // 找 rev (扫所有 chapter article data-rev 取最大) + const rev = _detectSiteRev(siteRoot, kind); + + const historyDir = path.join( + pathResolver.resolveHistoryDir(ctx.pid), + `${kind}-rev-${rev}` + ); + _copyDir(siteRoot, historyDir); + return { ok: true, rev, archived_to: historyDir }; + }, +}; + +function _detectSiteRev(siteRoot, kind) { + let maxRev = 0; + function walk(dir) { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, e.name); + if (e.isDirectory()) { + if (e.name !== 'assets') walk(full); + } else if (e.name.endsWith('.html')) { + try { + const $ = cheerio.load(fs.readFileSync(full, 'utf8')); + const r = $(`article[data-doc="${kind}"]`).first().attr('data-rev'); + if (r && /^\d+$/.test(r)) { + const n = parseInt(r, 10); + if (n > maxRev) maxRev = n; + } + } catch { /* ignore */ } + } + } + } + walk(siteRoot); + return maxRev || 1; +} + +function _copyDir(src, dst) { + fs.mkdirSync(dst, { recursive: true }); + for (const e of fs.readdirSync(src, { withFileTypes: true })) { + const s = path.join(src, e.name); + const d = path.join(dst, e.name); + if (e.isDirectory()) _copyDir(s, d); + else fs.copyFileSync(s, d); + } +} + +// ──────────────────────────────────────────────────────────────── +// diff_html +// ──────────────────────────────────────────────────────────────── + +const diff_html = { + name: 'diff_html', + description: 'cheerio section 级 diff:对比 oldHtml 和 newHtml,返 added/changed/removed section data-rev 列表。', + inputSchema: { + type: 'object', + required: ['oldHtml', 'newHtml'], + properties: { + oldHtml: { type: 'string' }, + newHtml: { type: 'string' }, + }, + }, + async execute({ oldHtml, newHtml }, _ctx) { + const $o = cheerio.load(oldHtml || ''); + const $n = cheerio.load(newHtml || ''); + const oldSecs = new Map(); + const newSecs = new Map(); + $o('section[data-rev]').each((_, el) => { + const r = $o(el).attr('data-rev'); + oldSecs.set(r, $o(el).text().trim()); + }); + $n('section[data-rev]').each((_, el) => { + const r = $n(el).attr('data-rev'); + newSecs.set(r, $n(el).text().trim()); + }); + const added = [], changed = [], removed = []; + for (const [r, txt] of newSecs) { + if (!oldSecs.has(r)) added.push(r); + else if (oldSecs.get(r) !== txt) changed.push(r); + } + for (const [r] of oldSecs) { + if (!newSecs.has(r)) removed.push(r); + } + return { added, changed, removed }; + }, +}; + +// ──────────────────────────────────────────────────────────────── +// diff_units +// ──────────────────────────────────────────────────────────────── + +const diff_units = { + name: 'diff_units', + description: '对比新旧 units.json: added/changed(contract_hash 变化)/removed。如不传 oldUnits,从 history/最新 units-rev-N.json 读。', + inputSchema: { + type: 'object', + required: ['newUnits'], + properties: { + newUnits: { type: 'array' }, + oldUnits: { type: 'array' }, + }, + }, + async execute({ newUnits, oldUnits }, ctx) { + let oldList = oldUnits; + if (!oldList && ctx?.pid) { + // 尝试从 history/ 取最新归档 + const historyDir = pathResolver.resolveHistoryDir(ctx.pid); + if (fs.existsSync(historyDir)) { + const files = fs.readdirSync(historyDir).filter(f => /^units-rev-\d+\.json$/.test(f)); + if (files.length) { + files.sort(); + const last = files[files.length - 1]; + const old = JSON.parse(fs.readFileSync(path.join(historyDir, last), 'utf8')); + oldList = old.units || []; + } + } + } + return unitRegistry.diffUnits(oldList || [], newUnits); + }, +}; + +// ──────────────────────────────────────────────────────────────── +// echo_diff +// ──────────────────────────────────────────────────────────────── + +const echo_diff = { + name: 'echo_diff', + description: '发 SSE 事件 prd.updated 或 sdd.updated,告知 Dashboard 文档已变。', + inputSchema: { + type: 'object', + required: ['kind', 'rev', 'summary'], + properties: { + kind: { type: 'string', enum: ['prd', 'sdd'] }, + rev: { type: 'integer' }, + summary: { type: 'string', maxLength: 500 }, + sections_diff: { type: 'object' }, + }, + }, + async execute({ kind, rev, summary, sections_diff }, ctx) { + _req(ctx, ['pid']); + if (ctx.sseBroker) { + ctx.sseBroker.emit(ctx.pid, `${kind}.updated`, { + rev, summary, sections_diff: sections_diff || null, + }); + } + // 同步写 changelog + try { + changelogWriter.append(ctx.pid, { + kind: `${kind}_rev`, + rev, + by: ctx.agent || 'system', + trigger: 'agent', + summary, + diff: sections_diff || {}, + }); + } catch (e) { + // 已存在该 rev → 跳过;其他错误抛 + if (!/already/.test(e.message)) { /* swallow */ } + } + return { ok: true }; + }, +}; + +// ──────────────────────────────────────────────────────────────── +// update_search_index +// ──────────────────────────────────────────────────────────────── + +const update_search_index = { + name: 'update_search_index', + description: '重建 prd/ 或 sdd/ 站点的 search-index.json (复用 scripts/build-search-index.js 的逻辑)。', + inputSchema: { + type: 'object', + properties: { + target: { type: 'string', enum: ['prd', 'sdd', 'both'], default: 'both' }, + }, + }, + async execute({ target = 'both' }, ctx) { + _req(ctx, ['pid']); + const built = []; + if (target === 'prd' || target === 'both') { + built.push(_buildSiteIndex(pathResolver.resolvePrdSite(ctx.pid))); + } + if (target === 'sdd' || target === 'both') { + built.push(_buildSiteIndex(pathResolver.resolveSddSite(ctx.pid))); + } + return { ok: true, built }; + }, +}; + +function _buildSiteIndex(siteRoot) { + if (!fs.existsSync(siteRoot)) return { skipped: true, reason: 'site not exist' }; + const entries = []; + function walk(dir, prefix) { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + if (e.name === 'assets') continue; + const rel = prefix ? `${prefix}/${e.name}` : e.name; + const full = path.join(dir, e.name); + if (e.isDirectory()) walk(full, rel); + else if (e.name.endsWith('.html')) { + const html = fs.readFileSync(full, 'utf8'); + const title = _extractTitle(html) || rel; + const text = _stripTagsKeepText(html).slice(0, 2000); + entries.push({ f: rel, title, content: text }); + } + } + } + walk(siteRoot, ''); + + const idx = { + version: '1', generated: new Date().toISOString(), + pages: entries.length, entries, + }; + const out = path.join(siteRoot, 'assets', 'search-index.json'); + fs.mkdirSync(path.dirname(out), { recursive: true }); + fs.writeFileSync(out, JSON.stringify(idx)); + return { ok: true, pages: entries.length, path: out }; +} + +function _extractTitle(html) { + const m = html.match(/([\s\S]*?)<\/title>/i); + return m ? m[1].trim() : null; +} + +function _stripTagsKeepText(s) { + return s + .replace(/<script[\s\S]*?<\/script>/gi, ' ') + .replace(/<style[\s\S]*?<\/style>/gi, ' ') + .replace(/<svg[\s\S]*?<\/svg>/gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +// ──────────────────────────────────────────────────────────────── +// validate_prd_site +// ──────────────────────────────────────────────────────────────── + +const REQUIRED_PRD_CHAPTERS = [ + 'index.html', + 'product/background.html', + 'product/personas.html', + 'product/scope.html', + 'feature/feature-tree.html', + 'flow/user-flows.html', +]; + +const EIGHT_TYPES = { + Tables: $ => $('table').length > 0, + Code: $ => $('pre').length > 0, + Illustrations: $ => $('svg').length > 0, + Interaction: $ => $('.tabs, details, nav').length > 0, + Spatial: $ => $('pre').filter((_,el) => $(el).text().includes('├──') || $(el).text().includes('└──')).length > 0, + Design: $ => $('.card').length > 0 || $('[class*=card]').length > 0, + Images: $ => $('svg, img').length > 0, + Workflows: $ => $('svg').filter((_,el) => /marker-end|stroke-dasharray|arrow/.test($(el).html() || '')).length > 0, +}; + +const validate_prd_site = { + name: 'validate_prd_site', + description: '整站校验 PRD: 必备章节存在 · cheerio 结构 · 8 类信息 ≥4。', + inputSchema: { type: 'object', properties: {} }, + async execute(_input, ctx) { + _req(ctx, ['pid']); + const root = pathResolver.resolvePrdSite(ctx.pid); + const issues = []; + if (!fs.existsSync(root)) { + return { ok: false, issues: ['prd/ site not initialized'] }; + } + + for (const f of REQUIRED_PRD_CHAPTERS) { + const p = path.join(root, f); + if (!fs.existsSync(p)) issues.push(`missing chapter: ${f}`); + } + + // 扫所有 chapter + const allFiles = []; + function walk(dir, prefix) { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + if (e.name === 'assets') continue; + const rel = prefix ? `${prefix}/${e.name}` : e.name; + const full = path.join(dir, e.name); + if (e.isDirectory()) walk(full, rel); + else if (e.name.endsWith('.html')) allFiles.push({ rel, full }); + } + } + walk(root, ''); + + for (const { rel, full } of allFiles) { + const html = fs.readFileSync(full, 'utf8'); + const v = docStore.validateHTML(html, 'prd'); + if (!v.ok) { + for (const e of v.errors) issues.push(`${rel}: ${e}`); + } + // 8 类信息检查 + try { + const $ = cheerio.load(html); + const covered = Object.entries(EIGHT_TYPES).filter(([_, fn]) => fn($)); + if (covered.length < 4) { + issues.push(`${rel}: only ${covered.length}/8 info types (need ≥4)`); + } + } catch { /* ignore */ } + } + + return { ok: issues.length === 0, issues }; + }, +}; + +// ──────────────────────────────────────────────────────────────── +// validate_sdd_consistency +// ──────────────────────────────────────────────────────────────── + +const validate_sdd_consistency = { + name: 'validate_sdd_consistency', + description: 'SDD ↔ units.json ↔ integration/spec.json 三方一致性: 每 Unit 在 sdd/mvcp/ 出现 · involves ⊆ units · contract_hash 一致 · DAG 无环。', + inputSchema: { type: 'object', properties: {} }, + async execute(_input, ctx) { + _req(ctx, ['pid']); + const issues = []; + + const unitsJson = docStore.readUnits(ctx.pid); + if (!unitsJson) return { ok: false, issues: ['units.json missing'] }; + + const v = docStore.validateUnitsSchema(unitsJson); + if (!v.ok) issues.push(...v.errors); + + // Unit 在 sdd/mvcp/ 至少出现 1 次 + const sddRoot = pathResolver.resolveSddSite(ctx.pid); + const mvcpDir = path.join(sddRoot, 'mvcp'); + let sddText = ''; + if (fs.existsSync(mvcpDir)) { + for (const f of fs.readdirSync(mvcpDir)) { + if (f.endsWith('.html')) sddText += fs.readFileSync(path.join(mvcpDir, f), 'utf8'); + } + } + for (const u of unitsJson.units) { + if (!sddText.includes(`data-unit-id="${u.id}"`) && + !sddText.includes(`data-def-name="${u.id}"`)) { + issues.push(`unit ${u.id} not referenced in sdd/mvcp/`); + } + } + + // integration/spec.json involves 检查 + const spec = docStore.readIntegrationSpec(ctx.pid); + if (spec && Array.isArray(spec.cases)) { + const ids = new Set(unitsJson.units.map(u => u.id)); + for (const c of spec.cases) { + for (const u of (c.involves || [])) { + if (!ids.has(u)) issues.push(`integration case ${c.id} involves unknown unit: ${u}`); + } + } + } + + return { ok: issues.length === 0, issues }; + }, +}; + +// ──────────────────────────────────────────────────────────────── +// hash_contract +// ──────────────────────────────────────────────────────────────── + +const hash_contract = { + name: 'hash_contract', + description: 'canonical JSON + sha256 截 16 hex。算 Unit contract 的 hash。', + inputSchema: { + type: 'object', + required: ['contract'], + properties: { + contract: { type: 'object' }, + }, + }, + async execute({ contract }, _ctx) { + return { ok: true, hash: docStore.hashContract(contract) }; + }, +}; + +// ──────────────────────────────────────────────────────────────── +function _req(ctx, keys) { + for (const k of keys) { + if (!ctx || ctx[k] == null) { + const { ToolMissingDependencyError } = require('./_errors'); + throw new ToolMissingDependencyError(`ctx.${k}`); + } + } +} + +module.exports = { + bootstrap_prd_site, + bootstrap_sdd_site, + write_chapter, + write_mvcp_page, + archive_doc, + diff_html, + diff_units, + echo_diff, + update_search_index, + validate_prd_site, + validate_sdd_consistency, + hash_contract, +}; diff --git a/server/src/tools/devloop/integrator-tools.js b/server/src/tools/devloop/integrator-tools.js new file mode 100644 index 0000000..3b9692e --- /dev/null +++ b/server/src/tools/devloop/integrator-tools.js @@ -0,0 +1,291 @@ +/** + * DevLoop · integrator-tools (6 个) + * port_picker 分配空闲 localhost 端口(避开 reserved) + * spawn_units 批量启动 be Unit 进程,每个独立端口 + * health_wait poll /api/health 直到 200 或超时 + * run_case 执行 integration spec 单个 case(含 {{stepN.x}} 占位符) + * parse_placeholder 解析 {{stepN.field}} 形式占位符 + * collect_stderr 从子进程取最近 stderr 行 + * + * 规格: docs/prd/newfunction/_spec/agents-integrator.md · §7 + */ + +const net = require('net'); +const fs = require('fs'); +const path = require('path'); +const { spawn } = require('child_process'); + +const pathResolver = require('../../../services/devloop/path-resolver'); +const { ToolValidationError, ToolMissingDependencyError } = require('./_errors'); + +const RESERVED_PORTS = new Set([3000, 3010, 5173, 5432, 6379, 8080, 8443]); + +// ──────────────────── port_picker ──────────────────── + +function isPortFree(port) { + return new Promise(resolve => { + const srv = net.createServer(); + srv.once('error', () => resolve(false)); + srv.once('listening', () => srv.close(() => resolve(true))); + srv.listen(port, '127.0.0.1'); + }); +} + +const port_picker = { + name: 'port_picker', + description: '分配 N 个空闲 localhost 端口(18000-19000),避开系统保留', + inputSchema: { + type: 'object', + required: ['count'], + properties: { + count: { type: 'integer', minimum: 1, maximum: 20 }, + }, + }, + async execute({ count }, _ctx) { + const picked = []; + let attempts = 0; + while (picked.length < count && attempts < 100) { + attempts++; + const p = 18000 + Math.floor(Math.random() * 1000); + if (RESERVED_PORTS.has(p) || picked.includes(p)) continue; + if (await isPortFree(p)) picked.push(p); + } + if (picked.length < count) { + throw new Error(`no free port after ${attempts} attempts`); + } + return { ok: true, ports: picked }; + }, +}; + +// ──────────────────── spawn_units ──────────────────── + +const _activeHandles = new Map(); // pid → ProcessHandle[] + +const spawn_units = { + name: 'spawn_units', + description: '批量启动 be Unit 真实进程(每个独立端口 + env 注入)', + inputSchema: { + type: 'object', + required: ['be_units', 'ports', 'log_path'], + properties: { + be_units: { type: 'array', items: { type: 'string' } }, + ports: { type: 'object' }, + log_path: { type: 'string' }, + }, + }, + async execute({ be_units, ports, log_path }, ctx) { + _req(ctx, ['pid']); + fs.mkdirSync(path.dirname(log_path), { recursive: true }); + const logStream = fs.createWriteStream(log_path, { flags: 'a' }); + const handles = []; + + for (const unitId of be_units) { + const port = ports[unitId]; + if (!port) throw new ToolValidationError('ports', `no port for ${unitId}`); + const cwd = pathResolver.resolveUnitDir(ctx.pid, unitId); + const entry = path.join(cwd, 'code', 'server.js'); + if (!fs.existsSync(entry)) { + throw new ToolValidationError('be_units', `${unitId} has no code/server.js`); + } + const child = spawn('node', [entry], { + cwd, + env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', UNIT_ID: unitId }, + detached: false, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const prefix = `[${unitId}:${port}] `; + child.stdout.on('data', c => logStream.write(prefix + c)); + child.stderr.on('data', c => logStream.write(prefix + c)); + handles.push({ unitId, port, pid: child.pid, proc: child }); + } + _activeHandles.set(ctx.pid, handles); + return { ok: true, handles: handles.map(h => ({ unitId: h.unitId, port: h.port, pid: h.pid })) }; + }, +}; + +async function killAll(pid, graceMs = 5000) { + const handles = _activeHandles.get(pid) || []; + for (const h of handles) { + try { h.proc.kill('SIGTERM'); } catch {} + } + await new Promise(r => setTimeout(r, graceMs)); + for (const h of handles) { + try { if (!h.proc.killed) h.proc.kill('SIGKILL'); } catch {} + } + _activeHandles.delete(pid); +} + +// ──────────────────── health_wait ──────────────────── + +const health_wait = { + name: 'health_wait', + description: 'poll /api/health (或指定 url) 直到 200 或超时', + inputSchema: { + type: 'object', + required: ['url'], + properties: { + url: { type: 'string' }, + timeout_ms: { type: 'integer', default: 30_000 }, + interval_ms: { type: 'integer', default: 500 }, + }, + }, + async execute({ url, timeout_ms = 30_000, interval_ms = 500 }, _ctx) { + const deadline = Date.now() + timeout_ms; + while (Date.now() < deadline) { + try { + const r = await fetch(url); + if (r.ok) return { ok: true, status: r.status, waited_ms: timeout_ms - (deadline - Date.now()) }; + } catch { /* not ready yet */ } + await new Promise(r => setTimeout(r, interval_ms)); + } + return { ok: false, timeout: true }; + }, +}; + +// ──────────────────── parse_placeholder ──────────────────── + +function parsePlaceholders(obj, prev) { + const s = JSON.stringify(obj); + const resolved = s.replace(/\{\{step(\d+)\.([\w.]+)\}\}/g, (_match, n, p) => { + const r = prev[parseInt(n, 10)]; + if (!r) throw new Error(`placeholder step${n} has no result yet`); + const parts = p.split('.'); + let v = r.body; + for (const k of parts) v = v?.[k]; + return v != null ? String(v) : ''; + }); + return JSON.parse(resolved); +} + +const parse_placeholder = { + name: 'parse_placeholder', + description: '解析 {{stepN.field}} 占位符,从前面 step 的 response.body 取值替换', + inputSchema: { + type: 'object', + required: ['template', 'previous_steps'], + properties: { + template: {}, + previous_steps: { type: 'array' }, + }, + }, + async execute({ template, previous_steps }, _ctx) { + return { ok: true, resolved: parsePlaceholders(template, previous_steps) }; + }, +}; + +// ──────────────────── run_case ──────────────────── + +const run_case = { + name: 'run_case', + description: '执行 integration spec 单个 case (含 placeholder 解析 + step expect 校验)', + inputSchema: { + type: 'object', + required: ['case_spec', 'ports'], + properties: { + case_spec: { type: 'object' }, + ports: { type: 'object' }, + timeout_ms: { type: 'integer', default: 30_000 }, + }, + }, + async execute({ case_spec, ports, timeout_ms = 30_000 }, _ctx) { + const deadline = Date.now() + timeout_ms; + const stepResults = []; + const httpTrace = []; + + for (let i = 0; i < case_spec.steps.length; i++) { + if (Date.now() > deadline) { + return { ok: false, failed_step: i, reason: 'timeout', httpTrace }; + } + const step = case_spec.steps[i]; + let body, headers; + try { + body = parsePlaceholders(step.body || {}, stepResults); + headers = parsePlaceholders(step.headers || {}, stepResults); + } catch (e) { + return { ok: false, failed_step: i, reason: e.message, httpTrace }; + } + + const [method, p] = step.call.split(' '); + const port = ports[step.unit]; + if (!port) { + return { ok: false, failed_step: i, reason: `no port for unit ${step.unit}`, httpTrace }; + } + const url = `http://127.0.0.1:${port}` + p; + const startedAt = Date.now(); + let r, json = {}; + try { + r = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json', ...headers }, + body: method === 'GET' ? undefined : JSON.stringify(body), + }); + try { json = await r.json(); } catch { /* maybe empty body */ } + } catch (e) { + return { ok: false, failed_step: i, got: { status: 0, error: e.message }, httpTrace }; + } + + httpTrace.push({ + ts: startedAt, step: i, unit: step.unit, method, url, + status: r.status, body: json, duration_ms: Date.now() - startedAt, + }); + stepResults.push({ status: r.status, body: json }); + + if (!_checkExpect(step.expect, { status: r.status, body: json })) { + return { ok: false, failed_step: i, got: { status: r.status, body: json }, httpTrace }; + } + } + return { ok: true, httpTrace, steps_ran: case_spec.steps.length }; + }, +}; + +function _checkExpect(expect, got) { + if (!expect) return true; + if (expect.status && expect.status !== got.status) return false; + // expect.json 简化检查:仅检查 key 存在性 + 类型;不做 JSONPath + if (expect.json && typeof expect.json === 'object') { + for (const [k, v] of Object.entries(expect.json)) { + if (got.body?.[k] === undefined) return false; + if (typeof v === 'string' && v.startsWith('$.')) continue; // JSONPath 简化:存在即可 + } + } + return true; +} + +// ──────────────────── collect_stderr ──────────────────── + +const collect_stderr = { + name: 'collect_stderr', + description: '从 process.log 取最近 N 行 stderr 节选(供 BugTriager 用)', + inputSchema: { + type: 'object', + required: ['log_path'], + properties: { + log_path: { type: 'string' }, + lines: { type: 'integer', default: 20 }, + }, + }, + async execute({ log_path, lines = 20 }, _ctx) { + if (!fs.existsSync(log_path)) return { ok: false, reason: 'log not exist', excerpt: '' }; + const raw = fs.readFileSync(log_path, 'utf8'); + const all = raw.split('\n'); + const excerpt = all.slice(-lines).join('\n'); + return { ok: true, excerpt }; + }, +}; + +function _req(ctx, keys) { + for (const k of keys) { + if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`); + } +} + +module.exports = { + port_picker, + spawn_units, + health_wait, + run_case, + parse_placeholder, + collect_stderr, + // 内部导出(kill / 测试用) + killAll, +}; diff --git a/server/src/tools/devloop/persistence-tools.js b/server/src/tools/devloop/persistence-tools.js new file mode 100644 index 0000000..a3ba281 --- /dev/null +++ b/server/src/tools/devloop/persistence-tools.js @@ -0,0 +1,289 @@ +/** + * DevLoop · persistence-tools + * + * 持久化 5 件套 (M-10/11/12/13 接入): + * - write_todolist 初始化 agent-state/{agent}-todo.json + * - update_todo_item 更新单条 item + 同步 progress_pct + * - update_runtime_state 修 agent-state/{agent}.json (current_step/total_steps) + * - timeline_append append timeline.ndjson + * - record_memory 写 memory/devloop/{agent}/ 下 .md + * + * 所有 Agent 都能用(toolPolicy 里写)。 + * 每个工具签名: { name, description, inputSchema, execute(input, ctx) } + * + * 规格: docs/prd/newfunction/_spec/sdd-control.md · C-15 + * docs/prd/newfunction/_spec/sdd-model.md · M-10/11/12/13 + * docs/prd/newfunction/_spec/arch-tool-registry.md · §③ + */ + +const fs = require('fs'); +const path = require('path'); + +const pathResolver = require('../../../services/devloop/path-resolver'); +const timelineLogger = require('../../../services/devloop/timeline-logger'); +const { ToolValidationError, ToolMissingDependencyError } = require('./_errors'); + +// ──────────────────────────────────────────────────────────────── +// write_todolist +// ──────────────────────────────────────────────────────────────── + +const write_todolist = { + name: 'write_todolist', + description: '初始化 agent-state/{agent}-todo.json,写入完整 todolist。Agent 开工第一件事。', + inputSchema: { + type: 'object', + required: ['items'], + properties: { + items: { + type: 'array', + minItems: 1, + items: { + type: 'object', + required: ['id', 'description'], + properties: { + id: { type: 'string', pattern: '^step-\\d{3}$' }, + description: { type: 'string' }, + depends_on: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, + }, + async execute({ items }, ctx) { + _requireCtx(ctx, ['pid', 'agent', 'call_id']); + const p = pathResolver.resolveAgentTodolist(ctx.pid, ctx.agent); + const todolist = { + agent: ctx.agent, + call_id: ctx.call_id, + created_at: new Date().toISOString(), + items: items.map((it, i) => ({ + id: it.id, + description: it.description, + depends_on: it.depends_on || [], + status: i === 0 ? 'doing' : 'todo', + ...(i === 0 ? { started_at: new Date().toISOString() } : {}), + })), + current: items[0]?.id || null, + progress_pct: 0, + }; + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(todolist, null, 2)); + + if (ctx.sseBroker) { + ctx.sseBroker.emit(ctx.pid, 'agent.todolist', { + agent: ctx.agent, call_id: ctx.call_id, items: todolist.items, + }); + } + return { ok: true, total: items.length, path: p }; + }, +}; + +// ──────────────────────────────────────────────────────────────── +// update_todo_item +// ──────────────────────────────────────────────────────────────── + +const update_todo_item = { + name: 'update_todo_item', + description: '更新 todolist 中单条 item 的 status;自动重算 progress_pct + 发 SSE。', + inputSchema: { + type: 'object', + required: ['id', 'status'], + properties: { + id: { type: 'string', pattern: '^step-\\d{3}$' }, + status: { + type: 'string', + enum: ['todo', 'doing', 'done', 'failed'], + }, + output_ref: { type: 'string' }, + error: { type: 'string' }, + }, + }, + async execute({ id, status, output_ref, error }, ctx) { + _requireCtx(ctx, ['pid', 'agent']); + const p = pathResolver.resolveAgentTodolist(ctx.pid, ctx.agent); + if (!fs.existsSync(p)) { + throw new ToolValidationError('todolist', `not found at ${p} (call write_todolist first)`); + } + const todolist = JSON.parse(fs.readFileSync(p, 'utf8')); + const item = todolist.items.find(it => it.id === id); + if (!item) { + throw new ToolValidationError('id', `unknown step: ${id}`); + } + const from = item.status; + item.status = status; + if (status === 'doing' && !item.started_at) { + item.started_at = new Date().toISOString(); + } + if (status === 'done' || status === 'failed') { + item.finished_at = new Date().toISOString(); + if (output_ref) item.output_ref = output_ref; + if (error) item.error = error; + } + + // 重算 progress_pct + const done = todolist.items.filter(it => it.status === 'done').length; + todolist.progress_pct = Math.round((done / todolist.items.length) * 100); + + // 找下一个 todo 设为 doing + if (status === 'done') { + const next = todolist.items.find(it => it.status === 'todo'); + if (next) { + next.status = 'doing'; + next.started_at = new Date().toISOString(); + todolist.current = next.id; + } else { + todolist.current = null; + } + } + + fs.writeFileSync(p, JSON.stringify(todolist, null, 2)); + + if (ctx.sseBroker) { + ctx.sseBroker.emit(ctx.pid, 'agent.step', { + agent: ctx.agent, call_id: ctx.call_id || todolist.call_id, + item_id: id, from, to: status, + progress_pct: todolist.progress_pct, + current_desc: todolist.items.find(it => it.id === todolist.current)?.description, + }); + } + return { ok: true, progress_pct: todolist.progress_pct }; + }, +}; + +// ──────────────────────────────────────────────────────────────── +// update_runtime_state +// ──────────────────────────────────────────────────────────────── + +const update_runtime_state = { + name: 'update_runtime_state', + description: '更新 M-10 AgentRuntimeState 的 current_step / total_steps(不改 status)。', + inputSchema: { + type: 'object', + required: ['current_step', 'total_steps'], + properties: { + current_step: { type: 'integer', minimum: 0 }, + total_steps: { type: 'integer', minimum: 1 }, + }, + }, + async execute({ current_step, total_steps }, ctx) { + _requireCtx(ctx, ['pid', 'agent']); + const p = pathResolver.resolveAgentState(ctx.pid, ctx.agent); + if (!fs.existsSync(p)) { + throw new ToolValidationError('state', `runtime state not found at ${p}`); + } + const state = JSON.parse(fs.readFileSync(p, 'utf8')); + state.current_step = current_step; + state.total_steps = total_steps; + state.updated_at = new Date().toISOString(); + fs.writeFileSync(p, JSON.stringify(state, null, 2)); + return { ok: true, current_step, total_steps }; + }, +}; + +// ──────────────────────────────────────────────────────────────── +// timeline_append +// ──────────────────────────────────────────────────────────────── + +const timeline_append = { + name: 'timeline_append', + description: 'Append 一条事件到 timeline.ndjson (M-13)。', + inputSchema: { + type: 'object', + required: ['kind', 'summary'], + properties: { + kind: { type: 'string' }, + ref: { type: 'string' }, + summary: { type: 'string', maxLength: 280 }, + payload: { type: 'object' }, + }, + }, + async execute({ kind, ref, summary, payload }, ctx) { + _requireCtx(ctx, ['pid', 'agent']); + const ev = timelineLogger.append(ctx.pid, { + source: ctx.agent, + kind, ref, summary, payload, + }); + return { ok: true, ts: ev.ts }; + }, +}; + +// ──────────────────────────────────────────────────────────────── +// record_memory +// ──────────────────────────────────────────────────────────────── + +const record_memory = { + name: 'record_memory', + description: '在 memory/devloop/{agent}/ 下写一条 .md (M-11)。同 topic 是 upsert。', + inputSchema: { + type: 'object', + required: ['topic', 'content', 'relevance'], + properties: { + topic: { type: 'string', minLength: 2 }, + content: { type: 'string', minLength: 10 }, + relevance: { type: 'number', minimum: 0, maximum: 1 }, + }, + }, + async execute({ topic, content, relevance }, ctx) { + _requireCtx(ctx, ['agent', 'company_id']); + const dir = pathResolver.resolveMemoryDir(ctx.company_id, ctx.agent); + fs.mkdirSync(dir, { recursive: true }); + + const slug = topic.toLowerCase() + .replace(/[^a-z0-9一-龥]+/g, '-') + .replace(/^-+|-+$/g, ''); + const p = path.join(dir, `${slug}.md`); + + let existing = null; + if (fs.existsSync(p)) { + const raw = fs.readFileSync(p, 'utf8'); + const m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (m) { + const fm = {}; + for (const line of m[1].split('\n')) { + const kv = line.match(/^(\w+):\s*(.*)$/); + if (kv) fm[kv[1]] = kv[2].trim(); + } + existing = { fm, body: m[2] }; + } + } + + const projects = existing?.fm?.project_refs + ? existing.fm.project_refs.replace(/[\[\]]/g, '').split(',').map(s => s.trim()).filter(Boolean) + : []; + if (ctx.pid && !projects.includes(ctx.pid)) projects.push(ctx.pid); + + const hits = existing?.fm?.hits ? (parseInt(existing.fm.hits, 10) + 1) : 1; + const frontmatter = [ + '---', + `agent: ${ctx.agent}`, + `topic: ${topic}`, + `project_refs: [${projects.join(', ')}]`, + `last_updated: ${new Date().toISOString()}`, + `relevance_score: ${relevance}`, + `hits: ${hits}`, + '---', + '', + ].join('\n'); + + fs.writeFileSync(p, frontmatter + content + '\n'); + return { ok: true, path: p, upserted: !!existing, hits }; + }, +}; + +// ──────────────────────────────────────────────────────────────── +// Helpers +// ──────────────────────────────────────────────────────────────── + +function _requireCtx(ctx, keys) { + for (const k of keys) { + if (ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`); + } +} + +module.exports = { + write_todolist, + update_todo_item, + update_runtime_state, + timeline_append, + record_memory, +}; diff --git a/server/src/tools/devloop/tester-tools.js b/server/src/tools/devloop/tester-tools.js new file mode 100644 index 0000000..799b078 --- /dev/null +++ b/server/src/tools/devloop/tester-tools.js @@ -0,0 +1,229 @@ +/** + * DevLoop · tester-tools (5 个) + * parse_vitest vitest JSON reporter 解析 → result.json + hint 生成 + * hint_builder 从 failure stack + expected/got 启发式生成 hint + * bug_open 调 bugTracker.open + 自动 dedup + * bug_transition bug 状态转换 + * bug_list 按 filter 查 bug + * + * 规格: docs/prd/newfunction/_spec/agents-tester.md · §7 + */ + +const fs = require('fs'); +const path = require('path'); + +const pathResolver = require('../../../services/devloop/path-resolver'); +const bugTracker = require('../../../services/devloop/bug-tracker'); +const { ToolValidationError, ToolMissingDependencyError } = require('./_errors'); + +// ──────────────────── hint_builder ──────────────────── + +const HINT_PATTERNS = [ + { match: s => /cannot read.*?of undefined/i.test(s), + hint: () => '某个对象/字段是 undefined,先做存在性判断再访问' }, + { match: s => /TypeError.*compare/i.test(s), + hint: () => '调用 bcrypt.compare 前先确认 user 存在;否则 null 参数会抛 TypeError' }, + { match: s => /SQLITE_CONSTRAINT/i.test(s), + hint: () => '数据库约束冲突(unique/foreign key),业务层应该先查再写' }, + { match: s => /timeout/i.test(s), + hint: () => '某个异步操作超时;检查是否漏 await 或外部依赖未 mock' }, +]; + +function buildHint(ctx) { + const stack = ctx.stack || ''; + for (const p of HINT_PATTERNS) { + if (p.match(stack)) return p.hint(ctx); + } + // status code 启发式 + const m = String(stack).match(/[Ee]xpected[:\s]+(\d{3})[\s\S]{0,200}?[Rr]eceived[:\s]+(\d{3})/); + const e = m ? parseInt(m[1], 10) : ctx.expected; + const g = m ? parseInt(m[2], 10) : ctx.got; + if (typeof e === 'number' && typeof g === 'number') { + if (e === 401 && g === 500) return '凭据错误应返 401 不是 500;检查认证路径是否 try/catch 兜底'; + if (e === 400 && g === 500) return '参数校验错应返 400 不是 500;在 route 层检查后再传 service'; + if (e === 404 && g === 200) return '不存在的资源不应返 200;返回前判断查询结果'; + return `期望 ${e} 收 ${g},检查对应分支处理`; + } + return `${ctx.testName || 'unknown'}: 检查 stack 推断原因 (Tester 应补充)`; +} + +const hint_builder = { + name: 'hint_builder', + description: '从 failure stack/expected/got 生成给 Coder 反思用的 hint(1-2 句人话)', + inputSchema: { + type: 'object', + properties: { + testName: { type: 'string' }, + expected: {}, + got: {}, + stack: { type: 'string' }, + }, + }, + async execute(failure, _ctx) { + return { ok: true, hint: buildHint(failure) }; + }, +}; + +// ──────────────────── parse_vitest ──────────────────── + +const parse_vitest = { + name: 'parse_vitest', + description: 'vitest --reporter=json 输出 → 结构化 result.json + auto-fill hint for failures', + inputSchema: { + type: 'object', + required: ['raw_json_path'], + properties: { + raw_json_path: { type: 'string' }, + result_out_path: { type: 'string', description: '可选,result.json 写入位置' }, + }, + }, + async execute({ raw_json_path, result_out_path }, _ctx) { + if (!fs.existsSync(raw_json_path)) { + return { + ok: false, crashed: true, + crash_reason: 'vitest raw json missing — process likely crashed early', + summary: { total: 0, passed: 0, failed: 0, skipped: 0 }, + failures: [], + }; + } + let raw; + try { + raw = JSON.parse(fs.readFileSync(raw_json_path, 'utf8')); + } catch (e) { + return { ok: false, crashed: true, crash_reason: 'invalid vitest json: ' + e.message }; + } + + const summary = { total: 0, passed: 0, failed: 0, skipped: 0 }; + const failures = []; + + const testResults = raw.testResults || raw.tests || []; + for (const file of testResults) { + const asserts = file.assertionResults || file.assertions || []; + for (const a of asserts) { + summary.total++; + if (a.status === 'passed') summary.passed++; + else if (a.status === 'failed') { + summary.failed++; + const stack = (a.failureMessages || a.errors || []).join('\n'); + const { expected, got } = _extractAssertion(stack); + failures.push({ + test: a.fullName || a.title, + expected, got, stack, + hint: buildHint({ testName: a.fullName, expected, got, stack }), + }); + } else summary.skipped++; + } + } + + const result = { + ok: summary.failed === 0 && summary.total > 0, + crashed: false, + summary, failures, + duration_ms: raw.testResults?.[0]?.perfStats?.runtime || 0, + }; + + if (result_out_path) { + fs.mkdirSync(path.dirname(result_out_path), { recursive: true }); + fs.writeFileSync(result_out_path, JSON.stringify(result, null, 2)); + } + return result; + }, +}; + +function _extractAssertion(stack) { + const exp = stack.match(/Expected[:\s]+([^\n]+)/); + const got = stack.match(/(?:Received|Actual)[:\s]+([^\n]+)/); + return { + expected: exp ? exp[1].trim() : null, + got: got ? got[1].trim() : null, + }; +} + +// ──────────────────── bug_open / transition / list ──────────────────── + +const bug_open = { + name: 'bug_open', + description: 'append bugs.ndjson 一条 status=open;dedupKey 命中 open/fixing 时复用旧 id', + inputSchema: { + type: 'object', + required: ['title'], + properties: { + source: { type: 'string', enum: ['unit_test','integration_test','triage','human'] }, + unit_id: { type: 'string' }, + run_id: { type: 'string' }, + test_name: { type: 'string' }, + category: { type: 'string' }, + severity: { type: 'string', enum: ['critical','high','medium','low'] }, + title: { type: 'string', maxLength: 120 }, + detail: { type: 'string' }, + affected_units: { type: 'array', items: { type: 'string' } }, + }, + }, + async execute(bug, ctx) { + _req(ctx, ['pid']); + // 注入 sseBroker + timelineLogger (如果 ctx 有) + if (ctx.sseBroker) bugTracker.attachSseBroker(ctx.sseBroker); + if (ctx.timelineLogger) bugTracker.attachTimelineLogger(ctx.timelineLogger); + const b = bugTracker.open(ctx.pid, { ...bug, by: ctx.agent || 'tester' }); + return { ok: true, bug_id: b.id, dedup: b.discovered_at !== new Date().toISOString().slice(0, b.discovered_at.length) }; + }, +}; + +const bug_transition = { + name: 'bug_transition', + description: 'bug 状态转换 (open → fixing → fixed → verified 等),走 P-9 状态机', + inputSchema: { + type: 'object', + required: ['bug_id', 'to'], + properties: { + bug_id: { type: 'string' }, + to: { type: 'string', enum: ['open','fixing','fixed','verified','wontfix','duplicate'] }, + note: { type: 'string' }, + fix_refs: { type: 'array', items: { type: 'string' } }, + duplicate_of: { type: 'string' }, + }, + }, + async execute({ bug_id, to, note, fix_refs, duplicate_of }, ctx) { + _req(ctx, ['pid']); + if (ctx.sseBroker) bugTracker.attachSseBroker(ctx.sseBroker); + if (ctx.timelineLogger) bugTracker.attachTimelineLogger(ctx.timelineLogger); + try { + bugTracker.transition(ctx.pid, bug_id, { + to, by: ctx.agent || 'system', note, fix_refs, duplicate_of, + }); + return { ok: true }; + } catch (e) { + if (e.name === 'InvalidBugTransitionError') { + throw new ToolValidationError('to', e.message); + } + throw e; + } + }, +}; + +const bug_list = { + name: 'bug_list', + description: '列 bug,可按 status/severity/unit_id 过滤;返回每个 bug.id 的最新态', + inputSchema: { + type: 'object', + properties: { + status: { type: 'array', items: { type: 'string' } }, + severity: { type: 'array', items: { type: 'string' } }, + unit_id: { type: 'string' }, + since: { type: 'string' }, + }, + }, + async execute(filter, ctx) { + _req(ctx, ['pid']); + const bugs = bugTracker.list(ctx.pid, filter || {}); + return { ok: true, bugs, total: bugs.length }; + }, +}; + +function _req(ctx, keys) { + for (const k of keys) { + if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`); + } +} + +module.exports = { hint_builder, parse_vitest, bug_open, bug_transition, bug_list }; diff --git a/server/src/tools/devloop/tool-registry.js b/server/src/tools/devloop/tool-registry.js new file mode 100644 index 0000000..8231b95 --- /dev/null +++ b/server/src/tools/devloop/tool-registry.js @@ -0,0 +1,109 @@ +/** + * DevLoop Tool Registry — loadAllTools() + * + * 把 server/src/tools/devloop/ 下所有 *-tools.js 加载并按工具 name 聚合到 Map。 + * tool-manager / ClaudeCliProtocol 启动 Agent 时通过 toolPolicy 取这里的 ToolDef。 + * + * 规格: docs/prd/newfunction/_spec/arch-tool-registry.md · §④ + */ + +const path = require('path'); +const fs = require('fs'); + +// 工具模块清单(手动 require,避免动态 require 出错) +const TOOL_MODULES = [ + './persistence-tools', + './doc-tools', + './coder-tools', + './tester-tools', + './integrator-tools', + './triager-tools', +]; + +let cached = null; + +function loadAllTools() { + if (cached) return cached; + + const registry = new Map(); + + for (const mod of TOOL_MODULES) { + const fullPath = path.join(__dirname, mod + '.js'); + if (!fs.existsSync(fullPath)) continue; + const tools = require(mod); + for (const key of Object.keys(tools)) { + const tool = tools[key]; + if (tool && typeof tool === 'object' && tool.name && typeof tool.execute === 'function') { + if (registry.has(tool.name)) { + throw new Error(`Duplicate tool name: ${tool.name} (in ${mod})`); + } + registry.set(tool.name, tool); + } + } + } + + cached = registry; + return registry; +} + +/** + * 列工具名(用于 _registry.json toolPolicy 校验) + */ +function listToolNames() { + return Array.from(loadAllTools().keys()).sort(); +} + +/** + * 按 toolPolicy 取一组工具(含 LemonBoss 既有工具是允许的,本函数只过滤 devloop 的) + */ +function getDevLoopTools(toolPolicy) { + const all = loadAllTools(); + const out = []; + for (const name of toolPolicy) { + if (all.has(name)) out.push(all.get(name)); + } + return out; +} + +/** + * 调度:根据 name 找工具,执行(中央调用入口,外部 ACP 调用方使用)。 + * - 校验工具存在 + * - 校验 input 符合 inputSchema(简化版:只校验 required + type) + * - 调 tool.execute(input, ctx) + * - 错误标准化 + */ +async function dispatch(name, input, ctx) { + const all = loadAllTools(); + const tool = all.get(name); + if (!tool) { + throw new Error(`Unknown tool: ${name}`); + } + _validateInput(input, tool.inputSchema, name); + return tool.execute(input, ctx); +} + +function _validateInput(input, schema, name) { + if (!schema || typeof schema !== 'object') return; + if (schema.required && Array.isArray(schema.required)) { + for (const k of schema.required) { + if (input == null || input[k] === undefined) { + const e = new Error(`Tool ${name}: missing required field "${k}"`); + e.code = 'TOOL_INPUT_INVALID'; + throw e; + } + } + } +} + +function _resetForTesting() { + cached = null; +} + +module.exports = { + loadAllTools, + listToolNames, + getDevLoopTools, + dispatch, + _resetForTesting, + TOOL_MODULES, +}; diff --git a/server/src/tools/devloop/triager-tools.js b/server/src/tools/devloop/triager-tools.js new file mode 100644 index 0000000..2682a53 --- /dev/null +++ b/server/src/tools/devloop/triager-tools.js @@ -0,0 +1,233 @@ +/** + * DevLoop · triager-tools (4 个) + * extract_evidence 验证 evidence 字符串在原文 grep 命中 + * category_classifier 启发式 4 类粗判(contract_mismatch / impl_bug / spec_ambiguous / infra_flake) + * diff_contract_vs_impl 对比 contract.json 声明 vs code 实际行为(轻量,看 endpoint 存在性) + * find_similar_bug 在 bugs.ndjson 找历史相似 bug + * + * 规格: docs/prd/newfunction/_spec/agents-triager.md · §7 + */ + +const fs = require('fs'); +const path = require('path'); + +const pathResolver = require('../../../services/devloop/path-resolver'); +const bugTracker = require('../../../services/devloop/bug-tracker'); +const docStore = require('../../../services/devloop/doc-store'); +const { ToolMissingDependencyError } = require('./_errors'); + +// ──────────────────── extract_evidence ──────────────────── + +function verifyOneEvidence(projectRoot, evidence) { + const sep = evidence.indexOf(': '); + if (sep < 0) return { ok: false, reason: 'malformed: need "path: quote"' }; + const relPath = evidence.slice(0, sep).replace(/^['"]|['"]$/g, '').trim(); + const quote = evidence.slice(sep + 2).replace(/^['"]|['"]$/g, '').trim(); + const norm = s => s.replace(/\s+/g, ' ').toLowerCase(); + + // 支持 "path:lineno" 形式 + let pathOnly = relPath; + let line = null; + const lineMatch = relPath.match(/^(.+):(\d+)$/); + if (lineMatch) { + pathOnly = lineMatch[1]; + line = parseInt(lineMatch[2], 10); + } + const abs = path.join(projectRoot, pathOnly); + if (!fs.existsSync(abs)) { + return { ok: false, reason: 'file not found: ' + pathOnly }; + } + const content = fs.readFileSync(abs, 'utf8'); + if (line) { + const lines = content.split('\n'); + const lineContent = lines[line - 1] || ''; + if (norm(lineContent).includes(norm(quote))) { + return { ok: true, hit_file: pathOnly, line }; + } + } + if (norm(content).includes(norm(quote))) { + return { ok: true, hit_file: pathOnly }; + } + return { ok: false, reason: 'quote not found in file', quote, file: pathOnly }; +} + +const extract_evidence = { + name: 'extract_evidence', + description: '验证 evidence 列表每条能在原文 grep 命中(防 Triager 编造证据)', + inputSchema: { + type: 'object', + required: ['evidence'], + properties: { + evidence: { type: 'array', items: { type: 'string' } }, + }, + }, + async execute({ evidence }, ctx) { + _req(ctx, ['pid']); + const projectRoot = pathResolver.resolveProjectRoot(ctx.pid); + const results = evidence.map(e => ({ evidence: e, ...verifyOneEvidence(projectRoot, e) })); + return { + ok: results.every(r => r.ok), + results, + failed: results.filter(r => !r.ok), + }; + }, +}; + +// ──────────────────── category_classifier ──────────────────── + +const category_classifier = { + name: 'category_classifier', + description: '启发式粗判:contract_mismatch / impl_bug / spec_ambiguous / infra_flake', + inputSchema: { + type: 'object', + required: ['result_json', 'process_log'], + properties: { + result_json: { type: 'object' }, + process_log: { type: 'string' }, + sdd_text: { type: 'string' }, + }, + }, + async execute({ result_json, process_log, sdd_text = '' }, _ctx) { + const hints = []; + const score = { contract_mismatch: 0, impl_bug: 0, spec_ambiguous: 0, infra_flake: 0 }; + + // infra_flake 信号 + const infraSignals = [ + /EADDRINUSE/, /ECONNREFUSED/, /ETIMEDOUT/, /EAI_AGAIN/, + /Cannot find module/, /port already in use/i, + /health check timeout/i, + ]; + if (infraSignals.some(re => re.test(process_log || ''))) { + score.infra_flake += 3; + hints.push('infra signal in process.log'); + } + + // spec_ambiguous 信号 + const failTxt = JSON.stringify(result_json || {}); + const failTokens = [...new Set(failTxt.toLowerCase().match(/[a-z]{4,}/g) || [])] + .filter(t => !['function','expected','received','stack','message','error'].includes(t)); + const sddLc = sdd_text.toLowerCase(); + const tokensNotInSdd = failTokens.filter(t => sddLc && !sddLc.includes(t)); + if (failTokens.length > 0 && tokensNotInSdd.length >= Math.max(2, failTokens.length / 2)) { + score.spec_ambiguous += 2; + hints.push('key tokens not found in SDD: ' + tokensNotInSdd.slice(0, 5).join(',')); + } + + // impl_bug 信号:5xx got vs 4xx expected + const fail0 = result_json?.failures?.[0]; + if (fail0?.got?.status >= 500 && fail0?.expected?.status >= 400 && fail0?.expected?.status < 500) { + score.impl_bug += 2; + hints.push('5xx got vs 4xx expected → impl_bug'); + } + + // contract_mismatch 兜底(默认) + if (Object.values(score).every(v => v === 0)) { + score.contract_mismatch += 1; + hints.push('no strong signal → default contract_mismatch'); + } + + const sorted = Object.entries(score).sort(([,a],[,b]) => b - a); + const [topCat, topScore] = sorted[0]; + let confidence; + if (topScore >= 3) confidence = 0.85; + else if (topScore === 2) confidence = 0.65; + else if (topScore === 1) confidence = 0.45; + else confidence = 0.30; + + return { ok: true, category: topCat, confidence, hints, score }; + }, +}; + +// ──────────────────── diff_contract_vs_impl ──────────────────── + +const diff_contract_vs_impl = { + name: 'diff_contract_vs_impl', + description: '轻量对比:contract.json 声明的 endpoint 是否在 code/ 文本中出现(只看签名,不验语义)', + inputSchema: { + type: 'object', + required: ['unit_id'], + properties: { unit_id: { type: 'string' } }, + }, + async execute({ unit_id }, ctx) { + _req(ctx, ['pid']); + const unitDir = pathResolver.resolveUnitDir(ctx.pid, unit_id); + const contractPath = path.join(unitDir, 'contract.json'); + if (!fs.existsSync(contractPath)) { + return { ok: false, reason: 'contract.json missing' }; + } + const contract = JSON.parse(fs.readFileSync(contractPath, 'utf8')); + const codeDir = path.join(unitDir, 'code'); + if (!fs.existsSync(codeDir)) { + return { ok: false, reason: 'code/ missing' }; + } + + // 把 code/ 全部文本拼起来 + let codeBlob = ''; + function walk(dir) { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, e.name); + if (e.isDirectory()) walk(full); + else if (/\.(js|ts|vue|jsx|tsx|mjs|cjs)$/.test(e.name)) { + codeBlob += fs.readFileSync(full, 'utf8') + '\n'; + } + } + } + walk(codeDir); + + const divergences = []; + const endpoints = contract.exports?.http || []; + for (const ep of endpoints) { + if (!codeBlob.includes(ep.path)) { + divergences.push(`declared ${ep.method} ${ep.path} not found in code`); + } + } + return { matches: divergences.length === 0, divergences }; + }, +}; + +// ──────────────────── find_similar_bug ──────────────────── + +const find_similar_bug = { + name: 'find_similar_bug', + description: '在 bugs.ndjson 找历史相似 bug(同 unit + 类似 test_name)', + inputSchema: { + type: 'object', + required: ['signature'], + properties: { + signature: { + type: 'object', + properties: { + unit_id: { type: 'string' }, + test_name: { type: 'string' }, + status: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, + async execute({ signature }, ctx) { + _req(ctx, ['pid']); + const bugs = bugTracker.list(ctx.pid, { + unit_id: signature.unit_id, + status: signature.status, + }); + const matches = bugs.filter(b => { + if (!signature.test_name) return true; + return b.test_name === signature.test_name || + (b.test_name && b.test_name.includes(signature.test_name)); + }); + return { ok: true, matches, total: matches.length }; + }, +}; + +function _req(ctx, keys) { + for (const k of keys) { + if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`); + } +} + +module.exports = { + extract_evidence, + category_classifier, + diff_contract_vs_impl, + find_similar_bug, +}; diff --git a/server/tests/devloop/tools/bash-json-guard.test.js b/server/tests/devloop/tools/bash-json-guard.test.js new file mode 100644 index 0000000..b9b7d1c --- /dev/null +++ b/server/tests/devloop/tools/bash-json-guard.test.js @@ -0,0 +1,75 @@ +/** + * bash-json-guard 单测 + */ + +const { jsonGuard, bashHooks, INLINE_JSON_PATTERNS } = require('../../../src/tools/devloop/bash-json-guard'); + +describe('bash-json-guard', () => { + describe('jsonGuard', () => { + it('passes plain commands', () => { + expect(jsonGuard('ls -la').ok).toBe(true); + expect(jsonGuard('node server.js').ok).toBe(true); + }); + + it('passes curl with @file', () => { + expect(jsonGuard('curl -X POST -d @/tmp/payload.json http://x').ok).toBe(true); + }); + + it('blocks curl -d single-quoted inline JSON', () => { + const r = jsonGuard(`curl -d '{"k":"v"}' http://x`); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/inline JSON/i); + }); + + it('blocks curl --data inline JSON', () => { + const r = jsonGuard(`curl --data '{"k":"v"}' http://x`); + expect(r.ok).toBe(false); + }); + + it('blocks curl --data-raw inline JSON', () => { + const r = jsonGuard(`curl --data-raw '{"a":1}' http://x`); + expect(r.ok).toBe(false); + }); + + it('blocks curl --data-binary inline JSON', () => { + const r = jsonGuard(`curl --data-binary '{"big":true}' http://x`); + expect(r.ok).toBe(false); + }); + + it('blocks double-quoted form too', () => { + const r = jsonGuard(`curl -d "{\\"k\\":\\"v\\"}" http://x`); + expect(r.ok).toBe(false); + }); + + it('returns matched_pattern on block', () => { + const r = jsonGuard(`curl -d '{}' http://x`); + expect(r.ok).toBe(false); + expect(typeof r.matched_pattern).toBe('string'); + }); + + it('passes non-string input safely', () => { + expect(jsonGuard(null).ok).toBe(true); + expect(jsonGuard(undefined).ok).toBe(true); + expect(jsonGuard(123).ok).toBe(true); + }); + }); + + describe('bashHooks.preExecute', () => { + it('throws JSON_GUARD_BLOCK on offending command', () => { + const hook = bashHooks.preExecute[0]; + let err = null; + try { hook(`curl -d '{"x":1}' http://x`); } catch (e) { err = e; } + expect(err).not.toBeNull(); + expect(err.code).toBe('JSON_GUARD_BLOCK'); + }); + + it('returns silently on safe command', () => { + const hook = bashHooks.preExecute[0]; + expect(() => hook('echo hello')).not.toThrow(); + }); + }); + + it('exports 4 INLINE_JSON_PATTERNS', () => { + expect(INLINE_JSON_PATTERNS.length).toBe(4); + }); +}); diff --git a/server/tests/devloop/tools/coder-tools.test.js b/server/tests/devloop/tools/coder-tools.test.js new file mode 100644 index 0000000..5822c77 --- /dev/null +++ b/server/tests/devloop/tools/coder-tools.test.js @@ -0,0 +1,154 @@ +/** + * coder-tools 单测 + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-ct-')); +process.env.HOME = TEST_HOME; + +const pathResolver = require('../../../services/devloop/path-resolver'); +const docStore = require('../../../services/devloop/doc-store'); +const tools = require('../../../src/tools/devloop/coder-tools'); + +function setupProject() { + pathResolver._resetForTesting(); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ct-proj-')); + const pid = 'p-codetest'; + const reg = { + [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' }, + }; + fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true }); + fs.writeFileSync( + path.join(TEST_HOME, '.devloop', 'linked-projects.json'), + JSON.stringify(reg) + ); + pathResolver._resetForTesting(); + return { pid, root }; +} + +describe('coder-tools', () => { + let env, ctx; + beforeEach(() => { + env = setupProject(); + ctx = { pid: env.pid, agent: 'coder' }; + }); + afterEach(() => { + fs.rmSync(env.root, { recursive: true, force: true }); + }); + + describe('contract_bridge', () => { + function buildContract(exports) { + // hashContract uses contract.exports || contract, so version field stays stable + const c = { exports }; + c.version = docStore.hashContract(c); + return c; + } + + it('returns ok when hash matches', async () => { + const unitId = 'u-be-auth'; + const unitDir = path.join(env.root, 'units', unitId); + fs.mkdirSync(unitDir, { recursive: true }); + const contract = buildContract({ http: [{ method: 'POST', path: '/api/login' }] }); + fs.writeFileSync( + path.join(unitDir, 'contract.json'), + JSON.stringify(contract, null, 2) + ); + fs.writeFileSync( + path.join(env.root, 'units.json'), + JSON.stringify({ + units: [{ id: unitId, kind: 'be', contract_hash: contract.version, depends_on: [] }], + }) + ); + const r = await tools.contract_bridge.execute({ unit_id: unitId }, ctx); + expect(r.ok).toBe(true); + expect(r.hash).toBe(contract.version); + }); + + it('flags hash mismatch (SDD-declared hash differs)', async () => { + const unitId = 'u-be-mis'; + const unitDir = path.join(env.root, 'units', unitId); + fs.mkdirSync(unitDir, { recursive: true }); + const contract = buildContract({ http: [{ method: 'GET', path: '/api/x' }] }); + fs.writeFileSync( + path.join(unitDir, 'contract.json'), + JSON.stringify(contract) + ); + fs.writeFileSync( + path.join(env.root, 'units.json'), + JSON.stringify({ + units: [{ id: unitId, kind: 'be', contract_hash: 'sha256:deadbeefdeadbeef', depends_on: [] }], + }) + ); + const r = await tools.contract_bridge.execute({ unit_id: unitId }, ctx); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/does not match SDD/); + }); + + it('returns ok=false when contract missing', async () => { + const r = await tools.contract_bridge.execute({ unit_id: 'u-nope' }, ctx); + expect(r.ok).toBe(false); + expect(r.reason).toMatch(/contract\.json missing/); + }); + }); + + describe('append_coder_log', () => { + it('writes attempt=1 entry without retry fields', async () => { + const r = await tools.append_coder_log.execute({ + unit_id: 'u-foo', attempt: 1, + this_fix: 'init', rationale: 'first', + }, ctx); + expect(r.ok).toBe(true); + const content = fs.readFileSync(r.path, 'utf8'); + expect(content).toMatch(/attempt: 1/); + expect(content).toMatch(/this_fix: init/); + }); + + it('rejects retry without last_failure/last_cause', async () => { + await expect(tools.append_coder_log.execute({ + unit_id: 'u-foo', attempt: 2, + this_fix: 'try again', rationale: 'hmm', + }, ctx)).rejects.toThrow(/last_failure.*last_cause/); + }); + + it('accepts retry with required fields', async () => { + const r = await tools.append_coder_log.execute({ + unit_id: 'u-foo', attempt: 2, + last_failure: 'auth returns 500', + last_cause: 'user undefined before bcrypt.compare', + this_fix: 'add user null check', + rationale: 'guard against missing user', + }, ctx); + expect(r.ok).toBe(true); + expect(r.attempt).toBe(2); + }); + }); + + describe('sandbox_check', () => { + it('returns ok when no out-of-sandbox writes', async () => { + const unitId = 'u-x'; + const unitDir = path.join(env.root, 'units', unitId, 'code'); + fs.mkdirSync(unitDir, { recursive: true }); + const since = Date.now() - 10_000; + fs.writeFileSync(path.join(unitDir, 'a.js'), 'console.log(1);'); + const r = await tools.sandbox_check.execute({ unit_id: unitId, since }, ctx); + expect(r.ok).toBe(true); + expect(r.violations).toEqual([]); + }); + + it('flags writes outside sandbox', async () => { + const unitId = 'u-y'; + fs.mkdirSync(path.join(env.root, 'units', unitId, 'code'), { recursive: true }); + const since = Date.now() - 1_000; + // 写一个非沙箱目录文件 + const badPath = path.join(env.root, 'rogue', 'evil.js'); + fs.mkdirSync(path.dirname(badPath), { recursive: true }); + fs.writeFileSync(badPath, 'oops'); + const r = await tools.sandbox_check.execute({ unit_id: unitId, since }, ctx); + expect(r.ok).toBe(false); + expect(r.violations.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/server/tests/devloop/tools/doc-tools.test.js b/server/tests/devloop/tools/doc-tools.test.js new file mode 100644 index 0000000..33b78aa --- /dev/null +++ b/server/tests/devloop/tools/doc-tools.test.js @@ -0,0 +1,194 @@ +/** + * doc-tools 单测 (PM + Architect) + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-dt-')); +process.env.HOME = TEST_HOME; + +const pathResolver = require('../../../services/devloop/path-resolver'); +const docStore = require('../../../services/devloop/doc-store'); +const tools = require('../../../src/tools/devloop/doc-tools'); + +function setupProject() { + pathResolver._resetForTesting(); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dt-proj-')); + const pid = 'p-doctest'; + const reg = { + [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' }, + }; + fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true }); + fs.writeFileSync( + path.join(TEST_HOME, '.devloop', 'linked-projects.json'), + JSON.stringify(reg) + ); + pathResolver._resetForTesting(); + return { pid, root }; +} + +const VALID_PRD_HTML = ` +<!doctype html> +<html><head><title>Scope +
+
+

S1

desc

+
T
+ +
code block
+
card
+
+
+`; + +const VALID_SDD_HTML = ` + +Model +
+

M-1

x

+
+`; + +describe('doc-tools', () => { + let env; + let ctx; + beforeEach(() => { + env = setupProject(); + ctx = { pid: env.pid, agent: 'pm' }; + }); + afterEach(() => { + fs.rmSync(env.root, { recursive: true, force: true }); + }); + + describe('bootstrap_prd_site / bootstrap_sdd_site', () => { + it('creates prd site skeleton', async () => { + const r = await tools.bootstrap_prd_site.execute({}, ctx); + expect(r.ok).toBe(true); + expect(fs.existsSync(path.join(env.root, 'prd', 'product'))).toBe(true); + expect(fs.existsSync(path.join(env.root, 'prd', 'assets', 'shell.css'))).toBe(true); + expect(fs.existsSync(path.join(env.root, 'prd', 'assets', 'search-index.json'))).toBe(true); + }); + + it('creates sdd site skeleton with mvcp/', async () => { + const r = await tools.bootstrap_sdd_site.execute({}, ctx); + expect(r.ok).toBe(true); + expect(fs.existsSync(path.join(env.root, 'sdd', 'mvcp'))).toBe(true); + }); + }); + + describe('write_chapter', () => { + it('writes valid PRD chapter', async () => { + await tools.bootstrap_prd_site.execute({}, ctx); + const r = await tools.write_chapter.execute({ + kind: 'prd', relPath: 'product/scope.html', html: VALID_PRD_HTML, + }, ctx); + expect(r.ok).toBe(true); + expect(fs.existsSync(path.join(env.root, 'prd', 'product', 'scope.html'))).toBe(true); + }); + + it('rejects invalid HTML', async () => { + await tools.bootstrap_prd_site.execute({}, ctx); + await expect(tools.write_chapter.execute({ + kind: 'prd', relPath: 'product/scope.html', html: '

no article tag

', + }, ctx)).rejects.toThrow(); + }); + }); + + describe('write_mvcp_page', () => { + it('writes sdd/mvcp/model.html', async () => { + await tools.bootstrap_sdd_site.execute({}, ctx); + const r = await tools.write_mvcp_page.execute({ + which: 'model', html: VALID_SDD_HTML, + }, ctx); + expect(r.ok).toBe(true); + expect(fs.existsSync(path.join(env.root, 'sdd', 'mvcp', 'model.html'))).toBe(true); + }); + }); + + describe('diff_html', () => { + it('detects added/changed/removed sections', async () => { + const oldHtml = `
A
B
`; + const newHtml = `
A
B-changed
C
`; + const r = await tools.diff_html.execute({ oldHtml, newHtml }, ctx); + expect(r.added).toEqual(['3']); + expect(r.changed).toEqual(['2']); + expect(r.removed).toEqual([]); + }); + }); + + describe('diff_units', () => { + it('detects added unit', async () => { + const r = await tools.diff_units.execute({ + newUnits: [ + { id: 'u-a', contract_hash: 'h1' }, + { id: 'u-b', contract_hash: 'h2' }, + ], + oldUnits: [{ id: 'u-a', contract_hash: 'h1' }], + }, ctx); + expect(r.added).toContain('u-b'); + }); + + it('detects changed unit (contract_hash diff)', async () => { + const r = await tools.diff_units.execute({ + newUnits: [{ id: 'u-a', contract_hash: 'h2' }], + oldUnits: [{ id: 'u-a', contract_hash: 'h1' }], + }, ctx); + expect(r.changed).toContain('u-a'); + }); + }); + + describe('echo_diff', () => { + it('emits SSE prd.updated', async () => { + const events = []; + ctx.sseBroker = { emit: (pid, k, p) => events.push({ pid, k, p }) }; + const r = await tools.echo_diff.execute({ + kind: 'prd', rev: 2, summary: 'scope rev 2', sections_diff: { added: ['s2'] }, + }, ctx); + expect(r.ok).toBe(true); + expect(events).toHaveLength(1); + expect(events[0].k).toBe('prd.updated'); + }); + }); + + describe('update_search_index', () => { + it('builds prd index after writes', async () => { + await tools.bootstrap_prd_site.execute({}, ctx); + await tools.write_chapter.execute({ + kind: 'prd', relPath: 'product/scope.html', html: VALID_PRD_HTML, + }, ctx); + const r = await tools.update_search_index.execute({ target: 'prd' }, ctx); + expect(r.ok).toBe(true); + expect(r.built[0].pages).toBeGreaterThan(0); + }); + }); + + describe('hash_contract', () => { + it('returns sha256-prefixed 16-hex hash', async () => { + const r = await tools.hash_contract.execute({ + contract: { name: 'login', method: 'POST', path: '/api/login' }, + }, ctx); + expect(r.hash).toMatch(/^sha256:[a-f0-9]{16}$/); + }); + + it('is deterministic for canonical-equivalent contracts', async () => { + const a = await tools.hash_contract.execute({ + contract: { a: 1, b: 2 }, + }, ctx); + const b = await tools.hash_contract.execute({ + contract: { b: 2, a: 1 }, + }, ctx); + expect(a.hash).toBe(b.hash); + }); + }); + + describe('validate_prd_site', () => { + it('flags missing required chapters', async () => { + await tools.bootstrap_prd_site.execute({}, ctx); + const r = await tools.validate_prd_site.execute({}, ctx); + expect(r.ok).toBe(false); + expect(r.issues.some(i => i.includes('missing chapter'))).toBe(true); + }); + }); +}); diff --git a/server/tests/devloop/tools/integrator-tools.test.js b/server/tests/devloop/tools/integrator-tools.test.js new file mode 100644 index 0000000..0eeeaf4 --- /dev/null +++ b/server/tests/devloop/tools/integrator-tools.test.js @@ -0,0 +1,160 @@ +/** + * integrator-tools 单测 + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const http = require('http'); + +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-it-')); +process.env.HOME = TEST_HOME; + +const pathResolver = require('../../../services/devloop/path-resolver'); +const tools = require('../../../src/tools/devloop/integrator-tools'); + +function setupProject() { + pathResolver._resetForTesting(); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'it-proj-')); + const pid = 'p-integtest'; + const reg = { + [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' }, + }; + fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true }); + fs.writeFileSync( + path.join(TEST_HOME, '.devloop', 'linked-projects.json'), + JSON.stringify(reg) + ); + pathResolver._resetForTesting(); + return { pid, root }; +} + +describe('integrator-tools', () => { + let env, ctx; + beforeEach(() => { + env = setupProject(); + ctx = { pid: env.pid, agent: 'integrator' }; + }); + afterEach(() => { + fs.rmSync(env.root, { recursive: true, force: true }); + }); + + describe('port_picker', () => { + it('allocates the requested number of free ports', async () => { + const r = await tools.port_picker.execute({ count: 3 }, ctx); + expect(r.ok).toBe(true); + expect(r.ports).toHaveLength(3); + for (const p of r.ports) { + expect(p).toBeGreaterThanOrEqual(18000); + expect(p).toBeLessThan(19000); + } + expect(new Set(r.ports).size).toBe(3); // unique + }); + }); + + describe('parse_placeholder', () => { + it('resolves {{step0.body.field}} from prev results', async () => { + const r = await tools.parse_placeholder.execute({ + template: { token: '{{step0.token}}' }, + previous_steps: [{ body: { token: 'abc123' } }], + }, ctx); + expect(r.resolved.token).toBe('abc123'); + }); + + it('throws when step ref missing', async () => { + await expect(tools.parse_placeholder.execute({ + template: { x: '{{step5.foo}}' }, + previous_steps: [], + }, ctx)).rejects.toThrow(/placeholder step5/); + }); + }); + + describe('health_wait', () => { + it('returns ok=true when server responds 200', async () => { + const srv = http.createServer((_req, res) => { + res.statusCode = 200; res.end('ok'); + }); + await new Promise(r => srv.listen(0, '127.0.0.1', r)); + const port = srv.address().port; + + const r = await tools.health_wait.execute({ + url: `http://127.0.0.1:${port}/api/health`, + timeout_ms: 2000, interval_ms: 50, + }, ctx); + srv.close(); + expect(r.ok).toBe(true); + expect(r.status).toBe(200); + }); + + it('returns ok=false on timeout', async () => { + const r = await tools.health_wait.execute({ + url: 'http://127.0.0.1:1/never', + timeout_ms: 300, interval_ms: 100, + }, ctx); + expect(r.ok).toBe(false); + expect(r.timeout).toBe(true); + }); + }); + + describe('collect_stderr', () => { + it('returns last N lines from log', async () => { + const p = path.join(env.root, 'process.log'); + fs.writeFileSync(p, ['l1','l2','l3','l4','l5'].join('\n')); + const r = await tools.collect_stderr.execute({ log_path: p, lines: 2 }, ctx); + expect(r.ok).toBe(true); + expect(r.excerpt).toBe('l4\nl5'); + }); + + it('returns ok=false when log missing', async () => { + const r = await tools.collect_stderr.execute({ + log_path: path.join(env.root, 'nope.log'), + }, ctx); + expect(r.ok).toBe(false); + }); + }); + + describe('run_case', () => { + it('executes a single GET case against a test server', async () => { + const srv = http.createServer((req, res) => { + if (req.url === '/api/ping') { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ pong: true })); + return; + } + res.statusCode = 404; res.end(); + }); + await new Promise(r => srv.listen(0, '127.0.0.1', r)); + const port = srv.address().port; + + const r = await tools.run_case.execute({ + case_spec: { + steps: [ + { unit: 'u-x', call: 'GET /api/ping', expect: { status: 200 } }, + ], + }, + ports: { 'u-x': port }, + }, ctx); + srv.close(); + expect(r.ok).toBe(true); + expect(r.steps_ran).toBe(1); + }); + + it('reports failed step when status mismatches', async () => { + const srv = http.createServer((_req, res) => { + res.statusCode = 500; res.end('{}'); + }); + await new Promise(r => srv.listen(0, '127.0.0.1', r)); + const port = srv.address().port; + const r = await tools.run_case.execute({ + case_spec: { + steps: [{ unit: 'u-x', call: 'GET /api/x', expect: { status: 200 } }], + }, + ports: { 'u-x': port }, + }, ctx); + srv.close(); + expect(r.ok).toBe(false); + expect(r.failed_step).toBe(0); + expect(r.got.status).toBe(500); + }); + }); +}); diff --git a/server/tests/devloop/tools/persistence-tools.test.js b/server/tests/devloop/tools/persistence-tools.test.js new file mode 100644 index 0000000..cc8e0b4 --- /dev/null +++ b/server/tests/devloop/tools/persistence-tools.test.js @@ -0,0 +1,176 @@ +/** + * persistence-tools 单测 + * + * 覆盖 5 个工具: + * write_todolist / update_todo_item / update_runtime_state / + * timeline_append / record_memory + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-pt-')); +process.env.HOME = TEST_HOME; + +const pathResolver = require('../../../services/devloop/path-resolver'); +const tools = require('../../../src/tools/devloop/persistence-tools'); + +function setupProject() { + pathResolver._resetForTesting(); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pt-proj-')); + const pid = 'p-test01'; + // 注入 cache (绕过 register) + pathResolver.init(); + // hack: 直接写 project.json + 让 init 重扫不可行,改为构造 linked 注册表 + const reg = { + [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' }, + }; + fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true }); + fs.writeFileSync( + path.join(TEST_HOME, '.devloop', 'linked-projects.json'), + JSON.stringify(reg) + ); + pathResolver._resetForTesting(); + return { pid, root, company_id: 'c-test' }; +} + +describe('persistence-tools', () => { + let ctx; + let env; + + beforeEach(() => { + env = setupProject(); + ctx = { + pid: env.pid, + agent: 'pm', + call_id: 'call-1', + company_id: env.company_id, + }; + }); + + afterEach(() => { + fs.rmSync(env.root, { recursive: true, force: true }); + }); + + describe('write_todolist', () => { + it('writes initial todolist with first item doing', async () => { + const r = await tools.write_todolist.execute({ + items: [ + { id: 'step-001', description: 'first step' }, + { id: 'step-002', description: 'second step' }, + ], + }, ctx); + expect(r.ok).toBe(true); + expect(r.total).toBe(2); + const written = JSON.parse(fs.readFileSync(r.path, 'utf8')); + expect(written.items[0].status).toBe('doing'); + expect(written.items[1].status).toBe('todo'); + expect(written.current).toBe('step-001'); + expect(written.progress_pct).toBe(0); + }); + + it('requires call_id in ctx', async () => { + await expect(tools.write_todolist.execute({ + items: [{ id: 'step-001', description: 'x' }], + }, { pid: env.pid, agent: 'pm' })).rejects.toThrow(/call_id/); + }); + + it('emits SSE when broker present', async () => { + const emitted = []; + ctx.sseBroker = { emit: (pid, kind, p) => emitted.push({ pid, kind, p }) }; + await tools.write_todolist.execute({ + items: [{ id: 'step-001', description: 'x' }], + }, ctx); + expect(emitted).toHaveLength(1); + expect(emitted[0].kind).toBe('agent.todolist'); + }); + }); + + describe('update_todo_item', () => { + beforeEach(async () => { + await tools.write_todolist.execute({ + items: [ + { id: 'step-001', description: 'a' }, + { id: 'step-002', description: 'b' }, + ], + }, ctx); + }); + + it('transitions doing → done and advances current to next', async () => { + const r = await tools.update_todo_item.execute({ + id: 'step-001', status: 'done', output_ref: 'units.json', + }, ctx); + expect(r.ok).toBe(true); + expect(r.progress_pct).toBe(50); + const p = pathResolver.resolveAgentTodolist(env.pid, 'pm'); + const tl = JSON.parse(fs.readFileSync(p, 'utf8')); + expect(tl.items[0].status).toBe('done'); + expect(tl.items[0].output_ref).toBe('units.json'); + expect(tl.items[1].status).toBe('doing'); + expect(tl.current).toBe('step-002'); + }); + + it('rejects unknown step id', async () => { + await expect(tools.update_todo_item.execute({ + id: 'step-999', status: 'done', + }, ctx)).rejects.toThrow(/unknown step/); + }); + }); + + describe('timeline_append', () => { + it('appends an event to timeline.ndjson', async () => { + const r = await tools.timeline_append.execute({ + kind: 'doc.updated', + summary: 'PRD scope rev 2', + ref: 'prd/scope.html', + }, ctx); + expect(r.ok).toBe(true); + expect(r.ts).toBeTruthy(); + const p = pathResolver.resolveTimelineLog(env.pid); + const content = fs.readFileSync(p, 'utf8').trim().split('\n'); + const lastEvent = JSON.parse(content[content.length - 1]); + expect(lastEvent.kind).toBe('doc.updated'); + expect(lastEvent.source).toBe('pm'); + }); + }); + + describe('record_memory', () => { + let uniqueAgent; + beforeEach(() => { + // memory dir is under DATA_DIR/companies//memory — not isolated by env.root. + // Use a unique agent per test to avoid cross-test pollution; cleanup after. + uniqueAgent = 'test-mem-' + Math.random().toString(36).slice(2, 8); + ctx.agent = uniqueAgent; + }); + afterEach(() => { + const dir = pathResolver.resolveMemoryDir(env.company_id, uniqueAgent); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('writes memory .md with frontmatter (new)', async () => { + const r = await tools.record_memory.execute({ + topic: 'jwt-auth-points', + content: 'use jose lib with ES256 signing alg', + relevance: 0.9, + }, ctx); + expect(r.ok).toBe(true); + expect(r.upserted).toBe(false); + expect(r.hits).toBe(1); + const content = fs.readFileSync(r.path, 'utf8'); + expect(content).toMatch(/relevance_score: 0\.9/); + expect(content).toMatch(/jose lib/); + }); + + it('upserts on same topic with hits++', async () => { + await tools.record_memory.execute({ + topic: 'jwt-auth', content: 'first version content here', relevance: 0.5, + }, ctx); + const r2 = await tools.record_memory.execute({ + topic: 'jwt-auth', content: 'updated version content here', relevance: 0.7, + }, ctx); + expect(r2.upserted).toBe(true); + expect(r2.hits).toBe(2); + }); + }); +}); diff --git a/server/tests/devloop/tools/sandbox-and-errors.test.js b/server/tests/devloop/tools/sandbox-and-errors.test.js new file mode 100644 index 0000000..7808b4e --- /dev/null +++ b/server/tests/devloop/tools/sandbox-and-errors.test.js @@ -0,0 +1,93 @@ +/** + * _sandbox + _errors 单测 + */ + +const path = require('path'); +const { + ToolValidationError, + ToolSandboxError, + ToolMissingDependencyError, +} = require('../../../src/tools/devloop/_errors'); +const sandbox = require('../../../src/tools/devloop/_sandbox'); + +describe('errors', () => { + it('ToolValidationError carries field + reason', () => { + const e = new ToolValidationError('html', 'too short'); + expect(e.name).toBe('ToolValidationError'); + expect(e.field).toBe('html'); + expect(e.reason).toBe('too short'); + expect(e.message).toContain('too short'); + }); + + it('ToolSandboxError reports agent + path', () => { + const e = new ToolSandboxError('/etc/passwd', 'coder'); + expect(e.name).toBe('ToolSandboxError'); + expect(e.path).toBe('/etc/passwd'); + expect(e.agent).toBe('coder'); + }); + + it('ToolMissingDependencyError names missing dep', () => { + const e = new ToolMissingDependencyError('ctx.pid'); + expect(e.message).toMatch(/ctx\.pid/); + }); +}); + +describe('_sandbox', () => { + const ctx = { + agent: 'coder', + projectDir: '/tmp/my-proj', + unit_id: 'u-be-auth', + }; + + it('matches exact file pattern', () => { + expect(sandbox.matchesPattern( + '/tmp/my-proj/timeline.ndjson', 'timeline.ndjson', ctx + )).toBe(true); + }); + + it('matches /** glob prefix', () => { + expect(sandbox.matchesPattern( + '/tmp/my-proj/prd/feature/login.html', 'prd/**', ctx + )).toBe(true); + }); + + it('matches /** root itself', () => { + expect(sandbox.matchesPattern( + '/tmp/my-proj/prd', 'prd/**', ctx + )).toBe(true); + }); + + it('substitutes {unitId} placeholder', () => { + expect(sandbox.matchesPattern( + '/tmp/my-proj/units/u-be-auth/code/server.js', + 'units/{unitId}/code/**', + ctx + )).toBe(true); + }); + + it('rejects paths outside projectDir', () => { + expect(sandbox.matchesPattern( + '/etc/passwd', 'prd/**', ctx + )).toBe(false); + }); + + it('isWithinAllowList allows when any pattern matches', () => { + expect(sandbox.isWithinAllowList( + '/tmp/my-proj/units/u-be-auth/contract.json', + ['prd/**', 'units/{unitId}/contract.json'], + ctx + )).toBe(true); + }); + + it('assertWritable throws ToolSandboxError on violation', () => { + expect(() => sandbox.assertWritable( + '/etc/passwd', ['prd/**'], ctx + )).toThrow(ToolSandboxError); + }); + + it('assertWritable is silent on allowed path', () => { + expect(() => sandbox.assertWritable( + '/tmp/my-proj/prd/index.html', ['prd/**'], ctx + )).not.toThrow(); + }); +}); diff --git a/server/tests/devloop/tools/tester-tools.test.js b/server/tests/devloop/tools/tester-tools.test.js new file mode 100644 index 0000000..5dd2b36 --- /dev/null +++ b/server/tests/devloop/tools/tester-tools.test.js @@ -0,0 +1,152 @@ +/** + * tester-tools 单测 + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-tt-')); +process.env.HOME = TEST_HOME; + +const pathResolver = require('../../../services/devloop/path-resolver'); +const tools = require('../../../src/tools/devloop/tester-tools'); + +function setupProject() { + pathResolver._resetForTesting(); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tt-proj-')); + const pid = 'p-testertest'; + const reg = { + [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' }, + }; + fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true }); + fs.writeFileSync( + path.join(TEST_HOME, '.devloop', 'linked-projects.json'), + JSON.stringify(reg) + ); + pathResolver._resetForTesting(); + return { pid, root }; +} + +describe('tester-tools', () => { + let env, ctx; + beforeEach(() => { + env = setupProject(); + ctx = { pid: env.pid, agent: 'tester' }; + }); + afterEach(() => { + fs.rmSync(env.root, { recursive: true, force: true }); + }); + + describe('hint_builder', () => { + it('hints on TypeError compare (bcrypt-like)', async () => { + const r = await tools.hint_builder.execute({ + stack: 'TypeError: Illegal arguments: undefined,string compare', + }, ctx); + expect(r.hint).toMatch(/bcrypt\.compare/); + }); + + it('hints on 401 expected vs 500 got', async () => { + const r = await tools.hint_builder.execute({ + expected: 401, got: 500, stack: '', + }, ctx); + expect(r.hint).toMatch(/401/); + }); + + it('parses status code from stack', async () => { + const r = await tools.hint_builder.execute({ + stack: 'Expected: 200\nReceived: 404', + }, ctx); + expect(r.hint).toMatch(/200/); + }); + }); + + describe('parse_vitest', () => { + it('returns crashed when raw json missing', async () => { + const r = await tools.parse_vitest.execute({ + raw_json_path: path.join(env.root, 'nope.json'), + }, ctx); + expect(r.ok).toBe(false); + expect(r.crashed).toBe(true); + }); + + it('parses passing vitest output', async () => { + const rawPath = path.join(env.root, 'raw.json'); + fs.writeFileSync(rawPath, JSON.stringify({ + testResults: [{ + assertionResults: [ + { status: 'passed', fullName: 'a' }, + { status: 'passed', fullName: 'b' }, + ], + }], + })); + const r = await tools.parse_vitest.execute({ raw_json_path: rawPath }, ctx); + expect(r.ok).toBe(true); + expect(r.summary.passed).toBe(2); + expect(r.failures).toHaveLength(0); + }); + + it('extracts failure with hint', async () => { + const rawPath = path.join(env.root, 'raw2.json'); + fs.writeFileSync(rawPath, JSON.stringify({ + testResults: [{ + assertionResults: [{ + status: 'failed', fullName: 'login returns 401', + failureMessages: ['Expected: 401\nReceived: 500\nat handler'], + }], + }], + })); + const r = await tools.parse_vitest.execute({ raw_json_path: rawPath }, ctx); + expect(r.ok).toBe(false); + expect(r.summary.failed).toBe(1); + expect(r.failures[0].hint).toMatch(/401/); + }); + + it('writes result.json when result_out_path given', async () => { + const rawPath = path.join(env.root, 'raw3.json'); + fs.writeFileSync(rawPath, JSON.stringify({ + testResults: [{ assertionResults: [{ status: 'passed', fullName: 't' }] }], + })); + const out = path.join(env.root, 'result.json'); + await tools.parse_vitest.execute({ + raw_json_path: rawPath, result_out_path: out, + }, ctx); + expect(fs.existsSync(out)).toBe(true); + }); + }); + + describe('bug_open / bug_list / bug_transition', () => { + it('opens a bug then lists it', async () => { + const r = await tools.bug_open.execute({ + title: 'login 500 instead of 401', + unit_id: 'u-be-auth', + test_name: 'login wrong password', + severity: 'high', + }, ctx); + expect(r.ok).toBe(true); + expect(r.bug_id).toBeTruthy(); + + const ls = await tools.bug_list.execute({ unit_id: 'u-be-auth' }, ctx); + expect(ls.total).toBeGreaterThanOrEqual(1); + }); + + it('transitions bug open → fixing', async () => { + const opened = await tools.bug_open.execute({ + title: 'x', unit_id: 'u-a', test_name: 't', + }, ctx); + const r = await tools.bug_transition.execute({ + bug_id: opened.bug_id, to: 'fixing', note: 'coder picked up', + }, ctx); + expect(r.ok).toBe(true); + }); + + it('rejects invalid bug transition', async () => { + const opened = await tools.bug_open.execute({ + title: 'y', unit_id: 'u-b', test_name: 't', + }, ctx); + await expect(tools.bug_transition.execute({ + bug_id: opened.bug_id, to: 'verified', + }, ctx)).rejects.toThrow(); + }); + }); +}); diff --git a/server/tests/devloop/tools/tool-registry.test.js b/server/tests/devloop/tools/tool-registry.test.js new file mode 100644 index 0000000..e1ca716 --- /dev/null +++ b/server/tests/devloop/tools/tool-registry.test.js @@ -0,0 +1,67 @@ +/** + * tool-registry 单测 + */ + +const registry = require('../../../src/tools/devloop/tool-registry'); + +describe('tool-registry', () => { + beforeEach(() => registry._resetForTesting()); + + it('loadAllTools returns a Map with unique tool names', () => { + const all = registry.loadAllTools(); + expect(all instanceof Map).toBe(true); + expect(all.size).toBeGreaterThanOrEqual(30); + for (const [name, tool] of all) { + expect(typeof name).toBe('string'); + expect(tool.name).toBe(name); + expect(typeof tool.execute).toBe('function'); + } + }); + + it('listToolNames includes core 5 persistence tools', () => { + const names = registry.listToolNames(); + expect(names).toContain('write_todolist'); + expect(names).toContain('update_todo_item'); + expect(names).toContain('timeline_append'); + expect(names).toContain('record_memory'); + expect(names).toContain('update_runtime_state'); + }); + + it('listToolNames includes PM/Architect doc tools', () => { + const names = registry.listToolNames(); + expect(names).toContain('write_chapter'); + expect(names).toContain('write_mvcp_page'); + expect(names).toContain('hash_contract'); + expect(names).toContain('validate_prd_site'); + }); + + it('listToolNames includes Coder/Tester/Integrator/Triager tools', () => { + const names = registry.listToolNames(); + expect(names).toContain('contract_bridge'); // coder + expect(names).toContain('parse_vitest'); // tester + expect(names).toContain('port_picker'); // integrator + expect(names).toContain('extract_evidence'); // triager + }); + + it('getDevLoopTools filters by toolPolicy', () => { + const subset = registry.getDevLoopTools(['hash_contract', 'parse_vitest', 'nope']); + expect(subset).toHaveLength(2); + expect(subset.map(t => t.name).sort()).toEqual(['hash_contract', 'parse_vitest']); + }); + + it('dispatch invokes a known tool', async () => { + const r = await registry.dispatch('hash_contract', { + contract: { name: 'login', method: 'POST' }, + }, {}); + expect(r.ok).toBe(true); + expect(r.hash).toMatch(/^sha256:[a-f0-9]{16}$/); + }); + + it('dispatch rejects unknown tool', async () => { + await expect(registry.dispatch('does_not_exist', {}, {})).rejects.toThrow(/Unknown tool/); + }); + + it('dispatch validates required fields', async () => { + await expect(registry.dispatch('hash_contract', {}, {})).rejects.toThrow(/missing required field "contract"/); + }); +}); diff --git a/server/tests/devloop/tools/triager-tools.test.js b/server/tests/devloop/tools/triager-tools.test.js new file mode 100644 index 0000000..20a7c26 --- /dev/null +++ b/server/tests/devloop/tools/triager-tools.test.js @@ -0,0 +1,148 @@ +/** + * triager-tools 单测 + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-tr-')); +process.env.HOME = TEST_HOME; + +const pathResolver = require('../../../services/devloop/path-resolver'); +const docStore = require('../../../services/devloop/doc-store'); +const tools = require('../../../src/tools/devloop/triager-tools'); + +function setupProject() { + pathResolver._resetForTesting(); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tr-proj-')); + const pid = 'p-triagertest'; + const reg = { + [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' }, + }; + fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true }); + fs.writeFileSync( + path.join(TEST_HOME, '.devloop', 'linked-projects.json'), + JSON.stringify(reg) + ); + pathResolver._resetForTesting(); + return { pid, root }; +} + +describe('triager-tools', () => { + let env, ctx; + beforeEach(() => { + env = setupProject(); + ctx = { pid: env.pid, agent: 'triager' }; + }); + afterEach(() => { + fs.rmSync(env.root, { recursive: true, force: true }); + }); + + describe('extract_evidence', () => { + it('verifies a quote present in a file', async () => { + const target = path.join(env.root, 'unit-x.js'); + fs.writeFileSync(target, 'const VAL = 42;\nfunction foo() { return VAL; }\n'); + const r = await tools.extract_evidence.execute({ + evidence: ['unit-x.js: function foo() { return VAL'], + }, ctx); + expect(r.ok).toBe(true); + expect(r.results[0].ok).toBe(true); + }); + + it('rejects malformed evidence', async () => { + const r = await tools.extract_evidence.execute({ + evidence: ['no-colon-no-quote'], + }, ctx); + expect(r.ok).toBe(false); + expect(r.failed[0].reason).toMatch(/malformed/); + }); + + it('reports quote not found', async () => { + const target = path.join(env.root, 'file.js'); + fs.writeFileSync(target, 'function bar() {}'); + const r = await tools.extract_evidence.execute({ + evidence: ['file.js: never-going-to-find-this'], + }, ctx); + expect(r.ok).toBe(false); + expect(r.failed[0].reason).toMatch(/not found/); + }); + }); + + describe('category_classifier', () => { + it('flags infra_flake on EADDRINUSE in process log', async () => { + const r = await tools.category_classifier.execute({ + result_json: { failures: [] }, + process_log: 'Error: listen EADDRINUSE 0.0.0.0:3000', + }, ctx); + expect(r.category).toBe('infra_flake'); + }); + + it('flags impl_bug on 500 vs 4xx', async () => { + const r = await tools.category_classifier.execute({ + result_json: { failures: [{ got: { status: 500 }, expected: { status: 401 } }] }, + process_log: '', + }, ctx); + expect(r.category).toBe('impl_bug'); + }); + + it('falls back to contract_mismatch with low confidence', async () => { + const r = await tools.category_classifier.execute({ + result_json: { failures: [] }, + process_log: '', + sdd_text: 'this sdd describes everything', + }, ctx); + expect(r.category).toBe('contract_mismatch'); + expect(r.confidence).toBeLessThanOrEqual(0.5); + }); + }); + + describe('diff_contract_vs_impl', () => { + it('reports divergence when endpoint not in code', async () => { + const unitId = 'u-x'; + const unitDir = path.join(env.root, 'units', unitId); + fs.mkdirSync(path.join(unitDir, 'code'), { recursive: true }); + fs.writeFileSync( + path.join(unitDir, 'contract.json'), + JSON.stringify({ + exports: { http: [{ method: 'POST', path: '/api/login' }] }, + }) + ); + fs.writeFileSync( + path.join(unitDir, 'code', 'server.js'), + 'function nothing(){}' + ); + const r = await tools.diff_contract_vs_impl.execute({ unit_id: unitId }, ctx); + expect(r.matches).toBe(false); + expect(r.divergences.some(d => d.includes('/api/login'))).toBe(true); + }); + + it('matches when endpoint appears in code', async () => { + const unitId = 'u-y'; + const unitDir = path.join(env.root, 'units', unitId); + fs.mkdirSync(path.join(unitDir, 'code'), { recursive: true }); + fs.writeFileSync( + path.join(unitDir, 'contract.json'), + JSON.stringify({ + exports: { http: [{ method: 'GET', path: '/api/ping' }] }, + }) + ); + fs.writeFileSync( + path.join(unitDir, 'code', 'server.js'), + `app.get('/api/ping', (q,r) => r.send('pong'))` + ); + const r = await tools.diff_contract_vs_impl.execute({ unit_id: unitId }, ctx); + expect(r.matches).toBe(true); + }); + }); + + describe('find_similar_bug', () => { + it('returns empty when no bugs.ndjson yet', async () => { + const r = await tools.find_similar_bug.execute({ + signature: { unit_id: 'u-none', test_name: 't' }, + }, ctx); + expect(r.ok).toBe(true); + expect(r.total).toBe(0); + }); + }); +});