Commit 73359308b82c62a4069e6b5e9450bbf0563a9adb
1 parent
83523ede
feat(devloop): P0.A tool layer — 10 modules, 88 unit tests passing
Adds the DevLoop Agent tool layer (CommonJS) under server/src/tools/devloop:
- _errors.js / _sandbox.js — shared infra (3 error classes + path allow-list w/ {unitId})
- tool-registry.js — loadAllTools / dispatch / listToolNames / getDevLoopTools
- persistence-tools.js (5) — write_todolist · update_todo_item · update_runtime_state · timeline_append · record_memory
- doc-tools.js (12) — 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
- coder-tools.js (3) — contract_bridge · sandbox_check · append_coder_log (retry fields required)
- tester-tools.js (5) — parse_vitest · hint_builder · bug_open · bug_transition · bug_list
- integrator-tools.js (6) — port_picker · spawn_units · health_wait · run_case · parse_placeholder · collect_stderr
- triager-tools.js (4) — extract_evidence · category_classifier · diff_contract_vs_impl · find_similar_bug
- bash-json-guard.js — preExecute hook blocking inline JSON in Bash (CLAUDE.md L91-99)
Total: 35 callable devloop tools.
Tests: 88 cases across 9 files, all passing; combined with P0.0 services
suite the project now has 159 green tests.
Specs:
- docs/prd/newfunction/_spec/arch-tool-registry.md
- docs/prd/newfunction/_spec/agents-{pm,architect,coder,tester,integrator,triager}.md
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Showing
19 changed files
with
3242 additions
and
0 deletions
server/src/tools/devloop/_errors.js
0 → 100644
| 1 | +/** | |
| 2 | + * DevLoop tool layer · 标准化错误类型 | |
| 3 | + * | |
| 4 | + * 用法: | |
| 5 | + * throw new ToolValidationError('summary', 'too short'); | |
| 6 | + * throw new ToolSandboxError('/abs/path', 'pm'); | |
| 7 | + * | |
| 8 | + * Claude CLI 看到 thrown error → 包装为 tool_result { is_error: true, content: msg } | |
| 9 | + */ | |
| 10 | + | |
| 11 | +class ToolValidationError extends Error { | |
| 12 | + constructor(field, reason) { | |
| 13 | + super(`${field}: ${reason}`); | |
| 14 | + this.name = 'ToolValidationError'; | |
| 15 | + this.field = field; | |
| 16 | + this.reason = reason; | |
| 17 | + } | |
| 18 | +} | |
| 19 | + | |
| 20 | +class ToolSandboxError extends Error { | |
| 21 | + constructor(p, agent) { | |
| 22 | + super(`Sandbox violation: ${agent} tried to write ${p}`); | |
| 23 | + this.name = 'ToolSandboxError'; | |
| 24 | + this.path = p; | |
| 25 | + this.agent = agent; | |
| 26 | + } | |
| 27 | +} | |
| 28 | + | |
| 29 | +class ToolMissingDependencyError extends Error { | |
| 30 | + constructor(dep) { | |
| 31 | + super(`Tool requires ${dep} in ctx (LoopController injection)`); | |
| 32 | + this.name = 'ToolMissingDependencyError'; | |
| 33 | + this.dep = dep; | |
| 34 | + } | |
| 35 | +} | |
| 36 | + | |
| 37 | +module.exports = { | |
| 38 | + ToolValidationError, | |
| 39 | + ToolSandboxError, | |
| 40 | + ToolMissingDependencyError, | |
| 41 | +}; | ... | ... |
server/src/tools/devloop/_sandbox.js
0 → 100644
| 1 | +/** | |
| 2 | + * Sandbox 边界校验 — Agent 写盘前的 path 检查。 | |
| 3 | + * | |
| 4 | + * 用法: | |
| 5 | + * const { assertWritable } = require('./_sandbox'); | |
| 6 | + * assertWritable(abs, allowList, ctx.agent); | |
| 7 | + * | |
| 8 | + * allowList 形式 (glob-like): | |
| 9 | + * "prd/**" → ${ctx.projectDir}/prd/** 任意子文件 | |
| 10 | + * "units/{unitId}/code/**" → ${ctx.unitDir}/code/** (unitId 自动替换) | |
| 11 | + * "agent-state/**" → ${ctx.projectDir}/agent-state/** 任意 | |
| 12 | + * "timeline.ndjson" → 精确文件 | |
| 13 | + */ | |
| 14 | + | |
| 15 | +const path = require('path'); | |
| 16 | +const { ToolSandboxError } = require('./_errors'); | |
| 17 | + | |
| 18 | +/** | |
| 19 | + * @param {string} abs - 待写绝对路径 | |
| 20 | + * @param {string[]} allowList - 允许写的相对 pattern (相对 ctx.projectDir 或 ctx.unitDir) | |
| 21 | + * @param {object} ctx - { agent, projectDir, unitDir? } | |
| 22 | + */ | |
| 23 | +function assertWritable(abs, allowList, ctx) { | |
| 24 | + const normalized = path.resolve(abs); | |
| 25 | + if (isWithinAllowList(normalized, allowList, ctx)) return; | |
| 26 | + throw new ToolSandboxError(normalized, ctx.agent || 'unknown'); | |
| 27 | +} | |
| 28 | + | |
| 29 | +function isWithinAllowList(abs, allowList, ctx) { | |
| 30 | + for (const pattern of allowList) { | |
| 31 | + if (matchesPattern(abs, pattern, ctx)) return true; | |
| 32 | + } | |
| 33 | + return false; | |
| 34 | +} | |
| 35 | + | |
| 36 | +function matchesPattern(abs, pattern, ctx) { | |
| 37 | + // 替换 {unitId} 等占位符 | |
| 38 | + const resolved = pattern | |
| 39 | + .replace('{unitId}', ctx.unit_id || '__no_unit__'); | |
| 40 | + | |
| 41 | + // 决定 base:含 units/{unitId}/ 的 pattern → 用 unitDir 的父目录(=projectDir/units/) | |
| 42 | + // 其他 → 用 projectDir | |
| 43 | + // 简化:统一用 projectDir 作为 base 拼接 pattern | |
| 44 | + const base = ctx.projectDir || ''; | |
| 45 | + const expected = path.join(base, resolved); | |
| 46 | + | |
| 47 | + // 处理 ** glob | |
| 48 | + if (expected.endsWith('/**')) { | |
| 49 | + const prefix = expected.slice(0, -3); // 去掉 "/**" | |
| 50 | + return abs === prefix || abs.startsWith(prefix + path.sep); | |
| 51 | + } | |
| 52 | + | |
| 53 | + return abs === expected; | |
| 54 | +} | |
| 55 | + | |
| 56 | +module.exports = { | |
| 57 | + assertWritable, | |
| 58 | + isWithinAllowList, | |
| 59 | + matchesPattern, | |
| 60 | +}; | ... | ... |
server/src/tools/devloop/bash-json-guard.js
0 → 100644
| 1 | +/** | |
| 2 | + * DevLoop · Bash inline-JSON guard 中间件 | |
| 3 | + * | |
| 4 | + * 拦截 Coder/Tester/Integrator 通过 Bash 工具发送的<b>inline JSON</b> 命令 | |
| 5 | + * (CLAUDE.md L91-99 强约束)。挂到 Bash 工具的 preExecute 钩子里。 | |
| 6 | + * | |
| 7 | + * 反例(禁): curl -d '{"k":"v"}' http://... | |
| 8 | + * 正例: echo '{"k":"v"}' > /tmp/x.json && curl -d @/tmp/x.json http://... | |
| 9 | + * | |
| 10 | + * 规格: docs/prd/newfunction/_spec/arch-tool-registry.md · §⑥ | |
| 11 | + */ | |
| 12 | + | |
| 13 | +// 检测 `-d '{...}'` `-d "{...}"` 或 `--data '{...}'` `--data-raw '{...}'` | |
| 14 | +const INLINE_JSON_PATTERNS = [ | |
| 15 | + /-d\s+(['"])\s*\{[\s\S]*?\}\s*\1/, | |
| 16 | + /--data\s+(['"])\s*\{[\s\S]*?\}\s*\1/, | |
| 17 | + /--data-raw\s+(['"])\s*\{[\s\S]*?\}\s*\1/, | |
| 18 | + /--data-binary\s+(['"])\s*\{[\s\S]*?\}\s*\1/, | |
| 19 | +]; | |
| 20 | + | |
| 21 | +function jsonGuard(cmd) { | |
| 22 | + if (typeof cmd !== 'string') return { ok: true }; | |
| 23 | + for (const re of INLINE_JSON_PATTERNS) { | |
| 24 | + if (re.test(cmd)) { | |
| 25 | + return { | |
| 26 | + ok: false, | |
| 27 | + reason: 'inline JSON in Bash command is forbidden (see CLAUDE.md L91-99). ' + | |
| 28 | + 'Use: echo \'{"k":"v"}\' > /tmp/x.json && curl -d @/tmp/x.json', | |
| 29 | + matched_pattern: re.source, | |
| 30 | + }; | |
| 31 | + } | |
| 32 | + } | |
| 33 | + return { ok: true }; | |
| 34 | +} | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * preExecute hook 注册形式(LemonBoss Bash 工具内部消费) | |
| 38 | + */ | |
| 39 | +const bashHooks = { | |
| 40 | + preExecute: [ | |
| 41 | + (cmd) => { | |
| 42 | + const r = jsonGuard(cmd); | |
| 43 | + if (!r.ok) { | |
| 44 | + const e = new Error(r.reason); | |
| 45 | + e.code = 'JSON_GUARD_BLOCK'; | |
| 46 | + e.matched_pattern = r.matched_pattern; | |
| 47 | + throw e; | |
| 48 | + } | |
| 49 | + }, | |
| 50 | + ], | |
| 51 | +}; | |
| 52 | + | |
| 53 | +module.exports = { jsonGuard, bashHooks, INLINE_JSON_PATTERNS }; | ... | ... |
server/src/tools/devloop/coder-tools.js
0 → 100644
| 1 | +/** | |
| 2 | + * DevLoop · coder-tools (3 个) | |
| 3 | + * contract_bridge verify contract.json ↔ units.json hash 一致 | |
| 4 | + * sandbox_check 扫本 Unit 周围 mtime,确认没写出沙箱 | |
| 5 | + * append_coder_log 结构化追加 coder.log (强制 retry 模板字段) | |
| 6 | + * | |
| 7 | + * 规格: docs/prd/newfunction/_spec/agents-coder.md · §7 | |
| 8 | + */ | |
| 9 | + | |
| 10 | +const fs = require('fs'); | |
| 11 | +const path = require('path'); | |
| 12 | + | |
| 13 | +const pathResolver = require('../../../services/devloop/path-resolver'); | |
| 14 | +const docStore = require('../../../services/devloop/doc-store'); | |
| 15 | +const { ToolValidationError, ToolMissingDependencyError } = require('./_errors'); | |
| 16 | + | |
| 17 | +const contract_bridge = { | |
| 18 | + name: 'contract_bridge', | |
| 19 | + description: 'verify contract.json hash 与 units.json 中该 Unit 的 contract_hash 一致', | |
| 20 | + inputSchema: { | |
| 21 | + type: 'object', | |
| 22 | + required: ['unit_id'], | |
| 23 | + properties: { unit_id: { type: 'string' } }, | |
| 24 | + }, | |
| 25 | + async execute({ unit_id }, ctx) { | |
| 26 | + _req(ctx, ['pid']); | |
| 27 | + const unitDir = pathResolver.resolveUnitDir(ctx.pid, unit_id); | |
| 28 | + const contractPath = path.join(unitDir, 'contract.json'); | |
| 29 | + if (!fs.existsSync(contractPath)) { | |
| 30 | + return { ok: false, reason: 'contract.json missing' }; | |
| 31 | + } | |
| 32 | + const contract = JSON.parse(fs.readFileSync(contractPath, 'utf8')); | |
| 33 | + const units = docStore.readUnits(ctx.pid); | |
| 34 | + if (!units) return { ok: false, reason: 'units.json missing' }; | |
| 35 | + const unit = units.units.find(u => u.id === unit_id); | |
| 36 | + if (!unit) return { ok: false, reason: 'unit not in units.json' }; | |
| 37 | + | |
| 38 | + const recomputed = docStore.hashContract(contract); | |
| 39 | + if (contract.version !== recomputed) { | |
| 40 | + return { | |
| 41 | + ok: false, | |
| 42 | + reason: 'contract.version self-inconsistent', | |
| 43 | + claimed: contract.version, actual: recomputed, | |
| 44 | + }; | |
| 45 | + } | |
| 46 | + if (recomputed !== unit.contract_hash) { | |
| 47 | + return { | |
| 48 | + ok: false, | |
| 49 | + reason: 'contract does not match SDD-declared hash', | |
| 50 | + expected: unit.contract_hash, actual: recomputed, | |
| 51 | + hint: 'contract.json 与 SDD 不一致,改 code 让契约对齐 SDD', | |
| 52 | + }; | |
| 53 | + } | |
| 54 | + return { ok: true, hash: recomputed }; | |
| 55 | + }, | |
| 56 | +}; | |
| 57 | + | |
| 58 | +const sandbox_check = { | |
| 59 | + name: 'sandbox_check', | |
| 60 | + description: '扫本 Unit 沙箱外的文件 mtime 变化,确认 Coder 没写出去', | |
| 61 | + inputSchema: { | |
| 62 | + type: 'object', | |
| 63 | + required: ['unit_id', 'since'], | |
| 64 | + properties: { | |
| 65 | + unit_id: { type: 'string' }, | |
| 66 | + since: { type: 'number', description: 'epoch ms; 之后被修改的文件才算' }, | |
| 67 | + }, | |
| 68 | + }, | |
| 69 | + async execute({ unit_id, since }, ctx) { | |
| 70 | + _req(ctx, ['pid']); | |
| 71 | + const projectRoot = pathResolver.resolveProjectRoot(ctx.pid); | |
| 72 | + const unitDir = pathResolver.resolveUnitDir(ctx.pid, unit_id); | |
| 73 | + | |
| 74 | + const allowedPrefixes = [ | |
| 75 | + path.join(unitDir, 'code'), | |
| 76 | + path.join(unitDir, 'contract.json'), | |
| 77 | + path.join(unitDir, 'coder.log'), | |
| 78 | + path.join(projectRoot, 'agent-state'), | |
| 79 | + path.join(projectRoot, 'timeline.ndjson'), | |
| 80 | + path.join(projectRoot, 'bugs.ndjson'), | |
| 81 | + path.join(projectRoot, 'changelog.ndjson'), | |
| 82 | + path.join(projectRoot, '.git'), | |
| 83 | + ]; | |
| 84 | + | |
| 85 | + const violations = []; | |
| 86 | + function walk(dir) { | |
| 87 | + if (!fs.existsSync(dir)) return; | |
| 88 | + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { | |
| 89 | + const p = path.join(dir, e.name); | |
| 90 | + if (e.isDirectory()) walk(p); | |
| 91 | + else { | |
| 92 | + try { | |
| 93 | + const st = fs.statSync(p); | |
| 94 | + if (st.mtimeMs < since) continue; | |
| 95 | + const within = allowedPrefixes.some(a => p === a || p.startsWith(a + path.sep)); | |
| 96 | + if (!within) violations.push(p); | |
| 97 | + } catch { /* ignore */ } | |
| 98 | + } | |
| 99 | + } | |
| 100 | + } | |
| 101 | + walk(projectRoot); | |
| 102 | + | |
| 103 | + return { ok: violations.length === 0, violations }; | |
| 104 | + }, | |
| 105 | +}; | |
| 106 | + | |
| 107 | +const append_coder_log = { | |
| 108 | + name: 'append_coder_log', | |
| 109 | + description: '结构化追加 coder.log。retry 模式必含 last_failure + last_cause 字段。', | |
| 110 | + inputSchema: { | |
| 111 | + type: 'object', | |
| 112 | + required: ['unit_id', 'attempt', 'this_fix', 'rationale'], | |
| 113 | + properties: { | |
| 114 | + unit_id: { type: 'string' }, | |
| 115 | + attempt: { type: 'integer', minimum: 1 }, | |
| 116 | + ts: { type: 'string' }, | |
| 117 | + last_failure: { type: 'string' }, | |
| 118 | + last_cause: { type: 'string' }, | |
| 119 | + this_fix: { type: 'string', minLength: 4 }, | |
| 120 | + rationale: { type: 'string', minLength: 4 }, | |
| 121 | + changed_files: { type: 'array', items: { type: 'string' } }, | |
| 122 | + }, | |
| 123 | + }, | |
| 124 | + async execute(decision, ctx) { | |
| 125 | + _req(ctx, ['pid']); | |
| 126 | + if (decision.attempt > 1) { | |
| 127 | + if (!decision.last_failure || !decision.last_cause) { | |
| 128 | + throw new ToolValidationError( | |
| 129 | + 'retry_decision', | |
| 130 | + 'attempt > 1 requires last_failure + last_cause fields' | |
| 131 | + ); | |
| 132 | + } | |
| 133 | + } | |
| 134 | + const p = path.join(pathResolver.resolveUnitDir(ctx.pid, decision.unit_id), 'coder.log'); | |
| 135 | + fs.mkdirSync(path.dirname(p), { recursive: true }); | |
| 136 | + | |
| 137 | + const fullEntry = { | |
| 138 | + ts: decision.ts || new Date().toISOString(), | |
| 139 | + attempt: decision.attempt, | |
| 140 | + ...(decision.last_failure ? { last_failure: decision.last_failure } : {}), | |
| 141 | + ...(decision.last_cause ? { last_cause: decision.last_cause } : {}), | |
| 142 | + this_fix: decision.this_fix, | |
| 143 | + rationale: decision.rationale, | |
| 144 | + changed_files: decision.changed_files || [], | |
| 145 | + }; | |
| 146 | + | |
| 147 | + const block = '---\n' + | |
| 148 | + Object.entries(fullEntry).map(([k, v]) => { | |
| 149 | + const val = typeof v === 'object' ? JSON.stringify(v) : v; | |
| 150 | + return `${k}: ${val}`; | |
| 151 | + }).join('\n') + '\n---\n\n'; | |
| 152 | + fs.appendFileSync(p, block); | |
| 153 | + return { ok: true, path: p, attempt: decision.attempt }; | |
| 154 | + }, | |
| 155 | +}; | |
| 156 | + | |
| 157 | +function _req(ctx, keys) { | |
| 158 | + for (const k of keys) { | |
| 159 | + if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`); | |
| 160 | + } | |
| 161 | +} | |
| 162 | + | |
| 163 | +module.exports = { contract_bridge, sandbox_check, append_coder_log }; | ... | ... |
server/src/tools/devloop/doc-tools.js
0 → 100644
| 1 | +/** | |
| 2 | + * DevLoop · doc-tools (PM + Architect 用,11 个工具) | |
| 3 | + * | |
| 4 | + * bootstrap_prd_site init prd/ 站点 (copy shell.css/js + 建目录) | |
| 5 | + * bootstrap_sdd_site 同上,SDD | |
| 6 | + * write_chapter 写 PRD chapter (走 docStore.writePrdChapter) | |
| 7 | + * write_mvcp_page 写 SDD MVCP 页(model/view/control/process 之一) | |
| 8 | + * archive_doc 把当前 prd|sdd|units 归档到 history/ | |
| 9 | + * diff_html cheerio section 级 diff (返 added/changed/removed 列表) | |
| 10 | + * diff_units units.json 三态 diff | |
| 11 | + * echo_diff 发 SSE prd.updated/sdd.updated + change_summary | |
| 12 | + * update_search_index 重建 prd|sdd 站点的 search-index.json (复用 build-search-index 脚本) | |
| 13 | + * validate_prd_site 整站 cheerio 校验 + 8 类信息 ≥4 | |
| 14 | + * validate_sdd_consistency SDD ↔ units.json ↔ integration/spec.json 一致性 | |
| 15 | + * hash_contract 暴露 docStore.hashContract | |
| 16 | + * | |
| 17 | + * 规格: docs/prd/newfunction/_spec/sdd-control.md | |
| 18 | + * docs/prd/newfunction/_spec/agents-pm.md | |
| 19 | + * docs/prd/newfunction/_spec/agents-architect.md | |
| 20 | + */ | |
| 21 | + | |
| 22 | +const fs = require('fs'); | |
| 23 | +const path = require('path'); | |
| 24 | +const { spawn } = require('child_process'); | |
| 25 | +const cheerio = require('cheerio'); | |
| 26 | + | |
| 27 | +const pathResolver = require('../../../services/devloop/path-resolver'); | |
| 28 | +const docStore = require('../../../services/devloop/doc-store'); | |
| 29 | +const unitRegistry = require('../../../services/devloop/unit-registry'); | |
| 30 | +const changelogWriter = require('../../../services/devloop/changelog-writer'); | |
| 31 | +const { ToolValidationError } = require('./_errors'); | |
| 32 | + | |
| 33 | +// 模板目录:copy shell.css/shell.js 进项目站点 | |
| 34 | +const TEMPLATE_ASSETS_DIR = path.resolve(__dirname, '../../../../docs/prd/newfunction/assets'); | |
| 35 | + | |
| 36 | +// ──────────────────────────────────────────────────────────────── | |
| 37 | +// bootstrap_prd_site / bootstrap_sdd_site | |
| 38 | +// ──────────────────────────────────────────────────────────────── | |
| 39 | + | |
| 40 | +function _bootstrapSite(siteRoot, kind) { | |
| 41 | + const subdirs = kind === 'prd' | |
| 42 | + ? ['product', 'feature', 'flow', 'prototype', 'assets'] | |
| 43 | + : ['mvcp', 'assets']; | |
| 44 | + | |
| 45 | + for (const d of subdirs) fs.mkdirSync(path.join(siteRoot, d), { recursive: true }); | |
| 46 | + | |
| 47 | + // 复制 shell.css + shell.js (如模板存在) | |
| 48 | + for (const f of ['shell.css', 'shell.js']) { | |
| 49 | + const src = path.join(TEMPLATE_ASSETS_DIR, f); | |
| 50 | + const dst = path.join(siteRoot, 'assets', f); | |
| 51 | + if (fs.existsSync(src) && !fs.existsSync(dst)) { | |
| 52 | + fs.copyFileSync(src, dst); | |
| 53 | + } else if (!fs.existsSync(dst)) { | |
| 54 | + // 模板不存在时落空文件 (开发 / 测试场景) | |
| 55 | + fs.writeFileSync(dst, `/* ${f} placeholder; copy real template here */\n`); | |
| 56 | + } | |
| 57 | + } | |
| 58 | + // 空的 search-index.json | |
| 59 | + const idx = path.join(siteRoot, 'assets', 'search-index.json'); | |
| 60 | + if (!fs.existsSync(idx)) { | |
| 61 | + fs.writeFileSync(idx, JSON.stringify({ version: '1', pages: 0, entries: [] })); | |
| 62 | + } | |
| 63 | + return { ok: true, root: siteRoot, dirs: subdirs.length, assets: 3 }; | |
| 64 | +} | |
| 65 | + | |
| 66 | +const bootstrap_prd_site = { | |
| 67 | + name: 'bootstrap_prd_site', | |
| 68 | + description: '初始化项目 prd/ 站点目录(含 assets/shell.css + shell.js + search-index.json 占位)。', | |
| 69 | + inputSchema: { type: 'object', properties: {} }, | |
| 70 | + async execute(_input, ctx) { | |
| 71 | + _req(ctx, ['pid']); | |
| 72 | + return _bootstrapSite(pathResolver.resolvePrdSite(ctx.pid), 'prd'); | |
| 73 | + }, | |
| 74 | +}; | |
| 75 | + | |
| 76 | +const bootstrap_sdd_site = { | |
| 77 | + name: 'bootstrap_sdd_site', | |
| 78 | + description: '初始化项目 sdd/ 站点目录(含 mvcp/ + assets/)。', | |
| 79 | + inputSchema: { type: 'object', properties: {} }, | |
| 80 | + async execute(_input, ctx) { | |
| 81 | + _req(ctx, ['pid']); | |
| 82 | + return _bootstrapSite(pathResolver.resolveSddSite(ctx.pid), 'sdd'); | |
| 83 | + }, | |
| 84 | +}; | |
| 85 | + | |
| 86 | +// ──────────────────────────────────────────────────────────────── | |
| 87 | +// write_chapter / write_mvcp_page | |
| 88 | +// ──────────────────────────────────────────────────────────────── | |
| 89 | + | |
| 90 | +const write_chapter = { | |
| 91 | + name: 'write_chapter', | |
| 92 | + description: '写 PRD chapter (HTML)。kind=prd 时走 prd/{relPath};kind=sdd 时走 sdd/{relPath}。自动校验 + 归档旧版。', | |
| 93 | + inputSchema: { | |
| 94 | + type: 'object', | |
| 95 | + required: ['kind', 'relPath', 'html'], | |
| 96 | + properties: { | |
| 97 | + kind: { type: 'string', enum: ['prd', 'sdd'] }, | |
| 98 | + relPath: { type: 'string', minLength: 1 }, | |
| 99 | + html: { type: 'string', minLength: 1 }, | |
| 100 | + }, | |
| 101 | + }, | |
| 102 | + async execute({ kind, relPath, html }, ctx) { | |
| 103 | + _req(ctx, ['pid']); | |
| 104 | + const writeFn = kind === 'prd' ? docStore.writePrdChapter : docStore.writeSddChapter; | |
| 105 | + try { | |
| 106 | + const r = writeFn(ctx.pid, relPath, html); | |
| 107 | + return { ok: true, ...r }; | |
| 108 | + } catch (e) { | |
| 109 | + if (e.name === 'ValidationError') { | |
| 110 | + throw new ToolValidationError('html', `${e.message}: ${e.details?.join(' · ')}`); | |
| 111 | + } | |
| 112 | + throw e; | |
| 113 | + } | |
| 114 | + }, | |
| 115 | +}; | |
| 116 | + | |
| 117 | +const write_mvcp_page = { | |
| 118 | + name: 'write_mvcp_page', | |
| 119 | + description: '写 SDD MVCP 页 (mvcp/{model|view|control|process}.html)。等价于 write_chapter(kind=sdd, relPath=mvcp/{which}.html)。', | |
| 120 | + inputSchema: { | |
| 121 | + type: 'object', | |
| 122 | + required: ['which', 'html'], | |
| 123 | + properties: { | |
| 124 | + which: { type: 'string', enum: ['model', 'view', 'control', 'process'] }, | |
| 125 | + html: { type: 'string', minLength: 1 }, | |
| 126 | + }, | |
| 127 | + }, | |
| 128 | + async execute({ which, html }, ctx) { | |
| 129 | + return write_chapter.execute( | |
| 130 | + { kind: 'sdd', relPath: `mvcp/${which}.html`, html }, | |
| 131 | + ctx | |
| 132 | + ); | |
| 133 | + }, | |
| 134 | +}; | |
| 135 | + | |
| 136 | +// ──────────────────────────────────────────────────────────────── | |
| 137 | +// archive_doc | |
| 138 | +// ──────────────────────────────────────────────────────────────── | |
| 139 | + | |
| 140 | +const archive_doc = { | |
| 141 | + name: 'archive_doc', | |
| 142 | + description: '手动触发归档当前 prd/ 或 sdd/ 整站到 history/ (write_chapter 已自动归档单文件; 此工具用于整站快照)。', | |
| 143 | + inputSchema: { | |
| 144 | + type: 'object', | |
| 145 | + required: ['kind'], | |
| 146 | + properties: { | |
| 147 | + kind: { type: 'string', enum: ['prd', 'sdd'] }, | |
| 148 | + }, | |
| 149 | + }, | |
| 150 | + async execute({ kind }, ctx) { | |
| 151 | + _req(ctx, ['pid']); | |
| 152 | + const siteRoot = kind === 'prd' | |
| 153 | + ? pathResolver.resolvePrdSite(ctx.pid) | |
| 154 | + : pathResolver.resolveSddSite(ctx.pid); | |
| 155 | + if (!fs.existsSync(siteRoot)) { | |
| 156 | + throw new ToolValidationError('kind', `${kind}/ does not exist`); | |
| 157 | + } | |
| 158 | + | |
| 159 | + // 找 rev (扫所有 chapter article data-rev 取最大) | |
| 160 | + const rev = _detectSiteRev(siteRoot, kind); | |
| 161 | + | |
| 162 | + const historyDir = path.join( | |
| 163 | + pathResolver.resolveHistoryDir(ctx.pid), | |
| 164 | + `${kind}-rev-${rev}` | |
| 165 | + ); | |
| 166 | + _copyDir(siteRoot, historyDir); | |
| 167 | + return { ok: true, rev, archived_to: historyDir }; | |
| 168 | + }, | |
| 169 | +}; | |
| 170 | + | |
| 171 | +function _detectSiteRev(siteRoot, kind) { | |
| 172 | + let maxRev = 0; | |
| 173 | + function walk(dir) { | |
| 174 | + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { | |
| 175 | + const full = path.join(dir, e.name); | |
| 176 | + if (e.isDirectory()) { | |
| 177 | + if (e.name !== 'assets') walk(full); | |
| 178 | + } else if (e.name.endsWith('.html')) { | |
| 179 | + try { | |
| 180 | + const $ = cheerio.load(fs.readFileSync(full, 'utf8')); | |
| 181 | + const r = $(`article[data-doc="${kind}"]`).first().attr('data-rev'); | |
| 182 | + if (r && /^\d+$/.test(r)) { | |
| 183 | + const n = parseInt(r, 10); | |
| 184 | + if (n > maxRev) maxRev = n; | |
| 185 | + } | |
| 186 | + } catch { /* ignore */ } | |
| 187 | + } | |
| 188 | + } | |
| 189 | + } | |
| 190 | + walk(siteRoot); | |
| 191 | + return maxRev || 1; | |
| 192 | +} | |
| 193 | + | |
| 194 | +function _copyDir(src, dst) { | |
| 195 | + fs.mkdirSync(dst, { recursive: true }); | |
| 196 | + for (const e of fs.readdirSync(src, { withFileTypes: true })) { | |
| 197 | + const s = path.join(src, e.name); | |
| 198 | + const d = path.join(dst, e.name); | |
| 199 | + if (e.isDirectory()) _copyDir(s, d); | |
| 200 | + else fs.copyFileSync(s, d); | |
| 201 | + } | |
| 202 | +} | |
| 203 | + | |
| 204 | +// ──────────────────────────────────────────────────────────────── | |
| 205 | +// diff_html | |
| 206 | +// ──────────────────────────────────────────────────────────────── | |
| 207 | + | |
| 208 | +const diff_html = { | |
| 209 | + name: 'diff_html', | |
| 210 | + description: 'cheerio section 级 diff:对比 oldHtml 和 newHtml,返 added/changed/removed section data-rev 列表。', | |
| 211 | + inputSchema: { | |
| 212 | + type: 'object', | |
| 213 | + required: ['oldHtml', 'newHtml'], | |
| 214 | + properties: { | |
| 215 | + oldHtml: { type: 'string' }, | |
| 216 | + newHtml: { type: 'string' }, | |
| 217 | + }, | |
| 218 | + }, | |
| 219 | + async execute({ oldHtml, newHtml }, _ctx) { | |
| 220 | + const $o = cheerio.load(oldHtml || ''); | |
| 221 | + const $n = cheerio.load(newHtml || ''); | |
| 222 | + const oldSecs = new Map(); | |
| 223 | + const newSecs = new Map(); | |
| 224 | + $o('section[data-rev]').each((_, el) => { | |
| 225 | + const r = $o(el).attr('data-rev'); | |
| 226 | + oldSecs.set(r, $o(el).text().trim()); | |
| 227 | + }); | |
| 228 | + $n('section[data-rev]').each((_, el) => { | |
| 229 | + const r = $n(el).attr('data-rev'); | |
| 230 | + newSecs.set(r, $n(el).text().trim()); | |
| 231 | + }); | |
| 232 | + const added = [], changed = [], removed = []; | |
| 233 | + for (const [r, txt] of newSecs) { | |
| 234 | + if (!oldSecs.has(r)) added.push(r); | |
| 235 | + else if (oldSecs.get(r) !== txt) changed.push(r); | |
| 236 | + } | |
| 237 | + for (const [r] of oldSecs) { | |
| 238 | + if (!newSecs.has(r)) removed.push(r); | |
| 239 | + } | |
| 240 | + return { added, changed, removed }; | |
| 241 | + }, | |
| 242 | +}; | |
| 243 | + | |
| 244 | +// ──────────────────────────────────────────────────────────────── | |
| 245 | +// diff_units | |
| 246 | +// ──────────────────────────────────────────────────────────────── | |
| 247 | + | |
| 248 | +const diff_units = { | |
| 249 | + name: 'diff_units', | |
| 250 | + description: '对比新旧 units.json: added/changed(contract_hash 变化)/removed。如不传 oldUnits,从 history/最新 units-rev-N.json 读。', | |
| 251 | + inputSchema: { | |
| 252 | + type: 'object', | |
| 253 | + required: ['newUnits'], | |
| 254 | + properties: { | |
| 255 | + newUnits: { type: 'array' }, | |
| 256 | + oldUnits: { type: 'array' }, | |
| 257 | + }, | |
| 258 | + }, | |
| 259 | + async execute({ newUnits, oldUnits }, ctx) { | |
| 260 | + let oldList = oldUnits; | |
| 261 | + if (!oldList && ctx?.pid) { | |
| 262 | + // 尝试从 history/ 取最新归档 | |
| 263 | + const historyDir = pathResolver.resolveHistoryDir(ctx.pid); | |
| 264 | + if (fs.existsSync(historyDir)) { | |
| 265 | + const files = fs.readdirSync(historyDir).filter(f => /^units-rev-\d+\.json$/.test(f)); | |
| 266 | + if (files.length) { | |
| 267 | + files.sort(); | |
| 268 | + const last = files[files.length - 1]; | |
| 269 | + const old = JSON.parse(fs.readFileSync(path.join(historyDir, last), 'utf8')); | |
| 270 | + oldList = old.units || []; | |
| 271 | + } | |
| 272 | + } | |
| 273 | + } | |
| 274 | + return unitRegistry.diffUnits(oldList || [], newUnits); | |
| 275 | + }, | |
| 276 | +}; | |
| 277 | + | |
| 278 | +// ──────────────────────────────────────────────────────────────── | |
| 279 | +// echo_diff | |
| 280 | +// ──────────────────────────────────────────────────────────────── | |
| 281 | + | |
| 282 | +const echo_diff = { | |
| 283 | + name: 'echo_diff', | |
| 284 | + description: '发 SSE 事件 prd.updated 或 sdd.updated,告知 Dashboard 文档已变。', | |
| 285 | + inputSchema: { | |
| 286 | + type: 'object', | |
| 287 | + required: ['kind', 'rev', 'summary'], | |
| 288 | + properties: { | |
| 289 | + kind: { type: 'string', enum: ['prd', 'sdd'] }, | |
| 290 | + rev: { type: 'integer' }, | |
| 291 | + summary: { type: 'string', maxLength: 500 }, | |
| 292 | + sections_diff: { type: 'object' }, | |
| 293 | + }, | |
| 294 | + }, | |
| 295 | + async execute({ kind, rev, summary, sections_diff }, ctx) { | |
| 296 | + _req(ctx, ['pid']); | |
| 297 | + if (ctx.sseBroker) { | |
| 298 | + ctx.sseBroker.emit(ctx.pid, `${kind}.updated`, { | |
| 299 | + rev, summary, sections_diff: sections_diff || null, | |
| 300 | + }); | |
| 301 | + } | |
| 302 | + // 同步写 changelog | |
| 303 | + try { | |
| 304 | + changelogWriter.append(ctx.pid, { | |
| 305 | + kind: `${kind}_rev`, | |
| 306 | + rev, | |
| 307 | + by: ctx.agent || 'system', | |
| 308 | + trigger: 'agent', | |
| 309 | + summary, | |
| 310 | + diff: sections_diff || {}, | |
| 311 | + }); | |
| 312 | + } catch (e) { | |
| 313 | + // 已存在该 rev → 跳过;其他错误抛 | |
| 314 | + if (!/already/.test(e.message)) { /* swallow */ } | |
| 315 | + } | |
| 316 | + return { ok: true }; | |
| 317 | + }, | |
| 318 | +}; | |
| 319 | + | |
| 320 | +// ──────────────────────────────────────────────────────────────── | |
| 321 | +// update_search_index | |
| 322 | +// ──────────────────────────────────────────────────────────────── | |
| 323 | + | |
| 324 | +const update_search_index = { | |
| 325 | + name: 'update_search_index', | |
| 326 | + description: '重建 prd/ 或 sdd/ 站点的 search-index.json (复用 scripts/build-search-index.js 的逻辑)。', | |
| 327 | + inputSchema: { | |
| 328 | + type: 'object', | |
| 329 | + properties: { | |
| 330 | + target: { type: 'string', enum: ['prd', 'sdd', 'both'], default: 'both' }, | |
| 331 | + }, | |
| 332 | + }, | |
| 333 | + async execute({ target = 'both' }, ctx) { | |
| 334 | + _req(ctx, ['pid']); | |
| 335 | + const built = []; | |
| 336 | + if (target === 'prd' || target === 'both') { | |
| 337 | + built.push(_buildSiteIndex(pathResolver.resolvePrdSite(ctx.pid))); | |
| 338 | + } | |
| 339 | + if (target === 'sdd' || target === 'both') { | |
| 340 | + built.push(_buildSiteIndex(pathResolver.resolveSddSite(ctx.pid))); | |
| 341 | + } | |
| 342 | + return { ok: true, built }; | |
| 343 | + }, | |
| 344 | +}; | |
| 345 | + | |
| 346 | +function _buildSiteIndex(siteRoot) { | |
| 347 | + if (!fs.existsSync(siteRoot)) return { skipped: true, reason: 'site not exist' }; | |
| 348 | + const entries = []; | |
| 349 | + function walk(dir, prefix) { | |
| 350 | + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { | |
| 351 | + if (e.name === 'assets') continue; | |
| 352 | + const rel = prefix ? `${prefix}/${e.name}` : e.name; | |
| 353 | + const full = path.join(dir, e.name); | |
| 354 | + if (e.isDirectory()) walk(full, rel); | |
| 355 | + else if (e.name.endsWith('.html')) { | |
| 356 | + const html = fs.readFileSync(full, 'utf8'); | |
| 357 | + const title = _extractTitle(html) || rel; | |
| 358 | + const text = _stripTagsKeepText(html).slice(0, 2000); | |
| 359 | + entries.push({ f: rel, title, content: text }); | |
| 360 | + } | |
| 361 | + } | |
| 362 | + } | |
| 363 | + walk(siteRoot, ''); | |
| 364 | + | |
| 365 | + const idx = { | |
| 366 | + version: '1', generated: new Date().toISOString(), | |
| 367 | + pages: entries.length, entries, | |
| 368 | + }; | |
| 369 | + const out = path.join(siteRoot, 'assets', 'search-index.json'); | |
| 370 | + fs.mkdirSync(path.dirname(out), { recursive: true }); | |
| 371 | + fs.writeFileSync(out, JSON.stringify(idx)); | |
| 372 | + return { ok: true, pages: entries.length, path: out }; | |
| 373 | +} | |
| 374 | + | |
| 375 | +function _extractTitle(html) { | |
| 376 | + const m = html.match(/<title>([\s\S]*?)<\/title>/i); | |
| 377 | + return m ? m[1].trim() : null; | |
| 378 | +} | |
| 379 | + | |
| 380 | +function _stripTagsKeepText(s) { | |
| 381 | + return s | |
| 382 | + .replace(/<script[\s\S]*?<\/script>/gi, ' ') | |
| 383 | + .replace(/<style[\s\S]*?<\/style>/gi, ' ') | |
| 384 | + .replace(/<svg[\s\S]*?<\/svg>/gi, ' ') | |
| 385 | + .replace(/<[^>]+>/g, ' ') | |
| 386 | + .replace(/\s+/g, ' ') | |
| 387 | + .trim(); | |
| 388 | +} | |
| 389 | + | |
| 390 | +// ──────────────────────────────────────────────────────────────── | |
| 391 | +// validate_prd_site | |
| 392 | +// ──────────────────────────────────────────────────────────────── | |
| 393 | + | |
| 394 | +const REQUIRED_PRD_CHAPTERS = [ | |
| 395 | + 'index.html', | |
| 396 | + 'product/background.html', | |
| 397 | + 'product/personas.html', | |
| 398 | + 'product/scope.html', | |
| 399 | + 'feature/feature-tree.html', | |
| 400 | + 'flow/user-flows.html', | |
| 401 | +]; | |
| 402 | + | |
| 403 | +const EIGHT_TYPES = { | |
| 404 | + Tables: $ => $('table').length > 0, | |
| 405 | + Code: $ => $('pre').length > 0, | |
| 406 | + Illustrations: $ => $('svg').length > 0, | |
| 407 | + Interaction: $ => $('.tabs, details, nav').length > 0, | |
| 408 | + Spatial: $ => $('pre').filter((_,el) => $(el).text().includes('├──') || $(el).text().includes('└──')).length > 0, | |
| 409 | + Design: $ => $('.card').length > 0 || $('[class*=card]').length > 0, | |
| 410 | + Images: $ => $('svg, img').length > 0, | |
| 411 | + Workflows: $ => $('svg').filter((_,el) => /marker-end|stroke-dasharray|arrow/.test($(el).html() || '')).length > 0, | |
| 412 | +}; | |
| 413 | + | |
| 414 | +const validate_prd_site = { | |
| 415 | + name: 'validate_prd_site', | |
| 416 | + description: '整站校验 PRD: 必备章节存在 · cheerio 结构 · 8 类信息 ≥4。', | |
| 417 | + inputSchema: { type: 'object', properties: {} }, | |
| 418 | + async execute(_input, ctx) { | |
| 419 | + _req(ctx, ['pid']); | |
| 420 | + const root = pathResolver.resolvePrdSite(ctx.pid); | |
| 421 | + const issues = []; | |
| 422 | + if (!fs.existsSync(root)) { | |
| 423 | + return { ok: false, issues: ['prd/ site not initialized'] }; | |
| 424 | + } | |
| 425 | + | |
| 426 | + for (const f of REQUIRED_PRD_CHAPTERS) { | |
| 427 | + const p = path.join(root, f); | |
| 428 | + if (!fs.existsSync(p)) issues.push(`missing chapter: ${f}`); | |
| 429 | + } | |
| 430 | + | |
| 431 | + // 扫所有 chapter | |
| 432 | + const allFiles = []; | |
| 433 | + function walk(dir, prefix) { | |
| 434 | + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { | |
| 435 | + if (e.name === 'assets') continue; | |
| 436 | + const rel = prefix ? `${prefix}/${e.name}` : e.name; | |
| 437 | + const full = path.join(dir, e.name); | |
| 438 | + if (e.isDirectory()) walk(full, rel); | |
| 439 | + else if (e.name.endsWith('.html')) allFiles.push({ rel, full }); | |
| 440 | + } | |
| 441 | + } | |
| 442 | + walk(root, ''); | |
| 443 | + | |
| 444 | + for (const { rel, full } of allFiles) { | |
| 445 | + const html = fs.readFileSync(full, 'utf8'); | |
| 446 | + const v = docStore.validateHTML(html, 'prd'); | |
| 447 | + if (!v.ok) { | |
| 448 | + for (const e of v.errors) issues.push(`${rel}: ${e}`); | |
| 449 | + } | |
| 450 | + // 8 类信息检查 | |
| 451 | + try { | |
| 452 | + const $ = cheerio.load(html); | |
| 453 | + const covered = Object.entries(EIGHT_TYPES).filter(([_, fn]) => fn($)); | |
| 454 | + if (covered.length < 4) { | |
| 455 | + issues.push(`${rel}: only ${covered.length}/8 info types (need ≥4)`); | |
| 456 | + } | |
| 457 | + } catch { /* ignore */ } | |
| 458 | + } | |
| 459 | + | |
| 460 | + return { ok: issues.length === 0, issues }; | |
| 461 | + }, | |
| 462 | +}; | |
| 463 | + | |
| 464 | +// ──────────────────────────────────────────────────────────────── | |
| 465 | +// validate_sdd_consistency | |
| 466 | +// ──────────────────────────────────────────────────────────────── | |
| 467 | + | |
| 468 | +const validate_sdd_consistency = { | |
| 469 | + name: 'validate_sdd_consistency', | |
| 470 | + description: 'SDD ↔ units.json ↔ integration/spec.json 三方一致性: 每 Unit 在 sdd/mvcp/ 出现 · involves ⊆ units · contract_hash 一致 · DAG 无环。', | |
| 471 | + inputSchema: { type: 'object', properties: {} }, | |
| 472 | + async execute(_input, ctx) { | |
| 473 | + _req(ctx, ['pid']); | |
| 474 | + const issues = []; | |
| 475 | + | |
| 476 | + const unitsJson = docStore.readUnits(ctx.pid); | |
| 477 | + if (!unitsJson) return { ok: false, issues: ['units.json missing'] }; | |
| 478 | + | |
| 479 | + const v = docStore.validateUnitsSchema(unitsJson); | |
| 480 | + if (!v.ok) issues.push(...v.errors); | |
| 481 | + | |
| 482 | + // Unit 在 sdd/mvcp/ 至少出现 1 次 | |
| 483 | + const sddRoot = pathResolver.resolveSddSite(ctx.pid); | |
| 484 | + const mvcpDir = path.join(sddRoot, 'mvcp'); | |
| 485 | + let sddText = ''; | |
| 486 | + if (fs.existsSync(mvcpDir)) { | |
| 487 | + for (const f of fs.readdirSync(mvcpDir)) { | |
| 488 | + if (f.endsWith('.html')) sddText += fs.readFileSync(path.join(mvcpDir, f), 'utf8'); | |
| 489 | + } | |
| 490 | + } | |
| 491 | + for (const u of unitsJson.units) { | |
| 492 | + if (!sddText.includes(`data-unit-id="${u.id}"`) && | |
| 493 | + !sddText.includes(`data-def-name="${u.id}"`)) { | |
| 494 | + issues.push(`unit ${u.id} not referenced in sdd/mvcp/`); | |
| 495 | + } | |
| 496 | + } | |
| 497 | + | |
| 498 | + // integration/spec.json involves 检查 | |
| 499 | + const spec = docStore.readIntegrationSpec(ctx.pid); | |
| 500 | + if (spec && Array.isArray(spec.cases)) { | |
| 501 | + const ids = new Set(unitsJson.units.map(u => u.id)); | |
| 502 | + for (const c of spec.cases) { | |
| 503 | + for (const u of (c.involves || [])) { | |
| 504 | + if (!ids.has(u)) issues.push(`integration case ${c.id} involves unknown unit: ${u}`); | |
| 505 | + } | |
| 506 | + } | |
| 507 | + } | |
| 508 | + | |
| 509 | + return { ok: issues.length === 0, issues }; | |
| 510 | + }, | |
| 511 | +}; | |
| 512 | + | |
| 513 | +// ──────────────────────────────────────────────────────────────── | |
| 514 | +// hash_contract | |
| 515 | +// ──────────────────────────────────────────────────────────────── | |
| 516 | + | |
| 517 | +const hash_contract = { | |
| 518 | + name: 'hash_contract', | |
| 519 | + description: 'canonical JSON + sha256 截 16 hex。算 Unit contract 的 hash。', | |
| 520 | + inputSchema: { | |
| 521 | + type: 'object', | |
| 522 | + required: ['contract'], | |
| 523 | + properties: { | |
| 524 | + contract: { type: 'object' }, | |
| 525 | + }, | |
| 526 | + }, | |
| 527 | + async execute({ contract }, _ctx) { | |
| 528 | + return { ok: true, hash: docStore.hashContract(contract) }; | |
| 529 | + }, | |
| 530 | +}; | |
| 531 | + | |
| 532 | +// ──────────────────────────────────────────────────────────────── | |
| 533 | +function _req(ctx, keys) { | |
| 534 | + for (const k of keys) { | |
| 535 | + if (!ctx || ctx[k] == null) { | |
| 536 | + const { ToolMissingDependencyError } = require('./_errors'); | |
| 537 | + throw new ToolMissingDependencyError(`ctx.${k}`); | |
| 538 | + } | |
| 539 | + } | |
| 540 | +} | |
| 541 | + | |
| 542 | +module.exports = { | |
| 543 | + bootstrap_prd_site, | |
| 544 | + bootstrap_sdd_site, | |
| 545 | + write_chapter, | |
| 546 | + write_mvcp_page, | |
| 547 | + archive_doc, | |
| 548 | + diff_html, | |
| 549 | + diff_units, | |
| 550 | + echo_diff, | |
| 551 | + update_search_index, | |
| 552 | + validate_prd_site, | |
| 553 | + validate_sdd_consistency, | |
| 554 | + hash_contract, | |
| 555 | +}; | ... | ... |
server/src/tools/devloop/integrator-tools.js
0 → 100644
| 1 | +/** | |
| 2 | + * DevLoop · integrator-tools (6 个) | |
| 3 | + * port_picker 分配空闲 localhost 端口(避开 reserved) | |
| 4 | + * spawn_units 批量启动 be Unit 进程,每个独立端口 | |
| 5 | + * health_wait poll /api/health 直到 200 或超时 | |
| 6 | + * run_case 执行 integration spec 单个 case(含 {{stepN.x}} 占位符) | |
| 7 | + * parse_placeholder 解析 {{stepN.field}} 形式占位符 | |
| 8 | + * collect_stderr 从子进程取最近 stderr 行 | |
| 9 | + * | |
| 10 | + * 规格: docs/prd/newfunction/_spec/agents-integrator.md · §7 | |
| 11 | + */ | |
| 12 | + | |
| 13 | +const net = require('net'); | |
| 14 | +const fs = require('fs'); | |
| 15 | +const path = require('path'); | |
| 16 | +const { spawn } = require('child_process'); | |
| 17 | + | |
| 18 | +const pathResolver = require('../../../services/devloop/path-resolver'); | |
| 19 | +const { ToolValidationError, ToolMissingDependencyError } = require('./_errors'); | |
| 20 | + | |
| 21 | +const RESERVED_PORTS = new Set([3000, 3010, 5173, 5432, 6379, 8080, 8443]); | |
| 22 | + | |
| 23 | +// ──────────────────── port_picker ──────────────────── | |
| 24 | + | |
| 25 | +function isPortFree(port) { | |
| 26 | + return new Promise(resolve => { | |
| 27 | + const srv = net.createServer(); | |
| 28 | + srv.once('error', () => resolve(false)); | |
| 29 | + srv.once('listening', () => srv.close(() => resolve(true))); | |
| 30 | + srv.listen(port, '127.0.0.1'); | |
| 31 | + }); | |
| 32 | +} | |
| 33 | + | |
| 34 | +const port_picker = { | |
| 35 | + name: 'port_picker', | |
| 36 | + description: '分配 N 个空闲 localhost 端口(18000-19000),避开系统保留', | |
| 37 | + inputSchema: { | |
| 38 | + type: 'object', | |
| 39 | + required: ['count'], | |
| 40 | + properties: { | |
| 41 | + count: { type: 'integer', minimum: 1, maximum: 20 }, | |
| 42 | + }, | |
| 43 | + }, | |
| 44 | + async execute({ count }, _ctx) { | |
| 45 | + const picked = []; | |
| 46 | + let attempts = 0; | |
| 47 | + while (picked.length < count && attempts < 100) { | |
| 48 | + attempts++; | |
| 49 | + const p = 18000 + Math.floor(Math.random() * 1000); | |
| 50 | + if (RESERVED_PORTS.has(p) || picked.includes(p)) continue; | |
| 51 | + if (await isPortFree(p)) picked.push(p); | |
| 52 | + } | |
| 53 | + if (picked.length < count) { | |
| 54 | + throw new Error(`no free port after ${attempts} attempts`); | |
| 55 | + } | |
| 56 | + return { ok: true, ports: picked }; | |
| 57 | + }, | |
| 58 | +}; | |
| 59 | + | |
| 60 | +// ──────────────────── spawn_units ──────────────────── | |
| 61 | + | |
| 62 | +const _activeHandles = new Map(); // pid → ProcessHandle[] | |
| 63 | + | |
| 64 | +const spawn_units = { | |
| 65 | + name: 'spawn_units', | |
| 66 | + description: '批量启动 be Unit 真实进程(每个独立端口 + env 注入)', | |
| 67 | + inputSchema: { | |
| 68 | + type: 'object', | |
| 69 | + required: ['be_units', 'ports', 'log_path'], | |
| 70 | + properties: { | |
| 71 | + be_units: { type: 'array', items: { type: 'string' } }, | |
| 72 | + ports: { type: 'object' }, | |
| 73 | + log_path: { type: 'string' }, | |
| 74 | + }, | |
| 75 | + }, | |
| 76 | + async execute({ be_units, ports, log_path }, ctx) { | |
| 77 | + _req(ctx, ['pid']); | |
| 78 | + fs.mkdirSync(path.dirname(log_path), { recursive: true }); | |
| 79 | + const logStream = fs.createWriteStream(log_path, { flags: 'a' }); | |
| 80 | + const handles = []; | |
| 81 | + | |
| 82 | + for (const unitId of be_units) { | |
| 83 | + const port = ports[unitId]; | |
| 84 | + if (!port) throw new ToolValidationError('ports', `no port for ${unitId}`); | |
| 85 | + const cwd = pathResolver.resolveUnitDir(ctx.pid, unitId); | |
| 86 | + const entry = path.join(cwd, 'code', 'server.js'); | |
| 87 | + if (!fs.existsSync(entry)) { | |
| 88 | + throw new ToolValidationError('be_units', `${unitId} has no code/server.js`); | |
| 89 | + } | |
| 90 | + const child = spawn('node', [entry], { | |
| 91 | + cwd, | |
| 92 | + env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', UNIT_ID: unitId }, | |
| 93 | + detached: false, | |
| 94 | + stdio: ['ignore', 'pipe', 'pipe'], | |
| 95 | + }); | |
| 96 | + const prefix = `[${unitId}:${port}] `; | |
| 97 | + child.stdout.on('data', c => logStream.write(prefix + c)); | |
| 98 | + child.stderr.on('data', c => logStream.write(prefix + c)); | |
| 99 | + handles.push({ unitId, port, pid: child.pid, proc: child }); | |
| 100 | + } | |
| 101 | + _activeHandles.set(ctx.pid, handles); | |
| 102 | + return { ok: true, handles: handles.map(h => ({ unitId: h.unitId, port: h.port, pid: h.pid })) }; | |
| 103 | + }, | |
| 104 | +}; | |
| 105 | + | |
| 106 | +async function killAll(pid, graceMs = 5000) { | |
| 107 | + const handles = _activeHandles.get(pid) || []; | |
| 108 | + for (const h of handles) { | |
| 109 | + try { h.proc.kill('SIGTERM'); } catch {} | |
| 110 | + } | |
| 111 | + await new Promise(r => setTimeout(r, graceMs)); | |
| 112 | + for (const h of handles) { | |
| 113 | + try { if (!h.proc.killed) h.proc.kill('SIGKILL'); } catch {} | |
| 114 | + } | |
| 115 | + _activeHandles.delete(pid); | |
| 116 | +} | |
| 117 | + | |
| 118 | +// ──────────────────── health_wait ──────────────────── | |
| 119 | + | |
| 120 | +const health_wait = { | |
| 121 | + name: 'health_wait', | |
| 122 | + description: 'poll /api/health (或指定 url) 直到 200 或超时', | |
| 123 | + inputSchema: { | |
| 124 | + type: 'object', | |
| 125 | + required: ['url'], | |
| 126 | + properties: { | |
| 127 | + url: { type: 'string' }, | |
| 128 | + timeout_ms: { type: 'integer', default: 30_000 }, | |
| 129 | + interval_ms: { type: 'integer', default: 500 }, | |
| 130 | + }, | |
| 131 | + }, | |
| 132 | + async execute({ url, timeout_ms = 30_000, interval_ms = 500 }, _ctx) { | |
| 133 | + const deadline = Date.now() + timeout_ms; | |
| 134 | + while (Date.now() < deadline) { | |
| 135 | + try { | |
| 136 | + const r = await fetch(url); | |
| 137 | + if (r.ok) return { ok: true, status: r.status, waited_ms: timeout_ms - (deadline - Date.now()) }; | |
| 138 | + } catch { /* not ready yet */ } | |
| 139 | + await new Promise(r => setTimeout(r, interval_ms)); | |
| 140 | + } | |
| 141 | + return { ok: false, timeout: true }; | |
| 142 | + }, | |
| 143 | +}; | |
| 144 | + | |
| 145 | +// ──────────────────── parse_placeholder ──────────────────── | |
| 146 | + | |
| 147 | +function parsePlaceholders(obj, prev) { | |
| 148 | + const s = JSON.stringify(obj); | |
| 149 | + const resolved = s.replace(/\{\{step(\d+)\.([\w.]+)\}\}/g, (_match, n, p) => { | |
| 150 | + const r = prev[parseInt(n, 10)]; | |
| 151 | + if (!r) throw new Error(`placeholder step${n} has no result yet`); | |
| 152 | + const parts = p.split('.'); | |
| 153 | + let v = r.body; | |
| 154 | + for (const k of parts) v = v?.[k]; | |
| 155 | + return v != null ? String(v) : ''; | |
| 156 | + }); | |
| 157 | + return JSON.parse(resolved); | |
| 158 | +} | |
| 159 | + | |
| 160 | +const parse_placeholder = { | |
| 161 | + name: 'parse_placeholder', | |
| 162 | + description: '解析 {{stepN.field}} 占位符,从前面 step 的 response.body 取值替换', | |
| 163 | + inputSchema: { | |
| 164 | + type: 'object', | |
| 165 | + required: ['template', 'previous_steps'], | |
| 166 | + properties: { | |
| 167 | + template: {}, | |
| 168 | + previous_steps: { type: 'array' }, | |
| 169 | + }, | |
| 170 | + }, | |
| 171 | + async execute({ template, previous_steps }, _ctx) { | |
| 172 | + return { ok: true, resolved: parsePlaceholders(template, previous_steps) }; | |
| 173 | + }, | |
| 174 | +}; | |
| 175 | + | |
| 176 | +// ──────────────────── run_case ──────────────────── | |
| 177 | + | |
| 178 | +const run_case = { | |
| 179 | + name: 'run_case', | |
| 180 | + description: '执行 integration spec 单个 case (含 placeholder 解析 + step expect 校验)', | |
| 181 | + inputSchema: { | |
| 182 | + type: 'object', | |
| 183 | + required: ['case_spec', 'ports'], | |
| 184 | + properties: { | |
| 185 | + case_spec: { type: 'object' }, | |
| 186 | + ports: { type: 'object' }, | |
| 187 | + timeout_ms: { type: 'integer', default: 30_000 }, | |
| 188 | + }, | |
| 189 | + }, | |
| 190 | + async execute({ case_spec, ports, timeout_ms = 30_000 }, _ctx) { | |
| 191 | + const deadline = Date.now() + timeout_ms; | |
| 192 | + const stepResults = []; | |
| 193 | + const httpTrace = []; | |
| 194 | + | |
| 195 | + for (let i = 0; i < case_spec.steps.length; i++) { | |
| 196 | + if (Date.now() > deadline) { | |
| 197 | + return { ok: false, failed_step: i, reason: 'timeout', httpTrace }; | |
| 198 | + } | |
| 199 | + const step = case_spec.steps[i]; | |
| 200 | + let body, headers; | |
| 201 | + try { | |
| 202 | + body = parsePlaceholders(step.body || {}, stepResults); | |
| 203 | + headers = parsePlaceholders(step.headers || {}, stepResults); | |
| 204 | + } catch (e) { | |
| 205 | + return { ok: false, failed_step: i, reason: e.message, httpTrace }; | |
| 206 | + } | |
| 207 | + | |
| 208 | + const [method, p] = step.call.split(' '); | |
| 209 | + const port = ports[step.unit]; | |
| 210 | + if (!port) { | |
| 211 | + return { ok: false, failed_step: i, reason: `no port for unit ${step.unit}`, httpTrace }; | |
| 212 | + } | |
| 213 | + const url = `http://127.0.0.1:${port}` + p; | |
| 214 | + const startedAt = Date.now(); | |
| 215 | + let r, json = {}; | |
| 216 | + try { | |
| 217 | + r = await fetch(url, { | |
| 218 | + method, | |
| 219 | + headers: { 'Content-Type': 'application/json', ...headers }, | |
| 220 | + body: method === 'GET' ? undefined : JSON.stringify(body), | |
| 221 | + }); | |
| 222 | + try { json = await r.json(); } catch { /* maybe empty body */ } | |
| 223 | + } catch (e) { | |
| 224 | + return { ok: false, failed_step: i, got: { status: 0, error: e.message }, httpTrace }; | |
| 225 | + } | |
| 226 | + | |
| 227 | + httpTrace.push({ | |
| 228 | + ts: startedAt, step: i, unit: step.unit, method, url, | |
| 229 | + status: r.status, body: json, duration_ms: Date.now() - startedAt, | |
| 230 | + }); | |
| 231 | + stepResults.push({ status: r.status, body: json }); | |
| 232 | + | |
| 233 | + if (!_checkExpect(step.expect, { status: r.status, body: json })) { | |
| 234 | + return { ok: false, failed_step: i, got: { status: r.status, body: json }, httpTrace }; | |
| 235 | + } | |
| 236 | + } | |
| 237 | + return { ok: true, httpTrace, steps_ran: case_spec.steps.length }; | |
| 238 | + }, | |
| 239 | +}; | |
| 240 | + | |
| 241 | +function _checkExpect(expect, got) { | |
| 242 | + if (!expect) return true; | |
| 243 | + if (expect.status && expect.status !== got.status) return false; | |
| 244 | + // expect.json 简化检查:仅检查 key 存在性 + 类型;不做 JSONPath | |
| 245 | + if (expect.json && typeof expect.json === 'object') { | |
| 246 | + for (const [k, v] of Object.entries(expect.json)) { | |
| 247 | + if (got.body?.[k] === undefined) return false; | |
| 248 | + if (typeof v === 'string' && v.startsWith('$.')) continue; // JSONPath 简化:存在即可 | |
| 249 | + } | |
| 250 | + } | |
| 251 | + return true; | |
| 252 | +} | |
| 253 | + | |
| 254 | +// ──────────────────── collect_stderr ──────────────────── | |
| 255 | + | |
| 256 | +const collect_stderr = { | |
| 257 | + name: 'collect_stderr', | |
| 258 | + description: '从 process.log 取最近 N 行 stderr 节选(供 BugTriager 用)', | |
| 259 | + inputSchema: { | |
| 260 | + type: 'object', | |
| 261 | + required: ['log_path'], | |
| 262 | + properties: { | |
| 263 | + log_path: { type: 'string' }, | |
| 264 | + lines: { type: 'integer', default: 20 }, | |
| 265 | + }, | |
| 266 | + }, | |
| 267 | + async execute({ log_path, lines = 20 }, _ctx) { | |
| 268 | + if (!fs.existsSync(log_path)) return { ok: false, reason: 'log not exist', excerpt: '' }; | |
| 269 | + const raw = fs.readFileSync(log_path, 'utf8'); | |
| 270 | + const all = raw.split('\n'); | |
| 271 | + const excerpt = all.slice(-lines).join('\n'); | |
| 272 | + return { ok: true, excerpt }; | |
| 273 | + }, | |
| 274 | +}; | |
| 275 | + | |
| 276 | +function _req(ctx, keys) { | |
| 277 | + for (const k of keys) { | |
| 278 | + if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`); | |
| 279 | + } | |
| 280 | +} | |
| 281 | + | |
| 282 | +module.exports = { | |
| 283 | + port_picker, | |
| 284 | + spawn_units, | |
| 285 | + health_wait, | |
| 286 | + run_case, | |
| 287 | + parse_placeholder, | |
| 288 | + collect_stderr, | |
| 289 | + // 内部导出(kill / 测试用) | |
| 290 | + killAll, | |
| 291 | +}; | ... | ... |
server/src/tools/devloop/persistence-tools.js
0 → 100644
| 1 | +/** | |
| 2 | + * DevLoop · persistence-tools | |
| 3 | + * | |
| 4 | + * 持久化 5 件套 (M-10/11/12/13 接入): | |
| 5 | + * - write_todolist 初始化 agent-state/{agent}-todo.json | |
| 6 | + * - update_todo_item 更新单条 item + 同步 progress_pct | |
| 7 | + * - update_runtime_state 修 agent-state/{agent}.json (current_step/total_steps) | |
| 8 | + * - timeline_append append timeline.ndjson | |
| 9 | + * - record_memory 写 memory/devloop/{agent}/ 下 .md | |
| 10 | + * | |
| 11 | + * 所有 Agent 都能用(toolPolicy 里写)。 | |
| 12 | + * 每个工具签名: { name, description, inputSchema, execute(input, ctx) } | |
| 13 | + * | |
| 14 | + * 规格: docs/prd/newfunction/_spec/sdd-control.md · C-15 | |
| 15 | + * docs/prd/newfunction/_spec/sdd-model.md · M-10/11/12/13 | |
| 16 | + * docs/prd/newfunction/_spec/arch-tool-registry.md · §③ | |
| 17 | + */ | |
| 18 | + | |
| 19 | +const fs = require('fs'); | |
| 20 | +const path = require('path'); | |
| 21 | + | |
| 22 | +const pathResolver = require('../../../services/devloop/path-resolver'); | |
| 23 | +const timelineLogger = require('../../../services/devloop/timeline-logger'); | |
| 24 | +const { ToolValidationError, ToolMissingDependencyError } = require('./_errors'); | |
| 25 | + | |
| 26 | +// ──────────────────────────────────────────────────────────────── | |
| 27 | +// write_todolist | |
| 28 | +// ──────────────────────────────────────────────────────────────── | |
| 29 | + | |
| 30 | +const write_todolist = { | |
| 31 | + name: 'write_todolist', | |
| 32 | + description: '初始化 agent-state/{agent}-todo.json,写入完整 todolist。Agent 开工第一件事。', | |
| 33 | + inputSchema: { | |
| 34 | + type: 'object', | |
| 35 | + required: ['items'], | |
| 36 | + properties: { | |
| 37 | + items: { | |
| 38 | + type: 'array', | |
| 39 | + minItems: 1, | |
| 40 | + items: { | |
| 41 | + type: 'object', | |
| 42 | + required: ['id', 'description'], | |
| 43 | + properties: { | |
| 44 | + id: { type: 'string', pattern: '^step-\\d{3}$' }, | |
| 45 | + description: { type: 'string' }, | |
| 46 | + depends_on: { type: 'array', items: { type: 'string' } }, | |
| 47 | + }, | |
| 48 | + }, | |
| 49 | + }, | |
| 50 | + }, | |
| 51 | + }, | |
| 52 | + async execute({ items }, ctx) { | |
| 53 | + _requireCtx(ctx, ['pid', 'agent', 'call_id']); | |
| 54 | + const p = pathResolver.resolveAgentTodolist(ctx.pid, ctx.agent); | |
| 55 | + const todolist = { | |
| 56 | + agent: ctx.agent, | |
| 57 | + call_id: ctx.call_id, | |
| 58 | + created_at: new Date().toISOString(), | |
| 59 | + items: items.map((it, i) => ({ | |
| 60 | + id: it.id, | |
| 61 | + description: it.description, | |
| 62 | + depends_on: it.depends_on || [], | |
| 63 | + status: i === 0 ? 'doing' : 'todo', | |
| 64 | + ...(i === 0 ? { started_at: new Date().toISOString() } : {}), | |
| 65 | + })), | |
| 66 | + current: items[0]?.id || null, | |
| 67 | + progress_pct: 0, | |
| 68 | + }; | |
| 69 | + fs.mkdirSync(path.dirname(p), { recursive: true }); | |
| 70 | + fs.writeFileSync(p, JSON.stringify(todolist, null, 2)); | |
| 71 | + | |
| 72 | + if (ctx.sseBroker) { | |
| 73 | + ctx.sseBroker.emit(ctx.pid, 'agent.todolist', { | |
| 74 | + agent: ctx.agent, call_id: ctx.call_id, items: todolist.items, | |
| 75 | + }); | |
| 76 | + } | |
| 77 | + return { ok: true, total: items.length, path: p }; | |
| 78 | + }, | |
| 79 | +}; | |
| 80 | + | |
| 81 | +// ──────────────────────────────────────────────────────────────── | |
| 82 | +// update_todo_item | |
| 83 | +// ──────────────────────────────────────────────────────────────── | |
| 84 | + | |
| 85 | +const update_todo_item = { | |
| 86 | + name: 'update_todo_item', | |
| 87 | + description: '更新 todolist 中单条 item 的 status;自动重算 progress_pct + 发 SSE。', | |
| 88 | + inputSchema: { | |
| 89 | + type: 'object', | |
| 90 | + required: ['id', 'status'], | |
| 91 | + properties: { | |
| 92 | + id: { type: 'string', pattern: '^step-\\d{3}$' }, | |
| 93 | + status: { | |
| 94 | + type: 'string', | |
| 95 | + enum: ['todo', 'doing', 'done', 'failed'], | |
| 96 | + }, | |
| 97 | + output_ref: { type: 'string' }, | |
| 98 | + error: { type: 'string' }, | |
| 99 | + }, | |
| 100 | + }, | |
| 101 | + async execute({ id, status, output_ref, error }, ctx) { | |
| 102 | + _requireCtx(ctx, ['pid', 'agent']); | |
| 103 | + const p = pathResolver.resolveAgentTodolist(ctx.pid, ctx.agent); | |
| 104 | + if (!fs.existsSync(p)) { | |
| 105 | + throw new ToolValidationError('todolist', `not found at ${p} (call write_todolist first)`); | |
| 106 | + } | |
| 107 | + const todolist = JSON.parse(fs.readFileSync(p, 'utf8')); | |
| 108 | + const item = todolist.items.find(it => it.id === id); | |
| 109 | + if (!item) { | |
| 110 | + throw new ToolValidationError('id', `unknown step: ${id}`); | |
| 111 | + } | |
| 112 | + const from = item.status; | |
| 113 | + item.status = status; | |
| 114 | + if (status === 'doing' && !item.started_at) { | |
| 115 | + item.started_at = new Date().toISOString(); | |
| 116 | + } | |
| 117 | + if (status === 'done' || status === 'failed') { | |
| 118 | + item.finished_at = new Date().toISOString(); | |
| 119 | + if (output_ref) item.output_ref = output_ref; | |
| 120 | + if (error) item.error = error; | |
| 121 | + } | |
| 122 | + | |
| 123 | + // 重算 progress_pct | |
| 124 | + const done = todolist.items.filter(it => it.status === 'done').length; | |
| 125 | + todolist.progress_pct = Math.round((done / todolist.items.length) * 100); | |
| 126 | + | |
| 127 | + // 找下一个 todo 设为 doing | |
| 128 | + if (status === 'done') { | |
| 129 | + const next = todolist.items.find(it => it.status === 'todo'); | |
| 130 | + if (next) { | |
| 131 | + next.status = 'doing'; | |
| 132 | + next.started_at = new Date().toISOString(); | |
| 133 | + todolist.current = next.id; | |
| 134 | + } else { | |
| 135 | + todolist.current = null; | |
| 136 | + } | |
| 137 | + } | |
| 138 | + | |
| 139 | + fs.writeFileSync(p, JSON.stringify(todolist, null, 2)); | |
| 140 | + | |
| 141 | + if (ctx.sseBroker) { | |
| 142 | + ctx.sseBroker.emit(ctx.pid, 'agent.step', { | |
| 143 | + agent: ctx.agent, call_id: ctx.call_id || todolist.call_id, | |
| 144 | + item_id: id, from, to: status, | |
| 145 | + progress_pct: todolist.progress_pct, | |
| 146 | + current_desc: todolist.items.find(it => it.id === todolist.current)?.description, | |
| 147 | + }); | |
| 148 | + } | |
| 149 | + return { ok: true, progress_pct: todolist.progress_pct }; | |
| 150 | + }, | |
| 151 | +}; | |
| 152 | + | |
| 153 | +// ──────────────────────────────────────────────────────────────── | |
| 154 | +// update_runtime_state | |
| 155 | +// ──────────────────────────────────────────────────────────────── | |
| 156 | + | |
| 157 | +const update_runtime_state = { | |
| 158 | + name: 'update_runtime_state', | |
| 159 | + description: '更新 M-10 AgentRuntimeState 的 current_step / total_steps(不改 status)。', | |
| 160 | + inputSchema: { | |
| 161 | + type: 'object', | |
| 162 | + required: ['current_step', 'total_steps'], | |
| 163 | + properties: { | |
| 164 | + current_step: { type: 'integer', minimum: 0 }, | |
| 165 | + total_steps: { type: 'integer', minimum: 1 }, | |
| 166 | + }, | |
| 167 | + }, | |
| 168 | + async execute({ current_step, total_steps }, ctx) { | |
| 169 | + _requireCtx(ctx, ['pid', 'agent']); | |
| 170 | + const p = pathResolver.resolveAgentState(ctx.pid, ctx.agent); | |
| 171 | + if (!fs.existsSync(p)) { | |
| 172 | + throw new ToolValidationError('state', `runtime state not found at ${p}`); | |
| 173 | + } | |
| 174 | + const state = JSON.parse(fs.readFileSync(p, 'utf8')); | |
| 175 | + state.current_step = current_step; | |
| 176 | + state.total_steps = total_steps; | |
| 177 | + state.updated_at = new Date().toISOString(); | |
| 178 | + fs.writeFileSync(p, JSON.stringify(state, null, 2)); | |
| 179 | + return { ok: true, current_step, total_steps }; | |
| 180 | + }, | |
| 181 | +}; | |
| 182 | + | |
| 183 | +// ──────────────────────────────────────────────────────────────── | |
| 184 | +// timeline_append | |
| 185 | +// ──────────────────────────────────────────────────────────────── | |
| 186 | + | |
| 187 | +const timeline_append = { | |
| 188 | + name: 'timeline_append', | |
| 189 | + description: 'Append 一条事件到 timeline.ndjson (M-13)。', | |
| 190 | + inputSchema: { | |
| 191 | + type: 'object', | |
| 192 | + required: ['kind', 'summary'], | |
| 193 | + properties: { | |
| 194 | + kind: { type: 'string' }, | |
| 195 | + ref: { type: 'string' }, | |
| 196 | + summary: { type: 'string', maxLength: 280 }, | |
| 197 | + payload: { type: 'object' }, | |
| 198 | + }, | |
| 199 | + }, | |
| 200 | + async execute({ kind, ref, summary, payload }, ctx) { | |
| 201 | + _requireCtx(ctx, ['pid', 'agent']); | |
| 202 | + const ev = timelineLogger.append(ctx.pid, { | |
| 203 | + source: ctx.agent, | |
| 204 | + kind, ref, summary, payload, | |
| 205 | + }); | |
| 206 | + return { ok: true, ts: ev.ts }; | |
| 207 | + }, | |
| 208 | +}; | |
| 209 | + | |
| 210 | +// ──────────────────────────────────────────────────────────────── | |
| 211 | +// record_memory | |
| 212 | +// ──────────────────────────────────────────────────────────────── | |
| 213 | + | |
| 214 | +const record_memory = { | |
| 215 | + name: 'record_memory', | |
| 216 | + description: '在 memory/devloop/{agent}/ 下写一条 .md (M-11)。同 topic 是 upsert。', | |
| 217 | + inputSchema: { | |
| 218 | + type: 'object', | |
| 219 | + required: ['topic', 'content', 'relevance'], | |
| 220 | + properties: { | |
| 221 | + topic: { type: 'string', minLength: 2 }, | |
| 222 | + content: { type: 'string', minLength: 10 }, | |
| 223 | + relevance: { type: 'number', minimum: 0, maximum: 1 }, | |
| 224 | + }, | |
| 225 | + }, | |
| 226 | + async execute({ topic, content, relevance }, ctx) { | |
| 227 | + _requireCtx(ctx, ['agent', 'company_id']); | |
| 228 | + const dir = pathResolver.resolveMemoryDir(ctx.company_id, ctx.agent); | |
| 229 | + fs.mkdirSync(dir, { recursive: true }); | |
| 230 | + | |
| 231 | + const slug = topic.toLowerCase() | |
| 232 | + .replace(/[^a-z0-9一-龥]+/g, '-') | |
| 233 | + .replace(/^-+|-+$/g, ''); | |
| 234 | + const p = path.join(dir, `${slug}.md`); | |
| 235 | + | |
| 236 | + let existing = null; | |
| 237 | + if (fs.existsSync(p)) { | |
| 238 | + const raw = fs.readFileSync(p, 'utf8'); | |
| 239 | + const m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); | |
| 240 | + if (m) { | |
| 241 | + const fm = {}; | |
| 242 | + for (const line of m[1].split('\n')) { | |
| 243 | + const kv = line.match(/^(\w+):\s*(.*)$/); | |
| 244 | + if (kv) fm[kv[1]] = kv[2].trim(); | |
| 245 | + } | |
| 246 | + existing = { fm, body: m[2] }; | |
| 247 | + } | |
| 248 | + } | |
| 249 | + | |
| 250 | + const projects = existing?.fm?.project_refs | |
| 251 | + ? existing.fm.project_refs.replace(/[\[\]]/g, '').split(',').map(s => s.trim()).filter(Boolean) | |
| 252 | + : []; | |
| 253 | + if (ctx.pid && !projects.includes(ctx.pid)) projects.push(ctx.pid); | |
| 254 | + | |
| 255 | + const hits = existing?.fm?.hits ? (parseInt(existing.fm.hits, 10) + 1) : 1; | |
| 256 | + const frontmatter = [ | |
| 257 | + '---', | |
| 258 | + `agent: ${ctx.agent}`, | |
| 259 | + `topic: ${topic}`, | |
| 260 | + `project_refs: [${projects.join(', ')}]`, | |
| 261 | + `last_updated: ${new Date().toISOString()}`, | |
| 262 | + `relevance_score: ${relevance}`, | |
| 263 | + `hits: ${hits}`, | |
| 264 | + '---', | |
| 265 | + '', | |
| 266 | + ].join('\n'); | |
| 267 | + | |
| 268 | + fs.writeFileSync(p, frontmatter + content + '\n'); | |
| 269 | + return { ok: true, path: p, upserted: !!existing, hits }; | |
| 270 | + }, | |
| 271 | +}; | |
| 272 | + | |
| 273 | +// ──────────────────────────────────────────────────────────────── | |
| 274 | +// Helpers | |
| 275 | +// ──────────────────────────────────────────────────────────────── | |
| 276 | + | |
| 277 | +function _requireCtx(ctx, keys) { | |
| 278 | + for (const k of keys) { | |
| 279 | + if (ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`); | |
| 280 | + } | |
| 281 | +} | |
| 282 | + | |
| 283 | +module.exports = { | |
| 284 | + write_todolist, | |
| 285 | + update_todo_item, | |
| 286 | + update_runtime_state, | |
| 287 | + timeline_append, | |
| 288 | + record_memory, | |
| 289 | +}; | ... | ... |
server/src/tools/devloop/tester-tools.js
0 → 100644
| 1 | +/** | |
| 2 | + * DevLoop · tester-tools (5 个) | |
| 3 | + * parse_vitest vitest JSON reporter 解析 → result.json + hint 生成 | |
| 4 | + * hint_builder 从 failure stack + expected/got 启发式生成 hint | |
| 5 | + * bug_open 调 bugTracker.open + 自动 dedup | |
| 6 | + * bug_transition bug 状态转换 | |
| 7 | + * bug_list 按 filter 查 bug | |
| 8 | + * | |
| 9 | + * 规格: docs/prd/newfunction/_spec/agents-tester.md · §7 | |
| 10 | + */ | |
| 11 | + | |
| 12 | +const fs = require('fs'); | |
| 13 | +const path = require('path'); | |
| 14 | + | |
| 15 | +const pathResolver = require('../../../services/devloop/path-resolver'); | |
| 16 | +const bugTracker = require('../../../services/devloop/bug-tracker'); | |
| 17 | +const { ToolValidationError, ToolMissingDependencyError } = require('./_errors'); | |
| 18 | + | |
| 19 | +// ──────────────────── hint_builder ──────────────────── | |
| 20 | + | |
| 21 | +const HINT_PATTERNS = [ | |
| 22 | + { match: s => /cannot read.*?of undefined/i.test(s), | |
| 23 | + hint: () => '某个对象/字段是 undefined,先做存在性判断再访问' }, | |
| 24 | + { match: s => /TypeError.*compare/i.test(s), | |
| 25 | + hint: () => '调用 bcrypt.compare 前先确认 user 存在;否则 null 参数会抛 TypeError' }, | |
| 26 | + { match: s => /SQLITE_CONSTRAINT/i.test(s), | |
| 27 | + hint: () => '数据库约束冲突(unique/foreign key),业务层应该先查再写' }, | |
| 28 | + { match: s => /timeout/i.test(s), | |
| 29 | + hint: () => '某个异步操作超时;检查是否漏 await 或外部依赖未 mock' }, | |
| 30 | +]; | |
| 31 | + | |
| 32 | +function buildHint(ctx) { | |
| 33 | + const stack = ctx.stack || ''; | |
| 34 | + for (const p of HINT_PATTERNS) { | |
| 35 | + if (p.match(stack)) return p.hint(ctx); | |
| 36 | + } | |
| 37 | + // status code 启发式 | |
| 38 | + const m = String(stack).match(/[Ee]xpected[:\s]+(\d{3})[\s\S]{0,200}?[Rr]eceived[:\s]+(\d{3})/); | |
| 39 | + const e = m ? parseInt(m[1], 10) : ctx.expected; | |
| 40 | + const g = m ? parseInt(m[2], 10) : ctx.got; | |
| 41 | + if (typeof e === 'number' && typeof g === 'number') { | |
| 42 | + if (e === 401 && g === 500) return '凭据错误应返 401 不是 500;检查认证路径是否 try/catch 兜底'; | |
| 43 | + if (e === 400 && g === 500) return '参数校验错应返 400 不是 500;在 route 层检查后再传 service'; | |
| 44 | + if (e === 404 && g === 200) return '不存在的资源不应返 200;返回前判断查询结果'; | |
| 45 | + return `期望 ${e} 收 ${g},检查对应分支处理`; | |
| 46 | + } | |
| 47 | + return `${ctx.testName || 'unknown'}: 检查 stack 推断原因 (Tester 应补充)`; | |
| 48 | +} | |
| 49 | + | |
| 50 | +const hint_builder = { | |
| 51 | + name: 'hint_builder', | |
| 52 | + description: '从 failure stack/expected/got 生成给 Coder 反思用的 hint(1-2 句人话)', | |
| 53 | + inputSchema: { | |
| 54 | + type: 'object', | |
| 55 | + properties: { | |
| 56 | + testName: { type: 'string' }, | |
| 57 | + expected: {}, | |
| 58 | + got: {}, | |
| 59 | + stack: { type: 'string' }, | |
| 60 | + }, | |
| 61 | + }, | |
| 62 | + async execute(failure, _ctx) { | |
| 63 | + return { ok: true, hint: buildHint(failure) }; | |
| 64 | + }, | |
| 65 | +}; | |
| 66 | + | |
| 67 | +// ──────────────────── parse_vitest ──────────────────── | |
| 68 | + | |
| 69 | +const parse_vitest = { | |
| 70 | + name: 'parse_vitest', | |
| 71 | + description: 'vitest --reporter=json 输出 → 结构化 result.json + auto-fill hint for failures', | |
| 72 | + inputSchema: { | |
| 73 | + type: 'object', | |
| 74 | + required: ['raw_json_path'], | |
| 75 | + properties: { | |
| 76 | + raw_json_path: { type: 'string' }, | |
| 77 | + result_out_path: { type: 'string', description: '可选,result.json 写入位置' }, | |
| 78 | + }, | |
| 79 | + }, | |
| 80 | + async execute({ raw_json_path, result_out_path }, _ctx) { | |
| 81 | + if (!fs.existsSync(raw_json_path)) { | |
| 82 | + return { | |
| 83 | + ok: false, crashed: true, | |
| 84 | + crash_reason: 'vitest raw json missing — process likely crashed early', | |
| 85 | + summary: { total: 0, passed: 0, failed: 0, skipped: 0 }, | |
| 86 | + failures: [], | |
| 87 | + }; | |
| 88 | + } | |
| 89 | + let raw; | |
| 90 | + try { | |
| 91 | + raw = JSON.parse(fs.readFileSync(raw_json_path, 'utf8')); | |
| 92 | + } catch (e) { | |
| 93 | + return { ok: false, crashed: true, crash_reason: 'invalid vitest json: ' + e.message }; | |
| 94 | + } | |
| 95 | + | |
| 96 | + const summary = { total: 0, passed: 0, failed: 0, skipped: 0 }; | |
| 97 | + const failures = []; | |
| 98 | + | |
| 99 | + const testResults = raw.testResults || raw.tests || []; | |
| 100 | + for (const file of testResults) { | |
| 101 | + const asserts = file.assertionResults || file.assertions || []; | |
| 102 | + for (const a of asserts) { | |
| 103 | + summary.total++; | |
| 104 | + if (a.status === 'passed') summary.passed++; | |
| 105 | + else if (a.status === 'failed') { | |
| 106 | + summary.failed++; | |
| 107 | + const stack = (a.failureMessages || a.errors || []).join('\n'); | |
| 108 | + const { expected, got } = _extractAssertion(stack); | |
| 109 | + failures.push({ | |
| 110 | + test: a.fullName || a.title, | |
| 111 | + expected, got, stack, | |
| 112 | + hint: buildHint({ testName: a.fullName, expected, got, stack }), | |
| 113 | + }); | |
| 114 | + } else summary.skipped++; | |
| 115 | + } | |
| 116 | + } | |
| 117 | + | |
| 118 | + const result = { | |
| 119 | + ok: summary.failed === 0 && summary.total > 0, | |
| 120 | + crashed: false, | |
| 121 | + summary, failures, | |
| 122 | + duration_ms: raw.testResults?.[0]?.perfStats?.runtime || 0, | |
| 123 | + }; | |
| 124 | + | |
| 125 | + if (result_out_path) { | |
| 126 | + fs.mkdirSync(path.dirname(result_out_path), { recursive: true }); | |
| 127 | + fs.writeFileSync(result_out_path, JSON.stringify(result, null, 2)); | |
| 128 | + } | |
| 129 | + return result; | |
| 130 | + }, | |
| 131 | +}; | |
| 132 | + | |
| 133 | +function _extractAssertion(stack) { | |
| 134 | + const exp = stack.match(/Expected[:\s]+([^\n]+)/); | |
| 135 | + const got = stack.match(/(?:Received|Actual)[:\s]+([^\n]+)/); | |
| 136 | + return { | |
| 137 | + expected: exp ? exp[1].trim() : null, | |
| 138 | + got: got ? got[1].trim() : null, | |
| 139 | + }; | |
| 140 | +} | |
| 141 | + | |
| 142 | +// ──────────────────── bug_open / transition / list ──────────────────── | |
| 143 | + | |
| 144 | +const bug_open = { | |
| 145 | + name: 'bug_open', | |
| 146 | + description: 'append bugs.ndjson 一条 status=open;dedupKey 命中 open/fixing 时复用旧 id', | |
| 147 | + inputSchema: { | |
| 148 | + type: 'object', | |
| 149 | + required: ['title'], | |
| 150 | + properties: { | |
| 151 | + source: { type: 'string', enum: ['unit_test','integration_test','triage','human'] }, | |
| 152 | + unit_id: { type: 'string' }, | |
| 153 | + run_id: { type: 'string' }, | |
| 154 | + test_name: { type: 'string' }, | |
| 155 | + category: { type: 'string' }, | |
| 156 | + severity: { type: 'string', enum: ['critical','high','medium','low'] }, | |
| 157 | + title: { type: 'string', maxLength: 120 }, | |
| 158 | + detail: { type: 'string' }, | |
| 159 | + affected_units: { type: 'array', items: { type: 'string' } }, | |
| 160 | + }, | |
| 161 | + }, | |
| 162 | + async execute(bug, ctx) { | |
| 163 | + _req(ctx, ['pid']); | |
| 164 | + // 注入 sseBroker + timelineLogger (如果 ctx 有) | |
| 165 | + if (ctx.sseBroker) bugTracker.attachSseBroker(ctx.sseBroker); | |
| 166 | + if (ctx.timelineLogger) bugTracker.attachTimelineLogger(ctx.timelineLogger); | |
| 167 | + const b = bugTracker.open(ctx.pid, { ...bug, by: ctx.agent || 'tester' }); | |
| 168 | + return { ok: true, bug_id: b.id, dedup: b.discovered_at !== new Date().toISOString().slice(0, b.discovered_at.length) }; | |
| 169 | + }, | |
| 170 | +}; | |
| 171 | + | |
| 172 | +const bug_transition = { | |
| 173 | + name: 'bug_transition', | |
| 174 | + description: 'bug 状态转换 (open → fixing → fixed → verified 等),走 P-9 状态机', | |
| 175 | + inputSchema: { | |
| 176 | + type: 'object', | |
| 177 | + required: ['bug_id', 'to'], | |
| 178 | + properties: { | |
| 179 | + bug_id: { type: 'string' }, | |
| 180 | + to: { type: 'string', enum: ['open','fixing','fixed','verified','wontfix','duplicate'] }, | |
| 181 | + note: { type: 'string' }, | |
| 182 | + fix_refs: { type: 'array', items: { type: 'string' } }, | |
| 183 | + duplicate_of: { type: 'string' }, | |
| 184 | + }, | |
| 185 | + }, | |
| 186 | + async execute({ bug_id, to, note, fix_refs, duplicate_of }, ctx) { | |
| 187 | + _req(ctx, ['pid']); | |
| 188 | + if (ctx.sseBroker) bugTracker.attachSseBroker(ctx.sseBroker); | |
| 189 | + if (ctx.timelineLogger) bugTracker.attachTimelineLogger(ctx.timelineLogger); | |
| 190 | + try { | |
| 191 | + bugTracker.transition(ctx.pid, bug_id, { | |
| 192 | + to, by: ctx.agent || 'system', note, fix_refs, duplicate_of, | |
| 193 | + }); | |
| 194 | + return { ok: true }; | |
| 195 | + } catch (e) { | |
| 196 | + if (e.name === 'InvalidBugTransitionError') { | |
| 197 | + throw new ToolValidationError('to', e.message); | |
| 198 | + } | |
| 199 | + throw e; | |
| 200 | + } | |
| 201 | + }, | |
| 202 | +}; | |
| 203 | + | |
| 204 | +const bug_list = { | |
| 205 | + name: 'bug_list', | |
| 206 | + description: '列 bug,可按 status/severity/unit_id 过滤;返回每个 bug.id 的最新态', | |
| 207 | + inputSchema: { | |
| 208 | + type: 'object', | |
| 209 | + properties: { | |
| 210 | + status: { type: 'array', items: { type: 'string' } }, | |
| 211 | + severity: { type: 'array', items: { type: 'string' } }, | |
| 212 | + unit_id: { type: 'string' }, | |
| 213 | + since: { type: 'string' }, | |
| 214 | + }, | |
| 215 | + }, | |
| 216 | + async execute(filter, ctx) { | |
| 217 | + _req(ctx, ['pid']); | |
| 218 | + const bugs = bugTracker.list(ctx.pid, filter || {}); | |
| 219 | + return { ok: true, bugs, total: bugs.length }; | |
| 220 | + }, | |
| 221 | +}; | |
| 222 | + | |
| 223 | +function _req(ctx, keys) { | |
| 224 | + for (const k of keys) { | |
| 225 | + if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`); | |
| 226 | + } | |
| 227 | +} | |
| 228 | + | |
| 229 | +module.exports = { hint_builder, parse_vitest, bug_open, bug_transition, bug_list }; | ... | ... |
server/src/tools/devloop/tool-registry.js
0 → 100644
| 1 | +/** | |
| 2 | + * DevLoop Tool Registry — loadAllTools() | |
| 3 | + * | |
| 4 | + * 把 server/src/tools/devloop/ 下所有 *-tools.js 加载并按工具 name 聚合到 Map。 | |
| 5 | + * tool-manager / ClaudeCliProtocol 启动 Agent 时通过 toolPolicy 取这里的 ToolDef。 | |
| 6 | + * | |
| 7 | + * 规格: docs/prd/newfunction/_spec/arch-tool-registry.md · §④ | |
| 8 | + */ | |
| 9 | + | |
| 10 | +const path = require('path'); | |
| 11 | +const fs = require('fs'); | |
| 12 | + | |
| 13 | +// 工具模块清单(手动 require,避免动态 require 出错) | |
| 14 | +const TOOL_MODULES = [ | |
| 15 | + './persistence-tools', | |
| 16 | + './doc-tools', | |
| 17 | + './coder-tools', | |
| 18 | + './tester-tools', | |
| 19 | + './integrator-tools', | |
| 20 | + './triager-tools', | |
| 21 | +]; | |
| 22 | + | |
| 23 | +let cached = null; | |
| 24 | + | |
| 25 | +function loadAllTools() { | |
| 26 | + if (cached) return cached; | |
| 27 | + | |
| 28 | + const registry = new Map(); | |
| 29 | + | |
| 30 | + for (const mod of TOOL_MODULES) { | |
| 31 | + const fullPath = path.join(__dirname, mod + '.js'); | |
| 32 | + if (!fs.existsSync(fullPath)) continue; | |
| 33 | + const tools = require(mod); | |
| 34 | + for (const key of Object.keys(tools)) { | |
| 35 | + const tool = tools[key]; | |
| 36 | + if (tool && typeof tool === 'object' && tool.name && typeof tool.execute === 'function') { | |
| 37 | + if (registry.has(tool.name)) { | |
| 38 | + throw new Error(`Duplicate tool name: ${tool.name} (in ${mod})`); | |
| 39 | + } | |
| 40 | + registry.set(tool.name, tool); | |
| 41 | + } | |
| 42 | + } | |
| 43 | + } | |
| 44 | + | |
| 45 | + cached = registry; | |
| 46 | + return registry; | |
| 47 | +} | |
| 48 | + | |
| 49 | +/** | |
| 50 | + * 列工具名(用于 _registry.json toolPolicy 校验) | |
| 51 | + */ | |
| 52 | +function listToolNames() { | |
| 53 | + return Array.from(loadAllTools().keys()).sort(); | |
| 54 | +} | |
| 55 | + | |
| 56 | +/** | |
| 57 | + * 按 toolPolicy 取一组工具(含 LemonBoss 既有工具是允许的,本函数只过滤 devloop 的) | |
| 58 | + */ | |
| 59 | +function getDevLoopTools(toolPolicy) { | |
| 60 | + const all = loadAllTools(); | |
| 61 | + const out = []; | |
| 62 | + for (const name of toolPolicy) { | |
| 63 | + if (all.has(name)) out.push(all.get(name)); | |
| 64 | + } | |
| 65 | + return out; | |
| 66 | +} | |
| 67 | + | |
| 68 | +/** | |
| 69 | + * 调度:根据 name 找工具,执行(中央调用入口,外部 ACP 调用方使用)。 | |
| 70 | + * - 校验工具存在 | |
| 71 | + * - 校验 input 符合 inputSchema(简化版:只校验 required + type) | |
| 72 | + * - 调 tool.execute(input, ctx) | |
| 73 | + * - 错误标准化 | |
| 74 | + */ | |
| 75 | +async function dispatch(name, input, ctx) { | |
| 76 | + const all = loadAllTools(); | |
| 77 | + const tool = all.get(name); | |
| 78 | + if (!tool) { | |
| 79 | + throw new Error(`Unknown tool: ${name}`); | |
| 80 | + } | |
| 81 | + _validateInput(input, tool.inputSchema, name); | |
| 82 | + return tool.execute(input, ctx); | |
| 83 | +} | |
| 84 | + | |
| 85 | +function _validateInput(input, schema, name) { | |
| 86 | + if (!schema || typeof schema !== 'object') return; | |
| 87 | + if (schema.required && Array.isArray(schema.required)) { | |
| 88 | + for (const k of schema.required) { | |
| 89 | + if (input == null || input[k] === undefined) { | |
| 90 | + const e = new Error(`Tool ${name}: missing required field "${k}"`); | |
| 91 | + e.code = 'TOOL_INPUT_INVALID'; | |
| 92 | + throw e; | |
| 93 | + } | |
| 94 | + } | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +function _resetForTesting() { | |
| 99 | + cached = null; | |
| 100 | +} | |
| 101 | + | |
| 102 | +module.exports = { | |
| 103 | + loadAllTools, | |
| 104 | + listToolNames, | |
| 105 | + getDevLoopTools, | |
| 106 | + dispatch, | |
| 107 | + _resetForTesting, | |
| 108 | + TOOL_MODULES, | |
| 109 | +}; | ... | ... |
server/src/tools/devloop/triager-tools.js
0 → 100644
| 1 | +/** | |
| 2 | + * DevLoop · triager-tools (4 个) | |
| 3 | + * extract_evidence 验证 evidence 字符串在原文 grep 命中 | |
| 4 | + * category_classifier 启发式 4 类粗判(contract_mismatch / impl_bug / spec_ambiguous / infra_flake) | |
| 5 | + * diff_contract_vs_impl 对比 contract.json 声明 vs code 实际行为(轻量,看 endpoint 存在性) | |
| 6 | + * find_similar_bug 在 bugs.ndjson 找历史相似 bug | |
| 7 | + * | |
| 8 | + * 规格: docs/prd/newfunction/_spec/agents-triager.md · §7 | |
| 9 | + */ | |
| 10 | + | |
| 11 | +const fs = require('fs'); | |
| 12 | +const path = require('path'); | |
| 13 | + | |
| 14 | +const pathResolver = require('../../../services/devloop/path-resolver'); | |
| 15 | +const bugTracker = require('../../../services/devloop/bug-tracker'); | |
| 16 | +const docStore = require('../../../services/devloop/doc-store'); | |
| 17 | +const { ToolMissingDependencyError } = require('./_errors'); | |
| 18 | + | |
| 19 | +// ──────────────────── extract_evidence ──────────────────── | |
| 20 | + | |
| 21 | +function verifyOneEvidence(projectRoot, evidence) { | |
| 22 | + const sep = evidence.indexOf(': '); | |
| 23 | + if (sep < 0) return { ok: false, reason: 'malformed: need "path: quote"' }; | |
| 24 | + const relPath = evidence.slice(0, sep).replace(/^['"]|['"]$/g, '').trim(); | |
| 25 | + const quote = evidence.slice(sep + 2).replace(/^['"]|['"]$/g, '').trim(); | |
| 26 | + const norm = s => s.replace(/\s+/g, ' ').toLowerCase(); | |
| 27 | + | |
| 28 | + // 支持 "path:lineno" 形式 | |
| 29 | + let pathOnly = relPath; | |
| 30 | + let line = null; | |
| 31 | + const lineMatch = relPath.match(/^(.+):(\d+)$/); | |
| 32 | + if (lineMatch) { | |
| 33 | + pathOnly = lineMatch[1]; | |
| 34 | + line = parseInt(lineMatch[2], 10); | |
| 35 | + } | |
| 36 | + const abs = path.join(projectRoot, pathOnly); | |
| 37 | + if (!fs.existsSync(abs)) { | |
| 38 | + return { ok: false, reason: 'file not found: ' + pathOnly }; | |
| 39 | + } | |
| 40 | + const content = fs.readFileSync(abs, 'utf8'); | |
| 41 | + if (line) { | |
| 42 | + const lines = content.split('\n'); | |
| 43 | + const lineContent = lines[line - 1] || ''; | |
| 44 | + if (norm(lineContent).includes(norm(quote))) { | |
| 45 | + return { ok: true, hit_file: pathOnly, line }; | |
| 46 | + } | |
| 47 | + } | |
| 48 | + if (norm(content).includes(norm(quote))) { | |
| 49 | + return { ok: true, hit_file: pathOnly }; | |
| 50 | + } | |
| 51 | + return { ok: false, reason: 'quote not found in file', quote, file: pathOnly }; | |
| 52 | +} | |
| 53 | + | |
| 54 | +const extract_evidence = { | |
| 55 | + name: 'extract_evidence', | |
| 56 | + description: '验证 evidence 列表每条能在原文 grep 命中(防 Triager 编造证据)', | |
| 57 | + inputSchema: { | |
| 58 | + type: 'object', | |
| 59 | + required: ['evidence'], | |
| 60 | + properties: { | |
| 61 | + evidence: { type: 'array', items: { type: 'string' } }, | |
| 62 | + }, | |
| 63 | + }, | |
| 64 | + async execute({ evidence }, ctx) { | |
| 65 | + _req(ctx, ['pid']); | |
| 66 | + const projectRoot = pathResolver.resolveProjectRoot(ctx.pid); | |
| 67 | + const results = evidence.map(e => ({ evidence: e, ...verifyOneEvidence(projectRoot, e) })); | |
| 68 | + return { | |
| 69 | + ok: results.every(r => r.ok), | |
| 70 | + results, | |
| 71 | + failed: results.filter(r => !r.ok), | |
| 72 | + }; | |
| 73 | + }, | |
| 74 | +}; | |
| 75 | + | |
| 76 | +// ──────────────────── category_classifier ──────────────────── | |
| 77 | + | |
| 78 | +const category_classifier = { | |
| 79 | + name: 'category_classifier', | |
| 80 | + description: '启发式粗判:contract_mismatch / impl_bug / spec_ambiguous / infra_flake', | |
| 81 | + inputSchema: { | |
| 82 | + type: 'object', | |
| 83 | + required: ['result_json', 'process_log'], | |
| 84 | + properties: { | |
| 85 | + result_json: { type: 'object' }, | |
| 86 | + process_log: { type: 'string' }, | |
| 87 | + sdd_text: { type: 'string' }, | |
| 88 | + }, | |
| 89 | + }, | |
| 90 | + async execute({ result_json, process_log, sdd_text = '' }, _ctx) { | |
| 91 | + const hints = []; | |
| 92 | + const score = { contract_mismatch: 0, impl_bug: 0, spec_ambiguous: 0, infra_flake: 0 }; | |
| 93 | + | |
| 94 | + // infra_flake 信号 | |
| 95 | + const infraSignals = [ | |
| 96 | + /EADDRINUSE/, /ECONNREFUSED/, /ETIMEDOUT/, /EAI_AGAIN/, | |
| 97 | + /Cannot find module/, /port already in use/i, | |
| 98 | + /health check timeout/i, | |
| 99 | + ]; | |
| 100 | + if (infraSignals.some(re => re.test(process_log || ''))) { | |
| 101 | + score.infra_flake += 3; | |
| 102 | + hints.push('infra signal in process.log'); | |
| 103 | + } | |
| 104 | + | |
| 105 | + // spec_ambiguous 信号 | |
| 106 | + const failTxt = JSON.stringify(result_json || {}); | |
| 107 | + const failTokens = [...new Set(failTxt.toLowerCase().match(/[a-z]{4,}/g) || [])] | |
| 108 | + .filter(t => !['function','expected','received','stack','message','error'].includes(t)); | |
| 109 | + const sddLc = sdd_text.toLowerCase(); | |
| 110 | + const tokensNotInSdd = failTokens.filter(t => sddLc && !sddLc.includes(t)); | |
| 111 | + if (failTokens.length > 0 && tokensNotInSdd.length >= Math.max(2, failTokens.length / 2)) { | |
| 112 | + score.spec_ambiguous += 2; | |
| 113 | + hints.push('key tokens not found in SDD: ' + tokensNotInSdd.slice(0, 5).join(',')); | |
| 114 | + } | |
| 115 | + | |
| 116 | + // impl_bug 信号:5xx got vs 4xx expected | |
| 117 | + const fail0 = result_json?.failures?.[0]; | |
| 118 | + if (fail0?.got?.status >= 500 && fail0?.expected?.status >= 400 && fail0?.expected?.status < 500) { | |
| 119 | + score.impl_bug += 2; | |
| 120 | + hints.push('5xx got vs 4xx expected → impl_bug'); | |
| 121 | + } | |
| 122 | + | |
| 123 | + // contract_mismatch 兜底(默认) | |
| 124 | + if (Object.values(score).every(v => v === 0)) { | |
| 125 | + score.contract_mismatch += 1; | |
| 126 | + hints.push('no strong signal → default contract_mismatch'); | |
| 127 | + } | |
| 128 | + | |
| 129 | + const sorted = Object.entries(score).sort(([,a],[,b]) => b - a); | |
| 130 | + const [topCat, topScore] = sorted[0]; | |
| 131 | + let confidence; | |
| 132 | + if (topScore >= 3) confidence = 0.85; | |
| 133 | + else if (topScore === 2) confidence = 0.65; | |
| 134 | + else if (topScore === 1) confidence = 0.45; | |
| 135 | + else confidence = 0.30; | |
| 136 | + | |
| 137 | + return { ok: true, category: topCat, confidence, hints, score }; | |
| 138 | + }, | |
| 139 | +}; | |
| 140 | + | |
| 141 | +// ──────────────────── diff_contract_vs_impl ──────────────────── | |
| 142 | + | |
| 143 | +const diff_contract_vs_impl = { | |
| 144 | + name: 'diff_contract_vs_impl', | |
| 145 | + description: '轻量对比:contract.json 声明的 endpoint 是否在 code/ 文本中出现(只看签名,不验语义)', | |
| 146 | + inputSchema: { | |
| 147 | + type: 'object', | |
| 148 | + required: ['unit_id'], | |
| 149 | + properties: { unit_id: { type: 'string' } }, | |
| 150 | + }, | |
| 151 | + async execute({ unit_id }, ctx) { | |
| 152 | + _req(ctx, ['pid']); | |
| 153 | + const unitDir = pathResolver.resolveUnitDir(ctx.pid, unit_id); | |
| 154 | + const contractPath = path.join(unitDir, 'contract.json'); | |
| 155 | + if (!fs.existsSync(contractPath)) { | |
| 156 | + return { ok: false, reason: 'contract.json missing' }; | |
| 157 | + } | |
| 158 | + const contract = JSON.parse(fs.readFileSync(contractPath, 'utf8')); | |
| 159 | + const codeDir = path.join(unitDir, 'code'); | |
| 160 | + if (!fs.existsSync(codeDir)) { | |
| 161 | + return { ok: false, reason: 'code/ missing' }; | |
| 162 | + } | |
| 163 | + | |
| 164 | + // 把 code/ 全部文本拼起来 | |
| 165 | + let codeBlob = ''; | |
| 166 | + function walk(dir) { | |
| 167 | + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { | |
| 168 | + const full = path.join(dir, e.name); | |
| 169 | + if (e.isDirectory()) walk(full); | |
| 170 | + else if (/\.(js|ts|vue|jsx|tsx|mjs|cjs)$/.test(e.name)) { | |
| 171 | + codeBlob += fs.readFileSync(full, 'utf8') + '\n'; | |
| 172 | + } | |
| 173 | + } | |
| 174 | + } | |
| 175 | + walk(codeDir); | |
| 176 | + | |
| 177 | + const divergences = []; | |
| 178 | + const endpoints = contract.exports?.http || []; | |
| 179 | + for (const ep of endpoints) { | |
| 180 | + if (!codeBlob.includes(ep.path)) { | |
| 181 | + divergences.push(`declared ${ep.method} ${ep.path} not found in code`); | |
| 182 | + } | |
| 183 | + } | |
| 184 | + return { matches: divergences.length === 0, divergences }; | |
| 185 | + }, | |
| 186 | +}; | |
| 187 | + | |
| 188 | +// ──────────────────── find_similar_bug ──────────────────── | |
| 189 | + | |
| 190 | +const find_similar_bug = { | |
| 191 | + name: 'find_similar_bug', | |
| 192 | + description: '在 bugs.ndjson 找历史相似 bug(同 unit + 类似 test_name)', | |
| 193 | + inputSchema: { | |
| 194 | + type: 'object', | |
| 195 | + required: ['signature'], | |
| 196 | + properties: { | |
| 197 | + signature: { | |
| 198 | + type: 'object', | |
| 199 | + properties: { | |
| 200 | + unit_id: { type: 'string' }, | |
| 201 | + test_name: { type: 'string' }, | |
| 202 | + status: { type: 'array', items: { type: 'string' } }, | |
| 203 | + }, | |
| 204 | + }, | |
| 205 | + }, | |
| 206 | + }, | |
| 207 | + async execute({ signature }, ctx) { | |
| 208 | + _req(ctx, ['pid']); | |
| 209 | + const bugs = bugTracker.list(ctx.pid, { | |
| 210 | + unit_id: signature.unit_id, | |
| 211 | + status: signature.status, | |
| 212 | + }); | |
| 213 | + const matches = bugs.filter(b => { | |
| 214 | + if (!signature.test_name) return true; | |
| 215 | + return b.test_name === signature.test_name || | |
| 216 | + (b.test_name && b.test_name.includes(signature.test_name)); | |
| 217 | + }); | |
| 218 | + return { ok: true, matches, total: matches.length }; | |
| 219 | + }, | |
| 220 | +}; | |
| 221 | + | |
| 222 | +function _req(ctx, keys) { | |
| 223 | + for (const k of keys) { | |
| 224 | + if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`); | |
| 225 | + } | |
| 226 | +} | |
| 227 | + | |
| 228 | +module.exports = { | |
| 229 | + extract_evidence, | |
| 230 | + category_classifier, | |
| 231 | + diff_contract_vs_impl, | |
| 232 | + find_similar_bug, | |
| 233 | +}; | ... | ... |
server/tests/devloop/tools/bash-json-guard.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * bash-json-guard 单测 | |
| 3 | + */ | |
| 4 | + | |
| 5 | +const { jsonGuard, bashHooks, INLINE_JSON_PATTERNS } = require('../../../src/tools/devloop/bash-json-guard'); | |
| 6 | + | |
| 7 | +describe('bash-json-guard', () => { | |
| 8 | + describe('jsonGuard', () => { | |
| 9 | + it('passes plain commands', () => { | |
| 10 | + expect(jsonGuard('ls -la').ok).toBe(true); | |
| 11 | + expect(jsonGuard('node server.js').ok).toBe(true); | |
| 12 | + }); | |
| 13 | + | |
| 14 | + it('passes curl with @file', () => { | |
| 15 | + expect(jsonGuard('curl -X POST -d @/tmp/payload.json http://x').ok).toBe(true); | |
| 16 | + }); | |
| 17 | + | |
| 18 | + it('blocks curl -d single-quoted inline JSON', () => { | |
| 19 | + const r = jsonGuard(`curl -d '{"k":"v"}' http://x`); | |
| 20 | + expect(r.ok).toBe(false); | |
| 21 | + expect(r.reason).toMatch(/inline JSON/i); | |
| 22 | + }); | |
| 23 | + | |
| 24 | + it('blocks curl --data inline JSON', () => { | |
| 25 | + const r = jsonGuard(`curl --data '{"k":"v"}' http://x`); | |
| 26 | + expect(r.ok).toBe(false); | |
| 27 | + }); | |
| 28 | + | |
| 29 | + it('blocks curl --data-raw inline JSON', () => { | |
| 30 | + const r = jsonGuard(`curl --data-raw '{"a":1}' http://x`); | |
| 31 | + expect(r.ok).toBe(false); | |
| 32 | + }); | |
| 33 | + | |
| 34 | + it('blocks curl --data-binary inline JSON', () => { | |
| 35 | + const r = jsonGuard(`curl --data-binary '{"big":true}' http://x`); | |
| 36 | + expect(r.ok).toBe(false); | |
| 37 | + }); | |
| 38 | + | |
| 39 | + it('blocks double-quoted form too', () => { | |
| 40 | + const r = jsonGuard(`curl -d "{\\"k\\":\\"v\\"}" http://x`); | |
| 41 | + expect(r.ok).toBe(false); | |
| 42 | + }); | |
| 43 | + | |
| 44 | + it('returns matched_pattern on block', () => { | |
| 45 | + const r = jsonGuard(`curl -d '{}' http://x`); | |
| 46 | + expect(r.ok).toBe(false); | |
| 47 | + expect(typeof r.matched_pattern).toBe('string'); | |
| 48 | + }); | |
| 49 | + | |
| 50 | + it('passes non-string input safely', () => { | |
| 51 | + expect(jsonGuard(null).ok).toBe(true); | |
| 52 | + expect(jsonGuard(undefined).ok).toBe(true); | |
| 53 | + expect(jsonGuard(123).ok).toBe(true); | |
| 54 | + }); | |
| 55 | + }); | |
| 56 | + | |
| 57 | + describe('bashHooks.preExecute', () => { | |
| 58 | + it('throws JSON_GUARD_BLOCK on offending command', () => { | |
| 59 | + const hook = bashHooks.preExecute[0]; | |
| 60 | + let err = null; | |
| 61 | + try { hook(`curl -d '{"x":1}' http://x`); } catch (e) { err = e; } | |
| 62 | + expect(err).not.toBeNull(); | |
| 63 | + expect(err.code).toBe('JSON_GUARD_BLOCK'); | |
| 64 | + }); | |
| 65 | + | |
| 66 | + it('returns silently on safe command', () => { | |
| 67 | + const hook = bashHooks.preExecute[0]; | |
| 68 | + expect(() => hook('echo hello')).not.toThrow(); | |
| 69 | + }); | |
| 70 | + }); | |
| 71 | + | |
| 72 | + it('exports 4 INLINE_JSON_PATTERNS', () => { | |
| 73 | + expect(INLINE_JSON_PATTERNS.length).toBe(4); | |
| 74 | + }); | |
| 75 | +}); | ... | ... |
server/tests/devloop/tools/coder-tools.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * coder-tools 单测 | |
| 3 | + */ | |
| 4 | + | |
| 5 | +const fs = require('fs'); | |
| 6 | +const path = require('path'); | |
| 7 | +const os = require('os'); | |
| 8 | + | |
| 9 | +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-ct-')); | |
| 10 | +process.env.HOME = TEST_HOME; | |
| 11 | + | |
| 12 | +const pathResolver = require('../../../services/devloop/path-resolver'); | |
| 13 | +const docStore = require('../../../services/devloop/doc-store'); | |
| 14 | +const tools = require('../../../src/tools/devloop/coder-tools'); | |
| 15 | + | |
| 16 | +function setupProject() { | |
| 17 | + pathResolver._resetForTesting(); | |
| 18 | + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ct-proj-')); | |
| 19 | + const pid = 'p-codetest'; | |
| 20 | + const reg = { | |
| 21 | + [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' }, | |
| 22 | + }; | |
| 23 | + fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true }); | |
| 24 | + fs.writeFileSync( | |
| 25 | + path.join(TEST_HOME, '.devloop', 'linked-projects.json'), | |
| 26 | + JSON.stringify(reg) | |
| 27 | + ); | |
| 28 | + pathResolver._resetForTesting(); | |
| 29 | + return { pid, root }; | |
| 30 | +} | |
| 31 | + | |
| 32 | +describe('coder-tools', () => { | |
| 33 | + let env, ctx; | |
| 34 | + beforeEach(() => { | |
| 35 | + env = setupProject(); | |
| 36 | + ctx = { pid: env.pid, agent: 'coder' }; | |
| 37 | + }); | |
| 38 | + afterEach(() => { | |
| 39 | + fs.rmSync(env.root, { recursive: true, force: true }); | |
| 40 | + }); | |
| 41 | + | |
| 42 | + describe('contract_bridge', () => { | |
| 43 | + function buildContract(exports) { | |
| 44 | + // hashContract uses contract.exports || contract, so version field stays stable | |
| 45 | + const c = { exports }; | |
| 46 | + c.version = docStore.hashContract(c); | |
| 47 | + return c; | |
| 48 | + } | |
| 49 | + | |
| 50 | + it('returns ok when hash matches', async () => { | |
| 51 | + const unitId = 'u-be-auth'; | |
| 52 | + const unitDir = path.join(env.root, 'units', unitId); | |
| 53 | + fs.mkdirSync(unitDir, { recursive: true }); | |
| 54 | + const contract = buildContract({ http: [{ method: 'POST', path: '/api/login' }] }); | |
| 55 | + fs.writeFileSync( | |
| 56 | + path.join(unitDir, 'contract.json'), | |
| 57 | + JSON.stringify(contract, null, 2) | |
| 58 | + ); | |
| 59 | + fs.writeFileSync( | |
| 60 | + path.join(env.root, 'units.json'), | |
| 61 | + JSON.stringify({ | |
| 62 | + units: [{ id: unitId, kind: 'be', contract_hash: contract.version, depends_on: [] }], | |
| 63 | + }) | |
| 64 | + ); | |
| 65 | + const r = await tools.contract_bridge.execute({ unit_id: unitId }, ctx); | |
| 66 | + expect(r.ok).toBe(true); | |
| 67 | + expect(r.hash).toBe(contract.version); | |
| 68 | + }); | |
| 69 | + | |
| 70 | + it('flags hash mismatch (SDD-declared hash differs)', async () => { | |
| 71 | + const unitId = 'u-be-mis'; | |
| 72 | + const unitDir = path.join(env.root, 'units', unitId); | |
| 73 | + fs.mkdirSync(unitDir, { recursive: true }); | |
| 74 | + const contract = buildContract({ http: [{ method: 'GET', path: '/api/x' }] }); | |
| 75 | + fs.writeFileSync( | |
| 76 | + path.join(unitDir, 'contract.json'), | |
| 77 | + JSON.stringify(contract) | |
| 78 | + ); | |
| 79 | + fs.writeFileSync( | |
| 80 | + path.join(env.root, 'units.json'), | |
| 81 | + JSON.stringify({ | |
| 82 | + units: [{ id: unitId, kind: 'be', contract_hash: 'sha256:deadbeefdeadbeef', depends_on: [] }], | |
| 83 | + }) | |
| 84 | + ); | |
| 85 | + const r = await tools.contract_bridge.execute({ unit_id: unitId }, ctx); | |
| 86 | + expect(r.ok).toBe(false); | |
| 87 | + expect(r.reason).toMatch(/does not match SDD/); | |
| 88 | + }); | |
| 89 | + | |
| 90 | + it('returns ok=false when contract missing', async () => { | |
| 91 | + const r = await tools.contract_bridge.execute({ unit_id: 'u-nope' }, ctx); | |
| 92 | + expect(r.ok).toBe(false); | |
| 93 | + expect(r.reason).toMatch(/contract\.json missing/); | |
| 94 | + }); | |
| 95 | + }); | |
| 96 | + | |
| 97 | + describe('append_coder_log', () => { | |
| 98 | + it('writes attempt=1 entry without retry fields', async () => { | |
| 99 | + const r = await tools.append_coder_log.execute({ | |
| 100 | + unit_id: 'u-foo', attempt: 1, | |
| 101 | + this_fix: 'init', rationale: 'first', | |
| 102 | + }, ctx); | |
| 103 | + expect(r.ok).toBe(true); | |
| 104 | + const content = fs.readFileSync(r.path, 'utf8'); | |
| 105 | + expect(content).toMatch(/attempt: 1/); | |
| 106 | + expect(content).toMatch(/this_fix: init/); | |
| 107 | + }); | |
| 108 | + | |
| 109 | + it('rejects retry without last_failure/last_cause', async () => { | |
| 110 | + await expect(tools.append_coder_log.execute({ | |
| 111 | + unit_id: 'u-foo', attempt: 2, | |
| 112 | + this_fix: 'try again', rationale: 'hmm', | |
| 113 | + }, ctx)).rejects.toThrow(/last_failure.*last_cause/); | |
| 114 | + }); | |
| 115 | + | |
| 116 | + it('accepts retry with required fields', async () => { | |
| 117 | + const r = await tools.append_coder_log.execute({ | |
| 118 | + unit_id: 'u-foo', attempt: 2, | |
| 119 | + last_failure: 'auth returns 500', | |
| 120 | + last_cause: 'user undefined before bcrypt.compare', | |
| 121 | + this_fix: 'add user null check', | |
| 122 | + rationale: 'guard against missing user', | |
| 123 | + }, ctx); | |
| 124 | + expect(r.ok).toBe(true); | |
| 125 | + expect(r.attempt).toBe(2); | |
| 126 | + }); | |
| 127 | + }); | |
| 128 | + | |
| 129 | + describe('sandbox_check', () => { | |
| 130 | + it('returns ok when no out-of-sandbox writes', async () => { | |
| 131 | + const unitId = 'u-x'; | |
| 132 | + const unitDir = path.join(env.root, 'units', unitId, 'code'); | |
| 133 | + fs.mkdirSync(unitDir, { recursive: true }); | |
| 134 | + const since = Date.now() - 10_000; | |
| 135 | + fs.writeFileSync(path.join(unitDir, 'a.js'), 'console.log(1);'); | |
| 136 | + const r = await tools.sandbox_check.execute({ unit_id: unitId, since }, ctx); | |
| 137 | + expect(r.ok).toBe(true); | |
| 138 | + expect(r.violations).toEqual([]); | |
| 139 | + }); | |
| 140 | + | |
| 141 | + it('flags writes outside sandbox', async () => { | |
| 142 | + const unitId = 'u-y'; | |
| 143 | + fs.mkdirSync(path.join(env.root, 'units', unitId, 'code'), { recursive: true }); | |
| 144 | + const since = Date.now() - 1_000; | |
| 145 | + // 写一个非沙箱目录文件 | |
| 146 | + const badPath = path.join(env.root, 'rogue', 'evil.js'); | |
| 147 | + fs.mkdirSync(path.dirname(badPath), { recursive: true }); | |
| 148 | + fs.writeFileSync(badPath, 'oops'); | |
| 149 | + const r = await tools.sandbox_check.execute({ unit_id: unitId, since }, ctx); | |
| 150 | + expect(r.ok).toBe(false); | |
| 151 | + expect(r.violations.length).toBeGreaterThan(0); | |
| 152 | + }); | |
| 153 | + }); | |
| 154 | +}); | ... | ... |
server/tests/devloop/tools/doc-tools.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * doc-tools 单测 (PM + Architect) | |
| 3 | + */ | |
| 4 | + | |
| 5 | +const fs = require('fs'); | |
| 6 | +const path = require('path'); | |
| 7 | +const os = require('os'); | |
| 8 | + | |
| 9 | +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-dt-')); | |
| 10 | +process.env.HOME = TEST_HOME; | |
| 11 | + | |
| 12 | +const pathResolver = require('../../../services/devloop/path-resolver'); | |
| 13 | +const docStore = require('../../../services/devloop/doc-store'); | |
| 14 | +const tools = require('../../../src/tools/devloop/doc-tools'); | |
| 15 | + | |
| 16 | +function setupProject() { | |
| 17 | + pathResolver._resetForTesting(); | |
| 18 | + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dt-proj-')); | |
| 19 | + const pid = 'p-doctest'; | |
| 20 | + const reg = { | |
| 21 | + [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' }, | |
| 22 | + }; | |
| 23 | + fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true }); | |
| 24 | + fs.writeFileSync( | |
| 25 | + path.join(TEST_HOME, '.devloop', 'linked-projects.json'), | |
| 26 | + JSON.stringify(reg) | |
| 27 | + ); | |
| 28 | + pathResolver._resetForTesting(); | |
| 29 | + return { pid, root }; | |
| 30 | +} | |
| 31 | + | |
| 32 | +const VALID_PRD_HTML = ` | |
| 33 | +<!doctype html> | |
| 34 | +<html><head><title>Scope</title></head><body> | |
| 35 | +<article data-doc="prd" data-rev="1"> | |
| 36 | + <section data-rev="1"> | |
| 37 | + <h3>S1</h3><p>desc</p> | |
| 38 | + <table><tr><td>T</td></tr></table> | |
| 39 | + <svg></svg> | |
| 40 | + <pre>code block</pre> | |
| 41 | + <div class="card">card</div> | |
| 42 | + </section> | |
| 43 | +</article> | |
| 44 | +</body></html>`; | |
| 45 | + | |
| 46 | +const VALID_SDD_HTML = ` | |
| 47 | +<!doctype html> | |
| 48 | +<html><head><title>Model</title></head><body> | |
| 49 | +<article data-doc="sdd" data-rev="1"> | |
| 50 | + <section data-rev="1"><h3>M-1</h3><p>x</p></section> | |
| 51 | +</article> | |
| 52 | +</body></html>`; | |
| 53 | + | |
| 54 | +describe('doc-tools', () => { | |
| 55 | + let env; | |
| 56 | + let ctx; | |
| 57 | + beforeEach(() => { | |
| 58 | + env = setupProject(); | |
| 59 | + ctx = { pid: env.pid, agent: 'pm' }; | |
| 60 | + }); | |
| 61 | + afterEach(() => { | |
| 62 | + fs.rmSync(env.root, { recursive: true, force: true }); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + describe('bootstrap_prd_site / bootstrap_sdd_site', () => { | |
| 66 | + it('creates prd site skeleton', async () => { | |
| 67 | + const r = await tools.bootstrap_prd_site.execute({}, ctx); | |
| 68 | + expect(r.ok).toBe(true); | |
| 69 | + expect(fs.existsSync(path.join(env.root, 'prd', 'product'))).toBe(true); | |
| 70 | + expect(fs.existsSync(path.join(env.root, 'prd', 'assets', 'shell.css'))).toBe(true); | |
| 71 | + expect(fs.existsSync(path.join(env.root, 'prd', 'assets', 'search-index.json'))).toBe(true); | |
| 72 | + }); | |
| 73 | + | |
| 74 | + it('creates sdd site skeleton with mvcp/', async () => { | |
| 75 | + const r = await tools.bootstrap_sdd_site.execute({}, ctx); | |
| 76 | + expect(r.ok).toBe(true); | |
| 77 | + expect(fs.existsSync(path.join(env.root, 'sdd', 'mvcp'))).toBe(true); | |
| 78 | + }); | |
| 79 | + }); | |
| 80 | + | |
| 81 | + describe('write_chapter', () => { | |
| 82 | + it('writes valid PRD chapter', async () => { | |
| 83 | + await tools.bootstrap_prd_site.execute({}, ctx); | |
| 84 | + const r = await tools.write_chapter.execute({ | |
| 85 | + kind: 'prd', relPath: 'product/scope.html', html: VALID_PRD_HTML, | |
| 86 | + }, ctx); | |
| 87 | + expect(r.ok).toBe(true); | |
| 88 | + expect(fs.existsSync(path.join(env.root, 'prd', 'product', 'scope.html'))).toBe(true); | |
| 89 | + }); | |
| 90 | + | |
| 91 | + it('rejects invalid HTML', async () => { | |
| 92 | + await tools.bootstrap_prd_site.execute({}, ctx); | |
| 93 | + await expect(tools.write_chapter.execute({ | |
| 94 | + kind: 'prd', relPath: 'product/scope.html', html: '<p>no article tag</p>', | |
| 95 | + }, ctx)).rejects.toThrow(); | |
| 96 | + }); | |
| 97 | + }); | |
| 98 | + | |
| 99 | + describe('write_mvcp_page', () => { | |
| 100 | + it('writes sdd/mvcp/model.html', async () => { | |
| 101 | + await tools.bootstrap_sdd_site.execute({}, ctx); | |
| 102 | + const r = await tools.write_mvcp_page.execute({ | |
| 103 | + which: 'model', html: VALID_SDD_HTML, | |
| 104 | + }, ctx); | |
| 105 | + expect(r.ok).toBe(true); | |
| 106 | + expect(fs.existsSync(path.join(env.root, 'sdd', 'mvcp', 'model.html'))).toBe(true); | |
| 107 | + }); | |
| 108 | + }); | |
| 109 | + | |
| 110 | + describe('diff_html', () => { | |
| 111 | + it('detects added/changed/removed sections', async () => { | |
| 112 | + const oldHtml = `<article><section data-rev="1">A</section><section data-rev="2">B</section></article>`; | |
| 113 | + const newHtml = `<article><section data-rev="1">A</section><section data-rev="2">B-changed</section><section data-rev="3">C</section></article>`; | |
| 114 | + const r = await tools.diff_html.execute({ oldHtml, newHtml }, ctx); | |
| 115 | + expect(r.added).toEqual(['3']); | |
| 116 | + expect(r.changed).toEqual(['2']); | |
| 117 | + expect(r.removed).toEqual([]); | |
| 118 | + }); | |
| 119 | + }); | |
| 120 | + | |
| 121 | + describe('diff_units', () => { | |
| 122 | + it('detects added unit', async () => { | |
| 123 | + const r = await tools.diff_units.execute({ | |
| 124 | + newUnits: [ | |
| 125 | + { id: 'u-a', contract_hash: 'h1' }, | |
| 126 | + { id: 'u-b', contract_hash: 'h2' }, | |
| 127 | + ], | |
| 128 | + oldUnits: [{ id: 'u-a', contract_hash: 'h1' }], | |
| 129 | + }, ctx); | |
| 130 | + expect(r.added).toContain('u-b'); | |
| 131 | + }); | |
| 132 | + | |
| 133 | + it('detects changed unit (contract_hash diff)', async () => { | |
| 134 | + const r = await tools.diff_units.execute({ | |
| 135 | + newUnits: [{ id: 'u-a', contract_hash: 'h2' }], | |
| 136 | + oldUnits: [{ id: 'u-a', contract_hash: 'h1' }], | |
| 137 | + }, ctx); | |
| 138 | + expect(r.changed).toContain('u-a'); | |
| 139 | + }); | |
| 140 | + }); | |
| 141 | + | |
| 142 | + describe('echo_diff', () => { | |
| 143 | + it('emits SSE prd.updated', async () => { | |
| 144 | + const events = []; | |
| 145 | + ctx.sseBroker = { emit: (pid, k, p) => events.push({ pid, k, p }) }; | |
| 146 | + const r = await tools.echo_diff.execute({ | |
| 147 | + kind: 'prd', rev: 2, summary: 'scope rev 2', sections_diff: { added: ['s2'] }, | |
| 148 | + }, ctx); | |
| 149 | + expect(r.ok).toBe(true); | |
| 150 | + expect(events).toHaveLength(1); | |
| 151 | + expect(events[0].k).toBe('prd.updated'); | |
| 152 | + }); | |
| 153 | + }); | |
| 154 | + | |
| 155 | + describe('update_search_index', () => { | |
| 156 | + it('builds prd index after writes', async () => { | |
| 157 | + await tools.bootstrap_prd_site.execute({}, ctx); | |
| 158 | + await tools.write_chapter.execute({ | |
| 159 | + kind: 'prd', relPath: 'product/scope.html', html: VALID_PRD_HTML, | |
| 160 | + }, ctx); | |
| 161 | + const r = await tools.update_search_index.execute({ target: 'prd' }, ctx); | |
| 162 | + expect(r.ok).toBe(true); | |
| 163 | + expect(r.built[0].pages).toBeGreaterThan(0); | |
| 164 | + }); | |
| 165 | + }); | |
| 166 | + | |
| 167 | + describe('hash_contract', () => { | |
| 168 | + it('returns sha256-prefixed 16-hex hash', async () => { | |
| 169 | + const r = await tools.hash_contract.execute({ | |
| 170 | + contract: { name: 'login', method: 'POST', path: '/api/login' }, | |
| 171 | + }, ctx); | |
| 172 | + expect(r.hash).toMatch(/^sha256:[a-f0-9]{16}$/); | |
| 173 | + }); | |
| 174 | + | |
| 175 | + it('is deterministic for canonical-equivalent contracts', async () => { | |
| 176 | + const a = await tools.hash_contract.execute({ | |
| 177 | + contract: { a: 1, b: 2 }, | |
| 178 | + }, ctx); | |
| 179 | + const b = await tools.hash_contract.execute({ | |
| 180 | + contract: { b: 2, a: 1 }, | |
| 181 | + }, ctx); | |
| 182 | + expect(a.hash).toBe(b.hash); | |
| 183 | + }); | |
| 184 | + }); | |
| 185 | + | |
| 186 | + describe('validate_prd_site', () => { | |
| 187 | + it('flags missing required chapters', async () => { | |
| 188 | + await tools.bootstrap_prd_site.execute({}, ctx); | |
| 189 | + const r = await tools.validate_prd_site.execute({}, ctx); | |
| 190 | + expect(r.ok).toBe(false); | |
| 191 | + expect(r.issues.some(i => i.includes('missing chapter'))).toBe(true); | |
| 192 | + }); | |
| 193 | + }); | |
| 194 | +}); | ... | ... |
server/tests/devloop/tools/integrator-tools.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * integrator-tools 单测 | |
| 3 | + */ | |
| 4 | + | |
| 5 | +const fs = require('fs'); | |
| 6 | +const path = require('path'); | |
| 7 | +const os = require('os'); | |
| 8 | +const http = require('http'); | |
| 9 | + | |
| 10 | +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-it-')); | |
| 11 | +process.env.HOME = TEST_HOME; | |
| 12 | + | |
| 13 | +const pathResolver = require('../../../services/devloop/path-resolver'); | |
| 14 | +const tools = require('../../../src/tools/devloop/integrator-tools'); | |
| 15 | + | |
| 16 | +function setupProject() { | |
| 17 | + pathResolver._resetForTesting(); | |
| 18 | + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'it-proj-')); | |
| 19 | + const pid = 'p-integtest'; | |
| 20 | + const reg = { | |
| 21 | + [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' }, | |
| 22 | + }; | |
| 23 | + fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true }); | |
| 24 | + fs.writeFileSync( | |
| 25 | + path.join(TEST_HOME, '.devloop', 'linked-projects.json'), | |
| 26 | + JSON.stringify(reg) | |
| 27 | + ); | |
| 28 | + pathResolver._resetForTesting(); | |
| 29 | + return { pid, root }; | |
| 30 | +} | |
| 31 | + | |
| 32 | +describe('integrator-tools', () => { | |
| 33 | + let env, ctx; | |
| 34 | + beforeEach(() => { | |
| 35 | + env = setupProject(); | |
| 36 | + ctx = { pid: env.pid, agent: 'integrator' }; | |
| 37 | + }); | |
| 38 | + afterEach(() => { | |
| 39 | + fs.rmSync(env.root, { recursive: true, force: true }); | |
| 40 | + }); | |
| 41 | + | |
| 42 | + describe('port_picker', () => { | |
| 43 | + it('allocates the requested number of free ports', async () => { | |
| 44 | + const r = await tools.port_picker.execute({ count: 3 }, ctx); | |
| 45 | + expect(r.ok).toBe(true); | |
| 46 | + expect(r.ports).toHaveLength(3); | |
| 47 | + for (const p of r.ports) { | |
| 48 | + expect(p).toBeGreaterThanOrEqual(18000); | |
| 49 | + expect(p).toBeLessThan(19000); | |
| 50 | + } | |
| 51 | + expect(new Set(r.ports).size).toBe(3); // unique | |
| 52 | + }); | |
| 53 | + }); | |
| 54 | + | |
| 55 | + describe('parse_placeholder', () => { | |
| 56 | + it('resolves {{step0.body.field}} from prev results', async () => { | |
| 57 | + const r = await tools.parse_placeholder.execute({ | |
| 58 | + template: { token: '{{step0.token}}' }, | |
| 59 | + previous_steps: [{ body: { token: 'abc123' } }], | |
| 60 | + }, ctx); | |
| 61 | + expect(r.resolved.token).toBe('abc123'); | |
| 62 | + }); | |
| 63 | + | |
| 64 | + it('throws when step ref missing', async () => { | |
| 65 | + await expect(tools.parse_placeholder.execute({ | |
| 66 | + template: { x: '{{step5.foo}}' }, | |
| 67 | + previous_steps: [], | |
| 68 | + }, ctx)).rejects.toThrow(/placeholder step5/); | |
| 69 | + }); | |
| 70 | + }); | |
| 71 | + | |
| 72 | + describe('health_wait', () => { | |
| 73 | + it('returns ok=true when server responds 200', async () => { | |
| 74 | + const srv = http.createServer((_req, res) => { | |
| 75 | + res.statusCode = 200; res.end('ok'); | |
| 76 | + }); | |
| 77 | + await new Promise(r => srv.listen(0, '127.0.0.1', r)); | |
| 78 | + const port = srv.address().port; | |
| 79 | + | |
| 80 | + const r = await tools.health_wait.execute({ | |
| 81 | + url: `http://127.0.0.1:${port}/api/health`, | |
| 82 | + timeout_ms: 2000, interval_ms: 50, | |
| 83 | + }, ctx); | |
| 84 | + srv.close(); | |
| 85 | + expect(r.ok).toBe(true); | |
| 86 | + expect(r.status).toBe(200); | |
| 87 | + }); | |
| 88 | + | |
| 89 | + it('returns ok=false on timeout', async () => { | |
| 90 | + const r = await tools.health_wait.execute({ | |
| 91 | + url: 'http://127.0.0.1:1/never', | |
| 92 | + timeout_ms: 300, interval_ms: 100, | |
| 93 | + }, ctx); | |
| 94 | + expect(r.ok).toBe(false); | |
| 95 | + expect(r.timeout).toBe(true); | |
| 96 | + }); | |
| 97 | + }); | |
| 98 | + | |
| 99 | + describe('collect_stderr', () => { | |
| 100 | + it('returns last N lines from log', async () => { | |
| 101 | + const p = path.join(env.root, 'process.log'); | |
| 102 | + fs.writeFileSync(p, ['l1','l2','l3','l4','l5'].join('\n')); | |
| 103 | + const r = await tools.collect_stderr.execute({ log_path: p, lines: 2 }, ctx); | |
| 104 | + expect(r.ok).toBe(true); | |
| 105 | + expect(r.excerpt).toBe('l4\nl5'); | |
| 106 | + }); | |
| 107 | + | |
| 108 | + it('returns ok=false when log missing', async () => { | |
| 109 | + const r = await tools.collect_stderr.execute({ | |
| 110 | + log_path: path.join(env.root, 'nope.log'), | |
| 111 | + }, ctx); | |
| 112 | + expect(r.ok).toBe(false); | |
| 113 | + }); | |
| 114 | + }); | |
| 115 | + | |
| 116 | + describe('run_case', () => { | |
| 117 | + it('executes a single GET case against a test server', async () => { | |
| 118 | + const srv = http.createServer((req, res) => { | |
| 119 | + if (req.url === '/api/ping') { | |
| 120 | + res.setHeader('Content-Type', 'application/json'); | |
| 121 | + res.end(JSON.stringify({ pong: true })); | |
| 122 | + return; | |
| 123 | + } | |
| 124 | + res.statusCode = 404; res.end(); | |
| 125 | + }); | |
| 126 | + await new Promise(r => srv.listen(0, '127.0.0.1', r)); | |
| 127 | + const port = srv.address().port; | |
| 128 | + | |
| 129 | + const r = await tools.run_case.execute({ | |
| 130 | + case_spec: { | |
| 131 | + steps: [ | |
| 132 | + { unit: 'u-x', call: 'GET /api/ping', expect: { status: 200 } }, | |
| 133 | + ], | |
| 134 | + }, | |
| 135 | + ports: { 'u-x': port }, | |
| 136 | + }, ctx); | |
| 137 | + srv.close(); | |
| 138 | + expect(r.ok).toBe(true); | |
| 139 | + expect(r.steps_ran).toBe(1); | |
| 140 | + }); | |
| 141 | + | |
| 142 | + it('reports failed step when status mismatches', async () => { | |
| 143 | + const srv = http.createServer((_req, res) => { | |
| 144 | + res.statusCode = 500; res.end('{}'); | |
| 145 | + }); | |
| 146 | + await new Promise(r => srv.listen(0, '127.0.0.1', r)); | |
| 147 | + const port = srv.address().port; | |
| 148 | + const r = await tools.run_case.execute({ | |
| 149 | + case_spec: { | |
| 150 | + steps: [{ unit: 'u-x', call: 'GET /api/x', expect: { status: 200 } }], | |
| 151 | + }, | |
| 152 | + ports: { 'u-x': port }, | |
| 153 | + }, ctx); | |
| 154 | + srv.close(); | |
| 155 | + expect(r.ok).toBe(false); | |
| 156 | + expect(r.failed_step).toBe(0); | |
| 157 | + expect(r.got.status).toBe(500); | |
| 158 | + }); | |
| 159 | + }); | |
| 160 | +}); | ... | ... |
server/tests/devloop/tools/persistence-tools.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * persistence-tools 单测 | |
| 3 | + * | |
| 4 | + * 覆盖 5 个工具: | |
| 5 | + * write_todolist / update_todo_item / update_runtime_state / | |
| 6 | + * timeline_append / record_memory | |
| 7 | + */ | |
| 8 | + | |
| 9 | +const fs = require('fs'); | |
| 10 | +const path = require('path'); | |
| 11 | +const os = require('os'); | |
| 12 | + | |
| 13 | +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-pt-')); | |
| 14 | +process.env.HOME = TEST_HOME; | |
| 15 | + | |
| 16 | +const pathResolver = require('../../../services/devloop/path-resolver'); | |
| 17 | +const tools = require('../../../src/tools/devloop/persistence-tools'); | |
| 18 | + | |
| 19 | +function setupProject() { | |
| 20 | + pathResolver._resetForTesting(); | |
| 21 | + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pt-proj-')); | |
| 22 | + const pid = 'p-test01'; | |
| 23 | + // 注入 cache (绕过 register) | |
| 24 | + pathResolver.init(); | |
| 25 | + // hack: 直接写 project.json + 让 init 重扫不可行,改为构造 linked 注册表 | |
| 26 | + const reg = { | |
| 27 | + [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' }, | |
| 28 | + }; | |
| 29 | + fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true }); | |
| 30 | + fs.writeFileSync( | |
| 31 | + path.join(TEST_HOME, '.devloop', 'linked-projects.json'), | |
| 32 | + JSON.stringify(reg) | |
| 33 | + ); | |
| 34 | + pathResolver._resetForTesting(); | |
| 35 | + return { pid, root, company_id: 'c-test' }; | |
| 36 | +} | |
| 37 | + | |
| 38 | +describe('persistence-tools', () => { | |
| 39 | + let ctx; | |
| 40 | + let env; | |
| 41 | + | |
| 42 | + beforeEach(() => { | |
| 43 | + env = setupProject(); | |
| 44 | + ctx = { | |
| 45 | + pid: env.pid, | |
| 46 | + agent: 'pm', | |
| 47 | + call_id: 'call-1', | |
| 48 | + company_id: env.company_id, | |
| 49 | + }; | |
| 50 | + }); | |
| 51 | + | |
| 52 | + afterEach(() => { | |
| 53 | + fs.rmSync(env.root, { recursive: true, force: true }); | |
| 54 | + }); | |
| 55 | + | |
| 56 | + describe('write_todolist', () => { | |
| 57 | + it('writes initial todolist with first item doing', async () => { | |
| 58 | + const r = await tools.write_todolist.execute({ | |
| 59 | + items: [ | |
| 60 | + { id: 'step-001', description: 'first step' }, | |
| 61 | + { id: 'step-002', description: 'second step' }, | |
| 62 | + ], | |
| 63 | + }, ctx); | |
| 64 | + expect(r.ok).toBe(true); | |
| 65 | + expect(r.total).toBe(2); | |
| 66 | + const written = JSON.parse(fs.readFileSync(r.path, 'utf8')); | |
| 67 | + expect(written.items[0].status).toBe('doing'); | |
| 68 | + expect(written.items[1].status).toBe('todo'); | |
| 69 | + expect(written.current).toBe('step-001'); | |
| 70 | + expect(written.progress_pct).toBe(0); | |
| 71 | + }); | |
| 72 | + | |
| 73 | + it('requires call_id in ctx', async () => { | |
| 74 | + await expect(tools.write_todolist.execute({ | |
| 75 | + items: [{ id: 'step-001', description: 'x' }], | |
| 76 | + }, { pid: env.pid, agent: 'pm' })).rejects.toThrow(/call_id/); | |
| 77 | + }); | |
| 78 | + | |
| 79 | + it('emits SSE when broker present', async () => { | |
| 80 | + const emitted = []; | |
| 81 | + ctx.sseBroker = { emit: (pid, kind, p) => emitted.push({ pid, kind, p }) }; | |
| 82 | + await tools.write_todolist.execute({ | |
| 83 | + items: [{ id: 'step-001', description: 'x' }], | |
| 84 | + }, ctx); | |
| 85 | + expect(emitted).toHaveLength(1); | |
| 86 | + expect(emitted[0].kind).toBe('agent.todolist'); | |
| 87 | + }); | |
| 88 | + }); | |
| 89 | + | |
| 90 | + describe('update_todo_item', () => { | |
| 91 | + beforeEach(async () => { | |
| 92 | + await tools.write_todolist.execute({ | |
| 93 | + items: [ | |
| 94 | + { id: 'step-001', description: 'a' }, | |
| 95 | + { id: 'step-002', description: 'b' }, | |
| 96 | + ], | |
| 97 | + }, ctx); | |
| 98 | + }); | |
| 99 | + | |
| 100 | + it('transitions doing → done and advances current to next', async () => { | |
| 101 | + const r = await tools.update_todo_item.execute({ | |
| 102 | + id: 'step-001', status: 'done', output_ref: 'units.json', | |
| 103 | + }, ctx); | |
| 104 | + expect(r.ok).toBe(true); | |
| 105 | + expect(r.progress_pct).toBe(50); | |
| 106 | + const p = pathResolver.resolveAgentTodolist(env.pid, 'pm'); | |
| 107 | + const tl = JSON.parse(fs.readFileSync(p, 'utf8')); | |
| 108 | + expect(tl.items[0].status).toBe('done'); | |
| 109 | + expect(tl.items[0].output_ref).toBe('units.json'); | |
| 110 | + expect(tl.items[1].status).toBe('doing'); | |
| 111 | + expect(tl.current).toBe('step-002'); | |
| 112 | + }); | |
| 113 | + | |
| 114 | + it('rejects unknown step id', async () => { | |
| 115 | + await expect(tools.update_todo_item.execute({ | |
| 116 | + id: 'step-999', status: 'done', | |
| 117 | + }, ctx)).rejects.toThrow(/unknown step/); | |
| 118 | + }); | |
| 119 | + }); | |
| 120 | + | |
| 121 | + describe('timeline_append', () => { | |
| 122 | + it('appends an event to timeline.ndjson', async () => { | |
| 123 | + const r = await tools.timeline_append.execute({ | |
| 124 | + kind: 'doc.updated', | |
| 125 | + summary: 'PRD scope rev 2', | |
| 126 | + ref: 'prd/scope.html', | |
| 127 | + }, ctx); | |
| 128 | + expect(r.ok).toBe(true); | |
| 129 | + expect(r.ts).toBeTruthy(); | |
| 130 | + const p = pathResolver.resolveTimelineLog(env.pid); | |
| 131 | + const content = fs.readFileSync(p, 'utf8').trim().split('\n'); | |
| 132 | + const lastEvent = JSON.parse(content[content.length - 1]); | |
| 133 | + expect(lastEvent.kind).toBe('doc.updated'); | |
| 134 | + expect(lastEvent.source).toBe('pm'); | |
| 135 | + }); | |
| 136 | + }); | |
| 137 | + | |
| 138 | + describe('record_memory', () => { | |
| 139 | + let uniqueAgent; | |
| 140 | + beforeEach(() => { | |
| 141 | + // memory dir is under DATA_DIR/companies/<cid>/memory — not isolated by env.root. | |
| 142 | + // Use a unique agent per test to avoid cross-test pollution; cleanup after. | |
| 143 | + uniqueAgent = 'test-mem-' + Math.random().toString(36).slice(2, 8); | |
| 144 | + ctx.agent = uniqueAgent; | |
| 145 | + }); | |
| 146 | + afterEach(() => { | |
| 147 | + const dir = pathResolver.resolveMemoryDir(env.company_id, uniqueAgent); | |
| 148 | + fs.rmSync(dir, { recursive: true, force: true }); | |
| 149 | + }); | |
| 150 | + | |
| 151 | + it('writes memory .md with frontmatter (new)', async () => { | |
| 152 | + const r = await tools.record_memory.execute({ | |
| 153 | + topic: 'jwt-auth-points', | |
| 154 | + content: 'use jose lib with ES256 signing alg', | |
| 155 | + relevance: 0.9, | |
| 156 | + }, ctx); | |
| 157 | + expect(r.ok).toBe(true); | |
| 158 | + expect(r.upserted).toBe(false); | |
| 159 | + expect(r.hits).toBe(1); | |
| 160 | + const content = fs.readFileSync(r.path, 'utf8'); | |
| 161 | + expect(content).toMatch(/relevance_score: 0\.9/); | |
| 162 | + expect(content).toMatch(/jose lib/); | |
| 163 | + }); | |
| 164 | + | |
| 165 | + it('upserts on same topic with hits++', async () => { | |
| 166 | + await tools.record_memory.execute({ | |
| 167 | + topic: 'jwt-auth', content: 'first version content here', relevance: 0.5, | |
| 168 | + }, ctx); | |
| 169 | + const r2 = await tools.record_memory.execute({ | |
| 170 | + topic: 'jwt-auth', content: 'updated version content here', relevance: 0.7, | |
| 171 | + }, ctx); | |
| 172 | + expect(r2.upserted).toBe(true); | |
| 173 | + expect(r2.hits).toBe(2); | |
| 174 | + }); | |
| 175 | + }); | |
| 176 | +}); | ... | ... |
server/tests/devloop/tools/sandbox-and-errors.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * _sandbox + _errors 单测 | |
| 3 | + */ | |
| 4 | + | |
| 5 | +const path = require('path'); | |
| 6 | +const { | |
| 7 | + ToolValidationError, | |
| 8 | + ToolSandboxError, | |
| 9 | + ToolMissingDependencyError, | |
| 10 | +} = require('../../../src/tools/devloop/_errors'); | |
| 11 | +const sandbox = require('../../../src/tools/devloop/_sandbox'); | |
| 12 | + | |
| 13 | +describe('errors', () => { | |
| 14 | + it('ToolValidationError carries field + reason', () => { | |
| 15 | + const e = new ToolValidationError('html', 'too short'); | |
| 16 | + expect(e.name).toBe('ToolValidationError'); | |
| 17 | + expect(e.field).toBe('html'); | |
| 18 | + expect(e.reason).toBe('too short'); | |
| 19 | + expect(e.message).toContain('too short'); | |
| 20 | + }); | |
| 21 | + | |
| 22 | + it('ToolSandboxError reports agent + path', () => { | |
| 23 | + const e = new ToolSandboxError('/etc/passwd', 'coder'); | |
| 24 | + expect(e.name).toBe('ToolSandboxError'); | |
| 25 | + expect(e.path).toBe('/etc/passwd'); | |
| 26 | + expect(e.agent).toBe('coder'); | |
| 27 | + }); | |
| 28 | + | |
| 29 | + it('ToolMissingDependencyError names missing dep', () => { | |
| 30 | + const e = new ToolMissingDependencyError('ctx.pid'); | |
| 31 | + expect(e.message).toMatch(/ctx\.pid/); | |
| 32 | + }); | |
| 33 | +}); | |
| 34 | + | |
| 35 | +describe('_sandbox', () => { | |
| 36 | + const ctx = { | |
| 37 | + agent: 'coder', | |
| 38 | + projectDir: '/tmp/my-proj', | |
| 39 | + unit_id: 'u-be-auth', | |
| 40 | + }; | |
| 41 | + | |
| 42 | + it('matches exact file pattern', () => { | |
| 43 | + expect(sandbox.matchesPattern( | |
| 44 | + '/tmp/my-proj/timeline.ndjson', 'timeline.ndjson', ctx | |
| 45 | + )).toBe(true); | |
| 46 | + }); | |
| 47 | + | |
| 48 | + it('matches /** glob prefix', () => { | |
| 49 | + expect(sandbox.matchesPattern( | |
| 50 | + '/tmp/my-proj/prd/feature/login.html', 'prd/**', ctx | |
| 51 | + )).toBe(true); | |
| 52 | + }); | |
| 53 | + | |
| 54 | + it('matches /** root itself', () => { | |
| 55 | + expect(sandbox.matchesPattern( | |
| 56 | + '/tmp/my-proj/prd', 'prd/**', ctx | |
| 57 | + )).toBe(true); | |
| 58 | + }); | |
| 59 | + | |
| 60 | + it('substitutes {unitId} placeholder', () => { | |
| 61 | + expect(sandbox.matchesPattern( | |
| 62 | + '/tmp/my-proj/units/u-be-auth/code/server.js', | |
| 63 | + 'units/{unitId}/code/**', | |
| 64 | + ctx | |
| 65 | + )).toBe(true); | |
| 66 | + }); | |
| 67 | + | |
| 68 | + it('rejects paths outside projectDir', () => { | |
| 69 | + expect(sandbox.matchesPattern( | |
| 70 | + '/etc/passwd', 'prd/**', ctx | |
| 71 | + )).toBe(false); | |
| 72 | + }); | |
| 73 | + | |
| 74 | + it('isWithinAllowList allows when any pattern matches', () => { | |
| 75 | + expect(sandbox.isWithinAllowList( | |
| 76 | + '/tmp/my-proj/units/u-be-auth/contract.json', | |
| 77 | + ['prd/**', 'units/{unitId}/contract.json'], | |
| 78 | + ctx | |
| 79 | + )).toBe(true); | |
| 80 | + }); | |
| 81 | + | |
| 82 | + it('assertWritable throws ToolSandboxError on violation', () => { | |
| 83 | + expect(() => sandbox.assertWritable( | |
| 84 | + '/etc/passwd', ['prd/**'], ctx | |
| 85 | + )).toThrow(ToolSandboxError); | |
| 86 | + }); | |
| 87 | + | |
| 88 | + it('assertWritable is silent on allowed path', () => { | |
| 89 | + expect(() => sandbox.assertWritable( | |
| 90 | + '/tmp/my-proj/prd/index.html', ['prd/**'], ctx | |
| 91 | + )).not.toThrow(); | |
| 92 | + }); | |
| 93 | +}); | ... | ... |
server/tests/devloop/tools/tester-tools.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * tester-tools 单测 | |
| 3 | + */ | |
| 4 | + | |
| 5 | +const fs = require('fs'); | |
| 6 | +const path = require('path'); | |
| 7 | +const os = require('os'); | |
| 8 | + | |
| 9 | +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-tt-')); | |
| 10 | +process.env.HOME = TEST_HOME; | |
| 11 | + | |
| 12 | +const pathResolver = require('../../../services/devloop/path-resolver'); | |
| 13 | +const tools = require('../../../src/tools/devloop/tester-tools'); | |
| 14 | + | |
| 15 | +function setupProject() { | |
| 16 | + pathResolver._resetForTesting(); | |
| 17 | + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tt-proj-')); | |
| 18 | + const pid = 'p-testertest'; | |
| 19 | + const reg = { | |
| 20 | + [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' }, | |
| 21 | + }; | |
| 22 | + fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true }); | |
| 23 | + fs.writeFileSync( | |
| 24 | + path.join(TEST_HOME, '.devloop', 'linked-projects.json'), | |
| 25 | + JSON.stringify(reg) | |
| 26 | + ); | |
| 27 | + pathResolver._resetForTesting(); | |
| 28 | + return { pid, root }; | |
| 29 | +} | |
| 30 | + | |
| 31 | +describe('tester-tools', () => { | |
| 32 | + let env, ctx; | |
| 33 | + beforeEach(() => { | |
| 34 | + env = setupProject(); | |
| 35 | + ctx = { pid: env.pid, agent: 'tester' }; | |
| 36 | + }); | |
| 37 | + afterEach(() => { | |
| 38 | + fs.rmSync(env.root, { recursive: true, force: true }); | |
| 39 | + }); | |
| 40 | + | |
| 41 | + describe('hint_builder', () => { | |
| 42 | + it('hints on TypeError compare (bcrypt-like)', async () => { | |
| 43 | + const r = await tools.hint_builder.execute({ | |
| 44 | + stack: 'TypeError: Illegal arguments: undefined,string compare', | |
| 45 | + }, ctx); | |
| 46 | + expect(r.hint).toMatch(/bcrypt\.compare/); | |
| 47 | + }); | |
| 48 | + | |
| 49 | + it('hints on 401 expected vs 500 got', async () => { | |
| 50 | + const r = await tools.hint_builder.execute({ | |
| 51 | + expected: 401, got: 500, stack: '', | |
| 52 | + }, ctx); | |
| 53 | + expect(r.hint).toMatch(/401/); | |
| 54 | + }); | |
| 55 | + | |
| 56 | + it('parses status code from stack', async () => { | |
| 57 | + const r = await tools.hint_builder.execute({ | |
| 58 | + stack: 'Expected: 200\nReceived: 404', | |
| 59 | + }, ctx); | |
| 60 | + expect(r.hint).toMatch(/200/); | |
| 61 | + }); | |
| 62 | + }); | |
| 63 | + | |
| 64 | + describe('parse_vitest', () => { | |
| 65 | + it('returns crashed when raw json missing', async () => { | |
| 66 | + const r = await tools.parse_vitest.execute({ | |
| 67 | + raw_json_path: path.join(env.root, 'nope.json'), | |
| 68 | + }, ctx); | |
| 69 | + expect(r.ok).toBe(false); | |
| 70 | + expect(r.crashed).toBe(true); | |
| 71 | + }); | |
| 72 | + | |
| 73 | + it('parses passing vitest output', async () => { | |
| 74 | + const rawPath = path.join(env.root, 'raw.json'); | |
| 75 | + fs.writeFileSync(rawPath, JSON.stringify({ | |
| 76 | + testResults: [{ | |
| 77 | + assertionResults: [ | |
| 78 | + { status: 'passed', fullName: 'a' }, | |
| 79 | + { status: 'passed', fullName: 'b' }, | |
| 80 | + ], | |
| 81 | + }], | |
| 82 | + })); | |
| 83 | + const r = await tools.parse_vitest.execute({ raw_json_path: rawPath }, ctx); | |
| 84 | + expect(r.ok).toBe(true); | |
| 85 | + expect(r.summary.passed).toBe(2); | |
| 86 | + expect(r.failures).toHaveLength(0); | |
| 87 | + }); | |
| 88 | + | |
| 89 | + it('extracts failure with hint', async () => { | |
| 90 | + const rawPath = path.join(env.root, 'raw2.json'); | |
| 91 | + fs.writeFileSync(rawPath, JSON.stringify({ | |
| 92 | + testResults: [{ | |
| 93 | + assertionResults: [{ | |
| 94 | + status: 'failed', fullName: 'login returns 401', | |
| 95 | + failureMessages: ['Expected: 401\nReceived: 500\nat handler'], | |
| 96 | + }], | |
| 97 | + }], | |
| 98 | + })); | |
| 99 | + const r = await tools.parse_vitest.execute({ raw_json_path: rawPath }, ctx); | |
| 100 | + expect(r.ok).toBe(false); | |
| 101 | + expect(r.summary.failed).toBe(1); | |
| 102 | + expect(r.failures[0].hint).toMatch(/401/); | |
| 103 | + }); | |
| 104 | + | |
| 105 | + it('writes result.json when result_out_path given', async () => { | |
| 106 | + const rawPath = path.join(env.root, 'raw3.json'); | |
| 107 | + fs.writeFileSync(rawPath, JSON.stringify({ | |
| 108 | + testResults: [{ assertionResults: [{ status: 'passed', fullName: 't' }] }], | |
| 109 | + })); | |
| 110 | + const out = path.join(env.root, 'result.json'); | |
| 111 | + await tools.parse_vitest.execute({ | |
| 112 | + raw_json_path: rawPath, result_out_path: out, | |
| 113 | + }, ctx); | |
| 114 | + expect(fs.existsSync(out)).toBe(true); | |
| 115 | + }); | |
| 116 | + }); | |
| 117 | + | |
| 118 | + describe('bug_open / bug_list / bug_transition', () => { | |
| 119 | + it('opens a bug then lists it', async () => { | |
| 120 | + const r = await tools.bug_open.execute({ | |
| 121 | + title: 'login 500 instead of 401', | |
| 122 | + unit_id: 'u-be-auth', | |
| 123 | + test_name: 'login wrong password', | |
| 124 | + severity: 'high', | |
| 125 | + }, ctx); | |
| 126 | + expect(r.ok).toBe(true); | |
| 127 | + expect(r.bug_id).toBeTruthy(); | |
| 128 | + | |
| 129 | + const ls = await tools.bug_list.execute({ unit_id: 'u-be-auth' }, ctx); | |
| 130 | + expect(ls.total).toBeGreaterThanOrEqual(1); | |
| 131 | + }); | |
| 132 | + | |
| 133 | + it('transitions bug open → fixing', async () => { | |
| 134 | + const opened = await tools.bug_open.execute({ | |
| 135 | + title: 'x', unit_id: 'u-a', test_name: 't', | |
| 136 | + }, ctx); | |
| 137 | + const r = await tools.bug_transition.execute({ | |
| 138 | + bug_id: opened.bug_id, to: 'fixing', note: 'coder picked up', | |
| 139 | + }, ctx); | |
| 140 | + expect(r.ok).toBe(true); | |
| 141 | + }); | |
| 142 | + | |
| 143 | + it('rejects invalid bug transition', async () => { | |
| 144 | + const opened = await tools.bug_open.execute({ | |
| 145 | + title: 'y', unit_id: 'u-b', test_name: 't', | |
| 146 | + }, ctx); | |
| 147 | + await expect(tools.bug_transition.execute({ | |
| 148 | + bug_id: opened.bug_id, to: 'verified', | |
| 149 | + }, ctx)).rejects.toThrow(); | |
| 150 | + }); | |
| 151 | + }); | |
| 152 | +}); | ... | ... |
server/tests/devloop/tools/tool-registry.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * tool-registry 单测 | |
| 3 | + */ | |
| 4 | + | |
| 5 | +const registry = require('../../../src/tools/devloop/tool-registry'); | |
| 6 | + | |
| 7 | +describe('tool-registry', () => { | |
| 8 | + beforeEach(() => registry._resetForTesting()); | |
| 9 | + | |
| 10 | + it('loadAllTools returns a Map with unique tool names', () => { | |
| 11 | + const all = registry.loadAllTools(); | |
| 12 | + expect(all instanceof Map).toBe(true); | |
| 13 | + expect(all.size).toBeGreaterThanOrEqual(30); | |
| 14 | + for (const [name, tool] of all) { | |
| 15 | + expect(typeof name).toBe('string'); | |
| 16 | + expect(tool.name).toBe(name); | |
| 17 | + expect(typeof tool.execute).toBe('function'); | |
| 18 | + } | |
| 19 | + }); | |
| 20 | + | |
| 21 | + it('listToolNames includes core 5 persistence tools', () => { | |
| 22 | + const names = registry.listToolNames(); | |
| 23 | + expect(names).toContain('write_todolist'); | |
| 24 | + expect(names).toContain('update_todo_item'); | |
| 25 | + expect(names).toContain('timeline_append'); | |
| 26 | + expect(names).toContain('record_memory'); | |
| 27 | + expect(names).toContain('update_runtime_state'); | |
| 28 | + }); | |
| 29 | + | |
| 30 | + it('listToolNames includes PM/Architect doc tools', () => { | |
| 31 | + const names = registry.listToolNames(); | |
| 32 | + expect(names).toContain('write_chapter'); | |
| 33 | + expect(names).toContain('write_mvcp_page'); | |
| 34 | + expect(names).toContain('hash_contract'); | |
| 35 | + expect(names).toContain('validate_prd_site'); | |
| 36 | + }); | |
| 37 | + | |
| 38 | + it('listToolNames includes Coder/Tester/Integrator/Triager tools', () => { | |
| 39 | + const names = registry.listToolNames(); | |
| 40 | + expect(names).toContain('contract_bridge'); // coder | |
| 41 | + expect(names).toContain('parse_vitest'); // tester | |
| 42 | + expect(names).toContain('port_picker'); // integrator | |
| 43 | + expect(names).toContain('extract_evidence'); // triager | |
| 44 | + }); | |
| 45 | + | |
| 46 | + it('getDevLoopTools filters by toolPolicy', () => { | |
| 47 | + const subset = registry.getDevLoopTools(['hash_contract', 'parse_vitest', 'nope']); | |
| 48 | + expect(subset).toHaveLength(2); | |
| 49 | + expect(subset.map(t => t.name).sort()).toEqual(['hash_contract', 'parse_vitest']); | |
| 50 | + }); | |
| 51 | + | |
| 52 | + it('dispatch invokes a known tool', async () => { | |
| 53 | + const r = await registry.dispatch('hash_contract', { | |
| 54 | + contract: { name: 'login', method: 'POST' }, | |
| 55 | + }, {}); | |
| 56 | + expect(r.ok).toBe(true); | |
| 57 | + expect(r.hash).toMatch(/^sha256:[a-f0-9]{16}$/); | |
| 58 | + }); | |
| 59 | + | |
| 60 | + it('dispatch rejects unknown tool', async () => { | |
| 61 | + await expect(registry.dispatch('does_not_exist', {}, {})).rejects.toThrow(/Unknown tool/); | |
| 62 | + }); | |
| 63 | + | |
| 64 | + it('dispatch validates required fields', async () => { | |
| 65 | + await expect(registry.dispatch('hash_contract', {}, {})).rejects.toThrow(/missing required field "contract"/); | |
| 66 | + }); | |
| 67 | +}); | ... | ... |
server/tests/devloop/tools/triager-tools.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * triager-tools 单测 | |
| 3 | + */ | |
| 4 | + | |
| 5 | +const fs = require('fs'); | |
| 6 | +const path = require('path'); | |
| 7 | +const os = require('os'); | |
| 8 | + | |
| 9 | +const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-tr-')); | |
| 10 | +process.env.HOME = TEST_HOME; | |
| 11 | + | |
| 12 | +const pathResolver = require('../../../services/devloop/path-resolver'); | |
| 13 | +const docStore = require('../../../services/devloop/doc-store'); | |
| 14 | +const tools = require('../../../src/tools/devloop/triager-tools'); | |
| 15 | + | |
| 16 | +function setupProject() { | |
| 17 | + pathResolver._resetForTesting(); | |
| 18 | + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tr-proj-')); | |
| 19 | + const pid = 'p-triagertest'; | |
| 20 | + const reg = { | |
| 21 | + [pid]: { root_path: root, mode: 'linked-new', company_id: 'c-test' }, | |
| 22 | + }; | |
| 23 | + fs.mkdirSync(path.join(TEST_HOME, '.devloop'), { recursive: true }); | |
| 24 | + fs.writeFileSync( | |
| 25 | + path.join(TEST_HOME, '.devloop', 'linked-projects.json'), | |
| 26 | + JSON.stringify(reg) | |
| 27 | + ); | |
| 28 | + pathResolver._resetForTesting(); | |
| 29 | + return { pid, root }; | |
| 30 | +} | |
| 31 | + | |
| 32 | +describe('triager-tools', () => { | |
| 33 | + let env, ctx; | |
| 34 | + beforeEach(() => { | |
| 35 | + env = setupProject(); | |
| 36 | + ctx = { pid: env.pid, agent: 'triager' }; | |
| 37 | + }); | |
| 38 | + afterEach(() => { | |
| 39 | + fs.rmSync(env.root, { recursive: true, force: true }); | |
| 40 | + }); | |
| 41 | + | |
| 42 | + describe('extract_evidence', () => { | |
| 43 | + it('verifies a quote present in a file', async () => { | |
| 44 | + const target = path.join(env.root, 'unit-x.js'); | |
| 45 | + fs.writeFileSync(target, 'const VAL = 42;\nfunction foo() { return VAL; }\n'); | |
| 46 | + const r = await tools.extract_evidence.execute({ | |
| 47 | + evidence: ['unit-x.js: function foo() { return VAL'], | |
| 48 | + }, ctx); | |
| 49 | + expect(r.ok).toBe(true); | |
| 50 | + expect(r.results[0].ok).toBe(true); | |
| 51 | + }); | |
| 52 | + | |
| 53 | + it('rejects malformed evidence', async () => { | |
| 54 | + const r = await tools.extract_evidence.execute({ | |
| 55 | + evidence: ['no-colon-no-quote'], | |
| 56 | + }, ctx); | |
| 57 | + expect(r.ok).toBe(false); | |
| 58 | + expect(r.failed[0].reason).toMatch(/malformed/); | |
| 59 | + }); | |
| 60 | + | |
| 61 | + it('reports quote not found', async () => { | |
| 62 | + const target = path.join(env.root, 'file.js'); | |
| 63 | + fs.writeFileSync(target, 'function bar() {}'); | |
| 64 | + const r = await tools.extract_evidence.execute({ | |
| 65 | + evidence: ['file.js: never-going-to-find-this'], | |
| 66 | + }, ctx); | |
| 67 | + expect(r.ok).toBe(false); | |
| 68 | + expect(r.failed[0].reason).toMatch(/not found/); | |
| 69 | + }); | |
| 70 | + }); | |
| 71 | + | |
| 72 | + describe('category_classifier', () => { | |
| 73 | + it('flags infra_flake on EADDRINUSE in process log', async () => { | |
| 74 | + const r = await tools.category_classifier.execute({ | |
| 75 | + result_json: { failures: [] }, | |
| 76 | + process_log: 'Error: listen EADDRINUSE 0.0.0.0:3000', | |
| 77 | + }, ctx); | |
| 78 | + expect(r.category).toBe('infra_flake'); | |
| 79 | + }); | |
| 80 | + | |
| 81 | + it('flags impl_bug on 500 vs 4xx', async () => { | |
| 82 | + const r = await tools.category_classifier.execute({ | |
| 83 | + result_json: { failures: [{ got: { status: 500 }, expected: { status: 401 } }] }, | |
| 84 | + process_log: '', | |
| 85 | + }, ctx); | |
| 86 | + expect(r.category).toBe('impl_bug'); | |
| 87 | + }); | |
| 88 | + | |
| 89 | + it('falls back to contract_mismatch with low confidence', async () => { | |
| 90 | + const r = await tools.category_classifier.execute({ | |
| 91 | + result_json: { failures: [] }, | |
| 92 | + process_log: '', | |
| 93 | + sdd_text: 'this sdd describes everything', | |
| 94 | + }, ctx); | |
| 95 | + expect(r.category).toBe('contract_mismatch'); | |
| 96 | + expect(r.confidence).toBeLessThanOrEqual(0.5); | |
| 97 | + }); | |
| 98 | + }); | |
| 99 | + | |
| 100 | + describe('diff_contract_vs_impl', () => { | |
| 101 | + it('reports divergence when endpoint not in code', async () => { | |
| 102 | + const unitId = 'u-x'; | |
| 103 | + const unitDir = path.join(env.root, 'units', unitId); | |
| 104 | + fs.mkdirSync(path.join(unitDir, 'code'), { recursive: true }); | |
| 105 | + fs.writeFileSync( | |
| 106 | + path.join(unitDir, 'contract.json'), | |
| 107 | + JSON.stringify({ | |
| 108 | + exports: { http: [{ method: 'POST', path: '/api/login' }] }, | |
| 109 | + }) | |
| 110 | + ); | |
| 111 | + fs.writeFileSync( | |
| 112 | + path.join(unitDir, 'code', 'server.js'), | |
| 113 | + 'function nothing(){}' | |
| 114 | + ); | |
| 115 | + const r = await tools.diff_contract_vs_impl.execute({ unit_id: unitId }, ctx); | |
| 116 | + expect(r.matches).toBe(false); | |
| 117 | + expect(r.divergences.some(d => d.includes('/api/login'))).toBe(true); | |
| 118 | + }); | |
| 119 | + | |
| 120 | + it('matches when endpoint appears in code', async () => { | |
| 121 | + const unitId = 'u-y'; | |
| 122 | + const unitDir = path.join(env.root, 'units', unitId); | |
| 123 | + fs.mkdirSync(path.join(unitDir, 'code'), { recursive: true }); | |
| 124 | + fs.writeFileSync( | |
| 125 | + path.join(unitDir, 'contract.json'), | |
| 126 | + JSON.stringify({ | |
| 127 | + exports: { http: [{ method: 'GET', path: '/api/ping' }] }, | |
| 128 | + }) | |
| 129 | + ); | |
| 130 | + fs.writeFileSync( | |
| 131 | + path.join(unitDir, 'code', 'server.js'), | |
| 132 | + `app.get('/api/ping', (q,r) => r.send('pong'))` | |
| 133 | + ); | |
| 134 | + const r = await tools.diff_contract_vs_impl.execute({ unit_id: unitId }, ctx); | |
| 135 | + expect(r.matches).toBe(true); | |
| 136 | + }); | |
| 137 | + }); | |
| 138 | + | |
| 139 | + describe('find_similar_bug', () => { | |
| 140 | + it('returns empty when no bugs.ndjson yet', async () => { | |
| 141 | + const r = await tools.find_similar_bug.execute({ | |
| 142 | + signature: { unit_id: 'u-none', test_name: 't' }, | |
| 143 | + }, ctx); | |
| 144 | + expect(r.ok).toBe(true); | |
| 145 | + expect(r.total).toBe(0); | |
| 146 | + }); | |
| 147 | + }); | |
| 148 | +}); | ... | ... |