sillyspec 3.24.7 → 3.25.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/package.json +1 -1
- package/src/constants.js +25 -0
- package/src/contract-matrix.js +48 -20
- package/src/endpoint-extractor.js +92 -6
- package/src/fs-atomic.js +69 -0
- package/src/hooks/worktree-guard.js +16 -4
- package/src/index.js +6 -7
- package/src/modules.js +2 -2
- package/src/progress.js +38 -53
- package/src/run.js +578 -459
- package/src/scan-postcheck.js +2 -10
- package/src/stage-contract.js +8 -17
- package/src/stages/brainstorm-auto.js +1 -1
- package/src/stages/brainstorm.js +571 -571
- package/src/stages/doctor.js +5 -5
- package/src/stages/execute.js +1 -1
- package/src/stages/index.js +1 -1
- package/src/stages/plan.js +2 -2
- package/src/stages/propose.js +1 -1
- package/src/stages/status.js +1 -1
- package/src/stages/verify.js +2 -0
- package/src/verify-postcheck.js +41 -0
- package/src/worktree-apply.js +45 -18
- package/src/worktree.js +13 -5
package/package.json
CHANGED
package/src/constants.js
CHANGED
|
@@ -68,3 +68,28 @@ export function isPointerStale(pointer) {
|
|
|
68
68
|
export function isPointerCorrupted(pointer) {
|
|
69
69
|
return !pointer || !pointer.specRoot || !pointer.savedAt
|
|
70
70
|
}
|
|
71
|
+
|
|
72
|
+
// ── scan 必需文档清单(stage-contract validator 与 scan-postcheck 共用单一真相源)──
|
|
73
|
+
// 曾分两处硬编码同一份清单,加新文档需同步改两处,易分叉。现收敛到此处一处。
|
|
74
|
+
export const SCAN_REQUIRED_DOCS = Object.freeze([
|
|
75
|
+
'ARCHITECTURE.md',
|
|
76
|
+
'CONVENTIONS.md',
|
|
77
|
+
'STRUCTURE.md',
|
|
78
|
+
'INTEGRATIONS.md',
|
|
79
|
+
'TESTING.md',
|
|
80
|
+
'CONCERNS.md',
|
|
81
|
+
'PROJECT.md',
|
|
82
|
+
])
|
|
83
|
+
|
|
84
|
+
// ── 辅助 stage 清单(stage-contract 转移规则 与 stages/index 注册表 共用单一真相源)──
|
|
85
|
+
// 曾在 stage-contract.js 与 stages/index.js 逐字重复定义,新增/调整辅助 stage 需同步改两处
|
|
86
|
+
// 易分叉。现收敛一处。(注意:主流程序 STAGE_ORDER/mainFlowStages/flowStages 语义不同、
|
|
87
|
+
// 各自用途正确,不在本清单范围。)
|
|
88
|
+
export const AUXILIARY_STAGES = Object.freeze([
|
|
89
|
+
'scan',
|
|
90
|
+
'quick',
|
|
91
|
+
'explore',
|
|
92
|
+
'archive',
|
|
93
|
+
'status',
|
|
94
|
+
'doctor',
|
|
95
|
+
])
|
package/src/contract-matrix.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
scanBackendEndpoints,
|
|
15
15
|
scanFrontendApiCalls,
|
|
16
16
|
normalizePath,
|
|
17
|
+
diffApiParity,
|
|
17
18
|
} from './endpoint-extractor.js'
|
|
18
19
|
import { parseTaskContracts } from './stages/plan-postcheck.js'
|
|
19
20
|
|
|
@@ -59,8 +60,12 @@ export function buildContractMatrix(planContent, changeDir) {
|
|
|
59
60
|
const taskDeps = parseTaskDependencies(planContent)
|
|
60
61
|
|
|
61
62
|
// 读取各 task 文档,分类 provider/consumer
|
|
63
|
+
// classify consumers(taskDeps keys)+ providers(deps 值)。
|
|
64
|
+
// 旧实现只 classify consumers → provider task 不在 keys 里 → providerClass 恒 undefined
|
|
65
|
+
// → contracts 恒空 → 端点契约管线(buildConsumerInjection / extractArtifactsForChange)全失效。
|
|
62
66
|
const taskClasses = {}
|
|
63
|
-
|
|
67
|
+
const allTaskNames = new Set([...Object.keys(taskDeps), ...Object.values(taskDeps).flat()])
|
|
68
|
+
for (const taskName of allTaskNames) {
|
|
64
69
|
const taskFile = join(changeDir, 'tasks', `${taskName}.md`)
|
|
65
70
|
if (existsSync(taskFile)) {
|
|
66
71
|
taskClasses[taskName] = classifyTask(readFileSync(taskFile, 'utf8'))
|
|
@@ -103,7 +108,9 @@ function parseTaskDependencies(planContent) {
|
|
|
103
108
|
const deps = {}
|
|
104
109
|
|
|
105
110
|
// 方式 1: 表格形式 | task-04 | ... | 01,02 |
|
|
106
|
-
|
|
111
|
+
// 列前缀用 [^|\d]*(不吃数字):旧 [^|]* 贪婪会吃掉 deps 列前导 0(" 01" → g2=1 → task-1),
|
|
112
|
+
// 导致 provider task-1.md 不存在 → contracts 恒空,端点契约管线失效。
|
|
113
|
+
const tableRows = planContent.matchAll(/\|[^|]*task-(\d+)[^|]*\|[^|]*\|[^|\d]*(?:task-)?(\d+(?:\s*[,,]\s*\d+)*)[^|]*\|/gi)
|
|
107
114
|
for (const match of tableRows) {
|
|
108
115
|
const task = `task-${match[1]}`
|
|
109
116
|
const depList = match[2].split(/[,,\s]+/).map(d => `task-${d.trim()}`).filter(d => d.startsWith('task-'))
|
|
@@ -137,7 +144,9 @@ function parseTaskDependencies(planContent) {
|
|
|
137
144
|
export function extractProviderArtifact(changeDir, worktreePath, specBase, taskName, runtimeRoot) {
|
|
138
145
|
// 平台模式 contract-artifacts 落 runtimeRoot;否则落 specBase/.runtime
|
|
139
146
|
const artifactRoot = runtimeRoot || join(specBase, '.runtime')
|
|
140
|
-
|
|
147
|
+
// 跨变更隔离:contract-artifacts/<changeName>/<taskName>/,避免不同变更同名 task 互相覆盖污染对账
|
|
148
|
+
const changeName = changeDir ? basename(changeDir) : '_unknown'
|
|
149
|
+
const artifactDir = join(artifactRoot, 'contract-artifacts', changeName, taskName)
|
|
141
150
|
const artifactPath = join(artifactDir, 'endpoints.json')
|
|
142
151
|
|
|
143
152
|
if (!worktreePath || !existsSync(worktreePath)) {
|
|
@@ -171,6 +180,28 @@ export function extractProviderArtifact(changeDir, worktreePath, specBase, taskN
|
|
|
171
180
|
}
|
|
172
181
|
}
|
|
173
182
|
|
|
183
|
+
/**
|
|
184
|
+
* 汇总提取一个变更内所有 provider task 的 endpoint artifact。
|
|
185
|
+
* run.js completeStep 在 execute Wave 完成时调用:扫 worktree → 按 buildContractMatrix 识别的
|
|
186
|
+
* provider task,各自落 contract-artifacts/<changeName>/<taskName>/endpoints.json。
|
|
187
|
+
* @param {{ changeDir: string, specBase: string, changeName: string, worktreePath: string|null }} args
|
|
188
|
+
* @returns {string|null} 日志摘要;无 provider/无 plan/worktree 缺失时返回 null(不打扰)
|
|
189
|
+
*/
|
|
190
|
+
export function extractArtifactsForChange({ changeDir, specBase, changeName, worktreePath }) {
|
|
191
|
+
if (!worktreePath || !changeDir || !existsSync(changeDir)) return null
|
|
192
|
+
const planFile = join(changeDir, 'plan.md')
|
|
193
|
+
if (!existsSync(planFile)) return null
|
|
194
|
+
const contracts = buildContractMatrix(readFileSync(planFile, 'utf8'), changeDir)
|
|
195
|
+
const providers = [...new Set(contracts.map(c => c.provider))]
|
|
196
|
+
if (providers.length === 0) return null
|
|
197
|
+
let withEndpoints = 0
|
|
198
|
+
for (const taskName of providers) {
|
|
199
|
+
const r = extractProviderArtifact(changeDir, worktreePath, specBase, taskName)
|
|
200
|
+
if (r.ok && r.endpoints.length > 0) withEndpoints++
|
|
201
|
+
}
|
|
202
|
+
return `📦 契约 artifact 提取: providers=${providers.join(',')}(${withEndpoints}/${providers.length} 含端点)`
|
|
203
|
+
}
|
|
204
|
+
|
|
174
205
|
// ─── Execute 阶段:前端 task 开始时注入契约 ─────────────────────────────
|
|
175
206
|
|
|
176
207
|
/**
|
|
@@ -184,12 +215,13 @@ export function extractProviderArtifact(changeDir, worktreePath, specBase, taskN
|
|
|
184
215
|
export function buildConsumerInjection(changeDir, specBase, taskName, contracts, runtimeRoot) {
|
|
185
216
|
// 平台模式 contract-artifacts 落 runtimeRoot;否则落 specBase/.runtime
|
|
186
217
|
const artifactRoot = runtimeRoot || join(specBase, '.runtime')
|
|
218
|
+
const changeName = changeDir ? basename(changeDir) : '_unknown'
|
|
187
219
|
const myContracts = contracts.filter(c => c.consumer === taskName)
|
|
188
220
|
if (myContracts.length === 0) return null
|
|
189
221
|
|
|
190
222
|
const parts = []
|
|
191
223
|
for (const contract of myContracts) {
|
|
192
|
-
const artifactDir = join(artifactRoot, 'contract-artifacts', contract.provider)
|
|
224
|
+
const artifactDir = join(artifactRoot, 'contract-artifacts', changeName, contract.provider)
|
|
193
225
|
const artifactFile = join(artifactDir, 'endpoints.json')
|
|
194
226
|
|
|
195
227
|
let endpoints = []
|
|
@@ -295,15 +327,16 @@ export function buildContractFieldInjection(changeDir, taskName) {
|
|
|
295
327
|
* verify 阶段执行 API parity check
|
|
296
328
|
* @param {string} specBase - .sillyspec 目录
|
|
297
329
|
* @param {string} worktreePath - worktree 路径
|
|
298
|
-
* @returns {{ ok: boolean, missingBackend: Array, unusedBackend: Array, summary: string }}
|
|
330
|
+
* @returns {{ ok: boolean, missingBackend: Array, unusedBackend: Array, summary: string, backendCount: number, frontendCount: number }}
|
|
299
331
|
*/
|
|
300
|
-
export function verifyApiParity(specBase,
|
|
301
|
-
const { diffApiParity } = require('./endpoint-extractor.js')
|
|
302
|
-
|
|
332
|
+
export function verifyApiParity(specBase, scanRoot, runtimeRoot, changeName = null) {
|
|
303
333
|
// 平台模式 contract-artifacts 落 runtimeRoot;否则落 specBase/.runtime
|
|
304
334
|
const artifactRoot = runtimeRoot || join(specBase, '.runtime')
|
|
305
|
-
//
|
|
306
|
-
|
|
335
|
+
// 读取该变更的所有 provider artifacts(contract-artifacts/<changeName>/*/endpoints.json)。
|
|
336
|
+
// changeName 缺失时回退扫顶层 contract-artifacts(CLI contractScan 跨变更场景兼容)。
|
|
337
|
+
const artifactBase = changeName
|
|
338
|
+
? join(artifactRoot, 'contract-artifacts', changeName)
|
|
339
|
+
: join(artifactRoot, 'contract-artifacts')
|
|
307
340
|
const allProviderEndpoints = []
|
|
308
341
|
|
|
309
342
|
if (existsSync(artifactBase)) {
|
|
@@ -325,17 +358,12 @@ export function verifyApiParity(specBase, worktreePath, runtimeRoot) {
|
|
|
325
358
|
}
|
|
326
359
|
}
|
|
327
360
|
|
|
328
|
-
//
|
|
329
|
-
if (!
|
|
330
|
-
return {
|
|
331
|
-
ok: true,
|
|
332
|
-
missingBackend: [],
|
|
333
|
-
unusedBackend: [],
|
|
334
|
-
summary: 'No worktree to scan for parity check',
|
|
335
|
-
}
|
|
361
|
+
// 扫描前端调用(scanRoot 通常 = 主工作区 cwd:verify 时代码已 apply 到主工作区)
|
|
362
|
+
if (!scanRoot || !existsSync(scanRoot)) {
|
|
363
|
+
return { ok: true, missingBackend: [], unusedBackend: [], summary: 'No scan root for parity check', backendCount: allProviderEndpoints.length, frontendCount: 0 }
|
|
336
364
|
}
|
|
337
365
|
|
|
338
|
-
const frontendCalls = scanFrontendApiCalls(
|
|
366
|
+
const frontendCalls = scanFrontendApiCalls(scanRoot)
|
|
339
367
|
const { missingBackend, unusedBackend } = diffApiParity(frontendCalls, allProviderEndpoints)
|
|
340
368
|
|
|
341
369
|
const ok = missingBackend.length === 0
|
|
@@ -347,5 +375,5 @@ export function verifyApiParity(specBase, worktreePath, runtimeRoot) {
|
|
|
347
375
|
summary += ` | ${unusedBackend.length} backend endpoints unused by frontend`
|
|
348
376
|
}
|
|
349
377
|
|
|
350
|
-
return { ok, missingBackend, unusedBackend, summary }
|
|
378
|
+
return { ok, missingBackend, unusedBackend, summary, backendCount: allProviderEndpoints.length, frontendCount: frontendCalls.length }
|
|
351
379
|
}
|
|
@@ -75,14 +75,96 @@ export function extractFastApiEndpoints(filePath) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
|
-
*
|
|
78
|
+
* 从单个文件提取 Express(Node)路由端点。
|
|
79
|
+
* 支持 router/app.get|post|put|delete|patch("path") 与链式 router.route("path").<method>()。
|
|
80
|
+
* app.use("prefix", router) 的前缀只做同文件近似(跨文件追踪不在首版范围)。
|
|
81
|
+
*
|
|
82
|
+
* @param {string} filePath - 文件绝对路径
|
|
83
|
+
* @returns {Array<{ method: string, path: string, source: string, line: number }>}
|
|
84
|
+
*/
|
|
85
|
+
export function extractExpressEndpoints(filePath) {
|
|
86
|
+
const content = readFileSync(filePath, 'utf8')
|
|
87
|
+
const lines = content.split('\n')
|
|
88
|
+
const endpoints = []
|
|
89
|
+
|
|
90
|
+
// 同文件内最后一个 app.use("prefix", ...) 作为前缀近似
|
|
91
|
+
let routerPrefix = ''
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
const useMatch = line.match(/\bapp\.use\s*\(\s*["'`]([^"'`]+)["'`]/)
|
|
94
|
+
if (useMatch) routerPrefix = useMatch[1]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (let i = 0; i < lines.length; i++) {
|
|
98
|
+
const line = lines[i]
|
|
99
|
+
const m = line.match(/\b(?:app|router)\.(get|post|put|delete|patch)\s*\(\s*["'`]([^"'`]+)["'`]/i)
|
|
100
|
+
if (m) {
|
|
101
|
+
endpoints.push({ method: m[1].toUpperCase(), path: routerPrefix + m[2], source: filePath, line: i + 1 })
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
const routeChain = line.match(/\.route\s*\(\s*["'`]([^"'`]+)["'`]/)
|
|
105
|
+
if (routeChain) {
|
|
106
|
+
// 链式 .route(path).get(h).put(h) —— 同行所有 method 配上 path
|
|
107
|
+
const chainPath = routerPrefix + routeChain[1]
|
|
108
|
+
for (const mm of line.matchAll(/\.(get|post|put|delete|patch)\s*\(/gi)) {
|
|
109
|
+
endpoints.push({ method: mm[1].toUpperCase(), path: chainPath, source: filePath, line: i + 1 })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return endpoints
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 从单个文件提取 Spring(Java)端点。
|
|
119
|
+
* 支持 @GetMapping/@PostMapping/.../@PatchMapping 与旧式 @RequestMapping(value="path", method=RequestMethod.X);
|
|
120
|
+
* 类级 @RequestMapping("api") 前缀合并(取文件内第一个作类前缀,近似)。
|
|
121
|
+
*
|
|
122
|
+
* @param {string} filePath - 文件绝对路径
|
|
123
|
+
* @returns {Array<{ method: string, path: string, source: string, line: number }>}
|
|
124
|
+
*/
|
|
125
|
+
export function extractSpringEndpoints(filePath) {
|
|
126
|
+
const content = readFileSync(filePath, 'utf8')
|
|
127
|
+
const lines = content.split('\n')
|
|
128
|
+
const endpoints = []
|
|
129
|
+
|
|
130
|
+
let classPrefix = ''
|
|
131
|
+
for (const line of lines) {
|
|
132
|
+
const cm = line.match(/@RequestMapping\s*\(\s*(?:value\s*=\s*)?["'`]([^"'`]+)["'`]/)
|
|
133
|
+
if (cm) { classPrefix = cm[1]; break }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const methodMap = { get: 'GET', post: 'POST', put: 'PUT', delete: 'DELETE', patch: 'PATCH' }
|
|
137
|
+
|
|
138
|
+
for (let i = 0; i < lines.length; i++) {
|
|
139
|
+
const line = lines[i]
|
|
140
|
+
const shortMatch = line.match(/@(Get|Post|Put|Delete|Patch)Mapping\s*\(\s*(?:value\s*=\s*)?["'`]([^"'`]+)["'`]/i)
|
|
141
|
+
if (shortMatch) {
|
|
142
|
+
endpoints.push({ method: methodMap[shortMatch[1].toLowerCase()], path: classPrefix + shortMatch[2], source: filePath, line: i + 1 })
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
const oldMatch = line.match(/@RequestMapping\s*\(\s*(?:value\s*=\s*)?["'`]([^"'`]+)["'`][^)]*?method\s*=\s*RequestMethod\.(\w+)/i)
|
|
146
|
+
if (oldMatch) {
|
|
147
|
+
endpoints.push({ method: oldMatch[2].toUpperCase(), path: classPrefix + oldMatch[1], source: filePath, line: i + 1 })
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return endpoints
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 从目录递归扫描后端端点(多框架)。
|
|
156
|
+
* 按扩展名分派:.py→FastAPI / .js,.ts→Express / .java→Spring。
|
|
157
|
+
* 保留 excludePatterns(向后兼容 opts);filePattern 在多框架分派下不再适用。
|
|
79
158
|
* @param {string} dir
|
|
80
|
-
* @param {{
|
|
159
|
+
* @param {{ excludePatterns?: RegExp[] }} opts
|
|
81
160
|
* @returns {Array<{ method: string, path: string, source: string, line: number }>}
|
|
82
161
|
*/
|
|
83
162
|
export function scanBackendEndpoints(dir, opts = {}) {
|
|
84
|
-
const
|
|
85
|
-
|
|
163
|
+
const excludePatterns = opts.excludePatterns || [
|
|
164
|
+
/^__pycache__$/, /^node_modules$/, /^\.venv$/, /^\.git$/, /^\.gradle$/,
|
|
165
|
+
/^(dist|build|target|out)$/i,
|
|
166
|
+
/test/i,
|
|
167
|
+
]
|
|
86
168
|
|
|
87
169
|
const results = []
|
|
88
170
|
if (!existsSync(dir)) return results
|
|
@@ -93,9 +175,12 @@ export function scanBackendEndpoints(dir, opts = {}) {
|
|
|
93
175
|
if (entry.isDirectory()) {
|
|
94
176
|
if (excludePatterns.some(p => p.test(entry.name))) continue
|
|
95
177
|
walk(full)
|
|
96
|
-
} else if (entry.isFile()
|
|
178
|
+
} else if (entry.isFile()) {
|
|
179
|
+
const ext = extname(entry.name).toLowerCase()
|
|
97
180
|
try {
|
|
98
|
-
results.push(...extractFastApiEndpoints(full))
|
|
181
|
+
if (ext === '.py') results.push(...extractFastApiEndpoints(full))
|
|
182
|
+
else if (ext === '.js' || ext === '.ts') results.push(...extractExpressEndpoints(full))
|
|
183
|
+
else if (ext === '.java') results.push(...extractSpringEndpoints(full))
|
|
99
184
|
} catch {}
|
|
100
185
|
}
|
|
101
186
|
}
|
|
@@ -224,6 +309,7 @@ export function normalizePath(rawPath) {
|
|
|
224
309
|
.replace(/\$\{[^}]+\}/g, '{param}')
|
|
225
310
|
.replace(/:\w+/g, '{param}')
|
|
226
311
|
.replace(/\$\w+/g, '{param}')
|
|
312
|
+
.replace(/\{[^}]+\}/g, '{param}') // Spring {planId} / FastAPI {plan_id} → {param}
|
|
227
313
|
}
|
|
228
314
|
|
|
229
315
|
// ─── 对账 ───────────────────────────────────────────────────────────────
|
package/src/fs-atomic.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fs-atomic.js — 原子文件写 + Windows 友好的 rename 重试
|
|
3
|
+
*
|
|
4
|
+
* 用于 .runtime/*.json、pointer、guard.json 等「会被其他进程 / hook 读取」的配置文件。
|
|
5
|
+
* 保证读者要么看到旧完整内容、要么看到新完整内容,不会读到半截 JSON。
|
|
6
|
+
*
|
|
7
|
+
* 注意:db.js 有自己的 _atomicWriteSync(含 .bak 备份),那是 DB 专属持久化逻辑,
|
|
8
|
+
* 已经过稳定性验证,此处不复用、不替换——保持 db.js 零改动。
|
|
9
|
+
*/
|
|
10
|
+
import { writeFileSync, renameSync, unlinkSync } from 'fs';
|
|
11
|
+
import { dirname, basename, join } from 'path';
|
|
12
|
+
|
|
13
|
+
// Windows 上杀毒 / 索引 / IDE 占用文件时,rename 偶发这些错误,短退避重试即可
|
|
14
|
+
const RENAME_RETRY_CODES = ['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY'];
|
|
15
|
+
|
|
16
|
+
/** 同步退避:优先 Atomics.wait,不可用时退化忙等(仅在 rename 冲突的极端情况触发)。 */
|
|
17
|
+
function sleepSync(ms) {
|
|
18
|
+
try {
|
|
19
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
20
|
+
} catch {
|
|
21
|
+
const end = Date.now() + ms;
|
|
22
|
+
while (Date.now() < end) { /* busy wait */ }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* rename 带退避重试,覆盖 Windows 偶发的 EPERM/EBUSY/EACCES/ENOTEMPTY。
|
|
28
|
+
* 目录 rename 同样适用(归档移动目录时可复用)。
|
|
29
|
+
* @param {string} from
|
|
30
|
+
* @param {string} to
|
|
31
|
+
* @param {number} [retries=5]
|
|
32
|
+
*/
|
|
33
|
+
export function renameSyncRetry(from, to, retries = 5) {
|
|
34
|
+
let lastErr;
|
|
35
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
36
|
+
try {
|
|
37
|
+
renameSync(from, to);
|
|
38
|
+
return;
|
|
39
|
+
} catch (err) {
|
|
40
|
+
lastErr = err;
|
|
41
|
+
const code = err && err.code;
|
|
42
|
+
// 非可重试错误码立即抛出,不浪费时间
|
|
43
|
+
if (!code || !RENAME_RETRY_CODES.includes(code)) throw err;
|
|
44
|
+
if (attempt < retries) sleepSync(15 * (attempt + 1));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw lastErr;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 原子写文本文件:同目录 tmp 写入 → rename 覆盖目标(带 Windows 退避重试)。
|
|
52
|
+
* - tmp 名含 pid,避免多进程并发写同一目标时 tmp 名碰撞(如多会话写 gate-status.json)。
|
|
53
|
+
* - rename 失败时清理 tmp,避免留下孤儿 .tmp 文件。
|
|
54
|
+
* - Node 的 rename 在 Windows 上用 MoveFileEx(REPLACE_EXISTING),可覆盖已存在目标;
|
|
55
|
+
* EPERM 仅在文件被其他进程锁定时出现,由 renameSyncRetry 退避重试。
|
|
56
|
+
* @param {string} filePath - 目标文件绝对路径
|
|
57
|
+
* @param {string} content - 文件内容
|
|
58
|
+
*/
|
|
59
|
+
export function writeAtomicSync(filePath, content) {
|
|
60
|
+
const dir = dirname(filePath);
|
|
61
|
+
const tmpPath = join(dir, `.${basename(filePath)}.${process.pid}.tmp`);
|
|
62
|
+
writeFileSync(tmpPath, content);
|
|
63
|
+
try {
|
|
64
|
+
renameSyncRetry(tmpPath, filePath);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
try { unlinkSync(tmpPath); } catch { /* tmp 可能已被 rename 走,忽略 */ }
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -267,7 +267,11 @@ function queryDbFirstCell(cwd, sql) {
|
|
|
267
267
|
}
|
|
268
268
|
|
|
269
269
|
export { queryDbFirstCell as _queryDbFirstCellForTest };
|
|
270
|
-
|
|
270
|
+
export {
|
|
271
|
+
matchReadonlyWhitelist as _matchReadonlyWhitelistForTest,
|
|
272
|
+
matchDangerBlacklist as _matchDangerBlacklistForTest,
|
|
273
|
+
isSingleCommandReadonly as _isSingleCommandReadonlyForTest,
|
|
274
|
+
};
|
|
271
275
|
/**
|
|
272
276
|
* 从 sillyspec.db 读取 currentStage
|
|
273
277
|
* 优先级:gate-status.json > sillyspec.db
|
|
@@ -472,7 +476,11 @@ function isSingleCommandReadonly(cmd, extraReadonlyCommands = []) {
|
|
|
472
476
|
// node/npm/npx 需要进一步检查子命令
|
|
473
477
|
if (cmdName === 'node' || cmdName === 'npm' || cmdName === 'npx') {
|
|
474
478
|
const rest = parts.slice(1).join(' ')
|
|
475
|
-
|
|
479
|
+
// 仅纯版本查询(`npm --version` / `node -v`,恰好两段)放行。
|
|
480
|
+
// 不能用 rest.includes('--version'):`npx any-pkg --version` 会执行 any-pkg,
|
|
481
|
+
// 旧实现因 && 优先级高于 || 使 --version 分支无长度约束,可绕过只读白名单。
|
|
482
|
+
const isBareVersionQuery = parts.length === 2 && (parts[1] === '--version' || parts[1] === '-v')
|
|
483
|
+
return isBareVersionQuery || rest === 'run test' || rest.startsWith('test')
|
|
476
484
|
}
|
|
477
485
|
return true
|
|
478
486
|
}
|
|
@@ -531,8 +539,12 @@ function isSingleCommandDangerous(cmd) {
|
|
|
531
539
|
* @returns {string[]}
|
|
532
540
|
*/
|
|
533
541
|
function splitCommandParts(command) {
|
|
534
|
-
//
|
|
535
|
-
|
|
542
|
+
// 按管道、链式操作符、语句分隔、重定向拆分。
|
|
543
|
+
// 必须覆盖 `||`/`&&`/`;`/`&`/`|`/`>`/`<`:否则 `echo foo; rm -rf x`、
|
|
544
|
+
// `cat f > /etc/passwd` 会被当成单一片段,首命令命中只读白名单 → 整条放行,
|
|
545
|
+
// 后半段的危险/写入操作绕过只读白名单与危险黑名单两道门。
|
|
546
|
+
// (`$(...)`/反引号命令替换需 shell-token 化,与零依赖风格冲突,暂不覆盖。)
|
|
547
|
+
return command.split(/\|\||&&|;|&|\||>|</g).map(s => s.trim()).filter(Boolean)
|
|
536
548
|
}
|
|
537
549
|
|
|
538
550
|
/**
|
package/src/index.js
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* 状态管理通过 sillyspec.db(SQLite)完成,使用 `sillyspec progress` 命令。
|
|
8
8
|
*/
|
|
9
9
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'fs';
|
|
10
|
+
import { writeAtomicSync } from './fs-atomic.js';
|
|
10
11
|
import { join, resolve } from 'path';
|
|
12
|
+
import { execSync } from 'child_process';
|
|
11
13
|
import { cmdInit, getVersion } from './init.js';
|
|
12
14
|
import { ProgressManager, resolvePlatformSpecDir } from './progress.js';
|
|
13
15
|
import { detectLocalYaml } from './local-detect.js';
|
|
@@ -185,7 +187,6 @@ async function main() {
|
|
|
185
187
|
function resolveEffectiveDir(baseDir) {
|
|
186
188
|
if (existsSync(join(baseDir, '.sillyspec'))) return baseDir
|
|
187
189
|
try {
|
|
188
|
-
const { execSync } = require('child_process')
|
|
189
190
|
const gitRoot = execSync('git rev-parse --show-toplevel', {
|
|
190
191
|
cwd: baseDir, encoding: 'utf8', timeout: 5000
|
|
191
192
|
}).trim()
|
|
@@ -280,7 +281,7 @@ async function main() {
|
|
|
280
281
|
}
|
|
281
282
|
case 'batch': {
|
|
282
283
|
if (filteredArgs.includes('--status')) {
|
|
283
|
-
const bp = pm.readBatchProgress(dir, progChangeName);
|
|
284
|
+
const bp = await pm.readBatchProgress(dir, progChangeName);
|
|
284
285
|
if (!bp) { console.log('📭 无批量进度数据'); break; }
|
|
285
286
|
const line = pm._renderBatchProgress(bp);
|
|
286
287
|
console.log(line || '📭 无批量进度数据');
|
|
@@ -299,7 +300,7 @@ async function main() {
|
|
|
299
300
|
console.log(' sillyspec progress batch --status');
|
|
300
301
|
break;
|
|
301
302
|
}
|
|
302
|
-
pm.updateBatchProgress(dir, batchData, progChangeName);
|
|
303
|
+
await pm.updateBatchProgress(dir, batchData, progChangeName);
|
|
303
304
|
console.log('✅ 批量进度已更新');
|
|
304
305
|
}
|
|
305
306
|
break;
|
|
@@ -1171,11 +1172,9 @@ SillySpec modules — 模块文档管理
|
|
|
1171
1172
|
lines.push('# order-service: "order/"');
|
|
1172
1173
|
const yamlText = lines.join('\n') + '\n';
|
|
1173
1174
|
|
|
1174
|
-
//
|
|
1175
|
+
// 原子写(tmp + rename + Windows EPERM 重试),避免半截 local.yaml
|
|
1175
1176
|
mkdirSync(specRoot, { recursive: true });
|
|
1176
|
-
|
|
1177
|
-
writeFileSync(tmpPath, yamlText, 'utf8');
|
|
1178
|
-
renameSync(tmpPath, localYamlPath);
|
|
1177
|
+
writeAtomicSync(localYamlPath, yamlText);
|
|
1179
1178
|
|
|
1180
1179
|
console.log(`✅ 已生成 local.yaml (type: ${detected.project.type})`);
|
|
1181
1180
|
console.log(` 路径: ${localYamlPath}`);
|
package/src/modules.js
CHANGED
|
@@ -79,7 +79,7 @@ export async function rebuildModuleMap(cwd) {
|
|
|
79
79
|
const content = readFileSync(join(modulesDir, f), 'utf8');
|
|
80
80
|
const fm = parseModuleCardFrontmatter(content);
|
|
81
81
|
const moduleId = fm.module_id || parseModuleIdFromFilename(f);
|
|
82
|
-
cards.push({ filename: f, moduleId });
|
|
82
|
+
cards.push({ filename: f, moduleId, content });
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
@@ -134,7 +134,7 @@ export async function rebuildModuleMap(cwd) {
|
|
|
134
134
|
|
|
135
135
|
for (const moduleId of allModuleIds) {
|
|
136
136
|
const card = cards.find(c => c.moduleId === moduleId);
|
|
137
|
-
const cardContent = card
|
|
137
|
+
const cardContent = card?.content || '';
|
|
138
138
|
|
|
139
139
|
// 从模块卡片提取 context index 字段
|
|
140
140
|
const role = extractSection(cardContent, '定位') || '';
|
package/src/progress.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, appendFileSync } from 'fs';
|
|
14
14
|
import { join, basename, dirname, resolve } from 'path';
|
|
15
15
|
import { DB } from './db.js';
|
|
16
|
+
import { writeAtomicSync } from './fs-atomic.js';
|
|
16
17
|
|
|
17
18
|
// 默认规范目录名(相对于 cwd)
|
|
18
19
|
const SPEC_DIR_NAME = '.sillyspec';
|
|
@@ -156,7 +157,14 @@ export class ProgressManager {
|
|
|
156
157
|
/** 获取 specDir(优先自定义,否则向上查找含 .sillyspec 的目录,fallback 到 cwd/.sillyspec) */
|
|
157
158
|
_getSpecDir(cwd) {
|
|
158
159
|
if (this._customSpecDir) return this._customSpecDir;
|
|
159
|
-
|
|
160
|
+
// specDir 对给定 cwd 在进程生命周期内不变(.sillyspec 目录不会被移动),
|
|
161
|
+
// 按 cwd 缓存,避免每次路径解析都向上 existsSync 遍历目录树(热路径)。
|
|
162
|
+
if (!this._specDirCache) this._specDirCache = new Map();
|
|
163
|
+
const cached = this._specDirCache.get(cwd);
|
|
164
|
+
if (cached) return cached;
|
|
165
|
+
const resolved = resolveSpecDir(cwd);
|
|
166
|
+
this._specDirCache.set(cwd, resolved);
|
|
167
|
+
return resolved;
|
|
160
168
|
}
|
|
161
169
|
|
|
162
170
|
_runtimePath(cwd, ...parts) {
|
|
@@ -217,44 +225,6 @@ export class ProgressManager {
|
|
|
217
225
|
};
|
|
218
226
|
}
|
|
219
227
|
|
|
220
|
-
async writeGlobal(cwd, data) {
|
|
221
|
-
// SQL: UPDATE project + UPSERT changes status
|
|
222
|
-
const db = await this._ensureDB(cwd);
|
|
223
|
-
db.transaction((sqlDb) => {
|
|
224
|
-
const now = new Date().toISOString();
|
|
225
|
-
|
|
226
|
-
// UPSERT project 行
|
|
227
|
-
sqlDb.run(`
|
|
228
|
-
INSERT INTO project (id, name, schema_version, created_at, updated_at)
|
|
229
|
-
VALUES (1, ?, ?, ?, ?)
|
|
230
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
231
|
-
name = excluded.name,
|
|
232
|
-
schema_version = excluded.schema_version,
|
|
233
|
-
updated_at = excluded.updated_at
|
|
234
|
-
`, [data.project || '', data._version || CURRENT_VERSION, now, now]);
|
|
235
|
-
|
|
236
|
-
// 同步 changes 表:确保 activeChanges 列表中的变更存在且为 active,
|
|
237
|
-
// 不在列表中的设为 archived
|
|
238
|
-
const activeChanges = data.activeChanges || [];
|
|
239
|
-
for (const cn of activeChanges) {
|
|
240
|
-
sqlDb.run(`
|
|
241
|
-
INSERT INTO changes (name, status, created_at, last_active)
|
|
242
|
-
VALUES (?, 'active', ?, ?)
|
|
243
|
-
ON CONFLICT(name) DO UPDATE SET status = 'active', last_active = excluded.last_active
|
|
244
|
-
`, [cn, now, now]);
|
|
245
|
-
}
|
|
246
|
-
if (activeChanges.length > 0) {
|
|
247
|
-
sqlDb.run(`
|
|
248
|
-
UPDATE changes SET status = 'archived'
|
|
249
|
-
WHERE status = 'active' AND name NOT IN (${activeChanges.map(() => '?').join(',')})
|
|
250
|
-
`, activeChanges);
|
|
251
|
-
} else {
|
|
252
|
-
// 没有活跃变更,将所有 active 归档
|
|
253
|
-
sqlDb.run("UPDATE changes SET status = 'archived' WHERE status = 'active'");
|
|
254
|
-
}
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
|
-
|
|
258
228
|
// ── 变更级别状态 ──
|
|
259
229
|
|
|
260
230
|
/**
|
|
@@ -650,20 +620,38 @@ export class ProgressManager {
|
|
|
650
620
|
console.error(`❌ 变更 ${newName} 已存在`);
|
|
651
621
|
return;
|
|
652
622
|
}
|
|
653
|
-
//
|
|
623
|
+
// 先更新 DB,再重命名目录;FS 失败则回滚 DB,避免"目录已改名但 DB 旧名"的孤儿
|
|
624
|
+
// (旧实现 FS-first 无补偿:renameSync 成功后 DB transaction 抛 EPERM/EBUSY 会让
|
|
625
|
+
// 目录已是 newName、DB 仍是 oldName,read(两名) 都失联且无自动恢复)。
|
|
654
626
|
const oldDir = this._changePath(cwd, oldName);
|
|
655
627
|
const newDir = this._changePath(cwd, newName);
|
|
628
|
+
const now = new Date().toISOString();
|
|
629
|
+
try {
|
|
630
|
+
db.transaction((sqlDb) => {
|
|
631
|
+
sqlDb.run(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`, [newName, now, oldName]);
|
|
632
|
+
});
|
|
633
|
+
} catch (e) {
|
|
634
|
+
console.error(`❌ 重命名失败:更新数据库时出错(${e.message})`);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
let renamed = true;
|
|
656
638
|
if (existsSync(oldDir)) {
|
|
657
|
-
|
|
639
|
+
try {
|
|
640
|
+
renameSync(oldDir, newDir);
|
|
641
|
+
} catch (e) {
|
|
642
|
+
renamed = false;
|
|
643
|
+
// FS 重命名失败:回滚 DB 恢复 oldName,保持 DB 与目录一致
|
|
644
|
+
try {
|
|
645
|
+
db.transaction((sqlDb) => {
|
|
646
|
+
sqlDb.run(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`, [oldName, now, newName]);
|
|
647
|
+
});
|
|
648
|
+
} catch {}
|
|
649
|
+
console.error(`❌ 重命名失败:移动目录出错(${e.message}),已回滚数据库`);
|
|
650
|
+
}
|
|
658
651
|
} else {
|
|
659
652
|
mkdirSync(newDir, { recursive: true });
|
|
660
653
|
}
|
|
661
|
-
|
|
662
|
-
const now = new Date().toISOString();
|
|
663
|
-
db.transaction((sqlDb) => {
|
|
664
|
-
sqlDb.run(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`, [newName, now, oldName]);
|
|
665
|
-
});
|
|
666
|
-
console.log(`✅ 变更已重命名:${oldName} → ${newName}`);
|
|
654
|
+
if (renamed) console.log(`✅ 变更已重命名:${oldName} → ${newName}`);
|
|
667
655
|
}
|
|
668
656
|
|
|
669
657
|
/**
|
|
@@ -1064,7 +1052,7 @@ export class ProgressManager {
|
|
|
1064
1052
|
mkdirSync(historyDir, { recursive: true });
|
|
1065
1053
|
const ts = now.replace(/[:.TZ-]/g, '');
|
|
1066
1054
|
const stageData = data.stages[stage];
|
|
1067
|
-
|
|
1055
|
+
writeAtomicSync(
|
|
1068
1056
|
join(historyDir, `${cn}-${stage}-${ts}.json`),
|
|
1069
1057
|
JSON.stringify({ change: cn, stage, data: stageData, completedAt: now }, null, 2) + '\n'
|
|
1070
1058
|
);
|
|
@@ -1666,8 +1654,7 @@ export class ProgressManager {
|
|
|
1666
1654
|
const changeId = changeRow[0].values[0][0];
|
|
1667
1655
|
sqlDb.run('DELETE FROM steps WHERE stage_id IN (SELECT id FROM stages WHERE change_id = ?)', [changeId]);
|
|
1668
1656
|
sqlDb.run('DELETE FROM stages WHERE change_id = ?', [changeId]);
|
|
1669
|
-
|
|
1670
|
-
// 重新插入所有阶段
|
|
1657
|
+
// 重新插入所有阶段(注意:上方已 DELETE stages,无需再 UPDATE)
|
|
1671
1658
|
for (const s of VALID_STAGES) {
|
|
1672
1659
|
sqlDb.run('INSERT OR IGNORE INTO stages (change_id, stage, status) VALUES (?, ?, "pending")', [changeId, s]);
|
|
1673
1660
|
}
|
|
@@ -1865,9 +1852,7 @@ export class ProgressManager {
|
|
|
1865
1852
|
updatedAt: new Date().toISOString(),
|
|
1866
1853
|
...(hasNoWorktree ? { noWorktree: true } : {}),
|
|
1867
1854
|
};
|
|
1868
|
-
|
|
1869
|
-
writeFileSync(tmpPath, JSON.stringify(gateData, null, 2) + '\n');
|
|
1870
|
-
renameSync(tmpPath, gatePath);
|
|
1855
|
+
writeAtomicSync(gatePath, JSON.stringify(gateData, null, 2) + '\n');
|
|
1871
1856
|
} catch (err) {
|
|
1872
1857
|
console.warn('⚠️ 写入 gate-status.json 失败:', err.message);
|
|
1873
1858
|
}
|