sillyspec 3.28.11 → 3.28.13
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/.claude/skills/sillyspec-execute/SKILL.md +222 -223
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-quick/SKILL.md +8 -8
- package/.claude/skills/sillyspec-verify/SKILL.md +87 -91
- package/.husky/pre-commit +4 -0
- package/.husky/pre-push +3 -0
- package/AGENTS.md +1 -1
- package/package.json +1 -1
- package/src/change-risk-profile.js +29 -7
- package/src/commit-guard.js +119 -0
- package/src/config-schema.js +2 -0
- package/src/decision-distill.js +32 -25
- package/src/diagnostic-codes.js +95 -0
- package/src/doctor-diagnostics.js +170 -1
- package/src/fr-index.js +359 -0
- package/src/knowledge-stats.js +99 -2
- package/src/machine-interface.js +37 -0
- package/src/run/archive-distill.js +29 -0
- package/src/run/prompt.js +32 -1
- package/src/run/verify-quality-scan.js +151 -5
- package/src/stage-contract.js +496 -19
- package/src/stage-review-checklist.js +13 -1
- package/src/stages/brainstorm.js +6 -1
- package/src/stages/verify.js +6 -2
- package/src/verify-facts-schema.js +21 -5
- package/src/verify-postcheck.js +86 -2
- package/src/verify-probes.js +1293 -27
package/src/machine-interface.js
CHANGED
|
@@ -22,6 +22,7 @@ import { resolveRuntimeRoot } from './run/shared.js';
|
|
|
22
22
|
import { runValidators, checkTransition, checkExecuteCodeEvidence } from './stage-contract.js';
|
|
23
23
|
import { validateTaskReviews, resolveLatestExecuteRunId } from './task-review.js';
|
|
24
24
|
import { runVerifyTestCheck } from './verify-postcheck.js';
|
|
25
|
+
import { checkCode } from './diagnostic-codes.js';
|
|
25
26
|
|
|
26
27
|
// ============ 退出码常量(D-004@v1)============
|
|
27
28
|
|
|
@@ -52,6 +53,9 @@ export const SCHEMA_VERSION = 1;
|
|
|
52
53
|
* @param {string[]} [fields.warnings=[]]
|
|
53
54
|
* @param {Array<object>|undefined} [fields.checks]
|
|
54
55
|
* @param {object|undefined} [fields.data]
|
|
56
|
+
* @param {string[]|undefined} [fields.codes] - 诊断码聚合(2026-09-17-mi-diagnostic-codes
|
|
57
|
+
* 加法式:失败 check 的 code 按出现序去重;信封级错误路径单码。可选键遵循 optional-once:
|
|
58
|
+
* !== undefined 才挂。errors 仍 string[],中文 message 不变,schema_version 仍 1。
|
|
55
59
|
* @returns {object} 可直接 JSON.stringify 的 envelope 对象
|
|
56
60
|
*/
|
|
57
61
|
export function buildEnvelope({
|
|
@@ -64,6 +68,7 @@ export function buildEnvelope({
|
|
|
64
68
|
warnings = [],
|
|
65
69
|
checks,
|
|
66
70
|
data,
|
|
71
|
+
codes,
|
|
67
72
|
}) {
|
|
68
73
|
const envelope = {
|
|
69
74
|
schema_version: SCHEMA_VERSION,
|
|
@@ -80,6 +85,7 @@ export function buildEnvelope({
|
|
|
80
85
|
if (facet !== undefined) envelope.facet = facet;
|
|
81
86
|
if (checks !== undefined) envelope.checks = checks;
|
|
82
87
|
if (data !== undefined) envelope.data = data;
|
|
88
|
+
if (codes !== undefined) envelope.codes = codes;
|
|
83
89
|
|
|
84
90
|
return envelope;
|
|
85
91
|
}
|
|
@@ -130,6 +136,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
|
|
|
130
136
|
change: changeName,
|
|
131
137
|
ok: false,
|
|
132
138
|
errors: [`无法核验:进度库不存在(${dbPath})——变更 ${changeName} 尚未运行过任何阶段,只读 gate 不会为其建库`],
|
|
139
|
+
codes: ['db_missing'],
|
|
133
140
|
});
|
|
134
141
|
return { envelope, exitCode: EXIT_UNKNOWN };
|
|
135
142
|
}
|
|
@@ -143,6 +150,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
|
|
|
143
150
|
change: changeName,
|
|
144
151
|
ok: false,
|
|
145
152
|
errors: [`变更不存在: ${changeName}`],
|
|
153
|
+
codes: ['change_not_found'],
|
|
146
154
|
});
|
|
147
155
|
return { envelope, exitCode: EXIT_UNKNOWN };
|
|
148
156
|
}
|
|
@@ -163,6 +171,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
|
|
|
163
171
|
});
|
|
164
172
|
checks.push({
|
|
165
173
|
id: 'artifacts',
|
|
174
|
+
code: checkCode('artifacts'),
|
|
166
175
|
ok: r.ok,
|
|
167
176
|
errors: r.errors || [],
|
|
168
177
|
warnings: r.warnings || [],
|
|
@@ -178,6 +187,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
|
|
|
178
187
|
const dfl = validateDesignFileList({ changeDir: join(specRoot, 'changes', changeName), cwd });
|
|
179
188
|
checks.push({
|
|
180
189
|
id: 'design-file-list',
|
|
190
|
+
code: checkCode('design-file-list'),
|
|
181
191
|
ok: (dfl.errors || []).length === 0,
|
|
182
192
|
// errors 元素是 {path, message} 对象(与 complete.js --done 打印侧同源),gate check 面拍平为 message 字符串
|
|
183
193
|
errors: (dfl.errors || []).map(e => e && e.message ? e.message : String(e)),
|
|
@@ -187,6 +197,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
|
|
|
187
197
|
// fail-open 与 complete.js 同口径:核验自身异常不误拦,warning 留痕
|
|
188
198
|
checks.push({
|
|
189
199
|
id: 'design-file-list',
|
|
200
|
+
code: checkCode('design-file-list'),
|
|
190
201
|
ok: true,
|
|
191
202
|
errors: [],
|
|
192
203
|
warnings: [`design 清单核验自身异常,跳过(${e && e.message ? e.message : e})`],
|
|
@@ -202,6 +213,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
|
|
|
202
213
|
const t = checkTransition(currentStage, stage, fromStageData ? { fromStageData } : {});
|
|
203
214
|
checks.push({
|
|
204
215
|
id: 'transition',
|
|
216
|
+
code: checkCode('transition'),
|
|
205
217
|
ok: t.allowed,
|
|
206
218
|
errors: t.allowed ? [] : [t.reason].filter(Boolean),
|
|
207
219
|
warnings: [],
|
|
@@ -214,6 +226,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
|
|
|
214
226
|
const ev = sharedEvidence;
|
|
215
227
|
checks.push({
|
|
216
228
|
id: 'execute-evidence',
|
|
229
|
+
code: checkCode('execute-evidence'),
|
|
217
230
|
ok: ev.status !== 'unchanged',
|
|
218
231
|
errors: ev.status === 'unchanged' ? [`base..head 无代码变更: ${ev.detail}`] : [],
|
|
219
232
|
warnings: ev.status === 'unknown' ? [`无法判定代码变更: ${ev.detail}`] : [],
|
|
@@ -267,6 +280,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
|
|
|
267
280
|
});
|
|
268
281
|
checks.push({
|
|
269
282
|
id: 'task-reviews',
|
|
283
|
+
code: checkCode('task-reviews'),
|
|
270
284
|
ok: tr.ok,
|
|
271
285
|
errors: tr.errors || [],
|
|
272
286
|
warnings: tr.warnings || [],
|
|
@@ -291,6 +305,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
|
|
|
291
305
|
}
|
|
292
306
|
checks.push({
|
|
293
307
|
id: 'verify-test',
|
|
308
|
+
code: checkCode('verify-test'),
|
|
294
309
|
ok: vt.status !== 'failed',
|
|
295
310
|
errors: vt.status === 'failed' ? [`测试失败: ${vt.reason || ''}`] : [],
|
|
296
311
|
warnings: vtWarnings,
|
|
@@ -309,6 +324,10 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
|
|
|
309
324
|
const ok = checks.filter((c) => !c.informational).every((c) => c.ok);
|
|
310
325
|
const exitCode = ok ? EXIT_OK : EXIT_BLOCKED;
|
|
311
326
|
|
|
327
|
+
// 诊断码聚合(加法式):失败 check 的 code 按出现序(push 序)去重;成功为空数组。
|
|
328
|
+
// 非 1:1:多条 errors 可共享一个 check 级码,逐条归因请读 checks[].code。
|
|
329
|
+
const codes = [...new Set(checks.filter((c) => !c.ok && c.code).map((c) => c.code))];
|
|
330
|
+
|
|
312
331
|
const errors = [];
|
|
313
332
|
const warnings = [];
|
|
314
333
|
for (const c of checks) {
|
|
@@ -324,6 +343,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
|
|
|
324
343
|
errors,
|
|
325
344
|
warnings,
|
|
326
345
|
checks,
|
|
346
|
+
codes,
|
|
327
347
|
});
|
|
328
348
|
|
|
329
349
|
return { envelope, exitCode };
|
|
@@ -335,6 +355,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
|
|
|
335
355
|
change: changeName,
|
|
336
356
|
ok: false,
|
|
337
357
|
errors: [`internal: ${e.message}`],
|
|
358
|
+
codes: ['internal_error'],
|
|
338
359
|
});
|
|
339
360
|
return { envelope, exitCode: EXIT_UNKNOWN };
|
|
340
361
|
}
|
|
@@ -379,6 +400,7 @@ export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot,
|
|
|
379
400
|
change: changeName,
|
|
380
401
|
ok: false,
|
|
381
402
|
errors: [`非法 facet: ${facet},合法值: ${FACETS.join(', ')}`],
|
|
403
|
+
codes: ['unknown_facet'],
|
|
382
404
|
});
|
|
383
405
|
return { envelope, exitCode: EXIT_UNKNOWN };
|
|
384
406
|
}
|
|
@@ -397,6 +419,7 @@ export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot,
|
|
|
397
419
|
change: changeName,
|
|
398
420
|
ok: false,
|
|
399
421
|
errors: [`无法核验:进度库不存在(${dbPath})——变更 ${changeName} 尚未运行过任何阶段,只读 derive 不会为其建库`],
|
|
422
|
+
codes: ['db_missing'],
|
|
400
423
|
});
|
|
401
424
|
return { envelope, exitCode: EXIT_UNKNOWN };
|
|
402
425
|
}
|
|
@@ -410,6 +433,7 @@ export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot,
|
|
|
410
433
|
change: changeName,
|
|
411
434
|
ok: false,
|
|
412
435
|
errors: [`变更不存在: ${changeName}`],
|
|
436
|
+
codes: ['change_not_found'],
|
|
413
437
|
});
|
|
414
438
|
return { envelope, exitCode: EXIT_UNKNOWN };
|
|
415
439
|
}
|
|
@@ -537,12 +561,20 @@ export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot,
|
|
|
537
561
|
change: changeName,
|
|
538
562
|
ok: false,
|
|
539
563
|
errors: [`非法 facet: ${facet},合法值: ${FACETS.join(', ')}`],
|
|
564
|
+
codes: ['unknown_facet'],
|
|
540
565
|
});
|
|
541
566
|
return { envelope, exitCode: EXIT_UNKNOWN };
|
|
542
567
|
}
|
|
543
568
|
}
|
|
544
569
|
|
|
545
570
|
// stage 仅 artifacts 时出现(产物校验绑定阶段语义);其余 facet 不传 stage。
|
|
571
|
+
// 诊断码(加法式):facet 失败面单码(execute-test 类 facet 失败即对应码),成功为空数组。
|
|
572
|
+
const FACET_FAILURE_CODE = {
|
|
573
|
+
'execute-evidence': 'execute_evidence_unchanged',
|
|
574
|
+
'verify-test': 'verify_test_failed',
|
|
575
|
+
'task-reviews': 'task_reviews_invalid',
|
|
576
|
+
artifacts: 'artifacts_invalid',
|
|
577
|
+
};
|
|
546
578
|
const envelope = buildEnvelope({
|
|
547
579
|
command: 'derive',
|
|
548
580
|
facet,
|
|
@@ -551,6 +583,7 @@ export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot,
|
|
|
551
583
|
errors,
|
|
552
584
|
warnings,
|
|
553
585
|
data,
|
|
586
|
+
codes: ok ? [] : [FACET_FAILURE_CODE[facet]].filter(Boolean),
|
|
554
587
|
stage: facet === 'artifacts' ? currentStage : undefined,
|
|
555
588
|
});
|
|
556
589
|
|
|
@@ -563,6 +596,7 @@ export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot,
|
|
|
563
596
|
change: changeName,
|
|
564
597
|
ok: false,
|
|
565
598
|
errors: [`internal: ${e.message}`],
|
|
599
|
+
codes: ['internal_error'],
|
|
566
600
|
});
|
|
567
601
|
return { envelope, exitCode: EXIT_UNKNOWN };
|
|
568
602
|
}
|
|
@@ -597,6 +631,7 @@ export function runStatusOverview({ cwd, specBase } = {}) {
|
|
|
597
631
|
command: 'progress show',
|
|
598
632
|
ok: false,
|
|
599
633
|
errors: [`无法核验:进度库不存在(${dbPath})——项目尚未初始化进度,只读总览不会为其建库`],
|
|
634
|
+
codes: ['db_missing'],
|
|
600
635
|
});
|
|
601
636
|
return { envelope, exitCode: EXIT_UNKNOWN };
|
|
602
637
|
}
|
|
@@ -618,6 +653,7 @@ export function runStatusOverview({ cwd, specBase } = {}) {
|
|
|
618
653
|
ok: true,
|
|
619
654
|
warnings,
|
|
620
655
|
data,
|
|
656
|
+
codes: [],
|
|
621
657
|
});
|
|
622
658
|
return { envelope, exitCode: EXIT_OK };
|
|
623
659
|
} catch (e) {
|
|
@@ -626,6 +662,7 @@ export function runStatusOverview({ cwd, specBase } = {}) {
|
|
|
626
662
|
command: 'progress show',
|
|
627
663
|
ok: false,
|
|
628
664
|
errors: [`internal: ${e.message}`],
|
|
665
|
+
codes: ['internal_error'],
|
|
629
666
|
});
|
|
630
667
|
return { envelope, exitCode: EXIT_UNKNOWN };
|
|
631
668
|
}
|
|
@@ -45,4 +45,33 @@ export async function executeArchiveDistill({ cwd, specBase, changeName }) {
|
|
|
45
45
|
} catch (e) {
|
|
46
46
|
console.warn(`\n⚠️ 决策提炼执行异常,降级跳过(best-effort,不阻断归档——同旧步降级语义): ${e && e.message ? e.message : e}`)
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
// ── FR 索引提炼(2026-09-18-fr-index-l1 L1:稳定 id 发号+承接翻链;同 best-effort 降级语义)──
|
|
50
|
+
try {
|
|
51
|
+
const { indexRequirements } = await import('../fr-index.js')
|
|
52
|
+
const fr = indexRequirements({ changeDir, knowledgeRoot, headHash })
|
|
53
|
+
const frWritten = Array.isArray(fr && fr.written) ? fr.written : []
|
|
54
|
+
if (fr && fr.skipped) {
|
|
55
|
+
console.log(`\nℹ️ FR 索引零输出:${fr.skipped}`)
|
|
56
|
+
} else if (frWritten.length > 0) {
|
|
57
|
+
console.log(`\n📌 FR 索引(CLI 机械执行,幂等):${frWritten.length} 条已发号入库——`)
|
|
58
|
+
for (const w of frWritten) console.log(` - ${w.id} → ${w.file}`)
|
|
59
|
+
}
|
|
60
|
+
for (const w of (fr && fr.warnings) || []) {
|
|
61
|
+
console.warn(` ⚠️ ${w}`)
|
|
62
|
+
}
|
|
63
|
+
// 遥测(L3 证据发生器指标,D-006/D-008):取代链跟随事件
|
|
64
|
+
const { appendKnowledgeHit } = await import('../knowledge-hits.js')
|
|
65
|
+
const runtimeRoot = join(specBase, '.runtime')
|
|
66
|
+
for (const s of (fr && fr.superseded) || []) {
|
|
67
|
+
appendKnowledgeHit(runtimeRoot, { type: 'fr-supersede', change: changeName, from: s.from, to: s.to })
|
|
68
|
+
}
|
|
69
|
+
// 删除缺口探针(D-008 护栏③):观察信号,不算 L3 门禁——L1 无删除声明义务,只采趋势数据
|
|
70
|
+
for (const u of (fr && fr.unreferenced) || []) {
|
|
71
|
+
appendKnowledgeHit(runtimeRoot, { type: 'fr-unreferenced', change: changeName, domain: u.domain, count: u.count })
|
|
72
|
+
console.log(` ℹ️ [观察信号·不算 L3 门禁] 触达域 ${u.domain} 有 ${u.count} 条 active FR 未被本次承接引用(fr-unreferenced 遥测,L3 裁决趋势数据)`)
|
|
73
|
+
}
|
|
74
|
+
} catch (e) {
|
|
75
|
+
console.warn(`\n⚠️ FR 索引执行异常,降级跳过(best-effort,不阻断归档): ${e && e.message ? e.message : e}`)
|
|
76
|
+
}
|
|
48
77
|
}
|
package/src/run/prompt.js
CHANGED
|
@@ -831,7 +831,38 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
|
|
|
831
831
|
// fail-soft 同三件套;无 quick 占位符自然零输出。
|
|
832
832
|
if (stageName === 'quick' && promptText.includes('{QUICK_CONTEXT_DIGEST}')) {
|
|
833
833
|
const digestSpecBase = resolvePromptSpecBase(platformOpts, cwd)
|
|
834
|
-
promptText = promptText.replace(/\{QUICK_CONTEXT_DIGEST
|
|
834
|
+
promptText = promptText.replace(/\{QUICK_CONTEXT_DIGEST}/g, buildQuickContextDigest(digestSpecBase, projectName))
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ①c {FR_INDEX_DIGEST}(2026-09-18-fr-index-l1 L1,D-004):brainstorm step8 的触达域现行 FR 注入
|
|
838
|
+
// ——写作期防重复 FR 的确定性清单(superseded 默认藏);无触达域索引/索引空 → 段消隐。
|
|
839
|
+
// fr-inject 遥测(L3 证据发生器指标①)fail-soft;token 本体在 stages/brainstorm.js step8 模板。
|
|
840
|
+
if (stageName === 'brainstorm' && promptText.includes('{FR_INDEX_DIGEST}')) {
|
|
841
|
+
try {
|
|
842
|
+
const frSpecBase = resolvePromptSpecBase(platformOpts, cwd)
|
|
843
|
+
const { discoverModuleIndex } = await import('../decision-distill.js')
|
|
844
|
+
const { resolveTouchedDomains, readActiveFrDigest } = await import('../fr-index.js')
|
|
845
|
+
const frChangeDir = join(frSpecBase, 'changes', String(changeName || ''))
|
|
846
|
+
const knowledgeRoot = join(frSpecBase, 'knowledge')
|
|
847
|
+
const domains = resolveTouchedDomains(frChangeDir, discoverModuleIndex(knowledgeRoot))
|
|
848
|
+
const entries = readActiveFrDigest(knowledgeRoot, domains)
|
|
849
|
+
if (entries.length === 0) {
|
|
850
|
+
promptText = promptText.replace(/\{FR_INDEX_DIGEST\}/g, '(触达域暂无 active FR 索引条目——本变更大概率是这些域的首批需求,照常写作)')
|
|
851
|
+
} else {
|
|
852
|
+
const lines = entries.map((e) => `- ${e.id} ${e.title}(来源 ${e.change}${e.scenarios.length ? ';场景:' + e.scenarios.slice(0, 3).join(',') : ''})`)
|
|
853
|
+
lines.push('')
|
|
854
|
+
lines.push('> 域解析自本变更 design.md 文件清单(漏域先核对清单)。改写/取代已有行为 → 对应 FR 块加承接行;新行为 → 新 FR 块。superseded 条目默认不列(历史回溯自行读 knowledge/fr/)。')
|
|
855
|
+
promptText = promptText.replace(/\{FR_INDEX_DIGEST\}/g, lines.join('\n'))
|
|
856
|
+
}
|
|
857
|
+
try {
|
|
858
|
+
const { appendKnowledgeHit } = await import('../knowledge-hits.js')
|
|
859
|
+
appendKnowledgeHit(join(frSpecBase, '.runtime'), {
|
|
860
|
+
type: 'fr-inject', change: changeName, domains, count: entries.length,
|
|
861
|
+
})
|
|
862
|
+
} catch { /* 遥测 fail-soft */ }
|
|
863
|
+
} catch (e) {
|
|
864
|
+
promptText = promptText.replace(/\{FR_INDEX_DIGEST\}/g, `(FR 索引注入失败:${e && e.message ? e.message : e}——可自行读 {SPEC_ROOT}/knowledge/fr/)`)
|
|
865
|
+
}
|
|
835
866
|
}
|
|
836
867
|
|
|
837
868
|
// ② {GIT_DIRTY}:工作区脏文件清单(git status --porcelain,quick 收尾步消费)
|
|
@@ -20,10 +20,15 @@
|
|
|
20
20
|
* 4. evaluateConclusionDraft(前置三·结论草稿不进判定链):机械事实全绿(实测通过 + lint
|
|
21
21
|
* 非 failed + 探针 1/3/5/6 干净 + 风险门非 integration/deployment-critical)才给 PASS
|
|
22
22
|
* 草稿;gate 对结论槽的独立复核逻辑不动——草稿只省打字成本,槽行仍由 agent 复核可改写。
|
|
23
|
+
* 5. smoke 亲跑执行段(2026-09-17-api-coverage-smoke task-01 / FR-01):commands.smoke
|
|
24
|
+
* 显式配置才执行(300s 超时帽/快照内超时回退主仓复跑一次),stdout+stderr tee 实录落
|
|
25
|
+
* .runtime/verify-logs/smoke-<change>.log;失败/超时只记 additive smokeResult 失败态
|
|
26
|
+
* **不 throw**(封顶信号归 --done PASS 封顶消费,非崩溃——与 test failed 的 throw 语义
|
|
27
|
+
* 刻意区分)。指纹已含 commands 段原文——配置 smoke 前后指纹自动变化,代码未变不重跑。
|
|
23
28
|
*/
|
|
24
29
|
import { createHash } from 'node:crypto'
|
|
25
|
-
import { execSync } from 'node:child_process'
|
|
26
|
-
import { existsSync, readFileSync, mkdirSync } from 'node:fs'
|
|
30
|
+
import { execSync, spawnSync } from 'node:child_process'
|
|
31
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
27
32
|
import { join, dirname } from 'node:path'
|
|
28
33
|
import { writeAtomicSync } from '../fs-atomic.js'
|
|
29
34
|
import { gitQuiet } from '../git-helper.js'
|
|
@@ -88,8 +93,11 @@ export function computeQualityScanFingerprint({ cwd, specBase }) {
|
|
|
88
93
|
/**
|
|
89
94
|
* 实测结果 + 指纹落盘(noAI 动作调用;--done 复用读回)。fail-soft:落盘失败只 warn,
|
|
90
95
|
* --done 将照旧亲测(复用是优化不是正确性依赖)。
|
|
96
|
+
* smokeResult(2026-09-17-api-coverage-smoke task-01)为 additive 段:RECORD_SCHEMA_VERSION
|
|
97
|
+
* 保持 1——存量记录无 smoke 段照常读回(smokeResult 兜底 null);未配置 smoke 时段形态为
|
|
98
|
+
* configured=false(记录仍写,供 --done PASS 封顶判 not-configured)。
|
|
91
99
|
*/
|
|
92
|
-
export function storeQualityScan({ specBase, cwd, changeName, testResult, lintResult }) {
|
|
100
|
+
export function storeQualityScan({ specBase, cwd, changeName, testResult, lintResult, smokeResult }) {
|
|
93
101
|
const path = qualityScanRecordPath(specBase, changeName)
|
|
94
102
|
if (!path) return null
|
|
95
103
|
const fingerprint = computeQualityScanFingerprint({ cwd, specBase })
|
|
@@ -103,6 +111,7 @@ export function storeQualityScan({ specBase, cwd, changeName, testResult, lintRe
|
|
|
103
111
|
ranAt: new Date().toISOString(),
|
|
104
112
|
testResult,
|
|
105
113
|
lintResult: lintResult || null,
|
|
114
|
+
smokeResult: smokeResult || null,
|
|
106
115
|
}, null, 2) + '\n')
|
|
107
116
|
return { path, fingerprint }
|
|
108
117
|
} catch (e) {
|
|
@@ -114,6 +123,7 @@ export function storeQualityScan({ specBase, cwd, changeName, testResult, lintRe
|
|
|
114
123
|
/**
|
|
115
124
|
* 读回复用记录:schema/source 校验 + 指纹当前匹配 + testResult 有效(passed/skipped;
|
|
116
125
|
* failed 不复用——失败态指纹下步骤本就未完成,防御性兜底)。任一不满足 → null。
|
|
126
|
+
* 复用条件只判 testResult(smoke 段随整条记录复用,additive 读回 smokeResult)。
|
|
117
127
|
*/
|
|
118
128
|
export function loadReusableQualityScan({ specBase, cwd, changeName }) {
|
|
119
129
|
const path = qualityScanRecordPath(specBase, changeName)
|
|
@@ -125,7 +135,7 @@ export function loadReusableQualityScan({ specBase, cwd, changeName }) {
|
|
|
125
135
|
if (rec.testResult.status === 'failed') return null
|
|
126
136
|
const fp = computeQualityScanFingerprint({ cwd, specBase })
|
|
127
137
|
if (!fp || fp !== rec.fingerprint) return null
|
|
128
|
-
return { testResult: rec.testResult, lintResult: rec.lintResult || null, ranAt: rec.ranAt || null, fingerprint: fp }
|
|
138
|
+
return { testResult: rec.testResult, lintResult: rec.lintResult || null, smokeResult: rec.smokeResult || null, ranAt: rec.ranAt || null, fingerprint: fp }
|
|
129
139
|
} catch {
|
|
130
140
|
return null
|
|
131
141
|
}
|
|
@@ -245,6 +255,131 @@ export function renderCoverageExistenceReport(cov) {
|
|
|
245
255
|
}
|
|
246
256
|
|
|
247
257
|
|
|
258
|
+
// ── smoke 亲跑执行段(2026-09-17-api-coverage-smoke task-01,FR-01 / D-001@v1 / D-010@v1)──
|
|
259
|
+
// 300s 超时帽与 gate-snapshot.js runGateSnapshotCommands 同款(300_000 先例)。
|
|
260
|
+
const SMOKE_TIMEOUT_MS = 300_000
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 从 local.yaml 文本提取 commands.smoke。
|
|
264
|
+
* 与 extractTestCommand 同风格同容错(带引号/不带引号;'unavailable' 视为未配置——
|
|
265
|
+
* 未配置/unavailable 口径对齐 coverage 先例:执行段休眠,记录 configured=false)。
|
|
266
|
+
* 模块内私有(runSmokeCheck 消费)。
|
|
267
|
+
*/
|
|
268
|
+
function extractSmokeCommand(yamlText) {
|
|
269
|
+
if (!yamlText) return null
|
|
270
|
+
const doubleQuoted = yamlText.match(/^\s*smoke:\s*"([^"]+)"\s*(?:#.*)?$/m)
|
|
271
|
+
const singleQuoted = yamlText.match(/^\s*smoke:\s*'([^']+)'\s*(?:#.*)?$/m)
|
|
272
|
+
const quoted = doubleQuoted || singleQuoted
|
|
273
|
+
if (quoted && quoted[1]) {
|
|
274
|
+
return quoted[1].toLowerCase() === 'unavailable' ? null : quoted[1].trim()
|
|
275
|
+
}
|
|
276
|
+
// bare 值不排除引号字符(extractTestCommand 坑 verify-bare-quote-miss 同款修正):
|
|
277
|
+
// 全引号形态已被上方 quoted 分支先行消费,此处引号只可能是嵌在值中部的。
|
|
278
|
+
const bare = yamlText.match(/^\s*smoke:[ \t]*([^\n#]+?)[ \t]*(?:#.*)?$/m)
|
|
279
|
+
if (bare && bare[1]) {
|
|
280
|
+
const cmd = bare[1].trim()
|
|
281
|
+
return cmd.toLowerCase() === 'unavailable' ? null : cmd
|
|
282
|
+
}
|
|
283
|
+
return null
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** spawnSync 结果的超时判定(gate-snapshot.js runGateSnapshotCommands 同款口径)。 */
|
|
287
|
+
function isSyncTimeout(r) {
|
|
288
|
+
return Boolean(r && r.error && (r.error.code === 'ETIMEDOUT' || /ETIMEDOUT/i.test(String(r.error.message || ''))))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* noAI smoke 亲跑:local.yaml 显式配置 commands.smoke 才执行(detect 不自动写,缺省零行为)。
|
|
293
|
+
* 命令脚本自理服务生命周期(后台起服+轮询就绪+finally 杀),CLI 只施加 300s 超时帽并 tee
|
|
294
|
+
* 实录 stdout+stderr 落 .runtime/verify-logs/smoke-<change>.log。失败语义与 test/lint 刻意
|
|
295
|
+
* 区分:非零退出/超时只记 smokeResult 失败态**不 throw**(封顶信号非崩溃——PASS 封顶消费
|
|
296
|
+
* 归 --done 第五条件 / task-03),本步推进语义不动。快照内超时 → 主仓 cwd 复跑一次
|
|
297
|
+
* (lint 快照超时回退同款策略:junction I/O 病态慢时保实测不假败)。
|
|
298
|
+
* 返回纯 JSON 可序列化(落进质量扫描记录 additive smokeResult 段)。
|
|
299
|
+
*/
|
|
300
|
+
function runSmokeCheck({ gateCwd, mainCwd, specBase, changeName, inSnapshot }) {
|
|
301
|
+
const ranAt = new Date().toISOString()
|
|
302
|
+
let yamlText = null
|
|
303
|
+
try { yamlText = readFileSync(join(specBase, 'local.yaml'), 'utf8') } catch { /* 无 local.yaml → 未配置 */ }
|
|
304
|
+
const command = extractSmokeCommand(yamlText)
|
|
305
|
+
if (!command) {
|
|
306
|
+
return {
|
|
307
|
+
configured: false, status: 'skipped', command: null,
|
|
308
|
+
exitCode: null, durationMs: null, logPath: null, ranAt,
|
|
309
|
+
timeout: false, fallbackMainRepo: false, source: 'cli-noai-smoke',
|
|
310
|
+
reason: yamlText ? '未配置 commands.smoke(或标记 unavailable)——执行段跳过(配置后 verify 自动亲跑)' : `local.yaml 不可读(${join(specBase, 'local.yaml')})`,
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const startedAt = Date.now()
|
|
314
|
+
let r = null
|
|
315
|
+
let spawnError = null
|
|
316
|
+
try {
|
|
317
|
+
r = spawnSync(command, { shell: true, cwd: gateCwd, timeout: SMOKE_TIMEOUT_MS, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 })
|
|
318
|
+
} catch (e) { spawnError = e }
|
|
319
|
+
let timedOut = isSyncTimeout(r)
|
|
320
|
+
let fallbackMainRepo = false
|
|
321
|
+
let ranCwd = gateCwd
|
|
322
|
+
// 快照内超时回退主仓复跑一次(lint 快照超时回退先例同款策略,主仓复跑保实测不假败)
|
|
323
|
+
if (inSnapshot && timedOut) {
|
|
324
|
+
fallbackMainRepo = true
|
|
325
|
+
console.warn('⚠️ 快照 smoke 超时(node_modules junction I/O 慢)→ 主仓复跑 smoke(并行噪声可能混入——失败先做归属鉴定)')
|
|
326
|
+
try {
|
|
327
|
+
r = spawnSync(command, { shell: true, cwd: mainCwd, timeout: SMOKE_TIMEOUT_MS, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 })
|
|
328
|
+
spawnError = null
|
|
329
|
+
ranCwd = mainCwd
|
|
330
|
+
timedOut = isSyncTimeout(r)
|
|
331
|
+
} catch (e) { spawnError = e; timedOut = false }
|
|
332
|
+
}
|
|
333
|
+
const durationMs = Date.now() - startedAt
|
|
334
|
+
const exitCode = r && typeof r.status === 'number' ? r.status : null
|
|
335
|
+
const failed = timedOut || Boolean(spawnError) || (r ? r.status !== 0 : true)
|
|
336
|
+
const reason = timedOut
|
|
337
|
+
? `超时(>300s 帽${fallbackMainRepo ? ',主仓复跑仍超时' : ''})`
|
|
338
|
+
: spawnError
|
|
339
|
+
? `spawn 异常:${spawnError.message || spawnError}`
|
|
340
|
+
: (exitCode !== 0 ? `smoke 命令退出码 ${exitCode}` : null)
|
|
341
|
+
// log 实录落盘(fail-soft:写失败只 warn 置 logPath=null,不影响判定与步骤推进)
|
|
342
|
+
let logPath = null
|
|
343
|
+
if (changeName) {
|
|
344
|
+
try {
|
|
345
|
+
const logDir = join(specBase, '.runtime', 'verify-logs')
|
|
346
|
+
mkdirSync(logDir, { recursive: true })
|
|
347
|
+
logPath = join(logDir, `smoke-${changeName}.log`)
|
|
348
|
+
const head = [
|
|
349
|
+
'# smoke 实录(source: cli-noai-smoke)',
|
|
350
|
+
`# command: ${command}`,
|
|
351
|
+
`# cwd: ${ranCwd}${fallbackMainRepo ? '(快照超时回退主仓)' : ''}`,
|
|
352
|
+
`# ranAt: ${ranAt} durationMs: ${durationMs} timeout: ${timedOut}`,
|
|
353
|
+
`# exitCode: ${exitCode === null ? '(无——spawn 异常/被信号杀)' : exitCode}`,
|
|
354
|
+
'---- stdout + stderr ----',
|
|
355
|
+
].join('\n')
|
|
356
|
+
writeFileSync(logPath, `${head}\n${String(r && r.stdout || '')}${String(r && r.stderr || '')}\n`)
|
|
357
|
+
} catch (e) {
|
|
358
|
+
logPath = null
|
|
359
|
+
console.warn(`⚠️ smoke 实录 log 落盘失败(fail-soft,判定不受影响): ${e && e.message ? e.message : e}`)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
configured: true, status: failed ? 'failed' : 'passed', command,
|
|
364
|
+
exitCode, durationMs, logPath, ranAt,
|
|
365
|
+
timeout: timedOut, fallbackMainRepo, source: 'cli-noai-smoke',
|
|
366
|
+
reason,
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** smoke 结果一行报告(语义边界内建:失败=封顶信号非崩溃,不阻断本步) */
|
|
371
|
+
function renderSmokeReport(s) {
|
|
372
|
+
if (!s || !s.configured) {
|
|
373
|
+
return `ℹ️ smoke 冒烟:${s && s.reason ? s.reason : '未配置 commands.smoke'}`
|
|
374
|
+
}
|
|
375
|
+
const logNote = s.logPath ? `——实录 ${s.logPath}` : ''
|
|
376
|
+
if (s.status === 'passed') {
|
|
377
|
+
return `✅ smoke 冒烟通过(exit 0,${s.durationMs}ms${s.fallbackMainRepo ? ';快照超时已回退主仓复跑' : ''})${logNote}`
|
|
378
|
+
}
|
|
379
|
+
return `⚠️ smoke 冒烟失败(${s.reason || `退出码 ${s.exitCode}`})——记失败态不阻断本步(PASS 封顶信号,--done 消费)${logNote}`
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
|
|
248
383
|
/** fallback 口径可见性(2026-09-11 驾驭第十二批):快照不可用回退主仓且主仓存在非 .sillyspec
|
|
249
384
|
* 脏文件时,明示实测判定面含并行 WIP——第三撞根因是「静默 fallback 看不出走了哪个口径」。 */
|
|
250
385
|
function warnIfMainRepoDirtyForGate(cwd) {
|
|
@@ -289,6 +424,7 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
|
|
|
289
424
|
let testCheck
|
|
290
425
|
let lintCheck
|
|
291
426
|
let coverageCheck = null
|
|
427
|
+
let smokeResult = null
|
|
292
428
|
try {
|
|
293
429
|
testCheck = runVerifyTestCheck({ cwd: gateCwd, specBase: gateSpecBase, changeName })
|
|
294
430
|
printVerifyTestCheck(testCheck)
|
|
@@ -304,6 +440,16 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
|
|
|
304
440
|
console.log(`\n⏳ noAI 质量扫描:CLI 亲自实测 commands.lint…`)
|
|
305
441
|
}
|
|
306
442
|
printVerifyLintCheck(lintCheck)
|
|
443
|
+
// smoke 亲跑(2026-09-17-api-coverage-smoke task-01 / FR-01):commands.smoke 配置才执行
|
|
444
|
+
// ——失败记失败态不 throw(与 test failed 的 throw 语义刻意区分:封顶信号非崩溃,PASS
|
|
445
|
+
// 封顶消费归 --done 第五条件 / task-03)。在快照 cleanup 前跑(cwd=gateCwd 隔离快照内),
|
|
446
|
+
// 配置从主仓 local.yaml 读(与 coverage 同口径);未配置一行 ℹ️(coverage 同款噪音面)。
|
|
447
|
+
try {
|
|
448
|
+
smokeResult = runSmokeCheck({ gateCwd, mainCwd: cwd, specBase, changeName, inSnapshot: Boolean(snap) })
|
|
449
|
+
console.log(renderSmokeReport(smokeResult))
|
|
450
|
+
} catch (e) {
|
|
451
|
+
console.warn(`⚠️ smoke 冒烟执行异常(fail-soft,不影响本步推进): ${e && e.message ? e.message : e}`)
|
|
452
|
+
}
|
|
307
453
|
// P2(noai-ir-roadmap §5):coverage 存在性事实——commands.coverage 显式配置才采集,
|
|
308
454
|
// 只证「有覆盖记录」不证「覆盖了行为」(渲染自带语义边界),fail-soft 不阻断。
|
|
309
455
|
try {
|
|
@@ -315,7 +461,7 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
|
|
|
315
461
|
} finally {
|
|
316
462
|
if (snap) { try { snap.cleanup() } catch {} }
|
|
317
463
|
}
|
|
318
|
-
storeQualityScan({ specBase, cwd, changeName, testResult: testCheck, lintResult: lintCheck, coverageCheck })
|
|
464
|
+
storeQualityScan({ specBase, cwd, changeName, testResult: testCheck, lintResult: lintCheck, smokeResult, coverageCheck })
|
|
319
465
|
const testFailed = testCheck.status === 'failed'
|
|
320
466
|
const lintBlocked = shouldBlockVerifyLint(lintCheck)
|
|
321
467
|
// 摩擦计数(friction-signal-hint task-03):记录条件与 throw 条件**刻意解耦**——advisory 档
|