Skip to content

Commit 547ffa1

Browse files
committed
refactor(scripts): 拆 line-limits 为 CLI 与 lineScan 两件
1 parent 50fde7b commit 547ffa1

2 files changed

Lines changed: 130 additions & 121 deletions

File tree

‎scripts/lib/lineScan.mjs‎

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

‎scripts/line-limits.mjs‎

Lines changed: 8 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -1,121 +1,17 @@
11
#!/usr/bin/env node
22
/**
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(人工列按路径保留,唯带 `(自动摘取,待细化)` 标记的理由是生成物、每次重摘);默认模式只读。
147
* 用法:node scripts/line-limits.mjs [--write|--full]
158
* 忽略任何传入的文件参数,**永远全树扫描**(门禁因此不必依赖 glob —— 旧 glob 的命中集还大于扫描域)。
169
*/
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';
1912
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';
11915

12016
/**
12117
* 扫描域规模守卫:`check()` 与 `writeTable()` **两个入口共用**,且必须在做任何事之前调用 ——
@@ -154,15 +50,6 @@ function assertScanSane(measured) {
15450
}
15551
}
15652

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-
16653
function writeTable() {
16754
const measured = scanTree();
16855
// ⚠️ 必须在 parseTable()/writeFileSync() **之前**:否则扫描域失效时它会把 138 条清成表头 + 历史节(实测 179 → 41 行)还打印 ✅、exit 0。同类破坏在"SCAN_DIRS 少写一个目录"时同样可达。

0 commit comments

Comments
 (0)