openxiangda-devkit-core 2.0.0-alpha.64 → 2.0.0-alpha.66

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,958 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, } from "node:fs";
3
+ import { basename, dirname, join, relative, resolve } from "node:path";
4
+ import { SCHEMA_VERSIONS, } from "openxiangda-contracts";
5
+ export const APP_SPEC_SCHEMAS = {
6
+ app: "openxiangda.appspec/app/v1",
7
+ capability: "openxiangda.appspec/capability/v1",
8
+ change: "openxiangda.appspec/change/v1",
9
+ decision: "openxiangda.appspec/decision/v1",
10
+ context: "openxiangda.appspec/context/v1",
11
+ };
12
+ export const APP_SPEC_LIMITS = {
13
+ maximumFiles: 128,
14
+ maximumFileBytes: 256 * 1024,
15
+ maximumTotalBytes: 2 * 1024 * 1024,
16
+ maximumDirectoryEntries: 256,
17
+ maximumHistoryDepth: 1,
18
+ };
19
+ const APP_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
20
+ const CHANGE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
21
+ const CAPABILITY_ID = /^CAP-[A-Z0-9]+(?:-[A-Z0-9]+)*$/;
22
+ const DECISION_ID = /^ADR-[0-9]{4}(?:-[A-Z0-9]+)*$/;
23
+ const REQUIREMENT_ID = /^REQ-[A-Z0-9]+(?:-[A-Z0-9]+)*$/;
24
+ const ACCEPTANCE_ID = /^AC-[A-Z0-9]+(?:-[A-Z0-9]+)*$/;
25
+ export function initializeAppSpec(input) {
26
+ if (!APP_ID.test(input.appCode)) {
27
+ throw stableError("APPSPEC_APP_CODE_INVALID", input.appCode);
28
+ }
29
+ const paths = appSpecPaths(input.root);
30
+ for (const directory of [
31
+ paths.root,
32
+ paths.capabilities,
33
+ paths.activeChanges,
34
+ paths.history,
35
+ paths.decisions,
36
+ ]) {
37
+ mkdirSync(directory, { recursive: true });
38
+ }
39
+ const created = [];
40
+ const existing = [];
41
+ if (existsSync(paths.app)) {
42
+ existing.push(relativePath(input.root, paths.app));
43
+ }
44
+ else {
45
+ exclusiveWrite(paths.app, renderApplicationSpec(input.appCode, input.appName));
46
+ created.push(relativePath(input.root, paths.app));
47
+ }
48
+ return {
49
+ schemaVersion: APP_SPEC_SCHEMAS.context,
50
+ enabled: true,
51
+ advisory: true,
52
+ releaseGate: false,
53
+ created,
54
+ existing,
55
+ };
56
+ }
57
+ export function createAppSpecCapability(input) {
58
+ initializeAppSpec(input);
59
+ if (!CAPABILITY_ID.test(input.id)) {
60
+ throw stableError("APPSPEC_CAPABILITY_ID_INVALID", input.id);
61
+ }
62
+ if (!input.title.trim())
63
+ throw stableError("APPSPEC_TITLE_REQUIRED", input.id);
64
+ const path = join(appSpecPaths(input.root).capabilities, `${fileNameForId(input.id, "CAP-")}.md`);
65
+ exclusiveWrite(path, renderCapabilitySpec({
66
+ id: input.id,
67
+ title: input.title.trim(),
68
+ resources: normalized(input.resources),
69
+ actions: normalized(input.actions),
70
+ }));
71
+ return {
72
+ schemaVersion: APP_SPEC_SCHEMAS.context,
73
+ enabled: true,
74
+ advisory: true,
75
+ releaseGate: false,
76
+ created: relativePath(input.root, path),
77
+ };
78
+ }
79
+ export function createAppSpecChange(input) {
80
+ initializeAppSpec(input);
81
+ if (!CHANGE_ID.test(input.id)) {
82
+ throw stableError("APPSPEC_CHANGE_ID_INVALID", input.id);
83
+ }
84
+ if (!input.title.trim())
85
+ throw stableError("APPSPEC_TITLE_REQUIRED", input.id);
86
+ const risk = input.risk || "L1";
87
+ if (!["L1", "L2", "L3"].includes(risk)) {
88
+ throw stableError("APPSPEC_RISK_INVALID", String(risk));
89
+ }
90
+ const path = join(appSpecPaths(input.root).activeChanges, `${input.id}.md`);
91
+ exclusiveWrite(path, renderChangeSpec({
92
+ id: input.id,
93
+ title: input.title.trim(),
94
+ risk,
95
+ summary: input.summary?.trim() || "",
96
+ capabilities: normalized(input.capabilities),
97
+ requirements: normalized(input.requirements),
98
+ resources: normalized(input.resources),
99
+ actions: normalized(input.actions),
100
+ }));
101
+ return {
102
+ schemaVersion: APP_SPEC_SCHEMAS.context,
103
+ enabled: true,
104
+ advisory: true,
105
+ releaseGate: false,
106
+ created: relativePath(input.root, path),
107
+ risk,
108
+ status: "draft",
109
+ };
110
+ }
111
+ export function closeAppSpecChange(input) {
112
+ if (!CHANGE_ID.test(input.id)) {
113
+ throw stableError("APPSPEC_CHANGE_ID_INVALID", input.id);
114
+ }
115
+ const paths = appSpecPaths(input.root);
116
+ const source = join(paths.activeChanges, `${input.id}.md`);
117
+ if (!existsSync(source)) {
118
+ throw stableError("APPSPEC_ACTIVE_CHANGE_NOT_FOUND", input.id);
119
+ }
120
+ assertRegularFile(source);
121
+ const year = String(new Date().getUTCFullYear());
122
+ const historyDirectory = join(paths.history, year);
123
+ const target = join(historyDirectory, `${input.id}.md`);
124
+ if (existsSync(target)) {
125
+ throw stableError("APPSPEC_HISTORY_CHANGE_EXISTS", relativePath(input.root, target));
126
+ }
127
+ mkdirSync(historyDirectory, { recursive: true });
128
+ const sourceContent = readFileSync(source, "utf8");
129
+ const document = parseAppSpecDocument(input.root, source, "change", sourceContent, []);
130
+ if (!document || document.id !== input.id) {
131
+ throw stableError("APPSPEC_ACTIVE_CHANGE_INVALID", input.id);
132
+ }
133
+ renameSync(source, target);
134
+ const closedAt = new Date().toISOString();
135
+ const updated = appendClosure(setFrontMatterValue(setFrontMatterValue(sourceContent, "status", "archived"), "closedAt", closedAt), input.summary?.trim() || "变更记录已归档;验收与发布结果以实际证据为准。", closedAt);
136
+ atomicReplace(target, updated);
137
+ return {
138
+ schemaVersion: APP_SPEC_SCHEMAS.context,
139
+ enabled: true,
140
+ advisory: true,
141
+ releaseGate: false,
142
+ archived: relativePath(input.root, target),
143
+ status: "archived",
144
+ };
145
+ }
146
+ export function inspectAppSpec(root, contract, selector) {
147
+ const paths = appSpecPaths(root);
148
+ if (!existsSync(paths.root))
149
+ return emptyContext(contract, selector);
150
+ const diagnostics = [];
151
+ if (!isRegularDirectory(paths.root)) {
152
+ diagnostics.push(issue("error", "APPSPEC_ROOT_INVALID", "appspec 必须是普通目录,不能是符号链接", "appspec"));
153
+ return emptyContext(contract, selector, diagnostics, true);
154
+ }
155
+ const collection = collectDocuments(root, diagnostics);
156
+ const application = collection.app[0] || null;
157
+ validateDocuments(collection, contract, diagnostics);
158
+ const selected = selectContext(collection, selector, diagnostics);
159
+ const allDocuments = [
160
+ ...collection.app,
161
+ ...collection.capabilities,
162
+ ...collection.activeChanges,
163
+ ...collection.history,
164
+ ...collection.decisions,
165
+ ];
166
+ const digest = allDocuments.length === 0
167
+ ? null
168
+ : createHash("sha256")
169
+ .update([
170
+ ...allDocuments
171
+ .sort((left, right) => left.path.localeCompare(right.path))
172
+ .map(document => `${document.path}\n${document.content}`),
173
+ contract.configDigest || "",
174
+ contract.contractDigest || "",
175
+ contract.aiCatalogDigest || "",
176
+ ].join("\n---\n"))
177
+ .digest("hex");
178
+ return {
179
+ schemaVersion: APP_SPEC_SCHEMAS.context,
180
+ enabled: true,
181
+ mode: "advisory",
182
+ releaseGate: false,
183
+ root: "appspec",
184
+ selector: selector || null,
185
+ contract,
186
+ application,
187
+ capabilities: selected.capabilities,
188
+ activeChanges: selected.activeChanges,
189
+ decisions: selected.decisions,
190
+ history: collection.history.map(summarize),
191
+ stats: {
192
+ files: allDocuments.length,
193
+ bytes: collection.bytes,
194
+ capabilities: collection.capabilities.length,
195
+ activeChanges: collection.activeChanges.length,
196
+ historyChanges: collection.history.length,
197
+ decisions: collection.decisions.length,
198
+ requirements: unique(collection.capabilities.flatMap(document => document.requirementIds)).length,
199
+ acceptanceScenarios: unique(collection.capabilities.flatMap(document => document.acceptanceIds)).length,
200
+ },
201
+ digest,
202
+ diagnostics,
203
+ };
204
+ }
205
+ export function advisoryAppSpecDiagnostics(diagnostics) {
206
+ return diagnostics.map(diagnostic => ({
207
+ ...diagnostic,
208
+ severity: diagnostic.severity === "error" ? "warning" : diagnostic.severity,
209
+ remediation: diagnostic.remediation ||
210
+ "AppSpec 是辅助层;可运行 openxiangda spec check 获取完整诊断,应用 check/deploy 不受阻断",
211
+ }));
212
+ }
213
+ export function summarizeAppSpecContext(context) {
214
+ return {
215
+ enabled: context.enabled,
216
+ mode: context.mode,
217
+ releaseGate: context.releaseGate,
218
+ digest: context.digest,
219
+ stats: context.stats,
220
+ diagnostics: {
221
+ errors: context.diagnostics.filter(item => item.severity === "error").length,
222
+ warnings: context.diagnostics.filter(item => item.severity === "warning").length,
223
+ info: context.diagnostics.filter(item => item.severity === "info").length,
224
+ },
225
+ nextCommand: context.enabled
226
+ ? "openxiangda spec context --json"
227
+ : "openxiangda spec init",
228
+ };
229
+ }
230
+ function emptyContext(contract, selector, diagnostics = [], enabled = false) {
231
+ return {
232
+ schemaVersion: APP_SPEC_SCHEMAS.context,
233
+ enabled,
234
+ mode: "advisory",
235
+ releaseGate: false,
236
+ root: "appspec",
237
+ selector: selector || null,
238
+ contract,
239
+ application: null,
240
+ capabilities: [],
241
+ activeChanges: [],
242
+ decisions: [],
243
+ history: [],
244
+ stats: {
245
+ files: 0,
246
+ bytes: 0,
247
+ capabilities: 0,
248
+ activeChanges: 0,
249
+ historyChanges: 0,
250
+ decisions: 0,
251
+ requirements: 0,
252
+ acceptanceScenarios: 0,
253
+ },
254
+ digest: null,
255
+ diagnostics,
256
+ };
257
+ }
258
+ function collectDocuments(root, diagnostics) {
259
+ const paths = appSpecPaths(root);
260
+ const result = {
261
+ app: [],
262
+ capabilities: [],
263
+ activeChanges: [],
264
+ history: [],
265
+ decisions: [],
266
+ bytes: 0,
267
+ };
268
+ const candidates = [];
269
+ if (existsSync(paths.app))
270
+ candidates.push({ path: paths.app, kind: "app" });
271
+ candidates.push(...markdownFiles(paths.capabilities, false, diagnostics, root).map(path => ({
272
+ path,
273
+ kind: "capability",
274
+ })), ...markdownFiles(paths.activeChanges, false, diagnostics, root).map(path => ({
275
+ path,
276
+ kind: "change",
277
+ })), ...markdownFiles(paths.history, true, diagnostics, root).map(path => ({
278
+ path,
279
+ kind: "change",
280
+ history: true,
281
+ })), ...markdownFiles(paths.decisions, false, diagnostics, root).map(path => ({
282
+ path,
283
+ kind: "decision",
284
+ })));
285
+ if (candidates.length > APP_SPEC_LIMITS.maximumFiles) {
286
+ diagnostics.push(issue("error", "APPSPEC_FILE_LIMIT_EXCEEDED", `AppSpec 文件数 ${candidates.length} 超过上限 ${APP_SPEC_LIMITS.maximumFiles}`, "appspec"));
287
+ candidates.splice(APP_SPEC_LIMITS.maximumFiles);
288
+ }
289
+ for (const candidate of candidates.sort((left, right) => left.path.localeCompare(right.path))) {
290
+ let stat;
291
+ try {
292
+ stat = lstatSync(candidate.path);
293
+ }
294
+ catch {
295
+ diagnostics.push(issue("error", "APPSPEC_FILE_UNREADABLE", "AppSpec 文件无法读取", relativePath(root, candidate.path)));
296
+ continue;
297
+ }
298
+ if (!stat.isFile() || stat.isSymbolicLink()) {
299
+ diagnostics.push(issue("error", "APPSPEC_FILE_INVALID", "AppSpec 只允许普通 Markdown 文件,不能使用符号链接", relativePath(root, candidate.path)));
300
+ continue;
301
+ }
302
+ if (stat.size > APP_SPEC_LIMITS.maximumFileBytes) {
303
+ diagnostics.push(issue("error", "APPSPEC_FILE_SIZE_EXCEEDED", `AppSpec 单文件超过 ${APP_SPEC_LIMITS.maximumFileBytes} 字节上限`, relativePath(root, candidate.path)));
304
+ continue;
305
+ }
306
+ if (result.bytes + stat.size > APP_SPEC_LIMITS.maximumTotalBytes) {
307
+ diagnostics.push(issue("error", "APPSPEC_TOTAL_SIZE_EXCEEDED", `AppSpec 总大小超过 ${APP_SPEC_LIMITS.maximumTotalBytes} 字节上限`, "appspec"));
308
+ break;
309
+ }
310
+ result.bytes += stat.size;
311
+ const content = readFileSync(candidate.path, "utf8");
312
+ const document = parseAppSpecDocument(root, candidate.path, candidate.kind, content, diagnostics);
313
+ if (!document)
314
+ continue;
315
+ if (candidate.kind === "app")
316
+ result.app.push(document);
317
+ else if (candidate.kind === "capability")
318
+ result.capabilities.push(document);
319
+ else if (candidate.kind === "decision")
320
+ result.decisions.push(document);
321
+ else if (candidate.history)
322
+ result.history.push(document);
323
+ else
324
+ result.activeChanges.push(document);
325
+ }
326
+ return result;
327
+ }
328
+ function validateDocuments(collection, contract, diagnostics) {
329
+ if (collection.app.length !== 1) {
330
+ diagnostics.push(issue("error", "APPSPEC_APPLICATION_REQUIRED", "启用 AppSpec 后必须且只能存在一份 appspec/app.md", "appspec/app.md"));
331
+ }
332
+ const app = collection.app[0];
333
+ if (app && app.id !== contract.appCode) {
334
+ diagnostics.push(issue("error", "APPSPEC_APP_CODE_MISMATCH", `AppSpec app=${app.id} 与工作区 ${contract.appCode} 不一致`, app.path));
335
+ }
336
+ for (const document of collection.activeChanges) {
337
+ if (["archived", "cancelled"].includes(document.status)) {
338
+ diagnostics.push(issue("error", "APPSPEC_ACTIVE_CHANGE_STATUS_INVALID", `活跃目录中的 ChangeSpec 不能使用 ${document.status} 状态`, document.path));
339
+ }
340
+ }
341
+ for (const document of collection.history) {
342
+ if (!["archived", "cancelled"].includes(document.status)) {
343
+ diagnostics.push(issue("error", "APPSPEC_HISTORY_CHANGE_STATUS_INVALID", `历史目录中的 ChangeSpec 必须是 archived 或 cancelled,当前为 ${document.status}`, document.path));
344
+ }
345
+ }
346
+ duplicateDiagnostics([...collection.capabilities, ...collection.activeChanges, ...collection.history, ...collection.decisions], document => document.id, "APPSPEC_DOCUMENT_ID_DUPLICATED", diagnostics);
347
+ duplicateDiagnostics(collection.capabilities.flatMap(document => document.requirementIds.map(id => ({ ...document, id }))), document => document.id, "APPSPEC_REQUIREMENT_ID_DUPLICATED", diagnostics);
348
+ duplicateDiagnostics(collection.capabilities.flatMap(document => document.acceptanceIds.map(id => ({ ...document, id }))), document => document.id, "APPSPEC_ACCEPTANCE_ID_DUPLICATED", diagnostics);
349
+ const capabilityIds = new Set(collection.capabilities.map(document => document.id));
350
+ const requirementIds = new Set(collection.capabilities.flatMap(document => document.requirementIds));
351
+ const resourceCodes = new Set(contract.resourceCodes);
352
+ const actionCodes = new Set(contract.actionCodes);
353
+ const decisionIds = new Set(collection.decisions.map(document => document.id));
354
+ for (const document of collection.capabilities) {
355
+ validateReferences(document, resourceCodes, actionCodes, "error", diagnostics);
356
+ for (const requirement of requirementsWithoutAcceptance(document.content)) {
357
+ diagnostics.push(issue("warning", "APPSPEC_REQUIREMENT_ACCEPTANCE_RECOMMENDED", `需求 ${requirement} 建议至少包含一个 #### AC-* 可证伪场景`, document.path));
358
+ }
359
+ }
360
+ for (const document of collection.activeChanges) {
361
+ const finalState = ["verified", "released"].includes(document.status);
362
+ const referenceSeverity = finalState ? "error" : "warning";
363
+ for (const id of document.references.capabilities) {
364
+ if (!capabilityIds.has(id)) {
365
+ diagnostics.push(issue(referenceSeverity, "APPSPEC_CHANGE_CAPABILITY_UNKNOWN", `ChangeSpec 引用的能力 ${id} 不存在`, document.path));
366
+ }
367
+ }
368
+ for (const id of document.references.requirements) {
369
+ if (!requirementIds.has(id)) {
370
+ diagnostics.push(issue(referenceSeverity, "APPSPEC_CHANGE_REQUIREMENT_UNKNOWN", `ChangeSpec 引用的需求 ${id} 尚未进入当前 CapabilitySpec`, document.path));
371
+ }
372
+ }
373
+ for (const id of document.references.decisions) {
374
+ if (!decisionIds.has(id)) {
375
+ diagnostics.push(issue(referenceSeverity, "APPSPEC_CHANGE_DECISION_UNKNOWN", `ChangeSpec 引用的决策 ${id} 不存在`, document.path));
376
+ }
377
+ }
378
+ validateReferences(document, resourceCodes, actionCodes, referenceSeverity, diagnostics);
379
+ validateRiskSections(document, diagnostics);
380
+ if (finalState && unresolvedQuestions(document.content).length > 0) {
381
+ diagnostics.push(issue("error", "APPSPEC_VERIFIED_CHANGE_HAS_OPEN_QUESTIONS", "verified/released ChangeSpec 仍有未勾选问题", document.path));
382
+ }
383
+ }
384
+ if (collection.activeChanges.length > 10) {
385
+ diagnostics.push(issue("warning", "APPSPEC_ACTIVE_CHANGE_COUNT_HIGH", `当前有 ${collection.activeChanges.length} 个活跃变更;建议关闭已完成记录,避免 AI 上下文膨胀`, "appspec/changes/active"));
386
+ }
387
+ }
388
+ function validateReferences(document, resourceCodes, actionCodes, severity, diagnostics) {
389
+ for (const code of document.references.resources) {
390
+ if (!resourceCodes.has(code)) {
391
+ diagnostics.push(issue(severity, "APPSPEC_RESOURCE_UNKNOWN", `AppSpec 引用的资源 ${code} 不在当前编译声明中`, document.path));
392
+ }
393
+ }
394
+ for (const code of document.references.actions) {
395
+ if (!actionCodes.has(code)) {
396
+ diagnostics.push(issue(severity, "APPSPEC_ACTION_UNKNOWN", `AppSpec 引用的动作 ${code} 不在当前编译声明中`, document.path));
397
+ }
398
+ }
399
+ }
400
+ function validateRiskSections(document, diagnostics) {
401
+ const risk = String(document.metadata.risk || "L1");
402
+ if (!["L1", "L2", "L3"].includes(risk)) {
403
+ diagnostics.push(issue("error", "APPSPEC_RISK_INVALID", `未知风险档位 ${risk}`, document.path));
404
+ return;
405
+ }
406
+ if (risk === "L1")
407
+ return;
408
+ const required = [
409
+ ["验收", "Acceptance"],
410
+ ["数据与权限", "Data and Permissions"],
411
+ ["回滚", "Rollback"],
412
+ ];
413
+ if (risk === "L3") {
414
+ required.push(["失败、并发与幂等", "Failure, Concurrency, and Idempotency"], ["架构决策", "Decision Records"]);
415
+ }
416
+ for (const alternatives of required) {
417
+ if (!hasSection(document.content, alternatives)) {
418
+ diagnostics.push(issue("warning", "APPSPEC_RISK_SECTION_RECOMMENDED", `${risk} ChangeSpec 建议补充“${alternatives[0]}”`, document.path));
419
+ }
420
+ }
421
+ }
422
+ function selectContext(collection, selector, diagnostics) {
423
+ if (!selector) {
424
+ return {
425
+ capabilities: collection.capabilities,
426
+ activeChanges: collection.activeChanges,
427
+ decisions: collection.decisions,
428
+ };
429
+ }
430
+ const change = collection.activeChanges.find(document => document.id === selector);
431
+ if (change) {
432
+ const capabilityIds = new Set(change.references.capabilities);
433
+ const decisionIds = new Set(change.references.decisions);
434
+ return {
435
+ capabilities: collection.capabilities.filter(document => capabilityIds.has(document.id)),
436
+ activeChanges: [change],
437
+ decisions: collection.decisions.filter(document => decisionIds.has(document.id)),
438
+ };
439
+ }
440
+ const capability = collection.capabilities.find(document => document.id === selector);
441
+ if (capability) {
442
+ return {
443
+ capabilities: [capability],
444
+ activeChanges: collection.activeChanges.filter(document => document.references.capabilities.includes(capability.id)),
445
+ decisions: collection.decisions,
446
+ };
447
+ }
448
+ diagnostics.push(issue("warning", "APPSPEC_SELECTOR_NOT_FOUND", `未找到 AppSpec selector ${selector},已返回应用总纲和空的相关上下文`, "appspec"));
449
+ return { capabilities: [], activeChanges: [], decisions: [] };
450
+ }
451
+ function parseAppSpecDocument(root, path, kind, content, diagnostics) {
452
+ const pointer = relativePath(root, path);
453
+ const frontMatter = parseFrontMatter(content, pointer, diagnostics);
454
+ if (!frontMatter)
455
+ return null;
456
+ validateMetadata(kind, frontMatter.metadata, pointer, diagnostics);
457
+ const expectedSchema = APP_SPEC_SCHEMAS[kind];
458
+ if (frontMatter.metadata.schema !== expectedSchema) {
459
+ diagnostics.push(issue("error", "APPSPEC_SCHEMA_INVALID", `期望 schema=${expectedSchema}`, pointer));
460
+ }
461
+ const id = String(kind === "app" ? frontMatter.metadata.app || "" : frontMatter.metadata.id || "");
462
+ const pattern = kind === "app"
463
+ ? APP_ID
464
+ : kind === "capability"
465
+ ? CAPABILITY_ID
466
+ : kind === "decision"
467
+ ? DECISION_ID
468
+ : CHANGE_ID;
469
+ if (!pattern.test(id)) {
470
+ diagnostics.push(issue("error", "APPSPEC_DOCUMENT_ID_INVALID", `无效 ${kind} id: ${id}`, pointer));
471
+ }
472
+ const status = String(frontMatter.metadata.status || "draft");
473
+ const validStatuses = {
474
+ app: ["active", "retired"],
475
+ capability: ["draft", "active", "retired"],
476
+ change: ["draft", "confirmed", "implementing", "verified", "released", "archived", "cancelled"],
477
+ decision: ["proposed", "accepted", "superseded", "rejected"],
478
+ };
479
+ if (!validStatuses[kind].includes(status)) {
480
+ diagnostics.push(issue("error", "APPSPEC_STATUS_INVALID", `${kind} status=${status} 不受支持`, pointer));
481
+ }
482
+ const h1 = content.match(/^#\s+(.+)$/m)?.[1]?.trim() || "";
483
+ const title = String(frontMatter.metadata.title || h1 || id);
484
+ const requirementIds = extractIds(content, /^###\s+(REQ-[A-Z0-9-]+)\b/gm, REQUIREMENT_ID);
485
+ const acceptanceIds = extractIds(content, /^####\s+(AC-[A-Z0-9-]+)\b/gm, ACCEPTANCE_ID);
486
+ return {
487
+ kind,
488
+ id,
489
+ title,
490
+ status,
491
+ path: pointer,
492
+ metadata: frontMatter.metadata,
493
+ requirementIds,
494
+ acceptanceIds,
495
+ references: {
496
+ capabilities: metadataStrings(frontMatter.metadata, "capabilities"),
497
+ requirements: metadataStrings(frontMatter.metadata, "requirements"),
498
+ resources: metadataStrings(frontMatter.metadata, "resources"),
499
+ actions: metadataStrings(frontMatter.metadata, "actions"),
500
+ decisions: metadataStrings(frontMatter.metadata, "decisions"),
501
+ },
502
+ content,
503
+ };
504
+ }
505
+ function parseFrontMatter(content, pointer, diagnostics) {
506
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
507
+ if (!match) {
508
+ diagnostics.push(issue("error", "APPSPEC_FRONT_MATTER_REQUIRED", "AppSpec Markdown 必须以受限 YAML front matter 开头", pointer));
509
+ return null;
510
+ }
511
+ const metadata = {};
512
+ const lines = (match[1] || "").split(/\r?\n/);
513
+ for (let index = 0; index < lines.length; index += 1) {
514
+ const raw = lines[index] || "";
515
+ if (!raw.trim() || raw.trimStart().startsWith("#"))
516
+ continue;
517
+ const entry = raw.match(/^([A-Za-z][A-Za-z0-9_-]*):(?:\s*(.*))?$/);
518
+ if (!entry) {
519
+ diagnostics.push(issue("error", "APPSPEC_FRONT_MATTER_INVALID", `不支持的 front matter 行: ${raw.trim()}`, pointer));
520
+ continue;
521
+ }
522
+ const key = entry[1] || "";
523
+ const rawValue = entry[2] || "";
524
+ if (rawValue.trim()) {
525
+ metadata[key] = parseMetadataValue(rawValue.trim());
526
+ continue;
527
+ }
528
+ const values = [];
529
+ while (index + 1 < lines.length) {
530
+ const next = lines[index + 1] || "";
531
+ const item = next.match(/^\s{2,}-\s+(.+)$/);
532
+ if (!item)
533
+ break;
534
+ values.push(String(parseMetadataValue(item[1] || "")));
535
+ index += 1;
536
+ }
537
+ metadata[key] = values;
538
+ }
539
+ return { metadata };
540
+ }
541
+ function parseMetadataValue(value) {
542
+ if (value === "true")
543
+ return true;
544
+ if (value === "false")
545
+ return false;
546
+ if (value === "[]")
547
+ return [];
548
+ if (value.startsWith("[") && value.endsWith("]")) {
549
+ try {
550
+ const parsed = JSON.parse(value);
551
+ if (Array.isArray(parsed))
552
+ return parsed.map(item => String(item));
553
+ }
554
+ catch {
555
+ return value
556
+ .slice(1, -1)
557
+ .split(",")
558
+ .map(item => unquote(item.trim()))
559
+ .filter(Boolean);
560
+ }
561
+ }
562
+ return unquote(value);
563
+ }
564
+ function unquote(value) {
565
+ if ((value.startsWith('"') && value.endsWith('"')) ||
566
+ (value.startsWith("'") && value.endsWith("'"))) {
567
+ if (value.startsWith('"')) {
568
+ try {
569
+ return String(JSON.parse(value));
570
+ }
571
+ catch {
572
+ return value.slice(1, -1);
573
+ }
574
+ }
575
+ return value.slice(1, -1).replaceAll("''", "'");
576
+ }
577
+ return value;
578
+ }
579
+ function metadataStrings(metadata, key) {
580
+ const value = metadata[key];
581
+ if (Array.isArray(value))
582
+ return normalized(value);
583
+ if (typeof value === "string" && value.trim())
584
+ return [value.trim()];
585
+ return [];
586
+ }
587
+ function requirementsWithoutAcceptance(content) {
588
+ const source = withoutHtmlComments(content);
589
+ const matches = [...source.matchAll(/^###\s+(REQ-[A-Z0-9-]+)\b/gm)];
590
+ const missing = [];
591
+ for (let index = 0; index < matches.length; index += 1) {
592
+ const start = matches[index]?.index || 0;
593
+ const end = matches[index + 1]?.index ?? source.length;
594
+ const block = source.slice(start, end);
595
+ if (!/^####\s+AC-[A-Z0-9-]+\b/m.test(block)) {
596
+ const id = matches[index]?.[1];
597
+ if (id)
598
+ missing.push(id);
599
+ }
600
+ }
601
+ return missing;
602
+ }
603
+ function unresolvedQuestions(content) {
604
+ const source = withoutHtmlComments(content);
605
+ const heading = source.match(/^##\s+(?:未确认问题|Unresolved Questions)\s*$/m);
606
+ if (heading?.index === undefined)
607
+ return [];
608
+ const tail = source.slice(heading.index + heading[0].length);
609
+ const nextHeading = tail.search(/^##\s+/m);
610
+ const section = nextHeading >= 0 ? tail.slice(0, nextHeading) : tail;
611
+ return section ? [...section.matchAll(/^- \[ \]\s+(.+)$/gm)].map(match => match[1]) : [];
612
+ }
613
+ function hasSection(content, alternatives) {
614
+ return alternatives.some(title => new RegExp(`^##\\s+${escapeRegExp(title)}\\s*$`, "m").test(content));
615
+ }
616
+ function duplicateDiagnostics(values, id, code, diagnostics) {
617
+ const seen = new Map();
618
+ for (const value of values) {
619
+ const key = id(value);
620
+ if (!key)
621
+ continue;
622
+ const previous = seen.get(key);
623
+ if (previous) {
624
+ diagnostics.push(issue("error", code, `稳定 ID ${key} 重复,首次出现在 ${previous}`, value.path));
625
+ }
626
+ else {
627
+ seen.set(key, value.path);
628
+ }
629
+ }
630
+ }
631
+ function markdownFiles(directory, recursive, diagnostics, root, depth = 0) {
632
+ if (!existsSync(directory))
633
+ return [];
634
+ if (!isRegularDirectory(directory)) {
635
+ diagnostics.push(issue("error", "APPSPEC_DIRECTORY_INVALID", "AppSpec 子目录必须是普通目录,不能是符号链接", relativePath(root, directory)));
636
+ return [];
637
+ }
638
+ const files = [];
639
+ const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
640
+ if (entries.length > APP_SPEC_LIMITS.maximumDirectoryEntries) {
641
+ diagnostics.push(issue("error", "APPSPEC_DIRECTORY_ENTRY_LIMIT_EXCEEDED", `AppSpec 单目录条目数超过 ${APP_SPEC_LIMITS.maximumDirectoryEntries} 上限`, relativePath(root, directory)));
642
+ entries.splice(APP_SPEC_LIMITS.maximumDirectoryEntries);
643
+ }
644
+ for (const entry of entries) {
645
+ const path = join(directory, entry.name);
646
+ if (entry.isSymbolicLink()) {
647
+ diagnostics.push(issue("error", "APPSPEC_SYMLINK_FORBIDDEN", "AppSpec 不读取符号链接", relativePath(root, path)));
648
+ continue;
649
+ }
650
+ if (entry.isDirectory() && recursive) {
651
+ if (depth >= APP_SPEC_LIMITS.maximumHistoryDepth) {
652
+ diagnostics.push(issue("error", "APPSPEC_HISTORY_DEPTH_EXCEEDED", "AppSpec 历史目录只允许 history/<year>/*.md", relativePath(root, path)));
653
+ }
654
+ else {
655
+ files.push(...markdownFiles(path, true, diagnostics, root, depth + 1));
656
+ }
657
+ }
658
+ else if (entry.isFile() && entry.name.endsWith(".md"))
659
+ files.push(path);
660
+ else if (!entry.name.startsWith(".")) {
661
+ diagnostics.push(issue("warning", "APPSPEC_UNKNOWN_ENTRY_IGNORED", "AppSpec 只读取约定目录中的 Markdown 文件", relativePath(root, path)));
662
+ }
663
+ }
664
+ return files;
665
+ }
666
+ function appSpecPaths(root) {
667
+ const workspace = resolve(root);
668
+ const appSpecRoot = resolve(workspace, "appspec");
669
+ assertInside(workspace, appSpecRoot);
670
+ return {
671
+ root: appSpecRoot,
672
+ app: join(appSpecRoot, "app.md"),
673
+ capabilities: join(appSpecRoot, "capabilities"),
674
+ activeChanges: join(appSpecRoot, "changes", "active"),
675
+ history: join(appSpecRoot, "changes", "history"),
676
+ decisions: join(appSpecRoot, "decisions"),
677
+ };
678
+ }
679
+ function renderApplicationSpec(appCode, appName) {
680
+ return `---
681
+ schema: ${APP_SPEC_SCHEMAS.app}
682
+ app: ${JSON.stringify(appCode)}
683
+ title: ${JSON.stringify(appName)}
684
+ status: active
685
+ ---
686
+ # ${appName}
687
+
688
+ AppSpec 是可选的业务意图辅助层,不是发布门禁。资源、字段、权限和动作仍以
689
+ \`openxiangda.config.ts\` 与实时编译合同为准。
690
+
691
+ ## 业务目标
692
+
693
+ <!-- 用业务语言说明应用解决的问题和可观察结果。 -->
694
+
695
+ ## 角色
696
+
697
+ <!-- 只记录业务角色及其目标;稳定 role code 在能力规格中引用。 -->
698
+
699
+ ## 范围
700
+
701
+ ### 包含
702
+
703
+ ### 不包含
704
+
705
+ ## 能力目录
706
+
707
+ <!-- 复杂业务再在 capabilities/ 下增加 CAP-*;小应用可以只维护本文件。 -->
708
+
709
+ ## 术语
710
+
711
+ ## 跨能力约束
712
+
713
+ ## 未确认问题
714
+
715
+ - 无。
716
+ `;
717
+ }
718
+ function renderCapabilitySpec(input) {
719
+ return `---
720
+ schema: ${APP_SPEC_SCHEMAS.capability}
721
+ id: ${input.id}
722
+ title: ${JSON.stringify(input.title)}
723
+ status: draft
724
+ resources: ${JSON.stringify(input.resources)}
725
+ actions: ${JSON.stringify(input.actions)}
726
+ ---
727
+ # ${input.title}
728
+
729
+ ## 目标与边界
730
+
731
+ <!-- 描述用户可观察的业务能力,不复制字段物理 Schema 或页面实现。 -->
732
+
733
+ ## 角色与权限
734
+
735
+ ## 当前有效需求
736
+
737
+ <!--
738
+ ### REQ-DOMAIN-001 规则名称
739
+
740
+ 当发生某个业务条件时,系统必须产生可观察结果。
741
+
742
+ #### AC-DOMAIN-001-01 正向或反向场景
743
+
744
+ - Given 已知前置条件
745
+ - When 用户或系统执行动作
746
+ - Then 观察到明确结果
747
+ -->
748
+
749
+ ## 状态与异常
750
+
751
+ ## 非目标
752
+ `;
753
+ }
754
+ function renderChangeSpec(input) {
755
+ const createdAt = new Date().toISOString();
756
+ return `---
757
+ schema: ${APP_SPEC_SCHEMAS.change}
758
+ id: ${input.id}
759
+ title: ${JSON.stringify(input.title)}
760
+ status: draft
761
+ risk: ${input.risk}
762
+ createdAt: ${JSON.stringify(createdAt)}
763
+ capabilities: ${JSON.stringify(input.capabilities)}
764
+ requirements: ${JSON.stringify(input.requirements)}
765
+ resources: ${JSON.stringify(input.resources)}
766
+ actions: ${JSON.stringify(input.actions)}
767
+ decisions: []
768
+ ---
769
+ # ${input.title}
770
+
771
+ ## 为什么
772
+
773
+ ${input.summary || "<!-- 一两句话说明问题、证据和期望结果。 -->"}
774
+
775
+ ## 变更
776
+
777
+ - ADDED:
778
+ - MODIFIED:
779
+ - REMOVED:
780
+ - 非目标:
781
+
782
+ ## 验收
783
+
784
+ - [ ] 一个可观察的正向结果
785
+ - [ ] 需要时补充拒绝、异常或权限反例
786
+
787
+ ## 数据与权限
788
+
789
+ - 无,或说明资源、字段、角色和数据范围变化。
790
+
791
+ ## 失败、并发与幂等
792
+
793
+ - L3 才需要详细维护;其他变化写“无”。
794
+
795
+ ## 回滚
796
+
797
+ - 回退声明/代码并保持旧数据可读;如不适用请说明。
798
+
799
+ ## 架构决策
800
+
801
+ - L3 如涉及 ADR,在 front matter 的 decisions 中引用。
802
+
803
+ ## 未确认问题
804
+
805
+ - 无。
806
+ `;
807
+ }
808
+ function appendClosure(content, summary, closedAt) {
809
+ return `${content.trimEnd()}\n\n## 关闭记录\n\n- 时间:${closedAt}\n- 摘要:${summary}\n- 说明:归档不等于部署或生产验收;以实际 Verification/Deployment 证据为准。\n`;
810
+ }
811
+ function setFrontMatterValue(content, key, value) {
812
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
813
+ if (!match)
814
+ throw stableError("APPSPEC_FRONT_MATTER_REQUIRED", key);
815
+ const encoded = JSON.stringify(value);
816
+ const body = match[1] || "";
817
+ const pattern = new RegExp(`^${escapeRegExp(key)}:.*$`, "m");
818
+ const nextBody = pattern.test(body)
819
+ ? body.replace(pattern, `${key}: ${encoded}`)
820
+ : `${body.trimEnd()}\n${key}: ${encoded}`;
821
+ return content.replace(match[0], `---\n${nextBody}\n---`);
822
+ }
823
+ function exclusiveWrite(path, content) {
824
+ mkdirSync(dirname(path), { recursive: true });
825
+ try {
826
+ writeFileSync(path, content, { encoding: "utf8", flag: "wx" });
827
+ }
828
+ catch (error) {
829
+ if (error.code === "EEXIST") {
830
+ throw stableError("APPSPEC_DOCUMENT_EXISTS", path);
831
+ }
832
+ throw error;
833
+ }
834
+ }
835
+ function atomicReplace(path, content) {
836
+ const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${Date.now()}.tmp`);
837
+ exclusiveWrite(temporary, content);
838
+ renameSync(temporary, path);
839
+ }
840
+ function assertRegularFile(path) {
841
+ const stat = lstatSync(path);
842
+ if (!stat.isFile() || stat.isSymbolicLink()) {
843
+ throw stableError("APPSPEC_FILE_INVALID", path);
844
+ }
845
+ }
846
+ function isRegularDirectory(path) {
847
+ try {
848
+ const stat = lstatSync(path);
849
+ return stat.isDirectory() && !stat.isSymbolicLink();
850
+ }
851
+ catch {
852
+ return false;
853
+ }
854
+ }
855
+ function assertInside(root, path) {
856
+ const base = `${resolve(root)}/`;
857
+ const target = `${resolve(path)}/`;
858
+ if (!target.startsWith(base))
859
+ throw stableError("APPSPEC_PATH_OUTSIDE_WORKSPACE", path);
860
+ }
861
+ function stableError(code, detail) {
862
+ return Object.assign(new Error(`${code}: ${detail}`), { code, retryable: false });
863
+ }
864
+ function issue(severity, code, message, path) {
865
+ return {
866
+ schemaVersion: SCHEMA_VERSIONS.diagnostic,
867
+ code,
868
+ severity,
869
+ message,
870
+ path,
871
+ retryable: false,
872
+ remediation: severity === "error"
873
+ ? "修正 AppSpec 后重新运行 openxiangda spec check;普通应用 check/deploy 不受阻断"
874
+ : "按当前变更风险决定是否补充;AppSpec 质量建议不阻断应用交付",
875
+ };
876
+ }
877
+ function summarize(document) {
878
+ return {
879
+ kind: document.kind,
880
+ id: document.id,
881
+ title: document.title,
882
+ status: document.status,
883
+ path: document.path,
884
+ requirementIds: document.requirementIds,
885
+ acceptanceIds: document.acceptanceIds,
886
+ };
887
+ }
888
+ function extractIds(content, pattern, validation) {
889
+ return unique([...withoutHtmlComments(content).matchAll(pattern)]
890
+ .map(match => match[1] || "")
891
+ .filter(value => validation.test(value)));
892
+ }
893
+ function withoutHtmlComments(content) {
894
+ return content.replace(/<!--[\s\S]*?-->/g, "");
895
+ }
896
+ function validateMetadata(kind, metadata, pointer, diagnostics) {
897
+ const allowed = {
898
+ app: new Set(["schema", "app", "title", "status"]),
899
+ capability: new Set([
900
+ "schema",
901
+ "id",
902
+ "title",
903
+ "status",
904
+ "resources",
905
+ "actions",
906
+ ]),
907
+ change: new Set([
908
+ "schema",
909
+ "id",
910
+ "title",
911
+ "status",
912
+ "risk",
913
+ "createdAt",
914
+ "closedAt",
915
+ "capabilities",
916
+ "requirements",
917
+ "resources",
918
+ "actions",
919
+ "decisions",
920
+ ]),
921
+ decision: new Set([
922
+ "schema",
923
+ "id",
924
+ "title",
925
+ "status",
926
+ "date",
927
+ "deciders",
928
+ "supersedes",
929
+ ]),
930
+ };
931
+ const identifierKey = kind === "app" ? "app" : "id";
932
+ for (const required of ["schema", identifierKey, "status"]) {
933
+ if (!(required in metadata)) {
934
+ diagnostics.push(issue("error", "APPSPEC_METADATA_REQUIRED", `${kind} front matter 缺少 ${required}`, pointer));
935
+ }
936
+ }
937
+ for (const key of Object.keys(metadata)) {
938
+ if (!allowed[kind].has(key)) {
939
+ diagnostics.push(issue("error", "APPSPEC_METADATA_UNKNOWN", `${kind} front matter 不支持 ${key}`, pointer));
940
+ }
941
+ }
942
+ }
943
+ function normalized(values) {
944
+ return unique((values || []).map(value => String(value).trim()).filter(Boolean));
945
+ }
946
+ function unique(values) {
947
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right));
948
+ }
949
+ function fileNameForId(id, prefix) {
950
+ return id.slice(prefix.length).toLowerCase();
951
+ }
952
+ function relativePath(root, path) {
953
+ return relative(resolve(root), resolve(path)).replaceAll("\\", "/");
954
+ }
955
+ function escapeRegExp(value) {
956
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
957
+ }
958
+ //# sourceMappingURL=app-spec.js.map