|
1 | 1 | #!/usr/bin/env node |
2 | 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 | | - * |
| 3 | + * @ai-context 行数红线的**门禁入口**(AGENTS.md §3.1);口径与扫描实现已拆至 `./lib/lineScan.mjs`(批 8 T1),原 7 个导出名**全部在原路径 re-export** ⇒ 模块导出面逐字不变、既有引用零改动。 |
| 4 | + * Why 拆件:本件原为单件 300 行(贴 ≤300 硬线、余量 0),而「末尾换行盲区」判据(批 8 T2)必须落在本件内 |
| 5 | + * ⇒ 纯函数与表解析搬进 `lib/`,本件只留进程级副作用(argv / `console` / `process.exit` / 主入口),承 AGENTS.md §3.1「纯逻辑与副作用物理分离」。 |
| 6 | + * 副作用:`--write` 会重写 docs/standards/line-limit-exemptions.md(人工列按路径保留,唯带 `(自动摘取,待细化)` 标记的理由是生成物、每次重摘);默认模式只读。 |
14 | 7 | * 用法:node scripts/line-limits.mjs [--write|--full] |
15 | 8 | * 忽略任何传入的文件参数,**永远全树扫描**(门禁因此不必依赖 glob —— 旧 glob 的命中集还大于扫描域)。 |
16 | 9 | */ |
17 | | -import { readdirSync, readFileSync, statSync, writeFileSync, existsSync } from 'node:fs'; |
18 | | -import { join, relative, dirname, sep, resolve } from 'node:path'; |
| 10 | +import { existsSync, writeFileSync } from 'node:fs'; |
| 11 | +import { join, resolve } from 'node:path'; |
19 | 12 | import { fileURLToPath } from 'node:url'; |
20 | | - |
21 | | -const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); |
22 | | -const SCAN_DIRS = ['app/src', 'app/src-tauri/src']; |
23 | | -const SOURCE_EXT = /\.(ts|tsx|rs)$/; |
24 | | -export const TABLE_PATH = 'docs/standards/line-limit-exemptions.md'; |
25 | | -export const HARD_LIMIT = 600; |
26 | | -export const SOFT_LIMIT = 300; |
27 | | - |
28 | | -/** |
29 | | - * >600 硬限违规的**冻结名单** —— 只允许减少。 |
30 | | - * 每完成一个拆分(0-C2 / 0-C3),就从这里删掉对应一行。 |
31 | | - */ |
32 | | -export const FROZEN_OVER_LIMIT = [ |
33 | | -]; |
34 | | - |
35 | | -const toPosix = (p) => p.split(sep).join('/'); |
36 | | - |
37 | | -/** 超硬限表格的**生成器前缀**:人工理由追在其后,靠 `stripOverPrefix` 保证 `--write` 幂等 */ |
38 | | -const OVER_PREFIX = `超硬限(>${HARD_LIMIT} 行),不允许豁免`; |
39 | | -/** 幂等:读回时先剥掉生成器自己加的前缀,否则每次 --write 都会叠加一层 */ |
40 | | -const stripOverPrefix = (t) => |
41 | | - t.startsWith(OVER_PREFIX) ? t.slice(OVER_PREFIX.length).replace(/^\s*——\s*/, '').trim() : t.trim(); |
42 | | - |
43 | | -/** |
44 | | - * 行数口径的**唯一实现**:全部行数(含空行)。 |
45 | | - * 与 [System.IO.File]::ReadAllLines(path, UTF8).Count 对**全部常规文本**等价(末尾换行不额外算一行); |
46 | | - * 例外仅两处(评审 15 例边界比对中唯二不等,仓内 0 命中):纯 `\r` 分行(无 `\n`)的文件、内容只有 BOM 的文件。 |
47 | | - */ |
48 | | -export function countLines(absPath) { |
49 | | - const s = readFileSync(absPath, 'utf8'); |
50 | | - if (s === '') return 0; |
51 | | - return s.split('\n').length - (s.endsWith('\n') ? 1 : 0); |
52 | | -} |
53 | | - |
54 | | -/** 递归收集源文件,返回 Map<仓库相对路径(正斜杠), 行数> */ |
55 | | -export function scanTree() { |
56 | | - const out = new Map(); |
57 | | - const walk = (absDir) => { |
58 | | - for (const entry of readdirSync(absDir)) { |
59 | | - const abs = join(absDir, entry); |
60 | | - if (statSync(abs).isDirectory()) walk(abs); |
61 | | - else if (SOURCE_EXT.test(entry)) out.set(toPosix(relative(ROOT, abs)), countLines(abs)); |
62 | | - } |
63 | | - }; |
64 | | - for (const d of SCAN_DIRS) { |
65 | | - const abs = join(ROOT, d); |
66 | | - if (existsSync(abs)) walk(abs); |
67 | | - } |
68 | | - return out; |
69 | | -} |
70 | | - |
71 | | -/** |
72 | | - * 从登记表抽出条目行:| 路径 | 行数 | 豁免理由 | 拆分计划 | —— **查表与写表共用这一处解析口径**。 |
73 | | - * Why 合并(原为 parseTable / parseReasons 两份近似正则):两者解析的是同一张表,改一处忘一处就会让 |
74 | | - * `check` 与 `--write` 对行数/理由的理解漂移;且只有 4 列全取,写入口才能保留人工维护的两列。 |
75 | | - */ |
76 | | -export function parseTable() { |
77 | | - const abs = join(ROOT, TABLE_PATH); |
78 | | - if (!existsSync(abs)) return []; |
79 | | - const rows = []; |
80 | | - for (const line of readFileSync(abs, 'utf8').split('\n')) { |
81 | | - const m = /^\|\s*`?([^|`]+?\.(?:rs|tsx?))`?\s*\|\s*(\d+)\s*\|\s*([^|]*)\|\s*([^|]*)\|/.exec(line); |
82 | | - if (m) rows.push({ path: m[1].trim(), declared: Number(m[2]), why: m[3].trim(), split: m[4].trim() }); |
83 | | - } |
84 | | - return rows; |
85 | | -} |
86 | | - |
87 | | -/** 自动理由的**来源标记**:带它的单元格是生成物 ⇒ 每次 `--write` 重新摘取(见 `humanWhy`) */ |
88 | | -const AUTO_SUFFIX = '(自动摘取,待细化)'; |
89 | | -/** 注释行前缀(`//` `//!` `///` `*` `/*`)—— 续读时逐行剥离;无前缀的行(裸代码)即注释块已结束 */ |
90 | | -const COMMENT_EDGE = /^[ \t]*(?:\/\/[/!]*|\/\*+|\*+\/?)[ \t]?/; |
91 | | -/** 续读上限:当前最长自然终止(`。!?` 句末)需 11 行,12 只作防御(防无空行无句末的注释块被整块吞进单元格) */ |
92 | | -const REASON_MAX_LINES = 12; |
93 | | - |
94 | | -/** |
95 | | - * 取该文件 `@ai-context` 的**首句**要点,作为自动补登时的理由。 |
96 | | - * Why 抓整行再清洗:本仓大量 `@ai-context` 行以 `**加粗**` 开头(如 `@ai-context **域图标**几何(9 个):…`), |
97 | | - * 用 `[^\n*]+` 直接卡在 `*` 上会**一格都捕获不到**,使自动理由全部退化成占位符。 |
98 | | - * Why 续读:`@ai-context` 常折行(首行停在 `(`/`——`/`+` 等连接符处)⇒ 只取第一个物理行会让理由半句截断 |
99 | | - * (2026-09-11 实测 43 条里 41 条如此)。续读到**句末 / 空行 / 注释块收尾**;句末只认 `。!?` —— 把 `;` 也算句末会停在未闭合括号里(实测 122 条有 1 条)。 |
100 | | - */ |
101 | | -function autoReason(absPath) { |
102 | | - const lines = readFileSync(absPath, 'utf8').split('\n'); |
103 | | - const at = lines.findIndex((l) => l.includes('@ai-context')); |
104 | | - if (at < 0) return '(待补理由:本条目由生成器补登)'; |
105 | | - const parts = []; |
106 | | - for (let i = at; i < lines.length && i < at + REASON_MAX_LINES; i++) { |
107 | | - const raw = lines[i]; |
108 | | - const close = raw.indexOf('*/'); // 行内块注释收尾:只取它前面的文字 |
109 | | - const bare = close < 0 ? raw : raw.slice(0, close); |
110 | | - if (i > at && !COMMENT_EDGE.test(bare)) break; // 无注释前缀 = 注释块已结束 |
111 | | - // 首行只取 `@ai-context` 之后的文字;续行剥注释前缀(折行处不补空格 —— 本仓注释在中英混排处断行) |
112 | | - const body = (i === at ? bare.replace(/^[\s\S]*?@ai-context[::]?\s*/, '') : bare.replace(COMMENT_EDGE, '')).trim(); |
113 | | - if (body) parts.push(body); |
114 | | - if (!body || close >= 0 || /[。!?]$/.test(body)) break; // 空行 / 块注释收尾 / 句末 |
115 | | - } |
116 | | - const text = parts.join('').replace(/\*\*/g, '').trim(); |
117 | | - return text ? `${text}${AUTO_SUFFIX}` : '(待补理由:本条目由生成器补登)'; |
118 | | -} |
| 13 | +import { ROOT, SCAN_DIRS, SOURCE_EXT, TABLE_PATH, HARD_LIMIT, SOFT_LIMIT, FROZEN_OVER_LIMIT, scanTree, parseTable, parseHistory, autoReason, stripOverPrefix, OVER_PREFIX } from './lib/lineScan.mjs'; |
| 14 | +export { countLines, scanTree, parseTable, FROZEN_OVER_LIMIT, HARD_LIMIT, SOFT_LIMIT, TABLE_PATH } from './lib/lineScan.mjs'; |
119 | 15 |
|
120 | 16 | /** |
121 | 17 | * 扫描域规模守卫:`check()` 与 `writeTable()` **两个入口共用**,且必须在做任何事之前调用 —— |
@@ -154,15 +50,6 @@ function assertScanSane(measured) { |
154 | 50 | } |
155 | 51 | } |
156 | 52 |
|
157 | | -/** 逐字保留「已拆分 / 登记移除记录」整节 */ |
158 | | -function parseHistory() { |
159 | | - const abs = join(ROOT, TABLE_PATH); |
160 | | - if (!existsSync(abs)) return ''; |
161 | | - const src = readFileSync(abs, 'utf8'); |
162 | | - const at = src.indexOf('## 已拆分'); |
163 | | - return at < 0 ? '' : src.slice(at).trimEnd() + '\n'; |
164 | | -} |
165 | | - |
166 | 53 | function writeTable() { |
167 | 54 | const measured = scanTree(); |
168 | 55 | // ⚠️ 必须在 parseTable()/writeFileSync() **之前**:否则扫描域失效时它会把 138 条清成表头 + 历史节(实测 179 → 41 行)还打印 ✅、exit 0。同类破坏在"SCAN_DIRS 少写一个目录"时同样可达。 |
|
0 commit comments