@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.
- package/CHANGELOG.md +12 -0
- package/dist/index.d.ts +410 -0
- package/dist/index.js +5 -0
- package/dist/rolldown-runtime-BqT_7tdF.js +1 -0
- package/lzi-builder.config.ts +11 -0
- package/oxlint.config.ts +6 -0
- package/package.json +34 -0
- package/src/__tests__/index-check/index-check.core.test.ts +99 -0
- package/src/__tests__/lint/lint.core.test.ts +163 -0
- package/src/__tests__/parser/format.utils.test.ts +74 -0
- package/src/__tests__/template/record-template.renderer.test.ts +92 -0
- package/src/fs/scan-registry.utils.ts +74 -0
- package/src/index-check/index-check.core.ts +200 -0
- package/src/index.ts +18 -0
- package/src/lint/lint.core.ts +228 -0
- package/src/parser/format.utils.ts +151 -0
- package/src/parser/record.parser.ts +202 -0
- package/src/template/record-template.renderer.ts +218 -0
- package/tsconfig/.cache/build.tsbuildinfo +1 -0
- package/tsconfig/.cache/node.tsbuildinfo +1 -0
- package/tsconfig/.cache/test.tsbuildinfo +1 -0
- package/tsconfig/app.json +13 -0
- package/tsconfig/build.json +15 -0
- package/tsconfig/node.json +12 -0
- package/tsconfig/test.json +15 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { runRecordsLint } from '@/lint/lint.core';
|
|
7
|
+
import { renderRecordTemplate } from '@/template/record-template.renderer';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* lint 核临时目录夹具测试:合法建档零违例;各规则面拦截;基线豁免命中。
|
|
11
|
+
*/
|
|
12
|
+
describe('records lint core', () => {
|
|
13
|
+
/**
|
|
14
|
+
* 夹具根目录
|
|
15
|
+
*/
|
|
16
|
+
let fixtureRoot: string;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 最小测试配置(引擎零仓库知识——配置内联构造)
|
|
20
|
+
*/
|
|
21
|
+
const baseConfig = {
|
|
22
|
+
scopes: [{ key: 'root', title: '全局', paths: ['.'], registryDir: 'docs/records' }],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
fixtureRoot = mkdtempSync(join(tmpdir(), 'lzi-records-lint-'));
|
|
27
|
+
mkdirSync(join(fixtureRoot, 'docs/records'), { recursive: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterAll(() => {
|
|
31
|
+
rmSync(fixtureRoot, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('create 渲染产物零违例(构造上天然过 lint)', () => {
|
|
35
|
+
writeFileSync(
|
|
36
|
+
join(fixtureRoot, 'docs/records/0001-good-entry.md'),
|
|
37
|
+
renderRecordTemplate({
|
|
38
|
+
number: '0001',
|
|
39
|
+
slug: 'good-entry',
|
|
40
|
+
title: '合法条目',
|
|
41
|
+
date: '2026-09-27',
|
|
42
|
+
source: '测试夹具',
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* lint 结果
|
|
48
|
+
*/
|
|
49
|
+
const result = runRecordsLint({
|
|
50
|
+
root: fixtureRoot,
|
|
51
|
+
config: { ...baseConfig, lint: { baseline: [] } },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(result.violations.filter((v) => v.file === 'docs/records/0001-good-entry.md')).toEqual(
|
|
55
|
+
[],
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('文件名形态违规拦截(R-FILENAME)', () => {
|
|
60
|
+
writeFileSync(
|
|
61
|
+
join(fixtureRoot, 'docs/records/badname.md'),
|
|
62
|
+
'# 0002 — x\n\n> 记录日期:2026-09-27\n> 来源:t\n',
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* lint 结果
|
|
67
|
+
*/
|
|
68
|
+
const result = runRecordsLint({
|
|
69
|
+
root: fixtureRoot,
|
|
70
|
+
config: { ...baseConfig, lint: { baseline: [] } },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(
|
|
74
|
+
result.violations.some(
|
|
75
|
+
(v) => v.rule === 'R-FILENAME' && v.file === 'docs/records/badname.md',
|
|
76
|
+
),
|
|
77
|
+
).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('H1 编号不一致拦截(R-H1-NUMBER)', () => {
|
|
81
|
+
writeFileSync(
|
|
82
|
+
join(fixtureRoot, 'docs/records/0003-slug.md'),
|
|
83
|
+
renderRecordTemplate({
|
|
84
|
+
number: '0009',
|
|
85
|
+
slug: 'slug',
|
|
86
|
+
title: '编号错位',
|
|
87
|
+
date: '2026-09-27',
|
|
88
|
+
source: 't',
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* lint 结果
|
|
94
|
+
*/
|
|
95
|
+
const result = runRecordsLint({
|
|
96
|
+
root: fixtureRoot,
|
|
97
|
+
config: { ...baseConfig, lint: { baseline: [] } },
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(
|
|
101
|
+
result.violations.some(
|
|
102
|
+
(v) => v.rule === 'R-H1-NUMBER' && v.file === 'docs/records/0003-slug.md',
|
|
103
|
+
),
|
|
104
|
+
).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('头部元数据缺失与假日期拦截(R-DATE-LINE / R-DATE-REAL / R-SOURCE-LINE)', () => {
|
|
108
|
+
writeFileSync(join(fixtureRoot, 'docs/records/0004-no-meta.md'), '# 0004 — 无元数据\n\n正文\n');
|
|
109
|
+
|
|
110
|
+
writeFileSync(
|
|
111
|
+
join(fixtureRoot, 'docs/records/0005-fake-date.md'),
|
|
112
|
+
'# 0005 — 假日期\n\n> 记录日期:2025-02-29\n> 来源:t\n',
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* lint 结果
|
|
117
|
+
*/
|
|
118
|
+
const result = runRecordsLint({
|
|
119
|
+
root: fixtureRoot,
|
|
120
|
+
config: { ...baseConfig, lint: { baseline: [] } },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(
|
|
124
|
+
result.violations.some(
|
|
125
|
+
(v) => v.rule === 'R-DATE-LINE' && v.file === 'docs/records/0004-no-meta.md',
|
|
126
|
+
),
|
|
127
|
+
).toBe(true);
|
|
128
|
+
expect(
|
|
129
|
+
result.violations.some(
|
|
130
|
+
(v) => v.rule === 'R-SOURCE-LINE' && v.file === 'docs/records/0004-no-meta.md',
|
|
131
|
+
),
|
|
132
|
+
).toBe(true);
|
|
133
|
+
expect(
|
|
134
|
+
result.violations.some(
|
|
135
|
+
(v) => v.rule === 'R-DATE-REAL' && v.file === 'docs/records/0005-fake-date.md',
|
|
136
|
+
),
|
|
137
|
+
).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('基线豁免命中(file + rule 精确匹配,豁免计数入观测面)', () => {
|
|
141
|
+
/**
|
|
142
|
+
* lint 结果(badname 档案 R-FILENAME 豁免)
|
|
143
|
+
*/
|
|
144
|
+
const result = runRecordsLint({
|
|
145
|
+
root: fixtureRoot,
|
|
146
|
+
config: {
|
|
147
|
+
...baseConfig,
|
|
148
|
+
lint: {
|
|
149
|
+
baseline: [
|
|
150
|
+
{ file: 'docs/records/badname.md', rule: 'R-FILENAME', reason: '存量宽口径命名' },
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(
|
|
157
|
+
result.violations.some(
|
|
158
|
+
(v) => v.rule === 'R-FILENAME' && v.file === 'docs/records/badname.md',
|
|
159
|
+
),
|
|
160
|
+
).toBe(false);
|
|
161
|
+
expect(result.exemptedCount).toBeGreaterThanOrEqual(1);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ASCII_KEBAB_SLUG_PATTERN,
|
|
5
|
+
extractIndexRefs,
|
|
6
|
+
extractRecordKey,
|
|
7
|
+
isRealCalendarDate,
|
|
8
|
+
RECORD_FILENAME_PATTERN,
|
|
9
|
+
} from '@/parser/format.utils';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 格式单一真源口径测试:accept / reject 双面样本。
|
|
13
|
+
*/
|
|
14
|
+
describe('format.utils 口径', () => {
|
|
15
|
+
test('文件名形态:合法样本通过', () => {
|
|
16
|
+
for (const name of ['0001-a.md', '0013-fallback-channel.md', '9999-x9-y.md', '013-a.md']) {
|
|
17
|
+
expect(RECORD_FILENAME_PATTERN.test(name)).toBe(true);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('文件名形态:非法样本拒绝', () => {
|
|
22
|
+
for (const name of [
|
|
23
|
+
'1-a.md',
|
|
24
|
+
'00001-a.md',
|
|
25
|
+
'0001_A.md',
|
|
26
|
+
'0001-a.md.bak',
|
|
27
|
+
'0001-a',
|
|
28
|
+
'A-001-a.md',
|
|
29
|
+
]) {
|
|
30
|
+
expect(RECORD_FILENAME_PATTERN.test(name)).toBe(false);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('slug 门禁:ASCII kebab 通过、中文与下划线拒绝', () => {
|
|
35
|
+
expect(ASCII_KEBAB_SLUG_PATTERN.test('fallback-channel')).toBe(true);
|
|
36
|
+
expect(ASCII_KEBAB_SLUG_PATTERN.test('通道启用')).toBe(false);
|
|
37
|
+
expect(ASCII_KEBAB_SLUG_PATTERN.test('fallback_channel')).toBe(false);
|
|
38
|
+
expect(ASCII_KEBAB_SLUG_PATTERN.test('-leading')).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('extractRecordKey 宽口径提取编号键', () => {
|
|
42
|
+
expect(extractRecordKey('0002-x.md')).toBe('0002');
|
|
43
|
+
expect(extractRecordKey('12-x.md')).toBe('12');
|
|
44
|
+
expect(extractRecordKey('README.md')).toBeNull();
|
|
45
|
+
expect(extractRecordKey('notes.md')).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('isRealCalendarDate 真实日历日判定(进位日拒绝)', () => {
|
|
49
|
+
expect(isRealCalendarDate('2026-09-27')).toBe(true);
|
|
50
|
+
expect(isRealCalendarDate('2024-02-29')).toBe(true);
|
|
51
|
+
expect(isRealCalendarDate('2025-02-29')).toBe(false);
|
|
52
|
+
expect(isRealCalendarDate('2026-13-01')).toBe(false);
|
|
53
|
+
expect(isRealCalendarDate('2026-04-31')).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('extractIndexRefs 提取 README 链接引用集', () => {
|
|
57
|
+
/**
|
|
58
|
+
* 引用集
|
|
59
|
+
*/
|
|
60
|
+
const refs = extractIndexRefs(
|
|
61
|
+
[
|
|
62
|
+
'# records',
|
|
63
|
+
'',
|
|
64
|
+
'- [0001 — 通道启用](./0001-channel-open.md)',
|
|
65
|
+
'- [0002 — 补充](./0002-second.md)',
|
|
66
|
+
'(见 ./0003-third.md 无链接形态不提取)',
|
|
67
|
+
].join('\n'),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(refs.get('0001')).toBe('channel-open');
|
|
71
|
+
expect(refs.get('0002')).toBe('second');
|
|
72
|
+
expect(refs.has('0003')).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { parseRecordDoc } from '@/parser/record.parser';
|
|
4
|
+
import {
|
|
5
|
+
renderRecordTemplate,
|
|
6
|
+
renderRecordTemplateSkeleton,
|
|
7
|
+
} from '@/template/record-template.renderer';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 渲染-解析回环测试:渲染产物(骨架与实例)必须可被解析器读档为合法建档态
|
|
11
|
+
* ——create 产物在构造上天然通过 lint 的守卫。
|
|
12
|
+
*/
|
|
13
|
+
describe('record-template renderer(渲染-解析回环)', () => {
|
|
14
|
+
test('skeleton 为取号前形态:占位编号被严格解析器拒绝(门禁属性非缺陷)', () => {
|
|
15
|
+
/**
|
|
16
|
+
* 骨架全文
|
|
17
|
+
*/
|
|
18
|
+
const skeleton = renderRecordTemplateSkeleton();
|
|
19
|
+
|
|
20
|
+
expect(skeleton.startsWith('# <NNNN> — ')).toBe(true);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 解析结果(占位编号非数字——解析器必须拒绝,防止占位形态混入注册表)
|
|
24
|
+
*/
|
|
25
|
+
const parsed = parseRecordDoc(skeleton);
|
|
26
|
+
|
|
27
|
+
expect(parsed.record).toBeNull();
|
|
28
|
+
expect(parsed.issues).toHaveLength(1);
|
|
29
|
+
expect(parsed.issues[0]?.message).toContain('H1');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('instance 头部实际值回读一致(编号/标题/日期/来源)', () => {
|
|
33
|
+
/**
|
|
34
|
+
* 实例全文
|
|
35
|
+
*/
|
|
36
|
+
const rendered = renderRecordTemplate({
|
|
37
|
+
number: '0013',
|
|
38
|
+
slug: 'fallback-channel-first-entry',
|
|
39
|
+
title: 'records 通道启用',
|
|
40
|
+
date: '2026-09-27',
|
|
41
|
+
source: 'Task 011 自举(2026-09-27)',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 解析结果
|
|
46
|
+
*/
|
|
47
|
+
const parsed = parseRecordDoc(rendered);
|
|
48
|
+
|
|
49
|
+
expect(parsed.issues).toEqual([]);
|
|
50
|
+
expect(parsed.record?.number).toBe('0013');
|
|
51
|
+
expect(parsed.record?.title).toBe('records 通道启用');
|
|
52
|
+
expect(parsed.record?.date).toBe('2026-09-27');
|
|
53
|
+
expect(parsed.record?.source).toBe('Task 011 自举(2026-09-27)');
|
|
54
|
+
expect(parsed.record?.ref).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('ref 提供时渲染关联行并可回读', () => {
|
|
58
|
+
/**
|
|
59
|
+
* 带关联实例全文
|
|
60
|
+
*/
|
|
61
|
+
const rendered = renderRecordTemplate({
|
|
62
|
+
number: '0002',
|
|
63
|
+
slug: 'with-ref',
|
|
64
|
+
title: '带关联条目',
|
|
65
|
+
date: '2026-09-27',
|
|
66
|
+
source: '测试',
|
|
67
|
+
ref: '[decisions/021](../../decisions/021-records-fallback-no-typing.md)',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 解析结果
|
|
72
|
+
*/
|
|
73
|
+
const parsed = parseRecordDoc(rendered);
|
|
74
|
+
|
|
75
|
+
expect(parsed.record?.ref).toBe(
|
|
76
|
+
'[decisions/021](../../decisions/021-records-fallback-no-typing.md)',
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('两模式正文共享同一占位指引(结构漂移在构造上不可能)', () => {
|
|
81
|
+
expect(renderRecordTemplateSkeleton()).toContain('(正文自由格式');
|
|
82
|
+
expect(
|
|
83
|
+
renderRecordTemplate({
|
|
84
|
+
number: '0001',
|
|
85
|
+
slug: 'x',
|
|
86
|
+
title: 't',
|
|
87
|
+
date: '2026-09-27',
|
|
88
|
+
source: 's',
|
|
89
|
+
}),
|
|
90
|
+
).toContain('(正文自由格式');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 注册表目录扫描工具(fs 编排层)
|
|
3
|
+
*
|
|
4
|
+
* 读侧统一入口:lint / index check / list 共用本扫描,文件枚举口径单一。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readdirSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { extractRecordKey } from '@/parser/format.utils';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 扫描到的注册表条目
|
|
14
|
+
*/
|
|
15
|
+
export type ScannedRecord = {
|
|
16
|
+
/**
|
|
17
|
+
* 文件基名(含扩展名)
|
|
18
|
+
*/
|
|
19
|
+
basename: string;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 编号键(数字字符串原样;宽口径非编号形态为 null)
|
|
23
|
+
*/
|
|
24
|
+
key: string | null;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 绝对路径
|
|
28
|
+
*/
|
|
29
|
+
absPath: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 扫描注册表目录(仅 .md 文件,按编号键升序;目录不存在返回空数组)
|
|
34
|
+
*
|
|
35
|
+
* @param dirAbs - 注册表目录绝对路径
|
|
36
|
+
* @returns 扫描条目列表
|
|
37
|
+
*/
|
|
38
|
+
export function scanRecordsDir(dirAbs: string): ScannedRecord[] {
|
|
39
|
+
if (!existsDir(dirAbs)) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* .md 基名列表
|
|
45
|
+
*/
|
|
46
|
+
const basenames = readdirSync(dirAbs).filter(
|
|
47
|
+
(name) => name.endsWith('.md') && name !== 'README.md',
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 扫描条目列表
|
|
52
|
+
*/
|
|
53
|
+
const entries: ScannedRecord[] = basenames.map((basename) => ({
|
|
54
|
+
basename,
|
|
55
|
+
key: extractRecordKey(basename),
|
|
56
|
+
absPath: join(dirAbs, basename),
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
return entries.sort((a, b) => (a.key ?? 'zzzz').localeCompare(b.key ?? 'zzzz'));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 判定目录存在(读侧守卫——注册表目录未建视为空注册表)
|
|
64
|
+
*
|
|
65
|
+
* @param dirAbs - 目录绝对路径
|
|
66
|
+
* @returns 存在为 true
|
|
67
|
+
*/
|
|
68
|
+
function existsDir(dirAbs: string): boolean {
|
|
69
|
+
try {
|
|
70
|
+
return readdirSync(dirAbs).length >= 0;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* records README 索引差量核(只查不写)
|
|
3
|
+
*
|
|
4
|
+
* 索引行人工登记、工具只检查不写入(与 decision 项目 index 先例同款裁定)。
|
|
5
|
+
* 判定三态:缺行(文件在、索引无)/ 多行(索引有、文件无)/ slug 漂移(索引
|
|
6
|
+
* 链接 slug 与实际文件 slug 不一致);报文附期望行供人工粘贴。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { scanRecordsDir } from '@/fs/scan-registry.utils';
|
|
13
|
+
import { captureGroup, extractIndexRefs, RECORD_FILENAME_PATTERN } from '@/parser/format.utils';
|
|
14
|
+
import { parseRecordDoc } from '@/parser/record.parser';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 索引差量发现条目
|
|
18
|
+
*/
|
|
19
|
+
export type IndexCheckFinding = {
|
|
20
|
+
/**
|
|
21
|
+
* 发现类别:readme-missing(README 未建)/ missing(缺行)/ stale(多行)/ slug-drift(slug 漂移)
|
|
22
|
+
*/
|
|
23
|
+
kind: 'readme-missing' | 'missing' | 'stale' | 'slug-drift';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 相关文件(posix 相对注册表目录)
|
|
27
|
+
*/
|
|
28
|
+
file: string;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 发现消息
|
|
32
|
+
*/
|
|
33
|
+
message: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 索引差量结果
|
|
38
|
+
*/
|
|
39
|
+
export type IndexCheckResult = {
|
|
40
|
+
/**
|
|
41
|
+
* 新鲜判定(无任何发现)
|
|
42
|
+
*/
|
|
43
|
+
ok: boolean;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 发现列表
|
|
47
|
+
*/
|
|
48
|
+
findings: IndexCheckFinding[];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 期望索引行列表(供人工粘贴)
|
|
52
|
+
*/
|
|
53
|
+
expectedLines: string[];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 注册表文件数
|
|
57
|
+
*/
|
|
58
|
+
fileCount: number;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 索引引用数
|
|
62
|
+
*/
|
|
63
|
+
indexedCount: number;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 索引差量入参
|
|
68
|
+
*/
|
|
69
|
+
export type IndexCheckInput = {
|
|
70
|
+
/**
|
|
71
|
+
* 注册表目录绝对路径
|
|
72
|
+
*/
|
|
73
|
+
registryDirAbs: string;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 运行索引差量检查(只读——本函数不写任何文件)
|
|
78
|
+
*
|
|
79
|
+
* @param input - 检查入参
|
|
80
|
+
* @returns 差量结果
|
|
81
|
+
*/
|
|
82
|
+
export function runIndexCheck(input: IndexCheckInput): IndexCheckResult {
|
|
83
|
+
/**
|
|
84
|
+
* 发现列表
|
|
85
|
+
*/
|
|
86
|
+
const findings: IndexCheckFinding[] = [];
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 扫描条目(编号形态合法者参与索引比对)
|
|
90
|
+
*/
|
|
91
|
+
const entries = scanRecordsDir(input.registryDirAbs).filter((entry) =>
|
|
92
|
+
RECORD_FILENAME_PATTERN.test(entry.basename),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 编号 → 条目映射(filter 已保证形态合法——exec 必中)
|
|
97
|
+
*/
|
|
98
|
+
const byKey = new Map(
|
|
99
|
+
entries.map((entry) => {
|
|
100
|
+
/**
|
|
101
|
+
* 文件名命中
|
|
102
|
+
*/
|
|
103
|
+
const match = RECORD_FILENAME_PATTERN.exec(entry.basename);
|
|
104
|
+
|
|
105
|
+
return [match === null ? (entry.key ?? entry.basename) : captureGroup(match, 1), entry];
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* README 绝对路径
|
|
111
|
+
*/
|
|
112
|
+
const readmeAbs = join(input.registryDirAbs, 'README.md');
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 索引引用集(编号 → slug)
|
|
116
|
+
*/
|
|
117
|
+
const refs = existsSync(readmeAbs)
|
|
118
|
+
? extractIndexRefs(readFileSync(readmeAbs, 'utf8'))
|
|
119
|
+
: new Map<string, string>();
|
|
120
|
+
|
|
121
|
+
if (!existsSync(readmeAbs)) {
|
|
122
|
+
findings.push({
|
|
123
|
+
kind: 'readme-missing',
|
|
124
|
+
file: 'README.md',
|
|
125
|
+
message: '注册表 README 未建(init 可落存根;索引行人工登记)',
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 缺行检测(文件在、索引无)+ 期望行构造
|
|
131
|
+
*/
|
|
132
|
+
const expectedLines: string[] = [];
|
|
133
|
+
|
|
134
|
+
for (const entry of entries) {
|
|
135
|
+
/**
|
|
136
|
+
* 文件名命中(filter 已保证形态合法)
|
|
137
|
+
*/
|
|
138
|
+
const match = RECORD_FILENAME_PATTERN.exec(entry.basename);
|
|
139
|
+
|
|
140
|
+
if (match === null) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 文件编号
|
|
146
|
+
*/
|
|
147
|
+
const key = captureGroup(match, 1);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 文件 slug
|
|
151
|
+
*/
|
|
152
|
+
const slug = captureGroup(match, 2);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 解析标题(期望行用;解析失败回退文件名)
|
|
156
|
+
*/
|
|
157
|
+
const parsed = parseRecordDoc(readFileSync(entry.absPath, 'utf8'));
|
|
158
|
+
|
|
159
|
+
expectedLines.push(`- [${key} — ${parsed.record?.title ?? slug}](./${entry.basename})`);
|
|
160
|
+
|
|
161
|
+
if (!refs.has(key)) {
|
|
162
|
+
findings.push({
|
|
163
|
+
kind: 'missing',
|
|
164
|
+
file: entry.basename,
|
|
165
|
+
message: `索引缺行:${entry.basename}(期望行见报文,人工登记)`,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (refs.get(key) !== slug) {
|
|
172
|
+
findings.push({
|
|
173
|
+
kind: 'slug-drift',
|
|
174
|
+
file: entry.basename,
|
|
175
|
+
message: `索引 slug 漂移:索引指向 ${key}-${refs.get(key)}.md,实际为 ${entry.basename}`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 多行检测(索引有、文件无)
|
|
182
|
+
*/
|
|
183
|
+
for (const [key, slug] of refs) {
|
|
184
|
+
if (!byKey.has(key)) {
|
|
185
|
+
findings.push({
|
|
186
|
+
kind: 'stale',
|
|
187
|
+
file: `${key}-${slug}.md`,
|
|
188
|
+
message: `索引多行:引用 ${key}-${slug}.md 但文件不存在(删除索引行或补文件)`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
ok: findings.length === 0,
|
|
195
|
+
findings,
|
|
196
|
+
expectedLines,
|
|
197
|
+
fileCount: entries.length,
|
|
198
|
+
indexedCount: refs.size,
|
|
199
|
+
};
|
|
200
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 引擎核入口
|
|
3
|
+
*
|
|
4
|
+
* 汇出:格式单一真源、解析器、模板渲染器、注册表扫描、lint 核、索引差量核。
|
|
5
|
+
* 引擎零仓库知识——一切仓库特有信息走 config(LziRecordsConfig 类型面)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export * from '@/fs/scan-registry.utils';
|
|
9
|
+
|
|
10
|
+
export * from '@/index-check/index-check.core';
|
|
11
|
+
|
|
12
|
+
export * from '@/lint/lint.core';
|
|
13
|
+
|
|
14
|
+
export * from '@/parser/format.utils';
|
|
15
|
+
|
|
16
|
+
export * from '@/parser/record.parser';
|
|
17
|
+
|
|
18
|
+
export * from '@/template/record-template.renderer';
|