Files

241 lines
11 KiB
JavaScript
Raw Permalink Normal View History

#!/usr/bin/env node
/**
* 管理端响应式契约守卫(admin-web)
*
* 为什么需要它:
* 2026-09-23 渲染态实测(Edge 无头 + CDP 取计算样式/DOM 文本)发现数据统计页
* **漏斗卡恒显示 0**,而正下方同一张卡里的汇总表却显示 12846 / 9217 / 8462 / 3812
* —— 同一个页面、同一个数据源,两处互相矛盾。
*
* 根因不是取数失败(控制台 0 错误、网络 0 失败):这三组是
* const funnelStages = [ { value: 0, ... } ] // 普通数组,非响应式
* funnelStages[0].value = userData.totalRegistered // 直接改元素
* 而模板读的就是同一个数组 —— **非响应式写入不触发重渲染**,Vue 看不到变化。
* 对比之下,凡走 `ref` 的值(汇总表、V11 区块、日程)全部正常。
*
* 这类缺陷的可怕之处:
* - 源码里完全合法(无 TS 报错、无 lint 报错、构建通过);
* - 19 个既有守卫全绿(都是源码结构断言,看不出「渲染后没更新」);
* - 只有**真跑起来看渲染结果**才暴露 —— 即「全绿 ≠ 有效」的又一例。
* 所以必须把它变成可执行断言:**非响应式声明 + 被当状态写 + 出现在模板里 = 失败**。
*
* 三项检查:
* 1) 非响应式状态(核心)—— 普通 `const x = [...]` / `= {}` 若在 <script> 里被写
* (`x[i] = `、`x.k = `、`.push()/.splice()` 等)且出现在 <template> 中,
* 则渲染不会更新。注意:**未被写过的静态常量清单(如 ACTIVITY_TYPES)是合法的**,
* 初始化后不再变化,不需要响应式 —— 故按「是否被写」区分,不做一刀切。
* 同时排除 `router.push()` 这类**实例方法调用**(判据:只有字面量数组/对象/Map/Set
* 才把 `.push()` 视为状态写入),以及模板局部变量(外层 v-for 解构出来的)。
* 2) 数据统计页同源契约 —— 漏斗卡与汇总表必须取自同一变量(卡片不得硬编码演示数字),
* 且 Phase 20 补齐的 4 个端点不得被删。
* 3) 颜色助手遮蔽 —— 从 @/theme/tokens **非别名**导入 `token` 时,不得再有同名局部
* 变量(`const token = localStorage.getItem('accessToken')` 是本项目常见写法)。
* 若有人在该函数内调用 token('--color-x') 会运行期崩溃。
* 已用 `token as tokenColor` 的文件不算遮蔽(检查会识别别名)。
*
* 实现注意:分析前**必须剥离注释**。否则注释里出现的 `const token = ...` 或
* `x.push(` 会造成误报(本守卫首次运行即被自己的说明注释误伤)。
*
* 退出码:0 通过;1 有失败项(含守卫自身异常)。
*/
import assert from 'node:assert/strict'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { join, relative, resolve } from 'node:path'
const root = resolve(import.meta.dirname, '..')
const SRC = resolve(root, 'admin-web/src')
const ANALYTICS = resolve(SRC, 'modules/analytics/AnalyticsView.vue')
assert.ok(existsSync(SRC), `找不到管理端源码目录 ${SRC}`)
// ---------------------------------------------------------------------------
// 工具
// ---------------------------------------------------------------------------
function walk(dir, out = []) {
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.name === 'node_modules' || e.name.startsWith('.')) continue
const p = join(dir, e.name)
if (e.isDirectory()) walk(p, out)
else if (e.name.endsWith('.vue')) out.push(p)
}
return out
}
/** 抽 SFC 的根级块。注意嵌套 <template #header> 会让非贪婪匹配提前截断。 */
function block(src, tag, attrs = '') {
const m = src.match(new RegExp(`^<${tag}${attrs}[^>]*>([\\s\\S]*?)^</${tag}>`, 'm'))
return m ? m[1] : null
}
/** 剥离 JS 注释(含字符串感知,避免 comment 里的内容参与判定) */
function stripComments(code) {
let out = ''
let i = 0
let quote = null
while (i < code.length) {
const c = code[i]
const n = code[i + 1]
if (quote) {
if (c === '\\') { out += c + (n ?? ''); i += 2; continue }
if (c === quote) quote = null
out += c; i++; continue
}
if (c === '"' || c === "'" || c === '`') { quote = c; out += c; i++; continue }
if (c === '/' && n === '/') { while (i < code.length && code[i] !== '\n') i++; continue }
if (c === '/' && n === '*') {
i += 2
while (i < code.length && !(code[i] === '*' && code[i + 1] === '/')) i++
i += 2
out += ' '
continue
}
out += c; i++
}
return out
}
const stripHtmlComments = (s) => s.replace(/<!--[\s\S]*?-->/g, '')
const REACTIVE_CTORS =
/^(ref|reactive|computed|shallowRef|shallowReactive|toRef|toRefs|customRef|readonly|markRaw)\s*[(<]/
/** 非别名导入颜色助手 → 存在遮蔽风险 */
const IMPORTS_TOKEN_HELPER = /import\s*\{[^}]*\btoken\b(?!\s+as\b)[^}]*\}\s*from\s*'@\/theme\/tokens'/
// ---------------------------------------------------------------------------
// 扫全量 .vue
// ---------------------------------------------------------------------------
const files = walk(SRC)
let totalVfor = 0
let reactiveOk = 0
let staticConst = 0
let skipped = 0
const violations = []
const shadowed = []
for (const file of files) {
const src = readFileSync(file, 'utf8')
const rel = relative(SRC, file).replace(/\\/g, '/')
const rawScr = block(src, 'script', ' setup')
const rawTpl = block(src, 'template')
if (!rawScr) continue
const scr = stripComments(rawScr)
const tpl = rawTpl ? stripHtmlComments(rawTpl) : null
// --- 3) 颜色助手遮蔽 ---
if (IMPORTS_TOKEN_HELPER.test(scr) && /(?:const|let|var)\s+token\s*=/.test(scr)) {
shadowed.push(rel)
}
if (!tpl) continue
// --- 声明表 ---
const kind = new Map()
const init = new Map()
for (const m of scr.matchAll(/\b(?:const|let)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*([^\n]*)/g)) {
if (kind.has(m[1])) continue
kind.set(m[1], REACTIVE_CTORS.test(m[2].trim()) ? 'reactive' : 'plain')
init.set(m[1], m[2])
}
/**
* 是否在 <script> 里被「当状态写」。声明行本身不算。
* `.push()/.splice()` 这类只有当容器是**字面量数组/对象/Map/Set** 时才算状态写入,
* 否则 `router.push()`(useRouter() 实例方法)会被误判。
*/
const isMutated = (name) => {
const esc = name.replace(/[$]/g, '\\$')
const literal = /^\s*[[{]|^\s*new\s+(?:Map|Set|Array|WeakMap|WeakSet)\b/.test(init.get(name) ?? '')
const mutator = literal
? '|\\\\.(?:push|pop|shift|unshift|splice|sort|reverse|set|add|delete)\\('
: ''
const write = new RegExp(
`^\\s*(?:if|while|for|return|await|void|el|else)?\\s*` +
`${esc}(?:\\[[^\\]]*\\]|\\.[A-Za-z_$][\\w$]*)*` +
`\\s*(?:=[^=]|\\+=|-=|\\+\\+|--${mutator})`,
)
return scr
.split('\n')
.some((l) => !/^\s*(?:const|let|var)\s/.test(l) && write.test(l))
}
/** 模板里是否引用了该标识符(排除属性名/字符串内的误匹配) */
const inTemplate = (name) =>
new RegExp(`(?<![\\w$.'"])${name}(?![\\w$])`).test(tpl)
// --- 1a) v-for 绑定非响应式集合 ---
for (const m of tpl.matchAll(/v-for\s*=\s*"([^"]*)"/g)) {
totalVfor++
const parts = m[1].split(/\s+in\s+|\s+of\s+/)
if (parts.length < 2) continue
const iter = parts[parts.length - 1].trim().replace(/^\(|\)$/g, '').trim()
// 成员表达式 / 函数调用 / 下标 → 不是本守卫能判定的裸标识符,跳过
if (/[.[\]()]/.test(iter)) { skipped++; continue }
const bare = iter.replace(/[!?]+$/, '')
if (!/^[A-Za-z_$][\w$]*$/.test(bare)) { skipped++; continue }
// 模板局部(外层 v-for 解构)不在此次声明表中 → 跳过
if (!kind.has(bare)) { skipped++; continue }
if (kind.get(bare) === 'reactive') { reactiveOk++; continue }
if (!isMutated(bare)) { staticConst++; continue }
violations.push({ rel, name: bare, via: 'v-for' })
}
// --- 1b) 非响应式 + 被写 + 模板引用(插值 / :prop 同样读不到更新) ---
for (const [name, k] of kind) {
if (k !== 'plain' || !isMutated(name) || !inTemplate(name)) continue
if (violations.some((v) => v.rel === rel && v.name === name)) continue
violations.push({ rel, name, via: 'template-ref' })
}
}
// ---------------------------------------------------------------------------
// 2) 数据统计页同源契约
// ---------------------------------------------------------------------------
assert.ok(existsSync(ANALYTICS), '缺少数据统计页 admin-web/src/modules/analytics/AnalyticsView.vue')
const avRaw = readFileSync(ANALYTICS, 'utf8')
const avScr = stripComments(block(avRaw, 'script', ' setup') ?? '')
const avTpl = stripHtmlComments(block(avRaw, 'template') ?? '')
for (const ep of ['user-funnel', 'match-funnel', 'safety', 'v11-dashboard']) {
assert.match(avScr, new RegExp(`/operator/analytics/${ep}`), `数据统计页必须调用 /operator/analytics/${ep}`)
}
for (const name of ['funnelStages', 'matchStages', 'safetyCards']) {
assert.match(
avScr,
new RegExp(`const ${name} = computed\\(`),
`${name} 必须用 computed 从 ref 派生(否则卡片与汇总表各持一份、无法同步)`,
)
}
// 同源:卡片取 ref,汇总表也必须渲染**同一个 ref 的同一字段**
for (const n of ['totalRegistered', 'verifiedCount', 'profileApproved', 'activeUsers']) {
assert.ok(avScr.includes(`funnelStats.value.${n}`), `漏斗卡字段 ${n} 必须取自 funnelStats(同源)`)
assert.ok(avTpl.includes(`funnelStats.${n}`), `同卡汇总表必须渲染 funnelStats.${n}(与卡片同源)`)
}
assert.doesNotMatch(
avScr,
/label:\s*'累计注册'[^\n]*value:\s*[\d,]/,
'卡片不得硬编码演示数字(应为 ref 派生)',
)
assert.deepEqual(shadowed, [], `局部变量 token 遮蔽了 @/theme/tokens 导入的颜色助手:${shadowed.join(', ')}`)
// ---------------------------------------------------------------------------
// 结果
// ---------------------------------------------------------------------------
if (violations.length > 0) {
console.error('发现非响应式状态(改动了但渲染不会更新,界面会显示陈旧/默认值):\n')
for (const v of violations) {
console.error(` ${v.rel} → ${v.name} [${v.via}]`)
}
console.error('\n修法:改用 ref()/reactive() 持有状态;或更彻底地 computed() 从单一数据源派生,')
console.error(' 让「同一份数据的两处展示」结构上不可能对不上。')
process.exit(1)
}
console.log('Admin reactivity contracts passed')
console.log(` 扫描 .vue : ${files.length}`)
console.log(` v-for 绑定点 : ${totalVfor}(响应式 ${reactiveOk} / 静态常量 ${staticConst} / 跳过 ${skipped})`)
console.log(' 非响应式状态 : 0')
console.log(' 颜色助手遮蔽 : 0')