sillyspec 3.27.7 → 3.27.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
- package/.npmrc-temp +1 -0
- package/package.json +1 -1
- package/src/commit-suggest.js +22 -0
- package/src/contract-matrix.js +59 -15
- package/src/decision-distill.js +33 -14
- package/src/docs-check.js +12 -2
- package/src/facade-hint.js +170 -0
- package/src/index.js +113 -15
- package/src/progress/stage-machine.js +3 -1
- package/src/run/command.js +81 -15
- package/src/run/complete-handlers.js +34 -0
- package/src/run/complete.js +10 -1
- package/src/run/gates.js +100 -8
- package/src/run/multi-repo-context.js +42 -4
- package/src/run/shared.js +50 -24
- package/src/run/stage.js +49 -3
- package/src/spec-sync.js +88 -1
- package/src/stage-contract.js +20 -7
- package/src/stages/execute.js +37 -16
- package/src/stages/plan.js +8 -0
- package/src/task-review.js +7 -0
- package/src/verify-postcheck.js +47 -6
- package/src/worktree-apply.js +254 -4
- package/src/worktree-cross.js +294 -0
- package/src/worktree-deps.js +156 -58
- package/templates/prompts/verify-probes.md +2 -1
|
@@ -92,7 +92,7 @@ execute 完成时,每个 task 必须有 `review.json` 且 verdict 通过,否
|
|
|
92
92
|
|
|
93
93
|
> 🔧 **mechanics 字段不要手算**:`schemaVersion`/`task`/`base`/`head`/`changedFiles`(/跨仓 `repo`) 全部可由 CLI 从 git + task 卡推导。子代理/你只需写**语义字段**(`specVerdict`/`qualityVerdict`/`reviewerNotes`/`requiredEvidence`),mechanics 字段可以瞎填占位(如 `"base": "TODO"`),写完跑 `sillyspec backfill-reviews --change <变更名> --adopt` 一键重算代填(verdict 原样保留)。gate 拦下 mechanics 错误时也跑同一条命令修复。
|
|
94
94
|
|
|
95
|
-
- 后端 router task 另需产出 API 端点清单:`sillyspec endpoints extract --change <变更名> --
|
|
95
|
+
- 后端 router task 另需产出 API 端点清单:`sillyspec endpoints extract --change <变更名> --all-tasks`(聚合逐 task 卡 allowed_paths 提取,与 verify 探针 5 聚合对账口径一致;单 task 可 `--task task-NN`,也可 `--dir`/`--files` 显式指定)。勿手扫装饰器手写——易漏 endpoint;勿只提单 task——局部端点集对账放大假 missing。
|
|
96
96
|
|
|
97
97
|
### Stage Review Gate(execute 末尾的 acceptance review.json)
|
|
98
98
|
|
package/.npmrc-temp
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
//registry.npmjs.org/:_authToken=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
|
package/package.json
CHANGED
package/src/commit-suggest.js
CHANGED
|
@@ -71,6 +71,28 @@ function classifyStagePath(p) {
|
|
|
71
71
|
return null
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* 暂存区快照(坑 same-main-staging-pollution,2026-08-28 用户实证:同 main 两个活跃会话
|
|
76
|
+
* 共享同一暂存区,A 的 commit 把 B 暂存的文件扫入——本轮竞态事故根因,无任何防护)。
|
|
77
|
+
* 防护 = 提交前可见:commit 提示里带 `git diff --cached --name-only` 快照 + 他者文件告警。
|
|
78
|
+
* @param {{ cwd: string, ownFiles?: string[]|null }} opts
|
|
79
|
+
* ownFiles:本会话/本变更的精确文件集(apply-pathspec 等);提供时对快照做差集,
|
|
80
|
+
* 不在集内的暂存文件 = 他者会话暂存(告警对象)。null/空数组 = 只出快照不做归属判定。
|
|
81
|
+
* @returns {{ available: boolean, staged: string[], foreign: string[] }}
|
|
82
|
+
* available=false:git 不可用/非仓库(调用方静默跳过,不误报)。
|
|
83
|
+
*/
|
|
84
|
+
export function collectStagedArea({ cwd, ownFiles = null }) {
|
|
85
|
+
const raw = gitQuiet(cwd, ['diff', '--cached', '--name-only'])
|
|
86
|
+
if (raw === null) return { available: false, staged: [], foreign: [] }
|
|
87
|
+
const staged = raw.split('\n').filter(Boolean).map(f => f.replace(/\\/g, '/'))
|
|
88
|
+
let foreign = []
|
|
89
|
+
if (Array.isArray(ownFiles) && ownFiles.length > 0) {
|
|
90
|
+
const own = new Set(ownFiles.map(f => String(f).replace(/\\/g, '/')))
|
|
91
|
+
foreign = staged.filter(f => !own.has(f))
|
|
92
|
+
}
|
|
93
|
+
return { available: true, staged, foreign }
|
|
94
|
+
}
|
|
95
|
+
|
|
74
96
|
/**
|
|
75
97
|
* 收集提交上下文并生成建议 message。
|
|
76
98
|
* @param {{ cwd: string, specDir?: string|null }} opts
|
package/src/contract-matrix.js
CHANGED
|
@@ -117,22 +117,50 @@ export function buildContractMatrix(planContent, changeDir) {
|
|
|
117
117
|
function parseTaskDependencies(registryContent) {
|
|
118
118
|
const deps = {}
|
|
119
119
|
|
|
120
|
-
// 方式 1:
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
120
|
+
// 方式 1: 任务总表表格形式(两种常见格式)
|
|
121
|
+
// A: | task-04 | Wave | 优先级 | 说明 | 依赖 | (task-NN 在独立列)
|
|
122
|
+
// B: | task | desc | deps | (task-NN 在任意列,bare 数字依赖)
|
|
123
|
+
// 逐行按 | 分列解析,避免旧单正则 [^|]* 跨列贪婪误读说明列数字(409/HTTP 状态码)
|
|
124
|
+
// 为假依赖(坑 contract-matrix-false-deps)。
|
|
125
|
+
// deps 列判定:从行尾倒数第一个「非分隔符、非 task 自身、非单数字(Wave/优先级)」的列,
|
|
126
|
+
// 且须含逗号/task- 引用/bare 两位以上数字。
|
|
127
|
+
const lines = registryContent.split('\n')
|
|
128
|
+
for (const line of lines) {
|
|
129
|
+
const taskIdMatch = line.match(/task-(\d+)/i)
|
|
130
|
+
if (!taskIdMatch) continue
|
|
131
|
+
const taskId = `task-${taskIdMatch[1]}`
|
|
132
|
+
|
|
133
|
+
const cells = line.split('|').map(c => c.trim()).filter(c => c.length > 0)
|
|
134
|
+
if (cells.length < 2) continue
|
|
135
|
+
|
|
136
|
+
// 从行尾倒数找 deps 列
|
|
137
|
+
let depCell = null
|
|
138
|
+
for (let i = cells.length - 1; i >= 0; i--) {
|
|
139
|
+
const c = cells[i]
|
|
140
|
+
if (/^-+$/.test(c)) continue // 分隔符 "-"
|
|
141
|
+
if (c.toLowerCase() === taskId.toLowerCase()) continue // task 自身列
|
|
142
|
+
// deps 列候选:含逗号(多依赖)或含 task- 引用
|
|
143
|
+
if (/[,,]/.test(c) || /task-\d+/i.test(c)) { depCell = c; break }
|
|
144
|
+
// bare 数字:须 ≥2 位且位于行尾第2+列(跳过优先级/Wave 列的个位数)
|
|
145
|
+
if (/^\d{2,}$/.test(c) && i >= cells.length - 2) { depCell = c; break }
|
|
146
|
+
}
|
|
147
|
+
if (!depCell) continue
|
|
148
|
+
const depNums = depCell.match(/\d+/g) || []
|
|
149
|
+
deps[taskId] = depNums.map(d => `task-${d.padStart(2, '0')}`)
|
|
128
150
|
}
|
|
129
151
|
|
|
130
152
|
// 方式 2: tasks.md 行内标注 "- [ ] task-04: 名称 (depends_on: task-01,02)"(新家,D-003@v1;
|
|
131
|
-
// 旧 plan.md checkbox
|
|
132
|
-
|
|
153
|
+
// 旧 plan.md checkbox 行内标注同格式兼容)。仅匹配 (depends_on: ...) 括号内内容,
|
|
154
|
+
// 不在自由文本上全文正则(坑 contract-matrix-false-deps:说明列数字被误读为依赖)。
|
|
155
|
+
const dependsPattern = registryContent.matchAll(/task-(\d+)\b[^)]*?\(depends_on:\s*([^)]+)\)/gi)
|
|
133
156
|
for (const match of dependsPattern) {
|
|
134
157
|
const task = `task-${match[1]}`
|
|
135
|
-
const
|
|
158
|
+
const depStr = match[2]
|
|
159
|
+
// 兼容两种写法:task-01, task-02 或 01, 02
|
|
160
|
+
const depList = depStr.split(/[,,]\s*/).map(s => {
|
|
161
|
+
const num = s.trim().match(/\d+/)
|
|
162
|
+
return num ? `task-${num[0].padStart(2, '0')}` : null
|
|
163
|
+
}).filter(Boolean)
|
|
136
164
|
if (!deps[task]) deps[task] = []
|
|
137
165
|
for (const d of depList) {
|
|
138
166
|
if (!deps[task].includes(d)) deps[task].push(d)
|
|
@@ -364,10 +392,13 @@ export function _readWorktreeMeta(specBase, scanRoot, changeName) {
|
|
|
364
392
|
|
|
365
393
|
/**
|
|
366
394
|
* 取本变更 diff 文件集(parity 前端调用收窄用,坑 probe5-fullrepo-frontend-noise)。
|
|
367
|
-
* 优先 worktree meta 锚点 diff(与 verify 同源);无 meta 退主仓 git diff HEAD
|
|
395
|
+
* 优先 worktree meta 锚点 diff(与 verify 同源);无 meta 退主仓 git diff HEAD;
|
|
396
|
+
* 两者皆空再退 apply-pathspec-<change>.txt(apply 落盘的精确清单)。
|
|
397
|
+
* 兜底链每级都空才返回 null/[](调用方回退全仓——坑 probe5-single-task-artifact-scope,
|
|
398
|
+
* 2026-08-28 实证:apply+commit/cleanup 后 diff 为空 → 全仓调用 × 局部端点 = 150 条假 missing)。
|
|
368
399
|
* @returns {string[]|null}
|
|
369
400
|
*/
|
|
370
|
-
function _resolveDiffFilesForParity(specBase, scanRoot, changeName) {
|
|
401
|
+
function _resolveDiffFilesForParity(specBase, scanRoot, changeName, runtimeRoot) {
|
|
371
402
|
try {
|
|
372
403
|
const wt = _readWorktreeMeta(specBase, scanRoot, changeName)
|
|
373
404
|
if (wt) {
|
|
@@ -401,7 +432,20 @@ function _resolveDiffFilesForParity(specBase, scanRoot, changeName) {
|
|
|
401
432
|
if (foreign.length > 0) {
|
|
402
433
|
console.warn(`⚠️ parity 对账已排除 ${foreign.length} 个并行会话声明的文件(${foreign.slice(0, 5).map(x => x.file).join(', ')}${foreign.length > 5 ? ' 等' : ''})`)
|
|
403
434
|
}
|
|
404
|
-
return own
|
|
435
|
+
if (own.length > 0) return own
|
|
436
|
+
// 主仓 diff 空(apply 后已 commit / cleanup 完毕)→ apply-pathspec 兜底:apply 成功时落盘的
|
|
437
|
+
// 本变更精确文件清单(与 gates 归因提示②同源)。仍拿不到才回 null(调用方退全仓)。
|
|
438
|
+
try {
|
|
439
|
+
const pathspecFile = join(runtimeRoot || join(specBase, '.runtime'), `apply-pathspec-${changeName}.txt`)
|
|
440
|
+
if (existsSync(pathspecFile)) {
|
|
441
|
+
const fromPathspec = readFileSync(pathspecFile, 'utf8').split('\n').map(l => l.trim()).filter(Boolean)
|
|
442
|
+
if (fromPathspec.length > 0) {
|
|
443
|
+
console.log(`ℹ️ parity 前端范围用 apply-pathspec 兜底(主仓 diff 为空,${fromPathspec.length} 文件)`)
|
|
444
|
+
return fromPathspec
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
} catch { /* 兜底失败 → null 交调用方全仓 */ }
|
|
448
|
+
return null
|
|
405
449
|
} catch { return null }
|
|
406
450
|
}
|
|
407
451
|
|
|
@@ -498,7 +542,7 @@ export function verifyApiParity(specBase, scanRoot, runtimeRoot, changeName = nu
|
|
|
498
542
|
const frontendRootLabel = frontendRoot === scanRoot ? 'scan-root' : 'worktree'
|
|
499
543
|
if (changeName) {
|
|
500
544
|
try {
|
|
501
|
-
const changed = _resolveDiffFilesForParity(specBase, scanRoot, changeName)
|
|
545
|
+
const changed = _resolveDiffFilesForParity(specBase, scanRoot, changeName, runtimeRoot)
|
|
502
546
|
if (changed && changed.length > 0) {
|
|
503
547
|
const changedSet = new Set(changed.map(f => f.replace(/\\/g, '/')))
|
|
504
548
|
frontendCalls = scanFrontendApiCalls(frontendRoot).filter(c => {
|
package/src/decision-distill.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* 零输出:无 decisions.md 或无入选条目 → skipped 带原因,不写任何文件、不动 INDEX。
|
|
23
23
|
*/
|
|
24
24
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
25
|
-
import { dirname, join } from 'node:path'
|
|
25
|
+
import { basename, dirname, join } from 'node:path'
|
|
26
26
|
|
|
27
27
|
/** FR-02:有实现影响的五类 type(scope 与未知 type 的 confirmed/accepted 不入选) */
|
|
28
28
|
const IMPLEMENTED_TYPES = new Set(['architecture', 'compatibility', 'boundary', 'definition', 'process'])
|
|
@@ -244,10 +244,14 @@ function newDomainPreamble(domain) {
|
|
|
244
244
|
]
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
/** 单条目段落渲染(design 接口定义的条目格式,逐字对齐:状态/锚点/最近确认/理由 + rejected
|
|
248
|
-
|
|
247
|
+
/** 单条目段落渲染(design 接口定义的条目格式,逐字对齐:状态/锚点/最近确认/理由 + rejected 专属两行)。
|
|
248
|
+
* 变更:行(2026-08-28,坑 distill-cross-change-supersede):D-xxx 编号是变更内局部序号,
|
|
249
|
+
* 跨变更同号(两个 D-002)互不相干——条目必须携带变更名限定,消费方(knowledge-match/
|
|
250
|
+
* docs-check 的字段行解析)只认各自标签,新增字段行是增量安全。 */
|
|
251
|
+
function renderBlockLines(entry, headHash, supersedesNote, changeName) {
|
|
249
252
|
const lines = [`## ${entry.id} ${oneLine(entry.title)}`.replace(/\s+$/, '')]
|
|
250
253
|
lines.push(`状态:${entry.selected}`)
|
|
254
|
+
if (changeName) lines.push(`变更:${oneLine(changeName)}`)
|
|
251
255
|
lines.push(`锚点:${oneLine(entry.anchor) || NOT_RECORDED}`)
|
|
252
256
|
lines.push(`最近确认:${oneLine(headHash) || NOT_RECORDED}`)
|
|
253
257
|
lines.push(`理由:${oneLine(entry.answer || entry.normalizedRequirement || entry.question)}`)
|
|
@@ -259,7 +263,9 @@ function renderBlockLines(entry, headHash, supersedesNote) {
|
|
|
259
263
|
return lines
|
|
260
264
|
}
|
|
261
265
|
|
|
262
|
-
/** 知识文件切段:preamble 行 + D-xxx@vN 条目段(段边界 = 下一 `## D-`
|
|
266
|
+
/** 知识文件切段:preamble 行 + D-xxx@vN 条目段(段边界 = 下一 `## D-` 头)。
|
|
267
|
+
* 段的变更归属取段内 `变更:<name>` 行;无该行的历史条目 → change=null(legacy:
|
|
268
|
+
* 升级前落库的条目,不参与新条目的同号匹配/supersede——只共存不误删)。 */
|
|
263
269
|
function splitKnowledgeSections(content) {
|
|
264
270
|
const sections = []
|
|
265
271
|
const preamble = []
|
|
@@ -275,7 +281,11 @@ function splitKnowledgeSections(content) {
|
|
|
275
281
|
else preamble.push(line)
|
|
276
282
|
}
|
|
277
283
|
if (cur) sections.push(cur)
|
|
278
|
-
for (const s of sections)
|
|
284
|
+
for (const s of sections) {
|
|
285
|
+
s.id = `${s.number}@v${s.version}`
|
|
286
|
+
const cm = s.lines.find(l => /^变更[::]/.test(l))
|
|
287
|
+
s.change = cm ? cm.replace(/^变更[::]\s*/, '').trim() || null : null
|
|
288
|
+
}
|
|
279
289
|
return { preamble, sections }
|
|
280
290
|
}
|
|
281
291
|
|
|
@@ -293,15 +303,20 @@ function joinKnowledgeFile(preamble, sections) {
|
|
|
293
303
|
|
|
294
304
|
/**
|
|
295
305
|
* 单条目写入一个域文件的段落集(就地修改 sections)。
|
|
296
|
-
*
|
|
306
|
+
* 坑 distill-cross-change-supersede(2026-08-28 用户实证:跨变更同号 D-002 在 knowledge
|
|
307
|
+
* 里互相 supersede):幂等键从「号」改为「号+变更」——同号匹配、版本守卫、旧版本段清除
|
|
308
|
+
* 全部限定在 entry.change 内;他者变更的同号段不参与(共存)。legacy 段(change=null,
|
|
309
|
+
* 升级前落库)不与任何新条目互认,只共存不误删。
|
|
310
|
+
* @returns {{ action: 'append'|'update'|'supersede' }|null} null = 同变更同号高版本已落库,迟到低版本不降级。
|
|
297
311
|
*/
|
|
298
312
|
function applyEntryToSections(sections, entry, headHash) {
|
|
299
|
-
const
|
|
313
|
+
const change = entry.change || null
|
|
314
|
+
const sameNumber = sections.filter(s => s.number === entry.number && (s.change || null) === change)
|
|
300
315
|
if (sameNumber.some(s => s.version > entry.version)) return null
|
|
301
316
|
let action
|
|
302
317
|
let supersedesNote = oneLine(entry.supersedes) || null
|
|
303
318
|
if (sameNumber.length === 0) {
|
|
304
|
-
sections.push({ number: entry.number, version: entry.version, id: entry.id, title: entry.title, lines: renderBlockLines(entry, headHash, supersedesNote) })
|
|
319
|
+
sections.push({ number: entry.number, version: entry.version, id: entry.id, title: entry.title, change, lines: renderBlockLines(entry, headHash, supersedesNote, change || undefined) })
|
|
305
320
|
action = 'append'
|
|
306
321
|
} else {
|
|
307
322
|
const same = sameNumber.find(s => s.version === entry.version)
|
|
@@ -309,10 +324,10 @@ function applyEntryToSections(sections, entry, headHash) {
|
|
|
309
324
|
if (!same && !supersedesNote) supersedesNote = `${slot.number}@v${slot.version}`
|
|
310
325
|
slot.version = entry.version
|
|
311
326
|
slot.title = entry.title
|
|
312
|
-
slot.lines = renderBlockLines(entry, headHash, supersedesNote)
|
|
313
|
-
//
|
|
327
|
+
slot.lines = renderBlockLines(entry, headHash, supersedesNote, change || undefined)
|
|
328
|
+
// 同变更同号旧版本段(< 新版本)就地清除——同文件内同变更同号只留一个段
|
|
314
329
|
for (let i = sections.length - 1; i >= 0; i--) {
|
|
315
|
-
if (sections[i].number === entry.number && sections[i] !== slot) sections.splice(i, 1)
|
|
330
|
+
if (sections[i].number === entry.number && (sections[i].change || null) === change && sections[i] !== slot) sections.splice(i, 1)
|
|
316
331
|
}
|
|
317
332
|
action = same ? 'update' : 'supersede'
|
|
318
333
|
}
|
|
@@ -412,6 +427,9 @@ function syncIndexRoutingLines(knowledgeRoot) {
|
|
|
412
427
|
* 缺否决理由/复潮条件被拦下(该条目未写盘,其余条目照常提炼)。
|
|
413
428
|
*/
|
|
414
429
|
export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleIndex = null) {
|
|
430
|
+
// 变更名限定(坑 distill-cross-change-supersede):条目幂等键 = 号+变更。changeDir 末段
|
|
431
|
+
// 即变更名(changes/<name> 与 archive 后的 changes/archive/<name> 同为 <name>)。
|
|
432
|
+
const changeName = basename(changeDir)
|
|
415
433
|
const parsed = parseDecisions(changeDir)
|
|
416
434
|
if (parsed.missing) {
|
|
417
435
|
return { written: [], skipped: `decisions.md 不存在(${join(changeDir, 'decisions.md')}),零输出`, needsWait: null }
|
|
@@ -450,7 +468,7 @@ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleI
|
|
|
450
468
|
const taken = new Set()
|
|
451
469
|
for (const e of distillable) {
|
|
452
470
|
const keep = highestByNumber.get(e.number)
|
|
453
|
-
if (keep && !taken.has(e.number)) { taken.add(e.number); batch.push(keep) }
|
|
471
|
+
if (keep && !taken.has(e.number)) { taken.add(e.number); keep.change = changeName; batch.push(keep) }
|
|
454
472
|
}
|
|
455
473
|
|
|
456
474
|
const decisionsDir = join(knowledgeRoot, 'decisions')
|
|
@@ -489,7 +507,8 @@ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleI
|
|
|
489
507
|
}
|
|
490
508
|
}
|
|
491
509
|
|
|
492
|
-
//
|
|
510
|
+
// 跨文件清理:同变更同号 ≤ 新版本的旧段散落其他域文件时移除(同变更同号全局只留最高
|
|
511
|
+
// 版本/目标文件);他者变更的同号段不参与(共存,坑 distill-cross-change-supersede)
|
|
493
512
|
let onDisk = []
|
|
494
513
|
try { onDisk = readdirSync(decisionsDir).filter(f => f.endsWith('.md')).map(f => f.replace(/\.md$/, '')) } catch { /* 刚创建,走 store */ }
|
|
495
514
|
const allDomains = [...new Set([...onDisk, ...store.keys()])]
|
|
@@ -498,7 +517,7 @@ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleI
|
|
|
498
517
|
for (const d of allDomains) {
|
|
499
518
|
if (targets.has(d)) continue
|
|
500
519
|
const st = loadDomain(d)
|
|
501
|
-
const removed = st.sections.filter(s => s.number === entry.number && s.version <= entry.version)
|
|
520
|
+
const removed = st.sections.filter(s => s.number === entry.number && s.version <= entry.version && (s.change || null) === (entry.change || null))
|
|
502
521
|
if (removed.length === 0) continue
|
|
503
522
|
st.sections = st.sections.filter(s => !removed.includes(s))
|
|
504
523
|
st.dirty = true
|
package/src/docs-check.js
CHANGED
|
@@ -630,10 +630,20 @@ function extractKnownFailureKeys(yamlText) {
|
|
|
630
630
|
if (inline) {
|
|
631
631
|
return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
|
|
632
632
|
}
|
|
633
|
-
|
|
633
|
+
// 坑 verify-known-failures-comment-line-truncation 同款(与 verify-postcheck.extractKnownFailures
|
|
634
|
+
// 口径互指对齐):块内注释行/空行打断连续列表项捕获链 → 注释后的键静默丢失。块定义放行
|
|
635
|
+
// 注释行与空行;提取侧只认 - 项行;行尾注释须 # 前有空白(裸 # 不截);引号值原样保留。
|
|
636
|
+
const block = yaml.match(/^known_failures:\s*\n((?:[ \t]+-[ \t].+\n?|[ \t]*#[^\n]*\n?|[ \t]*\n)+)/m)
|
|
634
637
|
if (block) {
|
|
635
638
|
return (block[1].match(/^[ \t]+-[ \t]+(.+)/gm) || [])
|
|
636
|
-
.map(s =>
|
|
639
|
+
.map(s => {
|
|
640
|
+
const item = s.replace(/^[ \t]+-[ \t]+/, '').trim()
|
|
641
|
+
const full = item.match(/^(['"])(.*)\1$/)
|
|
642
|
+
if (full) return full[2]
|
|
643
|
+
const stripped = item.replace(/[ \t]+#.*$/, '').trim()
|
|
644
|
+
const q2 = stripped.match(/^(['"])(.*)\1$/)
|
|
645
|
+
return q2 ? q2[2] : stripped
|
|
646
|
+
})
|
|
637
647
|
.filter(Boolean)
|
|
638
648
|
}
|
|
639
649
|
return []
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* facade-hint.js — plan 门「透传必经文件」候选探测(advisory)。
|
|
3
|
+
*
|
|
4
|
+
* 坑 plan-facade-files-manual-backfill(2026-08-28 用户实证 ×2):facade 文件(如
|
|
5
|
+
* daemon/service.py——新端点/新模块必须在它里面挂载/转发才对外生效)不进 design §6 /
|
|
6
|
+
* TaskCard allowed_paths,执行期子代理按现实改它 → apply Gate1 拦截 → 人工回补
|
|
7
|
+
* allowed_paths 两轮。本模块在 plan 完成时做静态引用扫描,把候选透传/聚合文件提前
|
|
8
|
+
* 亮给 agent(warning 不阻断):判定是语义,agent 拍板补不补。
|
|
9
|
+
*
|
|
10
|
+
* 两个信号(语言覆盖 py/js/ts/mjs/cjs/jsx/tsx/java 的 import/require 静态文本匹配):
|
|
11
|
+
* - direct:仓内文件 import 了 allowed_paths 里的模块(改被 facade 引用的模块,
|
|
12
|
+
* facade 常需同步改——转发签名/挂载点);
|
|
13
|
+
* - aggregate:仓内文件 import 了该模块同目录下 ≥2 个模块(聚合器/注册表形态,
|
|
14
|
+
* 新增同目录模块几乎必然要在此登记——service.py 挂 router 即此形态)。
|
|
15
|
+
*
|
|
16
|
+
* 启发式 → 恒 advisory:候选只列不改,由 agent 判定补 allowed_paths / design §6,
|
|
17
|
+
* 或确认无关忽略。性能护栏:allowed 条目 > 100 或仓内源码文件 > 5000 时静默跳过。
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
|
20
|
+
import { join, dirname, basename, extname, resolve } from 'path'
|
|
21
|
+
|
|
22
|
+
const SOURCE_EXTS = new Set(['.py', '.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs', '.java'])
|
|
23
|
+
const SKIP_DIRS = new Set([
|
|
24
|
+
'.git', 'node_modules', 'dist', 'build', 'out', '.next', '__pycache__',
|
|
25
|
+
'.venv', 'venv', 'env', '.sillyspec', '.worktrees', '.claude', '.runtime',
|
|
26
|
+
'coverage', 'target', '.idea', '.vscode',
|
|
27
|
+
])
|
|
28
|
+
const MAX_ALLOWED_ENTRIES = 100
|
|
29
|
+
const MAX_REPO_FILES = 5000
|
|
30
|
+
const MAX_FILE_BYTES = 512 * 1024
|
|
31
|
+
|
|
32
|
+
const toPosix = (p) => String(p).replace(/\\/g, '/')
|
|
33
|
+
|
|
34
|
+
/** 递归收集仓内源码文件(posix 相对路径),带总量护栏。 */
|
|
35
|
+
function walkSourceFiles(root, out = []) {
|
|
36
|
+
if (out.length > MAX_REPO_FILES) return out
|
|
37
|
+
let names
|
|
38
|
+
try { names = readdirSync(root) } catch { return out }
|
|
39
|
+
for (const name of names) {
|
|
40
|
+
if (SKIP_DIRS.has(name)) continue
|
|
41
|
+
const abs = join(root, name)
|
|
42
|
+
let st
|
|
43
|
+
try { st = statSync(abs) } catch { continue }
|
|
44
|
+
if (st.isDirectory()) walkSourceFiles(abs, out)
|
|
45
|
+
else if (st.isFile() && SOURCE_EXTS.has(extname(name).toLowerCase()) && st.size <= MAX_FILE_BYTES) {
|
|
46
|
+
out.push(toPosix(join(root, name)))
|
|
47
|
+
if (out.length > MAX_REPO_FILES) return out
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 从源码文本提取 import/require 说明符(py dotted + js/ts path-ish)。 */
|
|
54
|
+
export function extractImportSpecifiers(text, ext) {
|
|
55
|
+
const specs = new Set()
|
|
56
|
+
if (ext === '.py') {
|
|
57
|
+
// from X import a, b → 记 pkg 本身 + 逐名拼 'X.a'/'X.b'(前者保 from X.y import sym 的
|
|
58
|
+
// 直接引用形态,后者保 service.py 聚合挂载形态:from daemon.routers import users, orders)
|
|
59
|
+
for (const m of text.matchAll(/\bfrom\s+([.\w]+)\s+import\s+([^\n#]+)/g)) {
|
|
60
|
+
specs.add(m[1])
|
|
61
|
+
const names = m[2].split(',')
|
|
62
|
+
.map(s => s.trim().split(/\s+as\s+/i)[0].trim())
|
|
63
|
+
.filter(n => /^\w+$/.test(n))
|
|
64
|
+
for (const n of names) specs.add(`${m[1]}.${n}`)
|
|
65
|
+
}
|
|
66
|
+
for (const m of text.matchAll(/(?:^|\n)\s*import\s+([\w.]+)/g)) specs.add(m[1])
|
|
67
|
+
} else if (ext === '.java') {
|
|
68
|
+
for (const m of text.matchAll(/\bimport\s+([\w.]+)\s*;/g)) specs.add(m[1])
|
|
69
|
+
} else {
|
|
70
|
+
for (const m of text.matchAll(/(?:from\s+|require\(\s*|import\(\s*)['"]([^'"]+)['"]/g)) specs.add(m[1])
|
|
71
|
+
}
|
|
72
|
+
return [...specs]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 说明符是否指向模块名 mod(`m` / `pkg.m` / `./m` / `../d/m` 等尾部命中形态)。 */
|
|
76
|
+
export function specRefsModule(spec, mod) {
|
|
77
|
+
if (!spec || !mod) return false
|
|
78
|
+
if (spec === mod) return true
|
|
79
|
+
return spec.endsWith('.' + mod) || spec.endsWith('/' + mod)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 探测 allowed_paths 之外的 facade/聚合候选文件。
|
|
84
|
+
*
|
|
85
|
+
* @param {{ cwd: string, allowedPaths: string[] }} opts
|
|
86
|
+
* @returns {{ skipped: boolean, reason: string|null, candidates: Array<{file: string, via: string[]}> }}
|
|
87
|
+
*/
|
|
88
|
+
export function findFacadeCandidates({ cwd, allowedPaths }) {
|
|
89
|
+
const root = cwd || process.cwd()
|
|
90
|
+
const allowed = (allowedPaths || []).map(toPosix).filter(p => !p.includes('*'))
|
|
91
|
+
if (allowed.length === 0) return { skipped: true, reason: 'no-allowed-paths', candidates: [] }
|
|
92
|
+
if (allowed.length > MAX_ALLOWED_ENTRIES) return { skipped: true, reason: 'allowed-too-many', candidates: [] }
|
|
93
|
+
|
|
94
|
+
const files = walkSourceFiles(root)
|
|
95
|
+
if (files.length > MAX_REPO_FILES) return { skipped: true, reason: 'repo-too-large', candidates: [] }
|
|
96
|
+
|
|
97
|
+
const allowedSet = new Set(allowed)
|
|
98
|
+
const isAllowed = (abs) => allowed.some(a => abs === a || abs.endsWith('/' + a) || a.endsWith('/' + abs) || abs.replace(/\.[^.]+$/, '') === a.replace(/\.[^.]+$/, ''))
|
|
99
|
+
|
|
100
|
+
// 目录 → 该目录下模块名索引(聚合信号用:R import 同目录 ≥2 模块 = 聚合器)
|
|
101
|
+
const dirModules = new Map()
|
|
102
|
+
for (const f of files) {
|
|
103
|
+
const d = dirname(f)
|
|
104
|
+
if (!dirModules.has(d)) dirModules.set(d, new Set())
|
|
105
|
+
dirModules.get(d).add(basename(f, extname(f)))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 预读一遍源码 import(file → specs),两信号共用
|
|
109
|
+
const fileSpecs = []
|
|
110
|
+
for (const f of files) {
|
|
111
|
+
if (isAllowed(f)) continue
|
|
112
|
+
let text
|
|
113
|
+
try { text = readFileSync(f, 'utf8') } catch { continue }
|
|
114
|
+
fileSpecs.push({ file: f, specs: extractImportSpecifiers(text, extname(f).toLowerCase()) })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 逐 allowed 文件探测 direct + aggregate 命中
|
|
118
|
+
const byFile = new Map()
|
|
119
|
+
for (const p of allowed) {
|
|
120
|
+
const mod = basename(p, extname(p))
|
|
121
|
+
// allowed 是相对仓根路径,目录索引键是绝对路径(walk 产物)——resolve 对齐再查
|
|
122
|
+
const dirKey = toPosix(resolve(root, dirname(p)))
|
|
123
|
+
const dirLabel = toPosix(dirname(p))
|
|
124
|
+
const dirModSet = dirModules.get(dirKey) || new Set()
|
|
125
|
+
for (const { file, specs } of fileSpecs) {
|
|
126
|
+
const via = []
|
|
127
|
+
if (specs.some(s => specRefsModule(s, mod))) via.push(`direct import of ${mod}`)
|
|
128
|
+
// aggregate:R 引用 mod 同目录 ≥2 个不同仓内模块(mod 自身可不在——新增模块形态)
|
|
129
|
+
const hitMods = new Set()
|
|
130
|
+
for (const s of specs) {
|
|
131
|
+
for (const b of dirModSet) {
|
|
132
|
+
if (specRefsModule(s, b)) { hitMods.add(b); break }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (hitMods.size >= 2) via.push(`aggregates ≥2 modules of ${dirLabel === '.' ? './' : dirLabel + '/'}`)
|
|
136
|
+
if (via.length === 0) continue
|
|
137
|
+
const existing = byFile.get(file) || []
|
|
138
|
+
for (const v of via) if (!existing.includes(v)) existing.push(v)
|
|
139
|
+
byFile.set(file, existing)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const candidates = [...byFile.entries()]
|
|
143
|
+
.map(([file, via]) => ({ file: toPosix(file), via }))
|
|
144
|
+
.sort((a, b) => a.file.localeCompare(b.file))
|
|
145
|
+
return { skipped: false, reason: null, candidates }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* plan 门消费入口:探测 + 打印(advisory,不阻断)。allowed 集与 apply Gate1 同源
|
|
150
|
+
* (resolveApplyAllowSet 的主仓切片),确保「预检亮出的候选」与「执行期会拦的门」
|
|
151
|
+
* 用同一份清单口径。
|
|
152
|
+
*/
|
|
153
|
+
export function warnFacadeCandidateFiles({ cwd, changeName, allowSet }) {
|
|
154
|
+
try {
|
|
155
|
+
const r = findFacadeCandidates({ cwd, allowedPaths: [...(allowSet || [])] })
|
|
156
|
+
if (r.skipped || r.candidates.length === 0) return r
|
|
157
|
+
console.warn(`\n⚠️ plan 预检:${r.candidates.length} 个 allowed_paths 外的「透传/聚合」候选文件(advisory,不阻断):`)
|
|
158
|
+
for (const c of r.candidates.slice(0, 10)) {
|
|
159
|
+
console.warn(` - ${c.file}(${[...new Set(c.via)].join('; ')})`)
|
|
160
|
+
}
|
|
161
|
+
if (r.candidates.length > 10) console.warn(` …还有 ${r.candidates.length - 10} 个`)
|
|
162
|
+
console.warn(' 若属「透传必经文件」(facade 转发/路由挂载/注册登记——改 allowed 模块必须同步改它才生效):')
|
|
163
|
+
console.warn(' 现在补进对应 TaskCard allowed_paths 或 design §6 清单,避免执行期 apply Gate1 拦截后手工回补;')
|
|
164
|
+
console.warn(' 确认无关则忽略。执行期有据越界另有出口(review.json 声明 changedFiles,apply 有审计放行)。')
|
|
165
|
+
return r
|
|
166
|
+
} catch (e) {
|
|
167
|
+
console.warn(`⚠️ facade 候选探测失败(advisory 跳过): ${e.message}`)
|
|
168
|
+
return { skipped: true, reason: 'error', candidates: [] }
|
|
169
|
+
}
|
|
170
|
+
}
|