@longzai-intelligence-issues/config 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,180 @@
1
+ /**
2
+ * lzi-issues 配置加载器
3
+ *
4
+ * 复用 shared-config 的 loadUnifiedConfig 统一加载:优先工具专属
5
+ * lzi-issues.config.ts(cwd 向上查找),回退统一配置 lzi.config.ts 的 issues
6
+ * 字段;命中后 zod 形态解析 + 跨条目语义校验,非法即抛(加载即失败)。
7
+ */
8
+
9
+ import { loadUnifiedConfig } from '@longzai-intelligence-shared-config/core';
10
+ import { existsSync } from 'node:fs';
11
+ import { resolve } from 'node:path';
12
+ import { pathToFileURL } from 'node:url';
13
+
14
+ import { type LziIssuesConfig, parseLziIssuesConfig } from '@/schema/lzi-issues-config.schema';
15
+ import { validateLziIssuesConfig } from '@/validation/validate-cross-entries';
16
+
17
+ /**
18
+ * 配置来源类型
19
+ */
20
+ export type LziIssuesConfigSource = 'standalone' | 'unified' | 'explicit';
21
+
22
+ /**
23
+ * 加载成功结果
24
+ */
25
+ export type LoadLziIssuesConfigSuccess = {
26
+ /**
27
+ * 加载成功标记
28
+ */
29
+ ok: true;
30
+
31
+ /**
32
+ * 解析并校验后的配置(默认值全部落定)
33
+ */
34
+ config: LziIssuesConfig;
35
+
36
+ /**
37
+ * 命中的配置文件绝对路径
38
+ */
39
+ configFilePath: string;
40
+
41
+ /**
42
+ * 配置来源
43
+ */
44
+ source: LziIssuesConfigSource;
45
+ };
46
+
47
+ /**
48
+ * 加载失败结果(未找到配置)
49
+ */
50
+ export type LoadLziIssuesConfigFailure = {
51
+ /**
52
+ * 加载成功标记
53
+ */
54
+ ok: false;
55
+
56
+ /**
57
+ * 失败原因
58
+ */
59
+ reason: string;
60
+ };
61
+
62
+ /**
63
+ * 加载结果
64
+ */
65
+ export type LoadLziIssuesConfigResult = LoadLziIssuesConfigSuccess | LoadLziIssuesConfigFailure;
66
+
67
+ /**
68
+ * 加载选项
69
+ */
70
+ export type LoadLziIssuesConfigOptions = {
71
+ /**
72
+ * 查找起点目录(缺省 process.cwd())
73
+ */
74
+ cwd?: string;
75
+
76
+ /**
77
+ * 显式配置文件路径(优先级最高,绕过向上查找)
78
+ */
79
+ configPath?: string;
80
+ };
81
+
82
+ /**
83
+ * 解析 + 校验配置对象(形态 + 跨条目,非法即抛带路径上下文)
84
+ *
85
+ * @param input - 配置原始对象
86
+ * @param configFilePath - 配置文件路径(错误消息附注)
87
+ * @returns 解析并校验后的配置
88
+ * @throws {@link Error} 形态或跨条目违规
89
+ */
90
+ function parseAndValidate(input: unknown, configFilePath: string): LziIssuesConfig {
91
+ try {
92
+ /**
93
+ * 形态解析 + 跨条目校验后的配置
94
+ */
95
+ return validateLziIssuesConfig(parseLziIssuesConfig(input));
96
+ } catch (error) {
97
+ /**
98
+ * 原始错误消息
99
+ */
100
+ const message = error instanceof Error ? error.message : String(error);
101
+
102
+ throw new Error(`${message}(配置文件:${configFilePath})`);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * 加载 lzi-issues 配置
108
+ *
109
+ * @param options - 加载选项
110
+ * @returns 加载结果:命中返回解析后配置;未找到返回失败(显式路径不存在也按失败处理)
111
+ * @throws {@link Error} 命中配置但形态/跨条目非法(加载即失败语义)
112
+ */
113
+ export async function loadLziIssuesConfig(
114
+ options: LoadLziIssuesConfigOptions = {},
115
+ ): Promise<LoadLziIssuesConfigResult> {
116
+ if (options.configPath !== undefined) {
117
+ /**
118
+ * 显式路径解析结果(相对 cwd 锚定)
119
+ */
120
+ const resolved = resolve(options.cwd ?? process.cwd(), options.configPath);
121
+
122
+ if (!existsSync(resolved)) {
123
+ return {
124
+ ok: false,
125
+ reason: `未找到配置文件:${resolved}`,
126
+ };
127
+ }
128
+
129
+ return {
130
+ ok: true,
131
+ config: parseAndValidate(await importConfigModule(resolved), resolved),
132
+ configFilePath: resolved,
133
+ source: 'explicit',
134
+ };
135
+ }
136
+
137
+ /**
138
+ * 统一加载结果(standalone 优先 > unified issues 字段)
139
+ */
140
+ const unified = await loadUnifiedConfig({
141
+ standaloneFile: 'lzi-issues.config.ts',
142
+ unifiedField: 'issues',
143
+ cwd: options.cwd,
144
+ });
145
+
146
+ if (!unified.success) {
147
+ return {
148
+ ok: false,
149
+ reason:
150
+ '未找到 lzi-issues.config.ts(且 lzi.config.ts 无 issues 字段)——可先执行 lzi-issues init',
151
+ };
152
+ }
153
+
154
+ return {
155
+ ok: true,
156
+ config: parseAndValidate(unified.config, unified.configFilePath),
157
+ configFilePath: unified.configFilePath,
158
+ source: unified.source,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * 按显式文件 URL 动态导入配置模块(测试与工具内部使用)
164
+ *
165
+ * @param configPath - 配置文件绝对路径
166
+ * @returns 配置模块 default 导出
167
+ * @throws {@link Error} 导入失败或无 default 导出
168
+ */
169
+ export async function importConfigModule(configPath: string): Promise<unknown> {
170
+ /**
171
+ * 动态导入的配置模块
172
+ */
173
+ const module = await import(pathToFileURL(configPath).href);
174
+
175
+ if (module.default === undefined) {
176
+ throw new Error(`配置文件缺少 default 导出:${configPath}`);
177
+ }
178
+
179
+ return module.default;
180
+ }
@@ -0,0 +1,420 @@
1
+ /**
2
+ * lzi-issues 配置脚手架渲染(init 命令载体)
3
+ *
4
+ * 结构化渲染而非手拼字符串:create 模式生成完整 config 文件(defineConfig
5
+ * 范式 + 契约注释头);append 模式在既有 scopes 数组内做括号配平插入,不动
6
+ * 其他节。序列化器与基线再生渲染共用(同构 TS 字面量输出)。
7
+ */
8
+
9
+ import { DEFAULT_REGISTRY_DIR } from '@/schema/lzi-issues-config.schema';
10
+
11
+ /**
12
+ * scope 脚手架入参(init 命令旗标映射)
13
+ */
14
+ export type ScopeScaffoldInput = {
15
+ /**
16
+ * scope key(kebab-case)
17
+ */
18
+ key: string;
19
+
20
+ /**
21
+ * scope 人类可读名
22
+ */
23
+ title: string;
24
+
25
+ /**
26
+ * 覆盖路径 glob(缺省 ['.']——本目录树全部)
27
+ */
28
+ paths?: string[];
29
+
30
+ /**
31
+ * 注册表目录(缺省 docs/issues)
32
+ */
33
+ registryDir?: string;
34
+ };
35
+
36
+ /**
37
+ * 配置文件契约注释头(create 模式)
38
+ */
39
+ export const CONFIG_SCAFFOLD_HEADER = [
40
+ '/**',
41
+ ' * lzi-issues 配置(单一真源)——由 `lzi-issues init` 生成。',
42
+ ' *',
43
+ ' * 引擎零仓库知识:一切仓库特有信息(scope 注册 / 扫描面 / 门禁参数)只在本文件登记。',
44
+ ' * 存储默认 files(Local First):issue 为注册表 markdown,派生数据统一 .lzi/issues/。',
45
+ ' */',
46
+ ].join('\n');
47
+
48
+ /**
49
+ * 字符串字面量序列化(单引号,转义反斜杠 / 单引号 / 换行)
50
+ *
51
+ * @param value - 待序列化字符串
52
+ * @returns TS 字面量文本
53
+ */
54
+ function serializeString(value: string): string {
55
+ /**
56
+ * 转义后内容
57
+ */
58
+ const escaped = value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n');
59
+
60
+ return `'${escaped}'`;
61
+ }
62
+
63
+ /**
64
+ * 数组序列化(对象元素多行、原始元素单行;空数组渲染 [])
65
+ *
66
+ * @param values - 待序列化数组
67
+ * @param indentLevel - 当前缩进层级(2 空格每级)
68
+ * @returns TS 字面量文本
69
+ */
70
+ function serializeArray(values: readonly unknown[], indentLevel: number): string {
71
+ if (values.length === 0) {
72
+ return '[]';
73
+ }
74
+
75
+ /**
76
+ * 单元缩进(元素行)
77
+ */
78
+ const itemIndent = ' '.repeat(indentLevel + 1);
79
+
80
+ /**
81
+ * 收口缩进(闭括号行)
82
+ */
83
+ const closeIndent = ' '.repeat(indentLevel);
84
+
85
+ /**
86
+ * 是否对象元素数组(多行形态)
87
+ */
88
+ const objectItems = values.every((value) => typeof value === 'object' && value !== null);
89
+
90
+ if (objectItems) {
91
+ /**
92
+ * 逐元素序列化结果
93
+ */
94
+ const items = values.map((value) => `${itemIndent}${serializeValue(value, indentLevel + 1)},`);
95
+
96
+ return `[\n${items.join('\n')}\n${closeIndent}]`;
97
+ }
98
+
99
+ /**
100
+ * 原始元素单行形态
101
+ */
102
+ const items = values.map((value) => serializeValue(value, indentLevel + 1));
103
+
104
+ return `[${items.join(', ')}]`;
105
+ }
106
+
107
+ /**
108
+ * 对象序列化(多行;undefined 值字段跳过;空对象渲染 {})
109
+ *
110
+ * @param record - 待序列化对象
111
+ * @param indentLevel - 当前缩进层级(2 空格每级)
112
+ * @returns TS 字面量文本
113
+ */
114
+ function serializeObject(record: Record<string, unknown>, indentLevel: number): string {
115
+ /**
116
+ * 有效字段名列表(值为 undefined 的字段跳过)
117
+ */
118
+ const keys = Object.keys(record).filter((key) => record[key] !== undefined);
119
+
120
+ if (keys.length === 0) {
121
+ return '{}';
122
+ }
123
+
124
+ /**
125
+ * 字段缩进
126
+ */
127
+ const fieldIndent = ' '.repeat(indentLevel + 1);
128
+
129
+ /**
130
+ * 收口缩进
131
+ */
132
+ const closeIndent = ' '.repeat(indentLevel);
133
+
134
+ /**
135
+ * 逐字段序列化结果
136
+ */
137
+ const fields = keys.map(
138
+ (key) => `${fieldIndent}${key}: ${serializeValue(record[key], indentLevel + 1)},`,
139
+ );
140
+
141
+ return `{\n${fields.join('\n')}\n${closeIndent}}`;
142
+ }
143
+
144
+ /**
145
+ * 普通对象判定(数组除外)
146
+ *
147
+ * @param value - 待判定值
148
+ * @returns 是否普通对象
149
+ */
150
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
151
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
152
+ }
153
+
154
+ /**
155
+ * 任意配置值序列化(TS 字面量,与 oxfmt 风格对齐)
156
+ *
157
+ * @param value - 待序列化值
158
+ * @param indentLevel - 当前缩进层级
159
+ * @returns TS 字面量文本
160
+ */
161
+ function serializeValue(value: unknown, indentLevel: number): string {
162
+ if (typeof value === 'string') {
163
+ return serializeString(value);
164
+ }
165
+
166
+ if (Array.isArray(value)) {
167
+ return serializeArray(value, indentLevel);
168
+ }
169
+
170
+ if (isPlainRecord(value)) {
171
+ return serializeObject(value, indentLevel);
172
+ }
173
+
174
+ return String(value);
175
+ }
176
+
177
+ /**
178
+ * 序列化完整配置对象为 defineConfig 包裹的配置体文本(不含文件头注释)
179
+ *
180
+ * @param config - 配置对象(解析后或字面量输入形态)
181
+ * @returns 配置体文本(export default defineConfig({...});)
182
+ */
183
+ export function serializeConfigBody(config: Record<string, unknown>): string {
184
+ return `export default defineConfig(${serializeValue(config, 0)});\n`;
185
+ }
186
+
187
+ /**
188
+ * 渲染完整配置文件文本(create 模式)
189
+ *
190
+ * @param config - 配置对象
191
+ * @param header - 文件头注释(缺省 init 契约头)
192
+ * @returns 完整文件文本
193
+ */
194
+ export function renderConfigFile(
195
+ config: Record<string, unknown>,
196
+ header: string = CONFIG_SCAFFOLD_HEADER,
197
+ ): string {
198
+ return `import { defineConfig } from '@longzai-intelligence-issues/config';\n\n${header}\n${serializeConfigBody(config)}`;
199
+ }
200
+
201
+ /**
202
+ * 渲染 scope 条目字面量(4 空格基准缩进,append 模式插入单元)
203
+ *
204
+ * @param scope - scope 脚手架入参
205
+ * @returns scope 条目字面量文本
206
+ */
207
+ export function renderScopeEntry(scope: ScopeScaffoldInput): string {
208
+ /**
209
+ * 序列化用条目对象(缺省值落定)
210
+ */
211
+ const entry = {
212
+ key: scope.key,
213
+ title: scope.title,
214
+ paths: scope.paths ?? ['.'],
215
+ registryDir: scope.registryDir ?? DEFAULT_REGISTRY_DIR,
216
+ };
217
+
218
+ return serializeValue(entry, 2).trimStart();
219
+ }
220
+
221
+ /**
222
+ * 渲染 init 生成的完整配置文件(单 scope 起步)
223
+ *
224
+ * @param scope - 首个 scope 入参
225
+ * @returns 完整配置文件文本
226
+ */
227
+ export function renderConfigScaffoldFile(scope: ScopeScaffoldInput): string {
228
+ /**
229
+ * 脚手架配置对象(其余节省略——解析层统一落默认值)
230
+ */
231
+ const config = {
232
+ schemaVersion: 1,
233
+ scopes: [
234
+ {
235
+ key: scope.key,
236
+ title: scope.title,
237
+ paths: scope.paths ?? ['.'],
238
+ registryDir: scope.registryDir ?? DEFAULT_REGISTRY_DIR,
239
+ },
240
+ ],
241
+ };
242
+
243
+ return renderConfigFile(config);
244
+ }
245
+
246
+ /**
247
+ * 括号配平扫描:定位与 openIndex 处开括号匹配的闭括号下标
248
+ *
249
+ * 跳过字符串(' " `,含转义)与注释(// 行注释、块注释)。
250
+ *
251
+ * @param text - 待扫描文本
252
+ * @param openIndex - 开括号下标([ { ( 之一)
253
+ * @returns 匹配闭括号下标
254
+ * @throws {@link Error} 括号不配平或 openIndex 非开括号
255
+ */
256
+ function scanToMatchingBracket(text: string, openIndex: number): number {
257
+ /**
258
+ * 开括号字符
259
+ */
260
+ const openChar = text[openIndex] ?? '';
261
+
262
+ /**
263
+ * 括号配对表
264
+ */
265
+ const pairs: Record<string, string> = { '[': ']', '{': '}', '(': ')' };
266
+
267
+ /**
268
+ * 期望的闭括号字符
269
+ */
270
+ const closeChar = pairs[openChar];
271
+
272
+ if (closeChar === undefined) {
273
+ throw new Error(`扫描起点不是开括号:位置 ${openIndex}`);
274
+ }
275
+
276
+ /**
277
+ * 深度计数器
278
+ */
279
+ let depth = 0;
280
+
281
+ for (let index = openIndex; index < text.length; index += 1) {
282
+ /**
283
+ * 当前字符
284
+ */
285
+ const char = text[index] ?? '';
286
+
287
+ /**
288
+ * 下一字符
289
+ */
290
+ const next = text[index + 1] ?? '';
291
+
292
+ if (char === '/' && next === '/') {
293
+ /**
294
+ * 行注释:跳至行尾
295
+ */
296
+ index = text.indexOf('\n', index);
297
+
298
+ if (index === -1) {
299
+ break;
300
+ }
301
+
302
+ continue;
303
+ }
304
+
305
+ if (char === '/' && next === '*') {
306
+ /**
307
+ * 块注释:跳至注释闭包
308
+ */
309
+ index = text.indexOf('*/', index + 2);
310
+
311
+ if (index === -1) {
312
+ throw new Error('括号扫描遇未闭合块注释');
313
+ }
314
+
315
+ index += 1;
316
+
317
+ continue;
318
+ }
319
+
320
+ if (char === "'" || char === '"' || char === '`') {
321
+ /**
322
+ * 字符串:跳至闭引号(含转义)
323
+ */
324
+ index += 1;
325
+
326
+ while (index < text.length) {
327
+ /**
328
+ * 字符串内当前字符
329
+ */
330
+ const strChar = text[index] ?? '';
331
+
332
+ if (strChar === '\\') {
333
+ index += 2;
334
+
335
+ continue;
336
+ }
337
+
338
+ if (strChar === char) {
339
+ break;
340
+ }
341
+
342
+ index += 1;
343
+ }
344
+
345
+ continue;
346
+ }
347
+
348
+ if (char === openChar) {
349
+ depth += 1;
350
+ } else if (char === closeChar) {
351
+ depth -= 1;
352
+
353
+ if (depth === 0) {
354
+ return index;
355
+ }
356
+ }
357
+ }
358
+
359
+ throw new Error('括号不配平:scopes 数组未闭合');
360
+ }
361
+
362
+ /**
363
+ * 在既有配置文本的 scopes 数组内追加 scope(append 模式,不动其他节)
364
+ *
365
+ * 仅依赖括号配平定位数组收口,在收口前插入条目;scope key 已存在时显式抛错。
366
+ * 插入缩进按本包渲染器规范(4 空格)——非规范缩进的文件插入后由 oxfmt 归一。
367
+ *
368
+ * @param configText - 既有配置文件全文
369
+ * @param scope - 待追加 scope 入参
370
+ * @returns 追加后的配置文件全文
371
+ * @throws {@link Error} key 已注册 / 无 scopes 数组 / 括号不配平
372
+ */
373
+ export function appendScopeToConfigText(configText: string, scope: ScopeScaffoldInput): string {
374
+ /**
375
+ * key 重复检测模式(key: '<key>' 字面量形态)
376
+ */
377
+ const duplicatePattern = new RegExp(`key\\s*:\\s*'${scope.key}'`);
378
+
379
+ if (duplicatePattern.test(configText)) {
380
+ throw new Error(`scope key「${scope.key}」已注册,拒绝重复追加`);
381
+ }
382
+
383
+ /**
384
+ * scopes 数组开括号定位
385
+ */
386
+ const scopesMatch = /scopes\s*:\s*\[/.exec(configText);
387
+
388
+ if (scopesMatch === null) {
389
+ throw new Error('配置文件缺少 scopes 数组,无法追加 scope');
390
+ }
391
+
392
+ /**
393
+ * scopes 数组开括号下标
394
+ */
395
+ const openIndex = (scopesMatch.index ?? 0) + scopesMatch[0].length - 1;
396
+
397
+ /**
398
+ * 匹配闭括号下标
399
+ */
400
+ const closeIndex = scanToMatchingBracket(configText, openIndex);
401
+
402
+ /**
403
+ * 闭括号前最后一个非空白字符下标
404
+ */
405
+ let lastNonWs = closeIndex - 1;
406
+
407
+ while (lastNonWs >= 0 && /\s/.test(configText[lastNonWs] ?? '')) {
408
+ lastNonWs -= 1;
409
+ }
410
+
411
+ /**
412
+ * 插入文本(前一字符已是逗号则免逗号;否则补逗号)
413
+ */
414
+ const insertion =
415
+ configText[lastNonWs] === ','
416
+ ? `\n ${renderScopeEntry(scope)}`
417
+ : `,\n ${renderScopeEntry(scope)}`;
418
+
419
+ return `${configText.slice(0, closeIndex)}${insertion}${configText.slice(closeIndex)}`;
420
+ }