sillyspec 3.24.8 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.24.8",
3
+ "version": "3.25.0",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
package/src/constants.js CHANGED
@@ -80,3 +80,16 @@ export const SCAN_REQUIRED_DOCS = Object.freeze([
80
80
  'CONCERNS.md',
81
81
  'PROJECT.md',
82
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
+ ])
@@ -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
- for (const taskName of Object.keys(taskDeps)) {
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
- const tableRows = planContent.matchAll(/\|[^|]*task-(\d+)[^|]*\|[^|]*\|[^|]*(?:task-)?(\d+(?:\s*[,,]\s*\d+)*)[^|]*\|/gi)
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
- const artifactDir = join(artifactRoot, 'contract-artifacts', taskName)
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, worktreePath, runtimeRoot) {
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
- // 读取所有 provider artifacts
306
- const artifactBase = join(artifactRoot, 'contract-artifacts')
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 (!worktreePath || !existsSync(worktreePath)) {
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(worktreePath)
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
- * 从目录递归扫描所有 Python router 文件的端点
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 {{ filePattern?: RegExp, excludePatterns?: RegExp[] }} opts
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 filePattern = opts.filePattern || /(?:router|routes|api|endpoint|controller)\.py$/i
85
- const excludePatterns = opts.excludePatterns || [/__pycache__/, /node_modules/, /\.venv/, /test/i]
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() && filePattern.test(entry.name)) {
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
  // ─── 对账 ───────────────────────────────────────────────────────────────
@@ -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
- return rest.includes('--version') || rest.includes('-v') && parts.length <= 3 || rest === 'run test' || rest.startsWith('test')
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
- return command.split(/(?:\|\|&&|&&|\|)/g).map(s => s.trim()).filter(Boolean)
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
@@ -9,6 +9,7 @@
9
9
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'fs';
10
10
  import { writeAtomicSync } from './fs-atomic.js';
11
11
  import { join, resolve } from 'path';
12
+ import { execSync } from 'child_process';
12
13
  import { cmdInit, getVersion } from './init.js';
13
14
  import { ProgressManager, resolvePlatformSpecDir } from './progress.js';
14
15
  import { detectLocalYaml } from './local-detect.js';
@@ -186,7 +187,6 @@ async function main() {
186
187
  function resolveEffectiveDir(baseDir) {
187
188
  if (existsSync(join(baseDir, '.sillyspec'))) return baseDir
188
189
  try {
189
- const { execSync } = require('child_process')
190
190
  const gitRoot = execSync('git rev-parse --show-toplevel', {
191
191
  cwd: baseDir, encoding: 'utf8', timeout: 5000
192
192
  }).trim()
package/src/progress.js CHANGED
@@ -225,44 +225,6 @@ export class ProgressManager {
225
225
  };
226
226
  }
227
227
 
228
- async writeGlobal(cwd, data) {
229
- // SQL: UPDATE project + UPSERT changes status
230
- const db = await this._ensureDB(cwd);
231
- db.transaction((sqlDb) => {
232
- const now = new Date().toISOString();
233
-
234
- // UPSERT project 行
235
- sqlDb.run(`
236
- INSERT INTO project (id, name, schema_version, created_at, updated_at)
237
- VALUES (1, ?, ?, ?, ?)
238
- ON CONFLICT(id) DO UPDATE SET
239
- name = excluded.name,
240
- schema_version = excluded.schema_version,
241
- updated_at = excluded.updated_at
242
- `, [data.project || '', data._version || CURRENT_VERSION, now, now]);
243
-
244
- // 同步 changes 表:确保 activeChanges 列表中的变更存在且为 active,
245
- // 不在列表中的设为 archived
246
- const activeChanges = data.activeChanges || [];
247
- for (const cn of activeChanges) {
248
- sqlDb.run(`
249
- INSERT INTO changes (name, status, created_at, last_active)
250
- VALUES (?, 'active', ?, ?)
251
- ON CONFLICT(name) DO UPDATE SET status = 'active', last_active = excluded.last_active
252
- `, [cn, now, now]);
253
- }
254
- if (activeChanges.length > 0) {
255
- sqlDb.run(`
256
- UPDATE changes SET status = 'archived'
257
- WHERE status = 'active' AND name NOT IN (${activeChanges.map(() => '?').join(',')})
258
- `, activeChanges);
259
- } else {
260
- // 没有活跃变更,将所有 active 归档
261
- sqlDb.run("UPDATE changes SET status = 'archived' WHERE status = 'active'");
262
- }
263
- });
264
- }
265
-
266
228
  // ── 变更级别状态 ──
267
229
 
268
230
  /**
@@ -658,20 +620,38 @@ export class ProgressManager {
658
620
  console.error(`❌ 变更 ${newName} 已存在`);
659
621
  return;
660
622
  }
661
- // 重命名目录
623
+ // 先更新 DB,再重命名目录;FS 失败则回滚 DB,避免"目录已改名但 DB 旧名"的孤儿
624
+ // (旧实现 FS-first 无补偿:renameSync 成功后 DB transaction 抛 EPERM/EBUSY 会让
625
+ // 目录已是 newName、DB 仍是 oldName,read(两名) 都失联且无自动恢复)。
662
626
  const oldDir = this._changePath(cwd, oldName);
663
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;
664
638
  if (existsSync(oldDir)) {
665
- renameSync(oldDir, newDir);
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
+ }
666
651
  } else {
667
652
  mkdirSync(newDir, { recursive: true });
668
653
  }
669
- // 更新 DB
670
- const now = new Date().toISOString();
671
- db.transaction((sqlDb) => {
672
- sqlDb.run(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`, [newName, now, oldName]);
673
- });
674
- console.log(`✅ 变更已重命名:${oldName} → ${newName}`);
654
+ if (renamed) console.log(`✅ 变更已重命名:${oldName} → ${newName}`);
675
655
  }
676
656
 
677
657
  /**