kld-sdd 2.7.8 → 2.8.0
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/README.md +26 -0
- package/USABILITY.md +72 -0
- package/bin/kld-sdd-init.js +718 -34
- package/lib/command-bridge.js +26 -17
- package/lib/deploy-codebuddy-hooks.js +19 -26
- package/lib/deploy-strategy.js +293 -0
- package/lib/hook-gate-core.js +7 -8
- package/lib/hook-install.js +75 -0
- package/lib/init.js +507 -726
- package/lib/maintenance.js +221 -0
- package/lib/managed-hook-hashes.json +24 -0
- package/lib/settings-merge.js +102 -25
- package/lib/skills-bundle.js +5 -5
- package/lib/spec-choice.js +85 -0
- package/lib/tool-profiles.js +2 -2
- package/lib/uninstall.js +453 -0
- package/package.json +7 -4
- package/skywalk-sdd/context-client.cjs +38 -7
- package/skywalk-sdd/index.cjs +153 -27
- package/skywalk-sdd/kb-upload.cjs +224 -82
- package/skywalk-sdd/lib/check-review.cjs +41 -0
- package/skywalk-sdd/lib/shared.cjs +62 -41
- package/skywalk-sdd/lib/test-execution.cjs +130 -0
- package/skywalk-sdd/lib/usage-reporter.cjs +17 -8
- package/skywalk-sdd/ontology/archive-package.cjs +141 -0
- package/skywalk-sdd/ontology/artifact-parser.cjs +24 -3
- package/skywalk-sdd/ontology/cli.cjs +53 -0
- package/skywalk-sdd/ontology/id.cjs +1 -1
- package/skywalk-sdd/ontology/identity-index.cjs +5 -1
- package/skywalk-sdd/ontology/resolve-spec-root.cjs +49 -0
- package/skywalk-sdd/ontology/schema.cjs +6 -0
- package/skywalk-sdd/ontology/sdd-config.cjs +33 -5
- package/skywalk-sdd/ontology/spec-detect.cjs +421 -0
- package/skywalk-sdd/ontology/spec-ontology.cjs +507 -0
- package/skywalk-sdd/ontology/workspace-layout.cjs +65 -17
- package/templates/git-hooks/commit-msg +17 -85
- package/templates/git-hooks/commit-msg-runner.cjs +44 -0
- package/templates/git-hooks/commit-msg-sdd-trailer.cjs +60 -69
- package/templates/git-hooks/consistency-check-core.cjs +92 -261
- package/templates/git-hooks/delivery-check.cjs +231 -0
- package/templates/git-hooks/delivery-snapshot.cjs +181 -0
- package/templates/git-hooks/hooks.config +8 -6
- package/templates/git-hooks/pre-commit +19 -5
- package/templates/git-hooks/pre-commit-consistency-check.cjs +13 -31
- package/templates/git-hooks/pre-push +19 -5
- package/templates/git-hooks/pre-push-consistency-check.cjs +36 -83
- package/templates/hooks/claude/hooks/sdd-post-tool.cjs +2 -2
- package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +7 -8
- package/templates/hooks/codebuddy/hooks/sdd-post-tool.cjs +2 -2
- package/templates/openspec/proposal.md +1 -1
- package/templates/openspec/spec.md +8 -0
- package/templates/skills/kld-sdd/opsx-apply/SKILL.md +28 -24
- package/templates/skills/kld-sdd/opsx-apply/checklist.md +12 -3
- package/templates/skills/kld-sdd/opsx-apply/reference.md +21 -1
- package/templates/skills/kld-sdd/opsx-archive/SKILL.md +20 -18
- package/templates/skills/kld-sdd/opsx-check/SKILL.md +18 -13
- package/templates/skills/kld-sdd/opsx-check/reference.md +3 -1
- package/templates/skills/kld-sdd/opsx-check/review-template.json +22 -0
- package/templates/skills/kld-sdd/opsx-check/reviewer.md +27 -6
- package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +34 -112
- package/templates/skills/kld-sdd/opsx-consistency-check/scripts/scripts.cjs +15 -14
- package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +82 -61
- package/templates/skills/kld-sdd/opsx-kb-config/reference.md +8 -11
- package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +3 -2
- package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +2 -2
- package/templates/skills/kld-sdd/opsx-ontology-ingest/SKILL.md +77 -0
- package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +5 -4
- package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +14 -1
- package/templates/skills/kld-sdd/opsx-propose/SKILL.md +1 -1
- package/templates/skills/kld-sdd/opsx-propose/interaction-policy.md +9 -0
- package/templates/skills/kld-sdd/opsx-spec/SKILL.md +5 -3
- package/templates/skills/kld-sdd/opsx-task/SKILL.md +2 -2
- package/index.js +0 -13
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { execFileSync } = require('child_process');
|
|
6
|
+
// spec 判据的唯一真相源(spec-detect 对本模块的依赖是惰性,不构成循环)
|
|
7
|
+
const specDetect = require('./spec-detect.cjs');
|
|
6
8
|
|
|
7
9
|
const CODES = {
|
|
8
10
|
MISSING: 'SDD_YAML_MISSING',
|
|
@@ -156,12 +158,18 @@ function resolveMonoSpecRoot(repoRoot) {
|
|
|
156
158
|
return root;
|
|
157
159
|
}
|
|
158
160
|
|
|
161
|
+
/**
|
|
162
|
+
* 目录内容是否"像 spec 包"(任一产物标记存在)。
|
|
163
|
+
*
|
|
164
|
+
* ⚠️ 判据已收敛到 `spec-detect.cjs#hasSpecPackageContent`(全仓唯一判据入口)。
|
|
165
|
+
* 本函数保留仅为向后兼容既有调用点。
|
|
166
|
+
*
|
|
167
|
+
* 语义:仅用于用户**显式给定**路径的校验(`validateSpecPath` 的
|
|
168
|
+
* allowNestedPackage 分支)。不可用于检测 —— 单仓代码仓根可能有 modules.yaml
|
|
169
|
+
* 而无 openspec,仅凭内容无法区分。
|
|
170
|
+
*/
|
|
159
171
|
function looksLikeSpecPackageContent(dir) {
|
|
160
|
-
return (
|
|
161
|
-
fs.existsSync(path.join(dir, 'modules.yaml'))
|
|
162
|
-
|| fs.existsSync(path.join(dir, 'sdd.config.yaml'))
|
|
163
|
-
|| fs.existsSync(path.join(dir, 'openspec', 'changes'))
|
|
164
|
-
);
|
|
172
|
+
return specDetect.hasSpecPackageContent(dir);
|
|
165
173
|
}
|
|
166
174
|
|
|
167
175
|
function gitConfig(cwd, key) {
|
|
@@ -176,6 +184,25 @@ function gitConfig(cwd, key) {
|
|
|
176
184
|
}
|
|
177
185
|
}
|
|
178
186
|
|
|
187
|
+
/**
|
|
188
|
+
* 删除 git config --local sdd.specPath(卸载用)。
|
|
189
|
+
* 配置项不存在时视为成功(幂等)。
|
|
190
|
+
*/
|
|
191
|
+
function unsetSpecPath(codeRepoRoot) {
|
|
192
|
+
if (!gitConfig(codeRepoRoot, 'sdd.specPath')) {
|
|
193
|
+
return { ok: true, removed: false };
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
execFileSync('git', ['config', '--local', '--unset', 'sdd.specPath'], {
|
|
197
|
+
cwd: codeRepoRoot,
|
|
198
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
199
|
+
});
|
|
200
|
+
} catch (error) {
|
|
201
|
+
return { ok: false, message: `无法删除 git config sdd.specPath: ${error.message}` };
|
|
202
|
+
}
|
|
203
|
+
return { ok: true, removed: true };
|
|
204
|
+
}
|
|
205
|
+
|
|
179
206
|
function gitRemoteUrl(cwd, remote = 'origin') {
|
|
180
207
|
try {
|
|
181
208
|
return execFileSync('git', ['remote', 'get-url', remote], {
|
|
@@ -374,6 +401,7 @@ module.exports = {
|
|
|
374
401
|
resolveSpecPath,
|
|
375
402
|
validateSpecPath,
|
|
376
403
|
setSpecPath,
|
|
404
|
+
unsetSpecPath,
|
|
377
405
|
resolveCodeRepoContext,
|
|
378
406
|
gitHeadSha,
|
|
379
407
|
gitWorkingTreeClean,
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* spec 目录识别 —— **唯一判据入口**。
|
|
5
|
+
*
|
|
6
|
+
* 本模块只回答两个问题,不负责定位(定位在 resolve-spec-root.cjs):
|
|
7
|
+
*
|
|
8
|
+
* 1. 「这个目录是不是 spec 包?」 → classifySpecDir / isSpecCandidate
|
|
9
|
+
* 2. 「候选有哪些、优先级如何?」 → detectSpecCandidates
|
|
10
|
+
*
|
|
11
|
+
* 为什么需要分级而不是单一布尔值:两类调用方的松紧要求本来就不同。
|
|
12
|
+
*
|
|
13
|
+
* - **检测**("这个目录是不是当前项目的 spec 仓")→ 严,避免误报。
|
|
14
|
+
* 例:`sddmult-sdd-specs` 是个只有 .git 的空壳,若仅凭目录名就认定,
|
|
15
|
+
* 会被当成 spec 包采纳(init 往里灌内容、sync-repos 把它的路径写进代码仓
|
|
16
|
+
* git config)。故名称判定必须附带"至少一项 SDD 产物"。
|
|
17
|
+
* - **校验**("用户显式给的路径能不能当 spec 用")→ 宽,接受半成品。
|
|
18
|
+
* 例:`--skip-openspec` 时 `openspec/changes` 永不创建,四结构必然不齐,
|
|
19
|
+
* 但包裹包是合法的。
|
|
20
|
+
*
|
|
21
|
+
* 因此不是"同一个布尔值到处用",而是**同一个函数返回等级,调用方按需取阈值**。
|
|
22
|
+
*
|
|
23
|
+
* 等级(tier 越小优先级越高):
|
|
24
|
+
*
|
|
25
|
+
* | tier | 来源 | 判据 |
|
|
26
|
+
* |------|-----------------|---------------------------------------------------|
|
|
27
|
+
* | 0 | 显式声明 | `.sdd-spec-root` / `git config` / `.sdd.yaml mono` |
|
|
28
|
+
* | | | / `.sdd-workspace.yaml spec_path` |
|
|
29
|
+
* | 1 | 四结构齐全 | modules.yaml + sdd.config.yaml + openspec/changes |
|
|
30
|
+
* | | | + openspec/specs/overview.md |
|
|
31
|
+
* | 2 | 目录名 | `*-sdd-specs`(**含预填空壳**:用户先建目录后填充) |
|
|
32
|
+
* | — | 非候选 | 无结构证据且目录名不匹配 |
|
|
33
|
+
*
|
|
34
|
+
* 依赖方向:本模块是叶子节点,加载期不 require 任何其它 ontology 模块
|
|
35
|
+
* (需要 sdd-config / workspace-layout 时在函数内惰性 require),
|
|
36
|
+
* 因此 workspace-layout / resolve-spec-root / sdd-config 都可以安全依赖它。
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const fs = require('fs');
|
|
40
|
+
const path = require('path');
|
|
41
|
+
|
|
42
|
+
/** tier 常量 */
|
|
43
|
+
const TIER = Object.freeze({
|
|
44
|
+
/** 显式声明:.sdd-spec-root / git config sdd.specPath / .sdd.yaml layout: mono */
|
|
45
|
+
DECLARED: 0,
|
|
46
|
+
/** 四结构齐全 */
|
|
47
|
+
STRUCTURE: 1,
|
|
48
|
+
/** 目录名 `*-sdd-specs`(含预填空壳) */
|
|
49
|
+
NAME: 2,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 四结构标记 —— tier 1 判据,**四项必须同时满足**。
|
|
54
|
+
*
|
|
55
|
+
* 与"名称判定"的关键区别:四结构不看目录名,因此用户自定义命名的包裹包
|
|
56
|
+
* (如 `erp-specs`)只要内容齐全也能被识别。
|
|
57
|
+
*/
|
|
58
|
+
const SPEC_STRUCTURE_MARKS = Object.freeze([
|
|
59
|
+
'modules.yaml',
|
|
60
|
+
'sdd.config.yaml',
|
|
61
|
+
'openspec/changes',
|
|
62
|
+
'openspec/specs/overview.md',
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 半成品标记 —— 用于「显式给定路径」的宽松校验(任一满足即可)。
|
|
67
|
+
*
|
|
68
|
+
* 为什么比四结构宽:`--skip-openspec` 下 `openspec/changes` 永不创建
|
|
69
|
+
*(openspec init 是唯一创建者),但包裹包合法。此判据只用于
|
|
70
|
+
* `validateSpecPath({ allowNestedPackage: true })` —— 路径是用户显式给的,
|
|
71
|
+
* 只需确认"内容像 spec 包",无需确认"是不是当前项目的 spec 仓"。
|
|
72
|
+
*/
|
|
73
|
+
const SPEC_ARTIFACT_MARKS = Object.freeze([
|
|
74
|
+
'modules.yaml',
|
|
75
|
+
'sdd.config.yaml',
|
|
76
|
+
'openspec/changes',
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 候选来源的中文标签(供 init / uninstall / sync-repos 展示,唯一真相源)。
|
|
81
|
+
*
|
|
82
|
+
* **来源表示「怎么找到的」,判据强度由候选的 `tier` 字段承载**:
|
|
83
|
+
* - hint / git / mono 显式声明(权威)
|
|
84
|
+
* - workspace 工作区探测(子仓目录被识别为 spec 包)
|
|
85
|
+
* - scan 裸目录扫描命中(结构或命名,具体见 `evidence`)
|
|
86
|
+
*
|
|
87
|
+
* ⚠️ 历史文案 `workspace: '工作区探测:子仓结构判据命中'` 有误 —— 该来源
|
|
88
|
+
* 实际也可能由**目录名** `*-sdd-specs` 命中(空壳子仓即如此)。故标签不再
|
|
89
|
+
* 声称具体判据,避免与 `tier` 冲突。
|
|
90
|
+
*/
|
|
91
|
+
const SPEC_SOURCE_LABELS = Object.freeze({
|
|
92
|
+
hint: '权威:.sdd-spec-root 指向',
|
|
93
|
+
git: '权威:git config sdd.specPath',
|
|
94
|
+
mono: '权威:.sdd.yaml layout: mono',
|
|
95
|
+
'workspace-yaml': '权威:.sdd-workspace.yaml spec_path',
|
|
96
|
+
scan: '推测:目录内容或命名命中',
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/** tier 内来源优先级(数值越小越靠前) */
|
|
100
|
+
const SOURCE_RANK = Object.freeze({
|
|
101
|
+
hint: 0,
|
|
102
|
+
git: 1,
|
|
103
|
+
mono: 2,
|
|
104
|
+
'workspace-yaml': 3,
|
|
105
|
+
scan: 4,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 目录名是否符合 `*-sdd-specs` 命名约定。
|
|
110
|
+
*
|
|
111
|
+
* 单独导出是因为「名称命中」与「结构命中」是两个不同强度的证据,
|
|
112
|
+
* 调用方有时需要区分(如提示文案、诊断)。
|
|
113
|
+
*
|
|
114
|
+
* @param {string} dir
|
|
115
|
+
* @returns {boolean}
|
|
116
|
+
*/
|
|
117
|
+
function isSpecDirName(dir) {
|
|
118
|
+
if (!dir) return false;
|
|
119
|
+
const name = path.basename(path.resolve(dir)).toLowerCase();
|
|
120
|
+
return /-sdd-specs$/.test(name) || name.endsWith('sdd-specs');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 列出实际存在的标记(相对路径用 posix 形式,便于展示与断言) */
|
|
124
|
+
function listExistingMarks(dir, marks) {
|
|
125
|
+
return marks.filter((mark) => {
|
|
126
|
+
try {
|
|
127
|
+
return fs.existsSync(path.join(dir, ...mark.split('/')));
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 对目录做分级判定 —— **本模块的核心,全仓唯一的 spec 判据**。
|
|
136
|
+
*
|
|
137
|
+
* @param {string} dir 候选目录
|
|
138
|
+
* @returns {{tier: number, source: 'structure'|'name', evidence: string[]}|null}
|
|
139
|
+
* null 表示不是 spec 候选
|
|
140
|
+
*/
|
|
141
|
+
function classifySpecDir(dir) {
|
|
142
|
+
if (!dir) return null;
|
|
143
|
+
const abs = path.resolve(dir);
|
|
144
|
+
let st;
|
|
145
|
+
try {
|
|
146
|
+
st = fs.statSync(abs);
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
if (!st.isDirectory()) return null;
|
|
151
|
+
|
|
152
|
+
// tier 1:四结构齐全(不看目录名 —— 支持用户自定义命名)
|
|
153
|
+
const structure = listExistingMarks(abs, SPEC_STRUCTURE_MARKS);
|
|
154
|
+
if (structure.length === SPEC_STRUCTURE_MARKS.length) {
|
|
155
|
+
return { tier: TIER.STRUCTURE, source: 'structure', evidence: structure };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// tier 2:目录名命中。**空壳(evidence 为空)仍是候选** ——
|
|
159
|
+
// 典型场景是用户预建目录待填充;由调用方按 tier 排序决定取舍。
|
|
160
|
+
if (!isSpecDirName(abs)) return null;
|
|
161
|
+
return {
|
|
162
|
+
tier: TIER.NAME,
|
|
163
|
+
source: 'name',
|
|
164
|
+
evidence: listExistingMarks(abs, SPEC_ARTIFACT_MARKS),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 严格判据:是否可作为 spec 候选(检测用)。
|
|
170
|
+
*
|
|
171
|
+
* 与历史 `looksLikeSpecRepo` 语义等价 —— 名称命中**或**四结构齐全。
|
|
172
|
+
* 保留"名称即命中"是刻意的:空壳 `*-sdd-specs` 是预填场景,必须入选。
|
|
173
|
+
*
|
|
174
|
+
* @param {string} dir
|
|
175
|
+
* @returns {boolean}
|
|
176
|
+
*/
|
|
177
|
+
function isSpecCandidate(dir) {
|
|
178
|
+
return classifySpecDir(dir) !== null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 校验判据:目录是否可作为 kb-state.json 的宿主 / spec 根。
|
|
183
|
+
*
|
|
184
|
+
* 比 `isSpecCandidate` 多一条 `openspec/` 兜底 —— 半成品包裹包
|
|
185
|
+
* (`--skip-openspec` 产物,只有 openspec/ 目录树)也是合法的 spec 根。
|
|
186
|
+
*
|
|
187
|
+
* @param {string} dir
|
|
188
|
+
* @returns {boolean}
|
|
189
|
+
*/
|
|
190
|
+
function isSpecPackageRoot(dir) {
|
|
191
|
+
if (!dir) return false;
|
|
192
|
+
const abs = path.resolve(dir);
|
|
193
|
+
if (classifySpecDir(abs)) return true;
|
|
194
|
+
try {
|
|
195
|
+
return fs.existsSync(path.join(abs, 'openspec'));
|
|
196
|
+
} catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 宽松判据:目录内容是否"像 spec 包"(任一产物标记存在)。
|
|
203
|
+
*
|
|
204
|
+
* 仅用于用户**显式给定**路径的校验(`validateSpecPath` 的 allowNestedPackage)。
|
|
205
|
+
* 不可用于检测 —— 单仓代码仓根可能有 modules.yaml 而无 openspec。
|
|
206
|
+
*
|
|
207
|
+
* @param {string} dir
|
|
208
|
+
* @returns {boolean}
|
|
209
|
+
*/
|
|
210
|
+
function hasSpecPackageContent(dir) {
|
|
211
|
+
if (!dir) return false;
|
|
212
|
+
const abs = path.resolve(dir);
|
|
213
|
+
try {
|
|
214
|
+
if (!fs.statSync(abs).isDirectory()) return false;
|
|
215
|
+
} catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
return listExistingMarks(abs, SPEC_ARTIFACT_MARKS).length > 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 向上逐级读取 `.sdd-spec-root` 指针(内容为相对路径) */
|
|
222
|
+
function resolveHintUpward(anchorRoot) {
|
|
223
|
+
let current = path.resolve(anchorRoot);
|
|
224
|
+
const stop = path.parse(current).root;
|
|
225
|
+
for (;;) {
|
|
226
|
+
const hintPath = path.join(current, '.sdd-spec-root');
|
|
227
|
+
if (fs.existsSync(hintPath)) {
|
|
228
|
+
try {
|
|
229
|
+
const rel = String(fs.readFileSync(hintPath, 'utf8') || '')
|
|
230
|
+
.trim()
|
|
231
|
+
.split(/\r?\n/)[0]
|
|
232
|
+
.trim();
|
|
233
|
+
if (rel) {
|
|
234
|
+
const abs = path.resolve(current, rel);
|
|
235
|
+
if (fs.existsSync(abs)) return abs;
|
|
236
|
+
}
|
|
237
|
+
} catch { /* 指针损坏:继续向上,不阻断 */ }
|
|
238
|
+
}
|
|
239
|
+
if (current === stop) break;
|
|
240
|
+
current = path.dirname(current);
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 向上逐级查找 `.sdd.yaml` 的 `layout: mono` 声明 */
|
|
246
|
+
function resolveMonoUpward(anchorRoot) {
|
|
247
|
+
let current = path.resolve(anchorRoot);
|
|
248
|
+
const stop = path.parse(current).root;
|
|
249
|
+
for (;;) {
|
|
250
|
+
try {
|
|
251
|
+
const sddConfig = require('./sdd-config.cjs');
|
|
252
|
+
if (sddConfig.isMonoLayout(current)) {
|
|
253
|
+
const resolved = sddConfig.resolveMonoSpecRoot(current);
|
|
254
|
+
if (resolved && fs.existsSync(resolved)) return resolved;
|
|
255
|
+
}
|
|
256
|
+
} catch { /* sdd-config 不可用时跳过该来源 */ }
|
|
257
|
+
if (current === stop) break;
|
|
258
|
+
current = path.dirname(current);
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 向上逐级查找 `.sdd-workspace.yaml` 的 `spec_path` 声明(多仓布局)。
|
|
265
|
+
*
|
|
266
|
+
* 与 `resolveMonoUpward` 的区别:`.sdd.yaml layout: mono` 描述**单仓内**的包裹包,
|
|
267
|
+
* `.sdd-workspace.yaml` 描述**多仓工作区**的编排(spec 是兄弟目录的独立 Git 仓)。
|
|
268
|
+
* 二者都是显式声明,属权威来源。
|
|
269
|
+
*
|
|
270
|
+
* 解析委托 `workspace-layout.parseWorkspaceYaml`(唯一解析实现),避免此处
|
|
271
|
+
* 再写一遍正则 —— 历史上 skills 的 `opsx-consistency-check/scripts.cjs` 就是
|
|
272
|
+
* 自己解析该文件,导致「权威链不认这个来源、技能自己认」的不一致。
|
|
273
|
+
*
|
|
274
|
+
* @param {string} anchorRoot 搜索起点
|
|
275
|
+
* @returns {string|null} spec 根绝对路径;未声明 / 指向不存在时 null
|
|
276
|
+
*/
|
|
277
|
+
function resolveWorkspaceYamlUpward(anchorRoot) {
|
|
278
|
+
let current = path.resolve(anchorRoot);
|
|
279
|
+
const stop = path.parse(current).root;
|
|
280
|
+
for (;;) {
|
|
281
|
+
try {
|
|
282
|
+
const workspaceLayout = require('./workspace-layout.cjs');
|
|
283
|
+
const loaded = workspaceLayout.loadWorkspaceFile(current);
|
|
284
|
+
if (loaded.ok && loaded.data && loaded.data.spec_path) {
|
|
285
|
+
// spec_path 相对清单所在目录解析(清单是工作区级文件)
|
|
286
|
+
const abs = path.resolve(current, loaded.data.spec_path);
|
|
287
|
+
if (fs.existsSync(abs)) return abs;
|
|
288
|
+
}
|
|
289
|
+
} catch { /* 解析异常:继续向上,不阻断 */ }
|
|
290
|
+
if (current === stop) break;
|
|
291
|
+
current = path.dirname(current);
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* 枚举 spec 候选集合,按优先级排序。
|
|
298
|
+
*
|
|
299
|
+
* 这是 init / uninstall / sync-repos 三个 CLI 场景**共用**的入口:
|
|
300
|
+
* 三处都要求「多候选时让用户单选」,故必须返回集合而非单值。
|
|
301
|
+
*
|
|
302
|
+
* 来源与 tier:
|
|
303
|
+
* - hint / git / mono → tier 0(显式声明,权威)
|
|
304
|
+
* - 子仓 / 子目录 / 根自身命中 → tier 1(四结构)或 tier 2(名称)
|
|
305
|
+
*
|
|
306
|
+
* 排序:tier 升序 → 来源优先级 → 名称升序(保证输出稳定、可断言)。
|
|
307
|
+
*
|
|
308
|
+
* @param {string} anchorRoot 探测起点(工作目录 / 工作区根)
|
|
309
|
+
* @param {{gitSpecPath?: string, includeRoot?: boolean}} [options]
|
|
310
|
+
* gitSpecPath 由调用方读取后传入,避免本模块反向依赖 sdd-config 的 git 逻辑
|
|
311
|
+
* @returns {Array<{abs: string, name: string, source: string, tier: number, evidence: string[]}>}
|
|
312
|
+
*/
|
|
313
|
+
function detectSpecCandidates(anchorRoot, options = {}) {
|
|
314
|
+
const root = path.resolve(anchorRoot || process.cwd());
|
|
315
|
+
const includeRoot = options.includeRoot !== false;
|
|
316
|
+
const out = [];
|
|
317
|
+
const seen = new Set();
|
|
318
|
+
|
|
319
|
+
const push = (candidate, source, tier, evidence) => {
|
|
320
|
+
if (!candidate) return;
|
|
321
|
+
const abs = path.resolve(candidate);
|
|
322
|
+
if (seen.has(abs)) return;
|
|
323
|
+
let st;
|
|
324
|
+
try {
|
|
325
|
+
st = fs.statSync(abs);
|
|
326
|
+
} catch {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (!st.isDirectory()) return;
|
|
330
|
+
seen.add(abs);
|
|
331
|
+
out.push({
|
|
332
|
+
abs,
|
|
333
|
+
name: path.basename(abs),
|
|
334
|
+
source,
|
|
335
|
+
tier,
|
|
336
|
+
evidence: evidence || [],
|
|
337
|
+
});
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// ── tier 0:显式声明(权威)──────────────────────────────
|
|
341
|
+
push(resolveHintUpward(root), 'hint', TIER.DECLARED, ['.sdd-spec-root']);
|
|
342
|
+
push(options.gitSpecPath, 'git', TIER.DECLARED, ['git config sdd.specPath']);
|
|
343
|
+
push(resolveMonoUpward(root), 'mono', TIER.DECLARED, ['.sdd.yaml layout: mono']);
|
|
344
|
+
push(resolveWorkspaceYamlUpward(root), 'workspace-yaml', TIER.DECLARED,
|
|
345
|
+
['.sdd-workspace.yaml spec_path']);
|
|
346
|
+
|
|
347
|
+
// ── tier 1/2:结构 / 名称判据扫描(来源统一记 'scan')──────
|
|
348
|
+
// 判据强度由 `tier` 承载,来源只表示「怎么找到的」,不表示判据类型 ——
|
|
349
|
+
// 否则 UI 无法解释「同一来源却强度不同」。具体命中判据见 `evidence`。
|
|
350
|
+
const pushClassified = (dir) => {
|
|
351
|
+
const classified = classifySpecDir(dir);
|
|
352
|
+
if (!classified) return;
|
|
353
|
+
push(dir, 'scan', classified.tier, classified.evidence);
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
// 根自身
|
|
357
|
+
if (includeRoot) pushClassified(root);
|
|
358
|
+
|
|
359
|
+
// 一级子目录(含非 Git 目录 —— 单仓包裹包不是独立 Git)
|
|
360
|
+
try {
|
|
361
|
+
const workspaceLayout = require('./workspace-layout.cjs');
|
|
362
|
+
for (const child of workspaceLayout.listChildDirectories(root)) {
|
|
363
|
+
pushClassified(child.abs);
|
|
364
|
+
}
|
|
365
|
+
} catch { /* workspace-layout 不可用时退化为仅根目录 */ }
|
|
366
|
+
|
|
367
|
+
out.sort((a, b) => {
|
|
368
|
+
if (a.tier !== b.tier) return a.tier - b.tier;
|
|
369
|
+
const rankA = SOURCE_RANK[a.source] ?? 99;
|
|
370
|
+
const rankB = SOURCE_RANK[b.source] ?? 99;
|
|
371
|
+
if (rankA !== rankB) return rankA - rankB;
|
|
372
|
+
return a.name.localeCompare(b.name);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
return out;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* 枚举候选集合,并自动读取 `git config --local sdd.specPath` 作为 tier 0 来源。
|
|
380
|
+
*
|
|
381
|
+
* 这是 CLI 三个场景(init / uninstall / sync-repos)**共用的唯一枚举入口**。
|
|
382
|
+
* 历史上 init 与 uninstall 各写了一份完全相同的实现(都只是「读 git config
|
|
383
|
+
* 后调 listSpecCandidates」),本函数把它收敛为一处。
|
|
384
|
+
*
|
|
385
|
+
* 为什么 git 读取放在这里而不是让调用方传入:读取逻辑需要 sdd-config,
|
|
386
|
+
* 而 sdd-config 顶层 require 本模块 —— 放在函数内惰性 require 可避免加载期
|
|
387
|
+
* 循环依赖(本模块顶层只依赖 fs/path)。
|
|
388
|
+
*
|
|
389
|
+
* @param {string} anchorRoot 探测起点(工作目录 / 工作区根)
|
|
390
|
+
* @returns {Array<{abs: string, name: string, source: string, tier: number, evidence: string[]}>}
|
|
391
|
+
*/
|
|
392
|
+
function detectSpecCandidatesWithGit(anchorRoot) {
|
|
393
|
+
const root = path.resolve(anchorRoot || process.cwd());
|
|
394
|
+
let gitSpecPath = '';
|
|
395
|
+
try {
|
|
396
|
+
gitSpecPath = require('./sdd-config.cjs').gitConfig(root, 'sdd.specPath') || '';
|
|
397
|
+
} catch { /* 非 Git 仓或 git 不可用:跳过该来源 */ }
|
|
398
|
+
return detectSpecCandidates(root, { gitSpecPath });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** 取候选来源的中文标签;未知来源原样返回 */
|
|
402
|
+
function specSourceLabel(source) {
|
|
403
|
+
return SPEC_SOURCE_LABELS[source] || source || '未知来源';
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
module.exports = {
|
|
407
|
+
TIER,
|
|
408
|
+
SPEC_STRUCTURE_MARKS,
|
|
409
|
+
SPEC_ARTIFACT_MARKS,
|
|
410
|
+
SPEC_SOURCE_LABELS,
|
|
411
|
+
SOURCE_RANK,
|
|
412
|
+
isSpecDirName,
|
|
413
|
+
classifySpecDir,
|
|
414
|
+
isSpecCandidate,
|
|
415
|
+
isSpecPackageRoot,
|
|
416
|
+
hasSpecPackageContent,
|
|
417
|
+
resolveWorkspaceYamlUpward,
|
|
418
|
+
detectSpecCandidates,
|
|
419
|
+
detectSpecCandidatesWithGit,
|
|
420
|
+
specSourceLabel,
|
|
421
|
+
};
|