@longzai-intelligence-records/ledger 0.0.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,228 @@
1
+ /**
2
+ * records lint 核(判定全部基于解析器产物——口径单一真源)
3
+ *
4
+ * 规则面:文件名形态 / H1(含编号一致性)/ 记录日期行(含真实日历日)/ 来源
5
+ * 行。存量豁免走棘轮基线(file + rule 逐条登记,只许缩短——基线外的旧违例
6
+ * 依旧拦截,不放大豁免面)。records 无分型,故无任何分型节校验(decisions/021)。
7
+ */
8
+
9
+ import type { LziRecordsConfig } from '@longzai-intelligence-records/config';
10
+
11
+ import { readFileSync } from 'node:fs';
12
+ import { posix } from 'node:path';
13
+
14
+ import { scanRecordsDir } from '@/fs/scan-registry.utils';
15
+ import { isRealCalendarDate, RECORD_FILENAME_PATTERN } from '@/parser/format.utils';
16
+ import { parseRecordDoc } from '@/parser/record.parser';
17
+
18
+ /**
19
+ * lint 违例条目
20
+ */
21
+ export type RecordsLintViolation = {
22
+ /**
23
+ * 规则名
24
+ */
25
+ rule: string;
26
+
27
+ /**
28
+ * 文件(posix 相对仓库根)
29
+ */
30
+ file: string;
31
+
32
+ /**
33
+ * 行号(1 起算)
34
+ */
35
+ line: number;
36
+
37
+ /**
38
+ * 违例消息
39
+ */
40
+ message: string;
41
+ };
42
+
43
+ /**
44
+ * lint 结果
45
+ */
46
+ export type RecordsLintResult = {
47
+ /**
48
+ * 基线豁免后的违例列表
49
+ */
50
+ violations: RecordsLintViolation[];
51
+
52
+ /**
53
+ * 扫描文件数
54
+ */
55
+ fileCount: number;
56
+
57
+ /**
58
+ * 基线豁免命中数(棘轮观测面)
59
+ */
60
+ exemptedCount: number;
61
+ };
62
+
63
+ /**
64
+ * lint 入参
65
+ */
66
+ export type RecordsLintInput = {
67
+ /**
68
+ * 仓库根绝对路径
69
+ */
70
+ root: string;
71
+
72
+ /**
73
+ * 解析后配置
74
+ */
75
+ config: LziRecordsConfig;
76
+ };
77
+
78
+ /**
79
+ * 运行 lint(全 scope 注册表;只读)
80
+ *
81
+ * @param input - lint 入参
82
+ * @returns lint 结果(违例空 = 通过)
83
+ */
84
+ export function runRecordsLint(input: RecordsLintInput): RecordsLintResult {
85
+ /**
86
+ * 原始违例列表
87
+ */
88
+ const raw: RecordsLintViolation[] = [];
89
+
90
+ /**
91
+ * 豁免命中数
92
+ */
93
+ let exemptedCount = 0;
94
+
95
+ for (const scope of input.config.scopes) {
96
+ /**
97
+ * 注册表绝对目录
98
+ */
99
+ const dirAbs = joinPosixSafe(input.root, scope.registryDir);
100
+
101
+ /**
102
+ * 扫描条目
103
+ */
104
+ const entries = scanRecordsDir(dirAbs);
105
+
106
+ for (const entry of entries) {
107
+ /**
108
+ * 文件相对路径(posix,与基线登记口径一致)
109
+ */
110
+ const fileRel = posix.join(scope.registryDir, entry.basename);
111
+
112
+ /**
113
+ * 文件名形态判定
114
+ */
115
+ const filenameMatch = RECORD_FILENAME_PATTERN.exec(entry.basename);
116
+
117
+ if (filenameMatch === null) {
118
+ raw.push({
119
+ rule: 'R-FILENAME',
120
+ file: fileRel,
121
+ line: 0,
122
+ message: `文件名须为「NNNN-<slug>.md」(编号 3-4 位零填充 + ASCII kebab)`,
123
+ });
124
+
125
+ continue;
126
+ }
127
+
128
+ /**
129
+ * 解析结果
130
+ */
131
+ const parsed = parseRecordDoc(readFileSync(entry.absPath, 'utf8'));
132
+
133
+ if (parsed.record === null) {
134
+ for (const issue of parsed.issues) {
135
+ raw.push({ rule: 'R-H1', file: fileRel, line: issue.line, message: issue.message });
136
+ }
137
+
138
+ continue;
139
+ }
140
+
141
+ /**
142
+ * H1 编号与文件名编号一致性
143
+ */
144
+ if (parsed.record.number !== filenameMatch[1]) {
145
+ raw.push({
146
+ rule: 'R-H1-NUMBER',
147
+ file: fileRel,
148
+ line: 1,
149
+ message: `H1 编号「${parsed.record.number}」与文件名编号「${filenameMatch[1]}」不一致`,
150
+ });
151
+ }
152
+
153
+ /**
154
+ * 记录日期行判定(缺席或非真实日历日)
155
+ */
156
+ if (parsed.record.date === null) {
157
+ raw.push({
158
+ rule: 'R-DATE-LINE',
159
+ file: fileRel,
160
+ line: parsed.record.headerEndLine + 1,
161
+ message: '头部缺「> 记录日期:YYYY-MM-DD」元数据行',
162
+ });
163
+ } else if (!isRealCalendarDate(parsed.record.date)) {
164
+ raw.push({
165
+ rule: 'R-DATE-REAL',
166
+ file: fileRel,
167
+ line: 2,
168
+ message: `记录日期「${parsed.record.date}」非真实日历日期`,
169
+ });
170
+ }
171
+
172
+ /**
173
+ * 来源行判定
174
+ */
175
+ if (parsed.record.source === null) {
176
+ raw.push({
177
+ rule: 'R-SOURCE-LINE',
178
+ file: fileRel,
179
+ line: parsed.record.headerEndLine + 1,
180
+ message: '头部缺「> 来源:<出处>」元数据行',
181
+ });
182
+ }
183
+ }
184
+ }
185
+
186
+ /**
187
+ * 基线豁免(file + rule 精确命中即豁免;豁免面只许缩短)
188
+ */
189
+ const baselineKeys = new Set(
190
+ input.config.lint.baseline.map((entry) => `${entry.file}|${entry.rule}`),
191
+ );
192
+
193
+ /**
194
+ * 豁免后违例列表
195
+ */
196
+ const violations = raw.filter((violation) => {
197
+ /**
198
+ * 豁免命中标记
199
+ */
200
+ const hit = baselineKeys.has(`${violation.file}|${violation.rule}`);
201
+
202
+ if (hit) {
203
+ exemptedCount += 1;
204
+ }
205
+
206
+ return !hit;
207
+ });
208
+
209
+ return {
210
+ violations,
211
+ fileCount: input.config.scopes.reduce(
212
+ (count, scope) => count + scanRecordsDir(joinPosixSafe(input.root, scope.registryDir)).length,
213
+ 0,
214
+ ),
215
+ exemptedCount,
216
+ };
217
+ }
218
+
219
+ /**
220
+ * 拼接注册表绝对目录(posix 段拼接 + 根锚定)
221
+ *
222
+ * @param root - 仓库根绝对路径
223
+ * @param registryDir - 注册表目录(posix 相对)
224
+ * @returns 绝对路径
225
+ */
226
+ function joinPosixSafe(root: string, registryDir: string): string {
227
+ return `${root.replace(/\/+$/, '')}/${registryDir}`;
228
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * records 格式口径单一真源
3
+ *
4
+ * 全部格式口径(文件名形态、H1 形态、头部元数据行、占位符)集中于此模块。
5
+ * 读侧(解析器 / lint / 索引差量)与写侧(模板渲染)共用同一真源,杜绝两套
6
+ * 口径漂移——create 产物在构造上天然通过 lint。
7
+ *
8
+ * records 定位(decisions/021):广义降级 fallback 通道,无语义分型——格式面
9
+ * 只有头部元数据强制(记录日期 / 来源 / 可选关联),正文自由格式、无固定节。
10
+ */
11
+
12
+ /**
13
+ * 文件名形态(编号 3-4 位零填充 + ASCII kebab slug + .md)
14
+ */
15
+ export const RECORD_FILENAME_PATTERN = /^(\d{3,4})-([a-z0-9]+(?:-[a-z0-9]+)*)\.md$/;
16
+
17
+ /**
18
+ * slug 合法形态(ASCII kebab,小写字母/数字/连字符——与 issues 家族口径一致)
19
+ */
20
+ export const ASCII_KEBAB_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
21
+
22
+ /**
23
+ * H1 形态(编号入 H1,em dash 分隔——产品群 records 主流形态)
24
+ */
25
+ export const RECORD_H1_PATTERN = /^# (\d{3,4}) — (.+)$/;
26
+
27
+ /**
28
+ * 记录日期行形态(blockquote 元数据,YYYY-MM-DD)
29
+ */
30
+ export const DATE_LINE_PATTERN = /^> 记录日期:(\d{4}-\d{2}-\d{2})$/;
31
+
32
+ /**
33
+ * 来源行形态(blockquote 元数据,非空)
34
+ */
35
+ export const SOURCE_LINE_PATTERN = /^> 来源:(.+)$/;
36
+
37
+ /**
38
+ * 关联行形态(blockquote 元数据,可选行,非空)
39
+ */
40
+ export const REF_LINE_PATTERN = /^> 关联:(.+)$/;
41
+
42
+ /**
43
+ * 头部元数据键:记录日期
44
+ */
45
+ export const DATE_LINE_KEY = '记录日期';
46
+
47
+ /**
48
+ * 头部元数据键:来源
49
+ */
50
+ export const SOURCE_LINE_KEY = '来源';
51
+
52
+ /**
53
+ * 头部元数据键:关联
54
+ */
55
+ export const REF_LINE_KEY = '关联';
56
+
57
+ /**
58
+ * 正文占位符(建档即骨架,内容随工作回填)
59
+ */
60
+ export const SECTION_PLACEHOLDER = '(待补充)';
61
+
62
+ /**
63
+ * 正文自由格式指引行(渲染产物与骨架共享——fallback 通道定位的显式声明)
64
+ */
65
+ export const BODY_GUIDANCE =
66
+ '(正文自由格式——所有不清楚归属、被去除的内容都落此通道;无分型,无固定节)';
67
+
68
+ /**
69
+ * 索引行引用形态(README 索引行中的注册表文件链接——非全局模式,
70
+ * matchAll 消费侧自建全局实例,避免模块级正则共享 lastIndex 状态)
71
+ */
72
+ export const INDEX_LINK_PATTERN =
73
+ /\[?\d{3,4}[^\]\n]*\]?\(\.\/(\d{3,4})-([a-z0-9]+(?:-[a-z0-9]+)*)\.md\)/;
74
+
75
+ /**
76
+ * 正则捕获组安全取值(守卫形态——替代非空断言;未命中即内部口径错误)
77
+ *
78
+ * @param match - 正则命中结果
79
+ * @param index - 捕获组下标
80
+ * @returns 捕获组值
81
+ * @throws {@link Error} 未命中或捕获组越界(格式口径与调用侧失配)
82
+ */
83
+ export function captureGroup(match: RegExpMatchArray | null, index: number): string {
84
+ if (match === null || match[index] === undefined) {
85
+ throw new Error(`正则捕获组 ${index} 未命中(格式口径内部错误)`);
86
+ }
87
+
88
+ return match[index];
89
+ }
90
+
91
+ /**
92
+ * 提取 README 内容中的注册表引用集(编号 → slug,重复引用以后者为准)
93
+ *
94
+ * @param content - README 全文
95
+ * @returns 编号键 → slug 映射
96
+ */
97
+ export function extractIndexRefs(content: string): Map<string, string> {
98
+ /**
99
+ * 引用集
100
+ */
101
+ const refs = new Map<string, string>();
102
+
103
+ for (const match of content.matchAll(new RegExp(INDEX_LINK_PATTERN.source, 'g'))) {
104
+ refs.set(captureGroup(match, 1), captureGroup(match, 2));
105
+ }
106
+
107
+ return refs;
108
+ }
109
+
110
+ /**
111
+ * 从文件名提取编号键(宽口径:任何 `NNNN-*` 形态的 .md 基名)
112
+ *
113
+ * @param basename - 文件基名
114
+ * @returns 编号键(数字字符串原样);非编号形态为 null
115
+ */
116
+ export function extractRecordKey(basename: string): string | null {
117
+ /**
118
+ * 宽口径命中(数字前缀 + 连字符)
119
+ */
120
+ const match = /^(\d{1,4})[-._]/.exec(basename);
121
+
122
+ return match === null ? null : captureGroup(match, 1);
123
+ }
124
+
125
+ /**
126
+ * 判定日期字符串是否为真实存在的日历日期(YYYY-MM-DD)
127
+ *
128
+ * @param date - 日期字符串
129
+ * @returns 真实日历日期为 true
130
+ */
131
+ export function isRealCalendarDate(date: string): boolean {
132
+ /**
133
+ * 年 / 月 / 日数值
134
+ */
135
+ const [year, month, day] = date.split('-').map((part) => Number.parseInt(part, 10));
136
+
137
+ if (year === undefined || month === undefined || day === undefined) {
138
+ return false;
139
+ }
140
+
141
+ /**
142
+ * 构造日期(JS Date 会对越界日/月进位——进位即非法)
143
+ */
144
+ const constructed = new Date(Date.UTC(year, month - 1, day));
145
+
146
+ return (
147
+ constructed.getUTCFullYear() === year &&
148
+ constructed.getUTCMonth() === month - 1 &&
149
+ constructed.getUTCDate() === day
150
+ );
151
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * record 文档解析器
3
+ *
4
+ * 判定全部基于本解析器产物(口径单一真源 format.utils)。解析头部:H1(编号
5
+ * 入 H1)+ blockquote 元数据行(记录日期 / 来源 / 可选关联);正文自由格式
6
+ * 不解析(无分型——decisions/021)。
7
+ */
8
+
9
+ import {
10
+ captureGroup,
11
+ DATE_LINE_KEY,
12
+ DATE_LINE_PATTERN,
13
+ RECORD_H1_PATTERN,
14
+ REF_LINE_KEY,
15
+ REF_LINE_PATTERN,
16
+ SOURCE_LINE_KEY,
17
+ SOURCE_LINE_PATTERN,
18
+ } from '@/parser/format.utils';
19
+
20
+ /**
21
+ * 解析后的 record 头部
22
+ */
23
+ export type ParsedRecord = {
24
+ /**
25
+ * H1 编号(数字字符串原样)
26
+ */
27
+ number: string;
28
+
29
+ /**
30
+ * H1 标题
31
+ */
32
+ title: string;
33
+
34
+ /**
35
+ * 记录日期(YYYY-MM-DD)
36
+ */
37
+ date: string | null;
38
+
39
+ /**
40
+ * 来源
41
+ */
42
+ source: string | null;
43
+
44
+ /**
45
+ * 关联(可选行缺席为 null)
46
+ */
47
+ ref: string | null;
48
+
49
+ /**
50
+ * 头部元数据结束行号(1 起算;正文起始参考)
51
+ */
52
+ headerEndLine: number;
53
+ };
54
+
55
+ /**
56
+ * 解析问题条目(行级)
57
+ */
58
+ export type RecordParseIssue = {
59
+ /**
60
+ * 行号(1 起算;0 = 全文级)
61
+ */
62
+ line: number;
63
+
64
+ /**
65
+ * 问题消息
66
+ */
67
+ message: string;
68
+ };
69
+
70
+ /**
71
+ * 解析结果
72
+ */
73
+ export type RecordParseResult = {
74
+ /**
75
+ * 解析后的头部(H1 非法时为 null)
76
+ */
77
+ record: ParsedRecord | null;
78
+
79
+ /**
80
+ * 解析问题列表(空 = 合法建档态)
81
+ */
82
+ issues: RecordParseIssue[];
83
+ };
84
+
85
+ /**
86
+ * 解析 record 文档全文
87
+ *
88
+ * @param content - 文档全文
89
+ * @returns 解析结果:头部字段 + 行级问题(骨架建档态 = 占位值可解析、无问题)
90
+ */
91
+ export function parseRecordDoc(content: string): RecordParseResult {
92
+ /**
93
+ * 行列表
94
+ */
95
+ const lines = content.split('\n');
96
+
97
+ /**
98
+ * 问题列表
99
+ */
100
+ const issues: RecordParseIssue[] = [];
101
+
102
+ /**
103
+ * H1 命中(首行必须为合法 H1)
104
+ */
105
+ const h1Match = RECORD_H1_PATTERN.exec(lines[0] ?? '');
106
+
107
+ if (h1Match === null) {
108
+ issues.push({
109
+ line: 1,
110
+ message: `首行须为「# NNNN — 标题」形态 H1(编号入 H1,em dash 分隔)`,
111
+ });
112
+
113
+ return { record: null, issues };
114
+ }
115
+
116
+ /**
117
+ * 元数据命中集(键 → 值)
118
+ */
119
+ const meta = new Map<string, string>();
120
+
121
+ /**
122
+ * 头部扫描行号(H1 后自第 2 行起,直到非空非 blockquote 行止)
123
+ */
124
+ let cursor = 1;
125
+
126
+ while (cursor < lines.length) {
127
+ /**
128
+ * 当前行
129
+ */
130
+ const line = lines[cursor] ?? '';
131
+
132
+ if (line.trim() === '') {
133
+ cursor += 1;
134
+
135
+ continue;
136
+ }
137
+
138
+ /**
139
+ * 记录日期行命中
140
+ */
141
+ const dateMatch = DATE_LINE_PATTERN.exec(line);
142
+
143
+ if (dateMatch !== null) {
144
+ meta.set(DATE_LINE_KEY, captureGroup(dateMatch, 1));
145
+ cursor += 1;
146
+
147
+ continue;
148
+ }
149
+
150
+ /**
151
+ * 来源行命中
152
+ */
153
+ const sourceMatch = SOURCE_LINE_PATTERN.exec(line);
154
+
155
+ if (sourceMatch !== null) {
156
+ meta.set(SOURCE_LINE_KEY, captureGroup(sourceMatch, 1));
157
+ cursor += 1;
158
+
159
+ continue;
160
+ }
161
+
162
+ /**
163
+ * 关联行命中
164
+ */
165
+ const refMatch = REF_LINE_PATTERN.exec(line);
166
+
167
+ if (refMatch !== null) {
168
+ meta.set(REF_LINE_KEY, captureGroup(refMatch, 1));
169
+ cursor += 1;
170
+
171
+ continue;
172
+ }
173
+
174
+ break;
175
+ }
176
+
177
+ /**
178
+ * 记录日期缺席判定
179
+ */
180
+ if (!meta.has(DATE_LINE_KEY)) {
181
+ issues.push({ line: cursor + 1, message: `头部缺「> ${DATE_LINE_KEY}:YYYY-MM-DD」元数据行` });
182
+ }
183
+
184
+ /**
185
+ * 来源缺席判定
186
+ */
187
+ if (!meta.has(SOURCE_LINE_KEY)) {
188
+ issues.push({ line: cursor + 1, message: `头部缺「> ${SOURCE_LINE_KEY}:<出处>」元数据行` });
189
+ }
190
+
191
+ return {
192
+ record: {
193
+ number: captureGroup(h1Match, 1),
194
+ title: captureGroup(h1Match, 2),
195
+ date: meta.get(DATE_LINE_KEY) ?? null,
196
+ source: meta.get(SOURCE_LINE_KEY) ?? null,
197
+ ref: meta.get(REF_LINE_KEY) ?? null,
198
+ headerEndLine: cursor,
199
+ },
200
+ issues,
201
+ };
202
+ }