feat: 新增高铁游与车宿徒步游技能及相关基础设施
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
/* ============================================================
|
||||
* test_travel_citytour_isolation.js —— 「游山玩水」skill 全栈隔离校验
|
||||
*
|
||||
* 为什么需要本脚本:
|
||||
* 本 lab 的架构约定是「skill 全栈隔离」——每个 skill 在 skills/<skill_id>/ 下自带一整套
|
||||
* 后端(config/index/ai/validate/repair + 技能包)与一整套前端(页面/渲染器/样式/导出器/
|
||||
* ⬆图/照片),彼此零耦合。这个约定光靠注释和口头说明必然会被后续开发侵蚀(有人图省事
|
||||
* 直接 require 兄弟 skill 或引用 lab 层业务文件),所以这里把它变成「失败即挡」的硬校验。
|
||||
*
|
||||
* 自动覆盖未来新增的 skill:
|
||||
* 本脚本不写死任何 skill_id,而是通过 lab 的 registry 枚举当前所有 skill,
|
||||
* 逐个断言。所以以后每加一个 skill,这套校验自动生效,无需改本文件。
|
||||
*
|
||||
* 校验项(对每个 skill):
|
||||
* A. 后端:skill 目录内所有 .js 不得 require 出本目录(禁止 '../')
|
||||
* B. 前端:skill 目录内所有 .html/.js/.css 的相对引用,解析后必须落在
|
||||
* 「本 skill 目录内」或「lab 级 assets/vendor/」;其余一律违规
|
||||
* (即:禁止引用兄弟 skill、禁止引用 lab 层业务文件/配置/照片)
|
||||
* C. 前端:不得通过绝对路径引用 lab 业务文件(只允许 /api/... 接口前缀)
|
||||
* D. 契约:config.json / index.js(导出 bindRoutes)/ index.html 三者齐全且可加载
|
||||
* E. 运行时:后端能 require、bindRoutes 能注册出预期路由、prompt 能组装、校验器不抛异常
|
||||
*
|
||||
* 运行:node dev_test_scripts/unit/test_travel_citytour_isolation.js
|
||||
* 退出码:0 = 全部通过;1 = 存在违规
|
||||
* ============================================================ */
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const ROOT = process.cwd()
|
||||
const LAB_DIR = path.join(ROOT, 'src', 'server', 'thought_lab', 'labs', 'travel_citytour')
|
||||
const LAB_PUBLIC = path.join(ROOT, 'public', 'tools', 'thought_lab', 'labs', 'travel_citytour')
|
||||
const BE_SKILLS = path.join(LAB_DIR, 'skills')
|
||||
const FE_SKILLS = path.join(LAB_PUBLIC, 'skills')
|
||||
|
||||
// 唯一允许 skill 向上引用的位置:lab 级第三方库(无业务语义)
|
||||
const ALLOWED_UP_REF_PREFIX = path.join(LAB_PUBLIC, 'assets', 'vendor')
|
||||
|
||||
const bad = []
|
||||
const info = []
|
||||
|
||||
const registry = require(path.join(LAB_DIR, 'index.js')).constructor ? require(path.join(LAB_DIR, 'registry.js')) : require(path.join(LAB_DIR, 'registry.js'))
|
||||
const { makeCtx } = require(path.join(LAB_DIR, 'index.js'))
|
||||
|
||||
const rel = (p) => path.relative(ROOT, p)
|
||||
|
||||
/* 递归收集目录下的文件(跳过 data / node_modules / .gitkeep) */
|
||||
const walkFiles = (dir, exts, out, skipDirs) => {
|
||||
out = out || []
|
||||
skipDirs = skipDirs || ['data', 'node_modules', '.git']
|
||||
if (!fs.existsSync(dir)) return out
|
||||
fs.readdirSync(dir, { withFileTypes: true }).forEach((e) => {
|
||||
if (e.isDirectory()) {
|
||||
if (skipDirs.indexOf(e.name) >= 0) return
|
||||
walkFiles(path.join(dir, e.name), exts, out, skipDirs)
|
||||
return
|
||||
}
|
||||
if (exts.indexOf(path.extname(e.name)) >= 0) out.push(path.join(dir, e.name))
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/* ---------- A. 后端:禁止 require 出本 skill 目录 ---------- */
|
||||
const checkBackendIsolation = (skillId) => {
|
||||
const dir = path.join(BE_SKILLS, skillId)
|
||||
walkFiles(dir, ['.js']).forEach((file) => {
|
||||
const txt = fs.readFileSync(file, 'utf-8')
|
||||
// 去掉注释行,避免把「隔离说明」里提到的 ../ 误判成真实引用
|
||||
const code = txt.split(/\r?\n/).filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n')
|
||||
const m = code.match(/require\(\s*['"]\.\.?\/(\.\.\/)*[^'"]+['"]\s*\)/g) || []
|
||||
m.forEach((hit) => {
|
||||
if (/require\(\s*['"]\.\//.test(hit)) return // './xxx' 同目录,允许
|
||||
bad.push(`[后端隔离] ${rel(file)} 出现向上越级 require:${hit}(skill 后端只能 require 本目录文件或 npm 包)`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- B/C. 前端:相对引用与绝对引用必须落在允许范围 ---------- */
|
||||
const REF_RE = /(?:src|href)\s*=\s*["']([^"']+)["']|fetch\(\s*['"]([^'"]+)['"]/g
|
||||
|
||||
// 两条【契约要求】的导航例外(不是业务耦合,必须放行):
|
||||
// 1. 站点首页 '/' —— 401 引导面板里的「重新从首页进入」按钮,指到工具箱根
|
||||
// 2. lab 聚合页 <lab>/index.html —— 每个 skill 页面都要求有「← 返回技能列表」
|
||||
const LAB_AGG_PAGE = path.join(LAB_PUBLIC, 'index.html')
|
||||
|
||||
const checkFrontendIsolation = (skillId) => {
|
||||
const skillRoot = path.join(FE_SKILLS, skillId)
|
||||
walkFiles(skillRoot, ['.html', '.js', '.css'], null, ['node_modules', '.git']).forEach((file) => {
|
||||
const txt = fs.readFileSync(file, 'utf-8')
|
||||
REF_RE.lastIndex = 0
|
||||
let hit
|
||||
while ((hit = REF_RE.exec(txt)) !== null) {
|
||||
const ref = String(hit[1] || hit[2] || '').trim()
|
||||
if (!ref) continue
|
||||
if (/^(https?:)?\/\//.test(ref) || ref.indexOf('data:') === 0) continue // 外链/内联,放过
|
||||
if (ref === '/') continue // 例外 1:站点首页导航
|
||||
|
||||
if (ref.charAt(0) === '/') {
|
||||
// 绝对路径:只允许接口前缀,不允许指向 lab 业务文件
|
||||
if (ref.indexOf('/api/') !== 0) {
|
||||
bad.push(`[前端隔离] ${rel(file)} 出现绝对路径引用:${ref}(只允许 /api/... 接口前缀)`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (ref.charAt(0) === '#') continue
|
||||
|
||||
// 相对路径:解析后必须落在「本 skill 目录内」或「lab 级 assets/vendor/」
|
||||
const resolved = path.resolve(path.dirname(file), ref.split('?')[0].split('#')[0])
|
||||
if (resolved === LAB_AGG_PAGE) continue // 例外 2:返回聚合页
|
||||
const insideSkill = resolved === skillRoot || resolved.indexOf(skillRoot + path.sep) === 0
|
||||
const insideVendor = resolved === ALLOWED_UP_REF_PREFIX || resolved.indexOf(ALLOWED_UP_REF_PREFIX + path.sep) === 0
|
||||
if (!insideSkill && !insideVendor) {
|
||||
// 同 lab 但落在其他位置(兄弟 skill / lab 业务文件 / lab 配置)→ 违规
|
||||
if (resolved.indexOf(LAB_PUBLIC + path.sep) === 0 || resolved.indexOf(LAB_DIR + path.sep) === 0) {
|
||||
bad.push(`[前端隔离] ${rel(file)} 引用越界:${ref} → ${rel(resolved)}`
|
||||
+ `(只允许本 skill 目录内,或 lab 级 ${rel(ALLOWED_UP_REF_PREFIX)}/)`)
|
||||
}
|
||||
// 完全跳出 lab 目录的(指向其他 tool / lab)也算违规
|
||||
else if (resolved.indexOf(path.join(ROOT, 'public', 'tools') + path.sep) === 0
|
||||
|| resolved.indexOf(path.join(ROOT, 'src') + path.sep) === 0) {
|
||||
bad.push(`[前端隔离] ${rel(file)} 引用越界:${ref} → ${rel(resolved)}(禁止跨 tool / lab 引用)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- 与兄弟 skill 的交叉引用(代码层面,注释不算) ---------- */
|
||||
const checkSiblingRefs = (skillId, allIds) => {
|
||||
const siblings = allIds.filter((x) => x !== skillId)
|
||||
if (!siblings.length) return
|
||||
const scan = (dir, exts) => walkFiles(dir, exts).forEach((file) => {
|
||||
const code = fs.readFileSync(file, 'utf-8')
|
||||
.split(/\r?\n/).filter((l) => !/^\s*(\/\/|\*|\/\*|<!--)/.test(l)).join('\n')
|
||||
siblings.forEach((sib) => {
|
||||
// 只查「像引用」的用法(路径/字符串字面量),不看注释
|
||||
const re = new RegExp('[\'"`/]' + sib + '[/\'"`]', 'g')
|
||||
if (re.test(code)) {
|
||||
bad.push(`[兄弟隔离] ${rel(file)} 代码中引用了兄弟 skill「${sib}」(skill 之间不得互相引用)`)
|
||||
}
|
||||
})
|
||||
})
|
||||
scan(path.join(BE_SKILLS, skillId), ['.js', '.json'])
|
||||
scan(path.join(FE_SKILLS, skillId), ['.js', '.html', '.json'])
|
||||
}
|
||||
|
||||
/* ---------- D/E. 契约与运行时 ---------- */
|
||||
const checkContractAndRuntime = (skillId) => {
|
||||
const beDir = path.join(BE_SKILLS, skillId)
|
||||
const feDir = path.join(FE_SKILLS, skillId)
|
||||
const need = [
|
||||
[path.join(beDir, 'config.json'), '后端 config.json'],
|
||||
[path.join(beDir, 'index.js'), '后端 index.js'],
|
||||
[path.join(feDir, 'index.html'), '前端 index.html']
|
||||
]
|
||||
need.forEach(([p, label]) => {
|
||||
if (!fs.existsSync(p)) bad.push(`[契约] ${skillId} 缺 ${label}`)
|
||||
})
|
||||
if (bad.some((b) => b.indexOf('[契约] ' + skillId) === 0)) return
|
||||
|
||||
const cfg = JSON.parse(fs.readFileSync(path.join(beDir, 'config.json'), 'utf-8'))
|
||||
if (String(cfg.id || '') !== skillId) bad.push(`[契约] ${skillId} 的 config.json id 与目录名不一致:${cfg.id}`)
|
||||
|
||||
try {
|
||||
const mod = require(path.join(beDir, 'index.js'))
|
||||
if (typeof mod.bindRoutes !== 'function') {
|
||||
bad.push(`[契约] ${skillId} 的 index.js 未导出 bindRoutes`)
|
||||
return
|
||||
}
|
||||
// 后端不得自行实现鉴权(红线:鉴权必须由 lab 层通过 Router 注入)
|
||||
const code = fs.readFileSync(path.join(beDir, 'index.js'), 'utf-8')
|
||||
.split(/\r?\n/).filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n')
|
||||
;['nav_gate', 'parseCookie', 'verifyJwt', 'guidValue', 'DASHSCOPE_API_KEY'].forEach((k) => {
|
||||
if (code.indexOf(k) >= 0) {
|
||||
bad.push(`[红线] ${skillId}/index.js 出现「${k}」——鉴权/凭据必须由 lab 层 ctx 提供,skill 不得自行实现`)
|
||||
}
|
||||
})
|
||||
const routes = []
|
||||
const fakeRouter = { get: (p) => routes.push('GET ' + p), post: (p) => routes.push('POST ' + p), use: () => {} }
|
||||
mod.bindRoutes(fakeRouter, makeCtx(skillId))
|
||||
if (!routes.length) bad.push(`[契约] ${skillId} 的 bindRoutes 未注册任何路由`)
|
||||
info.push(`${skillId}:后端路由 ${routes.join(' / ')}`)
|
||||
} catch (e) {
|
||||
bad.push(`[契约] ${skillId} 后端加载失败:${String(e.message || e)}`)
|
||||
}
|
||||
|
||||
// 深度检查(可选):ai.js / validate.js 不是 lab 强制契约,只是两个已有 skill 的
|
||||
// 约定俗成的拆分方式。新 skill 完全可以不拆(把逻辑都写在 index.js 里)。
|
||||
// 所以这里只在「文件存在」时验证其可用性;不存在则跳过,避免把约定当强制而误报。
|
||||
const hasAi = fs.existsSync(path.join(beDir, 'ai.js'))
|
||||
const hasValidate = fs.existsSync(path.join(beDir, 'validate.js'))
|
||||
if (!hasAi && !hasValidate) {
|
||||
info.push(`${skillId}:未拆分 ai.js/validate.js(允许;lab 强制契约只有 config.json + index.js + index.html)`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const mod = require(path.join(beDir, 'index.js'))
|
||||
if (hasAi) {
|
||||
const aiMod = require(path.join(beDir, 'ai.js'))
|
||||
if (typeof mod.loadBundle === 'function' && typeof aiMod.buildMessages === 'function') {
|
||||
const b = mod.loadBundle()
|
||||
const msgs = aiMod.buildMessages(b, { destination: '测试地' }, null)
|
||||
if (!msgs || msgs.length !== 2) bad.push(`[运行时] ${skillId} buildMessages 未产出 system+user 两条消息`)
|
||||
info.push(`${skillId}:prompt ${msgs[0].content.length} 字符`)
|
||||
}
|
||||
}
|
||||
if (hasValidate) {
|
||||
const { validatePayload } = require(path.join(beDir, 'validate.js'))
|
||||
if (typeof mod.loadBundle === 'function' && typeof validatePayload === 'function') {
|
||||
const chk = validatePayload({}, mod.loadBundle())
|
||||
if (!chk.errors.length) bad.push(`[运行时] ${skillId} 校验器对空 payload 未报错(校验形同虚设)`)
|
||||
info.push(`${skillId}:空 payload 报 ${chk.errors.length} 个 error`)
|
||||
}
|
||||
}
|
||||
if (fs.existsSync(path.join(beDir, 'repair.js'))) require(path.join(beDir, 'repair.js'))
|
||||
} catch (e) {
|
||||
bad.push(`[运行时] ${skillId} 引擎加载失败:${String(e.message || e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- F. 离线样例(若存在)必须通过本 skill 自己的校验器 ---------- */
|
||||
const checkSamples = (skillId) => {
|
||||
const beDir = path.join(BE_SKILLS, skillId)
|
||||
const sampleDir = path.join(FE_SKILLS, skillId, 'sample')
|
||||
if (!fs.existsSync(sampleDir)) return
|
||||
const files = fs.readdirSync(sampleDir).filter((f) => f.endsWith('.json') && f !== 'report_spec.json')
|
||||
if (!files.length) return
|
||||
// 样例校验依赖本 skill 的 validate.js(可选拆分);没拆就跳过,不视为错误
|
||||
if (!fs.existsSync(path.join(beDir, 'validate.js'))) {
|
||||
info.push(`${skillId}:有样例但未拆分 validate.js,跳过样例校验`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const mod = require(path.join(beDir, 'index.js'))
|
||||
const { validatePayload } = require(path.join(beDir, 'validate.js'))
|
||||
const b = mod.loadBundle()
|
||||
files.forEach((f) => {
|
||||
const payload = JSON.parse(fs.readFileSync(path.join(sampleDir, f), 'utf-8'))
|
||||
const chk = validatePayload(payload, b)
|
||||
if (chk.errors.length) {
|
||||
bad.push(`[样例] ${skillId}/sample/${f} 未通过本 skill 校验器:${chk.errors.slice(0, 4).join(' / ')}`)
|
||||
} else {
|
||||
info.push(`${skillId}:样例 ${f} 校验通过(${chk.warnings.length} warnings,` +
|
||||
`卡片 ${chk.stats.cards} / 点位 ${chk.stats.markers}` +
|
||||
(chk.stats.addressCovered === undefined ? '' : ` / 地址覆盖 ${chk.stats.addressCovered}`) + ')')
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
bad.push(`[样例] ${skillId} 样例校验失败:${String(e.message || e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- G. destinations.json 的 sample_ref 必须相对【本 skill 前端目录】可解析 ----------
|
||||
这是隔离后修正过的口径:以前 sample_ref 指向 lab 级共享目录,会让 A skill 的样例被 B skill 引用。 */
|
||||
const checkSampleRefs = (skillId) => {
|
||||
const beDest = path.join(BE_SKILLS, skillId, 'destinations.json')
|
||||
if (!fs.existsSync(beDest)) return
|
||||
const feSkillDir = path.join(FE_SKILLS, skillId)
|
||||
try {
|
||||
const dest = JSON.parse(fs.readFileSync(beDest, 'utf-8'))
|
||||
;(dest.destinations || []).forEach((d) => {
|
||||
;((d && d.runs) || []).forEach((r) => {
|
||||
if (!r || !r.sample_ref) return
|
||||
const resolved = path.join(feSkillDir, r.sample_ref)
|
||||
if (!fs.existsSync(resolved)) {
|
||||
bad.push(`[样例] ${skillId} destinations[${d.key}].runs.sample_ref 不存在(应相对本 skill 前端目录):${r.sample_ref}`)
|
||||
} else if (resolved.indexOf(feSkillDir + path.sep) !== 0) {
|
||||
bad.push(`[样例] ${skillId} destinations[${d.key}].sample_ref 越界到本 skill 目录之外:${r.sample_ref}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
bad.push(`[样例] ${skillId} destinations.json 解析失败:${String(e.message || e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- H. 生成结果「有结构问题也必须落盘 + 200 回传」 ----------
|
||||
*
|
||||
* 背景(真实事故,本 skill 踩过):过去只要结构校验出现哪怕 1 个 error,服务端就整份丢弃、
|
||||
* 不落盘也不回传 payload,用户什么都拿不到。而那个 error 很可能只是校验器的误判 ——
|
||||
* 一次 4 分钟 + 上万 token 的生成就这样白烧了。
|
||||
*
|
||||
* 这条检查直接调用 skill 注册出来的 POST /generate(把 ctx.callLLM 换成桩,返回一份必然
|
||||
* 不合契约的空 payload),断言三件事:
|
||||
* 1) 结果仍被写入 skill 私有 data/last_result.json
|
||||
* 2) HTTPstatus 为 200(若为 422,前端会走失败分支,payload 反而渲染不出来)
|
||||
* 3) envelope.ok === false(如实告知有结构问题,不假装成功)
|
||||
*
|
||||
* 自动覆盖所有「拆了 ai.js」的 skill;未拆分的(如脚手架骨架)跳过,不算违规。
|
||||
* ---------- */
|
||||
const os = require('os')
|
||||
|
||||
const checkPersistOnError = (skillId) => {
|
||||
const beDir = path.join(BE_SKILLS, skillId)
|
||||
if (!fs.existsSync(path.join(beDir, 'ai.js'))) {
|
||||
info.push(`[落盘] ${skillId}:未拆分 ai.js,跳过本项检查`)
|
||||
return null
|
||||
}
|
||||
|
||||
let mod = null
|
||||
let bundle = null
|
||||
let answers = {}
|
||||
try {
|
||||
mod = require(path.join(beDir, 'index.js'))
|
||||
bundle = mod.loadBundle()
|
||||
// 按本 skill 自己的 qa.json 组装必填答案(不写死任何字段名)
|
||||
const qa = bundle.qa || {}
|
||||
if ((qa.destination_input || {}).required !== false) answers.destination = '测试地'
|
||||
;(qa.questions || []).forEach((q) => {
|
||||
if (!q || q.required === false || !q.key) return
|
||||
answers[q.key] = (q.default !== undefined && q.default !== null && q.default !== '')
|
||||
? q.default
|
||||
: (q.type === 'number' ? 1 : '测试值')
|
||||
})
|
||||
} catch (e) {
|
||||
bad.push(`[落盘] ${skillId} 准备失败:${String((e && e.message) || e)}`)
|
||||
return null
|
||||
}
|
||||
|
||||
let tmpDir = ''
|
||||
try {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tclab-last-'))
|
||||
} catch (e) {
|
||||
bad.push(`[落盘] ${skillId} 无法创建临时目录:${String((e && e.message) || e)}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const ctx = makeCtx(skillId)
|
||||
ctx.dataDir = tmpDir
|
||||
// 桩掉模型调用:返回空对象 → 必然触发一堆结构 error
|
||||
ctx.callLLM = async () => ({ content: JSON.stringify({}), finishReason: 'stop', usage: {} })
|
||||
|
||||
const handlers = {}
|
||||
const fakeRouter = {
|
||||
get: (p, ...mw) => { handlers['GET ' + p] = mw[mw.length - 1] },
|
||||
post: (p, ...mw) => { handlers['POST ' + p] = mw[mw.length - 1] },
|
||||
use: () => {}
|
||||
}
|
||||
|
||||
let gen = null
|
||||
try {
|
||||
mod.bindRoutes(fakeRouter, ctx)
|
||||
gen = handlers['POST /generate']
|
||||
} catch (e) {
|
||||
bad.push(`[落盘] ${skillId} 注册路由失败:${String((e && e.message) || e)}`)
|
||||
return null
|
||||
}
|
||||
if (typeof gen !== 'function') {
|
||||
bad.push(`[落盘] ${skillId} 未注册 POST /generate`)
|
||||
return null
|
||||
}
|
||||
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(c) { this.statusCode = c; return this },
|
||||
json(b) { this.body = b; return this },
|
||||
set() { return this },
|
||||
on() { return this },
|
||||
writableEnded: true
|
||||
}
|
||||
|
||||
return Promise.resolve()
|
||||
.then(() => gen({ body: { answers }, headers: {} }, res))
|
||||
.then(() => {
|
||||
const saved = path.join(tmpDir, 'last_result.json')
|
||||
if (!fs.existsSync(saved)) {
|
||||
bad.push(`[落盘] ${skillId} 结果有结构问题时未落盘 —— 用户会失去整份生成结果(该行为必须避免)`)
|
||||
return
|
||||
}
|
||||
let env = null
|
||||
try {
|
||||
env = JSON.parse(fs.readFileSync(saved, 'utf-8'))
|
||||
} catch (e) {
|
||||
bad.push(`[落盘] ${skillId} 落盘文件不是合法 JSON`)
|
||||
return
|
||||
}
|
||||
const nErr = ((env.check || {}).errors || []).length
|
||||
if (nErr === 0) {
|
||||
bad.push(`[落盘] ${skillId} 用例失效:预期产生结构 error,实际为 0`)
|
||||
return
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
bad.push(`[落盘] ${skillId} 有 error 时返回 ${res.statusCode}(应为 200;422 会让前端渲染不出 payload)`)
|
||||
}
|
||||
if (!res.body || res.body.ok !== false) {
|
||||
bad.push(`[落盘] ${skillId} 有 error 时 envelope.ok 应为 false(如实告知结构问题)`)
|
||||
}
|
||||
info.push(`[落盘] ${skillId}:${nErr} 处结构问题时仍落盘 + 200 回传 + ok=false(用户可查看/导出)`)
|
||||
})
|
||||
.catch((e) => {
|
||||
bad.push(`[落盘] ${skillId} 「有 error 也落盘」测试异常:${String((e && e.message) || e)}`)
|
||||
})
|
||||
.then(() => {
|
||||
try { fs.rmSync(tmpDir, { recursive: true, force: true }) } catch {}
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------- 主流程 ---------- */
|
||||
const health = registry.health()
|
||||
if (!health.ok) bad.push('[registry] 自检未通过:' + JSON.stringify(health.problems_detail))
|
||||
const ids = health.items.map((i) => i.id)
|
||||
if (!ids.length) bad.push('[registry] 未扫描到任何 skill')
|
||||
|
||||
ids.forEach((id) => {
|
||||
checkBackendIsolation(id)
|
||||
checkFrontendIsolation(id)
|
||||
checkContractAndRuntime(id)
|
||||
checkSamples(id)
|
||||
checkSampleRefs(id)
|
||||
})
|
||||
if (ids.length > 1) ids.forEach((id) => checkSiblingRefs(id, ids))
|
||||
|
||||
const report = () => {
|
||||
console.log('===== 游山玩水 · skill 全栈隔离校验 =====')
|
||||
info.push(`扫描到 ${ids.length} 个 skill:${ids.join(', ')}`)
|
||||
info.push('隔离口径:skill 内联引用只允许「本 skill 目录内」或「lab 级 assets/vendor/」;后端只允许 require 本目录或 npm 包')
|
||||
info.forEach((l) => console.log('· ' + l))
|
||||
if (bad.length) {
|
||||
console.log('\n发现 ' + bad.length + ' 个违规:')
|
||||
bad.forEach((b) => console.log(' ✗ ' + b))
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('\n✓ 隔离校验全部通过(' + ids.length + ' 个 skill)')
|
||||
}
|
||||
|
||||
Promise.all(ids.map((id) => checkPersistOnError(id)).filter(Boolean)).then(report)
|
||||
Reference in New Issue
Block a user