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

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/dist/app-spec.js CHANGED
@@ -7,12 +7,13 @@ export const APP_SPEC_SCHEMAS = {
7
7
  capability: "openxiangda.appspec/capability/v1",
8
8
  change: "openxiangda.appspec/change/v1",
9
9
  decision: "openxiangda.appspec/decision/v1",
10
- context: "openxiangda.appspec/context/v1",
10
+ context: "openxiangda.appspec/context/v2",
11
11
  };
12
12
  export const APP_SPEC_LIMITS = {
13
13
  maximumFiles: 128,
14
14
  maximumFileBytes: 256 * 1024,
15
15
  maximumTotalBytes: 2 * 1024 * 1024,
16
+ maximumContextBytes: 256 * 1024,
16
17
  maximumDirectoryEntries: 256,
17
18
  maximumHistoryDepth: 1,
18
19
  };
@@ -126,13 +127,61 @@ export function closeAppSpecChange(input) {
126
127
  }
127
128
  mkdirSync(historyDirectory, { recursive: true });
128
129
  const sourceContent = readFileSync(source, "utf8");
129
- const document = parseAppSpecDocument(input.root, source, "change", sourceContent, []);
130
+ const effectiveContent = input.currentSpec
131
+ ? setFrontMatterValue(sourceContent, "currentSpec", input.currentSpec)
132
+ : sourceContent;
133
+ const diagnostics = [];
134
+ const document = parseAppSpecDocument(input.root, source, "change", effectiveContent, diagnostics);
130
135
  if (!document || document.id !== input.id) {
131
136
  throw stableError("APPSPEC_ACTIVE_CHANGE_INVALID", input.id);
132
137
  }
138
+ const collectionDiagnostics = [];
139
+ const collection = collectDocuments(input.root, collectionDiagnostics);
140
+ const sourcePointer = relativePath(input.root, source);
141
+ const relatedPaths = new Set([sourcePointer]);
142
+ for (const capability of collection.capabilities) {
143
+ if (document.references.capabilities.includes(capability.id) ||
144
+ capability.requirementIds.some(id => [...document.references.requirements, ...document.requirementIds].includes(id)) ||
145
+ capability.acceptanceIds.some(id => document.acceptanceIds.includes(id))) {
146
+ relatedPaths.add(capability.path);
147
+ }
148
+ }
149
+ for (const decision of collection.decisions) {
150
+ if (document.references.decisions.includes(decision.id)) {
151
+ relatedPaths.add(decision.path);
152
+ }
153
+ }
154
+ const collectionFatalCodes = new Set([
155
+ "APPSPEC_FILE_LIMIT_EXCEEDED",
156
+ "APPSPEC_FILE_UNREADABLE",
157
+ "APPSPEC_FILE_INVALID",
158
+ "APPSPEC_FILE_SIZE_EXCEEDED",
159
+ "APPSPEC_TOTAL_SIZE_EXCEEDED",
160
+ "APPSPEC_DIRECTORY_INVALID",
161
+ "APPSPEC_DIRECTORY_ENTRY_LIMIT_EXCEEDED",
162
+ "APPSPEC_SYMLINK_FORBIDDEN",
163
+ "APPSPEC_HISTORY_DEPTH_EXCEEDED",
164
+ ]);
165
+ diagnostics.push(...collectionDiagnostics.filter(item => item.severity === "error" &&
166
+ item.path !== sourcePointer &&
167
+ (collectionFatalCodes.has(item.code) ||
168
+ (item.path !== undefined && relatedPaths.has(item.path)))));
169
+ validateCloseConvergence(document, collection, input.contract, diagnostics);
170
+ if (diagnostics.some(item => item.severity === "error")) {
171
+ return {
172
+ schemaVersion: APP_SPEC_SCHEMAS.context,
173
+ enabled: true,
174
+ advisory: true,
175
+ releaseGate: false,
176
+ archived: null,
177
+ status: document.status,
178
+ converged: false,
179
+ diagnostics,
180
+ };
181
+ }
133
182
  renameSync(source, target);
134
183
  const closedAt = new Date().toISOString();
135
- const updated = appendClosure(setFrontMatterValue(setFrontMatterValue(sourceContent, "status", "archived"), "closedAt", closedAt), input.summary?.trim() || "变更记录已归档;验收与发布结果以实际证据为准。", closedAt);
184
+ const updated = appendClosure(setFrontMatterValue(setFrontMatterValue(effectiveContent, "status", "archived"), "closedAt", closedAt), input.summary?.trim() || "变更记录已归档;验收与发布结果以实际证据为准。", closedAt);
136
185
  atomicReplace(target, updated);
137
186
  return {
138
187
  schemaVersion: APP_SPEC_SCHEMAS.context,
@@ -141,6 +190,8 @@ export function closeAppSpecChange(input) {
141
190
  releaseGate: false,
142
191
  archived: relativePath(input.root, target),
143
192
  status: "archived",
193
+ converged: true,
194
+ diagnostics,
144
195
  };
145
196
  }
146
197
  export function inspectAppSpec(root, contract, selector) {
@@ -163,18 +214,16 @@ export function inspectAppSpec(root, contract, selector) {
163
214
  ...collection.history,
164
215
  ...collection.decisions,
165
216
  ];
166
- const digest = allDocuments.length === 0
217
+ const index = summarizeCollection(collection);
218
+ const workspaceDigest = digestDocuments(allDocuments, contract);
219
+ const selectionDocuments = selectedDocuments(application, selected);
220
+ const selectionDigest = allDocuments.length === 0
167
221
  ? 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");
222
+ : digestDocuments(selectionDocuments, contract, selector ? [`selector:${selector}`] : [`index:${JSON.stringify(index)}`]);
223
+ const budgeted = applyContextBudget(application, selected);
224
+ if (budgeted.contextBudget.truncated) {
225
+ diagnostics.push(issue("warning", "APPSPEC_CONTEXT_BUDGET_EXCEEDED", `相关 AppSpec 正文超过 ${APP_SPEC_LIMITS.maximumContextBytes} 字节预算;已省略 ${budgeted.contextBudget.omitted.length} 份低优先级文档,可按稳定 ID 单独读取`, "appspec"));
226
+ }
178
227
  return {
179
228
  schemaVersion: APP_SPEC_SCHEMAS.context,
180
229
  enabled: true,
@@ -183,11 +232,13 @@ export function inspectAppSpec(root, contract, selector) {
183
232
  root: "appspec",
184
233
  selector: selector || null,
185
234
  contract,
186
- application,
187
- capabilities: selected.capabilities,
188
- activeChanges: selected.activeChanges,
189
- decisions: selected.decisions,
190
- history: collection.history.map(summarize),
235
+ index,
236
+ application: budgeted.application,
237
+ capabilities: budgeted.capabilities,
238
+ activeChanges: budgeted.activeChanges,
239
+ archivedChanges: budgeted.archivedChanges,
240
+ decisions: budgeted.decisions,
241
+ contextBudget: budgeted.contextBudget,
191
242
  stats: {
192
243
  files: allDocuments.length,
193
244
  bytes: collection.bytes,
@@ -198,7 +249,8 @@ export function inspectAppSpec(root, contract, selector) {
198
249
  requirements: unique(collection.capabilities.flatMap(document => document.requirementIds)).length,
199
250
  acceptanceScenarios: unique(collection.capabilities.flatMap(document => document.acceptanceIds)).length,
200
251
  },
201
- digest,
252
+ workspaceDigest,
253
+ selectionDigest,
202
254
  diagnostics,
203
255
  };
204
256
  }
@@ -215,7 +267,9 @@ export function summarizeAppSpecContext(context) {
215
267
  enabled: context.enabled,
216
268
  mode: context.mode,
217
269
  releaseGate: context.releaseGate,
218
- digest: context.digest,
270
+ workspaceDigest: context.workspaceDigest,
271
+ selectionDigest: context.selectionDigest,
272
+ contextBudget: context.contextBudget,
219
273
  stats: context.stats,
220
274
  diagnostics: {
221
275
  errors: context.diagnostics.filter(item => item.severity === "error").length,
@@ -236,11 +290,24 @@ function emptyContext(contract, selector, diagnostics = [], enabled = false) {
236
290
  root: "appspec",
237
291
  selector: selector || null,
238
292
  contract,
293
+ index: {
294
+ application: null,
295
+ capabilities: [],
296
+ activeChanges: [],
297
+ decisions: [],
298
+ history: [],
299
+ },
239
300
  application: null,
240
301
  capabilities: [],
241
302
  activeChanges: [],
303
+ archivedChanges: [],
242
304
  decisions: [],
243
- history: [],
305
+ contextBudget: {
306
+ maximumBytes: APP_SPEC_LIMITS.maximumContextBytes,
307
+ contentBytes: 0,
308
+ truncated: false,
309
+ omitted: [],
310
+ },
244
311
  stats: {
245
312
  files: 0,
246
313
  bytes: 0,
@@ -251,7 +318,8 @@ function emptyContext(contract, selector, diagnostics = [], enabled = false) {
251
318
  requirements: 0,
252
319
  acceptanceScenarios: 0,
253
320
  },
254
- digest: null,
321
+ workspaceDigest: null,
322
+ selectionDigest: null,
255
323
  diagnostics,
256
324
  };
257
325
  }
@@ -348,6 +416,7 @@ function validateDocuments(collection, contract, diagnostics) {
348
416
  duplicateDiagnostics(collection.capabilities.flatMap(document => document.acceptanceIds.map(id => ({ ...document, id }))), document => document.id, "APPSPEC_ACCEPTANCE_ID_DUPLICATED", diagnostics);
349
417
  const capabilityIds = new Set(collection.capabilities.map(document => document.id));
350
418
  const requirementIds = new Set(collection.capabilities.flatMap(document => document.requirementIds));
419
+ const acceptanceIds = new Set(collection.capabilities.flatMap(document => document.acceptanceIds));
351
420
  const resourceCodes = new Set(contract.resourceCodes);
352
421
  const actionCodes = new Set(contract.actionCodes);
353
422
  const decisionIds = new Set(collection.decisions.map(document => document.id));
@@ -376,7 +445,12 @@ function validateDocuments(collection, contract, diagnostics) {
376
445
  }
377
446
  }
378
447
  validateReferences(document, resourceCodes, actionCodes, referenceSeverity, diagnostics);
448
+ validateEmbeddedCurrentSpecIds(document, requirementIds, acceptanceIds, diagnostics);
379
449
  validateRiskSections(document, diagnostics);
450
+ if (finalState &&
451
+ currentSpecStatus(document) === "pending") {
452
+ diagnostics.push(issue("error", "APPSPEC_CURRENT_SPEC_NOT_CONVERGED", "verified/released ChangeSpec 必须确认 currentSpec=merged 或 not-applicable", document.path));
453
+ }
380
454
  if (finalState && unresolvedQuestions(document.content).length > 0) {
381
455
  diagnostics.push(issue("error", "APPSPEC_VERIFIED_CHANGE_HAS_OPEN_QUESTIONS", "verified/released ChangeSpec 仍有未勾选问题", document.path));
382
456
  }
@@ -385,6 +459,70 @@ function validateDocuments(collection, contract, diagnostics) {
385
459
  diagnostics.push(issue("warning", "APPSPEC_ACTIVE_CHANGE_COUNT_HIGH", `当前有 ${collection.activeChanges.length} 个活跃变更;建议关闭已完成记录,避免 AI 上下文膨胀`, "appspec/changes/active"));
386
460
  }
387
461
  }
462
+ function validateCloseConvergence(document, collection, contract, diagnostics) {
463
+ const currentSpec = currentSpecStatus(document);
464
+ if (currentSpec === "pending") {
465
+ diagnostics.push(issue("error", "APPSPEC_CURRENT_SPEC_NOT_CONVERGED", "关闭前必须确认 currentSpec=merged 或 not-applicable;该检查只约束 spec close", document.path));
466
+ }
467
+ const capabilityIds = new Set(collection.capabilities.map(item => item.id));
468
+ const requirementIds = new Set(collection.capabilities.flatMap(item => item.requirementIds));
469
+ const acceptanceIds = new Set(collection.capabilities.flatMap(item => item.acceptanceIds));
470
+ const decisionIds = new Set(collection.decisions.map(item => item.id));
471
+ for (const id of document.references.capabilities) {
472
+ const matches = collection.capabilities.filter(item => item.id === id);
473
+ if (!capabilityIds.has(id)) {
474
+ diagnostics.push(issue("error", "APPSPEC_CHANGE_CAPABILITY_UNKNOWN", `关闭前必须建立当前能力引用 ${id}`, document.path));
475
+ }
476
+ else if (matches.length > 1) {
477
+ diagnostics.push(issue("error", "APPSPEC_DOCUMENT_ID_DUPLICATED", `关闭前必须消除能力 ${id} 的重复定义`, document.path));
478
+ }
479
+ }
480
+ for (const id of document.references.requirements) {
481
+ const matches = collection.capabilities.filter(item => item.requirementIds.includes(id));
482
+ if (!requirementIds.has(id)) {
483
+ diagnostics.push(issue("error", "APPSPEC_CHANGE_REQUIREMENT_UNKNOWN", `关闭前必须把需求 ${id} 合入当前 CapabilitySpec`, document.path));
484
+ }
485
+ else if (matches.length > 1) {
486
+ diagnostics.push(issue("error", "APPSPEC_REQUIREMENT_ID_DUPLICATED", `关闭前必须消除需求 ${id} 的重复定义`, document.path));
487
+ }
488
+ }
489
+ for (const id of document.references.decisions) {
490
+ const matches = collection.decisions.filter(item => item.id === id);
491
+ if (!decisionIds.has(id)) {
492
+ diagnostics.push(issue("error", "APPSPEC_CHANGE_DECISION_UNKNOWN", `关闭前必须建立架构决策 ${id}`, document.path));
493
+ }
494
+ else if (matches.length > 1) {
495
+ diagnostics.push(issue("error", "APPSPEC_DOCUMENT_ID_DUPLICATED", `关闭前必须消除决策 ${id} 的重复定义`, document.path));
496
+ }
497
+ }
498
+ for (const id of document.requirementIds) {
499
+ const matches = collection.capabilities.filter(item => item.requirementIds.includes(id));
500
+ if (matches.length > 1) {
501
+ diagnostics.push(issue("error", "APPSPEC_REQUIREMENT_ID_DUPLICATED", `关闭前必须消除需求 ${id} 的重复定义`, document.path));
502
+ }
503
+ }
504
+ for (const id of document.acceptanceIds) {
505
+ const matches = collection.capabilities.filter(item => item.acceptanceIds.includes(id));
506
+ if (matches.length > 1) {
507
+ diagnostics.push(issue("error", "APPSPEC_ACCEPTANCE_ID_DUPLICATED", `关闭前必须消除验收场景 ${id} 的重复定义`, document.path));
508
+ }
509
+ }
510
+ validateEmbeddedCurrentSpecIds(document, requirementIds, acceptanceIds, diagnostics);
511
+ validateReferences(document, new Set(contract.resourceCodes), new Set(contract.actionCodes), "error", diagnostics);
512
+ validateRiskSections(document, diagnostics);
513
+ }
514
+ function validateEmbeddedCurrentSpecIds(document, currentRequirementIds, currentAcceptanceIds, diagnostics) {
515
+ for (const id of document.requirementIds) {
516
+ if (!currentRequirementIds.has(id)) {
517
+ diagnostics.push(issue("error", "APPSPEC_CHANGE_REQUIREMENT_NOT_CURRENT", `ChangeSpec 中的长期需求 ${id} 尚未进入当前 CapabilitySpec`, document.path));
518
+ }
519
+ }
520
+ for (const id of document.acceptanceIds) {
521
+ if (!currentAcceptanceIds.has(id)) {
522
+ diagnostics.push(issue("error", "APPSPEC_CHANGE_ACCEPTANCE_NOT_CURRENT", `ChangeSpec 中的验收场景 ${id} 尚未进入当前 CapabilitySpec`, document.path));
523
+ }
524
+ }
525
+ }
388
526
  function validateReferences(document, resourceCodes, actionCodes, severity, diagnostics) {
389
527
  for (const code of document.references.resources) {
390
528
  if (!resourceCodes.has(code)) {
@@ -406,25 +544,45 @@ function validateRiskSections(document, diagnostics) {
406
544
  if (risk === "L1")
407
545
  return;
408
546
  const required = [
409
- ["验收", "Acceptance"],
410
- ["数据与权限", "Data and Permissions"],
411
- ["回滚", "Rollback"],
547
+ {
548
+ titles: ["验收", "Acceptance"],
549
+ placeholders: [
550
+ "一个可观察的正向结果",
551
+ "需要时补充拒绝、异常或权限反例",
552
+ ],
553
+ },
554
+ {
555
+ titles: ["数据与权限", "Data and Permissions"],
556
+ placeholders: ["无,或说明资源、字段、角色和数据范围变化。"],
557
+ },
558
+ {
559
+ titles: ["回滚", "Rollback"],
560
+ placeholders: ["回退声明/代码并保持旧数据可读;如不适用请说明。"],
561
+ },
412
562
  ];
413
563
  if (risk === "L3") {
414
- required.push(["失败、并发与幂等", "Failure, Concurrency, and Idempotency"], ["架构决策", "Decision Records"]);
564
+ required.push({
565
+ titles: ["失败、并发与幂等", "Failure, Concurrency, and Idempotency"],
566
+ placeholders: ["L3 才需要详细维护;其他变化写“无”。"],
567
+ }, {
568
+ titles: ["架构决策", "Decision Records"],
569
+ placeholders: ["L3 如涉及 ADR,在 front matter 的 decisions 中引用。"],
570
+ });
415
571
  }
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));
572
+ for (const section of required) {
573
+ if (!hasMeaningfulSection(document.content, section.titles, section.placeholders)) {
574
+ diagnostics.push(issue("error", "APPSPEC_RISK_SECTION_INCOMPLETE", `${risk} ChangeSpec 的“${section.titles[0]}”为空或仍是默认占位内容`, document.path));
419
575
  }
420
576
  }
421
577
  }
422
578
  function selectContext(collection, selector, diagnostics) {
423
579
  if (!selector) {
424
580
  return {
425
- capabilities: collection.capabilities,
426
- activeChanges: collection.activeChanges,
427
- decisions: collection.decisions,
581
+ primary: collection.app[0] || null,
582
+ capabilities: [],
583
+ activeChanges: [],
584
+ archivedChanges: [],
585
+ decisions: [],
428
586
  };
429
587
  }
430
588
  const change = collection.activeChanges.find(document => document.id === selector);
@@ -432,21 +590,133 @@ function selectContext(collection, selector, diagnostics) {
432
590
  const capabilityIds = new Set(change.references.capabilities);
433
591
  const decisionIds = new Set(change.references.decisions);
434
592
  return {
593
+ primary: change,
435
594
  capabilities: collection.capabilities.filter(document => capabilityIds.has(document.id)),
436
595
  activeChanges: [change],
596
+ archivedChanges: [],
597
+ decisions: collection.decisions.filter(document => decisionIds.has(document.id)),
598
+ };
599
+ }
600
+ const archivedChange = collection.history.find(document => document.id === selector);
601
+ if (archivedChange) {
602
+ const capabilityIds = new Set(archivedChange.references.capabilities);
603
+ const decisionIds = new Set(archivedChange.references.decisions);
604
+ return {
605
+ primary: archivedChange,
606
+ capabilities: collection.capabilities.filter(document => capabilityIds.has(document.id)),
607
+ activeChanges: [],
608
+ archivedChanges: [archivedChange],
437
609
  decisions: collection.decisions.filter(document => decisionIds.has(document.id)),
438
610
  };
439
611
  }
440
612
  const capability = collection.capabilities.find(document => document.id === selector);
441
613
  if (capability) {
614
+ const activeChanges = collection.activeChanges.filter(document => document.references.capabilities.includes(capability.id));
615
+ const decisionIds = new Set(activeChanges.flatMap(document => document.references.decisions));
442
616
  return {
617
+ primary: capability,
443
618
  capabilities: [capability],
444
- activeChanges: collection.activeChanges.filter(document => document.references.capabilities.includes(capability.id)),
445
- decisions: collection.decisions,
619
+ activeChanges,
620
+ archivedChanges: [],
621
+ decisions: collection.decisions.filter(document => decisionIds.has(document.id)),
622
+ };
623
+ }
624
+ const decision = collection.decisions.find(document => document.id === selector);
625
+ if (decision) {
626
+ return {
627
+ primary: decision,
628
+ capabilities: [],
629
+ activeChanges: [],
630
+ archivedChanges: [],
631
+ decisions: [decision],
446
632
  };
447
633
  }
448
634
  diagnostics.push(issue("warning", "APPSPEC_SELECTOR_NOT_FOUND", `未找到 AppSpec selector ${selector},已返回应用总纲和空的相关上下文`, "appspec"));
449
- return { capabilities: [], activeChanges: [], decisions: [] };
635
+ return {
636
+ primary: null,
637
+ capabilities: [],
638
+ activeChanges: [],
639
+ archivedChanges: [],
640
+ decisions: [],
641
+ };
642
+ }
643
+ function summarizeCollection(collection) {
644
+ return {
645
+ application: collection.app[0] ? summarize(collection.app[0]) : null,
646
+ capabilities: collection.capabilities.map(summarize),
647
+ activeChanges: collection.activeChanges.map(summarize),
648
+ decisions: collection.decisions.map(summarize),
649
+ history: collection.history.map(summarize),
650
+ };
651
+ }
652
+ function selectedDocuments(application, selected) {
653
+ return uniqueDocuments([
654
+ selected.primary,
655
+ application,
656
+ ...selected.capabilities,
657
+ ...selected.activeChanges,
658
+ ...selected.archivedChanges,
659
+ ...selected.decisions,
660
+ ]);
661
+ }
662
+ function applyContextBudget(application, selected) {
663
+ const prioritized = selectedDocuments(application, selected);
664
+ const included = new Set();
665
+ const omitted = [];
666
+ let contentBytes = 0;
667
+ for (const document of prioritized) {
668
+ const bytes = Buffer.byteLength(document.content, "utf8");
669
+ if (contentBytes + bytes > APP_SPEC_LIMITS.maximumContextBytes) {
670
+ omitted.push(document);
671
+ continue;
672
+ }
673
+ included.add(document.path);
674
+ contentBytes += bytes;
675
+ }
676
+ const keep = (documents) => documents.filter(document => included.has(document.path));
677
+ return {
678
+ application: application && included.has(application.path) ? application : null,
679
+ capabilities: keep(selected.capabilities),
680
+ activeChanges: keep(selected.activeChanges),
681
+ archivedChanges: keep(selected.archivedChanges),
682
+ decisions: keep(selected.decisions),
683
+ contextBudget: {
684
+ maximumBytes: APP_SPEC_LIMITS.maximumContextBytes,
685
+ contentBytes,
686
+ truncated: omitted.length > 0,
687
+ omitted: omitted.map(summarize),
688
+ },
689
+ };
690
+ }
691
+ function uniqueDocuments(documents) {
692
+ const paths = new Set();
693
+ return documents.filter((document) => {
694
+ if (!document || paths.has(document.path))
695
+ return false;
696
+ paths.add(document.path);
697
+ return true;
698
+ });
699
+ }
700
+ function digestDocuments(documents, contract, extra = []) {
701
+ if (documents.length === 0 && extra.length === 0)
702
+ return null;
703
+ const contractIdentity = {
704
+ appCode: contract.appCode,
705
+ resourceCodes: unique(contract.resourceCodes),
706
+ actionCodes: unique(contract.actionCodes),
707
+ configDigest: contract.configDigest || null,
708
+ contractDigest: contract.contractDigest || null,
709
+ aiCatalogDigest: contract.aiCatalogDigest || null,
710
+ };
711
+ return createHash("sha256")
712
+ .update([
713
+ ...[...documents]
714
+ .sort((left, right) => left.path.localeCompare(right.path))
715
+ .map(document => `${document.path}\n${document.content}`),
716
+ ...extra,
717
+ `contract:${JSON.stringify(contractIdentity)}`,
718
+ ].join("\n---\n"))
719
+ .digest("hex");
450
720
  }
451
721
  function parseAppSpecDocument(root, path, kind, content, diagnostics) {
452
722
  const pointer = relativePath(root, path);
@@ -479,6 +749,12 @@ function parseAppSpecDocument(root, path, kind, content, diagnostics) {
479
749
  if (!validStatuses[kind].includes(status)) {
480
750
  diagnostics.push(issue("error", "APPSPEC_STATUS_INVALID", `${kind} status=${status} 不受支持`, pointer));
481
751
  }
752
+ if (kind === "change") {
753
+ const currentSpec = String(frontMatter.metadata.currentSpec || "pending");
754
+ if (!["pending", "merged", "not-applicable"].includes(currentSpec)) {
755
+ diagnostics.push(issue("error", "APPSPEC_CURRENT_SPEC_STATUS_INVALID", `change currentSpec=${currentSpec} 不受支持`, pointer));
756
+ }
757
+ }
482
758
  const h1 = content.match(/^#\s+(.+)$/m)?.[1]?.trim() || "";
483
759
  const title = String(frontMatter.metadata.title || h1 || id);
484
760
  const requirementIds = extractIds(content, /^###\s+(REQ-[A-Z0-9-]+)\b/gm, REQUIREMENT_ID);
@@ -610,8 +886,31 @@ function unresolvedQuestions(content) {
610
886
  const section = nextHeading >= 0 ? tail.slice(0, nextHeading) : tail;
611
887
  return section ? [...section.matchAll(/^- \[ \]\s+(.+)$/gm)].map(match => match[1]) : [];
612
888
  }
613
- function hasSection(content, alternatives) {
614
- return alternatives.some(title => new RegExp(`^##\\s+${escapeRegExp(title)}\\s*$`, "m").test(content));
889
+ function hasMeaningfulSection(content, alternatives, placeholders) {
890
+ const source = withoutHtmlComments(content);
891
+ for (const title of alternatives) {
892
+ const heading = new RegExp(`^##\\s+${escapeRegExp(title)}\\s*$`, "m").exec(source);
893
+ if (heading?.index === undefined)
894
+ continue;
895
+ const tail = source.slice(heading.index + heading[0].length);
896
+ const nextHeading = tail.search(/^##\s+/m);
897
+ const body = nextHeading >= 0 ? tail.slice(0, nextHeading) : tail;
898
+ const meaningfulLines = body
899
+ .split(/\r?\n/)
900
+ .map(line => line.trim())
901
+ .filter(Boolean)
902
+ .filter(line => !/^[-*](?:\s+\[[ xX]\])?\s*$/.test(line))
903
+ .filter(line => !placeholders.some(placeholder => line.includes(placeholder)));
904
+ if (meaningfulLines.length > 0)
905
+ return true;
906
+ }
907
+ return false;
908
+ }
909
+ function currentSpecStatus(document) {
910
+ const value = String(document.metadata.currentSpec || "pending");
911
+ return ["merged", "not-applicable"].includes(value)
912
+ ? value
913
+ : "pending";
615
914
  }
616
915
  function duplicateDiagnostics(values, id, code, diagnostics) {
617
916
  const seen = new Map();
@@ -758,6 +1057,7 @@ schema: ${APP_SPEC_SCHEMAS.change}
758
1057
  id: ${input.id}
759
1058
  title: ${JSON.stringify(input.title)}
760
1059
  status: draft
1060
+ currentSpec: pending
761
1061
  risk: ${input.risk}
762
1062
  createdAt: ${JSON.stringify(createdAt)}
763
1063
  capabilities: ${JSON.stringify(input.capabilities)}
@@ -881,8 +1181,11 @@ function summarize(document) {
881
1181
  title: document.title,
882
1182
  status: document.status,
883
1183
  path: document.path,
884
- requirementIds: document.requirementIds,
885
- acceptanceIds: document.acceptanceIds,
1184
+ risk: document.kind === "change" && ["L1", "L2", "L3"].includes(String(document.metadata.risk))
1185
+ ? String(document.metadata.risk)
1186
+ : null,
1187
+ requirementCount: document.requirementIds.length,
1188
+ acceptanceScenarioCount: document.acceptanceIds.length,
886
1189
  };
887
1190
  }
888
1191
  function extractIds(content, pattern, validation) {
@@ -909,6 +1212,7 @@ function validateMetadata(kind, metadata, pointer, diagnostics) {
909
1212
  "id",
910
1213
  "title",
911
1214
  "status",
1215
+ "currentSpec",
912
1216
  "risk",
913
1217
  "createdAt",
914
1218
  "closedAt",