kld-sdd 2.6.6 → 2.6.8

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.
Files changed (37) hide show
  1. package/bin/kld-sdd-init.js +39 -3
  2. package/lib/init.js +320 -45
  3. package/lib/workspace-layout.js +2 -0
  4. package/package.json +2 -2
  5. package/skywalk-sdd/context-client.cjs +12 -1
  6. package/skywalk-sdd/ontology/active-changes.cjs +297 -0
  7. package/skywalk-sdd/ontology/archive-package.cjs +95 -6
  8. package/skywalk-sdd/ontology/artifact-parser.cjs +50 -7
  9. package/skywalk-sdd/ontology/change-key.cjs +241 -0
  10. package/skywalk-sdd/ontology/cli.cjs +154 -0
  11. package/skywalk-sdd/ontology/external-key.cjs +159 -0
  12. package/skywalk-sdd/ontology/list-changes.cjs +110 -0
  13. package/skywalk-sdd/ontology/modules.cjs +167 -0
  14. package/skywalk-sdd/ontology/naming-diagnose.cjs +594 -0
  15. package/skywalk-sdd/ontology/normalizer.cjs +4 -0
  16. package/skywalk-sdd/ontology/schema.cjs +5 -0
  17. package/skywalk-sdd/ontology/sdd-config.cjs +335 -0
  18. package/skywalk-sdd/ontology/traceability-validator.cjs +175 -0
  19. package/skywalk-sdd/ontology/workspace-layout.cjs +194 -0
  20. package/templates/dot-sdd.yaml +8 -0
  21. package/templates/git-hooks/commit-msg-sdd-trailer.cjs +224 -0
  22. package/templates/modules.yaml +13 -0
  23. package/templates/openspec/proposal.md +12 -3
  24. package/templates/openspec/spec.md +3 -3
  25. package/templates/sdd.config.yaml +12 -0
  26. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +3 -3
  27. package/templates/skills/kld-sdd/opsx-apply/checklist.md +1 -1
  28. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +10 -0
  29. package/templates/skills/kld-sdd/opsx-check/SKILL.md +47 -3
  30. package/templates/skills/kld-sdd/opsx-explore/SKILL.md +37 -17
  31. package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +13 -14
  32. package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +13 -0
  33. package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +26 -15
  34. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +61 -23
  35. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +8 -2
  36. package/templates/skills/kld-sdd/opsx-task/SKILL.md +1 -0
  37. package/templates/skills/kld-sdd/opsx-tdd-core/checklist.md +1 -1
@@ -0,0 +1,241 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Change Key grammar for multi-repo association.
5
+ * Authority: 本体架构设计方案/10-最小化多仓库关联与变更命名方案.md §2.1 / §2.2
6
+ */
7
+
8
+ const CHANGE_KEY_REGEX = /^([a-z][a-z0-9]{1,7})-([0-9]{6})-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
9
+ const MODULE_CODE_REGEX = /^[a-z][a-z0-9]{1,7}$/;
10
+ const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
11
+ const SLUG_MAX_LEN = 48;
12
+ const RESERVED_CROSS = 'cross';
13
+
14
+ const CODES = {
15
+ FORMAT_INVALID: 'CHANGE_KEY_FORMAT_INVALID',
16
+ DATE_INVALID: 'CHANGE_KEY_DATE_INVALID',
17
+ MODULE_INVALID: 'CHANGE_KEY_MODULE_INVALID',
18
+ SLUG_INVALID: 'CHANGE_KEY_SLUG_INVALID',
19
+ CONFLICT: 'CHANGE_KEY_CONFLICT',
20
+ };
21
+
22
+ function pad2(value) {
23
+ return String(value).padStart(2, '0');
24
+ }
25
+
26
+ function formatYymmdd(date = new Date()) {
27
+ const year = date.getFullYear() % 100;
28
+ const month = date.getMonth() + 1;
29
+ const day = date.getDate();
30
+ return `${pad2(year)}${pad2(month)}${pad2(day)}`;
31
+ }
32
+
33
+ function isValidCalendarYymmdd(yymmdd) {
34
+ if (!/^[0-9]{6}$/.test(String(yymmdd || ''))) return false;
35
+ const year = 2000 + Number(yymmdd.slice(0, 2));
36
+ const month = Number(yymmdd.slice(2, 4));
37
+ const day = Number(yymmdd.slice(4, 6));
38
+ if (month < 1 || month > 12 || day < 1 || day > 31) return false;
39
+ const dt = new Date(year, month - 1, day);
40
+ return (
41
+ dt.getFullYear() === year &&
42
+ dt.getMonth() === month - 1 &&
43
+ dt.getDate() === day
44
+ );
45
+ }
46
+
47
+ function normalizeSlug(raw) {
48
+ if (raw == null) return '';
49
+ let slug = String(raw)
50
+ .trim()
51
+ .toLowerCase()
52
+ .replace(/[_\s.]+/g, '-')
53
+ .replace(/[^a-z0-9-]/g, '')
54
+ .replace(/-+/g, '-')
55
+ .replace(/^-+|-+$/g, '');
56
+ if (slug.length > SLUG_MAX_LEN) {
57
+ slug = slug.slice(0, SLUG_MAX_LEN).replace(/-+$/g, '');
58
+ }
59
+ return slug;
60
+ }
61
+
62
+ function normalizeModule(raw) {
63
+ if (raw == null) return '';
64
+ return String(raw).trim().toLowerCase();
65
+ }
66
+
67
+ function parse(changeKey) {
68
+ const key = String(changeKey || '').trim().toLowerCase();
69
+ const match = CHANGE_KEY_REGEX.exec(key);
70
+ if (!match) return null;
71
+ return {
72
+ changeKey: key,
73
+ module: match[1],
74
+ yymmdd: match[2],
75
+ slug: match[3],
76
+ };
77
+ }
78
+
79
+ function validate(changeKey, options = {}) {
80
+ const key = String(changeKey || '').trim();
81
+ if (!key) {
82
+ return { ok: false, code: CODES.FORMAT_INVALID, message: 'change-key 为空' };
83
+ }
84
+ if (key !== key.toLowerCase()) {
85
+ return {
86
+ ok: false,
87
+ code: CODES.FORMAT_INVALID,
88
+ message: `change-key 必须全小写: ${changeKey}`,
89
+ };
90
+ }
91
+ if (/[_\s.]/.test(key) || key.includes('--')) {
92
+ return {
93
+ ok: false,
94
+ code: CODES.FORMAT_INVALID,
95
+ message: `change-key 禁止下划线、空格、点号或连续连字符: ${changeKey}`,
96
+ };
97
+ }
98
+ if (!CHANGE_KEY_REGEX.test(key)) {
99
+ return {
100
+ ok: false,
101
+ code: CODES.FORMAT_INVALID,
102
+ message: `change-key 格式不合规: ${changeKey}`,
103
+ };
104
+ }
105
+ const parts = parse(key);
106
+ if (!parts) {
107
+ return {
108
+ ok: false,
109
+ code: CODES.FORMAT_INVALID,
110
+ message: `change-key 无法解析: ${changeKey}`,
111
+ };
112
+ }
113
+ if (!isValidCalendarYymmdd(parts.yymmdd)) {
114
+ return {
115
+ ok: false,
116
+ code: CODES.DATE_INVALID,
117
+ message: `change-key 日期不是真实日历日: ${parts.yymmdd}`,
118
+ };
119
+ }
120
+ if (parts.slug.length > SLUG_MAX_LEN) {
121
+ return {
122
+ ok: false,
123
+ code: CODES.SLUG_INVALID,
124
+ message: `slug 超过 ${SLUG_MAX_LEN} 字符: ${parts.slug}`,
125
+ };
126
+ }
127
+ const registeredModules = options.registeredModules;
128
+ if (registeredModules && typeof registeredModules.has === 'function') {
129
+ if (!registeredModules.has(parts.module)) {
130
+ return {
131
+ ok: false,
132
+ code: CODES.MODULE_INVALID,
133
+ message: `模块代号未注册: ${parts.module}`,
134
+ };
135
+ }
136
+ } else if (Array.isArray(registeredModules)) {
137
+ if (!registeredModules.includes(parts.module)) {
138
+ return {
139
+ ok: false,
140
+ code: CODES.MODULE_INVALID,
141
+ message: `模块代号未注册: ${parts.module}`,
142
+ };
143
+ }
144
+ }
145
+ return { ok: true, code: null, message: null, normalized: key, parts };
146
+ }
147
+
148
+ function toChangeId(changeKey) {
149
+ const result = validate(changeKey);
150
+ if (!result.ok) {
151
+ throw new Error(result.message);
152
+ }
153
+ return `CHG-${result.normalized.toUpperCase()}`;
154
+ }
155
+
156
+ function buildChangeKey(moduleCode, slug, options = {}) {
157
+ const module = normalizeModule(moduleCode);
158
+ const normalizedSlug = normalizeSlug(slug);
159
+ const yymmdd = options.yymmdd || formatYymmdd(options.date || new Date());
160
+
161
+ if (!MODULE_CODE_REGEX.test(module)) {
162
+ return {
163
+ ok: false,
164
+ code: CODES.MODULE_INVALID,
165
+ message: `模块代号不合规: ${moduleCode}`,
166
+ };
167
+ }
168
+ if (!SLUG_REGEX.test(normalizedSlug)) {
169
+ return {
170
+ ok: false,
171
+ code: CODES.SLUG_INVALID,
172
+ message: `slug 不合规: ${slug} → ${normalizedSlug}`,
173
+ };
174
+ }
175
+ if (!isValidCalendarYymmdd(yymmdd)) {
176
+ return {
177
+ ok: false,
178
+ code: CODES.DATE_INVALID,
179
+ message: `日期不是真实日历日: ${yymmdd}`,
180
+ };
181
+ }
182
+
183
+ let candidate = `${module}-${yymmdd}-${normalizedSlug}`;
184
+ const existing = options.existingKeys || new Set();
185
+ const existingSet = existing instanceof Set
186
+ ? existing
187
+ : new Set(Array.from(existing || []).map((item) => String(item).toLowerCase()));
188
+
189
+ if (!existingSet.has(candidate)) {
190
+ const checked = validate(candidate, options);
191
+ if (!checked.ok) return checked;
192
+ return {
193
+ ok: true,
194
+ changeKey: candidate,
195
+ changeId: toChangeId(candidate),
196
+ module,
197
+ yymmdd,
198
+ slug: normalizedSlug,
199
+ };
200
+ }
201
+
202
+ for (let suffix = 2; suffix <= 99; suffix += 1) {
203
+ candidate = `${module}-${yymmdd}-${normalizedSlug}-${suffix}`;
204
+ if (existingSet.has(candidate)) continue;
205
+ const checked = validate(candidate, options);
206
+ if (!checked.ok) return checked;
207
+ return {
208
+ ok: true,
209
+ changeKey: candidate,
210
+ changeId: toChangeId(candidate),
211
+ module,
212
+ yymmdd,
213
+ slug: `${normalizedSlug}-${suffix}`,
214
+ collided: true,
215
+ suffix,
216
+ };
217
+ }
218
+
219
+ return {
220
+ ok: false,
221
+ code: CODES.CONFLICT,
222
+ message: `无法为 ${module}-${yymmdd}-${normalizedSlug} 分配可用序号`,
223
+ };
224
+ }
225
+
226
+ module.exports = {
227
+ CHANGE_KEY_REGEX,
228
+ MODULE_CODE_REGEX,
229
+ SLUG_REGEX,
230
+ SLUG_MAX_LEN,
231
+ RESERVED_CROSS,
232
+ CODES,
233
+ formatYymmdd,
234
+ isValidCalendarYymmdd,
235
+ normalizeSlug,
236
+ normalizeModule,
237
+ parse,
238
+ validate,
239
+ toChangeId,
240
+ buildChangeKey,
241
+ };
@@ -10,6 +10,13 @@ const {
10
10
  } = require('./runtime.cjs');
11
11
  const { observeChangeArtifacts } = require('./artifact-observer.cjs');
12
12
  const { allocateIdentity } = require('./id.cjs');
13
+ const externalKey = require('./external-key.cjs');
14
+ const changeKey = require('./change-key.cjs');
15
+ const modules = require('./modules.cjs');
16
+ const namingDiagnose = require('./naming-diagnose.cjs');
17
+ const listChanges = require('./list-changes.cjs');
18
+ const sddConfig = require('./sdd-config.cjs');
19
+ const activeChanges = require('./active-changes.cjs');
13
20
 
14
21
  function parseArgs(argv) {
15
22
  const result = { _: [] };
@@ -53,6 +60,16 @@ function showHelp() {
53
60
  node skywalk-sdd/ontology/cli.cjs identity --delta-state=added
54
61
  node skywalk-sdd/ontology/cli.cjs identity --delta-state=modified --entity-id=<uuid> --predecessor-version=<uuid>
55
62
  node skywalk-sdd/ontology/cli.cjs identity --delta-state=unchanged --entity-id=<uuid> --version-id=<uuid>
63
+ node skywalk-sdd/ontology/cli.cjs external-key --validate <id> --type <requirement|feature|scenario>
64
+ node skywalk-sdd/ontology/cli.cjs change-key --validate <key>
65
+ node skywalk-sdd/ontology/cli.cjs change-key --generate --module=<code> --slug=<slug> [--date=yymmdd]
66
+ node skywalk-sdd/ontology/cli.cjs modules --validate [--project=.]
67
+ node skywalk-sdd/ontology/cli.cjs diagnose-naming [--project=.] [--change=<key>] [--mode=spec-repo|code-repo]
68
+ node skywalk-sdd/ontology/cli.cjs list-changes [--project=.] [--json]
69
+ node skywalk-sdd/ontology/cli.cjs link-spec --path=<spec-clone> [--project=.]
70
+ node skywalk-sdd/ontology/cli.cjs active-change --register --change=<key> [--title=] [--summary=] [--module=]
71
+ node skywalk-sdd/ontology/cli.cjs active-change --remove --change=<key>
72
+ node skywalk-sdd/ontology/cli.cjs active-change --list [--json]
56
73
  node skywalk-sdd/ontology/cli.cjs reconcile --project=. --change=<name> [--profile=...]
57
74
  node skywalk-sdd/ontology/cli.cjs check --project=. --change=<name> [--profile=...]
58
75
  node skywalk-sdd/ontology/cli.cjs status --project=. --change=<name>
@@ -73,6 +90,143 @@ function main(argv = process.argv.slice(2)) {
73
90
  console.log(JSON.stringify(allocateIdentity(args), null, 2));
74
91
  return;
75
92
  }
93
+ if (command === 'external-key') {
94
+ const id = args.validate || args._[1];
95
+ const type = args.type || args.t;
96
+ if (!id || !type) {
97
+ throw new Error('用法: external-key --validate <id> --type <requirement|feature|scenario>');
98
+ }
99
+ const result = externalKey.validate(id, type);
100
+ console.log(JSON.stringify({
101
+ ok: result.ok,
102
+ code: result.code,
103
+ message: result.message,
104
+ normalized: externalKey.normalize(id, type),
105
+ objectType: String(type).toLowerCase(),
106
+ }, null, 2));
107
+ if (!result.ok) process.exitCode = 1;
108
+ return;
109
+ }
110
+ if (command === 'change-key') {
111
+ if (args.generate || args._[1] === 'generate') {
112
+ const projectRoot = path.resolve(args.project || '.');
113
+ const registry = modules.loadModulesYaml(projectRoot);
114
+ const existing = listChanges.listActiveChanges(projectRoot);
115
+ const built = changeKey.buildChangeKey(args.module, args.slug, {
116
+ yymmdd: args.date || args.yymmdd,
117
+ existingKeys: existing,
118
+ registeredModules: registry.ok ? registry.codes : null,
119
+ });
120
+ console.log(JSON.stringify(built, null, 2));
121
+ if (!built.ok) process.exitCode = 1;
122
+ return;
123
+ }
124
+ const key = args.validate || args._[1];
125
+ if (!key) throw new Error('用法: change-key --validate <key> | --generate --module= --slug=');
126
+ const projectRoot = path.resolve(args.project || '.');
127
+ const registry = modules.loadModulesYaml(projectRoot);
128
+ const result = changeKey.validate(key, {
129
+ registeredModules: registry.ok ? registry.codes : null,
130
+ });
131
+ console.log(JSON.stringify({
132
+ ok: result.ok,
133
+ code: result.code,
134
+ message: result.message,
135
+ normalized: result.normalized || null,
136
+ changeId: result.ok ? changeKey.toChangeId(result.normalized) : null,
137
+ parts: result.parts || null,
138
+ }, null, 2));
139
+ if (!result.ok) process.exitCode = 1;
140
+ return;
141
+ }
142
+ if (command === 'modules') {
143
+ const projectRoot = path.resolve(args.project || '.');
144
+ const result = modules.loadModulesYaml(projectRoot);
145
+ console.log(JSON.stringify({
146
+ ok: result.ok,
147
+ code: result.code || null,
148
+ message: result.message || null,
149
+ path: result.path,
150
+ version: result.version || null,
151
+ modules: result.modules || null,
152
+ }, null, 2));
153
+ if (!result.ok) process.exitCode = 1;
154
+ return;
155
+ }
156
+ if (command === 'diagnose-naming') {
157
+ const projectRoot = path.resolve(args.project || '.');
158
+ const result = namingDiagnose.diagnose(projectRoot, {
159
+ change: args.change,
160
+ mode: args.mode,
161
+ });
162
+ if (args.json) {
163
+ console.log(JSON.stringify(result, null, 2));
164
+ } else {
165
+ console.log(namingDiagnose.formatReport(result));
166
+ console.log('');
167
+ console.log(JSON.stringify(result.summary, null, 2));
168
+ }
169
+ if (result.summary.overall === 'FAIL') process.exitCode = 1;
170
+ return;
171
+ }
172
+ if (command === 'list-changes') {
173
+ const projectRoot = path.resolve(args.project || '.');
174
+ const result = listChanges.groupChanges(projectRoot);
175
+ if (args.json) {
176
+ console.log(JSON.stringify(result, null, 2));
177
+ } else {
178
+ console.log(listChanges.formatGroupedText(result) || '(无活动 change)');
179
+ }
180
+ return;
181
+ }
182
+ if (command === 'link-spec') {
183
+ const projectRoot = path.resolve(args.project || '.');
184
+ const specPath = args.path || args._[1];
185
+ if (!specPath) throw new Error('用法: link-spec --path=<spec-clone> [--project=.]');
186
+ const result = sddConfig.setSpecPath(projectRoot, specPath);
187
+ console.log(JSON.stringify(result, null, 2));
188
+ if (!result.ok) process.exitCode = 1;
189
+ return;
190
+ }
191
+ if (command === 'active-change') {
192
+ const specRoot = path.resolve(args.project || '.');
193
+ if (args.list || args._[1] === 'list') {
194
+ const config = activeChanges.loadSddConfig(specRoot);
195
+ if (args.json) {
196
+ console.log(JSON.stringify(config, null, 2));
197
+ } else if (!config.ok) {
198
+ console.log(config.message);
199
+ } else if (config.activeChanges.length === 0) {
200
+ console.log('(无活动 change)');
201
+ } else {
202
+ for (const entry of config.activeChanges) {
203
+ console.log(`● ${entry.title || '(无标题)'}`);
204
+ console.log(` ${entry.changeKey}${entry.module ? ` [${entry.module}]` : ''}`);
205
+ if (entry.summary) console.log(` ${entry.summary}`);
206
+ }
207
+ }
208
+ if (!config.ok && config.code !== activeChanges.CODES.MISSING) process.exitCode = 1;
209
+ return;
210
+ }
211
+ const key = args.change || args._[1];
212
+ if (!key) throw new Error('用法: active-change --register|--remove --change=<key> [--project=<spec>]');
213
+ if (args.remove) {
214
+ const result = activeChanges.removeActiveChange(specRoot, key);
215
+ console.log(JSON.stringify(result, null, 2));
216
+ if (!result.ok) process.exitCode = 1;
217
+ return;
218
+ }
219
+ const result = activeChanges.registerActiveChange(specRoot, {
220
+ changeKey: key,
221
+ changeId: args['change-id'] || args.changeId,
222
+ title: args.title,
223
+ module: args.module,
224
+ summary: args.summary,
225
+ });
226
+ console.log(JSON.stringify(result, null, 2));
227
+ if (!result.ok) process.exitCode = 1;
228
+ return;
229
+ }
76
230
  const projectRoot = path.resolve(args.project || '.');
77
231
  const changeName = args.change;
78
232
  if (!changeName) throw new Error('缺少 --change 参数');
@@ -0,0 +1,159 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * External key grammar for requirement / feature / scenario.
5
+ * Authority: kb-sdd docs/superpowers/specs/2026-07-24-external-numbering-kb-sdd-design.md §2.
6
+ * Regex and normalization MUST stay byte-for-byte aligned with kb-sdd ExternalKeyFormat.java.
7
+ */
8
+
9
+ const REGEX = {
10
+ requirement: /^REQ-[A-Z][A-Z0-9]{1,7}-[0-9]{4}-[0-9]{3,6}$/,
11
+ feature: /^FEAT-[A-Z][A-Z0-9]{1,7}-[0-9]{3,6}$/,
12
+ scenario: /^REQ-[A-Z][A-Z0-9]{1,7}-[0-9]{4}-[0-9]{3,6}:SCN-[a-z0-9]+(-[a-z0-9]+)*-[0-9]{3}$/,
13
+ };
14
+
15
+ const SCENARIO_PARTS =
16
+ /^(REQ-[A-Z][A-Z0-9]{1,7}-[0-9]{4}-[0-9]{3,6}):SCN-([a-z0-9]+(?:-[a-z0-9]+)*)-([0-9]{3})$/;
17
+
18
+ const SLUG_MAX_LEN = 40;
19
+ const SCENARIO_KEY_MAX_LEN = 120;
20
+ const SCN_SEQ_MAX = 999;
21
+
22
+ const CODES = {
23
+ FORMAT_INVALID: 'EXTERNAL_KEY_FORMAT_INVALID',
24
+ SCOPE_MISMATCH: 'EXTERNAL_KEY_SCOPE_MISMATCH',
25
+ FEATURE_UNDECLARED: 'EXTERNAL_KEY_FEATURE_UNDECLARED',
26
+ SEQ_REUSED: 'EXTERNAL_KEY_SEQ_REUSED',
27
+ WAIVER_ACTIVE: 'NUMBERING_WAIVER_ACTIVE',
28
+ };
29
+
30
+ function normalizeObjectType(objectType) {
31
+ if (objectType == null || String(objectType).trim() === '') return null;
32
+ return String(objectType).trim().toLowerCase();
33
+ }
34
+
35
+ function normalize(id, objectType) {
36
+ if (id == null) return null;
37
+ const trimmed = String(id).trim();
38
+ if (!trimmed) return trimmed;
39
+ const type = normalizeObjectType(objectType);
40
+ if (type === 'scenario') {
41
+ const colon = trimmed.indexOf(':');
42
+ if (colon < 0) return trimmed.toUpperCase();
43
+ const reqPart = trimmed.slice(0, colon).trim().toUpperCase();
44
+ const rest = trimmed.slice(colon + 1).trim();
45
+ if (/^scn-/i.test(rest)) {
46
+ const afterScn = rest.slice(4);
47
+ const lastDash = afterScn.lastIndexOf('-');
48
+ if (lastDash > 0) {
49
+ const slug = afterScn.slice(0, lastDash).toLowerCase();
50
+ const seq = afterScn.slice(lastDash + 1);
51
+ return `${reqPart}:SCN-${slug}-${seq}`;
52
+ }
53
+ }
54
+ return `${reqPart}:${rest.toLowerCase()}`;
55
+ }
56
+ return trimmed.toUpperCase();
57
+ }
58
+
59
+ function validate(id, objectType) {
60
+ const type = normalizeObjectType(objectType);
61
+ if (!type || !REGEX[type]) {
62
+ return {
63
+ ok: false,
64
+ code: CODES.FORMAT_INVALID,
65
+ message: `未知 object_type,无法校验编号: ${objectType}`,
66
+ };
67
+ }
68
+ const normalized = normalize(id, type);
69
+ if (!normalized) {
70
+ return { ok: false, code: CODES.FORMAT_INVALID, message: 'external_id 为空' };
71
+ }
72
+ if (!REGEX[type].test(normalized)) {
73
+ return {
74
+ ok: false,
75
+ code: CODES.FORMAT_INVALID,
76
+ message: `编号格式不合规 (${type}): ${id} → ${normalized}`,
77
+ };
78
+ }
79
+ if (type === 'scenario') {
80
+ const parts = parseScenario(normalized);
81
+ if (!parts) {
82
+ return {
83
+ ok: false,
84
+ code: CODES.FORMAT_INVALID,
85
+ message: `场景键无法解析: ${normalized}`,
86
+ };
87
+ }
88
+ if (parts.slug.length > SLUG_MAX_LEN) {
89
+ return {
90
+ ok: false,
91
+ code: CODES.FORMAT_INVALID,
92
+ message: `场景 SLUG 超过 ${SLUG_MAX_LEN} 字符: ${parts.slug}`,
93
+ };
94
+ }
95
+ if (normalized.length > SCENARIO_KEY_MAX_LEN) {
96
+ return {
97
+ ok: false,
98
+ code: CODES.FORMAT_INVALID,
99
+ message: `场景键总长超过 ${SCENARIO_KEY_MAX_LEN} 字符`,
100
+ };
101
+ }
102
+ }
103
+ return { ok: true, code: null, message: null, normalized };
104
+ }
105
+
106
+ function parseScenario(key) {
107
+ const normalized = normalize(key, 'scenario');
108
+ if (!normalized) return null;
109
+ const match = SCENARIO_PARTS.exec(normalized);
110
+ if (!match) return null;
111
+ return {
112
+ requirementId: match[1],
113
+ slug: match[2],
114
+ seq: Number(match[3]),
115
+ };
116
+ }
117
+
118
+ function composeScenario(reqId, slug, seq) {
119
+ const req = normalize(reqId, 'requirement');
120
+ const normalizedSlug = String(slug || '').trim().toLowerCase();
121
+ const n = Number(seq);
122
+ if (!Number.isInteger(n) || n < 1 || n > SCN_SEQ_MAX) {
123
+ throw new Error(`SCN 序号必须在 1..${SCN_SEQ_MAX} 之间: ${seq}`);
124
+ }
125
+ const composed = `${req}:SCN-${normalizedSlug}-${String(n).padStart(3, '0')}`;
126
+ const result = validate(composed, 'scenario');
127
+ if (!result.ok) throw new Error(result.message);
128
+ return composed;
129
+ }
130
+
131
+ function nextScenarioSeq(reqId, usedKeys) {
132
+ const req = normalize(reqId, 'requirement');
133
+ let max = 0;
134
+ for (const key of usedKeys || []) {
135
+ const parts = parseScenario(key);
136
+ if (!parts) continue;
137
+ if (parts.requirementId === req) {
138
+ max = Math.max(max, parts.seq);
139
+ }
140
+ }
141
+ if (max >= SCN_SEQ_MAX) {
142
+ throw new Error(`SCN 序号已用尽 (999),请回需求管理系统拆分需求: ${req}`);
143
+ }
144
+ return max + 1;
145
+ }
146
+
147
+ module.exports = {
148
+ REGEX,
149
+ CODES,
150
+ SLUG_MAX_LEN,
151
+ SCENARIO_KEY_MAX_LEN,
152
+ SCN_SEQ_MAX,
153
+ normalize,
154
+ validate,
155
+ parseScenario,
156
+ composeScenario,
157
+ nextScenarioSeq,
158
+ normalizeObjectType,
159
+ };
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const modules = require('./modules.cjs');
6
+ const { readProposalFrontmatter } = require('./naming-diagnose.cjs');
7
+
8
+ function listActiveChanges(projectRoot) {
9
+ const changesDir = path.join(projectRoot, 'openspec', 'changes');
10
+ if (!fs.existsSync(changesDir)) return [];
11
+
12
+ return fs.readdirSync(changesDir)
13
+ .filter((name) => {
14
+ if (name === 'archive' || name.startsWith('.')) return false;
15
+ const full = path.join(changesDir, name);
16
+ return fs.statSync(full).isDirectory();
17
+ })
18
+ .sort();
19
+ }
20
+
21
+ function describeChange(projectRoot, changeName, registry) {
22
+ const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
23
+ const proposal = readProposalFrontmatter(changeDir);
24
+ const fm = proposal.ok ? proposal.frontmatter : {};
25
+ const moduleCode = (fm.module || '').toLowerCase();
26
+ const title = fm.title || '';
27
+ const changeKey = (fm['change-key'] || '').toLowerCase() || changeName;
28
+ const affected = Array.isArray(fm['affected-modules']) ? fm['affected-modules'] : [];
29
+ const legacy = !fm.module && !fm.title && !fm['change-key'];
30
+
31
+ let groupCode = 'uncategorized';
32
+ let groupName = '未分类';
33
+ if (!legacy && moduleCode) {
34
+ groupCode = moduleCode;
35
+ groupName = modules.getModuleName(registry, moduleCode) || moduleCode;
36
+ }
37
+
38
+ return {
39
+ changeKey,
40
+ directory: changeName,
41
+ title: title || changeName,
42
+ module: moduleCode || null,
43
+ affectedModules: affected,
44
+ changeId: fm['change-id'] || null,
45
+ legacy,
46
+ groupCode,
47
+ groupName,
48
+ hasProposal: proposal.ok,
49
+ };
50
+ }
51
+
52
+ function groupChanges(projectRoot) {
53
+ const registry = modules.loadModulesYaml(projectRoot);
54
+ const names = listActiveChanges(projectRoot);
55
+ const items = names.map((name) => describeChange(projectRoot, name, registry.ok ? registry : null));
56
+
57
+ const groups = new Map();
58
+ for (const item of items) {
59
+ if (!groups.has(item.groupCode)) {
60
+ groups.set(item.groupCode, {
61
+ code: item.groupCode,
62
+ name: item.groupName,
63
+ changes: [],
64
+ });
65
+ }
66
+ groups.get(item.groupCode).changes.push(item);
67
+ }
68
+
69
+ const ordered = [];
70
+ if (registry.ok) {
71
+ for (const code of Object.keys(registry.modules)) {
72
+ if (groups.has(code)) ordered.push(groups.get(code));
73
+ }
74
+ }
75
+ for (const [code, group] of groups.entries()) {
76
+ if (!ordered.find((item) => item.code === code)) {
77
+ ordered.push(group);
78
+ }
79
+ }
80
+
81
+ return {
82
+ registryOk: registry.ok,
83
+ modulesPath: registry.path || path.join(projectRoot, 'modules.yaml'),
84
+ groups: ordered,
85
+ changes: items,
86
+ };
87
+ }
88
+
89
+ function formatGroupedText(result) {
90
+ const lines = [];
91
+ for (const group of result.groups) {
92
+ lines.push(`${group.name} (${group.code})`);
93
+ for (const change of group.changes) {
94
+ lines.push(` ● ${change.title}`);
95
+ lines.push(` ${change.changeKey}`);
96
+ if (change.module === 'cross' && change.affectedModules.length) {
97
+ lines.push(` affected: ${change.affectedModules.join(', ')}`);
98
+ }
99
+ }
100
+ lines.push('');
101
+ }
102
+ return lines.join('\n').trimEnd();
103
+ }
104
+
105
+ module.exports = {
106
+ listActiveChanges,
107
+ describeChange,
108
+ groupChanges,
109
+ formatGroupedText,
110
+ };