|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * @ai-context 行数红线的**唯一**测量与校验工具(AGENTS.md §3.1)。 |
| 4 | + * |
| 5 | + * Why:红线此前无法执行 —— 规范没定口径、登记表是只追加的历史日志(同一文件多行、 |
| 6 | + * 读者无法判断哪行有效)、且 15 个 >600 违规中有 10 个被登记表用 <600 的数字 |
| 7 | + * 「认证为合规」。故把「测量」收进一个脚本:写表与查表共用同一套逻辑,口径只有一处实现。 |
| 8 | + * |
| 9 | + * 副作用:`--write` 会重写 docs/standards/line-limit-exemptions.md(人工维护的 |
| 10 | + * 「豁免理由 / 拆分计划」两列按路径保留);默认模式只读。 |
| 11 | + * 边界:只扫 app/src 与 app/src-tauri/src 下的 .ts/.tsx/.rs。>600 用**棘轮**而非 |
| 12 | + * 「立刻为零」—— 拆分是 0-C2/C3 的事,本脚本负责让违规**不再增加**且**可见**。 |
| 13 | + * |
| 14 | + * 用法:node scripts/line-limits.mjs [--write|--full] |
| 15 | + */ |
| 16 | +import { readdirSync, readFileSync, statSync, writeFileSync, existsSync } from 'node:fs'; |
| 17 | +import { join, relative, dirname, sep, resolve } from 'node:path'; |
| 18 | +import { fileURLToPath } from 'node:url'; |
| 19 | + |
| 20 | +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); |
| 21 | +const SCAN_DIRS = ['app/src', 'app/src-tauri/src']; |
| 22 | +const SOURCE_EXT = /\.(ts|tsx|rs)$/; |
| 23 | +export const TABLE_PATH = 'docs/standards/line-limit-exemptions.md'; |
| 24 | +export const HARD_LIMIT = 600; |
| 25 | +export const SOFT_LIMIT = 300; |
| 26 | + |
| 27 | +/** |
| 28 | + * >600 硬限违规的**冻结名单** —— 只允许减少。 |
| 29 | + * 每完成一个拆分(0-C2 / 0-C3),就从这里删掉对应一行。 |
| 30 | + */ |
| 31 | +export const FROZEN_OVER_LIMIT = [ |
| 32 | + 'app/src-tauri/src/lib.rs', |
| 33 | + 'app/src-tauri/src/types.rs', |
| 34 | + 'app/src-tauri/src/live_session_frame.rs', |
| 35 | + 'app/src-tauri/src/commands_ai_refine.rs', |
| 36 | + 'app/src/pages/ClassroomPage.tsx', |
| 37 | + 'app/src-tauri/src/db_goals.rs', |
| 38 | + 'app/src-tauri/src/commands_goals.rs', |
| 39 | + 'app/src-tauri/src/ai_refine_task.rs', |
| 40 | + 'app/src/components/SessionDetailPanel.tsx', |
| 41 | + 'app/src/components/NoteListView.tsx', |
| 42 | + 'app/src-tauri/src/note_filter.rs', |
| 43 | + 'app/src-tauri/src/artifact_templates.rs', |
| 44 | + 'app/src-tauri/src/video_profile.rs', |
| 45 | + 'app/src/components/SessionListPanel.tsx', |
| 46 | + 'app/src/pages/NotesPage.tsx', |
| 47 | +]; |
| 48 | + |
| 49 | +const toPosix = (p) => p.split(sep).join('/'); |
| 50 | + |
| 51 | +/** |
| 52 | + * 行数口径的**唯一实现**:全部行数(含空行)。 |
| 53 | + * 与 [System.IO.File]::ReadAllLines(path, UTF8).Count 等价:末尾换行不额外算一行。 |
| 54 | + */ |
| 55 | +export function countLines(absPath) { |
| 56 | + const s = readFileSync(absPath, 'utf8'); |
| 57 | + if (s === '') return 0; |
| 58 | + return s.split('\n').length - (s.endsWith('\n') ? 1 : 0); |
| 59 | +} |
| 60 | + |
| 61 | +/** 递归收集源文件,返回 Map<仓库相对路径(正斜杠), 行数> */ |
| 62 | +export function scanTree() { |
| 63 | + const out = new Map(); |
| 64 | + const walk = (absDir) => { |
| 65 | + for (const entry of readdirSync(absDir)) { |
| 66 | + const abs = join(absDir, entry); |
| 67 | + if (statSync(abs).isDirectory()) walk(abs); |
| 68 | + else if (SOURCE_EXT.test(entry)) out.set(toPosix(relative(ROOT, abs)), countLines(abs)); |
| 69 | + } |
| 70 | + }; |
| 71 | + for (const d of SCAN_DIRS) { |
| 72 | + const abs = join(ROOT, d); |
| 73 | + if (existsSync(abs)) walk(abs); |
| 74 | + } |
| 75 | + return out; |
| 76 | +} |
| 77 | + |
| 78 | +/** 从登记表抽出条目:| 路径 | 行数 | … | */ |
| 79 | +export function parseTable() { |
| 80 | + const abs = join(ROOT, TABLE_PATH); |
| 81 | + if (!existsSync(abs)) return []; |
| 82 | + const rows = []; |
| 83 | + for (const line of readFileSync(abs, 'utf8').split('\n')) { |
| 84 | + const m = /^\|\s*`?([^|`]+?\.(?:rs|tsx?))`?\s*\|\s*(\d+)\s*\|/.exec(line); |
| 85 | + if (m) rows.push({ path: m[1].trim(), declared: Number(m[2]) }); |
| 86 | + } |
| 87 | + return rows; |
| 88 | +} |
| 89 | + |
| 90 | +function check({ full }) { |
| 91 | + const measured = scanTree(); |
| 92 | + const rows = parseTable(); |
| 93 | + const registered = new Set(rows.map((r) => r.path)); |
| 94 | + const problems = []; |
| 95 | + |
| 96 | + // (a) 棘轮只减不增 |
| 97 | + const frozen = new Set(FROZEN_OVER_LIMIT); |
| 98 | + for (const [p, n] of measured) { |
| 99 | + if (n > HARD_LIMIT && !frozen.has(p)) problems.push(`(a) 新增 >${HARD_LIMIT} 硬限违规:${p}(${n} 行)→ 必须拆分,不允许登记豁免`); |
| 100 | + } |
| 101 | + // (b) 棘轮无残留 |
| 102 | + for (const p of FROZEN_OVER_LIMIT) { |
| 103 | + const n = measured.get(p); |
| 104 | + if (n === undefined) problems.push(`(b) 冻结名单里的文件已不存在:${p} → 从 FROZEN_OVER_LIMIT 删除该行`); |
| 105 | + else if (n <= HARD_LIMIT) problems.push(`(b) 冻结名单里的文件已回到 ${HARD_LIMIT} 以内:${p}(${n} 行)→ 从 FROZEN_OVER_LIMIT 删除该行`); |
| 106 | + } |
| 107 | + // (c) 无漏登 |
| 108 | + for (const [p, n] of measured) { |
| 109 | + if (n > SOFT_LIMIT && !registered.has(p)) problems.push(`(c) 超过 ${SOFT_LIMIT} 行但未登记:${p}(${n} 行)→ 运行 --write 补登并填写豁免理由`); |
| 110 | + } |
| 111 | + // (d) 无幽灵/过期条目 |
| 112 | + for (const r of rows) { |
| 113 | + const n = measured.get(r.path); |
| 114 | + if (n === undefined) problems.push(`(d) 登记表条目指向不存在的文件:${r.path} → 删除该行`); |
| 115 | + else if (n <= SOFT_LIMIT) problems.push(`(d) 登记表条目已回落至 ${SOFT_LIMIT} 行以内:${r.path}(${n} 行)→ 删除该行`); |
| 116 | + } |
| 117 | + // (e) 数值一致(仅 --full) |
| 118 | + if (full) { |
| 119 | + for (const r of rows) { |
| 120 | + const n = measured.get(r.path); |
| 121 | + if (n !== undefined && n !== r.declared) problems.push(`(e) 行数不一致:${r.path} 声明 ${r.declared} / 实测 ${n} → 运行 --write`); |
| 122 | + } |
| 123 | + } |
| 124 | + |
| 125 | + const over = [...measured.entries()].filter(([, n]) => n > HARD_LIMIT).length; |
| 126 | + const band = [...measured.entries()].filter(([, n]) => n > SOFT_LIMIT && n <= HARD_LIMIT).length; |
| 127 | + const mode = full ? '--full' : '默认'; |
| 128 | + if (problems.length) { |
| 129 | + console.error(`❌ line-limits(${mode}):${problems.length} 处问题`); |
| 130 | + for (const p of problems) console.error(` · ${p}`); |
| 131 | + console.error(`\n现状:>${HARD_LIMIT} 硬限 ${over}/${FROZEN_OVER_LIMIT.length}(棘轮)· ${SOFT_LIMIT+1}–${HARD_LIMIT} 档 ${band} · 登记条目 ${rows.length}`); |
| 132 | + process.exit(1); |
| 133 | + } |
| 134 | + console.log(`✅ line-limits(${mode}):>${HARD_LIMIT} 硬限 ${over}(棘轮内)· ${SOFT_LIMIT+1}–${HARD_LIMIT} 档 ${band} · 登记条目 ${rows.length}`); |
| 135 | +} |
| 136 | + |
| 137 | +// 主入口判定:**必须**用 fileURLToPath + resolve 比较。 |
| 138 | +// `import.meta.url` 在 Windows 上是 `file:///D:/.../a%20b.mjs`(三斜杠 + 空格转义成 %20), |
| 139 | +// 拼 `file://${process.argv[1]}` 得到的字符串**永远不相等** ⇒ 脚本会静默什么都不做(本计划初稿即有此错)。 |
| 140 | +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { |
| 141 | + check({ full: process.argv.includes('--full') }); |
| 142 | +} |
0 commit comments