sillyspec 3.26.11 → 3.26.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-archive/SKILL.md +1 -1
- package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
- package/.claude/skills/sillyspec-knowledge/SKILL.md +1 -1
- package/.claude/skills/sillyspec-plan/SKILL.md +5 -5
- package/.claude/skills/sillyspec-quick/SKILL.md +5 -1
- package/README.md +7 -5
- package/SKILL.md +5 -4
- package/package.json +1 -1
- package/packages/dashboard/server/executor.js +10 -1
- package/packages/dashboard/server/index.js +108 -46
- package/src/config-schema.js +3 -0
- package/src/contract-matrix.js +22 -13
- package/src/db.js +24 -4
- package/src/dispatch/probe.js +99 -63
- package/src/docs-check.js +58 -13
- package/src/docs-debt.js +6 -2
- package/src/docs-gate.js +1 -0
- package/src/doctor-diagnostics.js +208 -21
- package/src/fs-atomic.js +3 -1
- package/src/git-helper.js +7 -4
- package/src/hooks/worktree-guard.js +8 -2
- package/src/index.js +161 -12
- package/src/init.js +3 -2
- package/src/local-detect.js +9 -1
- package/src/modules.js +7 -5
- package/src/progress/stage-machine.js +589 -578
- package/src/progress/step-store.js +1 -1
- package/src/progress.js +184 -30
- package/src/quicklog.js +47 -16
- package/src/run/command.js +31 -8
- package/src/run/complete-handlers.js +21 -14
- package/src/run/complete.js +1181 -1121
- package/src/run/gates.js +148 -25
- package/src/run/prompt.js +9 -5
- package/src/run/shared.js +138 -58
- package/src/run/stage.js +41 -1
- package/src/setup.js +8 -10
- package/src/sillyhub-mcp/client.js +74 -6
- package/src/spec-dir-typo.js +3 -24
- package/src/spec-sync.js +43 -8
- package/src/stage-contract.js +12 -38
- package/src/stages/archive.js +4 -3
- package/src/stages/brainstorm-auto.js +1 -1
- package/src/stages/brainstorm.js +2 -1
- package/src/stages/doctor.js +28 -13
- package/src/stages/execute.js +164 -153
- package/src/stages/plan-postcheck.js +49 -28
- package/src/stages/plan.js +57 -40
- package/src/stages/quick.js +3 -1
- package/src/stages/verify.js +1 -1
- package/src/sync.js +323 -221
- package/src/task-review.js +246 -61
- package/src/taskcard.js +178 -0
- package/src/verify-postcheck.js +48 -35
- package/src/worktree-apply.js +58 -34
- package/src/worktree-deps.js +120 -20
- package/src/worktree.js +55 -9
- package/.npmrc-publish +0 -2
package/src/verify-postcheck.js
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* 测试,避免 monorepo 全量测试超 gate timeout。
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { execSync
|
|
18
|
+
import { execSync } from 'child_process'
|
|
19
|
+
import { gitQuiet } from './git-helper.js'
|
|
19
20
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
20
21
|
import { join } from 'path'
|
|
21
22
|
import { verifyApiParity } from './contract-matrix.js'
|
|
@@ -26,6 +27,18 @@ import { filterDeliverableFiles } from './worktree-apply.js'
|
|
|
26
27
|
const TEST_TIMEOUT_MS = Number(process.env.SILLYSPEC_TEST_TIMEOUT_MS) || 10 * 60 * 1000
|
|
27
28
|
const OUTPUT_TAIL_CHARS = 4000
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* 行尾归一(坑 verify-modules-crlf-blanket-fallback,2026-08-20 实证):Windows 仓的
|
|
32
|
+
* local.yaml 常为 CRLF(编辑器/CLI 写入),手写行扫描器的逐行正则里 `.` 不匹配 `\r`、
|
|
33
|
+
* `$`(无 m 标志)又要求真串尾——`\r` 残留行尾导致条目正则整条失配。实证受害:
|
|
34
|
+
* extractModules 返回 null(modules 映射恒失效 → 永远回退全量 → 600s 默认超时必炸)、
|
|
35
|
+
* extractKnownFailures 块式只捕获第一条。统一在解析入口归一,JS 的 `\r?\n` 拆分与
|
|
36
|
+
* yaml 库均兼容 LF 文本(零回归)。
|
|
37
|
+
*/
|
|
38
|
+
function normalizeLineEndings(text) {
|
|
39
|
+
return String(text || '').replace(/\r\n?/g, '\n')
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
/**
|
|
30
43
|
* 从 local.yaml 文本提取 commands.test。
|
|
31
44
|
* 轻量正则(与 worktree-deps/scan-postcheck 同风格,不引 yaml 依赖):
|
|
@@ -123,7 +136,7 @@ export function computeFullFallbackReason({ strategy, modulesPresent, hitCount }
|
|
|
123
136
|
*/
|
|
124
137
|
export function extractModules(yamlText) {
|
|
125
138
|
if (!yamlText) return null
|
|
126
|
-
const lines = yamlText.split('\n')
|
|
139
|
+
const lines = normalizeLineEndings(yamlText).split('\n')
|
|
127
140
|
|
|
128
141
|
// 定位 modules: 起始行(必须是顶层 key,行首无缩进或仅注释后顶层)
|
|
129
142
|
let startIdx = -1
|
|
@@ -167,8 +180,15 @@ function parseFlowValue(flowText, key) {
|
|
|
167
180
|
const m = flowText.match(re)
|
|
168
181
|
if (!m) return null
|
|
169
182
|
let v = m[1].trim()
|
|
170
|
-
if (
|
|
171
|
-
|
|
183
|
+
if (v.startsWith('"') && v.endsWith('"')) {
|
|
184
|
+
// 双引号值剥壳后解开转义(坑 verify-modules-crlf-blanket-fallback 次生发现):modules 的
|
|
185
|
+
// test 命令常含嵌套引号(node -e "…" / pytest "…"),yaml 源里写作 \";此前只剥外层引号
|
|
186
|
+
// 不解 \\ 与 \",解析结果残留字面反斜杠 → 命令直接坏。其余冷门转义(\n 等)按 YAML 规范
|
|
187
|
+
// 应真转义,但保守留字面(可见优于静默变换,测试命令不含它们)
|
|
188
|
+
v = v.slice(1, -1).replace(/\\(["\\])/g, '$1')
|
|
189
|
+
} else if (v.startsWith("'") && v.endsWith("'")) {
|
|
190
|
+
// 单引号值的 YAML 转义是 '' → '
|
|
191
|
+
v = v.slice(1, -1).replace(/''/g, "'")
|
|
172
192
|
}
|
|
173
193
|
return v.length > 0 ? v : null
|
|
174
194
|
}
|
|
@@ -227,11 +247,14 @@ export function aggregateStatus(results) {
|
|
|
227
247
|
*/
|
|
228
248
|
export function extractKnownFailures(yamlText) {
|
|
229
249
|
if (!yamlText) return []
|
|
230
|
-
|
|
250
|
+
// CRLF 归一(坑 verify-modules-crlf-blanket-fallback):块式正则的 `.+`/`\n?` 在 CRLF 行
|
|
251
|
+
// 间失配,只捕获到第一条豁免项就停——归一后整块捕获恢复
|
|
252
|
+
const yaml = normalizeLineEndings(yamlText)
|
|
253
|
+
const inline = yaml.match(/^known_failures:\s*\[([^\]]*)\]\s*(?:#.*)?$/m)
|
|
231
254
|
if (inline) {
|
|
232
255
|
return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
|
|
233
256
|
}
|
|
234
|
-
const block =
|
|
257
|
+
const block = yaml.match(/^known_failures:\s*\n((?:[ \t]+-[ \t].+\n?)+)/m)
|
|
235
258
|
if (block) {
|
|
236
259
|
return (block[1].match(/^[ \t]+-[ \t]+(.+)/gm) || [])
|
|
237
260
|
.map(s => s.replace(/^[ \t]+-[ \t]+/, '').trim().replace(/^['"]|['"]$/g, '').replace(/#.*$/, '').trim())
|
|
@@ -381,44 +404,29 @@ function assertSafeRefSpec(refSpec) {
|
|
|
381
404
|
/**
|
|
382
405
|
* 在指定 cwd 跑 `git diff --name-only <refSpec>`,返回相对仓库根的文件列表。
|
|
383
406
|
* git 不可用 / 非仓库 / ref 无效 → 返回 null(调用方 fallback)。
|
|
407
|
+
* QUAL-01 收口:本地 execFileSync 裸调 → git-helper gitQuiet(统一 safe.directory),timeout 30s 保留。
|
|
384
408
|
*/
|
|
385
409
|
function runGitDiffNameOnly(cwd, refSpec) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
encoding: 'utf8',
|
|
394
|
-
timeout: 30 * 1000,
|
|
395
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
396
|
-
})
|
|
397
|
-
return out.split('\n').map(l => l.trim()).filter(Boolean)
|
|
398
|
-
} catch {
|
|
399
|
-
return null
|
|
400
|
-
}
|
|
410
|
+
const ref = refSpec ? assertSafeRefSpec(refSpec) : null
|
|
411
|
+
if (refSpec && !ref) return null
|
|
412
|
+
const args = ['diff', '--name-only']
|
|
413
|
+
if (ref) args.push(ref)
|
|
414
|
+
const out = gitQuiet(cwd, args, { timeout: 30 * 1000, trim: false })
|
|
415
|
+
if (out === null) return null
|
|
416
|
+
return out.split('\n').map(l => l.trim()).filter(Boolean)
|
|
401
417
|
}
|
|
402
418
|
|
|
403
419
|
/**
|
|
404
420
|
* 在指定 cwd 跑 `git diff --name-status <refSpec>`,返回原始文本(调用方按行解析,
|
|
405
421
|
* 保留状态字母 D/R/C 供删除探针识别删除/重命名)。git 不可用 / 非仓库 / ref 无效 → 返回 null。
|
|
422
|
+
* QUAL-01 收口:同上走 gitQuiet(trim:false 保留原始文本)。
|
|
406
423
|
*/
|
|
407
424
|
function runGitDiffNameStatus(cwd, refSpec) {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
return execFileSync('git', args, {
|
|
414
|
-
cwd,
|
|
415
|
-
encoding: 'utf8',
|
|
416
|
-
timeout: 30 * 1000,
|
|
417
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
418
|
-
})
|
|
419
|
-
} catch {
|
|
420
|
-
return null
|
|
421
|
-
}
|
|
425
|
+
const ref = refSpec ? assertSafeRefSpec(refSpec) : null
|
|
426
|
+
if (refSpec && !ref) return null
|
|
427
|
+
const args = ['diff', '--name-status']
|
|
428
|
+
if (ref) args.push(ref)
|
|
429
|
+
return gitQuiet(cwd, args, { timeout: 30 * 1000, trim: false })
|
|
422
430
|
}
|
|
423
431
|
|
|
424
432
|
/**
|
|
@@ -555,6 +563,11 @@ function resolveMainChangedFiles(cwd, changeName) {
|
|
|
555
563
|
* }}
|
|
556
564
|
*/
|
|
557
565
|
export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = null }) {
|
|
566
|
+
// 信任边界声明(体检 SEC-02 核实):本函数三处 execSync(command) 的命令只来源于
|
|
567
|
+
// ① 主仓 specBase(resolveSpecDir → 主仓 .sillyspec/local.yaml)② 跨仓仓根
|
|
568
|
+
// <repo>/.sillyspec/local.yaml——均为仓库自有配置(与 `npm test` 同信任级:跑测试
|
|
569
|
+
// 本就是执行项目代码)。永不读取 agent 可写的 worktree 副本(对照 worktree-deps.js
|
|
570
|
+
// SEC-01 的源级分流);修改 yaml 来源时必须维持此边界。
|
|
558
571
|
const localYamlPath = join(specBase, 'local.yaml')
|
|
559
572
|
const yamlText = existsSync(localYamlPath) ? readFileSync(localYamlPath, 'utf8') : null
|
|
560
573
|
|
package/src/worktree-apply.js
CHANGED
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
* 8. 成功后自动 cleanup
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { execFileSync } from 'child_process';
|
|
16
15
|
import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync, readdirSync, readFileSync } from 'fs';
|
|
17
16
|
import { join, resolve } from 'path';
|
|
18
17
|
import { tmpdir } from 'os';
|
|
@@ -487,12 +486,17 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
487
486
|
return applyByMerge(result, changeName, projectRoot, wm);
|
|
488
487
|
}
|
|
489
488
|
|
|
490
|
-
// --- 4.5
|
|
491
|
-
// 分工:4.5
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
489
|
+
// --- 4.5 校验:主工作区「未提交」脏文件是否与本次变更重叠(overlap-only 拦截)---
|
|
490
|
+
// 分工:4.5(排除规则下 dirty∩changedFiles)+ 5a(更宽口径的同一交集)挡「未提交」dirty 重叠;
|
|
491
|
+
// 5b 管「已提交」HEAD 分叉(已放宽)。
|
|
492
|
+
// 2026-08-20 放宽(原全量拦截 → 只拦重叠):原「排除规则下任何未提交文件即整体拒绝」会把与本次
|
|
493
|
+
// 变更无关的脏文件也硬挡,用户被迫走 rescue cp 手动路径。重叠(dirtyFiles ∩ changedFiles,
|
|
494
|
+
// changedFiles 已含删除文件与 worktree 新增同名文件)才是 git apply 无法安全应用的实际危险区。
|
|
495
|
+
// 残余风险与兜底(Windows/autocrlf):实测 autocrlf on 时 git apply --3way 对 dirty 工作区可报
|
|
496
|
+
// `does not match index`(哪怕脏文件与 patch 不重叠;autocrlf off 时能干净应用,非 git 本质限制)。
|
|
497
|
+
// 交集空前提下该失败是安全的:step7 catch → rollbackApply 只 checkout/删除 patch 涉及文件
|
|
498
|
+
// (这些文件 apply 前无未提交修改,还原 = apply 前状态),无关脏文件不在 patch/rollback 范围
|
|
499
|
+
// 不受影响 → fail-safe,报错后 stash 重试即可。放行时 warning 提示此路径。
|
|
496
500
|
// 排除非交付物的元数据/文档 churn(execute 自身改的 + 多操作者常改的 agent 指引/文档),
|
|
497
501
|
// 否则别人改 CLAUDE.md/docs/.claude → 判定 dirty → apply 误阻断(多操作者仓库高频踩坑)。
|
|
498
502
|
// 注意:排除规则必须和 computeBaselineHash (worktree.js) 一致(虽已不比对 hash,仍用同一口径判当前 dirty)。
|
|
@@ -503,38 +507,47 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
503
507
|
const staged = gitQuiet(projectRoot, ['diff', '--cached', ...exclude]) || '';
|
|
504
508
|
const unstaged = gitQuiet(projectRoot, ['diff', ...exclude]) || '';
|
|
505
509
|
const untracked = gitQuiet(projectRoot, ['ls-files', '--others', '--exclude-standard', ...exclude]) || '';
|
|
506
|
-
// 意图(与 computeBaselineHash
|
|
510
|
+
// 意图(与 computeBaselineHash 注释一致):只看「未提交 dirty」——git apply --3way 对 dirty 工作区不稳。
|
|
507
511
|
// 不比对 hash 是否等于 execute 启动时 baselineHash:主仓 dirty→clean(execute 期间 commit 无关文件)后
|
|
508
512
|
// hash 必变,若仍比对会永久死锁(须手改 meta.baselineHash)。改判「排除规则下当前是否有未提交 dirty」。
|
|
509
513
|
const hasUncommittedDirty = staged !== '' || unstaged !== '' || untracked !== '';
|
|
510
514
|
if (hasUncommittedDirty) {
|
|
511
|
-
// 未提交 dirty 拦截:列脏文件 + 指引先 commit/stash(git --3way 对 dirty 工作区不稳,merge 同理,故不再提 --merge)
|
|
512
515
|
const dirtyFiles = [...new Set(
|
|
513
516
|
((gitQuiet(projectRoot, ['diff', '--name-only', 'HEAD']) || '').split('\n').filter(Boolean))
|
|
514
517
|
.concat((gitQuiet(projectRoot, ['ls-files', '--others', '--exclude-standard']) || '').split('\n').filter(Boolean))
|
|
515
518
|
)].filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json');
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
519
|
+
const overlapDirty = dirtyFiles.filter(f => changedFiles.includes(f));
|
|
520
|
+
if (overlapDirty.length > 0) {
|
|
521
|
+
// 重叠拦截:只有与本次变更同文件的未提交改动才无法安全 apply(列重叠文件,非全部脏文件)
|
|
522
|
+
const rescueDirty = computeRescueDirtyFiles(projectRoot);
|
|
523
|
+
result.rescueCommands = generateRescueCommands({
|
|
524
|
+
changedFiles,
|
|
525
|
+
dirtyFiles: rescueDirty,
|
|
526
|
+
hashMismatchFiles: result.hashMismatchFiles,
|
|
527
|
+
deletedFiles: result.deletedFiles,
|
|
528
|
+
worktreePath,
|
|
529
|
+
projectRoot,
|
|
530
|
+
});
|
|
531
|
+
result.errors.push(
|
|
532
|
+
`主工作区以下未提交文件与本次 apply 的变更重叠,git apply 无法安全应用:\n ${overlapDirty.join('\n ')}\n` +
|
|
533
|
+
`请先提交或暂存这些改动,再重新 apply:\n git add -A && git commit -m "..." 或 git stash\n` +
|
|
534
|
+
(result.rescueCommands.commands.length > 0 || result.rescueCommands.warnings.length > 0
|
|
535
|
+
? `或手动 rescue(旁路 git apply,逐文件 cp 安全子集;cp 后需手动 sillyspec worktree cleanup ${changeName}):\n` +
|
|
536
|
+
result.rescueCommands.commands.map(c => ` ${c}`).join('\n') +
|
|
537
|
+
(result.rescueCommands.warnings.length > 0 ? '\n' + result.rescueCommands.warnings.map(w => ` ${w}`).join('\n') : '') +
|
|
538
|
+
`\n (共 ${result.rescueCommands.cpFileCount} 个可安全 cp,${result.rescueCommands.excludedCount} 个被排除)\n`
|
|
539
|
+
: '')
|
|
540
|
+
);
|
|
541
|
+
if (!checkOnly) return result; // checkOnly 收集不短路(一次报全);真实 apply 短路
|
|
542
|
+
} else {
|
|
543
|
+
// 无关脏文件放行(只校验重叠文件):--3way 若因 autocrlf 报 does not match index 会被
|
|
544
|
+
// step7 catch 回滚(交集空前提下无损,见上注释),stash 后重试即可——不再硬挡 rescue 手动路径
|
|
545
|
+
result.warnings = (result.warnings || []).concat([
|
|
546
|
+
`主工作区有 ${dirtyFiles.length} 个与本次 apply 无关的未提交文件,已放行(只校验重叠文件):` +
|
|
547
|
+
`${dirtyFiles.slice(0, 5).join(', ')}${dirtyFiles.length > 5 ? ' 等' : ''}` +
|
|
548
|
+
`——若 apply --3way 因 CRLF/autocrlf 报 does not match index,可 git stash 后重试`
|
|
549
|
+
]);
|
|
550
|
+
}
|
|
538
551
|
}
|
|
539
552
|
}
|
|
540
553
|
|
|
@@ -656,6 +669,17 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
656
669
|
// FR-06:解析 git 原始 stderr 提取冲突文件列表(不静默吞掉)
|
|
657
670
|
const rawStderr = e.message || '';
|
|
658
671
|
const rollback = rollbackApply(projectRoot, trackedPatchFiles, newPatchFiles, rawStderr);
|
|
672
|
+
// autocrlf 副作用单独点名:主仓无关未提交 dirty + CRLF 转换可触发 does not match index
|
|
673
|
+
// (非文件内容冲突,交集空时 step4.5 已放行到这里)——回滚无损,stash 后重试即可
|
|
674
|
+
if (/does not match index/i.test(rawStderr)) {
|
|
675
|
+
result.errors.push(
|
|
676
|
+
`apply --3way 失败(does not match index):多为主仓未提交文件 + Windows autocrlf 的 CRLF 副作用触发,非文件内容冲突。\n` +
|
|
677
|
+
`已回滚工作区到 apply 前状态(无损,无关未提交文件未受影响)。\n` +
|
|
678
|
+
`处理:git stash 后重试 sillyspec worktree apply ${changeName},或 --merge 兜底。`
|
|
679
|
+
);
|
|
680
|
+
if (rollback.error) result.warnings = (result.warnings || []).concat([`回滚警告: ${rollback.error}`]);
|
|
681
|
+
return result;
|
|
682
|
+
}
|
|
659
683
|
result.errors.push(
|
|
660
684
|
`apply --3way 冲突:以下文件与主干「已提交」推进重叠,无法自动合并:\n` +
|
|
661
685
|
` ${rollback.conflicts.length > 0 ? rollback.conflicts.join('\n ') : '(未能获取冲突文件列表)'}\n` +
|
|
@@ -1174,9 +1198,9 @@ export function formatExecuteSummary({ changeName, stepsCompleted, stepsTotal, a
|
|
|
1174
1198
|
// worktree 还在,用 baselineCommit 或 baseHash 做 diff
|
|
1175
1199
|
try {
|
|
1176
1200
|
const diffBase = meta.baselineCommit || meta.baseHash;
|
|
1177
|
-
// execFileSync
|
|
1178
|
-
// 替代 `2>/dev/null
|
|
1179
|
-
const filesRaw =
|
|
1201
|
+
// QUAL-01 收口:裸 execFileSync → git-helper git(stderr 由 helper stdio 配置吞掉,
|
|
1202
|
+
// 替代 `2>/dev/null`——Windows cmd.exe 无法解析该重定向,导致 Windows 上恒抛错→Changed Files 空)
|
|
1203
|
+
const filesRaw = git(meta.worktreePath, ['diff', '--name-only', diffBase], { timeout: 30000 });
|
|
1180
1204
|
const files = filesRaw ? filesRaw.trim().split('\n').filter(Boolean) : [];
|
|
1181
1205
|
if (files.length > 0) {
|
|
1182
1206
|
lines.push(``);
|
package/src/worktree-deps.js
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { existsSync, readFileSync, realpathSync } from 'fs';
|
|
12
|
-
import { join } from 'path';
|
|
13
|
-
import {
|
|
12
|
+
import { join, isAbsolute, resolve as resolvePath, sep as pathSep } from 'path';
|
|
13
|
+
import { execFileSync } from 'child_process';
|
|
14
14
|
import { createHash } from 'crypto';
|
|
15
15
|
|
|
16
16
|
const LOCKFILES = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'];
|
|
@@ -44,16 +44,28 @@ function detectLockfile(dir) {
|
|
|
44
44
|
return null;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
47
|
+
/**
|
|
48
|
+
* 读取 local.yaml 文本(specBase 优先,回退 worktreePath/.sillyspec;不读 process.cwd 避免环境配置泄漏)。
|
|
49
|
+
* 返回 { text, source }——source 标记信任级(体检 SEC-01):
|
|
50
|
+
* - 'specBase':主仓配置,可信(其 commands.install 允许执行,仍过白名单+元字符门)
|
|
51
|
+
* - 'worktree':worktree 内副本,agent/子代理可写区——只作 project.type / modules 等
|
|
52
|
+
* 只读探测,其 commands.install 永不执行(否则绕过 worktree-guard 的危险命令拦截)
|
|
53
|
+
*/
|
|
54
|
+
function readLocalYamlSourced(specBase, worktreePath) {
|
|
55
|
+
if (specBase) {
|
|
56
|
+
const p = join(specBase, 'local.yaml');
|
|
57
|
+
if (existsSync(p)) return { text: readFileSync(p, 'utf8'), source: 'specBase' };
|
|
55
58
|
}
|
|
56
|
-
|
|
59
|
+
if (worktreePath) {
|
|
60
|
+
const p = join(worktreePath, '.sillyspec', 'local.yaml');
|
|
61
|
+
if (existsSync(p)) return { text: readFileSync(p, 'utf8'), source: 'worktree' };
|
|
62
|
+
}
|
|
63
|
+
return { text: null, source: null };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 兼容旧签名:只要文本 */
|
|
67
|
+
function readLocalYaml(specBase, worktreePath) {
|
|
68
|
+
return readLocalYamlSourced(specBase, worktreePath).text;
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
/** 从 local.yaml 文本提取 commands.install(轻量正则,不引 yaml 依赖,与 scan-postcheck 一致)*/
|
|
@@ -93,6 +105,10 @@ export function inferInstallCommand(projectType, worktreePath, userInstall) {
|
|
|
93
105
|
case 'maven':
|
|
94
106
|
return 'mvn -o test';
|
|
95
107
|
case 'gradle':
|
|
108
|
+
// win32 cmd.exe 跑不了 ./gradlew(体检 BUG-08 同型):优先 gradlew.bat,否则全局 gradle
|
|
109
|
+
if (process.platform === 'win32') {
|
|
110
|
+
return existsSync(join(worktreePath, 'gradlew.bat')) ? 'gradlew.bat test' : 'gradle test';
|
|
111
|
+
}
|
|
96
112
|
return './gradlew test';
|
|
97
113
|
case 'python':
|
|
98
114
|
// uv 优先(现代 Python 工具链,pyproject.toml/uv.lock 项目走 uv sync 建 .venv + 装依赖,
|
|
@@ -130,15 +146,80 @@ function tryLink(mainNodeModules, linkPath) {
|
|
|
130
146
|
}
|
|
131
147
|
}
|
|
132
148
|
|
|
133
|
-
/**
|
|
149
|
+
/**
|
|
150
|
+
* 执行 install 命令(带超时)。
|
|
151
|
+
*
|
|
152
|
+
* 安全收口(体检 SEC-01):commands.install 来自 local.yaml 配置,若经 execSync(shell)
|
|
153
|
+
* 原样执行,yaml 里的 `$(...)`/反引号/`;` 必然被解释——且 worktree 副本属 agent 可写区。
|
|
154
|
+
* 三道门:
|
|
155
|
+
* 1. 调用方保证来源可信(provisionDeps 只对 source==='specBase' 的 userInstall 走到这里)
|
|
156
|
+
* 2. 可执行前缀白名单(包管理器家族)
|
|
157
|
+
* 3. shell 元字符黑名单(win32 额外拦 %,cmd.exe 会展开 %VAR%),通过后拆 argv 数组执行不经 shell
|
|
158
|
+
*
|
|
159
|
+
* 链式命令支持(坑 worktree-install-whitelist-monorepo-chain,2026-08-20 实证):monorepo 的
|
|
160
|
+
* commands.install 常为链式写法(`cd web && pnpm install` / `npm install && npm run build:pkg`),
|
|
161
|
+
* 旧实现整条过白名单 + 元字符门,`&&` 必拒 → depsStatus=failed → execute deps 门控卡死且无自愈
|
|
162
|
+
* 路径(改 local.yaml 成单命令对需要 post-install build 的 monorepo 不可行)。现按 `&&` 拆段
|
|
163
|
+
* 逐段校验执行:每段独立过白名单 + 元字符门(拆分后残余的单 `&`/`|`/`;` 仍被元字符门拦),
|
|
164
|
+
* 段内允许 `cd <相对子路径>`(resolve 后必须仍在 worktree 根内,防越界建产物/读外部),
|
|
165
|
+
* 全程 argv 数组执行不经 shell、任一段失败即停(&& 语义)。安全面不变:每个执行段仍是
|
|
166
|
+
* 白名单包管理器命令,`||`/管道/`;`/后台均不支持。
|
|
167
|
+
*/
|
|
168
|
+
const INSTALL_BINARY_WHITELIST = /^(?:\.\/)?(?:pnpm|npm|yarn|bun|mvn|gradle|gradlew|uv|pip|pip3|python|python3|poetry|make)(?:\.cmd|\.bat|\.exe)?(?:\s|$)/;
|
|
169
|
+
const SHELL_METACHARS = /[;&|<>$`\n]/;
|
|
170
|
+
const WIN_SHELL_METACHARS = /[;&|<>$`\n%]/;
|
|
171
|
+
const CD_SEGMENT_RE = /^cd\s+([^\s]+)$/;
|
|
172
|
+
|
|
134
173
|
function tryInstall(cmd, cwd, timeout) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
return { ok:
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
174
|
+
const trimmed = String(cmd || '').trim();
|
|
175
|
+
if (!trimmed) {
|
|
176
|
+
return { ok: false, error: 'install 命令为空' };
|
|
177
|
+
}
|
|
178
|
+
const metaRe = process.platform === 'win32' ? WIN_SHELL_METACHARS : SHELL_METACHARS;
|
|
179
|
+
// && 拆段(单段命令与原路径完全一致,零回归);空段(`a &&`/`&& b`)视为畸形命令拒绝
|
|
180
|
+
const segments = trimmed.split('&&').map(s => s.trim());
|
|
181
|
+
if (segments.some(s => s === '')) {
|
|
182
|
+
return { ok: false, error: `install 链式命令含空段,拒绝执行: ${trimmed}` };
|
|
141
183
|
}
|
|
184
|
+
let curCwd = cwd;
|
|
185
|
+
const root = resolvePath(cwd);
|
|
186
|
+
const planned = [];
|
|
187
|
+
for (const seg of segments) {
|
|
188
|
+
// 白名单先于元字符(保持原判定顺序):`curl … | sh` 报非白名单而非元字符,
|
|
189
|
+
// `npm install; rm …` 过白名单后被元字符门拦——两类拒绝语义与原实现一致
|
|
190
|
+
const cd = seg.match(CD_SEGMENT_RE);
|
|
191
|
+
if (!cd && !INSTALL_BINARY_WHITELIST.test(seg)) {
|
|
192
|
+
return { ok: false, error: `install 命令不在包管理器白名单内,拒绝执行: ${seg}(支持 && 链式与 cd <子目录> 段,各执行段仍须为白名单包管理器命令)` };
|
|
193
|
+
}
|
|
194
|
+
if (metaRe.test(seg)) {
|
|
195
|
+
return { ok: false, error: `install 命令段含 shell 元字符,拒绝执行: ${seg}(链式仅支持 &&;|| / 管道 / ; / 后台不支持)` };
|
|
196
|
+
}
|
|
197
|
+
if (cd) {
|
|
198
|
+
const target = resolvePath(curCwd, cd[1]);
|
|
199
|
+
if (target !== root && !target.startsWith(root + pathSep)) {
|
|
200
|
+
return { ok: false, error: `install 命令的 cd 段越出 worktree 根,拒绝执行: ${seg}(cwd ${cwd})` };
|
|
201
|
+
}
|
|
202
|
+
curCwd = target;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
planned.push(seg);
|
|
206
|
+
}
|
|
207
|
+
for (const seg of planned) {
|
|
208
|
+
const argv = seg.split(/\s+/);
|
|
209
|
+
try {
|
|
210
|
+
if (process.platform === 'win32') {
|
|
211
|
+
// Windows 包管理器是 .cmd 垫片,无 shell 的 spawn 无法解析——经 cmd.exe /c 传参;
|
|
212
|
+
// 元字符(含 %)已拦,残余风险与 tryLink 的 mklink 同款
|
|
213
|
+
execFileSync('cmd.exe', ['/c', ...argv], { cwd: curCwd, timeout, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
214
|
+
} else {
|
|
215
|
+
execFileSync(argv[0], argv.slice(1), { cwd: curCwd, timeout, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
216
|
+
}
|
|
217
|
+
} catch (e) {
|
|
218
|
+
const msg = e.killed ? `timeout after ${timeout}ms` : ((e.stderr && e.stderr.toString()) || e.message);
|
|
219
|
+
return { ok: false, error: `${seg} failed (cwd ${curCwd}): ${msg}` };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { ok: true };
|
|
142
223
|
}
|
|
143
224
|
|
|
144
225
|
/** 目录是否含 nodejs 标记(package.json 或任一 lockfile)——识别 monorepo 里的 nodejs 子模块 */
|
|
@@ -174,6 +255,16 @@ function extractModulePaths(yamlText) {
|
|
|
174
255
|
return [...new Set(paths)];
|
|
175
256
|
}
|
|
176
257
|
|
|
258
|
+
/**
|
|
259
|
+
* modules 块 path 安全校验(体检 SEC-07):只允许 worktree 内的相对子路径——
|
|
260
|
+
* 拒绝绝对路径与 `..` 段,防止 local.yaml 配置把 junction/symlink 建到 worktree 之外。
|
|
261
|
+
*/
|
|
262
|
+
function isSafeModulePath(p) {
|
|
263
|
+
if (!p || isAbsolute(p)) return false;
|
|
264
|
+
const segs = p.split(/[\\/]+/);
|
|
265
|
+
return !segs.includes('..');
|
|
266
|
+
}
|
|
267
|
+
|
|
177
268
|
/**
|
|
178
269
|
* 对单个子模块目录 tryLink main 的 node_modules → wt 的 node_modules(modules 子模块专用)。
|
|
179
270
|
* 仅走 link 快路径(不 install——子模块 install 慢且易失败;lockfile 不一致时交给用户 pnpm install)。
|
|
@@ -211,12 +302,17 @@ function linkOneDir(wtDir, mainDir) {
|
|
|
211
302
|
export function provisionDeps(worktreePath, mainCwd, opts = {}) {
|
|
212
303
|
const { specBase = null, timeout = DEFAULT_TIMEOUT_MS, force = false } = opts;
|
|
213
304
|
const depsCheckedAt = new Date().toISOString();
|
|
214
|
-
const yamlText =
|
|
305
|
+
const { text: yamlText, source: yamlSource } = readLocalYamlSourced(specBase, worktreePath);
|
|
215
306
|
const wtHash = lockfileHash(worktreePath);
|
|
216
307
|
|
|
217
308
|
// ── 1. 根目录供给 ──
|
|
218
309
|
const projectType = detectProjectType(worktreePath, specBase);
|
|
219
|
-
|
|
310
|
+
// SEC-01:commands.install 只信主仓 specBase 的 local.yaml——worktree 副本是 agent 可写区,
|
|
311
|
+
// 其 install 命令若执行等于绕过 worktree-guard 的危险命令拦截
|
|
312
|
+
const userInstall = yamlSource === 'specBase' ? extractUserInstall(yamlText) : null;
|
|
313
|
+
if (yamlSource === 'worktree' && extractUserInstall(yamlText)) {
|
|
314
|
+
console.warn('⚠️ worktree 内 local.yaml 的 commands.install 被忽略(agent 可写区,只信主仓 .sillyspec/local.yaml)');
|
|
315
|
+
}
|
|
220
316
|
const installCmd = inferInstallCommand(projectType, worktreePath, userInstall);
|
|
221
317
|
|
|
222
318
|
let result;
|
|
@@ -257,6 +353,10 @@ export function provisionDeps(worktreePath, mainCwd, opts = {}) {
|
|
|
257
353
|
const modulePaths = extractModulePaths(yamlText);
|
|
258
354
|
const moduleResults = [];
|
|
259
355
|
for (const mp of modulePaths) {
|
|
356
|
+
if (!isSafeModulePath(mp)) {
|
|
357
|
+
moduleResults.push({ path: mp, status: 'skipped', reason: 'path 越界(绝对路径或 .. 段),拒绝 link' });
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
260
360
|
const wtDir = join(worktreePath, mp);
|
|
261
361
|
const mainDir = mainCwd ? join(mainCwd, mp) : null;
|
|
262
362
|
if (!existsSync(wtDir) || !mainDir || !hasNodeMarker(wtDir)) continue;
|
package/src/worktree.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* 分支命名:sillyspec/<change-name>
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { execFileSync } from 'child_process';
|
|
12
12
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, readlinkSync, unlinkSync } from 'fs';
|
|
13
13
|
import { join, resolve, dirname, relative, isAbsolute } from 'path';
|
|
14
14
|
import { createHash } from 'crypto';
|
|
@@ -463,6 +463,12 @@ export class WorktreeManager {
|
|
|
463
463
|
if (!check.supported) {
|
|
464
464
|
throw new Error(`git worktree add 失败: ${e.stderr || e.message}\n\n${check.reason ? `原因: ${check.reason}` : ''}\n建议: 升级 git 到 >= 2.15;或运行 \`sillyspec worktree doctor --fix\` 检查 worktree 状态。`);
|
|
465
465
|
}
|
|
466
|
+
// 体检 BUG-16 并发竞态防御:多 agent 同时首建同名 change 时,败者的 add 失败可能只是
|
|
467
|
+
// 「赢家刚建成目录/分支」。此时降级 in-place 会造成一个 worktree + 一个 in-place 的
|
|
468
|
+
// 分裂状态(两 agent 各写各的基线)——先重查,并发赢家已建成则按 already exists 抛错。
|
|
469
|
+
if (existsSync(worktreePath) || gitQuiet(this.cwd, ['rev-parse', '--verify', `refs/heads/${branch}`])) {
|
|
470
|
+
throw new Error(`worktree already exists: ${name}(并发创建,另一进程已抢先建成,本进程不降级 in-place). Run cleanup first.`);
|
|
471
|
+
}
|
|
466
472
|
// sandbox/permission fallback: 降级为 in-place + baseline protection
|
|
467
473
|
console.log(`⚠️ git worktree add 失败(可能是沙箱权限限制),降级为 in-place 模式 + baseline protection`);
|
|
468
474
|
console.log(` 原因: ${e.stderr || e.message}`);
|
|
@@ -928,7 +934,11 @@ export class WorktreeManager {
|
|
|
928
934
|
const residual = [];
|
|
929
935
|
if (!isInPlace && existsSync(worktreePath)) residual.push(`worktree dir: ${worktreePath}`);
|
|
930
936
|
if (existsSync(metaDir)) residual.push(`meta dir: ${metaDir}`);
|
|
931
|
-
|
|
937
|
+
// 斜杠方向归一后比较(体检 BUG-07):Windows 下 git worktree list 输出正斜杠、
|
|
938
|
+
// worktreePath 是反斜杠,直接 includes 恒 false → 残留场景漏报、cleanup 误报 cleaned
|
|
939
|
+
const toPosixNorm = (p) => String(p || '').replace(/\\/g, '/').toLowerCase()
|
|
940
|
+
const wtListOut = gitQuiet(this.cwd, ['worktree', 'list'], { timeout: 30000 })
|
|
941
|
+
if (!isInPlace && wtListOut && toPosixNorm(wtListOut).includes(toPosixNorm(worktreePath))) {
|
|
932
942
|
residual.push('git worktree list still references this worktree');
|
|
933
943
|
}
|
|
934
944
|
if (residual.length > 0) {
|
|
@@ -1033,11 +1043,32 @@ export class WorktreeManager {
|
|
|
1033
1043
|
* 不传则全量扫(兼容现有行为)。
|
|
1034
1044
|
* @returns {{ issues: Array<{ type: string, name: string, detail: string, fixable: boolean }>, fixed: string[], unfixable: string[] }}
|
|
1035
1045
|
*/
|
|
1036
|
-
doctor({ fix = false, staleHours = 24, changeName = null } = {}) {
|
|
1046
|
+
async doctor({ fix = false, staleHours = 24, changeName = null } = {}) {
|
|
1037
1047
|
const issues = [];
|
|
1038
1048
|
const fixed = [];
|
|
1039
1049
|
const unfixable = [];
|
|
1040
1050
|
|
|
1051
|
+
// 活跃变更注册表(坑 doctor-fix-orphan-branch-parallel-active,2026-08-20 实证):孤儿分支
|
|
1052
|
+
// 判定原只看本地 meta 目录注册表(metaNames),与变更活跃态的权威注册表(进度库 changes 表)
|
|
1053
|
+
// 数据源不一致——并行会话的活跃变更(meta 已清/in-place/平台模式 meta 在别处)分支会被全局
|
|
1054
|
+
// doctor --fix 误删。删分支前交叉核对:分支名 ∈ 活跃变更 → 保留(fixable:false 提示人工确认)。
|
|
1055
|
+
// 先探 DB 文件存在再实例化 ProgressManager(坑7 读路径建库:_ensureDB 不存在会建库污染)。
|
|
1056
|
+
// null = 进度库存在但读失败(无法判定活跃态,保守不自动删);空 Set = 无库/无活跃变更
|
|
1057
|
+
// (git-only 工作流零回归,孤儿分支照删)。
|
|
1058
|
+
let activeChanges = null;
|
|
1059
|
+
try {
|
|
1060
|
+
const specBaseOfWt = dirname(dirname(this.worktreeBase)); // <specBase>(.runtime/worktrees 上两级)
|
|
1061
|
+
if (existsSync(join(specBaseOfWt, '.runtime', 'sillyspec.db'))) {
|
|
1062
|
+
const { ProgressManager } = await import('./progress.js');
|
|
1063
|
+
activeChanges = new Set(new ProgressManager({ specDir: specBaseOfWt }).listChanges(this.cwd));
|
|
1064
|
+
} else {
|
|
1065
|
+
activeChanges = new Set();
|
|
1066
|
+
}
|
|
1067
|
+
} catch (e) {
|
|
1068
|
+
console.warn(`⚠️ doctor 进度库读取失败,孤儿分支的活跃态交叉核对不可用(保守不自动删): ${e.message}`);
|
|
1069
|
+
activeChanges = null;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1041
1072
|
// 1. 列出 git worktree list 中的条目
|
|
1042
1073
|
let gitWorktreeList = [];
|
|
1043
1074
|
try {
|
|
@@ -1167,16 +1198,30 @@ export class WorktreeManager {
|
|
|
1167
1198
|
}
|
|
1168
1199
|
}
|
|
1169
1200
|
|
|
1170
|
-
// 5. 检查 SillySpec
|
|
1201
|
+
// 5. 检查 SillySpec 分支残留(活跃变更交叉核对,见方法头 activeChanges 注释)
|
|
1171
1202
|
try {
|
|
1172
|
-
|
|
1203
|
+
// QUAL-01 收口:裸 execFileSync → git-helper git(原调用点未传 timeout,统一入口默认 5s 足够 branch --list)
|
|
1204
|
+
const branches = git(this.cwd, ['branch', '--list', `${BRANCH_PREFIX}*`]);
|
|
1173
1205
|
if (branches) {
|
|
1174
1206
|
for (const line of branches.split('\n').filter(Boolean)) {
|
|
1175
1207
|
const branch = line.replace(/^\*?\s+/, '').trim();
|
|
1176
1208
|
const name = branch.replace(BRANCH_PREFIX, '');
|
|
1177
1209
|
if (changeName && name !== changeName) continue; // --change 过滤:仅扫指定 change
|
|
1178
1210
|
if (!metaNames.has(name)) {
|
|
1179
|
-
|
|
1211
|
+
if (activeChanges && activeChanges.has(name)) {
|
|
1212
|
+
// 变更仍注册活跃:分支大概率是并行会话在用(或 apply 后被 review 引用审计保护保留)
|
|
1213
|
+
// ——数据源不一致的正解是保留 + 人工确认,不是删
|
|
1214
|
+
issues.push({ type: 'active-branch', name, fixable: false,
|
|
1215
|
+
detail: `分支 ${branch} 无本地 meta,但变更 "${name}" 在进度库仍注册为活跃(并行会话在跑 / meta 已清分支在用)——保留。确认废弃后:先归档/结束该变更,或手动 git branch -D ${branch}` });
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
if (activeChanges === null) {
|
|
1219
|
+
// 进度库读失败:无法判定活跃态,保守不自动删(宁可漏删不可误删并行会话分支)
|
|
1220
|
+
issues.push({ type: 'orphan-branch', name, fixable: false,
|
|
1221
|
+
detail: `分支疑似残留(无对应 meta,且进度库不可读无法交叉核对活跃态): ${branch}——保守不自动删,人工确认后 git branch -D ${branch}` });
|
|
1222
|
+
continue;
|
|
1223
|
+
}
|
|
1224
|
+
issues.push({ type: 'orphan-branch', name, detail: `分支残留(无对应 meta,且非活跃变更): ${branch}`, fixable: true });
|
|
1180
1225
|
if (fix) {
|
|
1181
1226
|
try { gitQuiet(this.cwd, ['branch', '-D', branch]); fixed.push(`deleted orphan branch: ${branch}`); } catch { unfixable.push(`branch delete failed: ${branch}`); }
|
|
1182
1227
|
}
|
|
@@ -1357,7 +1402,8 @@ export class WorktreeManager {
|
|
|
1357
1402
|
if (staged) {
|
|
1358
1403
|
try {
|
|
1359
1404
|
// 用 Buffer 模式读取,避免二进制 patch 被 UTF-8 解码损坏
|
|
1360
|
-
|
|
1405
|
+
// QUAL-01 收口:git-helper 新增 encoding:'buffer' 支持(二进制输出专用,跳过 trim)
|
|
1406
|
+
const patchBuf = git(mainCwd, ['diff', '--cached', '--binary'], { encoding: 'buffer', timeout: 30000 });
|
|
1361
1407
|
if (patchBuf && patchBuf.length > 0) {
|
|
1362
1408
|
const patchFile = join(worktreePath, '.sillyspec-baseline-staged.patch');
|
|
1363
1409
|
try {
|
|
@@ -1379,8 +1425,8 @@ export class WorktreeManager {
|
|
|
1379
1425
|
const unstaged = gitQuiet(mainCwd, ['diff', '--name-only'], { timeout: 30000 }) || '';
|
|
1380
1426
|
if (unstaged) {
|
|
1381
1427
|
try {
|
|
1382
|
-
// 用 Buffer 模式读取,避免二进制 patch 被 UTF-8
|
|
1383
|
-
const patchBuf =
|
|
1428
|
+
// 用 Buffer 模式读取,避免二进制 patch 被 UTF-8 解码损坏(QUAL-01 收口,同上)
|
|
1429
|
+
const patchBuf = git(mainCwd, ['diff', '--binary'], { encoding: 'buffer', timeout: 30000 });
|
|
1384
1430
|
if (patchBuf && patchBuf.length > 0) {
|
|
1385
1431
|
const patchFile = join(worktreePath, '.sillyspec-baseline-unstaged.patch');
|
|
1386
1432
|
try {
|
package/.npmrc-publish
DELETED