sillyspec 3.21.0 → 3.22.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/change-list.js +163 -87
- package/src/quick-recommend.js +5 -5
- package/src/stages/plan-postcheck.js +23 -43
package/package.json
CHANGED
package/src/change-list.js
CHANGED
|
@@ -1,87 +1,163 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from 'fs'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* @param {string} raw
|
|
8
|
-
* @returns {string}
|
|
9
|
-
*/
|
|
10
|
-
function
|
|
11
|
-
if (!raw) return ''
|
|
12
|
-
return raw
|
|
13
|
-
.replace(/`/g, '')
|
|
14
|
-
.replace(/\s*([^)]*)\s*$/, '')
|
|
15
|
-
.replace(/\s*\([^)]*\)\s*$/, '')
|
|
16
|
-
.replace(/\\/g, '/')
|
|
17
|
-
.replace(/\/+$/, '')
|
|
18
|
-
.trim()
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* @param {string}
|
|
27
|
-
* @
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
1
|
+
import { readFileSync, existsSync } from 'fs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 路径项归一化:去反引号、去行内括号注释(`src/foo.js (新增)` / `src/foo.js(说明)`)、
|
|
5
|
+
* 统一为正斜杠、去首尾空白与尾部斜杠。
|
|
6
|
+
* design 清单 cell 与 task allowed_paths 共用此归一化(两处写法都可能带注释/反引号)。
|
|
7
|
+
* @param {string} raw
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export function normalizePath(raw) {
|
|
11
|
+
if (!raw) return ''
|
|
12
|
+
return raw
|
|
13
|
+
.replace(/`/g, '')
|
|
14
|
+
.replace(/\s*([^)]*)\s*$/, '')
|
|
15
|
+
.replace(/\s*\([^)]*\)\s*$/, '')
|
|
16
|
+
.replace(/\\/g, '/')
|
|
17
|
+
.replace(/\/+$/, '')
|
|
18
|
+
.trim()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* glob → 正则:双星(globstar)跨零或多段目录(含零段,bash 语义)、
|
|
23
|
+
* 单星 → 单段([^/]*)、问号 → 单字符([^/]),其余转义。
|
|
24
|
+
* pattern 不含通配符时返回 false(调用方先用完全相等判断)。
|
|
25
|
+
* 不支持字符类(design/allowed_paths 实际不写,遇方括号按字面转义)。
|
|
26
|
+
* @param {string} str
|
|
27
|
+
* @param {string} pattern
|
|
28
|
+
* @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
export function globMatch(str, pattern) {
|
|
31
|
+
if (!/[*?]/.test(pattern)) return false
|
|
32
|
+
let re = '^'
|
|
33
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
34
|
+
const ch = pattern[i]
|
|
35
|
+
if (ch === '*') {
|
|
36
|
+
if (pattern[i + 1] === '*') {
|
|
37
|
+
// ** 跨零或多段:吞掉紧随的 /,输出 (?:.*/)? 让 src/**/a.js 也能匹配 src/a.js
|
|
38
|
+
if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 2 }
|
|
39
|
+
else { re += '.*'; i++ }
|
|
40
|
+
} else {
|
|
41
|
+
re += '[^/]*'
|
|
42
|
+
}
|
|
43
|
+
} else if (ch === '?') {
|
|
44
|
+
re += '[^/]'
|
|
45
|
+
} else {
|
|
46
|
+
re += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
re += '$'
|
|
50
|
+
try { return new RegExp(re).test(str) } catch { return false }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 双向容差匹配:design 清单文件 vs task allowed_paths(或脏文件 vs 清单)。
|
|
55
|
+
* 命中条件(任一):完全相等 / 目录前缀包含(双向)/ glob 通配(双向)。
|
|
56
|
+
* @param {string} a
|
|
57
|
+
* @param {string} b
|
|
58
|
+
* @returns {boolean}
|
|
59
|
+
*/
|
|
60
|
+
export function pathMatches(a, b) {
|
|
61
|
+
a = normalizePath(a)
|
|
62
|
+
b = normalizePath(b)
|
|
63
|
+
if (!a || !b) return false
|
|
64
|
+
if (a === b) return true
|
|
65
|
+
if (a.startsWith(b + '/') || b.startsWith(a + '/')) return true
|
|
66
|
+
if (globMatch(a, b) || globMatch(b, a)) return true
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 「文件变更清单」章节标题同义词(与 src/stage-contract.js 的识别集对齐,
|
|
72
|
+
* 避免两个校验器对「有没有清单」给出矛盾结论)。
|
|
73
|
+
*/
|
|
74
|
+
const FILE_LIST_SECTION_RE = /^#{2,3}\s*(文件变更清单|变更文件清单|文件清单|File Changes|Files to Change)/im
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* exclude 子标题词集:「不修改/暂缓/保留」类 —— 其下路径从清单移除,不强制 task 覆盖。
|
|
78
|
+
* 用排除集而非白名单:容忍「改动/变更/涉及」等近义 include 写法。
|
|
79
|
+
*/
|
|
80
|
+
const EXCLUDE_SUBSECTION_RE = /不修改|不变|保留|无变更|未变更|不改动|暂不|暂缓|暂定|待定|后续|未改|无需|不涉及/
|
|
81
|
+
|
|
82
|
+
function isPlaceholder(p) {
|
|
83
|
+
return !p || p === '—' || p === '-' || /^(n\/?a|无|none|-+)$/i.test(p)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 表头列定位:cell 整体匹配路径类词 */
|
|
87
|
+
function isPathHeaderCell(c) {
|
|
88
|
+
return /^(文件路径|文件名|文件|路径|filepath|filename|file\s*path|path)$/i.test(c)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 从 design.md 解析文件变更清单。兼容两种真实写法:
|
|
93
|
+
* ① 表格:`| 操作 | 文件路径 | 说明 |`(brainstorm 模板默认)
|
|
94
|
+
* ② 分类列表:`### 新增文件` / `### 修改文件` / `### 不修改文件` 下的 `- path`
|
|
95
|
+
* 表头列顺序自适应(定位「文件/路径/file/path」列,列顺序写反时不会把操作名当路径);
|
|
96
|
+
* 忽略 `.sillyspec/` 与占位符(`—`/`-`/`N/A`/`无`);「不修改/暂缓」子段下的路径会被排除;
|
|
97
|
+
* CRLF 容错。
|
|
98
|
+
* @param {string} designMdPath - design.md 文件路径
|
|
99
|
+
* @returns {Set<string>} 文件路径集合(相对路径,如 "src/worktree.js")
|
|
100
|
+
*/
|
|
101
|
+
export function parseFileChangeList(designMdPath) {
|
|
102
|
+
const result = new Set()
|
|
103
|
+
if (!designMdPath || !existsSync(designMdPath)) return result
|
|
104
|
+
|
|
105
|
+
const content = readFileSync(designMdPath, 'utf8').replace(/\r\n/g, '\n')
|
|
106
|
+
|
|
107
|
+
const sectionMatch = content.match(FILE_LIST_SECTION_RE)
|
|
108
|
+
if (!sectionMatch) return result
|
|
109
|
+
|
|
110
|
+
// 从标题后开始,截取到下一个 ## 标题或文件末尾
|
|
111
|
+
const afterSection = content.slice(sectionMatch.index + sectionMatch[0].length)
|
|
112
|
+
const nextSectionMatch = afterSection.match(/^##\s/m)
|
|
113
|
+
const relevantContent = nextSectionMatch
|
|
114
|
+
? afterSection.slice(0, nextSectionMatch.index)
|
|
115
|
+
: afterSection
|
|
116
|
+
|
|
117
|
+
const lines = relevantContent.split('\n')
|
|
118
|
+
let headerSkipped = false
|
|
119
|
+
let pathColIdx = 1 // 默认第 2 列;解析表头后可重定位
|
|
120
|
+
let listMode = 'include' // include | exclude(分类列表子段)
|
|
121
|
+
|
|
122
|
+
for (const line of lines) {
|
|
123
|
+
// 分类列表子标题:### 新增文件 / ### 修改文件 / ### 不修改文件
|
|
124
|
+
const subHeader = line.match(/^###\s+(.+?)\s*$/)
|
|
125
|
+
if (subHeader) {
|
|
126
|
+
listMode = EXCLUDE_SUBSECTION_RE.test(subHeader[1]) ? 'exclude' : 'include'
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 表格行
|
|
131
|
+
if (line.startsWith('|')) {
|
|
132
|
+
if (/^\|[-:\s|]+\|$/.test(line)) continue // 分隔行
|
|
133
|
+
const cells = line.split('|').slice(1, -1).map(c => c.trim())
|
|
134
|
+
if (cells.length < 2) continue
|
|
135
|
+
|
|
136
|
+
if (!headerSkipped) {
|
|
137
|
+
headerSkipped = true
|
|
138
|
+
// 表头列定位:找「文件/路径/file/path」列,找不到保持默认第 2 列
|
|
139
|
+
const idx = cells.findIndex(isPathHeaderCell)
|
|
140
|
+
if (idx >= 0) pathColIdx = idx
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const filePath = normalizePath(cells[pathColIdx] || '')
|
|
145
|
+
// 列定位兜底:取到纯操作词(表头未命中且列顺序异常)→ 跳过,避免把「修改」当路径
|
|
146
|
+
if (/^(新增|修改|删除|重命名|new|modify|update|delete|create|rename)$/i.test(filePath)) continue
|
|
147
|
+
if (isPlaceholder(filePath) || filePath.startsWith('.sillyspec/')) continue
|
|
148
|
+
result.add(filePath)
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 分类列表项:`- path` / `- \`path\``
|
|
153
|
+
const listItem = line.match(/^\s*-\s+(.+)/)
|
|
154
|
+
if (listItem) {
|
|
155
|
+
const filePath = normalizePath(listItem[1])
|
|
156
|
+
if (isPlaceholder(filePath) || filePath.startsWith('.sillyspec/')) continue
|
|
157
|
+
if (listMode === 'exclude') result.delete(filePath)
|
|
158
|
+
else result.add(filePath)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return result
|
|
163
|
+
}
|
package/src/quick-recommend.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { join } from 'path'
|
|
10
10
|
import { existsSync, readFileSync } from 'fs'
|
|
11
|
-
import { parseFileChangeList } from './change-list.js'
|
|
11
|
+
import { parseFileChangeList, pathMatches } from './change-list.js'
|
|
12
12
|
|
|
13
13
|
function escapeRegex(s) {
|
|
14
14
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
@@ -60,13 +60,13 @@ function readProposalContext(specDir, changeName) {
|
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
62
|
* 判断单个脏文件是否命中 design.md 声明的文件清单。
|
|
63
|
-
*
|
|
63
|
+
* 复用 change-list.js 的 pathMatches:完全相等 / 目录前缀(双向)/ glob 通配(双向),
|
|
64
|
+
* 与 plan-postcheck 的文件覆盖对账共用同一套匹配语义;并修复了 design 清单含 glob
|
|
65
|
+
* (如 `src/stages/scan/*.md`)时纯前缀匹配漏召回的问题。
|
|
64
66
|
*/
|
|
65
67
|
function fileHitsList(dirtyFile, fileList) {
|
|
66
|
-
if (fileList.has(dirtyFile)) return true
|
|
67
68
|
for (const declared of fileList) {
|
|
68
|
-
if (declared
|
|
69
|
-
if (dirtyFile.startsWith(declared + '/') || declared.startsWith(dirtyFile + '/')) return true
|
|
69
|
+
if (pathMatches(dirtyFile, declared)) return true
|
|
70
70
|
}
|
|
71
71
|
return false
|
|
72
72
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { existsSync, readFileSync, readdirSync } from 'fs'
|
|
13
13
|
import { join as pJoin } from 'path'
|
|
14
14
|
import jsYaml from 'js-yaml'
|
|
15
|
-
import { parseFileChangeList } from '../change-list.js'
|
|
15
|
+
import { parseFileChangeList, pathMatches } from '../change-list.js'
|
|
16
16
|
|
|
17
17
|
// ═══════════════════════════════════════════════════════════════
|
|
18
18
|
// 解析工具(从 plan.js 迁移)
|
|
@@ -337,38 +337,8 @@ export function validateCrossTaskContracts(changeDir) {
|
|
|
337
337
|
return { ok: errors.length === 0, errors, warnings }
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
-
//
|
|
341
|
-
// allowed_paths
|
|
342
|
-
function normalizePath(raw) {
|
|
343
|
-
if (!raw) return ''
|
|
344
|
-
return raw
|
|
345
|
-
.replace(/`/g, '')
|
|
346
|
-
.replace(/\s*([^)]*)\s*$/, '')
|
|
347
|
-
.replace(/\s*\([^)]*\)\s*$/, '')
|
|
348
|
-
.replace(/\\/g, '/')
|
|
349
|
-
.replace(/\/+$/, '')
|
|
350
|
-
.trim()
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function globMatch(str, pattern) {
|
|
354
|
-
if (!pattern.includes('*')) return false
|
|
355
|
-
const re = '^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*') + '$'
|
|
356
|
-
try { return new RegExp(re).test(str) } catch { return false }
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* 双向容差匹配:design 清单文件 vs task allowed_paths
|
|
361
|
-
* 命中条件(任一):完全相等 / 目录前缀包含(双向)/ glob 通配(双向)
|
|
362
|
-
*/
|
|
363
|
-
function pathMatches(designFile, allowedPath) {
|
|
364
|
-
const a = normalizePath(designFile)
|
|
365
|
-
const b = normalizePath(allowedPath)
|
|
366
|
-
if (!a || !b) return false
|
|
367
|
-
if (a === b) return true
|
|
368
|
-
if (a.startsWith(b + '/') || b.startsWith(a + '/')) return true
|
|
369
|
-
if (globMatch(a, b) || globMatch(b, a)) return true
|
|
370
|
-
return false
|
|
371
|
-
}
|
|
340
|
+
// 路径容差匹配(normalizePath / globMatch / pathMatches)复用自 change-list.js,
|
|
341
|
+
// design 清单解析与 allowed_paths 对账共用同一套匹配语义,避免两处逻辑漂移。
|
|
372
342
|
|
|
373
343
|
/**
|
|
374
344
|
* design.md 文件变更清单 → tasks allowed_paths 覆盖对账
|
|
@@ -380,8 +350,10 @@ function pathMatches(designFile, allowedPath) {
|
|
|
380
350
|
* 在 plan-postcheck(execute 前)确定性拦截:design 清单中每个源码文件必须被
|
|
381
351
|
* 至少一个 task 的 allowed_paths 覆盖(前缀 / glob 容差匹配)。
|
|
382
352
|
*
|
|
383
|
-
* fail-open 边界(不阻断):design.md
|
|
384
|
-
*
|
|
353
|
+
* fail-open 边界(不阻断):design.md 不存在、无 task 卡片(none 级别)、
|
|
354
|
+
* task 卡片均无 allowed_paths(由 validatePlanFeasibility 把关)。
|
|
355
|
+
* 阻断边界:有 task 卡片但 design 缺清单章节(light/full 已生成 task → design 必有清单)、
|
|
356
|
+
* 清单文件未被任何 task 的 allowed_paths 覆盖。
|
|
385
357
|
*
|
|
386
358
|
* @param {string} changeDir - 变更目录
|
|
387
359
|
* @returns {{ ok: boolean, errors: string[], warnings: string[], designFiles: string[], uncovered: string[] }}
|
|
@@ -395,18 +367,26 @@ export function validateDesignFileCoverage(changeDir) {
|
|
|
395
367
|
return { ok: true, errors, warnings, designFiles: [], uncovered: [] }
|
|
396
368
|
}
|
|
397
369
|
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
|
|
370
|
+
const tasksDir = pJoin(changeDir, 'tasks')
|
|
371
|
+
const taskFiles = existsSync(tasksDir)
|
|
372
|
+
? readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))
|
|
373
|
+
: []
|
|
374
|
+
|
|
375
|
+
// 无 task 卡片(plan_level=none 或老变更)→ 无对账对象,fail-open
|
|
376
|
+
if (taskFiles.length === 0) {
|
|
401
377
|
return { ok: true, errors, warnings, designFiles: [], uncovered: [] }
|
|
402
378
|
}
|
|
403
379
|
|
|
404
|
-
const
|
|
405
|
-
if (
|
|
406
|
-
|
|
380
|
+
const designFiles = [...parseFileChangeList(designPath)]
|
|
381
|
+
if (designFiles.length === 0) {
|
|
382
|
+
// 走到 plan-postcheck 说明已生成 task 卡片(light/full),brainstorm 模板规定清单必填。
|
|
383
|
+
// 无清单 = design↔execute 偏差温床(覆盖对账无从对起),阻断,不让它静默放过。
|
|
384
|
+
errors.push(
|
|
385
|
+
'design.md 缺少「文件变更清单」章节(或清单解析为空),无法做文件覆盖对账。' +
|
|
386
|
+
'该章节在 brainstorm 模板中为必填;请在 design.md 补上完整的文件变更清单(列出本次新增/修改/删除的源码文件)后重试。'
|
|
387
|
+
)
|
|
388
|
+
return { ok: false, errors, warnings, designFiles: [], uncovered: [] }
|
|
407
389
|
}
|
|
408
|
-
|
|
409
|
-
const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))
|
|
410
390
|
const allAllowed = []
|
|
411
391
|
for (const file of taskFiles) {
|
|
412
392
|
const content = readFileSync(pJoin(tasksDir, file), 'utf8')
|