@fanchao8609/agent_brain_sync 1.16.4 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,216 @@
1
+ // src/todo/validate.js — 条目级闸门:形状校验、长度限制、空行检查。
2
+
3
+ import { TODO_SECTIONS, isAllowedTodoSub, INDEX_SECTIONS, ENTRY_SHAPES, indexLinkSectionNeeds, BREAKPOINT_MAX, TASK_LINE_MAX } from './spec.js';
4
+ import { sigOfLine, REPORT_MARKERS, sentenceCount, isTaskLine, isAllowedTodoLine, todoSectionOf } from './common.js';
5
+
6
+ export function checkFileShape(file, text, { lintMode = false } = {}) {
7
+ const issues = [];
8
+ const lines = String(text ?? '').split('\n');
9
+ const allowed = file === 'index.md' ? INDEX_SECTIONS : file === 'todo.md' ? TODO_SECTIONS : null;
10
+
11
+ if (allowed) {
12
+ for (let i = 0; i < lines.length; i++) {
13
+ const l = lines[i];
14
+ // `## [` 开头是 log 的条目行,不是分区标签(否则会把 238 条流水全判非法)
15
+ if (/^##\s+\[/.test(l)) continue;
16
+ const m = l.match(/^##\s+(\S.*)$/);
17
+ const sub = l.match(/^###\s+(\S.*)$/);
18
+ if (!m && !sub) continue;
19
+ const name = (m ? m[1] : sub[1]).trim();
20
+ // todo 的 `###` 标签单独判(只许日期组 + Archived)
21
+ if (sub && file === 'todo.md') {
22
+ if (!isAllowedTodoSub(name)) {
23
+ issues.push({
24
+ code: 'SECTIONS-NOT-ALLOWED',
25
+ line: i + 1,
26
+ msg: `todo.md:${i + 1} 不允许的小节 \`### ${name}\`;` +
27
+ `todo.md 的 ### 只允许 \`### YYYY-MM-DD\`(Done 区日期组)、\`### Undated\` 与 \`### Archived\``,
28
+ });
29
+ }
30
+ continue;
31
+ }
32
+ if (!allowed.includes(name)) {
33
+ issues.push({
34
+ code: 'SECTIONS-NOT-ALLOWED',
35
+ line: i + 1,
36
+ msg: `${file}:${i + 1} 不允许的标签 \`${m ? '##' : '###'} ${name}\`;` +
37
+ `${file} 只允许 ${allowed.map((s) => `\`## ${s}\``).join(' / ')}${
38
+ file === 'todo.md' ? '(`###` 只许日期组与 `### Archived`)' : ''}。` +
39
+ `内容按语义归入现有标签,确实需要例外请先改白名单(见 src/todo.js 的 SECTIONS 常量)`,
40
+ });
41
+ }
42
+ }
43
+ }
44
+
45
+ // 条目形状
46
+ const shapes = ENTRY_SHAPES[file] || [];
47
+ if (shapes.length) {
48
+ // 先算每个行所属分区(形状只对被要求的区生效)
49
+ let section = null;
50
+ for (let i = 0; i < lines.length; i++) {
51
+ const l = lines[i];
52
+ // ★ 分区标题的识别不能吃掉 log 的条目行:log 的条目就是 `## [时间] …`,
53
+ // 若把它当分区标题就会 continue 掉本行,形状校验形同虚设(实测:
54
+ // kind=node / 缺竖线 全部漏放)。判据:`## [` 开头 = 条目,不是分区。
55
+ // ★ 分区标题的识别不能吃掉 log 的条目行:log 的条目就是 `## [时间] …`,
56
+ // 若把它当分区标题就会 continue 掉本行,形状校验形同虚设(实测:
57
+ // kind=node / 缺竖线 全部漏放)。
58
+ // ★ log.md 特殊:它**没有分区**,`## ` 开头的只能是条目(含写错的),
59
+ // 所以 log 里绝不能把 `## ` 行当分区标题 —— 全交给形状规则判。
60
+ const sm = file === 'log.md' ? null : (/^##\s+\[/.test(l) ? null : l.match(/^##\s+(\S.*)$/));
61
+ if (sm) { section = sm[1].trim(); continue; }
62
+ if (!l.trim()) continue;
63
+ const needShape = file === 'log.md'
64
+ ? true
65
+ : file === 'todo.md'
66
+ ? true // todo 的任务行形状与分区无关(Todo/Done 都要 `- [ ] …`)
67
+ : indexLinkSectionNeeds(section);
68
+ if (!needShape) continue;
69
+ for (const rule of shapes) {
70
+ const bad = rule.test(l);
71
+ if (bad) issues.push({ code: rule.name, line: i + 1, msg: `${file}:${i + 1} ${bad}` });
72
+ // lintOnly 规则只在体检时跑(不阻写入)—— 用于「内容质量」类问题,
73
+ // 那些问题当闸门会把存量脏库锁死。
74
+ if (!lintMode) continue;
75
+ const bad2 = rule.lintOnly ? rule.lintOnly(l) : null;
76
+ if (bad2) issues.push({ code: `${rule.name}-QUALITY`, line: i + 1, msg: `${file}:${i + 1} ${bad2}` });
77
+ }
78
+ }
79
+ }
80
+ // 条目之间的空行(与写入闸门 assertNoStrayBlank 同判据)。
81
+ // lint 只能「报」,不能抛 —— 体检不能因格式坏而挂。
82
+ const isEntryLine = (l) => /^-\s/.test(l) || /^#{1,3}\s+\[/.test(l);
83
+ const isHeadingLine = (l) => /^#{1,3}\s/.test(l);
84
+ for (let i = 0; i < lines.length; i++) {
85
+ if (lines[i].trim()) continue;
86
+ if (i > 0 && !lines[i - 1].trim()) continue; // 连续空行只报一次
87
+ let p = i - 1;
88
+ while (p >= 0 && !lines[p].trim()) p--;
89
+ let n = i + 1;
90
+ while (n < lines.length && !lines[n].trim()) n++;
91
+ const prev = p >= 0 ? lines[p] : null;
92
+ const next = n < lines.length ? lines[n] : null;
93
+ if (!prev || !next) continue;
94
+ if (isHeadingLine(prev)) continue; // 标签与首条目之间的空行是排版,不算乱
95
+ if (isEntryLine(prev) && isEntryLine(next)) {
96
+ issues.push({ code: 'STRAY-BLANK-LINE', line: i + 1, msg: `${file}:${i + 1} 条目之间不允许空行(条目必须紧贴)` });
97
+ }
98
+ }
99
+ return issues;
100
+ }
101
+
102
+ /** index 的某个区是否要求 `- [[页名]]` 形状(Rules 与未知区不要求)。 */
103
+
104
+ export function violationSignatures(text) {
105
+ const set = new Set();
106
+ const lines = String(text ?? '').split('\n');
107
+ for (const l of lines) set.add(sigOfLine(l));
108
+ const isEntry = (l) => /^-\s/.test(l) || /^#{1,3}\s+\[/.test(l);
109
+ for (let i = 1; i < lines.length - 1; i++) {
110
+ if (lines[i].trim()) continue;
111
+ if (isEntry(lines[i - 1]) && isEntry(lines[i + 1])) {
112
+ set.add('HAS-STRAY-BLANK');
113
+ break;
114
+ }
115
+ }
116
+ return set;
117
+ }
118
+ /** 断点里出现这些 = 明显是在塞报告(列表/代码块/表格)。 */
119
+
120
+ export function assertNoStrayBlank(text, file, prevSig = null) {
121
+ const lines = String(text ?? '').split('\n');
122
+ const isEntry = (l) => /^-\s/.test(l) || /^#{1,3}\s+\[/.test(l);
123
+ const isHeading = (l) => /^#{1,3}\s/.test(l);
124
+ for (let i = 0; i < lines.length; i++) {
125
+ if (lines[i].trim()) continue;
126
+ // 找上一个/下一个非空行
127
+ let p = i - 1;
128
+ while (p >= 0 && !lines[p].trim()) p--;
129
+ let n = i + 1;
130
+ while (n < lines.length && !lines[n].trim()) n++;
131
+ const prev = p >= 0 ? lines[p] : null;
132
+ const next = n < lines.length ? lines[n] : null;
133
+ if (prev === null || next === null) continue; // 文件头尾空行不算
134
+ // 连续多个空行:报一次就够
135
+ if (i > 0 && !lines[i - 1].trim()) continue;
136
+ if (!isEntry(prev) && !isEntry(next)) continue; // 正文段落之间允许
137
+ // ★ 标题与首个条目之间必留一个空行(排版必需)—— H1/标签后紧跟条目反而难看。
138
+ // 实测踩坑:log 模板就是 `# H1` + 空行 + `## [日期] …`,不放行会把模板本身卡死。
139
+ if (isHeading(prev)) continue;
140
+ // 两个条目之间(含「条目 → 空行 → 条目」)不允许
141
+ if (isEntry(prev) && isEntry(next)) {
142
+ // 存量空行(写入前就有)→ 放行,交给 lint 报
143
+ if (prevSig !== null && prevSig.has('HAS-STRAY-BLANK')) continue;
144
+ throw new Error(
145
+ `✗ ${file} 不允许条目之间的空行(第 ${i + 1} 行):\n` +
146
+ ` 上一行: ${prev.slice(0, 50)}\n` +
147
+ ` 下一行: ${next.slice(0, 50)}\n` +
148
+ ` 条目必须紧贴;标签与首条目之间才留一个空行。已拒绝写入 —— 请删掉这个空行再写。`,
149
+ );
150
+ }
151
+ }
152
+ return true;
153
+ }
154
+
155
+ /** 校验 todo 全文的条目内容。抛错 = 拒绝写入(不是修正)。 */
156
+
157
+ export function assertTodoContent(text, prevSig = null) {
158
+ const lines = String(text ?? '').split('\n');
159
+ /** 存量行(写入前就存在)→ 放行,交给 lint 报。 */
160
+ const isLegacy = (l) => prevSig !== null && prevSig.has(sigOfLine(l));
161
+ for (let i = 0; i < lines.length; i++) {
162
+ const l = lines[i];
163
+ const lineNo = i + 1;
164
+ if (l.startsWith('## ')) continue;
165
+ // 任务行
166
+ if (isTaskLine(l)) {
167
+ if (l.length > TASK_LINE_MAX && !isLegacy(l)) {
168
+ throw new Error(
169
+ `✗ 任务行太长(第 ${lineNo} 行 ${l.length} 字 > ${TASK_LINE_MAX})—— todo 只放「要做什么」,一句话。\n` +
170
+ ` 这么长的内容要么拆成多条任务,要么走 abs note(经验)/ abs log(流水)。\n` +
171
+ ` 原文开头: ${l.slice(0, 60)}...`,
172
+ );
173
+ }
174
+ continue;
175
+ }
176
+ // 断点附属行
177
+ if (l.trimStart().startsWith('↳ 断点:')) {
178
+ if (isLegacy(l)) continue; // 存量超长断点:不阻写入(lint 报)
179
+ const body = l.trimStart().slice('↳ 断点:'.length).trim();
180
+ if (body.length > BREAKPOINT_MAX) {
181
+ throw new Error(
182
+ `✗ 断点太长(第 ${lineNo} 行 ${body.length} 字 > ${BREAKPOINT_MAX})—— 断点只写「改到哪个文件哪一步」。\n` +
183
+ ` 写不下的内容请分流:实施报告/验证数据 → abs log;经验与坑 → abs note;长设计 → .brain/sources/ 页。\n` +
184
+ ` 原文开头: ${body.slice(0, 60)}...`,
185
+ );
186
+ }
187
+ if (REPORT_MARKERS.some((re) => re.test(body)) || sentenceCount(body) > 2) {
188
+ throw new Error(
189
+ `✗ 断点里像是塞了报告/清单(第 ${lineNo} 行)—— 断点是一句话(最多两句),不是文档。\n` +
190
+ ` todo 只能加 todo:要做的拆成任务,做完的写 abs log,经验写 abs note。\n` +
191
+ ` 原文开头: ${body.slice(0, 60)}...`,
192
+ );
193
+ }
194
+ continue;
195
+ }
196
+ // 裸文本行(2026-10-11):Todo 区里既不是任务行也不是断点的行 = 总结/报告/清单。
197
+ // 前面的修法都挂在「行的形态」上(超长断点、多句断点),把总结写成裸文本行就全部绕过
198
+ // ——同一个坑第三次复发。改成白名单后,换形态也落回这里。
199
+ // 按区判:Todo 区才管;Done/Archived 区有日期分组与归档链接,判据不同。
200
+ // 存量行(prevSig)放行,交给 lint 报 —— 否则老库直接被锁死写不进去。
201
+ if (todoSectionOf(lines, i) === 'Todo' && !isAllowedTodoLine(l) && !isLegacy(l)) {
202
+ throw new Error(
203
+ `✗ todo 的 Todo 区不允许裸文本(第 ${lineNo} 行)—— 这里只放任务行(- [ ] …)与断点行(↳ 断点: …)。\n` +
204
+ ` 「总结/实施报告/清单」请分流:流水与测试数据 → abs log;经验与坑 → abs note;长设计与方案 → .brain/sources/ 页。\n` +
205
+ ` 原文开头: ${l.trim().slice(0, 60)}...`,
206
+ );
207
+ }
208
+ }
209
+ return true;
210
+ }
211
+
212
+ /** 删掉「条目之间的空行」(条目前后紧贴才是标准形态)。
213
+ * 在 enforceBrainFormat 里跑两次:assertShape 前(清存量,否则存量空行会把写入全卡死)、
214
+ * 重建后(重排会新建邻接关系)。幂等 —— 第二次通常零改动。
215
+ * 为什么删而不是拒:空行不携带信息,删了不丢东西;而断点/条目形状带着内容,
216
+ * 没有安全的自动修法,只能拒并让人处理。 */