kld-sdd 2.7.8-1 → 2.7.8-2
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/USABILITY.md +20 -0
- package/bin/kld-sdd-init.js +1 -1
- package/lib/init.js +7 -0
- package/package.json +2 -2
- package/skywalk-sdd/index.cjs +123 -24
- package/skywalk-sdd/lib/check-review.cjs +41 -0
- package/skywalk-sdd/lib/test-execution.cjs +110 -0
- package/templates/git-hooks/commit-msg +19 -5
- package/templates/git-hooks/consistency-check-core.cjs +31 -21
- package/templates/git-hooks/pre-commit +19 -5
- package/templates/git-hooks/pre-push +19 -5
- package/templates/hooks/claude/hooks/sdd-post-tool.cjs +2 -2
- package/templates/hooks/codebuddy/hooks/sdd-post-tool.cjs +2 -2
- package/templates/skills/kld-sdd/opsx-apply/SKILL.md +1 -0
- package/templates/skills/kld-sdd/opsx-apply/reference.md +19 -1
- package/templates/skills/kld-sdd/opsx-archive/SKILL.md +10 -8
- package/templates/skills/kld-sdd/opsx-check/SKILL.md +7 -5
- 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 +5 -5
- package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +29 -154
- package/templates/skills/kld-sdd/opsx-kb-config/reference.md +8 -11
- package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +2 -2
- package/templates/skills/kld-sdd/opsx-propose/SKILL.md +1 -1
- package/templates/skills/kld-sdd/opsx-propose/interaction-policy.md +7 -0
- package/templates/skills/kld-sdd/opsx-task/SKILL.md +2 -2
package/USABILITY.md
CHANGED
|
@@ -82,3 +82,23 @@ SDD 应帮助团队留下三类东西:明确的业务约定、能够证明实
|
|
|
82
82
|
新增回归覆盖 Full/Simple 结果等价、正文增改删后的重新校验、跨 Change 身份冲突、strict profile、结束时重新解析、strict JSON 记录拒绝占位值,以及八种 Agent 的参考文件渲染。消费方需要重新安装/更新技能和 skywalk-sdd 运行时才能使用新参数;只更新技能文件会导致旧运行时缺少合并输出。
|
|
83
83
|
|
|
84
84
|
验证结果(2026-09-08,本次 Check 优化):完整 `npm test` 通过,包含新增 6 组 Check 回归与全部既有用例;`npm run bench:check` 的 7 轮结论与任务计数等价断言全部通过。
|
|
85
|
+
|
|
86
|
+
## 2026-09-10:真实执行与故障恢复
|
|
87
|
+
|
|
88
|
+
本次承接已发布的 `2.7.8-1`,继续提交在 `codex/sdd-usability`。以下新增能力尚未重新发布 npm;原有身份、入库协议、七种 Skill 使用上报保持兼容。
|
|
89
|
+
|
|
90
|
+
- `kld-sdd log` 修正为实际打包的 `index.cjs` 入口。strict 记录失败会保留 details 文件,成功持久化后才清理。
|
|
91
|
+
- `test-run` 执行真实命令,保存退出码、耗时、原始 stdout/stderr 及 SHA-256。当前自动识别 Node TAP/原生测试摘要;未识别的 runner 仍保存输出,标记计数未知,不能自动完成任务。
|
|
92
|
+
- `test-run --completion-file` 为明确声明的任务关联实际测试事件并同步 checkbox。命令执行成功不等于业务覆盖充分,任务与测试的对应关系仍需评审核验;缺少测试、路径错误、超时不能记成有效 RED。
|
|
93
|
+
- `check-record` 接收实际检查项与真实评审身份,自动计算分类统计、任务缺口和结果,可通过 `--end-event-id` 一次记录并结束。按预检 profile 重新核对正文和历史身份;变化时拒绝旧评审。结束阶段复用本次刚完成的校验,避免再跑一次同样的对账。
|
|
94
|
+
- Archive 在启动归档阶段前运行 doctor;终态通知独立记为 `run_end`,旧 `stage_end + run_ended` 在计算时兼容归一化,避免虚增阶段或孤儿日志。通知保留,原始旧日志不改写。
|
|
95
|
+
- 修复进程锁发布窗口:完整锁文件原子发布,其他进程不能把尚未写完的锁当作失效锁抢走。
|
|
96
|
+
- 文档按实际业务决策与验收行为组织,不为填模板反复复制背景、堆空章节或按每个参数值拆一次任务。
|
|
97
|
+
|
|
98
|
+
详细参数与示例在 [Apply 参考](templates/skills/kld-sdd/opsx-apply/reference.md) 和 [Check 指南](templates/skills/kld-sdd/opsx-check/SKILL.md)。新命令需要更新运行时和 Skills,旧 `record --strict` 仍可使用。
|
|
99
|
+
|
|
100
|
+
验证:完整 `npm test` 通过;新增 19 个真实执行/恢复回归,涵盖测试输出篡改、零测试、超时、RED、任务关联、旧评审拒绝、单次最终复核、真实归档和六进程竞争。打包及初始化器均部署两个新模块,安装后的真实入口也有回归覆盖。预检基准七轮中位数由 294.6 ms 降至 156.3 ms,减少 46.9%;它仅测程序预检,不代表模型评审总耗时。
|
|
101
|
+
|
|
102
|
+
本机独立后端联调确认七种 Skill 的使用事件均成功写入,断网事件保留并恢复补报,服务端统计为 8 次、覆盖 7 阶段。使用统计是独立的 usage 事件,不等于测试通过、归档完成或 Spec 已入库;当前服务端按提交次数计数,网络响应丢失后的重复提交不提供 exactly-once 保证。
|
|
103
|
+
|
|
104
|
+
配置 Skill 与参考同步去除默认首目标、截短 UUID 和旧监控项目绑定步骤,修正 KB API 字段映射;切换查询目标不再引导改写团队项目身份,权限失败也不清空其他目标可能仍有效的密钥。
|
package/bin/kld-sdd-init.js
CHANGED
|
@@ -16,7 +16,7 @@ const args = process.argv.slice(2);
|
|
|
16
16
|
|
|
17
17
|
if (args[0] === 'log') {
|
|
18
18
|
// Telemetry CLI 模式
|
|
19
|
-
require('../skywalk-sdd/index.
|
|
19
|
+
require('../skywalk-sdd/index.cjs').main();
|
|
20
20
|
} else if (args[0] === 'link-spec') {
|
|
21
21
|
const sddConfig = require('../skywalk-sdd/ontology/sdd-config.cjs');
|
|
22
22
|
const pathArg = args.find((item) => item.startsWith('--path='));
|
package/lib/init.js
CHANGED
|
@@ -1018,6 +1018,13 @@ function deployTelemetryDataDir(targetCwd = process.cwd()) {
|
|
|
1018
1018
|
if (!fs.existsSync(sharedLibDir)) {
|
|
1019
1019
|
fs.mkdirSync(sharedLibDir, { recursive: true });
|
|
1020
1020
|
}
|
|
1021
|
+
for (const name of ['test-execution.cjs', 'check-review.cjs']) {
|
|
1022
|
+
const src = path.join(pkgPath, 'skywalk-sdd', 'lib', name);
|
|
1023
|
+
if (!fs.existsSync(src)) throw new Error(`E_INIT_MISSING_CRITICAL: lib/${name} 源文件缺失`);
|
|
1024
|
+
fs.copyFileSync(src, path.join(sharedLibDir, name));
|
|
1025
|
+
deployedModules += 1;
|
|
1026
|
+
vlog(` ✓ 部署 skywalk-sdd/lib/${name}`);
|
|
1027
|
+
}
|
|
1021
1028
|
const sharedSrc = path.join(pkgPath, 'skywalk-sdd', 'lib', 'shared.cjs');
|
|
1022
1029
|
const sharedDst = path.join(sharedLibDir, 'shared.cjs');
|
|
1023
1030
|
if (fs.existsSync(sharedSrc)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kld-sdd",
|
|
3
|
-
"version": "2.7.8-
|
|
3
|
+
"version": "2.7.8-2",
|
|
4
4
|
"description": "KLD SDD OpenSpec 项目初始化工具 - 一键部署 SDD skills",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"bench:check": "node test/benchmark-check.cjs",
|
|
12
|
-
"test": "node --test test/kb-fact-lifecycle.test.cjs test/workflow-usability.test.cjs test/check-efficiency.test.cjs && node test/external-key.cjs && node test/change-key.cjs && node test/modules-and-sdd-config.cjs && node test/active-changes.cjs && node test/hook-layouts.cjs && node test/spec-package-init.cjs && node test/prompt-spec-path.cjs && node test/workspace-layout.cjs && node test/ontology-release-blockers.cjs && node test/ontology-semantic-core.cjs && node test/ontology-identity-versioning.cjs && node test/ontology-identity-continuity.cjs && node test/ontology-state-transaction.cjs && node test/ontology-process-concurrency.cjs && node test/ontology-observer-convergence.cjs && node test/ontology-working-runtime.cjs && node test/ontology-stage-materialization.cjs && node test/ontology-template-contract.cjs && node test/ontology-cli-archive.cjs && node test/archive-package-producer.cjs && node --test test/evidence-integrity-oracle.cjs test/evidence-integrity-wiring.cjs test/change-report-correctness.cjs test/report-metrics-correctness.cjs test/change-report-model-ui.cjs && node test/validate-skills-bundle.cjs && node test/tool-profiles.cjs && node test/settings-merge.cjs && node test/command-bridge.cjs && node test/codebuddy-hooks.cjs && node test/skill-content-contract.cjs && node test/init-agent-profiles.cjs && node test/init-report-runtime.cjs && node test/init-output-verbosity.cjs && node --test test/usage-contract.test.cjs test/user-config.test.cjs test/usage-runtime-removal.test.cjs skywalk-sdd/usage-reporter.test.cjs skywalk-sdd/usage-reporting.test.cjs && node test/progress-package-artifact.test.cjs && node --test test/progress-package-install.e2e.cjs && node test/consistency-check-core.cjs"
|
|
12
|
+
"test": "node --test test/execution-friction.test.cjs test/kb-fact-lifecycle.test.cjs test/workflow-usability.test.cjs test/check-efficiency.test.cjs && node test/external-key.cjs && node test/change-key.cjs && node test/modules-and-sdd-config.cjs && node test/active-changes.cjs && node test/hook-layouts.cjs && node test/spec-package-init.cjs && node test/prompt-spec-path.cjs && node test/workspace-layout.cjs && node test/ontology-release-blockers.cjs && node test/ontology-semantic-core.cjs && node test/ontology-identity-versioning.cjs && node test/ontology-identity-continuity.cjs && node test/ontology-state-transaction.cjs && node test/ontology-process-concurrency.cjs && node test/ontology-observer-convergence.cjs && node test/ontology-working-runtime.cjs && node test/ontology-stage-materialization.cjs && node test/ontology-template-contract.cjs && node test/ontology-cli-archive.cjs && node test/archive-package-producer.cjs && node --test test/evidence-integrity-oracle.cjs test/evidence-integrity-wiring.cjs test/change-report-correctness.cjs test/report-metrics-correctness.cjs test/change-report-model-ui.cjs && node test/validate-skills-bundle.cjs && node test/tool-profiles.cjs && node test/settings-merge.cjs && node test/command-bridge.cjs && node test/codebuddy-hooks.cjs && node test/skill-content-contract.cjs && node test/init-agent-profiles.cjs && node test/init-report-runtime.cjs && node test/init-output-verbosity.cjs && node --test test/usage-contract.test.cjs test/user-config.test.cjs test/usage-runtime-removal.test.cjs skywalk-sdd/usage-reporter.test.cjs skywalk-sdd/usage-reporting.test.cjs && node test/progress-package-artifact.test.cjs && node --test test/progress-package-install.e2e.cjs && node test/consistency-check-core.cjs"
|
|
13
13
|
},
|
|
14
14
|
"keywords": [
|
|
15
15
|
"kld",
|
package/skywalk-sdd/index.cjs
CHANGED
|
@@ -1313,28 +1313,32 @@ function acquireEventWriteLock(dataDir, changeName, options = {}) {
|
|
|
1313
1313
|
created_at: nowISO(),
|
|
1314
1314
|
change: safeChangeName(changeName),
|
|
1315
1315
|
};
|
|
1316
|
-
|
|
1316
|
+
const candidatePath = `${lockPath}.${token}.tmp`;
|
|
1317
1317
|
try {
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1318
|
+
fs.writeFileSync(candidatePath, JSON.stringify(payload), { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
|
1319
|
+
// Publish a complete payload atomically. A contender must never observe an
|
|
1320
|
+
// empty lock and remove it while its owner is still initializing it.
|
|
1321
|
+
fs.linkSync(candidatePath, lockPath);
|
|
1321
1322
|
return { ...payload, path: lockPath };
|
|
1322
1323
|
} catch (error) {
|
|
1323
|
-
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
1324
1324
|
if (error?.code !== 'EEXIST') throw error;
|
|
1325
1325
|
let existing = null;
|
|
1326
1326
|
try {
|
|
1327
1327
|
existing = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
1328
1328
|
} catch {}
|
|
1329
1329
|
const createdAt = Date.parse(existing?.created_at || '');
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1330
|
+
let age = 0;
|
|
1331
|
+
try { age = Date.now() - fs.statSync(lockPath).mtimeMs; } catch {}
|
|
1332
|
+
const stale = existing && Number.isFinite(createdAt)
|
|
1333
|
+
? !isProcessAlive(Number(existing.pid))
|
|
1334
|
+
: age > staleMs;
|
|
1333
1335
|
if (stale) {
|
|
1334
1336
|
try { fs.rmSync(lockPath, { force: true }); } catch {}
|
|
1335
1337
|
continue;
|
|
1336
1338
|
}
|
|
1337
1339
|
Atomics.wait(EVENT_LOCK_WAIT_BUFFER, 0, 0, 10);
|
|
1340
|
+
} finally {
|
|
1341
|
+
try { fs.unlinkSync(candidatePath); } catch {}
|
|
1338
1342
|
}
|
|
1339
1343
|
}
|
|
1340
1344
|
const error = new Error(`EVENT_WRITE_LOCK_TIMEOUT: 等待 change "${changeName}" 的事件写锁超时`);
|
|
@@ -1539,7 +1543,15 @@ function readAllEvents(dataDir) {
|
|
|
1539
1543
|
/**
|
|
1540
1544
|
* 计算单个 change 的四维指标
|
|
1541
1545
|
*/
|
|
1546
|
+
function normalizeLifecycleEvents(events) {
|
|
1547
|
+
// Older clients stored run notifications as stage_end with a different event ID.
|
|
1548
|
+
// Preserve the event and its wire kind without counting it as another stage.
|
|
1549
|
+
return events.map(event => event.type === 'stage_end' && event.event_kind === 'run_ended'
|
|
1550
|
+
? { ...event, type: 'run_end' } : event);
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1542
1553
|
function computeChangeMetrics(changeName, events) {
|
|
1554
|
+
events = normalizeLifecycleEvents(events);
|
|
1543
1555
|
const changeEvents = events.filter(e => e.change === changeName && !e.orphan);
|
|
1544
1556
|
if (changeEvents.length === 0) return null;
|
|
1545
1557
|
|
|
@@ -1868,6 +1880,7 @@ function createReworkBucket(command, capability) {
|
|
|
1868
1880
|
}
|
|
1869
1881
|
|
|
1870
1882
|
function summarizeStageExecutions(events) {
|
|
1883
|
+
events = normalizeLifecycleEvents(events);
|
|
1871
1884
|
const stageEvents = events.filter(e => !e.orphan && (e.type === 'stage_start' || e.type === 'stage_end'));
|
|
1872
1885
|
const starts = stageEvents
|
|
1873
1886
|
.filter(e => e.type === 'stage_start')
|
|
@@ -3329,6 +3342,7 @@ function computeTelemetryHealthMetrics(events, options = {}) {
|
|
|
3329
3342
|
}
|
|
3330
3343
|
|
|
3331
3344
|
function computePdfMvpMetrics(events, options = {}) {
|
|
3345
|
+
events = normalizeLifecycleEvents(events);
|
|
3332
3346
|
if (options.level === 'capability' || options.capability) {
|
|
3333
3347
|
return computeCapabilityMetrics(options.capability, events, options);
|
|
3334
3348
|
}
|
|
@@ -4856,6 +4870,7 @@ function tddPairFinding(pair) {
|
|
|
4856
4870
|
}
|
|
4857
4871
|
|
|
4858
4872
|
function buildReport(projectRoot, events, options = {}) {
|
|
4873
|
+
events = normalizeLifecycleEvents(events);
|
|
4859
4874
|
const scopedReport = scopeEventsForChangeReport(events, options.change);
|
|
4860
4875
|
events = scopedReport.events;
|
|
4861
4876
|
const archiveEvent = latestByTimestamp(events.filter(e => {
|
|
@@ -5208,6 +5223,7 @@ function filterEventsByDate(events, dateFrom, dateTo) {
|
|
|
5208
5223
|
}
|
|
5209
5224
|
|
|
5210
5225
|
function computeDoctorReport(events, options = {}) {
|
|
5226
|
+
events = normalizeLifecycleEvents(events);
|
|
5211
5227
|
const scopedEvents = options.change
|
|
5212
5228
|
? events.filter(e => e.change === options.change)
|
|
5213
5229
|
: events;
|
|
@@ -5418,12 +5434,12 @@ function cmdStart(args) {
|
|
|
5418
5434
|
|
|
5419
5435
|
const ONTOLOGY_AUTHORING_STAGES = new Set(['propose', 'spec', 'design', 'task', 'check']);
|
|
5420
5436
|
|
|
5421
|
-
function syncWorkingOntologyForStage(projectRoot, changeName, command, result) {
|
|
5437
|
+
function syncWorkingOntologyForStage(projectRoot, changeName, command, result, verifiedReconciliation = null) {
|
|
5422
5438
|
if (!ONTOLOGY_AUTHORING_STAGES.has(command) || result === 'failure') return null;
|
|
5423
5439
|
if (!changeName || changeName === 'general') return null;
|
|
5424
5440
|
const changeDir = getChangeDir(projectRoot, changeName);
|
|
5425
5441
|
if (!fs.existsSync(changeDir)) return null;
|
|
5426
|
-
const reconciled = require('./ontology/runtime.cjs').reconcileChange(
|
|
5442
|
+
const reconciled = verifiedReconciliation || require('./ontology/runtime.cjs').reconcileChange(
|
|
5427
5443
|
projectRoot,
|
|
5428
5444
|
changeName,
|
|
5429
5445
|
{
|
|
@@ -5592,7 +5608,8 @@ function cmdEnd(args, options = {}) {
|
|
|
5592
5608
|
|
|
5593
5609
|
let semanticState = null;
|
|
5594
5610
|
try {
|
|
5595
|
-
semanticState = syncWorkingOntologyForStage(projectRoot, change, command, result
|
|
5611
|
+
semanticState = syncWorkingOntologyForStage(projectRoot, change, command, result,
|
|
5612
|
+
command === 'check' ? options.verifiedReconciliation : null);
|
|
5596
5613
|
if (semanticState) {
|
|
5597
5614
|
details = {
|
|
5598
5615
|
...details,
|
|
@@ -5779,7 +5796,7 @@ function cmdEnd(args, options = {}) {
|
|
|
5779
5796
|
appendEvent(dataDir, event.change, cleanOptionalFields({
|
|
5780
5797
|
schema_version: SCHEMA_VERSION,
|
|
5781
5798
|
event_id: generateEventId(),
|
|
5782
|
-
type: '
|
|
5799
|
+
type: 'run_end',
|
|
5783
5800
|
event_kind: 'run_ended',
|
|
5784
5801
|
source: event.source,
|
|
5785
5802
|
command: 'archive',
|
|
@@ -6434,8 +6451,10 @@ function normalizeStrictTestResult(args, details) {
|
|
|
6434
6451
|
failure_type: failureType,
|
|
6435
6452
|
expected_failure: expectedFailure,
|
|
6436
6453
|
evidence_tier: 'strict',
|
|
6454
|
+
...(input.execution ? { execution: input.execution } : {}),
|
|
6437
6455
|
valid_red: tddPhase === 'red'
|
|
6438
6456
|
? expectedFailure && exitCode !== 0 && ['assertion', 'contract'].includes(failureType)
|
|
6457
|
+
&& (!input.execution || (input.counts_known && Number(input.failed) > 0))
|
|
6439
6458
|
: false,
|
|
6440
6459
|
};
|
|
6441
6460
|
if (input.counts_known) {
|
|
@@ -6453,6 +6472,7 @@ function isValidCompletionTestResults(testResults) {
|
|
|
6453
6472
|
if (!testResults || typeof testResults !== 'object') return false;
|
|
6454
6473
|
if (!['green', 'refactor', 'regression'].includes(testResults.tdd_phase)) return false;
|
|
6455
6474
|
if (testResults.exit_code !== 0 || testResults.failure_type !== 'none') return false;
|
|
6475
|
+
if (testResults.execution && (!testResults.counts_known || !(testResults.passed > 0))) return false;
|
|
6456
6476
|
if (testResults.counts_known === true && Number(testResults.failed) !== 0) return false;
|
|
6457
6477
|
return true;
|
|
6458
6478
|
}
|
|
@@ -7348,18 +7368,6 @@ function cmdRecord(args) {
|
|
|
7348
7368
|
try {
|
|
7349
7369
|
details = parseJsonOption(args, 'details-json', 'details-file', projectRoot, {});
|
|
7350
7370
|
detailsFilePath = args['details-file'] || args.details_file || null;
|
|
7351
|
-
// 默认清理 details-file,避免项目根目录被临时 JSON 污染;--keep-details 保留调试用
|
|
7352
|
-
if (detailsFilePath && !args['keep-details'] && !args.keep_details) {
|
|
7353
|
-
try {
|
|
7354
|
-
const fileToRemove = normalizeDetailsFilePath(detailsFilePath, projectRoot);
|
|
7355
|
-
if (fs.existsSync(fileToRemove)) {
|
|
7356
|
-
fs.unlinkSync(fileToRemove);
|
|
7357
|
-
}
|
|
7358
|
-
} catch (cleanupErr) {
|
|
7359
|
-
// 清理失败不阻塞主流程
|
|
7360
|
-
try { console.error(`[telemetry] details-file 清理失败(不阻塞): ${cleanupErr.message}`); } catch {}
|
|
7361
|
-
}
|
|
7362
|
-
}
|
|
7363
7371
|
} catch (err) {
|
|
7364
7372
|
fail(`details JSON 解析失败: ${err.message}`);
|
|
7365
7373
|
}
|
|
@@ -7467,6 +7475,9 @@ function cmdRecord(args) {
|
|
|
7467
7475
|
};
|
|
7468
7476
|
}
|
|
7469
7477
|
if (strict && type === 'test_result') {
|
|
7478
|
+
if (details?.test_results?.execution) {
|
|
7479
|
+
require('./lib/test-execution.cjs').verifyCapture(details.test_results, projectRoot);
|
|
7480
|
+
}
|
|
7470
7481
|
const testResults = normalizeStrictTestResult(args, details);
|
|
7471
7482
|
strictRunId = testResults.run_id;
|
|
7472
7483
|
strictTaskId = testResults.task_id;
|
|
@@ -7560,6 +7571,7 @@ function cmdRecord(args) {
|
|
|
7560
7571
|
const taskId = strictTaskId || details?.task_id || null;
|
|
7561
7572
|
if (taskId) runCheckTaskSync(projectRoot, change, taskId);
|
|
7562
7573
|
} catch (taskErr) {
|
|
7574
|
+
if (args['require-projection']) throw taskErr;
|
|
7563
7575
|
try { console.error(`[telemetry] tasks.md 主任务状态自动同步失败(不阻塞): ${taskErr.message}`); } catch {}
|
|
7564
7576
|
}
|
|
7565
7577
|
};
|
|
@@ -7636,6 +7648,85 @@ function cmdRecord(args) {
|
|
|
7636
7648
|
} catch (error) {
|
|
7637
7649
|
fail(error.message || String(error));
|
|
7638
7650
|
}
|
|
7651
|
+
// Keep retry input until validation, persistence and task projection all succeed.
|
|
7652
|
+
if (detailsFilePath && !args['keep-details'] && !args.keep_details) {
|
|
7653
|
+
try { fs.unlinkSync(normalizeDetailsFilePath(detailsFilePath, projectRoot)); }
|
|
7654
|
+
catch (error) {
|
|
7655
|
+
if (error.code !== 'ENOENT') console.error(`[telemetry] details-file 清理失败(不阻塞): ${error.message}`);
|
|
7656
|
+
}
|
|
7657
|
+
}
|
|
7658
|
+
if (!args.quiet) console.log(JSON.stringify(output, null, 2));
|
|
7659
|
+
return output;
|
|
7660
|
+
}
|
|
7661
|
+
|
|
7662
|
+
function cmdTestRun(args) {
|
|
7663
|
+
const projectRoot = normalizeProjectRoot(args.project || process.cwd());
|
|
7664
|
+
requireValidChangeName(args.change);
|
|
7665
|
+
requireStrictString(args['session-id'], 'TEST_SESSION_REQUIRED', 'session-id');
|
|
7666
|
+
const completion = args['completion-file']
|
|
7667
|
+
? JSON.parse(fs.readFileSync(path.resolve(args['completion-file']), 'utf8')) : [];
|
|
7668
|
+
const declared = String(args['task-ids'] || args['task-id'] || '').split(',').map(x => x.trim());
|
|
7669
|
+
if (!Array.isArray(completion) || completion.some(item => !declared.includes(item.task_id)
|
|
7670
|
+
|| typeof item.tdd_required !== 'boolean'
|
|
7671
|
+
|| (!Array.isArray(item.files) && !item.no_file_change_reason))) {
|
|
7672
|
+
throw new Error('TEST_COMPLETION_INVALID: 完成信息须关联本轮明确任务,并包含实际文件及 tdd_required');
|
|
7673
|
+
}
|
|
7674
|
+
for (const item of completion) {
|
|
7675
|
+
normalizeRepositoryRelativeFiles(item.files || []);
|
|
7676
|
+
runCheckTaskSync(projectRoot, args.change, item.task_id, true);
|
|
7677
|
+
}
|
|
7678
|
+
const captured = require('./lib/test-execution.cjs').captureTest(args, projectRoot, args.change);
|
|
7679
|
+
const record = cmdRecord({ ...args, quiet: true, strict: true, type: 'test_result',
|
|
7680
|
+
command: 'test', project: projectRoot, change: args.change, 'run-id': captured.runId,
|
|
7681
|
+
source: args.source || 'test-run', summary: args.summary || `执行测试:${captured.testResults.command}`,
|
|
7682
|
+
result: captured.testResults.result, details: undefined,
|
|
7683
|
+
'details-file': captured.receipt, 'keep-details': true,
|
|
7684
|
+
});
|
|
7685
|
+
const completed = [];
|
|
7686
|
+
if (isValidCompletionTestResults(captured.testResults)) {
|
|
7687
|
+
for (const item of completion) {
|
|
7688
|
+
const updated = cmdRecord({ ...args, quiet: true, strict: true, type: 'task_update', command: 'apply',
|
|
7689
|
+
'require-projection': true,
|
|
7690
|
+
project: projectRoot, 'run-id': `${captured.runId}:${item.task_id}`, 'task-id': item.task_id,
|
|
7691
|
+
status: 'completed', result: 'success', source: args.source || 'test-run', summary: `${item.task_id} 通过本轮测试`,
|
|
7692
|
+
'details-json': JSON.stringify({ task_update: { ...item, test_event_id: record.event_id } }),
|
|
7693
|
+
});
|
|
7694
|
+
completed.push({ task_id: item.task_id, event_id: updated.event_id, test_event_id: record.event_id });
|
|
7695
|
+
}
|
|
7696
|
+
}
|
|
7697
|
+
const output = { ...record, test_results: captured.testResults, receipt: captured.receipt, completed_tasks: completed };
|
|
7698
|
+
console.log(JSON.stringify(output, null, 2));
|
|
7699
|
+
process.exitCode = captured.testResults.exit_code;
|
|
7700
|
+
return output;
|
|
7701
|
+
}
|
|
7702
|
+
|
|
7703
|
+
function cmdCheckRecord(args) {
|
|
7704
|
+
const projectRoot = normalizeProjectRoot(args.project || process.cwd());
|
|
7705
|
+
requireValidChangeName(args.change);
|
|
7706
|
+
const preflight = JSON.parse(fs.readFileSync(path.resolve(args['preflight-file']), 'utf8'));
|
|
7707
|
+
const review = JSON.parse(fs.readFileSync(path.resolve(args['review-file']), 'utf8'));
|
|
7708
|
+
const state = require('./ontology/runtime.cjs').reconcileChange(projectRoot, args.change, { profile: preflight.profile || 'auto', markPending: true });
|
|
7709
|
+
if (!preflight.valid || !state.valid || preflight.revision !== state.revision
|
|
7710
|
+
|| preflight.review_inputs?.identity_history_hash !== state.state.identity_history_hash
|
|
7711
|
+
|| JSON.stringify(preflight.review_inputs?.files) !== JSON.stringify(state.state.files)) {
|
|
7712
|
+
throw new Error('CHECK_REVIEW_STALE: 文档或历史身份已变化,重新预检并评审受影响内容');
|
|
7713
|
+
}
|
|
7714
|
+
const events = readEvents(getDataDir(projectRoot), args.change);
|
|
7715
|
+
const beforeApply = !events.some(e => (e.type === 'stage_start' && ['apply', 'test', 'archive'].includes(e.command)) || ['test_result', 'task_update', 'build_result'].includes(e.type));
|
|
7716
|
+
const previous = events.filter(e => e.type === 'check_result').slice(-1)[0]?.details?.check_results;
|
|
7717
|
+
const assembled = require('./lib/check-review.cjs').assembleReview(review, scanTaskCompletion(projectRoot, args.change), {
|
|
7718
|
+
beforeApply, previousBeforeApply: previous?.fixed_before_apply, revision: preflight.revision,
|
|
7719
|
+
});
|
|
7720
|
+
const record = cmdRecord({ ...args, quiet: true, strict: true, type: 'check_result', command: 'check',
|
|
7721
|
+
project: projectRoot, result: assembled.result, 'run-id': args['run-id'] || generateEventId(),
|
|
7722
|
+
source: args.source || 'opsx-command', summary: args.summary || '按实际评审项生成 Check 结果',
|
|
7723
|
+
'session-id': args['session-id'] || review.reviewer?.parent_session_id || review.reviewer?.review_session_id,
|
|
7724
|
+
'details-json': JSON.stringify(assembled.details),
|
|
7725
|
+
});
|
|
7726
|
+
const ended = args['end-event-id'] ? cmdEnd({ ...args, 'event-id': args['end-event-id'], command: 'check',
|
|
7727
|
+
project: projectRoot, result: assembled.result, summary: args.summary || 'Check 已记录,复核文档状态',
|
|
7728
|
+
}, { silent: true, verifiedReconciliation: state }) : null;
|
|
7729
|
+
const output = { ...record, result: assembled.result, check_results: assembled.details.check_results, stage_end: ended };
|
|
7639
7730
|
console.log(JSON.stringify(output, null, 2));
|
|
7640
7731
|
return output;
|
|
7641
7732
|
}
|
|
@@ -8424,6 +8515,12 @@ function main() {
|
|
|
8424
8515
|
case 'record':
|
|
8425
8516
|
cmdRecord(flags);
|
|
8426
8517
|
break;
|
|
8518
|
+
case 'test-run':
|
|
8519
|
+
cmdTestRun(flags);
|
|
8520
|
+
break;
|
|
8521
|
+
case 'check-record':
|
|
8522
|
+
cmdCheckRecord(flags);
|
|
8523
|
+
break;
|
|
8427
8524
|
case 'metrics':
|
|
8428
8525
|
cmdMetrics(flags);
|
|
8429
8526
|
break;
|
|
@@ -8504,6 +8601,8 @@ SDD Telemetry CLI - 流程度量采集工具
|
|
|
8504
8601
|
start 记录 SDD 阶段开始,返回 event_id
|
|
8505
8602
|
end 记录 SDD 阶段结束,关联 event_id
|
|
8506
8603
|
record 记录 task_update/check_result/test_result 等结构化事件
|
|
8604
|
+
test-run 执行 --command-json 参数数组,采集退出码、测试计数、原始输出与任务关联
|
|
8605
|
+
check-record 从 --review-file 与 --preflight-file 计算并记录 Check 结果
|
|
8507
8606
|
metrics 查询度量指标(四维分析)
|
|
8508
8607
|
report 生成只读度量报告(不写入事件)
|
|
8509
8608
|
doctor 诊断 Telemetry 数据质量
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const CATEGORIES = ['completeness', 'consistency', 'executability', 'tdd_compliance'];
|
|
4
|
+
|
|
5
|
+
function assembleReview(review, taskCompletion, options = {}) {
|
|
6
|
+
if (!review || !Array.isArray(review.items) || !review.items.length) throw new Error('CHECK_ITEMS_REQUIRED: 提供实际评审项,不能提交空模板');
|
|
7
|
+
const categories = Object.fromEntries(CATEGORIES.map(key => [key, { passed: 0, total: 0 }]));
|
|
8
|
+
const warnings = [], suggestions = [], ids = new Set();
|
|
9
|
+
for (const item of review.items) {
|
|
10
|
+
if (!item.id || ids.has(item.id)) throw new Error('CHECK_ITEM_ID_INVALID: 检查项必须有唯一 ID');
|
|
11
|
+
ids.add(item.id);
|
|
12
|
+
if (!CATEGORIES.includes(item.category) || !['passed', 'error', 'warning', 'suggestion', 'na'].includes(item.status)) throw new Error('CHECK_ITEM_STATUS_INVALID');
|
|
13
|
+
if (typeof item.evidence !== 'string' || !item.evidence.trim()) throw new Error(`CHECK_ITEM_EVIDENCE_REQUIRED: ${item.id} 缺少来源证据或不适用原因`);
|
|
14
|
+
if (item.status === 'na') continue;
|
|
15
|
+
categories[item.category].total++;
|
|
16
|
+
if (item.status !== 'error') categories[item.category].passed++;
|
|
17
|
+
if (item.status === 'warning') warnings.push({ category: item.category, warning: item.id, description: item.description || item.evidence, target: item.evidence });
|
|
18
|
+
if (item.status === 'suggestion') suggestions.push(item);
|
|
19
|
+
}
|
|
20
|
+
const total = Object.values(categories).reduce((n, c) => n + c.total, 0);
|
|
21
|
+
const passed = Object.values(categories).reduce((n, c) => n + c.passed, 0);
|
|
22
|
+
if (!total) throw new Error('CHECK_ITEMS_REQUIRED: 至少有一个实际适用的评审项');
|
|
23
|
+
if (taskCompletion.has_incomplete) warnings.push({ category: 'task_completion', warning: 'TASKS_INCOMPLETE', description: `${taskCompletion.incomplete} 个任务尚未完成`, target: 'tasks.md' });
|
|
24
|
+
const result = total !== passed ? 'failure' : (warnings.length ? 'partial' : 'success');
|
|
25
|
+
return {
|
|
26
|
+
result,
|
|
27
|
+
details: { check_results: {
|
|
28
|
+
...review.reviewer,
|
|
29
|
+
total, errors: total - passed, warnings: warnings.length, suggestions: suggestions.length,
|
|
30
|
+
categories, warning_items: warnings, warning_dispositions: review.warning_dispositions || [],
|
|
31
|
+
// This field means items satisfied before implementation, not defect fixes.
|
|
32
|
+
fixed_before_apply: options.beforeApply ? passed : Math.min(total, options.previousBeforeApply || 0),
|
|
33
|
+
consistency_score: null,
|
|
34
|
+
task_completion: { ...taskCompletion, checked_for_archive_readiness: !options.beforeApply },
|
|
35
|
+
review_items: review.items,
|
|
36
|
+
review_revision: options.revision,
|
|
37
|
+
} },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { assembleReview };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const { spawnSync } = require('node:child_process');
|
|
7
|
+
|
|
8
|
+
function parseCounts(output) {
|
|
9
|
+
// Prefer runner totals: counting TAP parent suites as tests double-counts nested tests.
|
|
10
|
+
const value = name => {
|
|
11
|
+
const matches = [...output.matchAll(new RegExp(`^(?:#|ℹ)\\s+${name}\\s+(\\d+)\\s*$`, 'gm'))];
|
|
12
|
+
return matches.length ? Number(matches[matches.length - 1][1]) : null;
|
|
13
|
+
};
|
|
14
|
+
const total = value('tests'), passed = value('pass'), failed = value('fail');
|
|
15
|
+
const skipped = value('skipped'), todo = value('todo');
|
|
16
|
+
if ([total, passed, failed].every(Number.isInteger)
|
|
17
|
+
&& total === passed + failed + (skipped || 0) + (todo || 0)) {
|
|
18
|
+
return { counts_known: true, passed, failed, skipped: (skipped || 0) + (todo || 0) };
|
|
19
|
+
}
|
|
20
|
+
return { counts_known: false };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function classifyFailure(output, exitCode, error, signal) {
|
|
24
|
+
if (error?.code === 'ETIMEDOUT') return 'timeout';
|
|
25
|
+
if (error || signal) return 'infrastructure';
|
|
26
|
+
if (exitCode === 0) return 'none';
|
|
27
|
+
// A missing executable/module/test path is not evidence of a failing business assertion.
|
|
28
|
+
if (/MODULE_NOT_FOUND|ERR_MODULE_NOT_FOUND|Cannot find module|Could not find|ENOENT|SyntaxError|ERR_TEST_FAILURE.*cancelled/i.test(output)) return 'infrastructure';
|
|
29
|
+
if (/ERR_ASSERTION|AssertionError|AssertionFailedError|org\.opentest4j\.AssertionFailedError/.test(output)) return 'assertion';
|
|
30
|
+
return 'infrastructure';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function captureTest(args, projectRoot, change) {
|
|
34
|
+
let command;
|
|
35
|
+
try { command = JSON.parse(args['command-json'] || 'null'); } catch { /* validated below */ }
|
|
36
|
+
if (!Array.isArray(command) || !command.length || command.some(x => typeof x !== 'string' || x.includes('\0')) || !command[0]) {
|
|
37
|
+
throw new Error('TEST_COMMAND_REQUIRED: --command-json 必须是非空命令参数数组,不通过 shell 执行');
|
|
38
|
+
}
|
|
39
|
+
const phase = args['tdd-phase'] || 'regression';
|
|
40
|
+
if (!['red', 'green', 'refactor', 'regression', 'not-applicable'].includes(phase)) throw new Error('TEST_PHASE_INVALID');
|
|
41
|
+
const taskIds = [...new Set(String(args['task-ids'] || args['task-id'] || '').split(',').map(x => x.trim()).filter(Boolean))];
|
|
42
|
+
if (!taskIds.length) throw new Error('TEST_TASKS_REQUIRED: 明确指定 --task-ids,工具不猜测测试覆盖的任务');
|
|
43
|
+
const timeout = Number(args['timeout-ms'] || 120000);
|
|
44
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 3600000) throw new Error('TEST_TIMEOUT_INVALID');
|
|
45
|
+
const cwd = path.resolve(args.cwd || projectRoot);
|
|
46
|
+
if (!fs.statSync(cwd).isDirectory()) throw new Error('TEST_CWD_INVALID');
|
|
47
|
+
const runId = args['run-id'] || crypto.randomUUID();
|
|
48
|
+
const key = crypto.createHash('sha256').update(`${change}:${runId}`).digest('hex');
|
|
49
|
+
const dir = path.join(projectRoot, 'skywalk-sdd', 'state', 'test-runs', key);
|
|
50
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
51
|
+
// A run ID identifies one execution. Never re-execute a command under an old receipt.
|
|
52
|
+
try { fs.mkdirSync(dir); } catch (error) {
|
|
53
|
+
if (error.code === 'EEXIST') throw new Error('TEST_RUN_EXISTS: 此 run-id 已执行;使用保存的 details.json 重试记录,重新执行须使用新 run-id');
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
const startedAt = new Date().toISOString();
|
|
57
|
+
const started = process.hrtime.bigint();
|
|
58
|
+
const childEnv = { ...process.env };
|
|
59
|
+
// The wrapper can itself run inside node:test; its child is a fresh test runner.
|
|
60
|
+
delete childEnv.NODE_TEST_CONTEXT;
|
|
61
|
+
const child = spawnSync(command[0], command.slice(1), {
|
|
62
|
+
cwd, env: childEnv, shell: false, encoding: 'utf8', timeout, maxBuffer: 16 * 1024 * 1024,
|
|
63
|
+
});
|
|
64
|
+
const duration = Math.round(Number(process.hrtime.bigint() - started) / 1e6);
|
|
65
|
+
const stdout = child.stdout || '', stderr = child.stderr || '';
|
|
66
|
+
const output = stdout + '\n' + stderr;
|
|
67
|
+
const exitCode = Number.isInteger(child.status) ? child.status : (child.error?.code === 'ETIMEDOUT' ? 124 : 1);
|
|
68
|
+
const failureType = classifyFailure(output, exitCode, child.error, child.signal);
|
|
69
|
+
const counts = parseCounts(output);
|
|
70
|
+
const files = {};
|
|
71
|
+
for (const [name, content] of Object.entries({ 'stdout.log': stdout, 'stderr.log': stderr })) {
|
|
72
|
+
fs.writeFileSync(path.join(dir, name), content, { mode: 0o600 });
|
|
73
|
+
files[name] = crypto.createHash('sha256').update(content).digest('hex');
|
|
74
|
+
}
|
|
75
|
+
const execution = {
|
|
76
|
+
origin: 'command-execution', argv: command, cwd, started_at: startedAt,
|
|
77
|
+
finished_at: new Date().toISOString(), signal: child.signal || null,
|
|
78
|
+
error: child.error ? { code: child.error.code, message: child.error.message } : null,
|
|
79
|
+
output_files: Object.entries(files).map(([name, sha256]) => ({ path: path.join(dir, name), sha256 })),
|
|
80
|
+
task_association: 'explicit-caller-declaration',
|
|
81
|
+
receipt_path: path.join(dir, 'execution.json'),
|
|
82
|
+
};
|
|
83
|
+
const testResults = {
|
|
84
|
+
run_id: runId, verified_task_ids: taskIds, tdd_phase: phase,
|
|
85
|
+
command: JSON.stringify(command), result: exitCode === 0 ? 'success' : 'failure',
|
|
86
|
+
exit_code: exitCode, duration_ms: duration, ...counts, failure_type: failureType,
|
|
87
|
+
expected_failure: phase === 'red' && args['expected-failure'] === true,
|
|
88
|
+
execution,
|
|
89
|
+
};
|
|
90
|
+
const receipt = path.join(dir, 'details.json');
|
|
91
|
+
fs.writeFileSync(execution.receipt_path, JSON.stringify(testResults, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
|
92
|
+
fs.writeFileSync(receipt, JSON.stringify({ test_results: testResults }, null, 2) + '\n', { mode: 0o600 });
|
|
93
|
+
return { testResults, runId, receipt };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function verifyCapture(input, projectRoot) {
|
|
97
|
+
const base = fs.realpathSync(path.join(projectRoot, 'skywalk-sdd/state/test-runs')) + path.sep;
|
|
98
|
+
const receiptPath = fs.realpathSync(input.execution.receipt_path);
|
|
99
|
+
if (input.execution.origin !== 'command-execution' || !receiptPath.startsWith(base)) throw new Error('TEST_CAPTURE_INVALID: 执行回执不在当前项目');
|
|
100
|
+
const receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8'));
|
|
101
|
+
for (const key of Object.keys(receipt)) {
|
|
102
|
+
if (JSON.stringify(receipt[key]) !== JSON.stringify(input[key])) throw new Error(`TEST_CAPTURE_MISMATCH: ${key} 与真实执行回执不一致`);
|
|
103
|
+
}
|
|
104
|
+
for (const output of receipt.execution.output_files) {
|
|
105
|
+
const file = fs.realpathSync(output.path);
|
|
106
|
+
if (!file.startsWith(base) || crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') !== output.sha256) throw new Error('TEST_CAPTURE_HASH_MISMATCH: 原始测试输出已变化');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { captureTest, parseCounts, classifyFailure, verifyCapture };
|
|
@@ -10,13 +10,24 @@ git_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
|
|
10
10
|
exit 1
|
|
11
11
|
}
|
|
12
12
|
hooks_dir=""
|
|
13
|
+
# 统一反斜杠为正斜杠,保证 Windows 盘符路径(P:\a\b → P:/a/b)能被 case 判定为绝对路径
|
|
14
|
+
normalize_path() {
|
|
15
|
+
printf '%s' "$1" | tr '\\' '/'
|
|
16
|
+
}
|
|
17
|
+
# 绝对路径(/ 开头或盘符 X: 开头)直接使用;相对路径基于 git 根拼接
|
|
18
|
+
resolve_spec_dir() {
|
|
19
|
+
raw=$(normalize_path "$1")
|
|
20
|
+
case "$raw" in
|
|
21
|
+
/*|[A-Za-z]:*) printf '%s' "$raw" ;;
|
|
22
|
+
*) printf '%s' "$git_root/$raw" ;;
|
|
23
|
+
esac
|
|
24
|
+
}
|
|
13
25
|
spec_path=$(git -C "$git_root" config --local sdd.specPath 2>/dev/null)
|
|
14
26
|
if [ -n "$spec_path" ]; then
|
|
15
|
-
|
|
16
|
-
hooks_dir="$spec_path/skywalk-sdd/git-hooks"
|
|
27
|
+
hooks_dir="$(resolve_spec_dir "$spec_path")/skywalk-sdd/git-hooks"
|
|
17
28
|
elif [ -f "$git_root/.sdd-spec-root" ]; then
|
|
18
29
|
spec_rel=$(head -1 "$git_root/.sdd-spec-root" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
|
19
|
-
|
|
30
|
+
hooks_dir="$(resolve_spec_dir "$spec_rel")/skywalk-sdd/git-hooks"
|
|
20
31
|
fi
|
|
21
32
|
if [ -n "$hooks_dir" ] && [ ! -d "$hooks_dir" ]; then
|
|
22
33
|
echo "[SDD] 已配置的 Spec 路径失效:$hooks_dir"
|
|
@@ -27,12 +38,15 @@ fi
|
|
|
27
38
|
# 方法3: 从 git 根向上搜索 skywalk-sdd/git-hooks(spec 仓本身或父级)
|
|
28
39
|
if [ -z "$hooks_dir" ]; then
|
|
29
40
|
search_dir="$git_root"
|
|
30
|
-
while [ "$search_dir"
|
|
41
|
+
while [ -n "$search_dir" ] && [ "$search_dir" != "/" ]; do
|
|
31
42
|
if [ -d "$search_dir/skywalk-sdd/git-hooks" ]; then
|
|
32
43
|
hooks_dir="$search_dir/skywalk-sdd/git-hooks"
|
|
33
44
|
break
|
|
34
45
|
fi
|
|
35
|
-
|
|
46
|
+
# Windows 盘符根(P:/)dirname 后不变,需显式收敛,避免死循环
|
|
47
|
+
parent_dir="$(dirname "$search_dir")"
|
|
48
|
+
[ "$parent_dir" = "$search_dir" ] && break
|
|
49
|
+
search_dir="$parent_dir"
|
|
36
50
|
done
|
|
37
51
|
fi
|
|
38
52
|
|