/* ============================================================ * test_travel_citytour_isolation.js —— 「游山玩水」skill 全栈隔离校验 * * 为什么需要本脚本: * 本 lab 的架构约定是「skill 全栈隔离」——每个 skill 在 skills// 下自带一整套 * 后端(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 聚合页 /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*(\/\/|\*|\/\*|