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.
@@ -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 <变更名> --task <task-NN>`(CLI 静态扫描 FastAPI/Express/Spring 路由装饰器生成 endpoints.json,verify 探针 5 消费;扫描面默认取 task 卡 allowed_paths ∪ design 清单,也可 `--dir`/`--files` 显式指定)。勿手扫装饰器手写——易漏 endpoint。
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.27.7",
3
+ "version": "3.27.11",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -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
@@ -117,22 +117,50 @@ export function buildContractMatrix(planContent, changeDir) {
117
117
  function parseTaskDependencies(registryContent) {
118
118
  const deps = {}
119
119
 
120
- // 方式 1: 任务总表表格形式 | task-04 | ... | 01,02 |
121
- // 列前缀用 [^|\d]*(不吃数字):旧 [^|]* 贪婪会吃掉 deps 列前导 0(" 01" → g2=1 → task-1),
122
- // 导致 provider task-1.md 不存在 → contracts 恒空,端点契约管线失效。
123
- const tableRows = registryContent.matchAll(/\|[^|]*task-(\d+)[^|]*\|[^|]*\|[^|\d]*(?:task-)?(\d+(?:\s*[,,]\s*\d+)*)[^|]*\|/gi)
124
- for (const match of tableRows) {
125
- const task = `task-${match[1]}`
126
- const depList = match[2].split(/[,,\s]+/).map(d => `task-${d.trim()}`).filter(d => d.startsWith('task-'))
127
- deps[task] = depList
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
- const dependsPattern = registryContent.matchAll(/task-(\d+).*?depends_on.*?(\d+(?:\s*[,,]\s*\d+)*)/gi)
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 depList = match[2].split(/[,,\s]+/).map(d => `task-${d.trim()}`).filter(d => d.startsWith('task-'))
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 => {
@@ -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
- function renderBlockLines(entry, headHash, supersedesNote) {
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) s.id = `${s.number}@v${s.version}`
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
- * @returns {{ action: 'append'|'update'|'supersede' }|null} null = 高版本已落库,迟到低版本不降级。
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 sameNumber = sections.filter(s => s.number === entry.number)
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
- const block = yaml.match(/^known_failures:\s*\n((?:[ \t]+-[ \t].+\n?)+)/m)
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 => s.replace(/^[ \t]+-[ \t]+/, '').trim().replace(/^['"]|['"]$/g, '').replace(/#.*$/, '').trim())
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
+ }