Files
Scoreboard/web/scripts/check-declaration-order.mjs
T
AskaEth db94d9c119 test(web): 补两类守卫,专抓「组件 setup 期抛异常」这类既有测试抓不到的问题
背景:基础数据「运动员」页整块空白的根因是 setup 期 TDZ(watch 注册时同步求值,
间接引用到后面才声明的 const),vue-tsc 与 449 条单元测试都没抓到——组件从不真正挂载。

1) scripts/check-declaration-order.mjs + src/shared/tests/declaration-order.test.ts
   静态检查:从 watch/watchEffect 的源出发沿 const 依赖链做传递闭包,
   任何声明行晚于求值行的 const 即判违规。已随既有测试命令运行(纯 node,无需浏览器)。
   变异验证:把 AthletesSection 退回 ab7662b 的写法 → 精确报
   「AthletesSection.vue:84 会求值到 filtered,但它声明在第 272 行」。

2) scripts/smoke-admin.mjs(pnpm smoke)
   真 Chromium 冒烟:14 个管理端路由 + 7 个基础数据分区 + 大屏页,
   任何 console 错误或未捕获异常即失败并给出路由名。
   变异验证:同一份代码下修复版通过、退回版报
   「/base-data › 运动员 ← ReferenceError: Cannot access 'Y' before initialization」。
   playwright 不是本仓库依赖(离线开发装不上),脚本缺依赖时会明确报错退出而不是假装通过。
2026-09-11 22:58:19 +08:00

168 lines
6.6 KiB
JavaScript

/**
* 声明顺序守卫:抓「setup 期就会求值的表达式(含它的间接依赖)引用了后面才声明的 const」这一类 TDZ。
*
* 背景:AthletesSection.vue 里 `filteredIds = computed(() => filtered.value...)` 引用了
* 199 行之后才声明的 `filtered`,而 `watch(filteredIds, ...)` 在注册时会**同步求值一次源** ——
* setup 阶段就访问到 TDZ 中的 `filtered`,抛
* `ReferenceError: Cannot access 'filtered' before initialization`,整个分区渲染失败、
* 页面只剩标签栏。这个缺陷躲过了 vue-tsc 与全部单元测试(没有 jsdom,组件从不真正挂载),
* 在 dev_0.1.6 / 0.1.7 / 0.1.8 三个版本里都没被发现,所以单独做一个静态检查。
*
* 规则(保守,只报确定会炸的):
* 1. `watch(...)` / `watchEffect(...)` 是**注册时同步求值**的(Vue 要取初始值);
* 2. 从它的第一个实参出发,沿「const 初始化式里引用了哪些 const」这条边做**传递闭包**;
* 3. 闭包里任何 const 的声明行落在 watch 调用行**之后**,就是一次必然的 TDZ。
* 直接依赖与间接依赖都要查 —— 只查直接依赖会漏掉本次事故(引用的 filteredIds 声明在前面)。
*
* 只扫 `<script setup>` / `.ts` 模块顶层;`var`/`function` 有提升,不参与判断。
*/
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join, relative } from 'node:path'
/** 从源码里抠出需要检查的脚本片段(.vue 取 <script setup> 块,.ts 整份)。 */
export function scriptBlocksOf(fileName, source) {
if (!fileName.endsWith('.vue')) return [{ offsetLine: 0, code: source }]
const match = /<script setup[^>]*>([\s\S]*?)<\/script>/.exec(source)
if (!match) return []
const before = source.slice(0, match.index)
return [{ offsetLine: before.split('\n').length - 1, code: match[1] }]
}
/** 取 `=` 之后的初始化表达式文本:按括号深度扫描,回到深度 0 且遇到换行即结束。 */
function initializerOf(code, from) {
let depth = 0
let i = from
for (; i < code.length; i += 1) {
const ch = code[i]
if (ch === '(' || ch === '[' || ch === '{') depth += 1
else if (ch === ')' || ch === ']' || ch === '}') {
if (depth === 0) break
depth -= 1
} else if (ch === '\n' && depth === 0) break
}
return code.slice(from, i)
}
/** 收集 `const NAME = ...`:声明行(1 基,含 offsetLine)与初始化式里引用到的标识符。 */
export function constDeclarations(code, offsetLine = 0) {
const out = new Map()
const re = /^const\s+([A-Za-z_$][\w$]*)\s*=/gm
let m
while ((m = re.exec(code)) !== null) {
const name = m[1]
const line = offsetLine + code.slice(0, m.index).split('\n').length
const init = initializerOf(code, m.index + m[0].length)
const refs = new Set()
for (const id of init.matchAll(/(\.)?\b([A-Za-z_$][\w$]*)\b/g)) {
if (id[1] === '.') continue // 属性访问里的名字不是变量
refs.add(id[2])
}
if (!out.has(name)) out.set(name, { line, refs })
}
return out
}
/** 找「注册时同步求值」的调用点,返回 [{ expr, line }]。 */
export function eagerWatchRefs(code, offsetLine = 0) {
const out = []
const re = /\bwatch(?:Effect)?\s*\(/g
let m
while ((m = re.exec(code)) !== null) {
const start = m.index + m[0].length
let depth = 0
let end = start
for (let i = start; i < code.length; i += 1) {
const ch = code[i]
if (ch === '(' || ch === '[' || ch === '{') depth += 1
else if (ch === ')' || ch === ']' || ch === '}') {
if (depth === 0) break
depth -= 1
} else if (ch === ',' && depth === 0) break
end = i
}
const line = offsetLine + code.slice(0, m.index).split('\n').length
out.push({ expr: code.slice(start, end + 1), line })
}
return out
}
/** 从一组标识符出发,沿 const 依赖边做传递闭包。 */
function closureOf(seeds, decls) {
const seen = new Set()
const stack = [...seeds]
while (stack.length > 0) {
const name = stack.pop()
if (seen.has(name)) continue
const decl = decls.get(name)
if (!decl) continue
seen.add(name)
for (const ref of decl.refs) if (!seen.has(ref)) stack.push(ref)
}
return seen
}
/** 返回违规列表 [{ file, line, name, declaredAt }]。 */
export function findOrderViolations(fileName, source) {
const violations = []
for (const block of scriptBlocksOf(fileName, source)) {
const decls = constDeclarations(block.code, block.offsetLine)
for (const { expr, line } of eagerWatchRefs(block.code, block.offsetLine)) {
const seeds = []
for (const id of expr.matchAll(/(\.)?\b([A-Za-z_$][\w$]*)\b/g)) {
if (id[1] !== '.') seeds.push(id[2])
}
for (const name of closureOf(seeds, decls)) {
const decl = decls.get(name)
if (decl.line > line) violations.push({ file: fileName, line, name, declaredAt: decl.line })
}
}
}
return violations
}
/** 递归列出目录下的 .ts / .vue(跳过依赖与构建产物)。 */
export function sourceFilesUnder(root) {
const out = []
const skip = new Set(['node_modules', 'dist', '.git', 'assets', '__snapshots__'])
const walk = (dir) => {
for (const entry of readdirSync(dir)) {
if (skip.has(entry)) continue
const full = join(dir, entry)
const st = statSync(full)
if (st.isDirectory()) walk(full)
else if (
(entry.endsWith('.ts') || entry.endsWith('.vue')) &&
// 测试文件里会有意内联「反面样例」源码字符串,扫它们只会自伤
!entry.endsWith('.test.ts')
) {
out.push(full)
}
}
}
walk(root)
return out
}
/** 供测试与命令行共用:扫一个根目录,返回带相对路径的违规列表。 */
export function scanWebSources(root) {
const violations = []
for (const file of sourceFilesUnder(root)) {
for (const v of findOrderViolations(relative(root, file), readFileSync(file, 'utf8'))) violations.push(v)
}
return violations
}
if (import.meta.url === `file://${process.argv[1]}`) {
const root = process.argv[2] ?? new URL('../src', import.meta.url).pathname
const violations = scanWebSources(root)
if (violations.length === 0) {
console.log(`声明顺序检查通过:${root} 下没有「注册期求值引用后声明 const」的写法。`)
} else {
console.error(`声明顺序检查失败:${violations.length} 处`)
for (const v of violations) {
console.error(` - ${v.file}:${v.line} 会求值到 ${v.name},但它声明在第 ${v.declaredAt} 行`)
}
process.exit(1)
}
}