sillyspec 3.24.8 → 3.25.1
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 +13 -0
- package/src/contract-matrix.js +48 -20
- package/src/db.js +28 -8
- package/src/endpoint-extractor.js +92 -6
- package/src/hooks/worktree-guard.js +25 -6
- package/src/index.js +6 -6
- package/src/machine-interface.js +13 -7
- package/src/progress.js +46 -50
- package/src/run/command.js +1011 -0
- package/src/run/complete-handlers.js +873 -0
- package/src/run/complete.js +695 -0
- package/src/run/gates.js +321 -0
- package/src/run/prompt.js +604 -0
- package/src/run/quick-audit.js +102 -0
- package/src/run/scan-profile.js +238 -0
- package/src/run/shared.js +375 -0
- package/src/run/stage.js +414 -0
- package/src/run.js +23 -4278
- package/src/stage-contract.js +11 -11
- package/src/stages/index.js +1 -1
- package/src/verify-postcheck.js +41 -0
- package/src/worktree-apply.js +45 -18
- package/src/worktree.js +30 -13
package/package.json
CHANGED
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
|
+
])
|
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
|
}
|
package/src/db.js
CHANGED
|
@@ -2,6 +2,11 @@ import initSqlJs from 'sql.js';
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
3
3
|
import { basename, dirname, join } from 'path';
|
|
4
4
|
|
|
5
|
+
// DB schema 版本(与 project.schema_version DEFAULT 对齐)。_createSchema 改动(加表/列/migration)时 bump,
|
|
6
|
+
// 触发 .schema-version 戳失效 → 下次 init 重跑 _createSchema + _save(W4-H:高频读路径 gate/derive/status
|
|
7
|
+
// 每次 new ProgressManager 都过 init,原无条件 _createSchema+_save 致每次整库 export+三段原子写)。
|
|
8
|
+
const DB_SCHEMA_VERSION = 3;
|
|
9
|
+
|
|
5
10
|
export class DB {
|
|
6
11
|
constructor(dbPath) {
|
|
7
12
|
this.dbPath = dbPath;
|
|
@@ -25,11 +30,21 @@ export class DB {
|
|
|
25
30
|
this.db.run('PRAGMA foreign_keys = ON');
|
|
26
31
|
this.db.run('PRAGMA synchronous = NORMAL');
|
|
27
32
|
|
|
28
|
-
// 5.
|
|
29
|
-
|
|
33
|
+
// 5. 创建表结构(仅当 schema 版本戳不匹配时——W4-H):
|
|
34
|
+
// schema 已最新则只 load 不写,省掉 _createSchema + _save 的整库 export + 三段原子写。
|
|
35
|
+
// 戳在但 db 缺表属"手动改 db"边角(SillySpec 不支持),_loadDatabase 的损坏检测兜底正常路径。
|
|
36
|
+
const versionPath = `${this.dbPath}.schema-version`;
|
|
37
|
+
let schemaCurrent = false;
|
|
38
|
+
try {
|
|
39
|
+
schemaCurrent = existsSync(versionPath)
|
|
40
|
+
&& readFileSync(versionPath, 'utf8').trim() === String(DB_SCHEMA_VERSION);
|
|
41
|
+
} catch { /* 戳读取失败保守重跑 schema */ }
|
|
30
42
|
|
|
31
|
-
|
|
32
|
-
|
|
43
|
+
if (!schemaCurrent) {
|
|
44
|
+
this._createSchema();
|
|
45
|
+
this._save();
|
|
46
|
+
try { writeFileSync(versionPath, String(DB_SCHEMA_VERSION)); } catch { /* 戳写入失败不阻断,下次重跑 */ }
|
|
47
|
+
}
|
|
33
48
|
}
|
|
34
49
|
|
|
35
50
|
close() {
|
|
@@ -43,15 +58,20 @@ export class DB {
|
|
|
43
58
|
transaction(fn) {
|
|
44
59
|
if (!this.db) throw new Error('DB not initialized');
|
|
45
60
|
this.db.run('BEGIN');
|
|
61
|
+
let result;
|
|
46
62
|
try {
|
|
47
|
-
|
|
63
|
+
result = fn(this.db);
|
|
48
64
|
this.db.run('COMMIT');
|
|
49
|
-
this._save();
|
|
50
|
-
return result;
|
|
51
65
|
} catch (err) {
|
|
52
|
-
|
|
66
|
+
// fn 抛错或 COMMIT 失败:事务级回滚(此时事务确实活跃,ROLLBACK 有效)
|
|
67
|
+
try { this.db.run('ROLLBACK'); } catch { /* 事务可能已被 fn 内的错自动回滚 */ }
|
|
53
68
|
throw err;
|
|
54
69
|
}
|
|
70
|
+
// COMMIT 成功后才持久化。_save 失败时抛原生错(磁盘满/EPERM),不再被 catch 里
|
|
71
|
+
// 无效的 ROLLBACK 二级异常("no transaction active")掩盖——内存库已 commit,
|
|
72
|
+
// 下次 _save / close() 会落盘,调用方拿到的是准确的持久化错误而非被替换的误报。
|
|
73
|
+
this._save();
|
|
74
|
+
return result;
|
|
55
75
|
}
|
|
56
76
|
|
|
57
77
|
/** 获取底层 db 对象(供 progress.js 直接使用) */
|
|
@@ -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
|
// ─── 对账 ───────────────────────────────────────────────────────────────
|
|
@@ -258,16 +258,27 @@ function queryDbFirstCell(cwd, sql) {
|
|
|
258
258
|
"if(!fs.existsSync(dbPath))process.exit(0);" +
|
|
259
259
|
"import(lib).then(async m=>{const SQL=await m.default();" +
|
|
260
260
|
"const db=new SQL.Database(fs.readFileSync(dbPath));const r=db.exec(sql);" +
|
|
261
|
-
"if(r.length&&r[0].values.length)process.stdout.write(String(r[0].values[0][0]??''));db.close()}).catch(()
|
|
261
|
+
"if(r.length&&r[0].values.length)process.stdout.write(String(r[0].values[0][0]??''));db.close()}).catch(e=>{process.stderr.write('hook db query failed: '+String(e&&e.message||e));process.exit(1)});"
|
|
262
262
|
try {
|
|
263
263
|
return execFileSync(process.execPath, ['-e', script], {
|
|
264
264
|
encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'],
|
|
265
265
|
}).trim() || null
|
|
266
|
-
} catch {
|
|
266
|
+
} catch (e) {
|
|
267
|
+
// DB 查询失败(db 损坏 / sql.js resolve 失败 / 子进程超时):降级返回 null,调用方
|
|
268
|
+
//(readCurrentStage 等)对 null 走 fail-closed。补 warn 让 doctor/agent 能看到根因,
|
|
269
|
+
// 而非完全静默(子进程把诊断写到了 stderr,execFileSync 抛错时挂在 e.stderr 上)。
|
|
270
|
+
const detail = (e && e.stderr ? String(e.stderr).trim() : '') || (e && e.message) || 'timeout/crash'
|
|
271
|
+
console.warn('⚠️ sillyspec.db 查询失败,hook 降级 fail-closed: ' + detail)
|
|
272
|
+
return null
|
|
273
|
+
}
|
|
267
274
|
}
|
|
268
275
|
|
|
269
276
|
export { queryDbFirstCell as _queryDbFirstCellForTest };
|
|
270
|
-
|
|
277
|
+
export {
|
|
278
|
+
matchReadonlyWhitelist as _matchReadonlyWhitelistForTest,
|
|
279
|
+
matchDangerBlacklist as _matchDangerBlacklistForTest,
|
|
280
|
+
isSingleCommandReadonly as _isSingleCommandReadonlyForTest,
|
|
281
|
+
};
|
|
271
282
|
/**
|
|
272
283
|
* 从 sillyspec.db 读取 currentStage
|
|
273
284
|
* 优先级:gate-status.json > sillyspec.db
|
|
@@ -472,7 +483,11 @@ function isSingleCommandReadonly(cmd, extraReadonlyCommands = []) {
|
|
|
472
483
|
// node/npm/npx 需要进一步检查子命令
|
|
473
484
|
if (cmdName === 'node' || cmdName === 'npm' || cmdName === 'npx') {
|
|
474
485
|
const rest = parts.slice(1).join(' ')
|
|
475
|
-
|
|
486
|
+
// 仅纯版本查询(`npm --version` / `node -v`,恰好两段)放行。
|
|
487
|
+
// 不能用 rest.includes('--version'):`npx any-pkg --version` 会执行 any-pkg,
|
|
488
|
+
// 旧实现因 && 优先级高于 || 使 --version 分支无长度约束,可绕过只读白名单。
|
|
489
|
+
const isBareVersionQuery = parts.length === 2 && (parts[1] === '--version' || parts[1] === '-v')
|
|
490
|
+
return isBareVersionQuery || rest === 'run test' || rest.startsWith('test')
|
|
476
491
|
}
|
|
477
492
|
return true
|
|
478
493
|
}
|
|
@@ -531,8 +546,12 @@ function isSingleCommandDangerous(cmd) {
|
|
|
531
546
|
* @returns {string[]}
|
|
532
547
|
*/
|
|
533
548
|
function splitCommandParts(command) {
|
|
534
|
-
//
|
|
535
|
-
|
|
549
|
+
// 按管道、链式操作符、语句分隔、重定向拆分。
|
|
550
|
+
// 必须覆盖 `||`/`&&`/`;`/`&`/`|`/`>`/`<`:否则 `echo foo; rm -rf x`、
|
|
551
|
+
// `cat f > /etc/passwd` 会被当成单一片段,首命令命中只读白名单 → 整条放行,
|
|
552
|
+
// 后半段的危险/写入操作绕过只读白名单与危险黑名单两道门。
|
|
553
|
+
// (`$(...)`/反引号命令替换需 shell-token 化,与零依赖风格冲突,暂不覆盖。)
|
|
554
|
+
return command.split(/\|\||&&|;|&|\||>|</g).map(s => s.trim()).filter(Boolean)
|
|
536
555
|
}
|
|
537
556
|
|
|
538
557
|
/**
|
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()
|
|
@@ -340,8 +340,8 @@ async function main() {
|
|
|
340
340
|
for (const e of (c.errors || [])) console.log(` ✗ ${e}`);
|
|
341
341
|
for (const w of (c.warnings || [])) console.log(` ⚠ ${w}`);
|
|
342
342
|
}
|
|
343
|
-
|
|
344
|
-
|
|
343
|
+
// 顶层 errors/warnings 是各 check 的聚合,已在上面按 check 分组显示;不再重复打印——
|
|
344
|
+
// 非 json 是 agent 默认消费路径,重复文本纯耗 context(W1-J)。
|
|
345
345
|
}
|
|
346
346
|
process.exitCode = exitCode;
|
|
347
347
|
break;
|
|
@@ -402,7 +402,7 @@ async function main() {
|
|
|
402
402
|
const { runCommand } = await import('./run.js')
|
|
403
403
|
// 平台模式(--spec-dir 已指定)时,--dir 是明确的 source_root,不应被 resolveEffectiveDir 纠正
|
|
404
404
|
const effectiveDir = specDir ? dir : resolveEffectiveDir(dir)
|
|
405
|
-
await runCommand(filteredArgs.slice(1), effectiveDir, specDir)
|
|
405
|
+
await runCommand(filteredArgs.slice(1), effectiveDir, specDir, { json })
|
|
406
406
|
break
|
|
407
407
|
}
|
|
408
408
|
// 顶层命令别名:转发 runCommand,与 case 'run': 路径行为一致。
|
|
@@ -513,7 +513,7 @@ async function main() {
|
|
|
513
513
|
}
|
|
514
514
|
// 否则:保持原有 prompt 驱动的 bash 自检流程
|
|
515
515
|
const { runCommand } = await import('./run.js');
|
|
516
|
-
await runCommand([command, ...filteredArgs.slice(1)], doctorEffectiveDir, specDir);
|
|
516
|
+
await runCommand([command, ...filteredArgs.slice(1)], doctorEffectiveDir, specDir, { json });
|
|
517
517
|
break;
|
|
518
518
|
}
|
|
519
519
|
case 'scan':
|
|
@@ -528,7 +528,7 @@ async function main() {
|
|
|
528
528
|
const { runCommand } = await import('./run.js')
|
|
529
529
|
const stageArgs = [command, ...filteredArgs.slice(1)]
|
|
530
530
|
const effectiveDir = specDir ? dir : resolveEffectiveDir(dir)
|
|
531
|
-
await runCommand(stageArgs, effectiveDir, specDir)
|
|
531
|
+
await runCommand(stageArgs, effectiveDir, specDir, { json })
|
|
532
532
|
break
|
|
533
533
|
}
|
|
534
534
|
case 'knowledge': {
|
package/src/machine-interface.js
CHANGED
|
@@ -125,10 +125,16 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot }
|
|
|
125
125
|
const currentStage = progress.currentStage || '';
|
|
126
126
|
const checks = [];
|
|
127
127
|
|
|
128
|
+
// W4-G (D-008):execute 阶段预计算 code evidence 一次,供 artifacts check(经 runValidators
|
|
129
|
+
// → validateExecuteOutputs)与下方 execute-evidence check 共享——避免各调一次 checkExecuteCodeEvidence
|
|
130
|
+
// 各 spawn 2 个 git 进程(gate execute 一次省 ≈ 60-200ms on Windows)。
|
|
131
|
+
const sharedEvidence = stage === 'execute' ? checkExecuteCodeEvidence(cwd, changeName) : undefined;
|
|
132
|
+
|
|
128
133
|
// ── a. artifacts:阶段产物校验(全部阶段)──
|
|
129
134
|
const r = runValidators(stage, cwd, changeName, {
|
|
130
135
|
projectName: progress.project,
|
|
131
136
|
specRoot,
|
|
137
|
+
...(sharedEvidence ? { evidence: sharedEvidence } : {}),
|
|
132
138
|
});
|
|
133
139
|
checks.push({
|
|
134
140
|
id: 'artifacts',
|
|
@@ -137,24 +143,24 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot }
|
|
|
137
143
|
warnings: r.warnings || [],
|
|
138
144
|
});
|
|
139
145
|
|
|
140
|
-
// ── b. transition
|
|
141
|
-
// 与 run.js
|
|
142
|
-
// transition
|
|
146
|
+
// ── b. transition:状态转换合法性(参与综合 ok,与 completeStep 硬阻断一致)──
|
|
147
|
+
// 与 run.js runStage 同源:传 fromStageData 触发 failed_post_check 门控。
|
|
148
|
+
// transition 必须参与 ok:否则 gate 返回 ok=true、exit 0,Agent 据此 --done 却被
|
|
149
|
+
// runStage 的 checkTransition 硬阻断(exit 1),gate/run 判定分裂(design §8 漂移)。
|
|
143
150
|
const fromStageData = (progress.stages && currentStage && progress.stages[currentStage]) || undefined;
|
|
144
151
|
const t = checkTransition(currentStage, stage, fromStageData ? { fromStageData } : {});
|
|
145
152
|
checks.push({
|
|
146
153
|
id: 'transition',
|
|
147
154
|
ok: t.allowed,
|
|
148
|
-
informational: true,
|
|
149
155
|
errors: t.allowed ? [] : [t.reason].filter(Boolean),
|
|
150
156
|
warnings: [],
|
|
151
157
|
});
|
|
152
158
|
|
|
153
159
|
// ── c. execute 阶段追加 execute-evidence + task-reviews ──
|
|
154
|
-
// D-008
|
|
155
|
-
//
|
|
160
|
+
// D-008 + W4-G:checkExecuteCodeEvidence 整个 runGate 只调一次(顶部 sharedEvidence 预计算),
|
|
161
|
+
// 这里复用——真正落实去重(原注释声称"只调一次"但实现里 execute-evidence check 仍重复调了第二次)。
|
|
156
162
|
if (stage === 'execute') {
|
|
157
|
-
const ev =
|
|
163
|
+
const ev = sharedEvidence;
|
|
158
164
|
checks.push({
|
|
159
165
|
id: 'execute-evidence',
|
|
160
166
|
ok: ev.status !== 'unchanged',
|
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,42 @@ 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
|
-
|
|
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
|
+
console.error(`❌ 重命名失败:移动目录出错(${e.message}),已回滚数据库`);
|
|
649
|
+
} catch (rollbackErr) {
|
|
650
|
+
// 回滚本身也失败:DB 是 newName、目录是 oldName,两端分裂且无自动恢复。
|
|
651
|
+
// 不能再撒谎"已回滚"——必须显式报错让用户跑 doctor 修复。
|
|
652
|
+
console.error(`❌ 重命名失败且数据库回滚也失败(状态分裂,需手动修复):原错=${e.message} 回滚错=${rollbackErr.message}。请跑 sillyspec doctor --json`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
666
655
|
} else {
|
|
667
656
|
mkdirSync(newDir, { recursive: true });
|
|
668
657
|
}
|
|
669
|
-
|
|
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}`);
|
|
658
|
+
if (renamed) console.log(`✅ 变更已重命名:${oldName} → ${newName}`);
|
|
675
659
|
}
|
|
676
660
|
|
|
677
661
|
/**
|
|
@@ -1835,9 +1819,16 @@ export class ProgressManager {
|
|
|
1835
1819
|
const gatePath = this._runtimePath(cwd, 'gate-status.json');
|
|
1836
1820
|
|
|
1837
1821
|
if (!rows || rows.length === 0 || rows[0].values.length === 0) {
|
|
1838
|
-
// 无 execute/quick
|
|
1822
|
+
// 无 execute/quick 阶段的活跃变更:写墓碑而非 unlink。unlink 失败(Windows 杀毒/索引占用)
|
|
1823
|
+
// 会被空 catch 吞掉,陈旧 gate-status 残留 → hook 把 brainstorm 误当 execute 放行(fail-open)。
|
|
1824
|
+
// 墓碑 {stage:null} 让 readCurrentStage 的 `gateStatus.stage` 为假 → 自动 fallback DB,语义等价删除但原子。
|
|
1839
1825
|
if (existsSync(gatePath)) {
|
|
1840
|
-
try {
|
|
1826
|
+
try {
|
|
1827
|
+
this._ensureRuntimeDir(cwd);
|
|
1828
|
+
writeAtomicSync(gatePath, JSON.stringify({ stage: null, tombstone: true, updatedAt: new Date().toISOString() }) + '\n');
|
|
1829
|
+
} catch (e) {
|
|
1830
|
+
console.warn(`⚠️ gate-status 墓碑写入失败(hook 可能读到陈旧缓存): ${e.message}`);
|
|
1831
|
+
}
|
|
1841
1832
|
}
|
|
1842
1833
|
return;
|
|
1843
1834
|
}
|
|
@@ -1857,9 +1848,14 @@ export class ProgressManager {
|
|
|
1857
1848
|
}
|
|
1858
1849
|
|
|
1859
1850
|
if (!gateStage) {
|
|
1860
|
-
// current_stage 为 NULL 的边界情况,等同于无 execute/quick
|
|
1851
|
+
// current_stage 为 NULL 的边界情况,等同于无 execute/quick:同样写墓碑(见上 rationale)
|
|
1861
1852
|
if (existsSync(gatePath)) {
|
|
1862
|
-
try {
|
|
1853
|
+
try {
|
|
1854
|
+
this._ensureRuntimeDir(cwd);
|
|
1855
|
+
writeAtomicSync(gatePath, JSON.stringify({ stage: null, tombstone: true, updatedAt: new Date().toISOString() }) + '\n');
|
|
1856
|
+
} catch (e) {
|
|
1857
|
+
console.warn(`⚠️ gate-status 墓碑写入失败(hook 可能读到陈旧缓存): ${e.message}`);
|
|
1858
|
+
}
|
|
1863
1859
|
}
|
|
1864
1860
|
return;
|
|
1865
1861
|
}
|