sillyspec 3.27.9 → 3.27.12
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 +214 -214
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-quick/SKILL.md +116 -116
- package/README.md +3 -2
- package/package.json +1 -1
- package/src/change-delete.js +181 -0
- package/src/commit-suggest.js +22 -0
- package/src/contract-matrix.js +20 -4
- package/src/decision-distill.js +33 -14
- package/src/docs-check.js +138 -2
- package/src/endpoint-extractor.js +7 -4
- package/src/facade-hint.js +170 -0
- package/src/index.js +259 -22
- package/src/init.js +104 -27
- package/src/module-changelog.js +116 -0
- package/src/progress/change-registry.js +28 -0
- package/src/progress/shared.js +6 -0
- package/src/progress/stage-machine.js +85 -8
- package/src/progress.js +3 -0
- package/src/run/command.js +60 -0
- package/src/run/complete-handlers.js +25 -1
- package/src/run/gates.js +62 -5
- package/src/run/prompt.js +12 -1
- package/src/run/shared.js +81 -23
- package/src/run/stage.js +21 -1
- package/src/spec-sync.js +88 -1
- package/src/stages/archive.js +5 -0
- package/src/stages/execute.js +3 -1
- package/src/sync.js +453 -17
- package/src/verify-postcheck.js +47 -6
- package/src/workflow.js +4 -0
- package/src/worktree-apply.js +62 -4
- package/src/worktree.js +6 -4
- package/templates/{claude-instruction.md → agents-instruction.md} +2 -2
- package/templates/prompts/verify-probes.md +2 -1
package/src/sync.js
CHANGED
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
* HTTP 请求:Node.js 原生 fetch(Node 22+)
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync, chmodSync } from 'fs';
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync, chmodSync, rmSync } from 'fs';
|
|
12
12
|
import { writeAtomicSync } from './fs-atomic.js';
|
|
13
|
-
import { join } from 'path';
|
|
13
|
+
import { join, dirname, basename, isAbsolute, relative } from 'path';
|
|
14
14
|
import { resolvePlatformSpecDir } from './progress.js';
|
|
15
15
|
import { safeGit } from './git-helper.js';
|
|
16
16
|
import { openDatabase } from './db-engine.js';
|
|
17
|
-
import { PLATFORM_MANAGED_FILENAME } from './run/shared.js';
|
|
17
|
+
import { PLATFORM_MANAGED_FILENAME, QUICK_SID_RE } from './run/shared.js';
|
|
18
18
|
import { syncSpecTree } from './spec-sync.js';
|
|
19
19
|
|
|
20
20
|
// sync 是 best-effort(网络失败只 warn):平台指针失效时不抛,跳过平台、回退本地。
|
|
@@ -29,6 +29,9 @@ function safePlatformSpecDir(cwd) {
|
|
|
29
29
|
|
|
30
30
|
const LOCAL_YAML = '.sillyspec/local.yaml';
|
|
31
31
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
32
|
+
// X2 spec bundle 整树下载(tar 流式响应体,审计 B2):大树/慢网远超常规 JSON 轮询口径,
|
|
33
|
+
// 独立大超时(daemon 同端点 30s;CLI 面向交互式大仓更宽)。
|
|
34
|
+
const PULL_BUNDLE_TIMEOUT_MS = 120_000;
|
|
32
35
|
|
|
33
36
|
// 未连接平台是本地独立用户的合法默认状态。sync / checkApproval 由 run 流程在后台 best-effort
|
|
34
37
|
// 触发(每步完成、execute 阶段启动);syncDocuments 仅由手动 `sillyspec platform sync-docs`
|
|
@@ -205,8 +208,10 @@ function resolvePlatformUser(cwd, explicitUser) {
|
|
|
205
208
|
// HUB-09:单请求超时与外部 signal 合并——自动同步熔断(run/shared.js trigger* 的 race)
|
|
206
209
|
// 触发 abort 时在飞 fetch 被真实取消,而不是熔断后任由请求自行完成(平台可能已接受,
|
|
207
210
|
// 客户端却当作超时放弃;spec 树推送无 base_ts 自愈兜底)。AbortSignal.any 需 Node ≥20.3。
|
|
208
|
-
|
|
209
|
-
|
|
211
|
+
// timeoutMs 可选:pullSpecBundle 整树下载走 PULL_BUNDLE_TIMEOUT_MS 大口径(审计 B2),
|
|
212
|
+
// 其余调用缺省 REQUEST_TIMEOUT_MS。
|
|
213
|
+
function combineSignals(external, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
214
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
210
215
|
return external ? AbortSignal.any([timeoutSignal, external]) : timeoutSignal;
|
|
211
216
|
}
|
|
212
217
|
|
|
@@ -262,6 +267,129 @@ async function fetchJsonWithStatus(url, options = {}) {
|
|
|
262
267
|
}
|
|
263
268
|
}
|
|
264
269
|
|
|
270
|
+
// ── X2 spec bundle tar 解析(task-14 / design §7.1)──
|
|
271
|
+
|
|
272
|
+
// 平台 build_bundle(spec_workspace/service.py,Python tarfile mode "w")输出未压缩 tar,
|
|
273
|
+
// Python 3.8+ 默认 PAX 格式:路径 ≤100 字符是纯 ustar 头,超限走 typeflag 'x' 的 PAX
|
|
274
|
+
// 扩展头(path 记录)+ 截断名条目;GNU tar 长名走 'L'。仓内无三方 tar 库(package.json
|
|
275
|
+
// deps 无 tar/zlib 需求),按 512 字节头极简自实现——tar-slip 双重校验照搬 daemon
|
|
276
|
+
// extractTar(sillyhub-daemon/src/spec-sync.ts:982)同款范式,两仓行为对齐。
|
|
277
|
+
|
|
278
|
+
/** tar 头字符串字段读:截到首个 NUL,去首尾空白。 */
|
|
279
|
+
function _readTarString(buf) {
|
|
280
|
+
const end = buf.indexOf(0);
|
|
281
|
+
const s = (end === -1 ? buf : buf.subarray(0, end)).toString('utf8');
|
|
282
|
+
return s.replace(/\0/g, '').trim();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** tar 头 size 字段(12 字节八进制)→ 字节数。 */
|
|
286
|
+
function _readTarSize(field) {
|
|
287
|
+
const s = _readTarString(field);
|
|
288
|
+
if (!s) return 0;
|
|
289
|
+
return parseInt(s, 8) || 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* PAX 扩展头 data 解析为 {key: value}:记录形态 "<len> <key>=<value>\n" 重复,
|
|
294
|
+
* len 十进制**字节数**且含自身位数、空格与尾部 \n。
|
|
295
|
+
* 必须在 Buffer 上按字节偏移推进/切片:value 含非 ASCII(中文文件名)时 len(字节)
|
|
296
|
+
* > JS 字符串长度(UTF-16 码元),按码元校验会首记录即 break、path 丢失 → 文件落
|
|
297
|
+
* 实体头 name 的 ascii/replace 混淆路径并互相覆盖(与 daemon spec-sync.ts 同款修复)。
|
|
298
|
+
*/
|
|
299
|
+
function _parsePaxRecords(data) {
|
|
300
|
+
const out = {};
|
|
301
|
+
let pos = 0;
|
|
302
|
+
while (pos < data.length) {
|
|
303
|
+
const sp = data.indexOf(0x20, pos);
|
|
304
|
+
if (sp === -1) break;
|
|
305
|
+
const len = parseInt(data.toString('utf8', pos, sp), 10);
|
|
306
|
+
if (!Number.isInteger(len) || len <= 0 || pos + len > data.length) break;
|
|
307
|
+
const record = data.toString('utf8', sp + 1, pos + len - 1); // 尾部 \n 不属于 value
|
|
308
|
+
const eq = record.indexOf('=');
|
|
309
|
+
if (eq > 0) out[record.slice(0, eq)] = record.slice(eq + 1);
|
|
310
|
+
pos += len;
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* tar-slip 双重校验(daemon extractTar 同款):显式穿越模式 + join 后 relative 复核。
|
|
317
|
+
* 通过返回安全相对路径的原样 name;命中穿越抛 Error(调用方转为失败 reason,不落盘)。
|
|
318
|
+
*/
|
|
319
|
+
function _assertSafeTarName(name, targetDir) {
|
|
320
|
+
if (name.includes('..') || isAbsolute(name) || /^[A-Za-z]:[\\/]/.test(name)) {
|
|
321
|
+
throw new Error(`tar 路径越界已拦截: ${name}`);
|
|
322
|
+
}
|
|
323
|
+
const fullPath = join(targetDir, name);
|
|
324
|
+
const rel = relative(targetDir, fullPath);
|
|
325
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
326
|
+
throw new Error(`tar 路径逃逸目标目录已拦截: ${name} -> ${fullPath}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* 解析整只未压缩 tar 为条目列表 [{ name, isDir, data }](先全量解析校验,后落盘——
|
|
332
|
+
* 恶意/损坏 tar 在任何本地删除动作之前暴露,见 pullSpecBundle 覆盖顺序)。
|
|
333
|
+
* 容忍并落地 tar 顶层 PLATFORM-BUNDLE.json(task-08 快照元数据,design §7.3);
|
|
334
|
+
* symlink/fifo/dev 等非常规条目跳过(spec 树不应含,daemon 同款)。
|
|
335
|
+
* @param {Buffer} tarBuf
|
|
336
|
+
* @returns {Array<{name: string, isDir: boolean, data: Buffer}>}
|
|
337
|
+
*/
|
|
338
|
+
function _parseSpecTar(tarBuf) {
|
|
339
|
+
const entries = [];
|
|
340
|
+
let offset = 0;
|
|
341
|
+
let pendingPath = null; // PAX 'x' path 记录 / GNU 'L' 长名,覆盖下一实条目的 name
|
|
342
|
+
while (offset + 512 <= tarBuf.length) {
|
|
343
|
+
const header = tarBuf.subarray(offset, offset + 512);
|
|
344
|
+
if (header.every((b) => b === 0)) break; // 结尾 zero block → 结束
|
|
345
|
+
const typeflag = String.fromCharCode(header[156] ?? 0);
|
|
346
|
+
const size = _readTarSize(header.subarray(124, 136));
|
|
347
|
+
offset += 512;
|
|
348
|
+
const data = tarBuf.subarray(offset, offset + size);
|
|
349
|
+
offset += Math.ceil(size / 512) * 512;
|
|
350
|
+
|
|
351
|
+
let name = _readTarString(header.subarray(0, 100));
|
|
352
|
+
// POSIX ustar prefix 字段(magic 'ustar\0' + version '00' 时有效);GNU 长名走 'L' 条目
|
|
353
|
+
const magic = header.subarray(257, 263).toString('latin1');
|
|
354
|
+
const version = header.subarray(263, 265).toString('latin1');
|
|
355
|
+
if (magic.startsWith('ustar') && version === '00') {
|
|
356
|
+
const prefix = _readTarString(header.subarray(345, 500));
|
|
357
|
+
if (prefix) name = `${prefix}/${name}`;
|
|
358
|
+
}
|
|
359
|
+
if (pendingPath) { name = pendingPath; pendingPath = null; }
|
|
360
|
+
|
|
361
|
+
if (typeflag === 'x' || typeflag === 'X') {
|
|
362
|
+
const recs = _parsePaxRecords(data);
|
|
363
|
+
if (recs.path) pendingPath = recs.path;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (typeflag === 'L') { // GNU LongLink(长文件名)
|
|
367
|
+
pendingPath = _readTarString(data);
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (typeflag === 'K') continue; // GNU 长链接目标:spec 树无符号链接,跳过
|
|
371
|
+
if (!name) continue;
|
|
372
|
+
|
|
373
|
+
if (typeflag === '5' || name.endsWith('/')) {
|
|
374
|
+
_assertSafeTarName(name.replace(/\/+$/, ''), '.'); // 目录条目同样过穿越校验(相对假根)
|
|
375
|
+
entries.push({ name, isDir: true, data: Buffer.alloc(0) });
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (typeflag === '0' || typeflag === '\0') {
|
|
379
|
+
_assertSafeTarName(name, '.');
|
|
380
|
+
entries.push({ name, isDir: false, data });
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
debugLog(`[sync] spec bundle tar 跳过非常规条目: ${name} (typeflag ${typeflag})`);
|
|
384
|
+
}
|
|
385
|
+
return entries;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** 目录有内容判定(daemon dirHasContent 同语义):不存在/读失败/空均视为无内容。 */
|
|
389
|
+
function _dirHasContent(dir) {
|
|
390
|
+
try { return readdirSync(dir).length > 0; } catch { return false }
|
|
391
|
+
}
|
|
392
|
+
|
|
265
393
|
// ── SyncManager ──
|
|
266
394
|
|
|
267
395
|
export class SyncManager {
|
|
@@ -438,18 +566,29 @@ export class SyncManager {
|
|
|
438
566
|
const changeDir = join(specBase, 'changes', changeName);
|
|
439
567
|
let archivedQuietly = false;
|
|
440
568
|
if (!existsSync(changeDir)) {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
if (
|
|
444
|
-
console.log(`[sync]
|
|
569
|
+
// change-delete(2026-08-30):DB status='deleted' 的行目录已随删除移除,属正常时序,
|
|
570
|
+
// 单行 info 提示后继续推删除终态/墓碑(不落入「目录不存在」warn)
|
|
571
|
+
if (this._changeDbStatus(changeName) === 'deleted') {
|
|
572
|
+
console.log(`[sync] 变更已删除(DB status=deleted),继续推送删除终态/墓碑: ${changeName}`);
|
|
445
573
|
} else {
|
|
446
|
-
|
|
574
|
+
archivedQuietly = existsSync(join(specBase, 'changes', 'archive', changeName))
|
|
575
|
+
|| this._changeDbStatus(changeName) === 'archived';
|
|
576
|
+
if (archivedQuietly) {
|
|
577
|
+
console.log(`[sync] 变更已归档(目录在 archive/),继续从 DB 推送最终状态: ${changeName}`);
|
|
578
|
+
} else {
|
|
579
|
+
console.warn(`[sync] 变更目录不存在(可能是已归档,继续从 DB 同步最终状态): ${changeName}`);
|
|
580
|
+
}
|
|
447
581
|
}
|
|
448
582
|
}
|
|
449
583
|
// 透传给本次 sync 链内的 syncDocuments(best-effort 四件套直推:归档后目录已移走,
|
|
450
584
|
// 其「变更不存在」warn 属正常时序,静默化)
|
|
451
585
|
this._suppressDocsMissingWarn = archivedQuietly;
|
|
452
586
|
|
|
587
|
+
// X1 墓碑(design §5.5,变更 2026-08-29-change-delete-closure-and-spec-pull task-13):
|
|
588
|
+
// 触发判据见循环内 tombstoneDue(DB status='archived')。
|
|
589
|
+
// 常规终态推送保持原语义(archived/active 原值照推,platform-sync-archive-final-state 钉死),
|
|
590
|
+
// 墓碑作为推送链成功后的追加 POST(见 _pushTombstone)。
|
|
591
|
+
|
|
453
592
|
const MAX_PUSH_ATTEMPTS = 2;
|
|
454
593
|
for (let attempt = 1; attempt <= MAX_PUSH_ATTEMPTS; attempt++) {
|
|
455
594
|
// 读取 progress 数据(serializeForSync 六表裸 JSON,task-02 / D-005@v2,替代 read() 聚合视图)。
|
|
@@ -468,6 +607,25 @@ export class SyncManager {
|
|
|
468
607
|
return { synced: 0, errors: [`变更无进度数据: ${changeName}`] };
|
|
469
608
|
}
|
|
470
609
|
|
|
610
|
+
// X1 墓碑判定(design §5.5 / task-13;审计 B1 收窄):仅 DB status='archived'
|
|
611
|
+
// (unregisterChange 链——归档收尾/自愈/quick 收尾均置该值)或 status='deleted'
|
|
612
|
+
// (change-delete 命令,2026-08-30——载荷本身携带 deleted,平台 _apply_cli_tombstone
|
|
613
|
+
// 见该值即置 location='deleted';追加墓碑 POST 幂等兜底)触发,常规推送成功后追加
|
|
614
|
+
// 一次 changes[].status='deleted' 墓碑(平台 task-04 写路径置 location='deleted' 收敛镜像)。
|
|
615
|
+
// 不再把「实体目录双失」当裸删发墓碑:platform pull(进度同步)只写 DB 不建目录 →
|
|
616
|
+
// DB active + 目录双失是合法态,误发墓碑会让平台把活跃变更软删(全体成员生效)。
|
|
617
|
+
// 真实裸删(用户手动 rm -rf 目录)由平台镜像收敛兜底(spec-sync delete ops → scoped
|
|
618
|
+
// 定向删除,computeSpecOps 护栏只拦整树/changes 整删,不拦单变更 scoped 删除),
|
|
619
|
+
// CLI 不发墓碑不损失收敛。quick 会话豁免:triggerSync 对 quick 已降级 syncSpecTreeOnly,
|
|
620
|
+
// 直调也不给孤儿 key 造墓碑。
|
|
621
|
+
const tombstoneDue = !QUICK_SID_RE.test(changeName)
|
|
622
|
+
&& (progressData.changes?.[0]?.status === 'archived'
|
|
623
|
+
|| progressData.changes?.[0]?.status === 'deleted');
|
|
624
|
+
|
|
625
|
+
// X3 步骤开始上报(design §8.2 / task-13):opts.stepStart 时把 current_stage 的第一个
|
|
626
|
+
// 未完成步投影为 in-progress(仅载荷不写 DB;triggerStepStartSync 是唯一传该 flag 的入口)
|
|
627
|
+
if (opts.stepStart) this._projectStepStart(progressData, changeName);
|
|
628
|
+
|
|
471
629
|
// 元字段走 HTTP header(D-015 / task-09):body 保持裸六表 JSON,sillyhub 老版忽略 header 零回归
|
|
472
630
|
const headers = {
|
|
473
631
|
'Content-Type': 'application/json',
|
|
@@ -493,6 +651,14 @@ export class SyncManager {
|
|
|
493
651
|
signal: opts.signal, // HUB-09:熔断 abort 传到底层请求
|
|
494
652
|
});
|
|
495
653
|
|
|
654
|
+
// X1(task-04 契约):409 + code='change_deleted' = 平台已删 key 拒收——不是 base_ts 冲突。
|
|
655
|
+
// 单行提示即可,不落冲突文件、不打全幅横幅:墓碑已收敛后的重复上行(归档收尾多步各推一次)、
|
|
656
|
+
// 多用户下他端删除后本地仍在推进,都是可预期态,按冲突卡死人工 resolve 反而是误报。
|
|
657
|
+
if (res.status === 409 && res.body && res.body.code === 'change_deleted') {
|
|
658
|
+
console.warn(`⚠️ [sync] 平台已删除变更「${changeName}」,本次进度上行被拒收(change_deleted);本地如仍需推进请在平台侧确认`);
|
|
659
|
+
return { synced: 0, errors: [], platformDeleted: true, reason: '平台已删除(change_deleted 拒收)' };
|
|
660
|
+
}
|
|
661
|
+
|
|
496
662
|
if (res.ok) {
|
|
497
663
|
// 更新 platform_last_sync + 推进 base_ts(ql-20260818-008:值优先平台回执 last_pushed_at,
|
|
498
664
|
// 缺省用本次 pushedAt——服务器 _apply 存的就是客户端 X-SillySpec-Pushed-At 原值,回写一致。
|
|
@@ -541,6 +707,18 @@ export class SyncManager {
|
|
|
541
707
|
debugLog(`[sync] spec 树增量同步失败(不影响进度): ${changeName}: ${err.message}`);
|
|
542
708
|
}
|
|
543
709
|
|
|
710
|
+
// X1 墓碑上行(收敛加速器,best-effort):常规终态 + 文档/spec 树都推完后追加一次
|
|
711
|
+
// changes[].status='deleted'(顺序在后——常规推送保持 archived/active 原语义是既有
|
|
712
|
+
// 契约,platform-sync-archive-final-state 钉死)。失败静默:平台闭环走方案 A 镜像驱动
|
|
713
|
+
// 收敛(design §5.5),墓碑只是让收敛即时。
|
|
714
|
+
if (tombstoneDue) {
|
|
715
|
+
try {
|
|
716
|
+
await this._pushTombstone(changeName, { signal: opts.signal });
|
|
717
|
+
} catch (err) {
|
|
718
|
+
debugLog(`[sync] 墓碑上行失败(不影响主推送): ${changeName}: ${err.message}`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
544
722
|
return { synced: 1, errors: [] };
|
|
545
723
|
}
|
|
546
724
|
|
|
@@ -620,6 +798,92 @@ export class SyncManager {
|
|
|
620
798
|
return { synced: 0, errors: [`同步重试耗尽(仍冲突): ${changeName}`] };
|
|
621
799
|
}
|
|
622
800
|
|
|
801
|
+
/**
|
|
802
|
+
* X3 步骤开始上报的载荷投影(design §8.2,变更 2026-08-29-change-delete-closure-and-spec-pull
|
|
803
|
+
* task-13):把 current_stage 下第一个未完成步(completed/skipped 之外,含 waiting——等待中
|
|
804
|
+
* 的步同样「在跑」)的 status 改为 'in-progress'。
|
|
805
|
+
*
|
|
806
|
+
* 仅改本次上行载荷,不写 DB——步骤行 DB 种子是 pending,completeStep 的
|
|
807
|
+
* findIndex(pending||in-progress) 两者等价,但保持 DB 不被推送侧污染(--done 推送路径
|
|
808
|
+
* 行为不变,回归约束)。六表 steps[].status 状态机本就含 in-progress 值(step-store
|
|
809
|
+
* VALID_STATUSES),平台侧裸 JSON 透传(design §8.2 已核实后端零改动)。
|
|
810
|
+
* @param {object} progressData serializeForSync 输出(原地修改)
|
|
811
|
+
* @param {string} changeName
|
|
812
|
+
*/
|
|
813
|
+
_projectStepStart(progressData, changeName) {
|
|
814
|
+
try {
|
|
815
|
+
const row = progressData && progressData.changes && progressData.changes[0];
|
|
816
|
+
if (!row || !row.current_stage || !Array.isArray(progressData.steps)) return;
|
|
817
|
+
const idx = progressData.steps.findIndex(s =>
|
|
818
|
+
s.change_name === changeName && s.stage === row.current_stage
|
|
819
|
+
&& s.status !== 'completed' && s.status !== 'skipped');
|
|
820
|
+
if (idx !== -1) progressData.steps[idx].status = 'in-progress';
|
|
821
|
+
} catch { /* 投影失败按原载荷推送(best-effort) */ }
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* X1:把墓碑状态写进载荷——changes[0].status='deleted'(对齐既有 'archived' 状态语义,
|
|
826
|
+
* design §5.5)。平台写路径(服务端 task-04 `_apply_cli_tombstone`)见到该值即置
|
|
827
|
+
* Change.location='deleted' 并触发镜像软删收敛。
|
|
828
|
+
* @param {object} progressData serializeForSync 输出(原地修改,返回同一引用)
|
|
829
|
+
*/
|
|
830
|
+
_applyTombstoneStatus(progressData) {
|
|
831
|
+
if (progressData && Array.isArray(progressData.changes) && progressData.changes[0]) {
|
|
832
|
+
progressData.changes[0].status = 'deleted';
|
|
833
|
+
}
|
|
834
|
+
return progressData;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* X1 墓碑上行(design §5.5 / task-13):同端点同结构的一次追加 POST(changes[].status='deleted')。
|
|
839
|
+
*
|
|
840
|
+
* 由 sync() 成功路径在常规推送 + 文档/spec 树链之后触发(CLI 单进程顺序推送,base_ts 进程内
|
|
841
|
+
* 单调,无乐观锁冲突)。fresh 重读 DB 序列化——常规推送成功的 base_ts 回填已落库,本推送带上
|
|
842
|
+
* 该 base_ts;重复墓碑(平台已删)收到 409 code='change_deleted' 属预期,静默返回。
|
|
843
|
+
* Best-effort:任何失败只 debugLog,不影响 sync() 主结果(平台闭环走方案 A 兜底)。
|
|
844
|
+
* @param {string} changeName
|
|
845
|
+
* @param {{signal?: object}} [opts]
|
|
846
|
+
* @returns {Promise<{synced: number}>}
|
|
847
|
+
*/
|
|
848
|
+
async _pushTombstone(changeName, opts = {}) {
|
|
849
|
+
const platform = this._getPlatform();
|
|
850
|
+
if (!platform) return { synced: 0 };
|
|
851
|
+
const { ProgressManager } = await import('./progress.js');
|
|
852
|
+
const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
|
|
853
|
+
const progressData = pm.serializeForSync(this.cwd, changeName);
|
|
854
|
+
if (progressData === null) return { synced: 0 }; // DB 行已不在(彻底删除),无墓碑可推,方案 A 兜底
|
|
855
|
+
this._applyTombstoneStatus(progressData);
|
|
856
|
+
|
|
857
|
+
const headers = {
|
|
858
|
+
'Content-Type': 'application/json',
|
|
859
|
+
Authorization: `Bearer ${platform.token}`,
|
|
860
|
+
};
|
|
861
|
+
const pushedAt = new Date().toISOString();
|
|
862
|
+
const pushUser = platform.user || resolvePlatformUser(this.cwd) || null;
|
|
863
|
+
if (pushUser) headers['X-SillySpec-User'] = pushUser;
|
|
864
|
+
const baseTs = progressData.changes?.[0]?.last_synced_platform_ts;
|
|
865
|
+
if (baseTs) headers['X-SillySpec-Base-Ts'] = baseTs;
|
|
866
|
+
headers['X-SillySpec-Pushed-At'] = pushedAt;
|
|
867
|
+
|
|
868
|
+
const syncUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/progress`;
|
|
869
|
+
const res = await fetchJsonWithStatus(syncUrl, {
|
|
870
|
+
method: 'POST',
|
|
871
|
+
headers,
|
|
872
|
+
body: JSON.stringify(progressData),
|
|
873
|
+
signal: opts.signal,
|
|
874
|
+
});
|
|
875
|
+
if (res.ok) {
|
|
876
|
+
console.log(`[sync] 已上行墓碑(status=deleted): ${changeName}`);
|
|
877
|
+
try {
|
|
878
|
+
const ackTs = res.body && typeof res.body.last_pushed_at === 'string' ? res.body.last_pushed_at : pushedAt;
|
|
879
|
+
pm._updatePlatformLastSync(this.cwd, changeName, ackTs);
|
|
880
|
+
} catch { /* 回填失败下次 push 409 由自竞态自愈收敛 */ }
|
|
881
|
+
return { synced: 1 };
|
|
882
|
+
}
|
|
883
|
+
debugLog(`[sync] 墓碑上行未成功: ${changeName} (status=${res.status},平台方案 A 兜底)`);
|
|
884
|
+
return { synced: 0 };
|
|
885
|
+
}
|
|
886
|
+
|
|
623
887
|
/**
|
|
624
888
|
* 自竞态判定辅助:fresh 读本地 DB 的 last_synced_platform_ts(base_ts),判断它是否已覆盖
|
|
625
889
|
* 平台 409 回执的 last_pushed_at(字符串字典序比较,契约 §4.2——ISO 8601 字典序 == 时间序)。
|
|
@@ -806,22 +1070,23 @@ export class SyncManager {
|
|
|
806
1070
|
}
|
|
807
1071
|
|
|
808
1072
|
/**
|
|
809
|
-
* DB
|
|
1073
|
+
* DB 侧变更状态探测(坑 post-archive-sync-noise;2026-08-30 泛化):只读直查 changes.status,
|
|
1074
|
+
* 供「目录缺失」降噪分支区分 archived(归档终态照推)与 deleted(change-delete 删除终态)。
|
|
810
1075
|
* 只读直查(node:sqlite,不经 ProgressManager——sync 是短进程,惰性 import 无收益且
|
|
811
|
-
* 本调用点在同步代码段);DB 不存在/无行/读失败 →
|
|
1076
|
+
* 本调用点在同步代码段);DB 不存在/无行/读失败 → null(保守退回普通 warn 措辞)。
|
|
812
1077
|
* @param {string} changeName
|
|
813
|
-
* @returns {
|
|
1078
|
+
* @returns {string|null}
|
|
814
1079
|
*/
|
|
815
|
-
|
|
1080
|
+
_changeDbStatus(changeName) {
|
|
816
1081
|
try {
|
|
817
1082
|
const dbPath = join(safePlatformSpecDir(this.cwd) || join(this.cwd, '.sillyspec'), '.runtime', 'sillyspec.db');
|
|
818
|
-
if (!existsSync(dbPath)) return
|
|
1083
|
+
if (!existsSync(dbPath)) return null;
|
|
819
1084
|
const db = openDatabase(dbPath, { readOnly: true });
|
|
820
1085
|
try {
|
|
821
1086
|
const row = db.prepare("SELECT status FROM changes WHERE name = ?").get(changeName);
|
|
822
|
-
return row?.status
|
|
1087
|
+
return row?.status || null;
|
|
823
1088
|
} finally { try { db.close() } catch {} }
|
|
824
|
-
} catch { return
|
|
1089
|
+
} catch { return null }
|
|
825
1090
|
}
|
|
826
1091
|
|
|
827
1092
|
/**
|
|
@@ -1115,6 +1380,171 @@ export class SyncManager {
|
|
|
1115
1380
|
}
|
|
1116
1381
|
}
|
|
1117
1382
|
|
|
1383
|
+
/**
|
|
1384
|
+
* X2(task-14 / design §7.1 / FR-07 / FR-08):拉平台 spec 整树快照并解压到 specDir。
|
|
1385
|
+
*
|
|
1386
|
+
* 与既有 pull()(进度六表 DB import)正交:本方法操作的是 .sillyspec **文件树**——
|
|
1387
|
+
* GET {platform.url}/api/changes/-/spec-bundle(task-08 端点,Bearer shpsync token,
|
|
1388
|
+
* application/x-tar 流 + X-Spec-Version 响应头)下载未压缩 tar 后解包。
|
|
1389
|
+
*
|
|
1390
|
+
* 覆盖语义对齐 daemon pullSpecBundle(sillyhub-daemon/src/spec-sync.ts):
|
|
1391
|
+
* - specDir 为空(或不存在)→ 直接解压;
|
|
1392
|
+
* - 非空且无 force → 拒绝并明确提示(fail-fast,不发网络请求);
|
|
1393
|
+
* - force → rm 整树 + 解包(先下载并全量解析校验 tar,成功后才 rm——恶意/损坏
|
|
1394
|
+
* bundle 不先删本地)。local.yaml 是唯一保留例外:本机连接凭据,服务端 bundle
|
|
1395
|
+
* 恒排除它(spec-sync.js UPLOAD_EXCLUDE_FILENAMES),rm 前读出、解包落定后
|
|
1396
|
+
* 原样恢复——删它零收益纯断连。崩溃安全(审计 B3):rm 前凭据先原子备份到树外
|
|
1397
|
+
* `<specDir 根名>.local.yaml.bak`,成功后自动清理;恢复失败时从 .bak 兜底复制,
|
|
1398
|
+
* 兜底也失败则 .bak 留在现场供手工恢复(凭据不因覆盖中断永久丢失)。
|
|
1399
|
+
*
|
|
1400
|
+
* 快照语义(design §7.4):主动拉取服务器**打包时刻**快照,无自动同步、无会话中
|
|
1401
|
+
* 刷新;daemon 模式任务/会话开始时按 latest_spec_version 自动取新(本方法不涉)。
|
|
1402
|
+
*
|
|
1403
|
+
* Best Effort:未连接平台静默跳过(本地合法状态);网络/解包失败返回 ok:false 不抛。
|
|
1404
|
+
* @param {{force?: boolean, specDir?: string|null, signal?: AbortSignal}} [opts]
|
|
1405
|
+
* force 整树覆盖;specDir 显式覆盖树根(--spec-dir 全局选项通道;缺省与 sync()
|
|
1406
|
+
* 同源:平台模式锚 specRoot,本地模式 cwd/.sillyspec);signal 外部熔断
|
|
1407
|
+
* @returns {Promise<{ok: boolean, pulled: boolean, specDir?: string, specVersion?: string|null, reason?: string}>}
|
|
1408
|
+
*/
|
|
1409
|
+
async pullSpecBundle(opts = {}) {
|
|
1410
|
+
const { force = false } = opts;
|
|
1411
|
+
const platform = this._getPlatform();
|
|
1412
|
+
if (!platform) {
|
|
1413
|
+
debugLog('[sync] 未连接平台(本地合法状态);pullSpecBundle 跳过');
|
|
1414
|
+
return { ok: false, pulled: false, reason: '未连接平台' };
|
|
1415
|
+
}
|
|
1416
|
+
// 树根与 sync()/spec 树推送同源(BUG-01:平台模式必须锚 specRoot,勿硬编码 cwd/.sillyspec)
|
|
1417
|
+
const specDir = opts.specDir || safePlatformSpecDir(this.cwd) || join(this.cwd, '.sillyspec');
|
|
1418
|
+
|
|
1419
|
+
// 覆盖守卫(daemon 同款):非空且无 --force 拒绝——先于网络请求,本地不可逆动作前 fail-fast
|
|
1420
|
+
if (_dirHasContent(specDir) && !force) {
|
|
1421
|
+
return {
|
|
1422
|
+
ok: false, pulled: false, specDir,
|
|
1423
|
+
reason: `specDir 非空(${specDir}),拒绝整树覆盖;确认用平台快照替换本地请加 --force`,
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// 下载流式 tar(spec 树小,整只缓冲与 daemon getSpecBundle 同口径)。
|
|
1428
|
+
// 审计 B2:下载(含响应体读取)走 PULL_BUNDLE_TIMEOUT_MS 独立大口径——大树/慢网下
|
|
1429
|
+
// 10s 常规轮询口径必超时;仍尊重外部熔断 signal(两者 combine)。
|
|
1430
|
+
const bundleUrl = `${platform.url}/api/changes/-/spec-bundle`;
|
|
1431
|
+
let res;
|
|
1432
|
+
try {
|
|
1433
|
+
res = await fetch(bundleUrl, {
|
|
1434
|
+
headers: { Authorization: `Bearer ${platform.token}` },
|
|
1435
|
+
signal: combineSignals(opts.signal, PULL_BUNDLE_TIMEOUT_MS),
|
|
1436
|
+
});
|
|
1437
|
+
} catch (err) {
|
|
1438
|
+
// AbortSignal.timeout 到期抛 TimeoutError,外部 signal abort 抛 AbortError,两者都是超时/中断口径
|
|
1439
|
+
const why = (err.name === 'AbortError' || err.name === 'TimeoutError')
|
|
1440
|
+
? `下载超时/中断 (${PULL_BUNDLE_TIMEOUT_MS / 1000}s 上限或外部熔断)`
|
|
1441
|
+
: err.message;
|
|
1442
|
+
console.warn(`[sync] GET ${bundleUrl} 请求失败: ${why}`);
|
|
1443
|
+
return { ok: false, pulled: false, specDir, reason: `拉取 spec bundle 失败: ${why}` };
|
|
1444
|
+
}
|
|
1445
|
+
if (!res.ok) {
|
|
1446
|
+
const why = res.status === 404
|
|
1447
|
+
? '平台工作区尚无 spec 内容(HTTP 404,空 bundle)'
|
|
1448
|
+
: `HTTP ${res.status}`;
|
|
1449
|
+
console.warn(`[sync] GET ${bundleUrl} → ${res.status}`);
|
|
1450
|
+
return { ok: false, pulled: false, specDir, reason: `拉取 spec bundle 失败: ${why}` };
|
|
1451
|
+
}
|
|
1452
|
+
const specVersion = res.headers.get('x-spec-version');
|
|
1453
|
+
let tarBuf;
|
|
1454
|
+
try {
|
|
1455
|
+
tarBuf = Buffer.from(await res.arrayBuffer());
|
|
1456
|
+
} catch (err) {
|
|
1457
|
+
console.warn(`[sync] 读取 spec bundle 响应体失败: ${err.message}`);
|
|
1458
|
+
return { ok: false, pulled: false, specDir, reason: `读取 spec bundle 失败: ${err.message}` };
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// 全量解析 + 穿越校验在 rm 之前(tar-slip 命中时本地原样保留)
|
|
1462
|
+
let entries;
|
|
1463
|
+
try {
|
|
1464
|
+
entries = _parseSpecTar(tarBuf);
|
|
1465
|
+
} catch (err) {
|
|
1466
|
+
console.warn(`[sync] spec bundle 无效: ${err.message}`);
|
|
1467
|
+
return { ok: false, pulled: false, specDir, reason: `spec bundle 无效: ${err.message}` };
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// 覆盖语义:rm 整树(容忍不存在)→ 逐条目落盘(join 后 relative 双重校验再走一道)
|
|
1471
|
+
// local.yaml 唯一豁免(rm 前校验存在性并读出,解包落定后原样恢复 + 校验回存在):
|
|
1472
|
+
// 本机连接凭据(platform/mcp 段含 shpsync token),服务端 bundle 恒排除它
|
|
1473
|
+
// (spec-sync.js UPLOAD_EXCLUDE_FILENAMES)——rm 整树删它零收益纯断连。恢复放在
|
|
1474
|
+
// 条目循环之后,即便异常 bundle 携带同名条目也以本地为准(凭据永不取自服务端)。
|
|
1475
|
+
//
|
|
1476
|
+
// 审计 B3 崩溃安全(三层):rm 整树后凭据只剩内存 Buffer,任一步失败(条目写失败/
|
|
1477
|
+
// 恢复写失败——Windows EPERM·AV 锁·磁盘满)或进程崩溃即凭据永久丢失。三层防护:
|
|
1478
|
+
// ①rm 前先把内容原子写到树外 `<specDir 根名>.local.yaml.bak`(树内会被 rm 一并删掉,
|
|
1479
|
+
// 故放父目录);②恢复主路径不变,失败时从 .bak 再兜底复制一次;③成功路径末尾
|
|
1480
|
+
// 清理 .bak(明文 token 不留第二份;顺带清历史失败残留)。
|
|
1481
|
+
const localYamlPath = join(specDir, 'local.yaml');
|
|
1482
|
+
const localYamlBakPath = join(dirname(specDir), `${basename(specDir)}.local.yaml.bak`);
|
|
1483
|
+
let localYamlSaved = null; // null = 本无此文件,无需恢复
|
|
1484
|
+
if (existsSync(localYamlPath)) {
|
|
1485
|
+
try {
|
|
1486
|
+
localYamlSaved = readFileSync(localYamlPath); // Buffer 字节级:注释/CRLF 原样
|
|
1487
|
+
} catch (err) {
|
|
1488
|
+
// fail-closed:读不出就无从恢复,继续 rm = 确定性丢凭据断连,宁可整单拒绝
|
|
1489
|
+
console.warn(`[sync] local.yaml 存在但读取失败,拒绝 --force 整树覆盖: ${err.message}`);
|
|
1490
|
+
return { ok: false, pulled: false, specDir, reason: `local.yaml 读取失败,已拒绝整树覆盖(防丢连接凭据): ${err.message}` };
|
|
1491
|
+
}
|
|
1492
|
+
try {
|
|
1493
|
+
writeAtomicSync(localYamlBakPath, localYamlSaved);
|
|
1494
|
+
try { chmodSync(localYamlBakPath, 0o600); } catch { /* best-effort,同 local.yaml SEC-04 */ }
|
|
1495
|
+
} catch (err) {
|
|
1496
|
+
// 兜底都写不进(磁盘满等):继续 rm = 凭据只剩内存一份,恢复再失败即永久丢失,拒绝整单
|
|
1497
|
+
console.warn(`[sync] local.yaml 备份写入失败,拒绝 --force 整树覆盖: ${err.message}`);
|
|
1498
|
+
return { ok: false, pulled: false, specDir, reason: `local.yaml 备份失败,已拒绝整树覆盖(防丢连接凭据): ${err.message}` };
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
try {
|
|
1502
|
+
rmSync(specDir, { recursive: true, force: true });
|
|
1503
|
+
} catch (err) {
|
|
1504
|
+
// Windows EPERM/EBUSY:整树内 .runtime/sillyspec.db 被运行中 daemon/CLI 持有时 rm 必失败
|
|
1505
|
+
//(本地树未被删除,可安全整单退出;.bak 留待下次成功覆盖时清理)
|
|
1506
|
+
const holdHint = err.code === 'EBUSY' || err.code === 'EPERM'
|
|
1507
|
+
? '(可能被运行中的 daemon/CLI 持有 .runtime/sillyspec.db,请先停止相关进程再 --force)' : '';
|
|
1508
|
+
console.warn(`[sync] 删除本地 specDir 失败${holdHint}: ${err.message}`);
|
|
1509
|
+
return { ok: false, pulled: false, specDir, reason: `删除本地 specDir 失败${holdHint}: ${err.message}` };
|
|
1510
|
+
}
|
|
1511
|
+
try {
|
|
1512
|
+
for (const e of entries) {
|
|
1513
|
+
_assertSafeTarName(e.name.replace(/\/+$/, ''), specDir);
|
|
1514
|
+
const fullPath = join(specDir, e.name);
|
|
1515
|
+
if (e.isDir) {
|
|
1516
|
+
mkdirSync(fullPath, { recursive: true });
|
|
1517
|
+
} else {
|
|
1518
|
+
mkdirSync(dirname(fullPath), { recursive: true });
|
|
1519
|
+
writeFileSync(fullPath, e.data);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
if (localYamlSaved !== null) {
|
|
1523
|
+
mkdirSync(specDir, { recursive: true }); // 空 bundle(零条目)时树根未被条目重建
|
|
1524
|
+
writeAtomicSync(localYamlPath, localYamlSaved); // fs-atomic 契约:hook/probe 并发读 local.yaml
|
|
1525
|
+
try { chmodSync(localYamlPath, 0o600); } catch { /* SEC-04 best-effort,同 writeLocalYamlRaw */ }
|
|
1526
|
+
if (!existsSync(localYamlPath)) throw new Error('local.yaml 恢复后校验缺失');
|
|
1527
|
+
}
|
|
1528
|
+
// ③ 成功路径:兜底使命完成,清理 .bak(force:true 容忍本就无 .bak 的路径)
|
|
1529
|
+
rmSync(localYamlBakPath, { force: true });
|
|
1530
|
+
} catch (err) {
|
|
1531
|
+
// ② 恢复兜底:主路径失败(条目写失败中断恢复 / 恢复自身失败)时从 .bak 再试一次,
|
|
1532
|
+
// 仍失败不掩盖——.bak 留在树外原处,用户可手工复制为 local.yaml(凭据不因本次失败丢失)
|
|
1533
|
+
if (localYamlSaved !== null && existsSync(localYamlBakPath)) {
|
|
1534
|
+
try {
|
|
1535
|
+
mkdirSync(specDir, { recursive: true });
|
|
1536
|
+
writeAtomicSync(localYamlPath, readFileSync(localYamlBakPath));
|
|
1537
|
+
try { chmodSync(localYamlPath, 0o600); } catch { /* best-effort */ }
|
|
1538
|
+
} catch { /* .bak 兜底也失败:保留 .bak 现场供手工恢复 */ }
|
|
1539
|
+
console.warn(`[sync] 连接凭据已备份于 ${localYamlBakPath},恢复失败可手工复制为 local.yaml`);
|
|
1540
|
+
}
|
|
1541
|
+
console.warn(`[sync] 解压 spec bundle 失败: ${err.message}`);
|
|
1542
|
+
return { ok: false, pulled: false, specDir, reason: `解压 spec bundle 失败: ${err.message}` };
|
|
1543
|
+
}
|
|
1544
|
+
console.log(`[sync] 已拉取 spec 快照并解压到 ${specDir}${specVersion ? `(平台 spec_version: ${specVersion},打包时刻快照非实时)` : ''}`);
|
|
1545
|
+
return { ok: true, pulled: true, specDir, specVersion };
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1118
1548
|
/**
|
|
1119
1549
|
* 冲突解决三选一(task-13 / D-002 / D-010 / D-013 / FR-05)。
|
|
1120
1550
|
* 读 task-12 写的 sync-conflict-<change>.json,按 mode 处理后必清冲突文件防累积(R-04 / constraints)。
|
|
@@ -1369,6 +1799,12 @@ export async function pullList(cwd) {
|
|
|
1369
1799
|
return new SyncManager(cwd).pullList();
|
|
1370
1800
|
}
|
|
1371
1801
|
|
|
1802
|
+
// X2(task-14 / FR-07 / FR-08):spec 整树快照拉取便捷导出(index.js 顶层 pull --spec 命令用,
|
|
1803
|
+
// 与 SyncManager.pullSpecBundle 实例方法共用实现)。快照语义见实例方法 docstring(design §7.4)。
|
|
1804
|
+
export async function pullSpecBundle(cwd, opts) {
|
|
1805
|
+
return new SyncManager(cwd).pullSpecBundle(opts);
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1372
1808
|
// task-13 / D-002 / D-010 / D-013 / FR-05:冲突解决三选一(index.js platform resolve 子命令用)
|
|
1373
1809
|
export async function resolve(changeName, mode, cwd) {
|
|
1374
1810
|
return new SyncManager(cwd).resolve(changeName, mode);
|
package/src/verify-postcheck.js
CHANGED
|
@@ -579,10 +579,21 @@ export function extractKnownFailures(yamlText) {
|
|
|
579
579
|
if (inline) {
|
|
580
580
|
return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
|
|
581
581
|
}
|
|
582
|
-
|
|
582
|
+
// 坑 verify-known-failures-comment-line-truncation(2026-08-28 连续踩两次):块内注释行/空行
|
|
583
|
+
// 打断「连续列表项」的捕获链 → 只取到注释前的项,注释后的项静默丢失 → 清单残缺、
|
|
584
|
+
// verify 实测 remaining>0 假红。块定义放行注释行与空行(提取侧本就只认 `- ` 项行,
|
|
585
|
+
// 吸收进块的注释不影响结果)。
|
|
586
|
+
const block = yaml.match(/^known_failures:\s*\n((?:[ \t]+-[ \t].+\n?|[ \t]*#[^\n]*\n?|[ \t]*\n)+)/m)
|
|
583
587
|
if (block) {
|
|
584
588
|
return (block[1].match(/^[ \t]+-[ \t]+(.+)/gm) || [])
|
|
585
|
-
.map(s =>
|
|
589
|
+
.map(s => {
|
|
590
|
+
const item = s.replace(/^[ \t]+-[ \t]+/, '').trim()
|
|
591
|
+
const full = item.match(/^(['"])(.*)\1$/)
|
|
592
|
+
if (full) return full[2] // 整体引号值原样保留(内部 # 不是注释)
|
|
593
|
+
const stripped = item.replace(/[ \t]+#.*$/, '').trim() // 行尾注释须 # 前有空白(YAML 语义),裸 # 不截
|
|
594
|
+
const q2 = stripped.match(/^(['"])(.*)\1$/)
|
|
595
|
+
return q2 ? q2[2] : stripped
|
|
596
|
+
})
|
|
586
597
|
.filter(Boolean)
|
|
587
598
|
}
|
|
588
599
|
return []
|
|
@@ -591,9 +602,25 @@ export function extractKnownFailures(yamlText) {
|
|
|
591
602
|
// 单条失败行标记(跨 pytest/jest/vitest/go/generic)。大小写不敏感。
|
|
592
603
|
// 刻意用 FAIL/FAILED + 配合 SUMMARY_LINE_RE 排除汇总行:否则 pytest/jest 的
|
|
593
604
|
// "N failed" 汇总会被误判为「需豁免的失败行」→ 永远 remaining>0 → known_failures 失效。
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
605
|
+
// ×(U+00D7) 是 vitest 失败行的实际前缀标记(✕✗✘ 是 jest/mocha 形态)。
|
|
606
|
+
const PER_TEST_FAIL_RE = /(FAILED|\bFAIL\b|✕|✗|✘|×|panic\s*:|assertionerror|traceback|---\s*fail|error:|exception)/i
|
|
607
|
+
// 汇总/计数行(非单条失败):pytest === 框、jest "Tests:"、vitest "Test Files N failed"/"Tests N failed | M passed"
|
|
608
|
+
// (无冒号形态)、裸 "N failed/passed"、go "FAILED in Ns"、pnpm "ELIFECYCLE Test failed" 退出横幅等。
|
|
609
|
+
const SUMMARY_LINE_RE = /(={2,}.*={2,}|^\s*\d+\s+(failed|passed|skipped|pending)\b|tests?\s*:|test\s+suites?\s*:|^\s*(?:test\s+files|tests?)\s+\d|failed\s+in\s+\d|failed\s+tests\s+\d|elifecycle)/i
|
|
610
|
+
// 控制台捕获噪声行(非失败信号):vitest 把测试内 console 输出捕获为
|
|
611
|
+
// `stderr | <file> > <测试名>` 横幅 + 后续内容行——挂在通过用例上的噪声里常含
|
|
612
|
+
// error:/failed 字样(jsdom「Not implemented」警告、用例名本身带 failed 等),
|
|
613
|
+
// 与失败信号(× 行 / FAIL 横幅 / 汇总计数)无关,全部剔除。
|
|
614
|
+
const CONSOLE_CAPTURE_RE = /^\s*(?:stdout|stderr)\s*\|/
|
|
615
|
+
const ENV_NOISE_RE = /not\s+implemented\s*:/i
|
|
616
|
+
// 通过行(行首标记,剥 ANSI 后判定):vitest/jest/mocha 的 ✓√✔ 前缀行 + jest 的 PASS 文件行。
|
|
617
|
+
// 坑 verify-known-failures-pass-line-false-positive:FAILED/error:/exception 是子串匹配,
|
|
618
|
+
// 通过行用例名恰含这些字样(如「✓ … 超时后 syncStatus=failed」)会被误判失败行——
|
|
619
|
+
// 2710 用例套件 382 个"失败行"里 378 假阳性,known_failures 无法逐条枚举而实质失效。
|
|
620
|
+
// 框架输出里通过行恒以通过标记开头、失败行恒以失败标记开头,行首判定即足以分离两类。
|
|
621
|
+
const PASS_LINE_RE = /^\s*(?:[✓√✔]|PASS\b)/
|
|
622
|
+
// ANSI 色码剥离(分类用):TTY 捕获的输出里 ✓/× 前缀可能被色码包裹,行首锚定会失配。
|
|
623
|
+
const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g
|
|
597
624
|
|
|
598
625
|
/**
|
|
599
626
|
* 把测试输出按行筛出「失败行」,再按 known_failures 模式分为已豁免 / 未豁免。
|
|
@@ -603,7 +630,15 @@ const SUMMARY_LINE_RE = /(={2,}.*={2,}|^\s*\d+\s+(failed|passed|skipped|pending)
|
|
|
603
630
|
*/
|
|
604
631
|
export function partitionFailures(output, knownFailures) {
|
|
605
632
|
const lines = String(output || '').split(/\r?\n/)
|
|
606
|
-
|
|
633
|
+
// 分类在剥 ANSI 后的行上做,返回保留原文(remaining 展示给 agent 时不变形)
|
|
634
|
+
const failureLines = lines.filter(l => {
|
|
635
|
+
if (PASS_LINE_RE.test(l)) return false
|
|
636
|
+
const bare = l.replace(ANSI_RE, '')
|
|
637
|
+
if (PASS_LINE_RE.test(bare)) return false
|
|
638
|
+
if (CONSOLE_CAPTURE_RE.test(bare)) return false
|
|
639
|
+
if (ENV_NOISE_RE.test(bare)) return false
|
|
640
|
+
return PER_TEST_FAIL_RE.test(bare) && !SUMMARY_LINE_RE.test(bare)
|
|
641
|
+
})
|
|
607
642
|
const pats = (knownFailures || []).map(p => String(p).toLowerCase()).filter(Boolean)
|
|
608
643
|
const exempted = []
|
|
609
644
|
const remaining = []
|
|
@@ -1528,6 +1563,12 @@ export function printVerifyParityCheck(result) {
|
|
|
1528
1563
|
console.warn(` - ${m.method} ${m.path} ← ${m.consumerFile}:${m.consumerLine}`)
|
|
1529
1564
|
}
|
|
1530
1565
|
if (result.missingBackend.length > 20) console.warn(` …还有 ${result.missingBackend.length - 20} 个`)
|
|
1566
|
+
// 全仓口径告警(坑 probe5-single-task-artifact-scope,2026-08-28 实证:diff/apply 清单都不可得
|
|
1567
|
+
// 时回退全仓,全仓调用 × 本变更局部端点 = 大量假 missing,agent 照单记 FAIL 误导归档判定)
|
|
1568
|
+
if (/\[scope: full-repo\]/.test(result.summary || '')) {
|
|
1569
|
+
console.warn(' ⚠️ 本次 scope 为 full-repo(diff 与 apply-pathspec 均不可得时的兜底)——missing 大概率是口径错配噪音而非真实 contract gap。')
|
|
1570
|
+
console.warn(' 先收窄复跑:sillyspec verify-probes --change <变更名>(change-diff/apply-pathspec 口径)再判定。')
|
|
1571
|
+
}
|
|
1531
1572
|
console.warn(' 提示:检查是否后端漏实现,或前端调用了尚未实现的端点。确认无误可在 design.md 标注豁免。')
|
|
1532
1573
|
}
|
|
1533
1574
|
|