sillyspec 3.29.5 → 3.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/sillyspec-auto/SKILL.md +1 -1
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -2
- package/.claude/skills/sillyspec-commit/SKILL.md +1 -1
- package/.claude/skills/sillyspec-flow/SKILL.md +57 -0
- package/.claude/skills/sillyspec-quick/SKILL.md +7 -105
- package/AGENTS.md +38 -20
- package/SKILL.md +9 -3
- package/assets/command-cards/flow.md +32 -0
- package/assets/command-cards/run-brainstorm.md +1 -1
- package/assets/command-cards/run-quick.md +8 -25
- package/package.json +2 -1
- package/packages/dashboard/server/executor.js +2 -1
- package/packages/dashboard/server/parser.js +7 -7
- package/r7-test-env/README-R7-TEST.md +93 -0
- package/r7-test-env/demo-project/lib/calc.js +6 -0
- package/r7-test-env/demo-project/package.json +1 -0
- package/r7-test-env/demo-project/test.js +10 -0
- package/r7-test-env/prompt-R7-SMOKE.md +41 -0
- package/r7-test-env/sillyspec-r7.cmd +3 -0
- package/round4/account.mjs +18 -5
- package/round4/b1-analyze-task08.mjs +40 -0
- package/round4/b1-b1-target-validation.md +32 -0
- package/round4/b3a-prompt-audit-result.md +66 -0
- package/round4/b3a-prompt-audit.mjs +89 -0
- package/round4/optimization-plan.md +165 -0
- package/round4/r4-final-report.html +347 -0
- package/round4/r5-scenario-matrix.md +46 -0
- package/src/change-list.js +9 -0
- package/src/classify-change.js +6 -0
- package/src/command-cards.js +2 -1
- package/src/commit-guard.js +2 -0
- package/src/config-schema.js +64 -12
- package/src/db.js +35 -4
- package/src/decision-distill.js +9 -3
- package/src/docs-check.js +1 -1
- package/src/doctor-diagnostics.js +44 -2
- package/src/flow-draft.js +748 -0
- package/src/flow-parity.js +236 -0
- package/src/flow-review.js +184 -0
- package/src/flow.js +1492 -0
- package/src/foreign-declared.js +17 -1
- package/src/fr-index.js +396 -22
- package/src/fs-atomic.js +5 -1
- package/src/git-helper.js +2 -2
- package/src/greenfield-bootstrap.js +98 -0
- package/src/handoff.js +120 -7
- package/src/hunk-attribution.js +191 -0
- package/src/index.js +570 -16
- package/src/knowledge-classify.js +47 -1
- package/src/knowledge-digest.js +162 -0
- package/src/knowledge-quicklog.js +246 -0
- package/src/machine-draft.js +128 -0
- package/src/machine-interface.js +107 -5
- package/src/mcp-server.js +1 -0
- package/src/module-resolve.js +3 -1
- package/src/preview-progress.js +160 -0
- package/src/progress/change-registry.js +11 -3
- package/src/progress/shared.js +3 -2
- package/src/progress.js +56 -13
- package/src/quicklog.js +44 -6
- package/src/redomain.js +147 -0
- package/src/review-material-pack.js +204 -1
- package/src/run/archive-distill.js +4 -2
- package/src/run/bg-sync.js +67 -0
- package/src/run/command.js +123 -10
- package/src/run/complete-handlers.js +327 -72
- package/src/run/complete.js +350 -71
- package/src/run/gate-snapshot-ledger.js +213 -0
- package/src/run/gate-snapshot.js +250 -34
- package/src/run/gates.js +370 -233
- package/src/run/green-cache.js +123 -0
- package/src/run/junction-rm.js +70 -0
- package/src/run/next.js +14 -0
- package/src/run/prompt.js +185 -55
- package/src/run/quick-audit.js +185 -12
- package/src/run/shared.js +120 -3
- package/src/run/stage.js +182 -216
- package/src/run/test-ledger.js +264 -0
- package/src/run/verify-quality-scan.js +84 -7
- package/src/scope-audit.js +6 -1
- package/src/sentinel-assertions.js +114 -0
- package/src/setup.js +3 -3
- package/src/spec-sync.js +41 -1
- package/src/stage-contract.js +26 -8
- package/src/stage-review-checklist.js +2 -0
- package/src/stage-review.js +7 -2
- package/src/stage-templates.js +157 -0
- package/src/stages/archive.js +2 -0
- package/src/stages/brainstorm.js +24 -106
- package/src/stages/execute.js +221 -84
- package/src/stages/knowledge.js +74 -1
- package/src/stages/plan-postcheck.js +352 -1
- package/src/stages/plan.js +23 -1
- package/src/stages/quick.js +10 -2
- package/src/stages/verify.js +4 -2
- package/src/sync.js +81 -23
- package/src/task-done.js +181 -0
- package/src/task-review.js +8 -55
- package/src/test-bindings.js +549 -0
- package/src/timeline.js +218 -0
- package/src/ui-visual.js +175 -0
- package/src/verify-draft.js +214 -0
- package/src/verify-postcheck.js +860 -158
- package/src/verify-probes.js +59 -3
- package/src/watcher.js +1031 -0
- package/src/worktree-apply.js +44 -4
- package/src/worktree-deps.js +22 -8
- package/src/worktree-policy.js +42 -0
- package/src/worktree.js +102 -15
- package/templates/agents-instruction.md +37 -18
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gate-snapshot-ledger.js — 门禁隔离快照的生命周期账本与陈旧回收
|
|
3
|
+
* (2026-09-24-gate-snapshot-lifecycle,task-01;D-003@v2 / D-004@v2 / D-005@v1)
|
|
4
|
+
*
|
|
5
|
+
* 存在理由:门禁快照(%TEMP%/sillyspec-gate-*,src/run/gate-snapshot.js 建、quick/verify
|
|
6
|
+
* 门 finally 清)在三条路径必然泄漏——进程被杀(cleanup 根本没跑)、Windows rmSync 无重试
|
|
7
|
+
* 遇 EPERM/junction 锁、git worktree remove 失败留 prunable 注册。实证攒到 41 个目录+2 个
|
|
8
|
+
* prunable 注册且全仓无清扫机制。本模块提供:
|
|
9
|
+
* ①账本(create 登记 / cleanup 销账,幂等原子写)——崩溃残留的可追踪凭据;
|
|
10
|
+
* ②路径与条目 fail-closed 守卫——账本是可变 JSON,删除原语调用前必过(D-005@v1);
|
|
11
|
+
* ③TTL×pid 三态保守判定(D-003@v2)与「目录+worktree 注册双清」回收执行(D-004@v2):
|
|
12
|
+
* 双清确认才销号——删不掉的残留必须留在账本里等下一轮,否则永久失追踪。
|
|
13
|
+
*
|
|
14
|
+
* 信任边界:删除原语仅由本模块的回收路径调用,且每个条目先过 isSafeLedgerEntry;
|
|
15
|
+
* gate-snapshot.js 自身 cleanup 的 root 来自 mkdtemp(生成即受信)不经过账本。
|
|
16
|
+
* 异常面:全部 fail-open 吞掉(账本/回收任一异常零阻断,退现状残留)。
|
|
17
|
+
*/
|
|
18
|
+
import { execFileSync } from 'node:child_process'
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
20
|
+
import { basename, dirname, join, resolve, sep } from 'node:path'
|
|
21
|
+
import { tmpdir } from 'node:os'
|
|
22
|
+
import { writeAtomicSync } from '../fs-atomic.js'
|
|
23
|
+
import { safeRemoveDirWithLinks, unlinkLinkPath, collectLinkPathsUnder } from './junction-rm.js'
|
|
24
|
+
|
|
25
|
+
const LEDGER_FILE = 'active-gate-snapshots.json'
|
|
26
|
+
const HOUR_MS = 3600_000
|
|
27
|
+
const STALE_HOURS_DEFAULT = 24
|
|
28
|
+
|
|
29
|
+
/** 账本文件路径(runtime 域:.sillyspec/.runtime/active-gate-snapshots.json) */
|
|
30
|
+
export function gateSnapshotLedgerPath(runtimeRoot) {
|
|
31
|
+
return join(runtimeRoot, LEDGER_FILE)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 读账本(损坏/缺失/非数组 → 空数组,fail-open) */
|
|
35
|
+
export function readGateSnapshotLedger(runtimeRoot) {
|
|
36
|
+
if (!runtimeRoot) return []
|
|
37
|
+
try {
|
|
38
|
+
const p = gateSnapshotLedgerPath(runtimeRoot)
|
|
39
|
+
if (!existsSync(p)) return []
|
|
40
|
+
const data = JSON.parse(readFileSync(p, 'utf8'))
|
|
41
|
+
if (!Array.isArray(data)) return []
|
|
42
|
+
return data.filter((e) => e && typeof e === 'object' && typeof e.snapshotRoot === 'string')
|
|
43
|
+
} catch {
|
|
44
|
+
return []
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeLedger(runtimeRoot, entries) {
|
|
49
|
+
mkdirSync(runtimeRoot, { recursive: true })
|
|
50
|
+
writeAtomicSync(gateSnapshotLedgerPath(runtimeRoot), JSON.stringify(entries, null, 2))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 登记快照(幂等:同 root 重复调用不追加;runtimeRoot 缺失退 no-op) */
|
|
54
|
+
export function registerGateSnapshot({ runtimeRoot, snapshotRoot, pid = process.pid }) {
|
|
55
|
+
if (!runtimeRoot || !snapshotRoot) return
|
|
56
|
+
try {
|
|
57
|
+
const entries = readGateSnapshotLedger(runtimeRoot)
|
|
58
|
+
if (entries.some((e) => e.snapshotRoot === snapshotRoot)) return
|
|
59
|
+
entries.push({ snapshotRoot, pid, createdAt: Date.now() })
|
|
60
|
+
writeLedger(runtimeRoot, entries)
|
|
61
|
+
} catch { /* fail-open:登记失败仅退化为残留不可追踪,不阻断门禁 */ }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 销账(幂等:缺失不抛;调用方负责先确认双清——本函数不判目录/注册状态) */
|
|
65
|
+
export function unregisterGateSnapshot({ runtimeRoot, snapshotRoot }) {
|
|
66
|
+
if (!runtimeRoot || !snapshotRoot) return
|
|
67
|
+
try {
|
|
68
|
+
const entries = readGateSnapshotLedger(runtimeRoot)
|
|
69
|
+
const next = entries.filter((e) => e.snapshotRoot !== snapshotRoot)
|
|
70
|
+
if (next.length === entries.length) return
|
|
71
|
+
writeLedger(runtimeRoot, next)
|
|
72
|
+
} catch { /* fail-open:销账失败保留条目,下轮重试 */ }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 路径守卫(D-005@v1):必须是 tmpdir 的**直接子目录**且 basename 严格匹配
|
|
77
|
+
* sillyspec-gate-* ——账本可被篡改,删除原语调用前唯一路径闸门。
|
|
78
|
+
*/
|
|
79
|
+
export function isSafeSnapshotRoot(snapshotRoot, { tmpBase = tmpdir() } = {}) {
|
|
80
|
+
if (typeof snapshotRoot !== 'string' || !snapshotRoot) return false
|
|
81
|
+
if (snapshotRoot.includes('..')) return false
|
|
82
|
+
// 路径比较口径:win32 不区分大小写(TEMP/TMP 跨进程大小写漂移会让合法残留永不满足守卫)
|
|
83
|
+
const fold = (p) => (process.platform === 'win32' ? p.toLowerCase() : p)
|
|
84
|
+
const norm = resolve(snapshotRoot)
|
|
85
|
+
if (fold(dirname(norm)) !== fold(resolve(tmpBase))) return false
|
|
86
|
+
return /^sillyspec-gate-/.test(basename(norm))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 条目结构守卫:根过路径守卫 ∧ pid 正整数 ∧ createdAt 有限毫秒值 */
|
|
90
|
+
export function isSafeLedgerEntry(entry, opts) {
|
|
91
|
+
return !!entry
|
|
92
|
+
&& isSafeSnapshotRoot(entry.snapshotRoot, opts)
|
|
93
|
+
&& Number.isInteger(entry.pid) && entry.pid > 0
|
|
94
|
+
&& Number.isFinite(entry.createdAt)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** staleHours 值域:非有限/非正 → 回退 24h(D-003@v2 单一入口) */
|
|
98
|
+
export function resolveStaleHours(value = process.env.SILLYSPEC_GATE_SNAPSHOT_STALE_HOURS) {
|
|
99
|
+
const n = Number(value)
|
|
100
|
+
return Number.isFinite(n) && n > 0 ? n : STALE_HOURS_DEFAULT
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* pid 活性三态(D-003@v2,破坏性场景从严):成功/EPERM=活,ESRCH=死,
|
|
105
|
+
* 无效 pid/其他异常=按活跳过。与 bg-sync 终止扫描口径(无效 pid 按死)有意分歧——
|
|
106
|
+
* 那边误判代价=进程残留,这边误判代价=删掉活跃快照目录。
|
|
107
|
+
*/
|
|
108
|
+
function defaultIsProcessAlive(pid) {
|
|
109
|
+
if (!Number.isInteger(pid) || pid <= 0) return true
|
|
110
|
+
try {
|
|
111
|
+
process.kill(pid, 0)
|
|
112
|
+
return true
|
|
113
|
+
} catch (e) {
|
|
114
|
+
return !(e && e.code === 'ESRCH')
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 纯判定:守卫 ∧ 超 TTL ∧ 明确死 三合取才 stale;其余(未超期/存活/守卫不过/
|
|
120
|
+
* 异常)一律保留。staleHours 非法值回退 24h。
|
|
121
|
+
*/
|
|
122
|
+
export function selectStaleSnapshots(entries, { now = Date.now(), staleHours, isProcessAlive = defaultIsProcessAlive } = {}) {
|
|
123
|
+
const hours = resolveStaleHours(staleHours)
|
|
124
|
+
const probe = typeof isProcessAlive === 'function' ? isProcessAlive : defaultIsProcessAlive
|
|
125
|
+
const stale = []
|
|
126
|
+
const alive = []
|
|
127
|
+
for (const e of Array.isArray(entries) ? entries : []) {
|
|
128
|
+
if (!isSafeLedgerEntry(e)) { alive.push(e); continue }
|
|
129
|
+
if ((now - e.createdAt) / HOUR_MS <= hours) { alive.push(e); continue }
|
|
130
|
+
let isAlive
|
|
131
|
+
try { isAlive = !!probe(e.pid) } catch { isAlive = true }
|
|
132
|
+
if (isAlive) { alive.push(e); continue }
|
|
133
|
+
stale.push(e)
|
|
134
|
+
}
|
|
135
|
+
return { stale, alive }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function defaultRunGit(cwd, args) {
|
|
139
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60000, windowsHide: true }).trim()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function defaultRemoveDir(p) {
|
|
143
|
+
// junction 安全删除(坑 junction-pierce-rm-sync,2026-09-24 实证):残留快照内
|
|
144
|
+
// node_modules/venv junction 的目标是主仓真身,裸 rmSync(recursive) 穿透链接清空目标
|
|
145
|
+
// (收口期三次实证)——删前先解链(junction-rm.js 单一实现);解链失败抛错由调用方
|
|
146
|
+
// 按「删不掉保留条目待下轮」处置,绝不回落裸 rmSync。
|
|
147
|
+
safeRemoveDirWithLinks(p, { maxRetries: 5, retryDelay: 100 })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** worktree 注册是否仍登记该 root(跨平台路径归一 + win32 大小写不敏感) */
|
|
151
|
+
function defaultWorktreeRegistered(cwd, snapshotRoot) {
|
|
152
|
+
const out = defaultRunGit(cwd, ['worktree', 'list', '--porcelain'])
|
|
153
|
+
const norm = (s) => {
|
|
154
|
+
const v = String(s).replace(/\\/g, '/')
|
|
155
|
+
return process.platform === 'win32' ? v.toLowerCase() : v
|
|
156
|
+
}
|
|
157
|
+
const target = norm(snapshotRoot)
|
|
158
|
+
return out.split('\n').some((line) => line.startsWith('worktree ') && norm(line.slice('worktree '.length)) === target)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 回收执行(D-004@v2):逐条 remove → rmSync(remove 失败补 prune),双清确认
|
|
163
|
+
* (目录不存在 ∧ worktree list 无注册)才销号;删不掉的计入 skipped 保留条目。
|
|
164
|
+
* 返回 {reclaimed, skipped};异常全吞退零副作用。
|
|
165
|
+
*/
|
|
166
|
+
export function reclaimStaleGateSnapshots({
|
|
167
|
+
runtimeRoot,
|
|
168
|
+
cwd,
|
|
169
|
+
now = Date.now(),
|
|
170
|
+
staleHours,
|
|
171
|
+
isProcessAlive = defaultIsProcessAlive,
|
|
172
|
+
runGit = defaultRunGit,
|
|
173
|
+
removeDir = defaultRemoveDir,
|
|
174
|
+
worktreeRegistered = defaultWorktreeRegistered,
|
|
175
|
+
} = {}) {
|
|
176
|
+
if (!runtimeRoot || !cwd) return { reclaimed: [], skipped: [] }
|
|
177
|
+
try {
|
|
178
|
+
const { stale } = selectStaleSnapshots(readGateSnapshotLedger(runtimeRoot), { now, staleHours, isProcessAlive })
|
|
179
|
+
const reclaimed = []
|
|
180
|
+
const skipped = []
|
|
181
|
+
for (const e of stale) {
|
|
182
|
+
const root = e.snapshotRoot
|
|
183
|
+
let removeFailed = false
|
|
184
|
+
// 先解链(坑 git-worktree-remove-pierce,2026-09-24 实证:git worktree remove --force
|
|
185
|
+
// 在 Windows 跟随 junction 删主仓真身内容——残留快照内 node_modules/venv/copy 链接必须
|
|
186
|
+
// 先解链再删。解链失败按删不掉处置:保留条目待下轮,绝不带着链接跑删除)。
|
|
187
|
+
let linksCleared = true
|
|
188
|
+
try {
|
|
189
|
+
for (const link of collectLinkPathsUnder(root)) unlinkLinkPath(link)
|
|
190
|
+
} catch { linksCleared = false }
|
|
191
|
+
if (linksCleared) {
|
|
192
|
+
try { runGit(cwd, ['worktree', 'remove', '--force', root]) } catch { removeFailed = true }
|
|
193
|
+
try { removeDir(root) } catch { /* 目录删不掉→下方双清判定保留条目 */ }
|
|
194
|
+
} else {
|
|
195
|
+
removeFailed = true
|
|
196
|
+
}
|
|
197
|
+
if (removeFailed) { try { runGit(cwd, ['worktree', 'prune']) } catch { /* prune 失败→下方判定保留 */ } }
|
|
198
|
+
let dirGone = false
|
|
199
|
+
let regGone = false
|
|
200
|
+
try { dirGone = !existsSync(root) } catch { dirGone = false }
|
|
201
|
+
try { regGone = !worktreeRegistered(cwd, root) } catch { regGone = false }
|
|
202
|
+
if (dirGone && regGone) {
|
|
203
|
+
unregisterGateSnapshot({ runtimeRoot, snapshotRoot: root })
|
|
204
|
+
reclaimed.push(root)
|
|
205
|
+
} else {
|
|
206
|
+
skipped.push(root)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return { reclaimed, skipped }
|
|
210
|
+
} catch {
|
|
211
|
+
return { reclaimed: [], skipped: [] }
|
|
212
|
+
}
|
|
213
|
+
}
|
package/src/run/gate-snapshot.js
CHANGED
|
@@ -11,16 +11,103 @@
|
|
|
11
11
|
* junction 复用主仓依赖(零拷贝),local.yaml 从主仓复制(gitignore 不进 HEAD),在快照
|
|
12
12
|
* cwd 跑对账。快照基建任何失败 → 返回 null,调用方回退主仓现行为(零回归兜底)。
|
|
13
13
|
*
|
|
14
|
-
* 快照生命周期:一次门禁一批(create → run → cleanup
|
|
15
|
-
*
|
|
14
|
+
* 快照生命周期:一次门禁一批(create → run → cleanup);崩溃残留(进程被杀时 cleanup
|
|
15
|
+
* 不跑)由账本回收路径兜底——gate-snapshot-ledger.js 登记 create、cleanup 双清确认后销账,
|
|
16
|
+
* 下个门禁建快照前按 TTL×pid 双闸自愈回收(账本落 runtime 域 .sillyspec/.runtime/,
|
|
17
|
+
* 不进 git 不上平台)。
|
|
16
18
|
*/
|
|
17
19
|
import { execFileSync, spawnSync } from 'node:child_process'
|
|
18
|
-
import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync,
|
|
19
|
-
import { join, dirname, relative, resolve } from 'node:path'
|
|
20
|
+
import { mkdtempSync, mkdirSync, copyFileSync, writeFileSync, existsSync, readdirSync, statSync, symlinkSync, readFileSync } from 'node:fs'
|
|
21
|
+
import { join, dirname, relative, resolve, isAbsolute } from 'node:path'
|
|
20
22
|
import { tmpdir } from 'node:os'
|
|
23
|
+
import { registerGateSnapshot, unregisterGateSnapshot, reclaimStaleGateSnapshots } from './gate-snapshot-ledger.js'
|
|
24
|
+
import { safeRemoveDirWithLinks, unlinkLinkPath, collectLinkPathsUnder } from './junction-rm.js'
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* worktree 会话跳快照判定(2026-09-23 R9/R10 双实证根治):cwd 位于 sillyspec 会话专属
|
|
28
|
+
* execute worktree(.sillyspec/.runtime/worktrees/<change>)时,隔离快照的立命之本——
|
|
29
|
+
* 「主仓共享工作区并行会话脏文件不污染本会话门禁」——天然不成立问题不存在:worktree
|
|
30
|
+
* 本身就是单会话独占的隔离环境。而快照在此场景只剩纯成本:venv/node_modules junction
|
|
31
|
+
* 冻结与慢 I/O 面(R9 3MB/0CPU 冻结、R10 15min 慢跑均出自 Temp 快照内)、快照构建时延、
|
|
32
|
+
* 双层 overlay。跳过 = 直接在 worktree cwd 实测(口径与主仓 fallback 同构)。
|
|
33
|
+
* 判定(跨平台):路径分隔符先归一为 /,再匹配 /.sillyspec/.runtime/worktrees/<name>。
|
|
34
|
+
*/
|
|
35
|
+
export function shouldSkipGateSnapshotForWorktree(cwd) {
|
|
36
|
+
const p = String(cwd || '').replace(/\\/g, '/')
|
|
37
|
+
return /\/\.sillyspec\/\.runtime\/worktrees\/[^/]+/.test(p)
|
|
38
|
+
}
|
|
21
39
|
|
|
22
40
|
function git(cwd, args) {
|
|
23
|
-
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60000 }).trim()
|
|
41
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60000, windowsHide: true }).trim()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** worktree 注册是否仍登记该 root(跨平台路径归一 + win32 大小写不敏感) */
|
|
45
|
+
function worktreeListContains(cwd, snapshotRoot) {
|
|
46
|
+
const out = git(cwd, ['worktree', 'list', '--porcelain'])
|
|
47
|
+
const norm = (s) => {
|
|
48
|
+
const v = String(s).replace(/\\/g, '/')
|
|
49
|
+
return process.platform === 'win32' ? v.toLowerCase() : v
|
|
50
|
+
}
|
|
51
|
+
const target = norm(snapshotRoot)
|
|
52
|
+
return out.split('\n').some((line) => line.startsWith('worktree ') && norm(line.slice('worktree '.length)) === target)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 快照目录 + worktree 注册的清理体(2026-09-24-gate-snapshot-lifecycle task-02,FR-02)——
|
|
57
|
+
* Windows 硬化:rmSync 带重试(EPERM/junction 锁是本次 41 个残留目录的实证成因),
|
|
58
|
+
* worktree remove 失败后补 prune 清 prunable 注册(本次实证 2 条)。
|
|
59
|
+
* git/删目录/存在性判定的依赖均可注入(故障注入测试见 test/gate-snapshot-cleanup.test.mjs)。
|
|
60
|
+
* 返回双清结果供调用方决定账本销账(D-004@v2);全失败不抛(fail-open)。
|
|
61
|
+
*/
|
|
62
|
+
const REMOVE_DIR_RETRY = { maxRetries: 5, retryDelay: 100 }
|
|
63
|
+
|
|
64
|
+
export function cleanupSnapshot({
|
|
65
|
+
snapshotRoot,
|
|
66
|
+
cwd,
|
|
67
|
+
runGit = (c, a) => git(c, a),
|
|
68
|
+
// junction 安全删除(坑 git-worktree-remove-pierce,2026-09-24 实证):`git worktree remove
|
|
69
|
+
// --force` 的递归删在 Windows **跟随 junction 删目标内容**(实测 victim 复现;worktree.js
|
|
70
|
+
// cleanup 链的 unlinkNodeModulesLinks 先解链正是防它)——快照内 node_modules/venv/copy 面/
|
|
71
|
+
// 钉定缓存链接全部指向主仓真身,必须**先解链再交给 git/rmSync 删目录本体**。解链失败 →
|
|
72
|
+
// 保护性放弃本次删除(目录留账本待回收——双清契约天然兼容)。
|
|
73
|
+
removeDir = (p, opts) => safeRemoveDirWithLinks(p, opts),
|
|
74
|
+
dirExists = (p) => existsSync(p),
|
|
75
|
+
worktreeRegistered = (c, p) => worktreeListContains(c, p),
|
|
76
|
+
}) {
|
|
77
|
+
let worktreeCleaned = true
|
|
78
|
+
// 0. 先解链快照内全部 junction/reparse 点(含深层:venv 族与 gate_snapshot.copy 面在
|
|
79
|
+
// 子目录层)。解链失败不删目录(穿透风险下,保护主仓优先于清理彻底性)。
|
|
80
|
+
let linksCleared = true
|
|
81
|
+
try {
|
|
82
|
+
for (const link of collectLinkPathsUnder(snapshotRoot)) unlinkLinkPath(link)
|
|
83
|
+
} catch (e) {
|
|
84
|
+
linksCleared = false
|
|
85
|
+
console.warn(`⚠️ 快照 junction 解链失败(保护主仓链接目标,本次不删快照目录——条目留账本待下轮回收): ${e && e.message ? e.message : e}`)
|
|
86
|
+
}
|
|
87
|
+
if (linksCleared) {
|
|
88
|
+
// 注:worktree remove/prune 无 --quiet 选项(非法 flag 会必抛并被 catch 吞掉——旧实现
|
|
89
|
+
// 正是因此让 git remove 每次都失败、只剩无重试 rmSync 兜底,41 个残留目录的根因之一)
|
|
90
|
+
try {
|
|
91
|
+
runGit(cwd, ['worktree', 'remove', '--force', snapshotRoot])
|
|
92
|
+
} catch {
|
|
93
|
+
worktreeCleaned = false
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
worktreeCleaned = false
|
|
97
|
+
}
|
|
98
|
+
let dirRemoved = !dirExists(snapshotRoot)
|
|
99
|
+
if (linksCleared && !dirRemoved) {
|
|
100
|
+
try { removeDir(snapshotRoot, REMOVE_DIR_RETRY) } catch { /* 删不掉→下方按实际存在性判定 */ }
|
|
101
|
+
dirRemoved = !dirExists(snapshotRoot)
|
|
102
|
+
}
|
|
103
|
+
if (!worktreeCleaned) {
|
|
104
|
+
// remove 失败:prune 清掉「目录已不在但注册悬空」的 prunable 条目
|
|
105
|
+
try { runGit(cwd, ['worktree', 'prune']) } catch { /* prune 失败→下方复核 */ }
|
|
106
|
+
}
|
|
107
|
+
// 双清契约(D-004@v2):worktreeCleaned 只认 git worktree list 的确认答复——remove 退出码
|
|
108
|
+
// 为零不等于注册已清(prune 未生效或 list 仍含该 root 时仍可能残留)。查询异常按未清。
|
|
109
|
+
try { worktreeCleaned = !worktreeRegistered(cwd, snapshotRoot) } catch { worktreeCleaned = false }
|
|
110
|
+
return { dirRemoved, worktreeCleaned }
|
|
24
111
|
}
|
|
25
112
|
|
|
26
113
|
/**
|
|
@@ -50,7 +137,7 @@ function runOverlayImportSmoke(pythonBin, snapshotRoot, overlaidFiles) {
|
|
|
50
137
|
let acted = false
|
|
51
138
|
for (const f of overlaidFiles) {
|
|
52
139
|
if (reverted.has(f)) continue
|
|
53
|
-
const bad = smokeImportPython(pythonBin, snapshotRoot, f)
|
|
140
|
+
const bad = smokeImportPython(pythonBin, snapshotRoot, f, byModule)
|
|
54
141
|
if (!bad) continue
|
|
55
142
|
acted = true
|
|
56
143
|
// 依赖归因:报错点名另一 overlay 文件 → 回退依赖( importer 自身往往无恙)
|
|
@@ -82,32 +169,66 @@ function findSnapshotPython(envRelDirs, snapshotRoot) {
|
|
|
82
169
|
}
|
|
83
170
|
|
|
84
171
|
/**
|
|
85
|
-
* 单文件 import 冒烟:`python -c "import <mod>"
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
172
|
+
* 单文件 import 冒烟:`python -c "import <mod>"`,包根从浅到深全尝试后才定性。
|
|
173
|
+
*
|
|
174
|
+
* 坑 verify-gate-worktree-crossrepo-three-defects 缺陷一(2026-09-21 实证):旧实现首个
|
|
175
|
+
* import-class 错误立即判坏——浅根(快照根)下 `No module named 'backend'`(backend 不是
|
|
176
|
+
* 快照根顶层包,包根本就该在 backend/)属 **cwd 假阳性**,却触发交付文件被回退 HEAD →
|
|
177
|
+
* 快照内丢 stats() 等新增 → 模块子集 AttributeError 连环假红。
|
|
178
|
+
*
|
|
179
|
+
* 定性规则(全根耗尽后):
|
|
180
|
+
* - 任一根 import 成功 → 文件健康(null);
|
|
181
|
+
* - SyntaxError → 立即判坏(语法坏与包根无关);
|
|
182
|
+
* - `cannot import name X`(模块已解析、缺名)→ 判坏(真部分态,根无关);
|
|
183
|
+
* - `ModuleNotFoundError: No module named 'X'` → 仅当 X 末段映射到**另一 overlay .py**
|
|
184
|
+
* (byModule)才判坏(overlay 依赖缺失);第三方依赖缺失/浅根前缀 → 环境问题不判坏
|
|
185
|
+
* (回退语义只针对「部分态坏文件」,坑原文口径)。
|
|
186
|
+
*
|
|
187
|
+
* @param {string} pythonBin 解释器
|
|
188
|
+
* @param {string} snapshotRoot 快照根
|
|
189
|
+
* @param {string} relFile overlay 文件(仓库根相对 POSIX)
|
|
190
|
+
* @param {Map<string,string>} byModule 末段模块名 → overlay 文件(缺失归因用)
|
|
89
191
|
* @returns {string|null} 失败原因(判坏时);通过/环境性失败返回 null
|
|
90
192
|
*/
|
|
91
|
-
function smokeImportPython(pythonBin, snapshotRoot, relFile) {
|
|
193
|
+
function smokeImportPython(pythonBin, snapshotRoot, relFile, byModule) {
|
|
92
194
|
const noExt = relFile.replace(/\.py$/, '')
|
|
93
195
|
const parts = noExt.split('/')
|
|
94
196
|
while (parts.length > 0 && parts[parts.length - 1] === '__init__') parts.pop()
|
|
95
197
|
if (parts.length === 0) return null
|
|
96
|
-
|
|
198
|
+
let nameErr = null
|
|
199
|
+
let missingOverlayErr = null
|
|
97
200
|
for (let cut = 0; cut < parts.length; cut++) {
|
|
98
201
|
const mod = parts.slice(cut).join('.')
|
|
99
202
|
if (!mod || !/^[A-Za-z_][\w.]*$/.test(mod)) continue
|
|
100
203
|
const pkgRoot = join(snapshotRoot, ...parts.slice(0, cut))
|
|
101
204
|
const r = spawnSync(pythonBin, ['-c', `import ${mod}`], {
|
|
102
|
-
cwd: pkgRoot, encoding: 'utf-8', timeout: 10_000,
|
|
205
|
+
cwd: pkgRoot, encoding: 'utf-8', timeout: 10_000, windowsHide: true,
|
|
103
206
|
})
|
|
104
207
|
if (r.status === 0) return null
|
|
105
208
|
const err = String((r.stderr || '') + (r.stdout || ''))
|
|
106
|
-
if (/
|
|
107
|
-
|
|
209
|
+
if (/SyntaxError/i.test(err)) {
|
|
210
|
+
// 语法坏与包根无关:该文件任何根下都不可导入,立即判坏
|
|
211
|
+
return (err.match(/SyntaxError[^\n]*/) || ['语法错误'])[0].slice(0, 120)
|
|
212
|
+
}
|
|
213
|
+
if (/cannot import name/i.test(err)) {
|
|
214
|
+
// 模块已解析、缺名 = 真部分态(依赖 overlay 兄弟缺符号);记录,全根耗尽后判坏
|
|
215
|
+
if (!nameErr) nameErr = (err.match(/cannot import name[^\n]*/) || ['cannot import name'])[0].slice(0, 120)
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
const mnf = err.match(/ModuleNotFoundError: No module named '([A-Za-z_][\w.]*)'/)
|
|
219
|
+
if (mnf) {
|
|
220
|
+
const missingLast = mnf[1].split('.').pop()
|
|
221
|
+
if (byModule.has(missingLast)) {
|
|
222
|
+
// 缺的模块是 overlay 兄弟(如 crud.py 未进快照)→ 部分态候选,记录
|
|
223
|
+
if (!missingOverlayErr) missingOverlayErr = (err.match(/ModuleNotFoundError[^\n]*/) || ['module not found'])[0].slice(0, 120)
|
|
224
|
+
}
|
|
225
|
+
// else:第三方依赖缺失 / 浅根前缀(cwd 假阳性)→ 环境问题,继续试更深包根
|
|
226
|
+
continue
|
|
108
227
|
}
|
|
109
|
-
//
|
|
228
|
+
// 其余失败(超时/崩溃等)→ 环境性,继续
|
|
110
229
|
}
|
|
230
|
+
if (nameErr) return nameErr
|
|
231
|
+
if (missingOverlayErr) return missingOverlayErr
|
|
111
232
|
return null
|
|
112
233
|
}
|
|
113
234
|
|
|
@@ -240,7 +361,7 @@ export function runGateSnapshotCommands(snapshotRoot, commands) {
|
|
|
240
361
|
ran++
|
|
241
362
|
let r = null
|
|
242
363
|
try {
|
|
243
|
-
r = spawnSync(cmd, { shell: true, cwd: snapshotRoot, timeout: 300_000, encoding: 'utf8' })
|
|
364
|
+
r = spawnSync(cmd, { shell: true, cwd: snapshotRoot, timeout: 300_000, encoding: 'utf8', windowsHide: true })
|
|
244
365
|
} catch (e) {
|
|
245
366
|
const reason = `spawn 异常:${e && e.message ? e.message : e}`
|
|
246
367
|
failed.push({ cmd, reason })
|
|
@@ -332,7 +453,9 @@ export function applyGateSnapshotCopy(cwd, snapshotRoot) {
|
|
|
332
453
|
viaLink = existsSync(dst)
|
|
333
454
|
} catch { viaLink = false }
|
|
334
455
|
if (!viaLink) {
|
|
335
|
-
|
|
456
|
+
// 假链接残留清理:dst 是 junction 本体——rmSync(dst) 在 Node v24 Windows 会穿透删
|
|
457
|
+
// 链接目标(主仓 copy 面真身,坑 junction-pierce-rm-sync),必须 rmdir 解链语义
|
|
458
|
+
try { unlinkLinkPath(dst) } catch { /* 假链接残留清不掉 → 下方复制报错走 warn */ }
|
|
336
459
|
if (srcStat.isDirectory()) copyDirRecursive(src, dst)
|
|
337
460
|
else copyFileSync(src, dst)
|
|
338
461
|
}
|
|
@@ -352,12 +475,25 @@ export function applyGateSnapshotCopy(cwd, snapshotRoot) {
|
|
|
352
475
|
* @param {{ cwd: string, files: string[] }} opts cwd=主仓根;files=本会话变更文件(仓库根相对 POSIX)
|
|
353
476
|
* @returns {{ snapshotRoot: string, cleanup: () => void, reason?: string }|null} 失败返回 null(调用方回退主仓)
|
|
354
477
|
*/
|
|
355
|
-
export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSmoke = false, mergeBase = null }) {
|
|
478
|
+
export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSmoke = false, mergeBase = null, runtimeRoot = null }) {
|
|
356
479
|
let snapshotRoot = null
|
|
357
480
|
try {
|
|
358
481
|
// 前置:主仓须是 git 仓且有 HEAD(无 git 环境回退主仓现行为)
|
|
359
482
|
git(cwd, ['rev-parse', 'HEAD'])
|
|
360
483
|
|
|
484
|
+
// 陈旧快照自愈(2026-09-24-gate-snapshot-lifecycle,D-002@v1/D-003@v2/D-004@v2):
|
|
485
|
+
// 建快照前同步回收账本内「守卫通过 ∧ 超 TTL ∧ pid 明确死」的条目(目录+worktree 注册
|
|
486
|
+
// 双清,删不掉的保留待下轮)。全 try/catch 恒吞——回收异常零阻断建快照;reclaimed 空
|
|
487
|
+
// 时零输出(正常路径逐字节不变)。runtimeRoot 缺失→账本链路退 no-op(禁 cwd 猜)。
|
|
488
|
+
try {
|
|
489
|
+
if (runtimeRoot) {
|
|
490
|
+
const { reclaimed, skipped } = reclaimStaleGateSnapshots({ runtimeRoot, cwd })
|
|
491
|
+
if (reclaimed.length > 0) {
|
|
492
|
+
console.log(`🧹 门禁快照自愈:回收 ${reclaimed.length} 个失活残留${skipped.length > 0 ? `(${skipped.length} 个删不掉,留账本待下轮)` : ''}`)
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
} catch { /* 回收异常 fail-open 退现状 */ }
|
|
496
|
+
|
|
361
497
|
// symlink-store 布局探测(坑 gate-snapshot-pnpm-store-break,2026-09-12 驾驭第十七批②,
|
|
362
498
|
// 用户第三次踩「lint 沙箱临时目录跑 pnpm 必假败」):pnpm/bun/lerna 的 node_modules 内部
|
|
363
499
|
// 是指向 store 的符号链接网——junction 进临时目录后跨根解析失效,实测必假败。
|
|
@@ -370,6 +506,8 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSm
|
|
|
370
506
|
|
|
371
507
|
snapshotRoot = mkdtempSync(join(tmpdir(), 'sillyspec-gate-'))
|
|
372
508
|
git(cwd, ['worktree', 'add', '--detach', '--quiet', snapshotRoot, 'HEAD'])
|
|
509
|
+
// 登记账本(D-004@v2):进程被杀时 cleanup 不跑,条目是残留唯一的可追踪凭据
|
|
510
|
+
registerGateSnapshot({ runtimeRoot, snapshotRoot })
|
|
373
511
|
|
|
374
512
|
// 会话文件 overlay:主仓工作区版本覆盖进快照(本会话的最新态;文件不存在=已删,跳过)。
|
|
375
513
|
// sourceRoot(verify 门定向跑用,2026-09-10 驾驭小结第八批):overlay 源切换为本变更
|
|
@@ -422,8 +560,12 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSm
|
|
|
422
560
|
if (cwdDiff && !wtDiff) {
|
|
423
561
|
src = cwdPath // 主仓直写新、worktree 停在基线 → 取主仓
|
|
424
562
|
} else if (!(wtDiff && !cwdDiff)) {
|
|
425
|
-
|
|
426
|
-
|
|
563
|
+
// M2(2026-09-21-r5-efficiency-batch2 D-002@v2):双写分叉改取 worktree 分支版——sourceRoot=worktree
|
|
564
|
+
// 定向跑的语义=验证本变更分支交付(batch1 实证:分叉取主仓拿走无本变更代码的版本,配上
|
|
565
|
+
// worktree 新增测试文件 → src/test 血统断裂 → module 实测假红一轮)。主仓侧同文件异动是
|
|
566
|
+
// 干扰源,其内容在 apply/对齐后由主仓单源复核兜底。
|
|
567
|
+
console.warn(`⚠️ 快照双写分叉:${f} 在主仓与 worktree 均有不同修改——取 worktree 分支版(本变更交付血统);主仓侧改动若需保留请 apply/对齐后复跑`)
|
|
568
|
+
src = wtPath
|
|
427
569
|
} // wtDiff && !cwdDiff → 保持 worktree(正常流零改变)
|
|
428
570
|
}
|
|
429
571
|
}
|
|
@@ -465,11 +607,36 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSm
|
|
|
465
607
|
const envMissing = envDirsLinked(cwd, snapshotRoot)
|
|
466
608
|
if (envMissing.length > 0) {
|
|
467
609
|
console.warn(`⚠️ 门禁快照环境不完整(${envMissing.join('、')} 在主仓存在、快照内缺失)——快照作废回退主仓实测(宁可主仓口径不可假沙箱硬挂;回退后失败先做污染归属鉴定)`)
|
|
468
|
-
|
|
469
|
-
|
|
610
|
+
// 与正常 cleanup/失败 catch 同一清理体:双清确认才销账(该早退曾残留旧式 --quiet
|
|
611
|
+
// 清理与无重试 rmSync——独立审查 P1-1 根因未除尽的漏网面)
|
|
612
|
+
try {
|
|
613
|
+
const { dirRemoved: dGone, worktreeCleaned: wClean } = cleanupSnapshot({ snapshotRoot, cwd })
|
|
614
|
+
if (dGone && wClean) unregisterGateSnapshot({ runtimeRoot, snapshotRoot })
|
|
615
|
+
} catch {}
|
|
470
616
|
return null
|
|
471
617
|
}
|
|
472
618
|
|
|
619
|
+
// env 钉定缓存链接面(r5l 方案 5 / P17,通用机制见 resolvePinnedCacheLinks jsdoc):
|
|
620
|
+
// 工具把缓存钉在仓内(UV_CACHE_DIR=.uv-cache 类)时 gitignored 不进 HEAD,快照缺它 →
|
|
621
|
+
// 构建隔离环境(含已装构建后端)缺失 → 快照内构建假红。junction 同相对路径进快照,
|
|
622
|
+
// 与环境目录族同款链接原语。整段 fail-soft:链接失败只 warn 不作废快照(缓存面缺失的
|
|
623
|
+
// 影响是工具自身的构建失败,失败输出自带归因提示);未钉定/钉在仓外 → 全段零行为零输出。
|
|
624
|
+
const pinnedCacheLinks = []
|
|
625
|
+
for (const link of resolvePinnedCacheLinks({ cwd })) {
|
|
626
|
+
if (!existsSync(link.srcAbs)) continue
|
|
627
|
+
const dst = join(snapshotRoot, link.rel)
|
|
628
|
+
try {
|
|
629
|
+
mkdirSync(dirname(dst), { recursive: true })
|
|
630
|
+
symlinkSync(link.srcAbs, dst, 'junction')
|
|
631
|
+
pinnedCacheLinks.push({ envName: link.envName, tool: link.tool, rel: link.rel })
|
|
632
|
+
} catch (e) {
|
|
633
|
+
console.warn(`⚠️ 门禁快照构建缓存链接失败(${link.envName}=${link.rel}):${e && e.message ? e.message : e}——快照内该工具构建可能缺缓存假红,失败先对照主仓口径`)
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (pinnedCacheLinks.length > 0) {
|
|
637
|
+
console.log(`🔬 门禁快照构建缓存面:${pinnedCacheLinks.map((x) => `${x.envName}=${x.rel}`).join('、')} 已 junction 进快照(P17:gitignored 仓内缓存携带构建隔离环境——缺它快照内构建假红;⚠️ 活链接,快照内构建写穿回主仓缓存)`)
|
|
638
|
+
}
|
|
639
|
+
|
|
473
640
|
// gate_snapshot.commands 命令面(D-002@v2,2026-09-17-feedback-hardening R1,用户
|
|
474
641
|
// 2026-09-17 负面①实证「全量快照必挂」):主仓侧生成物缺失(fresh clone 未跑
|
|
475
642
|
// postinstall)或过期时,copy 面只搬「主仓现有态」供给缺失/陈旧态——本段在环境链接
|
|
@@ -526,16 +693,23 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSm
|
|
|
526
693
|
}
|
|
527
694
|
|
|
528
695
|
const cleanup = () => {
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
696
|
+
// 残留不再「交 OS tmp 清理」(%TEMP% 不会被 OS 清)——删不掉的快照由账本回收路径
|
|
697
|
+
// (下个 create 前按 TTL×pid 双闸自愈,2026-09-24-gate-snapshot-lifecycle)兜底;
|
|
698
|
+
// 销账只在「目录+worktree 注册双清确认」后执行(D-004@v2),删不掉保留条目待下轮
|
|
699
|
+
try {
|
|
700
|
+
const { dirRemoved, worktreeCleaned } = cleanupSnapshot({ snapshotRoot, cwd })
|
|
701
|
+
if (dirRemoved && worktreeCleaned) unregisterGateSnapshot({ runtimeRoot, snapshotRoot })
|
|
702
|
+
} catch { /* 双失败已由 cleanupSnapshot 吞掉;条目保留 */ }
|
|
532
703
|
}
|
|
533
|
-
return { snapshotRoot, cleanup, overlaid }
|
|
704
|
+
return { snapshotRoot, cleanup, overlaid, pinnedCacheLinks }
|
|
534
705
|
} catch (e) {
|
|
535
|
-
// 基建失败(worktree add
|
|
706
|
+
// 基建失败(worktree add 拒绝/磁盘满/…):尽力清理后回退主仓;销账同走双清确认
|
|
707
|
+
// (D-004@v2:清不掉的条目保留待回收——它不是悬空条目,是待回收记录)
|
|
536
708
|
if (snapshotRoot) {
|
|
537
|
-
try {
|
|
538
|
-
|
|
709
|
+
try {
|
|
710
|
+
const { dirRemoved, worktreeCleaned } = cleanupSnapshot({ snapshotRoot, cwd })
|
|
711
|
+
if (dirRemoved && worktreeCleaned) unregisterGateSnapshot({ runtimeRoot, snapshotRoot })
|
|
712
|
+
} catch {}
|
|
539
713
|
}
|
|
540
714
|
return null
|
|
541
715
|
}
|
|
@@ -562,9 +736,48 @@ export function detectSymlinkStoreLayout(cwd) {
|
|
|
562
736
|
/** 环境目录名集(根与子包通用) */
|
|
563
737
|
const ENV_DIR_NAMES = new Set(['node_modules', '.venv', 'venv', 'env'])
|
|
564
738
|
|
|
739
|
+
/**
|
|
740
|
+
* 「env 钉定缓存目录」注册表(r5l-forensic-verdict 方案 5 / P17,用户裁定走通用机制不做
|
|
741
|
+
* 单一工具硬编码):构建/包管理工具用 env var 把缓存钉在**仓内**(相对 cwd 或仓内绝对路径)
|
|
742
|
+
* 时,该目录 gitignored 不进 HEAD——隔离快照(HEAD worktree)天然缺它,快照内构建隔离
|
|
743
|
+
* (uv builds-v0 / pip build 隔离环境)拿不到已装好的构建后端(P17:hatchling EPERM 假红
|
|
744
|
+
* 两轮卡 37min)。机制对任意工具通用:解析「注册的 env var → 仓内路径 → junction 进快照
|
|
745
|
+
* 同相对路径」,工具只是注册表一行数据——扩新工具(cargo/poetry/自定义)加一行即可,
|
|
746
|
+
* 不新增代码分支。家目录缺省形态(~/.cache/uv、%LOCALAPPDATA%/uv 等)**刻意不链接**:
|
|
747
|
+
* 快照内命令继承完整进程 env,家目录绝对路径直达真实缓存,junction 同一批文件零机制增益。
|
|
748
|
+
* pnpm-store 族不入表:符号链接 store 经 junction 跨根失效(detectSymlinkStoreLayout 先例)。
|
|
749
|
+
*/
|
|
750
|
+
const PINNED_CACHE_ENV_VARS = [
|
|
751
|
+
{ env: 'UV_CACHE_DIR', tool: 'uv' },
|
|
752
|
+
{ env: 'PIP_CACHE_DIR', tool: 'pip' },
|
|
753
|
+
{ env: 'POETRY_CACHE_DIR', tool: 'poetry' },
|
|
754
|
+
]
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* 解析 env 钉定的仓内缓存目录(纯解析,不做链接/IO 判存在——可测性)。
|
|
758
|
+
* 规则:注册表逐项取 env 值;相对路径按 cwd 解析(与 uv/pip 相对 cwd 语义一致);解析后
|
|
759
|
+
* 落在仓内(relative 不出仓)才算钉定缓存;仓外路径(家目录/自定义绝对位置)经继承 env
|
|
760
|
+
* 直达,不属于本面。同 var 多次出现取首个命中。
|
|
761
|
+
* @returns {{ envName: string, tool: string, srcAbs: string, rel: string }[]} 仓内钉定缓存清单
|
|
762
|
+
*/
|
|
763
|
+
export function resolvePinnedCacheLinks({ cwd, env = process.env } = {}) {
|
|
764
|
+
const out = []
|
|
765
|
+
for (const entry of PINNED_CACHE_ENV_VARS) {
|
|
766
|
+
const v = env[entry.env]
|
|
767
|
+
if (!v || !String(v).trim()) continue
|
|
768
|
+
const abs = isAbsolute(String(v).trim()) ? String(v).trim() : resolve(cwd, String(v).trim())
|
|
769
|
+
let rel = null
|
|
770
|
+
try { rel = relative(cwd, abs) } catch { continue }
|
|
771
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel)) continue // 仓外直达,无需链接
|
|
772
|
+
if (out.some((x) => x.rel === rel.split('\\').join('/'))) continue
|
|
773
|
+
out.push({ envName: entry.env, tool: entry.tool, srcAbs: abs, rel: rel.split('\\').join('/') })
|
|
774
|
+
}
|
|
775
|
+
return out
|
|
776
|
+
}
|
|
777
|
+
|
|
565
778
|
/**
|
|
566
779
|
* 递归依赖发现(坑 gate-snapshot-monorepo-layout):深度 ≤3 扫描(跳过环境目录自身内部/
|
|
567
|
-
* .git/dist/build/.sillyspec),返回所有环境目录的仓库根相对 POSIX
|
|
780
|
+
* .git/dist/build/.sillyspec),返回所有环境目录的仓库根相对 POSIX 路径(含根四目录)。
|
|
568
781
|
* pnpm/nx/lerna workspace 的 packages/<pkg>/node_modules 覆盖。
|
|
569
782
|
*/
|
|
570
783
|
export function discoverEnvDirs(cwd, { maxDepth = 3 } = {}) {
|
|
@@ -627,6 +840,10 @@ export async function createVerifyGateSnapshot({ cwd, changeName, specBase, plat
|
|
|
627
840
|
try {
|
|
628
841
|
const { resolveVerifyChangedFiles } = await import('../verify-postcheck.js')
|
|
629
842
|
const { resolveRuntimeRoot } = await import('./shared.js')
|
|
843
|
+
// 账本 runtimeRoot(2026-09-24-gate-snapshot-lifecycle task-03):函数级解析一次,
|
|
844
|
+
// 平台/漂移模式口径与本函数内他处一致;解析失败退 null(账本链路 no-op,禁 cwd 猜)
|
|
845
|
+
let verifyRuntimeRoot = null
|
|
846
|
+
try { verifyRuntimeRoot = resolveRuntimeRoot(platformOpts, specBase) } catch { verifyRuntimeRoot = null }
|
|
630
847
|
const { splitOwnVsForeignDiffFiles } = await import('../foreign-declared.js')
|
|
631
848
|
let changeFiles = resolveVerifyChangedFiles(cwd, changeName, null, {
|
|
632
849
|
specBase, includeWorkingTree: true,
|
|
@@ -636,8 +853,7 @@ export async function createVerifyGateSnapshot({ cwd, changeName, specBase, plat
|
|
|
636
853
|
// 并入后主仓全部在途文件进场——他者活跃变更显式声明的文件(quick --files / 他者 design
|
|
637
854
|
// 清单)剔除,只留本变更相关面(无主文件保留,fail-closed 口径与 probe6 一致)。
|
|
638
855
|
try {
|
|
639
|
-
const
|
|
640
|
-
const { foreign } = splitOwnVsForeignDiffFiles(cwd, changeName, changeFiles, { specBase, runtimeRoot })
|
|
856
|
+
const { foreign } = splitOwnVsForeignDiffFiles(cwd, changeName, changeFiles, { specBase, runtimeRoot: verifyRuntimeRoot })
|
|
641
857
|
if (foreign.length > 0) {
|
|
642
858
|
const foreignSet = new Set(foreign.map(x => x.file))
|
|
643
859
|
changeFiles = changeFiles.filter(f => !foreignSet.has(f))
|
|
@@ -670,7 +886,7 @@ export async function createVerifyGateSnapshot({ cwd, changeName, specBase, plat
|
|
|
670
886
|
}
|
|
671
887
|
} catch { /* meta 读取失败 → 主仓 cwd 源(files 已含本变更 working-tree 改动) */ }
|
|
672
888
|
|
|
673
|
-
const snap = createGateSnapshot({ cwd, files: changeFiles, sourceRoot, mergeBase: snapMergeBase })
|
|
889
|
+
const snap = createGateSnapshot({ cwd, files: changeFiles, sourceRoot, mergeBase: snapMergeBase, runtimeRoot: verifyRuntimeRoot })
|
|
674
890
|
if (!snap) return null
|
|
675
891
|
|
|
676
892
|
// 变更文档随快照:module-impact.md(test_strategy=evidence-auto 消费)/tasks.md 等
|